# Gopheria — full text > Go, beneath the surface. Deep dives into the Go runtime, concurrency, performance, and the trade-offs behind clean abstractions. Generated 2026-08-28. 3 posts. --- # sync.Pool survives one GC, not two > A pool holding 100 objects lost none across one collection and all 100 across two. Here is the victim cache, and what it costs at 122,000 ops per millisecond. - Source: https://gopheria.com/blog/sync-pool-under-gc-pressure/ - Published: 2026-08-27 `sync.Pool` gets described as a cache often enough that the description sticks, and then someone is surprised when their pool is empty. It is not a cache. It is a set of objects the garbage collector is explicitly allowed to take back, and the rule for when it takes them is both simple and rarely stated precisely. Here it is, measured. All numbers: `go1.27.0 darwin/arm64`, Apple M4 Pro, 12 cores, macOS 26.6.2. Benchmarks with `-benchmem -count=10`, summarised by `benchstat`. ## The eviction rule Counting evictions directly is easier than inferring them. Give the pool a `New` that increments a counter, put a known number of objects in, then take the same number out. Every `New` call is an object the pool could not supply. ```go var misses int func newPool() *sync.Pool { misses = 0 return &sync.Pool{New: func() any { misses++; return new(buf) }} } func drain(p *sync.Pool) int { misses = 0 for i := 0; i < objects; i++ { p.Get() } return misses } ``` `sync.Pool` is per-P, so the test pins itself to a single P with `runtime.GOMAXPROCS(1)`. Otherwise the answer depends on where the goroutine happened to be running, which is a different article. ```text $ go run ./pool no GC: 0/100 Gets missed after 1 GC: 0/100 Gets missed after 2 GC: 100/100 Gets missed ``` One collection: nothing lost. Two: everything lost. Not a proportion, not a heuristic — a cliff. ## The victim cache `sync.Pool` registers a cleanup that runs at the start of every collection, before the world restarts. It does two things in this order: it discards whatever is currently in the victim cache, then it moves the live pool contents into it. So an object that was in the pool when a collection began is not freed. It is demoted. `Get` still finds it — the lookup falls through to the victim cache when the primary is empty — and a `Get` that hits the victim promotes the object back to the primary pool. The next collection then discards that victim. An object survives exactly one collection, and a pool that receives no `Put` between two collections is empty after the second. The victim cache was added in Go 1.13 for exactly the pathology the naive design produced: a service with a two-millisecond GC period had its pools flattened every two milliseconds, so the pool spent most of its life cold and the allocation it was meant to avoid happened anyway. One cycle of grace turns a cliff into a ramp. ## What it costs when the GC is actually running The eviction rule is only interesting if it shows up in throughput. Three benchmarks: allocating a 4 KiB buffer every iteration; the same work through a pool; and the same pool with a background goroutine calling `runtime.GC()` every millisecond underneath it. The buffer has to genuinely outlive the call, or the compiler stack-allocates the unpooled case and the comparison measures nothing: ```go var keep *buf //go:noinline func use(x *buf) { x.b[0]++ keep = x } ``` ```text │ sec/op │ B/op │ allocs/op │ %miss │ NoPool-12 367.1n ± 1% 4.000Ki ± 0% 1.000 ± 0% — Pool-12 7.202n ± 0% 0.000 ± 0% 0.000 ± 0% 600.0n ± 0% PoolUnderGC-12 8.236n ± 1% 0.000 ± 0% 0.000 ± 0% 376.9µ ± 6% ``` The pool is **51 times faster** than allocating. A collection every millisecond raises the miss rate by a factor of 628 — from one miss in 167 million operations to one in 265,000 — and costs **14% of throughput**. Still 44 times faster than not pooling. ## The arithmetic is the mechanism That miss rate is worth a second look, because it is not a vague "GC hurts a bit" number. It is the eviction rule. At 8.2ns per operation the loop runs roughly 122,000 iterations per millisecond, so one GC interval is about 122,000 operations. The measured miss rate is one per 265,000 — which is one miss per **two** GC cycles. That is exactly what the victim cache predicts: the single buffer this loop cycles survives the first collection in the victim, and is lost on the second. The benchmark and the runtime source agree to within rounding, which is the strongest form of evidence a measurement like this can offer. ## When this actually hurts The cost of an eviction is one `New` call per object the pool was holding. Three things make that expensive: **An expensive `New`.** A 4 KiB `make` costs 367ns. A `New` that compiles a template, dials a connection or builds a 1 MB scratch buffer costs orders of magnitude more, and it now runs every two GC cycles per P. **Low throughput.** The benchmark above hides eviction because it performs 122,000 operations between collections. A service handling 200 requests per second with a GC every 100ms performs about 20 operations per cycle — the pool is cold as often as it is warm, and it is close to pure overhead. **Many Ps.** Pools are per-P. Twelve Ps means twelve pools to refill after every second collection, not one. The corollary is the practical rule: `sync.Pool` pays when objects are cheap to create and created constantly. When they are expensive to create and used rarely, you want a free list you own — and then bounding it is your problem, which is the responsibility `sync.Pool` was taking off you. :::warning Putting variable-sized objects back is the other classic footgun. A pooled `bytes.Buffer` that once grew to 40 MB keeps that capacity when it is returned, and every subsequent `Get` hands out a 40 MB buffer. Check capacity before `Put`, and drop anything oversized on the floor. ::: ## What this does not measure `runtime.GC()` every millisecond is a stand-in for GC pressure, not a model of it. A real collector runs concurrently, is paced by `GOGC` and `GOMEMLIMIT`, and its cycles are not evenly spaced. What this experiment reproduces faithfully is the *eviction schedule*; what it does not reproduce is the write barrier, the assist cost, or the effect of a pool full of pointer-bearing objects on mark time. The single-P eviction test is a deliberate simplification too. On a real multi-P workload, `Get` can steal from another P's shared queue before falling through to `New`, so the miss rate is lower than the per-P arithmetic suggests and depends on how work is distributed. And none of this says whether pooling is worth it in *your* service. It says what the pool does and what it costs when it fails. The allocation you avoid may have been free anyway — see [escape analysis](/blog/escape-analysis-is-not-a-rule-of-thumb/) for how often that turns out to be true. --- # Escape analysis is not a rule of thumb > The compiler said "does not escape" and the benchmark reported 104 KiB per operation. Both were right. What -gcflags=-m actually tells you. - Source: https://gopheria.com/blog/escape-analysis-is-not-a-rule-of-thumb/ - Published: 2026-08-26 "Don't return pointers, it causes heap allocation." "Passing by value avoids the GC." Every Go codebase has a comment like this, and the reasoning behind it is almost always a memory of reading `go build -gcflags=-m` output once. The output is real. The rule of thumb built on it usually is not. Here is a single file, its actual escape analysis, and the benchmark that contradicts the obvious reading of it. All numbers: `go1.27.0 darwin/arm64`, Apple M4 Pro, 12 cores, macOS 26.6.2. Benchmarks with `-benchmem -count=10`, summarised by `benchstat`. ## The subject Eight small functions covering the cases people actually argue about: ```go type Point struct{ X, Y int } // 1. The textbook case: the pointer outlives the frame. func NewPoint(x, y int) *Point { p := Point{x, y} return &p } // 2. The address is taken, but never leaves. func Sum(x, y int) int { p := Point{x, y} q := &p return q.X + q.Y } // 4. Same allocation, one size known at compile time and one not. func FixedBuffer() byte { buf := make([]byte, 1024); return buf[0] } func VarBuffer(n int) byte { buf := make([]byte, n); return buf[0] } // 5. Both sizes constant. Only one of them fits. func SmallFixed() byte { buf := make([]byte, 65536); return buf[0] } func LargeFixed() byte { buf := make([]byte, 65537); return buf[0] } ``` `-l` disables inlining, which is what you want while reading escape output: inlining rewrites the program, and you are trying to read the verdict for the function you wrote. ```text $ go build -gcflags='-m -l' ./escape escape.go:9:2: moved to heap: p escape.go:22:24: x escapes to heap escape.go:31:13: make([]byte, 1024) does not escape escape.go:36:13: make([]byte, n) does not escape escape.go:42:13: make([]byte, 65536) does not escape escape.go:47:13: make([]byte, 65537) escapes to heap escape.go:54:14: []*int{...} does not escape escape.go:64:7: func literal does not escape escape.go:69:5: func literal escapes to heap ``` Read line 36. `make([]byte, n)`, size unknown at compile time, **does not escape**. Hold on to that. ## What the benchmark says ```text │ sec/op │ B/op │ allocs/op │ NewPoint-12 4.587n ± 3% 16.00 ± 0% 1.000 ± 0% Sum-12 0.2303n ± 1% 0.000 ± 0% 0.000 ± 0% FixedBuffer-12 10.19n ± 0% 0.000 ± 0% 0.000 ± 0% VarBufferSmall-12 10.19n ± 0% 0.000 ± 0% 0.000 ± 0% VarBufferLarge-12 4.134µ ±21% 104.0Ki ± 0% 1.000 ± 0% SmallFixed-12 1.138µ ± 5% 0.000 ± 0% 0.000 ± 0% LargeFixed-12 3.446µ ±20% 72.00Ki ± 0% 1.000 ± 0% SumAll-12 1.407n ± 0% 0.000 ± 0% 0.000 ± 0% ``` `VarBufferSmall` and `VarBufferLarge` are the **same function**, the one the compiler said does not escape. Called with 1024 it allocates nothing. Called with 100,000 it allocates 104 KiB and one object, every single time. ## Why both are correct Escape analysis answers one question: *does this value outlive the frame that created it?* For `make([]byte, n)` the answer is no, and `-m` reports it honestly. Whether the frame can **hold** the value is a different question, and for a variable size the compiler cannot answer it at compile time. So it emits both paths: a stack allocation when `n` is small enough, and a call into `runtime. makeslice` when it is not. The branch is taken at runtime, per call. That is the whole misunderstanding in one line of output. **`-m` is not an allocation report.** It tells you what the compiler proved about lifetime. What actually lands on the heap depends on lifetime *and* size *and*, once inlining is back on, the call site. The threshold for a single constant-sized object is 64 KiB, and the two `Fixed` functions sit either side of it: 65,536 bytes stayed on the stack, 65,537 went to the heap. One byte. ## Taking an address is not the expensive part `Sum` takes the address of a local, dereferences it twice, and costs **0.23 nanoseconds** with zero allocations — this is the compiler keeping the whole thing in registers. `SumAll` builds a three-element slice of pointers to three locals and still allocates nothing. Indirection is not the trigger. *Escape* is the trigger, and escape means outliving the frame. The rule "don't take addresses of locals" costs you readable code and buys nothing. The rule that survives contact with the data is narrower: **do not return pointers to values the caller does not need to keep**, and be aware that passing a value to something that stores it — an interface, a channel, a goroutine, a global — is what actually moves it. The interface case is worth seeing, because nothing in the source looks like it escapes: ```text escape.go:22:24: x escapes to heap ``` `x` is an `int` passed to a variadic `...any`. Boxing it into an interface means taking its address, and the callee's signature says nothing about whether it keeps the pointer, so the compiler must assume the worst. This is why logging an integer in a hot path allocates and a `strconv.AppendInt` into a reused buffer does not. ## The closure case is the same rule wearing a costume Two function literals, two different verdicts: ```text escape.go:64:7: func literal does not escape escape.go:69:5: func literal escapes to heap ``` The first is called immediately and discarded. The second is handed to `go`. Nothing about the closures differs — the capture is the same variable, the body is the same expression. What differs is that a goroutine's lifetime is unrelated to the frame that started it, so the closure and everything it captures must survive independently. This is the rule that explains every case in the file, including the interface one: the compiler is not asking whether you took an address, it is asking whether it can prove the value dies here. Anything it cannot see the end of — a goroutine, an interface method it cannot devirtualise, a channel send, a global — is an escape. That is also why a goroutine capturing a large struct costs more than the `go` statement suggests, and why the fix is passing the handful of fields it uses rather than the struct. Whether the goroutine then [costs you an OS thread](/blog/go-scheduler-blocking-goroutine/) is a separate question with its own surprising answer. ## Stack allocation is not free either `SmallFixed` allocates nothing and takes **1.14µs**. `NewPoint` allocates 16 bytes on the heap and takes **4.6 nanoseconds**. Zeroing 64 KiB of stack per call costs more than a small heap allocation, by two orders of magnitude. "Zero allocs" is a good signal and a bad target: a benchmark reporting `0 allocs/op` while burning a microsecond per call has optimised the metric rather than the program. ## What this does not cover Everything above was compiled with inlining disabled. That is correct for reading `-m` and wrong for predicting production, where inlining routinely changes the answer — a constructor that escapes on its own can stop escaping once it is inlined into a caller that drops the result. The honest workflow is: read `-m -l` to understand the function, then benchmark the real build to find out what happens. The 64 KiB limit is an implementation detail of the current gc compiler, not a language guarantee. It has moved before and can move again. And this measures allocation, not cost. An allocation that survives into the old generation costs the collector far more than one that dies immediately, and `allocs/op` cannot tell them apart. That needs a heap profile, which is a different article. :::tip The one-line version: run `-gcflags='-m -l'` to learn what the compiler proved, run `-benchmem` to learn what actually happened, and never let the first stand in for the second. ::: --- # What the Go scheduler does when your goroutine blocks > A blocking syscall costs an OS thread. A blocking network read costs nothing. Measured with 200 goroutines, GOMAXPROCS=2 and a thread count. - Source: https://gopheria.com/blog/go-scheduler-blocking-goroutine/ - Published: 2026-08-25 Ask what happens when a goroutine blocks and you usually get an answer about goroutines being cheap. That answer is true and it is not the interesting part. The interesting part is what happens to the *thread* the goroutine was running on, because that is the resource you can actually run out of. The runtime's answer depends entirely on **how** the goroutine blocked, and the gap between the two cases is much larger than most people expect. ## The three vocabulary words The scheduler is usually drawn as G, M and P, and the letters matter here: - **G** — a goroutine. Cheap, a few kilobytes, thousands are fine. - **M** — a machine, meaning an OS thread. Expensive: a kernel stack, a scheduling entity, real memory. - **P** — a processor, meaning *permission to run Go code*. There are `GOMAXPROCS` of them and no more. An M needs a P to execute Go code. That is the whole design: goroutines are unlimited, threads are not, and the P is the token that rations the middle. When a goroutine blocks, the question the runtime has to answer is whether the P can be taken away from it and given to someone else. ## The measurement Two hundred goroutines, each blocked in a different way, `GOMAXPROCS=2`, and a count of the OS threads the process created. Thread count comes from the runtime's own `threadcreate` profile, so there is nothing to interpret: ```go func osThreads() int { return pprof.Lookup("threadcreate").Count() } ``` Case one parks each goroutine in `read(2)` on the read end of a raw pipe nobody writes to. The descriptors come from `syscall.Pipe`, not `os.Pipe`, which matters: raw file descriptors never reach the netpoller, so the syscall really does block. ```go func blockInSyscall() { for i := 0; i < blockers; i++ { var fds [2]int if err := syscall.Pipe(fds[:]); err != nil { panic(err) } go func(fd int) { buf := make([]byte, 1) syscall.Read(fd, buf) }(fds[0]) } } ``` Case two parks each goroutine in a read on an idle TCP connection — same "waiting for bytes that never come", different plumbing. ```text $ GOMAXPROCS=2 ./threads syscall GOMAXPROCS=2 threads before: 4 goroutines blocked: 200 threads after: 203 $ GOMAXPROCS=2 ./threads netpoll GOMAXPROCS=2 threads before: 4 goroutines blocked: 200 threads after: 4 ``` Two hundred and three threads against four. Same number of blocked goroutines, same amount of nothing happening, a fifty-fold difference in what it costs. ## What the scheduler saw `GODEBUG=schedtrace=1000` prints the scheduler's own view once a second, and it explains the number: ```text # 200 goroutines in read(2) SCHED 1007ms: gomaxprocs=2 idleprocs=2 threads=202 spinningthreads=0 idlethreads=0 runqueue=0 [ 0 0 ] # 200 goroutines in a TCP read SCHED 1011ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [ 0 0 ] ``` Look at `idleprocs=2` in the first line. Both Ps are idle while 202 threads exist. That looks like a contradiction and it is the whole mechanism. When a goroutine enters a syscall, the runtime marks its P as `_Psyscall` and lets the thread go. If the syscall returns quickly the thread reacquires its own P and nothing happened. If it does not, the monitor thread — `sysmon`, which runs outside the scheduler precisely so it can watch it — notices a P that has been sitting in `_Psyscall` too long and **retakes** it: the P is detached from the blocked thread and handed to another M, which is started if none is available. So the blocked thread keeps its goroutine and its kernel wait, and the P — the right to run Go code — moves on without it. The Ps are idle because there is no Go code to run. The threads exist because 200 of them are sitting in the kernel. ## Why the network case is free `net.Conn` never issues a blocking read. Every descriptor created through `net` or `os` is registered with the **netpoller**, the runtime's epoll/kqueue integration. A read on an empty connection does not enter the kernel and wait; it parks the goroutine on the netpoller and returns the thread to the scheduler immediately. The goroutine is now the runtime's problem rather than the kernel's. When bytes arrive, the netpoller — polled by the scheduler when it goes looking for work, and by sysmon when nothing else does — moves the goroutine back onto a run queue. No thread was ever committed to the wait. Four threads for two hundred idle connections is the reason a Go server holds tens of thousands of them without a thread-per-connection catastrophe. ## Slow is what matters, not "syscall" The handoff is not free — retaking a P and starting an M costs more than the syscall usually does. So sysmon does not do it eagerly. It only retakes a P whose syscall has been running longer than **20µs**. Two hundred goroutines in a tight loop of `write(2)` to `/dev/null` spend nearly all their time inside syscalls, but each call returns in well under that threshold: ```text $ GOMAXPROCS=2 ./threads fast GOMAXPROCS=2 threads before: 4 goroutines blocked: 200 threads after: 5 ``` One extra thread. The word "syscall" is not what costs you; **duration** is. A program making millions of fast syscalls behaves nothing like a program making two hundred slow ones. ## What this means when it is your service - A goroutine blocked on a channel, a mutex, a timer, or any `net`/`os` I/O costs no thread. Blocking on those is not a resource decision. - A goroutine blocked in a cgo call, a `syscall.Syscall` on a raw descriptor, or a read from a slow local disk costs a thread for the duration. Two hundred concurrent ones cost two hundred threads. - That is why an unbounded worker pool doing cgo or file I/O degrades in a way an unbounded pool doing network I/O does not — and why the fix is a semaphore around the blocking call rather than a lower `GOMAXPROCS`. - The runtime's own ceiling is `debug.SetMaxThreads`, default 10,000, and it does not throttle. It crashes the process. Worth noting that the goroutine itself is the cheap part of all this, but not free: the closure a `go` statement captures has to outlive the frame that started it, which makes it a heap allocation every time. That is the subject of [escape analysis is not a rule of thumb](/blog/escape-analysis-is-not-a-rule-of-thumb/), where the compiler's verdict on that closure is shown next to what it actually costs. ## What this does not measure Thread count is not thread cost. These threads are parked in the kernel and consume no CPU; what they consume is memory, scheduler bookkeeping and file descriptors, none of which this experiment quantifies. The numbers are also one machine's — `go1.27.0 darwin/arm64`, Apple M4 Pro, 12 cores, macOS 26.6.2. The 20µs retake threshold and the netpoller integration are runtime constants rather than platform ones, so the shape should hold anywhere; the exact thread counts will not. And the raw pipe is a laboratory instrument, not a workload. It was chosen because it is the shortest way to get a syscall the netpoller cannot see. If you want to reproduce this against something real, a `database/sql` driver using cgo is the closest production equivalent. :::note Everything above is observable in your own program: `GODEBUG=schedtrace=1000` costs nothing to turn on, and `idleprocs` next to `threads` is usually the first place a thread problem becomes visible. :::