Repository navigation
x/tools/gopls: issues many redundant go list invocations concurrently #81408
Description
Go version
go version go1.26.8 linux/amd64
Output of go env in your module/workspace:
AR='ar'
CC='gcc'
CGO_CFLAGS='...' # censored: lots of ROS2 -I paths
CGO_CPPFLAGS=''
CGO_CXXFLAGS='...' # censored: same content as CGO_FLAGS
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='...' # censored: lots of -L and -Wl,-rpath for ROS2
CXX='g++'
GCCGO='gccgo'
GO111MODULE=''
GOAMD64='v1'
GOARCH='amd64'
GOAUTH='netrc'
GOBIN=''
GOCACHE='/home/vscode/.cache/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/home/vscode/.config/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -m64 -pthread -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=/tmp/go-build1645505042=/tmp/go-build -gno-record-gcc-switches'
GOHOSTARCH='amd64'
GOHOSTOS='linux'
GOINSECURE=''
GOMOD='/dev/null'
GOMODCACHE='/home/vscode/go/pkg/mod'
GONOPROXY='...' # censored
GONOSUMDB='...' # censored
GOOS='linux'
GOPATH='/home/vscode/go'
GOPRIVATE='...' # censored
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/usr/local/go'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/home/vscode/.config/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='/usr/local/go/pkg/tool/linux_amd64'
GOVCS=''
GOVERSION='go1.26.8'
GOWORK='...' # censored
PKG_CONFIG='pkg-config'What did you do?
Ran a codegen step that made a large edit to many packages that use CGO.
What did you see happen?
gopls invoked many identical (same args) copies of go list .... By the time I regained control of my machine, 12 were still running. I strongly suspect many more were started.
Each of these invocations itself tried to run all the cgo compilation, each trying to use all 16 cores/hyperthreads on my system.
By the time I regained control of the machine, the load average had spiked to over 1000. It took several minutes to gain even this level of control, during which time I couldn't even start a new terminal to try to renice or kill some of the extra processes, mainly cc1 invoked indirectly by cgo by go list eating the CPU.
What did you expect to see?
Updates should be batched, or old ones killed off and replaced, so that it doesn't try to use hundreds of times as many CPU cores as are present on the system.
Activity
pjweinb commentedon Sep 10, 2026
Thanks for the report. gopls is likely creating new snapshots too rapidly, and cancels with a 5 second delay. (and i wonder if the whole descendant process group should be killed, as go list spawns jobs)
Some things that might help:
Debounce in didChangeWatchedFiles so multiple changes would result in only a single new snapshot?
golang.org/x/sync/singleflight.Group for go list invocations?
reduce maxInFlight, as go list already tries to use all the cpus?
We need to think about it.
gopherbot commentedon Sep 10, 2026
Change https://go.dev/cl/830824 mentions this issue:
internal/gocommand: kill process group on cancelgopherbot commentedon Sep 10, 2026
Change https://go.dev/cl/830844 mentions this issue:
gopls/internal/server: debounce didChangeWatchedFilesgopherbot commentedon Sep 11, 2026
Change https://go.dev/cl/830884 mentions this issue:
gopls/internal/cache: serialize package reloadingtagawa0525 commentedon Sep 12, 2026
A measurement on CL 830844 patch set 2 (dacbdbd80), cherry-picked onto 249605012, against 249605012 itself, both built with go1.27.0 linux/amd64. Two-file module:
a.gowithfunc Target() {}(open in the editor),b.gowithfunc Caller() { Target() }(not open). A client over stdio removes the call fromb.goon disk, sendsworkspace/didChangeWatchedFiles(Changed) for it, and immediately starts askingtextDocument/referencesonTargetevery 10ms.Today, the first request, sent right after the notification, already answers 0 references: gopls handles the notification before the request that follows it. With the CL, the requests sent up to 40ms after the notification answer 1 reference, the call that no longer exists; the first to answer 0 is the one sent at 50ms or 60ms, once the debounce has flushed. Sending the notification 20 times 30ms apart (a burst, the case the CL is for) after putting the call back, the requests sent up to 500ms, the
debounceMax, answer 0, while the file on disk has had the call the whole time; the one sent at 510ms answers 1. Two runs each, same numbers (times are send times; the requests go out on a fixed 10ms schedule regardless of when the answers arrive, and the burst notifications are sent from the same loop, a notification first when both are due at the same time).So the CL changes what a client can rely on: after the change, a request that follows
didChangeWatchedFileson the same connection may be answered from the snapshot before the change, and nothing tells the client which it got. The CL flushes pending changes when an editor operation arrives, to keep on-disk files and overlays ordered, but a request does not flush them. That matters most for the client that only edits on disk and then asks, which is what tools driving gopls without an editor do. The CL itself treats one such client differently:gopls mcp'sfileOfnow applies its file events throughsession.DidModifyFilesdirectly, bypassing the debounce, "so snapshot queries are not delayed by the debounce timer". An LSP client in the same position has no such path.For reference, two ways other servers coalesce work while letting a client tell what its answer reflects. pyright applies the notification at once and defers only the analysis: the watched-file change marks the file dirty synchronously and schedules the reanalysis on a timer (
markFilesDirty/scheduleReanalysisinpackages/pyright-internal/src/analyzer/service.ts), and in my measurement a request right after the notification answered with the new content. rust-analyzer hands the change to its VFS loader and coalesces VFS events per loop turn, but a request whose snapshot is invalidated underneath it is retried or answered with-32801 ContentModified(handlers/dispatch.rs), never from the old snapshot, and for a change that triggers a reloadexperimental/serverStatusreportsquiescent: falseand thentrue. In gopls terms the first pattern would be keeping the snapshot invalidation in order and coalescing only the reload; the smallest change would be draining the pending changes at the start of request handling too, asdidModifyFilesdoes for editor operations: a burst with no request in the middle is still one modification pass, and a request in the middle costs one flush, which is what it costs today.Whichever way this goes, what the client above needs is to be able to tell whether a notification it sent has been applied. Today's ordering gives that implicitly. A flush on request, a
ContentModifiedinstead of an answer from before the change, or a notification when pending changes have been applied would each give it explicitly. An answer from before a change the client has already reported, with no signal, is the one outcome it cannot handle.The probe is
scripts/gopls/health-probe.py --scenario stale-after-watched-changeat https://github.com/tagawa0525/lsp-det.internal/gocommand: kill process group on cancel