close
Skip to content

runtime: /sched/goroutines/not-in-go:goroutines grows without bound for callbacks from non-Go threads on Windows #82136

Description

@jkroepke

Go version

go version go1.27.2 windows/amd64 (also reproduces with go1.26.9)

Output of go env in your module/workspace:

set GOARCH=amd64
set GOOS=windows
set CGO_ENABLED=0

What did you do?

Threads that were not created by Go call a Go function through syscall.NewCallback, once each, and then exit. This is what happens with Windows APIs that deliver results on their own thread pool (in our case the Windows Management Infrastructure API, mi.dll, used by windows_exporter).

Minimal reproducer, no cgo and no dependencies:

// Command notingo shows /sched/goroutines/not-in-go:goroutines growing on
// Windows when threads not created by Go call into Go once and exit.
package main

import (
	"flag"
	"fmt"
	"runtime"
	"runtime/metrics"
	"syscall"
	"time"
)

var (
	kernel32            = syscall.NewLazyDLL("kernel32.dll")
	createThread        = kernel32.NewProc("CreateThread")
	waitForSingleObject = kernel32.NewProc("WaitForSingleObject")
)

// callback runs on a thread created by CreateThread, so the runtime
// borrows an extra M for it (needm) and returns it afterwards (dropm).
var callback = syscall.NewCallback(func(arg uintptr) uintptr {
	return 0
})

func report(when string) {
	s := []metrics.Sample{
		{Name: "/sched/goroutines/not-in-go:goroutines"},
		{Name: "/sched/threads/total:threads"},
	}
	metrics.Read(s)
	fmt.Printf("%-7s not-in-go=%d NumGoroutine=%d threads=%d\n", when, s[0].Value.Uint64(), runtime.NumGoroutine(), s[1].Value.Uint64())
}

func main() {
	threads := flag.Int("threads", 5000, "number of foreign threads to start, one after another")
	pause := flag.Duration("pause", 0, "pause between threads")
	flag.Parse()

	fmt.Printf("%s: threads=%d pause=%s\n", runtime.Version(), *threads, *pause)
	report("before:")

	for range *threads {
		h, _, err := createThread.Call(0, 0, callback, 0, 0, 0)
		if h == 0 {
			panic(err)
		}
		waitForSingleObject.Call(h, syscall.INFINITE)
		syscall.CloseHandle(syscall.Handle(h))
		if *pause > 0 {
			time.Sleep(*pause)
		}
	}

	time.Sleep(100 * time.Millisecond)
	report("after:")
}

Run with go run . (5000 threads, no pause) and with go run . -threads 200 -pause 30ms.

What did you see happen?

go1.27.2: threads=5000 pause=0s
before: not-in-go=0 NumGoroutine=1 threads=5
after:  not-in-go=4071 NumGoroutine=1 threads=5

go1.27.2: threads=200 pause=30ms
before: not-in-go=0 NumGoroutine=1 threads=5
after:  not-in-go=0 NumGoroutine=1 threads=5

go1.26.9: threads=5000 pause=0s
before: not-in-go=0 NumGoroutine=1 threads=5
after:  not-in-go=3977 NumGoroutine=1 threads=5

Every callback can leave /sched/goroutines/not-in-go:goroutines one higher than before, and it never comes back down. The exact number varies between runs (3432 and 4071 out of 5000 on go1.27.2). NumGoroutine and the thread count stay flat, and the foreign threads have exited, so nothing is actually outside Go.

With a 30 ms pause between threads, the metric stays at 0. That points at a race with sysmon retaking the P that the extra M kept from its previous callback. The increment in needm (addGSyscallNoP, runtime/proc.go) is meant to be undone when the thread acquires a P in exitsyscall. When the callback instead goes through a path that doesn't call decGSyscallNoP, the increment is never matched. I haven't pinned down exactly which path that is.

In a long-running service (windows_exporter, one MI callback per WMI query result) the metric grows by about 800/hour and reached about 3,500 after 4 hours, while go_goroutines stayed at 15–16 and pprof's goroutine and goroutineleak profiles showed nothing unusual. Users see it as a goroutine leak on their dashboards.

What did you expect to see?

/sched/goroutines/not-in-go:goroutines returns to 0 (or close to it) once the foreign threads have returned from Go and exited, as it does when the callbacks are spaced out.

Related: #76435 fixed counting of idle cgo threads (isExtraInC), which doesn't cover this case. #78877 is a flaky TestNotInGoMetric/CgoCallAndCallback, off by one in the other direction, in the same accounting.

Activity

  1. added
    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.
    on Oct 11, 2026
  2. changed the title [-]runtime/metrics: /sched/goroutines/not-in-go:goroutines grows without bound for callbacks from non-Go threads on Windows[/-] [+]runtime: /sched/goroutines/not-in-go:goroutines grows without bound for callbacks from non-Go threads on Windows[/+] on Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    NeedsInvestigationSomeone must examine and confirm this is a valid issue and not a duplicate of an existing one.OS-Windowscompiler/runtimeIssues related to the Go compiler and/or runtime.

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions