indexpool tracks which slots of a slice are available. It hands out the indexes 0..count-1, each to at most one
holder at a time, and blocks when every slot is taken. The slice is yours: reusable objects, connections, workers, or
the slots of one byte buffer. Pair a []T with an indexpool and you have a typed object pool that, unlike
sync.Pool, never drops an object at GC and never boxes one in an interface.
- Close to
sync.Pool's speed. At 10 cores a round trip measured 0.89–0.94 ns againstsync.Pool's 0.84–0.86 ns (Apple M5; see Performance). - Never loses a slot. Nothing is dropped at GC, and nothing is allocated after
New. - Blocks when every slot is in use, which gives backpressure.
TryAcquiresheds load instead. - Only an integer moves around. The free list holds no pointers, so the GC never scans it, and an index can cross a channel, a ring buffer, or the kernel boundary.
go get github.com/smarty/indexpoolfunc New(count int) (*Pool, error) // count indexes, all free; fails only if count < 1
func (*Pool) Acquire() int // a free index, blocking until one is released
func (*Pool) TryAcquire() int // a free index without blocking, or -1 if none is free just now
func (*Pool) Release(index int) error // fails if index is out of rangeThat's the whole public surface.
- Indexes are dense,
0..count-1, so they address your slice directly:&items[pool.Acquire()]. TryAcquirereturns-1for none, asstrings.Indexandslices.Indexdo. Indexing a slice with an unchecked-1panics, and releasing it returns an error, so a forgotten check fails loudly. It can return-1while a slot is free, if it races a release on another core; treat-1as "none free just now".- There's no ordering or fairness. A blocked
Acquirecan be overtaken, and it can't time out or be cancelled.
- Release each index exactly once, and stop using its slot once you do. Another goroutine may already hold it.
- A normal build doesn't detect a double release. It would put the index in the pool twice, and two holders would then share the slot.
- Build or test with
-tags indexpool_debugto makeReleasefail for an index that isn't acquired. It can't catch a stale holder releasing an index that has since been re-acquired, because a bareintcarries no generation.
- Never wait for a second slot while holding one. If every holder does that, they deadlock: a fixed pool can't
satisfy nested acquires once every slot is out. Take what you need in one place, or use
TryAcquirefor the extras. - Size the pool to peak concurrent use. That's the most that can be in flight. Past it,
Acquireblocks.
Twenty concurrent requests share four reusable sessions. Each request acquires a slot, uses the session in it, and releases the slot; a request that finds all four in use blocks until one is released.
type session struct {
buffer bytes.Buffer // reused across requests, so its storage is allocated once and kept
uses int
}
sessions := make([]session, 4)
pool, _ := indexpool.New(len(sessions))
var requests sync.WaitGroup
for request := range 20 {
requests.Go(func() {
index := pool.Acquire() // blocks while all 4 sessions are in use
current := &sessions[index]
current.buffer.Reset()
_, _ = fmt.Fprintf(¤t.buffer, "request %d", request)
current.uses++
_ = pool.Release(index) // the session is off-limits from here on
})
}
requests.Wait()
served := 0
for index := range sessions {
served += sessions[index].uses
}
_, _ = fmt.Println(served, "requests served by", len(sessions), "sessions") // 20 requests served by 4 sessionsThe slice can be one byte buffer cut into fixed slots. The pool knows nothing about the buffer: slot index is
backing[index*slotSize : (index+1)*slotSize], computed by the caller. A producer frames 100 messages directly into
acquired slots and sends only the index over a channel. Four workers recover the bytes from the index, process them,
and release it.
const slotSize, slotCount = 64 << 10, 16 // 1 MiB of backing
backing := make([]byte, slotCount*slotSize)
slot := func(index int) []byte {
start := index * slotSize
return backing[start : start+slotSize : start+slotSize] // capped, so append can't spill into the next slot
}
pool, _ := indexpool.New(slotCount)
indexes := make(chan int, slotCount) // only indexes cross goroutines; the bytes stay in the backing buffer
var workers sync.WaitGroup
for range 4 {
workers.Go(func() {
for index := range indexes {
bytes := slot(index)
length := binary.LittleEndian.Uint32(bytes)
process(bytes[4 : 4+length])
_ = pool.Release(index) // the slot is off-limits from here on
}
})
}
for message := range 100 {
index := pool.Acquire() // blocks while all 16 slots are in flight
bytes := slot(index)
length := copy(bytes[4:], fmt.Sprintf("message %d", message))
binary.LittleEndian.PutUint32(bytes, uint32(length))
indexes <- index
}
close(indexes)
workers.Wait() backing buffer: 1 MiB, one allocation, owned by the caller
┌────────┬────────┬────────┬─────┬────────┐
│ slot 0 │ slot 1 │ slot 2 │ ... │ slot 15│ slot(index) = backing[index*64K : (index+1)*64K : (index+1)*64K]
└────────┴────────┴────────┴─────┴────────┘
▲ write in place ▲ read in place: slot(index)
│ │
producer ── Acquire ──► index ──► chan int ──► worker ── Release(index) ──► indexpool (per-P, Swap)
▲ │
└────────── blocks here when all 16 slots are in flight ◄─────────────────────┘
- No per-message allocation and no copy between goroutines: only an
intcrosses the channel. - Backpressure: the producer can't outrun the workers by more than 16 messages.
- Everything is released: after
workers.Wait(), all 16 slots are free again.
TryAcquire never blocks: a request that finds every slot in use gets -1 at once.
pool, _ := indexpool.New(2)
first, second := pool.TryAcquire(), pool.TryAcquire()
if pool.TryAcquire() < 0 {
_, _ = fmt.Println("third request rejected: all slots in use")
}
_ = pool.Release(first)
if third := pool.TryAcquire(); third >= 0 {
_, _ = fmt.Println("after one release, a request gets a slot")
_ = pool.Release(third)
}
_ = pool.Release(second)You own the buffer, so you choose its source and alignment: make, mmap, or huge pages. Go's make page-aligns
large allocations in practice but doesn't promise it, which matters for O_DIRECT. An integer index into one block
is useful beyond Go:
- io_uring. Register the buffer with
IORING_REGISTER_BUFFERS, and every fixed read or write skips per-call page pinning. Either register it as one iovec and address slotindexatbase + index*slotSizewithbuf_index0, or register one iovec per slot so the index is thebuf_index. The second form is capped by the kernel's limit on registered buffers. - RDMA and
mlock. One memory region or onemlockcovers every slot. - Huge pages.
madvise(MADV_HUGEPAGE)makes each 2 MiB page physically contiguous (not the whole block) and cuts TLB entries: 16 MiB of slots needs 8 entries at 2 MiB pages instead of 4,096 at 4 KiB. - The GC sees one pointer-free object instead of one per buffer.
Contiguity doesn't buy cache-prefetch locality: prefetchers stop at 4 KiB page boundaries.
| Situation | Use instead | Why |
|---|---|---|
| Objects or buffers vary widely in size | sync.Pool per size class |
Every slot is one fixed shape. A 100-byte message in a 64 KiB slot wastes 99.8% of it, and a payload over the slot size doesn't fit at all. |
| Memory should shrink back after a burst | sync.Pool |
Dropping idle items at GC is sync.Pool's feature. A fixed pool keeps its whole slice for life. |
| A miss must never wait, and allocating is acceptable | sync.Pool |
Acquire blocks. TryAcquire with a fallback allocation works, but the fallback has no slot. |
| Concurrency can't be bounded, or code holds one slot while acquiring another | sync.Pool |
A fixed pool deadlocks on nested acquires once every slot is out, and blocks under unbounded load. |
| Throughput is modest (well under millions of acquires per second) and simplicity matters most | A buffered chan int of indexes |
13–22 ns per round trip, standard library only, no linkname, and fair. The extra 20 ns doesn't matter at that rate. |
go:linkname to runtime.procPin isn't acceptable (policy, gccgo or TinyGo, or a future Go that drops it) |
A stock sync.Pool caching index hints, with a per-slot atomic flag as the truth |
Claim a hint with a CAS on its flag; on a miss, scan the flags. That design measured 1.22–1.30 ns at 10 cores, needs no linkname, and catches a release of a free slot in every build. |
Waiters need FIFO fairness, a timeout, or context cancellation |
A channel or golang.org/x/sync/semaphore |
Acquire has none of these. |
The free list is a clone of Go 1.27.4's sync.Pool (src/sync/pool.go and poolqueue.go) holding indexes instead
of pointers. Each P (the scheduler's per-core context) has a private slot and a lock-free shared queue. The owner
pushes and pops the queue's head, and any P can steal from its tail.
Upstream sync.Pool |
indexpool |
Why |
|---|---|---|
| Drops idle items at every GC | No GC hook | Nothing is ever lost or re-allocated. |
Abandons its per-P array when GOMAXPROCS grows |
Keeps every per-P structure | No index is stranded. Since Go 1.25 the runtime can change GOMAXPROCS on its own. |
| Private slot is owner-only (plain loads and stores) | Private slot uses atomic Swap, so any P can steal it |
In a fixed, blocking pool, an index parked on an idle P would otherwise strand a blocked Acquire forever. |
Stores any |
Stores index+1 in an atomic.Uint64 |
No boxing and no pointers for the GC to scan. |
Cost per operation. An index acquired and released on the same P costs one Swap each. Across Ps, Release
also pushes the token its Swap displaced onto the shared queue (one atomic Add), and Acquire takes from a shared
queue (one CAS). A producer handing indexes to workers on other cores pays those on nearly every message.
Why Swap. Measured per Get+Put pair, single goroutine, on the M5 (BenchmarkPrimitive*):
| Option for the private slot | Per Get+Put pair |
|---|---|
| Plain loads and stores (owner-only, upstream's design) | 0.32–0.33 ns |
| Dekker handshake (atomic Store, Load, plain access, Store) | 0.89 ns |
Swap, one per operation |
1.09 ns |
| CAS, one per operation | 2.74–2.76 ns |
Swap costs about 0.77 ns over an owner-only slot: that is the price of being stealable. Dekker is about 0.2 ns
cheaper on arm64 only. On amd64 Go compiles atomic Store to XCHG, so Dekker would need four read-modify-writes
per pair against Swap's two. That's reasoned from the instructions, not measured on amd64.
Blocking. It follows Linux's percpu_ida tag allocator: register as a waiter, then make a final scan.
- An
Acquirethat finds nothing takes a mutex, increments a waiter count, and scans every P. - If the scan finds nothing, it parks on a
sync.Cond. Releasepublishes its index, then reads the waiter count, and signals only if it's non-zero.
Go's atomics are sequentially consistent, so either the scan sees the index or Release sees the waiter. Holding the
mutex from registration to Wait keeps a signal from arriving early. When nobody waits, Release pays one load of a
cache line that stays shared.
Pinning. The current P comes from runtime.procPin via go:linkname. It's on the runtime's sanctioned list
(go.dev/issue/67401) and builds without -checklinkname=0. Nothing blocks while pinned: stealing and waiting run
unpinned.
Apple M5 (10 mixed performance and efficiency cores), macOS, Go 1.27.4. Each benchmark is Release(Acquire()) in
b.RunParallel, with benchmarks run one at a time: 5 runs per cell in one session, at 16, 64, 256, and 1024 indexes.
sync.Pool holds 64 KiB buffers by pointer, as it would in the byte-buffer use case. These were measured just before
the rename to indexpool and the switch of TryAcquire to -1; neither changes the Acquire/Release path.
| 1 core | 4 cores | 10 cores | |
|---|---|---|---|
indexpool, 16–1024 indexes |
5.14–5.47 ns | 1.37–1.45 ns typical; 6 of 20 runs 4.86–4.91 ns | 0.89–0.94 ns |
Buffered chan int of indexes |
13.0–13.6 ns | 20.4–22.2 ns | 20.3–21.8 ns |
sync.Pool (drops items at GC) |
4.97–5.10 ns | 1.36–1.42 ns | 0.84–0.86 ns |
- Compare within a session. Earlier sessions measured
sync.Pool's 1-core time 12% apart, more than some gaps between rows. - Slow 4-core runs come and go. They appeared in some sessions and not others. The suspect is the scheduler placing work on efficiency cores; it hasn't been profiled.
- Steady-state benchmarks hide
sync.Pool's leak. It makes 0 allocations while its items are always in use. InBenchmarkRefillAfterIdle(acquire 256 indexes, release them, run two GCs),indexpoolmakes 0 allocations per refill. An earlier design built on a stocksync.Poolmade 14 per refill, rebuilding its internal structures after the GCs.
Designs measured on the way here, with one item per goroutine:
| Design | 1 core | 4 cores | 10 cores | Why it lost |
|---|---|---|---|---|
| Buffered channel | 12.6 ns | 24.5 ns | 23 ns | One lock shared by all: flat, but it never scales |
| Vyukov bounded MPMC ring (CAS on positions) | 5 ns | 94 ns | 270 ns | Four contended cache lines per round trip, plus CAS retry storms |
| Same ring with fetch-add tickets | 3.7 ns | 80 ns | ~1100 ns | Strict FIFO: one descheduled ticket holder stalls everyone |
| Tagged Treiber stack | 11 ns | 220 ns | 600 ns | One hot head word |
sync.Mutex + slice stack |
7 ns | 218 ns | 138 ns | One hot lock word |
| Per-P shards of atomic pointer slots | 9.5 ns | 4.5 ns | 13.5 ns | Every slot atomic, plus linear shard scans |
Stock sync.Pool of index hints + per-slot CAS flag |
7.1–7.5 ns | 2.0–2.1 ns | 1.22–1.30 ns | Two CASes per round trip and a rescan after every GC |
Under contention, any design with one shared hot cache line pays at least one cross-core transfer per operation,
about 100 ns or more on this chip. Per-P sharding is what got close to sync.Pool.
MIT, Copyright (c) 2026 Smarty, LLC. See LICENSE.
Portions of queue.go and indexpool.go are derived from Go's src/sync/poolqueue.go and src/sync/pool.go,
Copyright The Go Authors, and are used under Go's BSD-style license in LICENSE-GO. Each of those files
says so in its header.