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.
Go version
go version go1.27.2 windows/amd64 (also reproduces with go1.26.9)
Output of
go envin your module/workspace: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:
Run with go run . (5000 threads, no pause) and with go run . -threads 200 -pause 30ms.
What did you see happen?
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.