Skip to content

LoopContext/heapguard

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

heapguard

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.

What it guarantees

  • 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.

What it does not claim

  • 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.
  • heapguard trades some throughput versus sync.Pool for strict retention and meaningful observability. Storage uses sharded bounded MPMC queues to reduce multi-core contention, but strict accounting still has a measurable cost.

Requirements

Go 1.22 or newer.

Installation

go get github.com/LoopContext/heapguard

Basic use

package 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.

Strict capacity and runtime target

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 Capacity

Trim removes all currently idle objects without changing the target:

dropped := pool.Trim()

Adaptive pool

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:

  1. The pool has a meaningful recent miss ratio.
  2. 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
}

Metrics semantics

Stats() returns cumulative counters and instantaneous gauges:

  • Gets, Hits, Misses, Created, Prewarmed
  • Puts, Dropped, Rejected, Trimmed, NilPuts
  • Capacity, Target, Retained, Shards

Retained means idle objects currently stored by this pool. It does not pretend to count objects still referenced by user code.

HTTP integration

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())
    },
))

JSON integration

jsonpool.Parser pools the destination value only. encoding/json may still allocate for strings, maps, slices, and internal decoder state.

Verification

make check   # gofmt, go vet, and go test -race
make stress  # repeated high-contention invariant tests
make bench   # compare against sync.Pool and escaping allocations

CI runs go vet and go test -race across supported Go releases.

Choosing between heapguard and sync.Pool

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.

Design notes

The invariants and tradeoffs are documented in docs/design.md.

License

MIT.

About

No description, website, or topics provided.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors