Go in Docker: container-aware GOMAXPROCS (Go 1.25) and Green Tea GC (Go 1.26)
Go 1.25 sets GOMAXPROCS from the container's CPU limit and Go 1.26 makes Green Tea the default GC. What to check in your Dockerfile and go.mod, with measurements.
- Before Go 1.25, a container limited to 2 CPUs on a 16-core host got GOMAXPROCS=16 and was throttled in bursts
- Go 1.25 reads the cgroup CPU limit, rounds up, never goes below 2, and follows changes, but only with go 1.25 or later in go.mod
- Setting GOMAXPROCS yourself turns the automatic updates off; and Go does not read the memory limit, so set GOMEMLIMIT
- Green Tea is the default GC since Go 1.26; the docs expect 10-40% less GC overhead in GC-heavy programs
Go is a favourite for services that run in containers, yet for years the runtime had no idea it was CPU-limited. The result was a program paused by the kernel in bursts, with no error to show for it. Go 1.25 fixed that, and Go 1.26 switched the garbage collector (GC) to Green Tea. This article covers both, what to check in Docker, and real measurements.
GC measurements were run on Go 1.27.3 (Windows 11, AMD Ryzen 7 5800X, 16 logical CPUs). The cgroup behaviour is checked against the Go and Docker documentation as of October 2026, when the supported releases are 1.27.3 and 1.26.10.
The old problem: GOMAXPROCS equals the host’s cores
GOMAXPROCS is the maximum number of threads running Go code at once. From Go 1.5 to 1.24 the default was the CPU count of the host, not the container.
Docker limits CPU with cgroups: --cpus=2 means 200 ms of CPU time in every 100 ms period. Use it up early and the kernel pauses the whole process until the period ends. That is throttling.
Worked example: how throttling happens
A 16-CPU host, a container with --cpus=2, Go 1.24, so GOMAXPROCS=16:
- CPU budget per period = 2 × 100 ms = 200 ms
- With all 16 threads busy, the budget is gone in 200 ÷ 16 = 12.5 ms
- For the remaining 100 − 12.5 = 87.5 ms the program is frozen, and every in-flight request waits with it
The moment with the most threads busy is often the GC. In the measurements at the end, the old GC at GOMAXPROCS=16 used 1.21 s of CPU in 0.229 s of wall time: 1.21 ÷ 0.229 ≈ 5.3 cores on GC alone, far past a 2-core budget. With GOMAXPROCS=2 the program never runs Go code on more than 2 threads and stays under the ceiling.
What changed in Go 1.25
On Linux the runtime reads the cgroup CPU limit (cpu.max on cgroup v2) and uses it as the default when it is below the host’s CPU count. The rules, from the runtime docs:
- A fractional limit is rounded up, e.g. 2.5 → 3
- Never below 2, unless the machine or affinity mask has fewer than 2 CPUs
- Updated automatically when the limit or CPU count changes, at most once a second
- Kubernetes CPU requests are ignored; only limits count
- Turned off by setting the
GOMAXPROCSvariable, callingruntime.GOMAXPROCS(n), or GODEBUGcontainermaxprocs=0/updatemaxprocs=0 - Active only when the
goline ingo.modis 1.25 or later
On a 16-CPU host:
| Container setting | GOMAXPROCS (Go 1.25+) | Why |
|---|---|---|
| no limit | 16 | no limit |
--cpus=2 | 2 | the limit |
--cpus=2.5 | 3 | rounded up |
--cpus=0.5 | 2 | minimum of 2 |
--cpu-shares=512 | 16 | shares are a weight, not a limit |
--cpuset-cpus=0,1 | 2 | CPUs in the affinity mask |
env GOMAXPROCS=4 | 4 | explicit value wins, no auto updates |
What to check in Docker
1. go.mod. This line matters more than the builder image tag:
module example.com/api
go 1.27
2. Remove old manual settings, such as ENV GOMAXPROCS=2 in a Dockerfile or a library that calls runtime.GOMAXPROCS at startup (e.g. go.uber.org/automaxprocs), because any explicit setting disables the automatic updates.
3. Log the value at startup so you know what you got:
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Printf("go=%s NumCPU=%d GOMAXPROCS=%d\n",
runtime.Version(), runtime.NumCPU(), runtime.GOMAXPROCS(0))
}
runtime.GOMAXPROCS(0) reads without changing. On the test machine it printed NumCPU=16 GOMAXPROCS=16, and GOMAXPROCS=2 go run . printed 2. Inside a Linux container, see the actual limit with cat /sys/fs/cgroup/cpu.max; with --cpus=1.5 it reads 150000 100000 (quota and period in microseconds).
4. Go does not read the memory limit. The default memory limit is unlimited (math.MaxInt64). If the container has a mem_limit, set GOMEMLIMIT a little below it; the GC guide suggests 5-10% headroom for memory the runtime cannot see.
# docker-compose.yml (the Go service)
services:
api:
image: example/api:1.0
cpus: 1.5
mem_limit: 512m
environment:
GOMEMLIMIT: 460MiB # 512 × 0.9 ≈ 460
This extends the compose file from hardening Docker on a small server, which already caps memory for every container.
Since Go 1.25 the runtime handles the CPU side for you. The memory side is still yours: pair every
mem_limitwith aGOMEMLIMIT.
What Green Tea GC is
Go’s GC has to mark every live object in the heap, and that work costs the most CPU when the heap is full of many small objects. Green Tea redesigns how small objects are marked and scanned, for better memory locality and better scaling across cores.
- Go 1.25 (August 2025): an experiment, enabled with
GOEXPERIMENT=greenteagc - Go 1.26 (February 2026): on by default. The notes expect 10-40% less GC overhead in real programs that use the GC heavily, and roughly another 10% on newer amd64 CPUs (Intel Ice Lake or AMD Zen 4 and later) thanks to vector instructions
- Opt out with
GOEXPERIMENT=nogreenteagcat build time. The 1.26 notes expected it to be removed in 1.27, but go1.27.3 still accepts it (go version -mshowsGOEXPERIMENT=nogreenteagc)
No code changes: rebuild with Go 1.26 or later.
Measured: Green Tea versus the old GC
The test program keeps a binary tree 20 levels deep (about 2 million small objects) alive throughout, then builds and discards a 14-level tree 400 times. GC CPU time comes from runtime/metrics:
s := []metrics.Sample{{Name: "/cpu/classes/gc/total:cpu-seconds"}}
metrics.Read(s)
fmt.Printf("gcCPU=%.2fs\n", s[0].Value.Float64())
Two builds from the same code:
go build -o gt.exe ./gcb # Green Tea (default)
GOEXPERIMENT=nogreenteagc go build -o old.exe ./gcb # the old GC
Median of 5 runs:
| GOMAXPROCS | GC | GC CPU time | Wall time, whole program |
|---|---|---|---|
| 16 | old | 1.21 s | 229 ms |
| 16 | Green Tea | 0.53 s | 207 ms |
| 2 | old | 0.25 s | 290 ms |
| 2 | Green Tea | 0.21 s | 272 ms |
As ratios: at 16 threads GC time fell by (1.21 − 0.53) ÷ 1.21 ≈ 56%; at 2 threads by (0.25 − 0.21) ÷ 0.25 = 16%. Wall time fell 10% and 6%.
Read this carefully. It is a microbenchmark made entirely of small objects, the case Green Tea is best at, and the 5800X is Zen 3, so the vector speed-up does not apply. For real services, go by the documented 10-40% and measure your own p99 latency and CPU before and after the upgrade. Note also how much smaller the gain is at GOMAXPROCS=2: in a CPU-limited container, do not expect 16-core numbers.
Common mistakes
- Builder image bumped to golang:1.27 but go.mod still says
go 1.24. You get the old GOMAXPROCS ENV GOMAXPROCSleft in the Dockerfile. A fixed value that ignores limit changes- Only a CPU request in Kubernetes, expecting Go to size itself. It needs a limit
- No GOMEMLIMIT. The heap grows into
mem_limitand the container is OOM-killed while the GC still had room to collect - Benchmarking GC on an unlimited dev machine and projecting the result onto a 2-core production container
- Keeping
nogreenteagcfor good. It is a temporary escape hatch
On a small server that splits RAM and CPU between several systems (the 2 GB server layout), these two settings are the difference between a service that stalls now and then for no visible reason and one that stays steady.
Sources
FAQ
Do I need to change code to get the new GOMAXPROCS?
No code change, but you must build with Go 1.25 or later and the go line in go.mod must be 1.25 or later. With go 1.24 the runtime keeps the old behaviour for compatibility. Also make sure no GOMAXPROCS environment variable is left set.
In Kubernetes I set only a CPU request, no limit. What do I get?
GOMAXPROCS equal to the node's CPU count, as before. Go ignores requests, because a container without a limit can use idle CPU beyond its request. If you want Go to size itself down, set a limit.
If Green Tea causes problems, can I turn it off?
Yes, build with GOEXPERIMENT=nogreenteagc. The Go 1.26 notes say the option is expected to be removed in 1.27, but go1.27.3 still builds with it. Treat it as a temporary escape hatch and report the problem to the Go team.
Commands and settings were tested on sample systems. Try them on a test machine before production.