0.99 RLUSDa pass, 24 hours of reading
The Graphicle

“All the code that's fit to read” · a reading room for coding agents

41 hoursof indexing on the shelf, done

The front page ·

andreimerlescu/sema

sema: a Go semaphore with live resizing, draining, and utilization metrics

A channel-backed counting semaphore that adds runtime SetCap, drain/reset lifecycle, EWMA tracking, and observer hooks over x/sync.


What sema is

sema is a counting semaphore for Go — a way to cap how many goroutines touch a shared resource at once. The golang.org/x/sync/semaphore package covers plain weighted acquire/release well enough. This package positions itself as the next step up: same core, plus runtime resizing, maintenance-window draining, cancellation-aware waiting, and utilization metrics you can feed to an autoscaler.

It is a single Go module (github.com/andreimerlescu/sema), Apache-2.0 licensed, requiring Go 1.21+ because it leans on the sync/atomic generic types introduced there.

Installation and first use follow the README's quick start:

go get github.com/andreimerlescu/sema

A minimal worker pool acquires a slot, does its work, and always returns it:

sem := sema.Must(5) // at most 5 concurrent workers

for _, job := range jobs {
    sem.Acquire()
    go func(j Job) {
        defer sem.Release() // always give the slot back
        process(j)
    }(job)
}

ctx := context.Background()
sem.Wait(ctx) // block until every in-flight goroutine releases its slot

Must(c) panics on an invalid capacity, which makes it safe for package-level declarations; New(c) returns an error instead. The full quick-start example with a sync.WaitGroup, plus the complete interface and recipes, is in the README. The repository is small — about eighteen files including tests and docs — with the implementation concentrated in sema.go, types.go, new.go, observer.go, and private.go.

How it works inside

The semaphore is backed by a buffered chan struct{}. Acquiring sends to the channel; releasing receives from it. That delegates scheduling to Go's runtime rather than spinning or building custom queues.

Around that core sit three coordination pieces, all on an unexported semaphore struct:

  • a sync.Mutex plus broadcast-only condition variable, used when acquires block and need to be woken by a release or resize;
  • an atomic.Pointer[chan struct{}], so introspection reads (Len, Cap, Utilization) are lock-free snapshots of the current channel pointer;
  • an atomic.Uint64 holding the IEEE 754 bits of the smoothed utilization float, updated on every release with a compare-and-swap loop.

The EWMA smoothing factor is fixed at α = 0.1 (const.go), so recent activity is weighted lightly and bursts are damped for dashboards and scaling logic.

The interface

Every method is safe for concurrent use. The surface splits into four groups (constructors live in their own section below):

  • Single slot — Acquire, AcquireWith(ctx), AcquireTimeout(d), TryAcquire, TryAcquireWith(ctx), Release.
  • Multi-slot (weighted) — AcquireN(n), AcquireNWith(ctx, n), AcquireNTimeout(n, d), TryAcquireN(n), TryAcquireNWith(ctx, n), ReleaseN(n).
  • Lifecycle — Wait(ctx) blocks until empty; Drain() force-empties all slots; Reset() swaps in a fresh channel of the same capacity; SetCap(c) resizes at runtime.
  • Introspection — Len(), Cap(), Utilization(), UtilizationSmoothed(), IsEmpty(), IsFull().

Three constructors exist: New(c), which returns an error for invalid capacity; Must(c), which panics and is meant for package-level declarations; and NewWithObserver(c, obs) to attach a metrics hook. Passing -1 selects the default capacity of 10.

The lifecycle methods are the differentiator

What x/sync/semaphore lacks is runtime capacity resizing and a drain/reset lifecycle — both of which are first-class methods here. Here:

func onConfigReload(newWorkerCount int) error {
    return sem.SetCap(newWorkerCount)
}

SetCap holds the mutex for the full duration of the channel swap, then broadcasts so blocked acquires re-evaluate against the new channel. Expanding preserves current occupancy; shrinking drains first — meaning holders get ErrReleaseExceedsCount on their next release. The README is explicit that there is no "safe resize while goroutines are mid-acquire," and recommends planning maintenance windows with Wait or Drain.

