objpool

package module
v0.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 24, 2026 License: MIT Imports: 1 Imported by: 0

README

objpool

Typed object pools for Go.

  • Pool[T] is a statically typed wrapper over sync.Pool: Get returns a ready *T, never nil, with no type assertion.
  • FreeList[T] is its GC-proof counterpart, for few, large elements too costly to lose to a garbage collection.
  • RatchetTrim is a drop policy for pooled elements holding on to memory that a past burst made them grow.
buffers := objpool.New(
	func() *bytes.Buffer { return new(bytes.Buffer) },     // build on a miss
	func(b *bytes.Buffer) error { b.Reset(); return nil }, // rewind on reuse
)

buf := buffers.Get() // a ready, empty *bytes.Buffer
defer buffers.Put(buf)

Install

go get github.com/JohanLindvall/objpool

Requires Go 1.18 or later.

Why

sync.Pool is untyped, so every call site repeats two decisions: the type assertion on Get, and what to do when the pool is empty — and call sites that each make them on their own drift apart. Folding the element type into the pool makes Get return a ready *T: a mis-asserted type becomes a compile error, and the empty-pool path cannot be forgotten. The same goes for rewinding a recycled element: the pool does it, so no call site can forget to.

The element contract

Both pools are built from the same two functions:

  • newFn func() *T builds an element whenever Get has none to reuse. It runs on the caller's goroutine and must return a ready, non-nil element.
  • resetFn func(*T) error rewinds a recycled element before Get returns it. It never runs on a freshly built element, so required setup belongs in newFn. Pass nil when there is nothing to rewind, or when rewinding needs an argument only the call site has — a pooled gzip.Writer is reset onto its destination by the caller, after Get.

The reset runs in Get, not Put, on purpose: a reset that fails can then be answered with a fresh element. Returning an error from resetFn declares the element unfit to reuse; Get drops it and builds a replacement, and the caller never sees the error. So Get never returns nil, and never a half-rewound element.

That guarantee rests on newFn, so breaking it fails early and loudly: the constructor panics on a nil newFn — at construction, typically program start, rather than on the first miss under load — and Get panics if newFn returns nil, naming the cause instead of handing back a nil that dereferences in unrelated code. Put(nil) is ignored.

Both pools are safe for concurrent use; the elements they hand out are not shared. Create them with their constructors — the zero values are not usable — and do not copy them after first use.

Pool

Pool[T] keeps sync.Pool's semantics, including the one that matters most: Put is a hint, not storage. The runtime may drop a pooled element at any time, and drops every idle one within two garbage collections, so nothing whose loss would matter may live only in a Pool.

That is the right trade for many small, cheap elements. It silently defeats reuse, though, once collections outpace an element's use cycle: at a high allocation rate the collector runs several times per cycle, so a large element is rebuilt nearly every time — and the rebuilds' allocations then drive the very collections that evict it. That is what FreeList is for.

FreeList

FreeList[T] never drops an element it is given: a parked element survives any number of collections, and Get hands back the most recently parked one.

buffers := objpool.NewFreeList(64, // park at most 64 idle buffers
	func() *bytes.Buffer { return bytes.NewBuffer(make([]byte, 0, 1<<20)) },
	func(b *bytes.Buffer) error { b.Reset(); return nil }, // keeps the capacity
)

It needs no cap to stay bounded: Get builds only when the list is empty, so the number of elements never exceeds the peak number checked out at once. The price is that memory stays at that peak. maxIdle is defense in depth on top: a Put that finds maxIdle elements already parked drops its element (maxIdle <= 0 means no limit). Set it well above peak concurrency, or it reintroduces the rebuild churn the list exists to prevent.

Reuse is the whole point, so a reset must keep the capacity the list exists to keep warm: truncate a buffer to length zero; don't replace it.

RatchetTrim

A FreeList keeps whatever it is given, forever — including buffers that a burst grew and the quieter traffic after it never uses again. RatchetTrim decides, at release, when such an element should be dropped instead of put back:

type scratch struct {
	buf  []byte
	trim objpool.RatchetTrim // one policy per element
}

scratches := objpool.NewFreeList(0,
	func() *scratch { return &scratch{trim: objpool.DefaultRatchetTrim()} },
	func(s *scratch) error { s.buf = s.buf[:0]; return nil },
)

// release puts s back, unless its buffer has sat mostly unused for too long.
release := func(s *scratch) {
	if s.trim.Due(cap(s.buf), len(s.buf)) {
		return // drop: the garbage collector reclaims the oversized buffer
	}
	scratches.Put(s)
}

