heapguard is a bounded, observable, and optionally adaptive object pool for Go.
It is intentionally not a wrapper around sync.Pool. sync.Pool is excellent as an ephemeral runtime cache, but the Go runtime may remove its contents at any time. That makes strict capacity, exact retained counts, and retention tuning impossible. heapguard chooses predictable behavior instead.
- A hard upper bound on retained idle objects.
- Correct hit, miss, allocation, rejection, trim, and drop counters.
- Race-free concurrent
Get,Put,SetTarget,Trim, and metrics snapshots. - Immutable reset and retention callbacks after construction.
- Adaptive tuning of one pool's retention target; objects are never migrated between unrelated pools.
- Runtime pressure derived from interval deltas in
runtime/metrics, not lifetime-average GC pauses. - A stoppable, idempotent adaptive controller.
- Pooling is not automatically faster for every object or workload.
- JSON decoding is not allocation-free merely because the destination struct is pooled.
- The built-in adaptive policy is a transparent heuristic, not an oracle. Benchmark with your own traffic and replace the policy when domain knowledge is better.
heapguardtrades some throughput versussync.Poolfor strict retention and meaningful observability. Storage uses sharded bounded MPMC queues to reduce multi-core contention, but strict accounting still has a measurable cost.
Go 1.22 or newer.
go get github.com/LoopContext/heapguardpackage main
import (
"bytes"
"fmt"
"github.com/LoopContext/heapguard"
)
func main() {
pool := heapguard.MustNewPool(heapguard.Config[bytes.Buffer]{
Capacity: 256,
Shards: 8, // optional; zero auto-selects from GOMAXPROCS
InitialSize: 32,
New: func() *bytes.Buffer { return &bytes.Buffer{} },
Reset: func(b *bytes.Buffer) { b.Reset() },
Retain: func(b *bytes.Buffer) bool { return b.Cap() <= 64<<10 },
})
buf := pool.Get()
buf.WriteString("hello")
fmt.Println(buf.String())
pool.Put(buf)
fmt.Printf("%+v\n", pool.Stats())
}Retain prevents a one-off giant buffer from becoming a permanent resident. Shards: 1 minimizes overhead for mostly sequential workloads; the zero value chooses a multi-core-oriented default. Reset preserves its backing array when that is desirable.
Capacity never changes and is a hard bound. Target is the current retention limit inside that capacity:
pool.SetTarget(64) // drains excess objects before returning
pool.SetTarget(0) // retain nothing
pool.SetTarget(999999) // clamped to CapacityTrim removes all currently idle objects without changing the target:
dropped := pool.Trim()base := heapguard.MustNewPool(heapguard.Config[bytes.Buffer]{
Capacity: 1024,
New: func() *bytes.Buffer { return &bytes.Buffer{} },
Reset: func(b *bytes.Buffer) { b.Reset() },
Retain: func(b *bytes.Buffer) bool { return b.Cap() <= 256<<10 },
})
base.SetTarget(64)
cfg := heapguard.DefaultAdaptiveConfig()
cfg.MinTarget = 32
cfg.MaxTarget = 1024
cfg.Interval = 5 * time.Second
pool, err := heapguard.NewAdaptivePool(base, cfg)
if err != nil {
log.Fatal(err)
}
defer pool.Close()The default AIMD policy grows only when both conditions are true:
- The pool has a meaningful recent miss ratio.
- The runtime shows allocation or GC CPU pressure.
It shrinks conservatively when heap utilization is high or idle objects remain near the target for several intervals. Every decision exposes a reason through AdaptiveStats().
A custom policy is a small interface:
type Policy interface {
Decide(heapguard.AdaptiveInput) heapguard.Decision
}Stats() returns cumulative counters and instantaneous gauges:
Gets,Hits,Misses,Created,PrewarmedPuts,Dropped,Rejected,Trimmed,NilPutsCapacity,Target,Retained,Shards
Retained means idle objects currently stored by this pool. It does not pretend to count objects still referenced by user code.
No hidden global registry is used. StatsHandler and AdaptiveStatsHandler expose the specific pool you pass them:
http.Handle("/debug/heapguard", httpguard.StatsHandler(pool))Request-scoped pooling is also available:
http.HandleFunc("/work", httpguard.WithPooledHandler(pool,
func(buf *bytes.Buffer, w http.ResponseWriter, r *http.Request) {
buf.WriteString("ok")
_, _ = w.Write(buf.Bytes())
},
))jsonpool.Parser pools the destination value only. encoding/json may still allocate for strings, maps, slices, and internal decoder state.
make check # gofmt, go vet, and go test -race
make stress # repeated high-contention invariant tests
make bench # compare against sync.Pool and escaping allocationsCI runs go vet and go test -race across supported Go releases.
Use sync.Pool when maximum throughput and runtime-managed ephemeral caching are exactly what you need.
Use heapguard when you require one or more of these:
- strict memory-retention bounds;
- observable hit/miss behavior;
- explicit rejection of oversized objects;
- deterministic trimming;
- a tunable retention target.
The invariants and tradeoffs are documented in docs/design.md.
MIT.