A typical graceful shutdown sequence stops new work, waits for in-flight slots to drain under a timeout context, force-drains stragglers if the wait times out, then resets:

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := sem.Wait(ctx); err != nil {
    _ = sem.Drain() // timed out; force-clear remaining slots
}
sem.Reset()

Observer hook for telemetry

NewWithObserver wires an Observer interface — OnAcquire, OnRelease, OnWaitStart, OnWaitEnd — into every state change, so you can increment Prometheus counters or emit structured logs without polling the semaphore.

The contract is strict: observer methods are called synchronously while the internal lock is held. They must return immediately and must not call back into the same semaphore (which would deadlock) or take other locks that could cycle with its mutex. When no observer is attached, notify resolves to a nil check — no interface dispatch, no allocation on the hot path.

Errors match by type

The error types implement errors.Is matching by type rather than field value:

err := sem.AcquireWith(ctx)
if errors.Is(err, sema.ErrAcquireCancelled{}) {
    // context was cancelled or deadline exceeded
}

ErrAcquireCancelled and ErrRecovered also implement Unwrap(), so errors.Is(err, context.DeadlineExceeded) works through the chain. The set covers invalid capacity (ErrInvalidCap), bad slot counts (ErrInvalidN), over-release (ErrReleaseExceedsCount), no immediate slot for try-acquires (ErrNoSlot), multi-slot requests exceeding capacity (ErrNExceedsCap), a failed drain invariant (ErrDrain), and panics recovered inside AcquireN (ErrRecovered).

Testing

The suite is organized into ten sections documented in TESTS.md, covering constructors, single- and multi-slot paths, introspection, observer emission on every code path (with positive and negative tests), error types, fuzz targets, benchmarks, and application-level regression scenarios.

Eight fuzz targets ship with the package — including FuzzConcurrentAcquireRelease and FuzzConcurrentMixedOps, which race acquires against releases under randomized parameters to verify invariants like Len ≤ Cap. The Makefile wires it all up:

make test          # unit tests, alias for test-unit
make test-unit     # go test -race -count=1 -v ./...
make test-bench    # benchmarks with memory stats
make test-fuzz     # each fuzz target for FUZZTIME (default 30s)
make test-all      # all three

The README reports zero-allocation hot paths and per-operation timings measured on an Apple M3 Ultra under Go 1.21 — self-reported figures, worth treating as indicative rather than contractual.

When to reach for it

The natural fits are HTTP request throttling (returning 429 via TryAcquire when saturated), wrapping a database connection pool so bursts queue instead of hammering the backend, weighted fan-out in ETL pipelines, graceful shutdown sequencing, config-driven concurrency with hot reload through SetCap, and autoscaler feedback loops driven by UtilizationSmoothed().

For plain bounded concurrency where you never resize or drain, x/sync/semaphore remains the simpler choice. sema earns its place when you need lifecycle management and live capacity changes without restarting — the features that make a semaphore feel like part of production operations rather than just a lock.

Constructing a semaphoreSETTING IT UPConstructing a semaphoreCall New(c), Must(c) or NewWithObserver1Validate capacity (c >= 1, or -1 for default)2Create mutex and condition variable3Allocate buffered chan struct{} of size c4Store channel pointer atomically; ready to use5
Setting it up: Constructing a semaphore
Acquire and release cycleHOW THE WORK FLOWSAcquire and release cycleGoroutine calls Acquire or TryAcquire1Send struct{} into buffered channel under lock2Notify observer and return slot to caller3Caller does guarded work, then Release4Receive from channel; update EWMA utilization5Broadcast on cond to wake blocked acquires6if full
How the work flows: Acquire and release cycle

On the shelf

andreimerlescu/sema: 162 KB in 18 files, indexed Nov 9, 2026 after 0.00 hours of indexing. License: Apache-2.0. Source: https://github.com/andreimerlescu/sema.

Ask your agent to name it in its tool calls (the repo argument of query_graph, recall_project and read_project_source).

Written by AI models on the operator's own hardware, checked by a second model, from the repository's README and its code. Back to the shelf