A cycle is wasteful when the element retains at least Floor bytes and more than Factor times what the cycle used; Strikes consecutive wasteful cycles make Due report drop, and one honest cycle resets the count. So a burst is forgiven quickly, while a footprint nothing uses any more is released within a few cycles. DefaultRatchetTrim drops an element retaining at least 16 MiB once it has used less than a quarter of its footprint for 8 consecutive cycles; the zero value never trims.

Choosing

Pool[T] FreeList[T]
Built on sync.Pool a mutex-guarded LIFO stack
Idle elements dropped at the runtime's discretion, all within two GCs kept until drawn
Bounded by garbage collection peak concurrent checkouts, and optionally maxIdle
Put is a hint a guarantee, up to maxIdle
Suits many small, cheap elements few large, expensive-to-rebuild elements

API overview

Symbol Description
New(newFn, resetFn) *Pool[T] Create a Pool.
(*Pool[T]) Get() *T A recycled element, rewound, or a fresh one. Never nil.
(*Pool[T]) Put(v *T) Offer v back for reuse.
NewFreeList(maxIdle, newFn, resetFn) *FreeList[T] Create a FreeList.
(*FreeList[T]) Get() *T The most recently parked element, rewound, or a fresh one. Never nil.
(*FreeList[T]) Put(v *T) Park v, or drop it past maxIdle.
RatchetTrim Drop policy with Floor, Factor and Strikes; one per element.
DefaultRatchetTrim() RatchetTrim A 16 MiB floor, factor 4, 8 strikes.
(*RatchetTrim) Due(retained, used int) bool Record a cycle; report whether to drop the element.

The package documentation has the full contracts and runnable examples.

Releases

CI tags every green commit on main with the next patch version; minor and major versions are tagged by hand.

License

MIT

Documentation

Overview

Package objpool provides typed object pools: Pool, a statically typed wrapper over sync.Pool, and FreeList, its GC-proof counterpart for few, large elements. A third piece, RatchetTrim, decides when a pooled element holds far more memory than its work uses, and should be dropped rather than reused.

The untyped sync.Pool leaves two things to every call site: the type assertion, and what to do when the pool is empty — and call sites that each handle those on their own diverge. Folding the element type into the pool makes Get return a ready *T, so a mis-asserted type is a compile error and the empty-pool path cannot be forgotten.

Choosing a pool

Pool suits many small, cheap elements: like sync.Pool, it may drop idle elements at any time, so it never pins memory it no longer needs. FreeList suits few, large, expensive ones: it never drops what it is given, so reuse survives garbage collection, and its size is bounded by the peak number of elements checked out at once. It does not bound the memory each element retains; RatchetTrim decides when an element that a past burst inflated should be dropped instead of reused.

Element lifecycle

Both pools take the same two functions, whose full contract New describes:

  • newFn builds an element whenever Get has none to reuse. It must return a ready, non-nil element.
  • resetFn, when non-nil, rewinds a recycled element before Get returns it. It never runs on a freshly built element, and an error from it drops the element in favor of a fresh build.

Get therefore never returns nil. Put ignores nil and does no work beyond parking the element; the caller must not use an element after putting it back.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type FreeList

type FreeList[T any] struct {
	// contains filtered or unexported fields
}

FreeList is the GC-proof counterpart of Pool, for elements that are few, large, and expensive to rebuild — an encoder whose buffers and compression state have grown to megabytes, say. A Pool silently defeats reuse for such elements once garbage collections outpace their use cycle: at a high allocation rate the collector runs several times per cycle, so the Pool rebuilds the element nearly every time, and the rebuilds' allocations feed the very collection rate that evicts it.

A FreeList never evicts, yet stays bounded without a cap: Get builds only when the list is empty, so the number of elements never exceeds the peak number checked out at once — for a fixed set of workers, the number of workers, whose memory the process must afford at peak anyway. The price is that memory stays at that peak: elements parked beyond current demand are kept, not released.

Nor does the list bound the memory each element retains. An element whose footprint can ratchet upward (a burst grows its buffers; quieter traffic never shrinks them) needs a trim policy at the caller, who drops the element instead of putting it back — the list keeps whatever it is given, forever. RatchetTrim is such a policy.

A FreeList is safe for concurrent use; the elements it hands out are not shared. Create one with NewFreeList: the zero value is not usable. A FreeList must not be copied after first use.

func NewFreeList

func NewFreeList[T any](maxIdle int, newFn func() *T, resetFn func(*T) error) *FreeList[T]

NewFreeList returns a FreeList that builds elements with newFn, rewinds parked ones with resetFn, and parks at most maxIdle idle elements (maxIdle <= 0: no limit).

newFn and resetFn follow New's contract exactly, panics included. Reuse is the whole point of a FreeList, so a reset must keep the capacity the list exists to keep warm: truncating a buffer to length zero is right; replacing it with a new one defeats the list.

maxIdle is defense in depth on top of the structural bound (see FreeList): a Put that finds maxIdle elements already parked drops its element to the garbage collector. Set it comfortably above the peak number of elements checked out at once, because a cap below that quietly reintroduces the rebuild churn the list exists to prevent — visible as newFn calls that keep coming after warm-up. It never limits Get.

Example
package main

import (
	"bytes"
	"fmt"
	"runtime"

	"github.com/JohanLindvall/objpool"
)

func main() {
	builds := 0
	buffers := objpool.NewFreeList(8,
		func() *bytes.Buffer {
			builds++
			return bytes.NewBuffer(make([]byte, 0, 1<<20))
		},
		// Truncate, keeping the grown capacity the list exists to preserve.
		func(b *bytes.Buffer) error { b.Reset(); return nil },
	)

	for i := 0; i < 3; i++ {
		buf := buffers.Get()
		buf.WriteString("payload")
		buffers.Put(buf)
		runtime.GC() // would empty a Pool; the FreeList keeps its element
	}
	fmt.Println("builds:", builds)

}
Output:
builds: 1

func (*FreeList[T]) Get

func (l *FreeList[T]) Get() *T

Get returns a parked element rewound by resetFn, or a freshly built one when the list is empty — never nil. Unlike Pool.Get, it hands back a parked element whenever one remains: the most recently parked, replaced by a fresh build only if its reset reports it unfit.

func (*FreeList[T]) Put

func (l *FreeList[T]) Put(v *T)

Put parks v for reuse, or drops it when maxIdle elements are already parked; a nil v is ignored. Rewinding is Get's job (see NewFreeList), so v keeps its contents until it is next drawn. Putting the same element twice hands it out twice: exclusivity is the caller's to keep, as with Pool.

type Pool

type Pool[T any] struct {
	// contains filtered or unexported fields
}

Pool is a typed wrapper over sync.Pool: Get returns a ready *T — never nil, never needing a type assertion — and Put offers one back.

It keeps sync.Pool's semantics, including the one that matters most: Put is a hint, not storage. The runtime may drop a pooled element at any time, and drops every idle one within two garbage collections, so nothing whose loss would matter may live only in a Pool; see FreeList for elements too costly to lose.

A Pool is safe for concurrent use; the elements it hands out are not shared. Create one with New: the zero value is not usable. A Pool must not be copied after first use.

func New

func New[T any](newFn func() *T, resetFn func(*T) error) *Pool[T]

New returns a Pool that builds elements with newFn and rewinds recycled ones with resetFn.

newFn runs whenever Get finds the pool empty, on the caller's goroutine, and may capture whatever construction needs. It must return a ready, non-nil element.

resetFn, when non-nil, rewinds a recycled element before Get hands it out, which is what lets Get promise a clean element instead of leaving each call site to remember. It runs in Get rather than in Put deliberately: a reset that fails can then be answered with a fresh element, whereas in Put there is no caller left to tell. It never runs on a freshly built element — newFn already returns a ready one — so required setup belongs in newFn, not resetFn. Pass nil when there is nothing to rewind, or when rewinding needs an argument only the call site has: a pooled gzip.Writer, say, is reset onto its destination by the caller after Get.

A resetFn that returns an error declares the element unfit to reuse: Get drops it and builds a replacement, so the error costs one rebuild and never reaches the caller. That path exists so that a reset which can fail never hands back a half-rewound element.

Get's never-nil guarantee rests on newFn, so both ways of breaking it panic early, with a message naming the cause: New panics if newFn is nil — at construction, typically program start, rather than on the first miss under load — and Get panics if newFn returns nil, rather than handing back a nil that dereferences in unrelated code. Neither check touches a pool hit.

Example
package main

import (
	"bytes"
	"fmt"

	"github.com/JohanLindvall/objpool"
)

func main() {
	buffers := objpool.New(
		func() *bytes.Buffer { return new(bytes.Buffer) },
		func(b *bytes.Buffer) error { b.Reset(); return nil },
	)

	buf := buffers.Get() // a ready *bytes.Buffer: never nil, no type assertion
	buf.WriteString("hello")
	fmt.Println(buf)
	buffers.Put(buf)

	// Recycled and rewound, or freshly built: either way it comes back empty.
	fmt.Println(buffers.Get().Len())

}
Output:
hello
0

func (*Pool[T]) Get

func (p *Pool[T]) Get() *T

Get returns a recycled element rewound by resetFn, or a freshly built one when the pool has none — never nil. The caller owns it until handing it back with Put.

func (*Pool[T]) Put

func (p *Pool[T]) Put(v *T)

Put offers v back for reuse; a nil v is ignored. Rewinding is Get's job (see New), so v keeps its contents until it is next drawn or the runtime drops it.

type RatchetTrim

type RatchetTrim struct {
	// Floor exempts elements retaining fewer bytes: no cycle below it is
	// wasteful.
	Floor int
	// Factor is the retained/used ratio above which a cycle is wasteful; a
	// Factor <= 0 makes every cycle at or above Floor wasteful.
	Factor int
	// Strikes is how many consecutive wasteful cycles make Due report drop;
	// Strikes <= 0 disables the policy, which is why the zero value never trims.
	Strikes int
	// contains filtered or unexported fields
}

RatchetTrim is a drop policy for pooled elements whose retained memory can ratchet upward: a burst grows an element's buffers, the quieter traffic after it never uses that capacity again, and a FreeList keeps it forever. The decision belongs to the caller at release: keep one RatchetTrim in each pooled element, call Due as the element is released, and drop the element instead of putting it back when Due reports true.

A cycle is wasteful when the element retains at least Floor bytes and more than Factor times what the cycle used; Strikes consecutive wasteful cycles make Due report drop. The floor exempts elements small enough that a rebuild would cost more than the memory it reclaims — size alone, regardless of how busy the element is. The strikes forgive a burst quickly, while a footprint the traffic after it never uses is released within a few cycles.

The zero value never trims; DefaultRatchetTrim returns a tuned policy. A RatchetTrim is not safe for concurrent use: like the element it lives on, it has one holder at a time.

Example
package main

import (
	"fmt"

	"github.com/JohanLindvall/objpool"
)

func main() {
	// job is a pooled element whose buffer grows to fit the largest input it has seen.
	type job struct {
		buf  []byte
		trim objpool.RatchetTrim
	}

	jobs := objpool.NewFreeList(0,
		func() *job {
			// A small policy to keep the example light; DefaultRatchetTrim suits
			// elements that grow to megabytes.
			return &job{trim: objpool.RatchetTrim{Floor: 1 << 10, Factor: 4, Strikes: 3}}
		},
		func(j *job) error { j.buf = j.buf[:0]; return nil },
	)

	// release puts j back, or drops it once its buffer has sat mostly unused for
	// Strikes consecutive cycles. It reports whether j was kept.
	release := func(j *job) bool {
		if j.trim.Due(cap(j.buf), len(j.buf)) {
			return false // dropped: the garbage collector reclaims the buffer
		}
		jobs.Put(j)
		return true
	}

	j := jobs.Get()
	j.buf = append(j.buf, make([]byte, 64<<10)...) // a burst grows the buffer to 64 KiB
	fmt.Println("burst: kept =", release(j))

	for i := 1; i <= 3; i++ {
		j = jobs.Get()
		j.buf = append(j.buf, "small"...) // quiet traffic uses a sliver of it
		fmt.Printf("quiet %d: kept = %v\n", i, release(j))
	}

}
Output:
burst: kept = true
quiet 1: kept = true
quiet 2: kept = true
quiet 3: kept = false

func DefaultRatchetTrim

func DefaultRatchetTrim() RatchetTrim

DefaultRatchetTrim returns a policy that drops an element retaining at least 16 MiB once it has used less than a quarter of its footprint for 8 consecutive cycles.

func (*RatchetTrim) Due

func (t *RatchetTrim) Due(retained, used int) bool

Due records one cycle and reports whether the element should be dropped rather than put back. retained is the element's kept footprint in bytes; used is what the cycle's work actually needed. One honest cycle — capacity genuinely used — resets the count, so sustained heavy traffic never cycles elements.

A cycle with used == 0 is the most wasteful case, not an exemption: an element above the floor that did no work for Strikes consecutive cycles holds its memory for no benefit at all. That cannot punish an element for sitting idle in a FreeList: Due runs only when a holder releases the element, never while it is parked.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL