sema

package module
v1.1.0 Latest Latest
Warning

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

Go to latest
Published: Apr 13, 2026 License: Apache-2.0 Imports: 6 Imported by: 2

README

sema — Semaphore for Go

The semaphore you wish the standard library had — with dynamic resizing, context cancellation, graceful drain/reset, EWMA utilization tracking, and observability hooks your production system actually needs.

Go Reference Go Report Card Apache 2.0 License


Why sema?

You've hit the point where golang.org/x/sync/semaphore isn't enough. You need to resize capacity at runtime without restarting. You need to drain in-flight work for a maintenance window. You need utilization metrics without bolting on a separate monitoring layer. You need Wait to know when the semaphore is idle so you can shut down cleanly.

sema is a drop-in semaphore that handles all of this out of the box.

Feature comparison
Feature x/sync/semaphore marusama/semaphore sema
Weighted acquire / release
Context cancellation
Non-blocking try-acquire
Dedicated timeout acquire
Dynamic capacity resize
Drain / Reset for maintenance
Wait until idle
Instant utilization (Len/Cap)
Smoothed utilization (EWMA)
Observer / metrics hook
Structured error types
Zero-alloc hot path

x/sync/semaphore is excellent for simple bounded concurrency. marusama/semaphore adds resizing via CAS. sema goes further: lifecycle management (Drain, Reset, Wait), real-time utilization tracking, and a pluggable observer that wires directly into Prometheus, OpenTelemetry, or structured logging — without polling, without a metrics goroutine, and without any overhead when no observer is attached.


When to use sema

HTTP request throttling — cap concurrent handler goroutines per endpoint and return 429 instantly via TryAcquire when the server is saturated.

Database connection limiting — wrap your connection pool with a semaphore so burst traffic queues gracefully instead of hammering the database with rejected connections.

Fan-out control in ETL pipelines — use AcquireNWith to give resource-heavy transforms weighted slots while lightweight stages get single slots, all sharing one concurrency budget.

Graceful shutdown — call Wait to block until all in-flight work finishes, then Drain and Reset for a clean restart. No leaked goroutines, no abandoned connections.

Config-driven concurrency with hot reload — call SetCap when your config file changes. The semaphore resizes live, preserving in-flight work when expanding and draining when shrinking.

Autoscaler feedback loop — feed UtilizationSmoothed() into your scaling logic. The EWMA smooths out bursts so your autoscaler doesn't thrash on transient spikes.


Installation

go get github.com/andreimerlescu/sema

Requires Go 1.21+ (uses sync/atomic generic types).


Quick start

package main

import (
    "fmt"
    "sync"

    "github.com/andreimerlescu/sema"
)

func main() {
    // Allow at most 5 concurrent workers.
    sem := sema.Must(5)

    var wg sync.WaitGroup
    for i := 0; i < 20; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()

            sem.Acquire()          // block until a slot is free
            defer sem.Release()    // always give the slot back

            fmt.Printf("worker %d running (%d/%d slots used)\n",
                id, sem.Len(), sem.Cap())
        }(i)
    }

    wg.Wait()
    fmt.Printf("done — semaphore empty: %v\n", sem.IsEmpty())
}

The full interface

Every method is safe for concurrent use.

type Semaphore interface {
    // ── Single-slot ─────────────────────────────────────────────────────
    Acquire()                                  // block until a slot is free
    AcquireWith(ctx context.Context) error     // block, honour context
    AcquireTimeout(d time.Duration) error      // block, honour deadline
    TryAcquire() bool                          // succeed or return false immediately
    TryAcquireWith(ctx context.Context) error  // succeed or return ErrNoSlot / ErrAcquireCancelled

    Release() error                            // free one slot

    // ── Multi-slot ──────────────────────────────────────────────────────
    AcquireN(n int) error
    AcquireNWith(ctx context.Context, n int) error
    AcquireNTimeout(n int, d time.Duration) error
    TryAcquireN(n int) bool
    TryAcquireNWith(ctx context.Context, n int) error

    ReleaseN(n int) error                      // free n slots atomically

    // ── Lifecycle ───────────────────────────────────────────────────────
    Wait(ctx context.Context) error            // block until Len() == 0
    Drain() error                              // forcibly empty all slots
    Reset() error                              // replace channel; preserves Cap
    SetCap(c int) error                        // resize at runtime

    // ── Introspection ───────────────────────────────────────────────────
    Len() int                                  // current occupancy
    Cap() int                                  // current capacity
    Utilization() float64                      // Len/Cap snapshot
    UtilizationSmoothed() float64              // EWMA of Len/Cap over time
    IsEmpty() bool
    IsFull() bool
}

Constructors

// New returns a Semaphore with capacity c.
// Pass -1 to use the default capacity (10).
// Returns ErrInvalidCap for c == 0 or c < -1.
s, err := sema.New(10)

// Must panics instead of returning an error.
// Safe for package-level var declarations.
s := sema.Must(10)

// NewWithObserver wires a metrics/logging hook into every state change.
s, err := sema.NewWithObserver(10, myObserver)

Recipes

Worker pool

The most common pattern. Exactly N goroutines run at any moment.

sem := sema.Must(N)

for _, job := range jobs {
    sem.Acquire()
    go func(j Job) {
        defer sem.Release()
        process(j)
    }(job)
}

// Wait for every in-flight goroutine to release its slot.
ctx := context.Background()
sem.Wait(ctx)
Context-aware acquire

Cancel or time-out a waiting goroutine without leaking it.

ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()

if err := sem.AcquireWith(ctx); err != nil {
    // errors.Is(err, sema.ErrAcquireCancelled{}) == true
    log.Println("request dropped — semaphore full")
    return
}
defer sem.Release()
Non-blocking fast path

Reject immediately when the semaphore is full, without touching the scheduler.

if !sem.TryAcquire() {
    http.Error(w, "server busy", http.StatusTooManyRequests)
    return
}
defer sem.Release()
serveRequest(w, r)
Premium / burst clients (multi-slot)

Allocate weighted slots for high-priority or resource-intensive operations.

const premiumWeight = 3

ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer cancel()

if err := sem.AcquireNWith(ctx, premiumWeight); err != nil {
    http.Error(w, "capacity unavailable", http.StatusServiceUnavailable)
    return
}
defer sem.ReleaseN(premiumWeight)

servePremiumRequest(w, r)
Dynamic resize (config reload)

Adjust capacity at runtime without restarting the process.

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

Expanding (new cap ≥ current occupancy): existing slots are preserved and the channel grows. Shrinking (new cap < current occupancy): all slots are drained first, then the channel shrinks. Plan accordingly.

Maintenance window

Drain in-flight work, verify idle, perform maintenance, then resume.

// 1. Signal no new work should start (application-level flag, not shown).
// 2. Wait for all current slots to drain — or force it.
if err := sem.Drain(); err != nil {
    return err
}

// 3. Confirm idle before touching shared resources.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := sem.Wait(ctx); err != nil {
    return fmt.Errorf("timed out waiting for idle: %w", err)
}

// 4. Perform maintenance.
rotateLogs()

// 5. Reset to a guaranteed-clean state and resume.
sem.Reset()
Utilization monitoring

Feed semaphore metrics into your observability stack without adding lock contention to the hot path.

// Snapshot — suitable for a Prometheus gauge collector.
util := sem.Utilization()

// Exponentially weighted moving average — suitable for dashboards
// and alerting. Smooths out short bursts automatically.
smooth := sem.UtilizationSmoothed()

metrics.GaugeSet("worker_pool.utilization", util)
metrics.GaugeSet("worker_pool.utilization_smoothed", smooth)
Observer — structured metrics hook

Observer lets you attach counters, histograms, or structured logs to every semaphore event without polling.

type prometheusObserver struct {
    acquireTotal prometheus.Counter
    releaseTotal prometheus.Counter
    waitDuration prometheus.Histogram
    waitStart    time.Time
}

func (o *prometheusObserver) OnAcquire(count, cap int) {
    o.acquireTotal.Inc()
}
func (o *prometheusObserver) OnRelease(count, cap int) {
    o.releaseTotal.Inc()
}
func (o *prometheusObserver) OnWaitStart() {
    o.waitStart = time.Now()
}
func (o *prometheusObserver) OnWaitEnd(err error) {
    o.waitDuration.Observe(time.Since(o.waitStart).Seconds())
}

sem, err := sema.NewWithObserver(10, &prometheusObserver{
    acquireTotal: promauto.NewCounter(prometheus.CounterOpts{
        Name: "sema_acquire_total",
    }),
    releaseTotal: promauto.NewCounter(prometheus.CounterOpts{
        Name: "sema_release_total",
    }),
    waitDuration: promauto.NewHistogram(prometheus.HistogramOpts{
        Name:    "sema_wait_duration_seconds",
        Buckets: prometheus.DefBuckets,
    }),
})

Observer contract: every method must return immediately. Never acquire a lock inside an observer method — it is called while the semaphore's internal state is being updated.


Performance

Benchmarks on Apple M3 Ultra (28 cores), Go 1.21, go test -bench=. -benchmem:

Operation ns/op allocs/op
Acquire + Release 30 0
TryAcquire + Release 32 0
AcquireWith + Release 33 0
AcquireN + ReleaseN 38 0
Acquire + Release (parallel, 28 cores) 173 0
Acquire + Release with Observer 40 0
Len 2.2 0
Cap 1.6 0
Utilization 3.0 0
UtilizationSmoothed 0.9 0

The hot path (acquire/release) is zero-allocation. Observer dispatch adds ~10ns when attached and zero cost when no observer is registered. Introspection methods (Len, Cap, Utilization, UtilizationSmoothed) are lock-free atomic reads.


Error reference

All errors implement errors.Is with type-only matching, so you never need to compare field values:

err := sem.AcquireWith(ctx)
if errors.Is(err, sema.ErrAcquireCancelled{}) {
    // context was cancelled or deadline exceeded
}
Error When returned
ErrInvalidCap New or SetCap called with c == 0 or c < -1
ErrInvalidN Any *N method called with n < 1
ErrNExceedsCap AcquireN / AcquireNWith / TryAcquireNWith with n > Cap()
ErrNoSlot TryAcquireWith / TryAcquireNWith when no slot is immediately available
ErrAcquireCancelled Any *With or *Timeout method when the context expires or is cancelled
ErrReleaseExceedsCount Release / ReleaseN called more times than Acquire
ErrDrain Internal invariant failure during Drain (indicates a bug — please open an issue)
ErrRecovered A panic was recovered inside AcquireN — wraps the original panic value

ErrAcquireCancelled and ErrRecovered both implement Unwrap(), so errors.Is(err, context.DeadlineExceeded) works as expected through the chain.


Design notes

Channel-based core

The semaphore is backed by a buffered chan struct{}. Acquiring a slot sends to the channel; releasing receives from it. This delegates scheduling to the Go runtime's existing channel machinery — no spin loops, no custom queues.

Atomic EWMA

UtilizationSmoothed() is updated on every Release / ReleaseN using a compare-and-swap loop on an atomic.Uint64 storing the IEEE 754 bits of a float64. The smoothing factor α = 0.1 means recent activity is weighted lightly, providing a stable trend signal for dashboards and autoscalers.

SetCap safety

SetCap holds the mutex for the full duration of the channel swap. Goroutines blocked on Acquire will unblock via cond.Broadcast() after the swap completes and will find the new channel. Expanding preserves current occupancy; shrinking drains first. There is no "safe resize while goroutines are mid-acquire" — plan maintenance windows accordingly using Wait or Drain.

Zero observer overhead

When no observer is registered (NewWithObserver was not used), the notify call resolves to a nil check and returns. There is no interface dispatch, no allocation, and no additional branch in the hot path.


Testing

The package ships with comprehensive test coverage across four categories:

Unit tests — every interface method, every error type, every edge case. Constructor validation, blocking behavior, context cancellation mid-wait, partial-acquire rollback, observer emission on every code path, EWMA correctness, and SetCap channel-swap survival.

Fuzz tests — 8 fuzz targets including FuzzConcurrentAcquireRelease and FuzzConcurrentMixedOps that race acquires against releases under randomized parameters to verify invariants (Len ≤ Cap, Len ≥ 0, no slot leaks).

Benchmark tests — per-operation throughput for every method, parallel contention under GOMAXPROCS cores, and isolated observer overhead measurement.

Integration tests — multi-phase lifecycle scenarios covering drain → wait → reset → resize → resume sequences and context cancellation during drain.

All tests pass under the race detector (go test -race ./...).

# Unit tests with race detector
go test -race -count=1 -v ./...

# Benchmarks with memory stats
go test -bench=. -benchmem ./...

# Fuzz targets (30 seconds each)
make test-fuzz

# Everything
make test-all

The test suite design is documented in TESTS.md.


Contributing

Pull requests are welcome. Before opening one:

  1. go test -race ./... must pass cleanly.
  2. New methods require a unit test, a fuzz target, and a benchmark.
  3. Observer emission points require a positive and a negative observer test.
  4. Update TESTS.md with any new coverage decisions.

License

Apache 2.0 License © Andrei Merlescu


Built with care and a lot of go test -race.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type ErrAcquireCancelled added in v1.1.0

type ErrAcquireCancelled struct{ Cause error }

ErrAcquireCancelled indicates that a context-aware acquire or wait was terminated because the context was cancelled or its deadline expired before the operation could complete. It wraps the underlying context error and implements errors.Unwrap, so callers can use errors.Is(err, context.Canceled) or errors.Is(err, context.DeadlineExceeded) to distinguish the cause.

func (ErrAcquireCancelled) Error added in v1.1.0

func (e ErrAcquireCancelled) Error() string

Error implements the [error] interface.

func (ErrAcquireCancelled) Is added in v1.1.0

func (e ErrAcquireCancelled) Is(target error) bool

Is supports matching via errors.Is by type.

func (ErrAcquireCancelled) String added in v1.1.0

func (e ErrAcquireCancelled) String() string

ErrAcquireCancelled is returned by context-aware acquire methods ([semaphore.AcquireWith], [semaphore.AcquireNWith], [semaphore.AcquireTimeout], etc.) when the context is cancelled or its deadline expires before a slot can be acquired.

The Cause field holds the underlying context error (typically context.Canceled or context.DeadlineExceeded).

ErrAcquireCancelled implements errors.Unwrap, so the cause can be inspected with errors.Is or errors.As.

Example:

ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
time.Sleep(5 * time.Millisecond)

if err := sem.AcquireWith(ctx); err != nil {
    var acqErr sema.ErrAcquireCancelled
    if errors.As(err, &acqErr) {
        fmt.Printf("acquire cancelled: %v\n", acqErr.Cause)
    }
    if errors.Is(err, context.DeadlineExceeded) {
        fmt.Println("it was a timeout")
    }
}

func (ErrAcquireCancelled) Unwrap added in v1.1.0

func (e ErrAcquireCancelled) Unwrap() error

Unwrap returns the underlying context error, enabling use with errors.Is and errors.As for matching context.Canceled or context.DeadlineExceeded.

type ErrDrain added in v1.1.0

type ErrDrain struct{ Cause string }

ErrDrain indicates that Semaphore.Drain could not fully empty the internal channel. This is an unexpected internal condition and should not occur under normal usage.

  • Cause: a human-readable description of what went wrong.

func (ErrDrain) Error added in v1.1.0

func (e ErrDrain) Error() string

Error implements the [error] interface.

func (ErrDrain) Is added in v1.1.0

func (e ErrDrain) Is(target error) bool

Is supports matching via errors.Is by type.

func (ErrDrain) String added in v1.1.0

func (e ErrDrain) String() string

ErrDrain is returned by [semaphore.Drain] when the internal channel could not be fully emptied. The Cause field contains a human-readable description of what went wrong.

Example:

if err := sem.Drain(); err != nil {
    var drainErr sema.ErrDrain
    if errors.As(err, &drainErr) {
        fmt.Printf("drain problem: %s\n", drainErr.Cause)
    }
}

type ErrInvalidCap added in v1.1.0

type ErrInvalidCap struct{ Value int }

ErrInvalidCap indicates that an invalid capacity was passed to New, NewWithObserver, Must, or Semaphore.SetCap. Valid capacities are any integer >= 1, or the special value -1 which selects [defaultCap].

  • Value: the invalid capacity that was provided.

func (ErrInvalidCap) Error added in v1.1.0

func (e ErrInvalidCap) Error() string

Error implements the [error] interface.

func (ErrInvalidCap) Is added in v1.1.0

func (e ErrInvalidCap) Is(target error) bool

Is supports matching via errors.Is by type. Any ErrInvalidCap matches any other ErrInvalidCap regardless of the Value field.

func (ErrInvalidCap) String added in v1.1.0

func (e ErrInvalidCap) String() string

ErrInvalidCap is returned by [semaphore.SetCap] when the requested capacity is less than 1 and not the special value -1 (which resets to [defaultCap]).

The Value field contains the invalid capacity that was passed.

Example:

if err := sem.SetCap(0); err != nil {
    var capErr sema.ErrInvalidCap
    if errors.As(err, &capErr) {
        fmt.Printf("bad capacity: %d\n", capErr.Value)
    }
}

type ErrInvalidN added in v1.1.0

type ErrInvalidN struct{ Value int }

ErrInvalidN indicates that an invalid slot count (n < 1) was passed to a multi-slot method such as Semaphore.AcquireN, Semaphore.ReleaseN, or their variants.

  • Value: the invalid n that was provided.

func (ErrInvalidN) Error added in v1.1.0

func (e ErrInvalidN) Error() string

Error implements the [error] interface.

func (ErrInvalidN) Is added in v1.1.0

func (e ErrInvalidN) Is(target error) bool

Is supports matching via errors.Is by type.

func (ErrInvalidN) String added in v1.1.0

func (e ErrInvalidN) String() string

ErrInvalidN is returned by [semaphore.AcquireN], [semaphore.ReleaseN], and related multi-slot methods when n is less than 1.

The Value field contains the invalid n that was passed.

Example:

if err := sem.AcquireN(0); err != nil {
    var nErr sema.ErrInvalidN
    if errors.As(err, &nErr) {
        fmt.Printf("invalid n: %d\n", nErr.Value)
    }
}

type ErrNExceedsCap added in v1.1.0

type ErrNExceedsCap struct {
	Requested int
	Cap       int
}

ErrNExceedsCap indicates that a multi-slot acquire was rejected because the requested count exceeds the semaphore's capacity. Allowing the acquire would deadlock, since there can never be enough slots to satisfy the request.

  • Requested: the number of slots the caller asked for.
  • Cap: the semaphore's capacity at the time of the call.

func (ErrNExceedsCap) Error added in v1.1.0

func (e ErrNExceedsCap) Error() string

Error implements the [error] interface.

func (ErrNExceedsCap) Is added in v1.1.0

func (e ErrNExceedsCap) Is(target error) bool

Is supports matching via errors.Is by type.

func (ErrNExceedsCap) String added in v1.1.0

func (e ErrNExceedsCap) String() string

ErrNExceedsCap is returned by [semaphore.AcquireN], [semaphore.AcquireNWith], and related multi-slot methods when the requested count n exceeds the semaphore's current capacity. Acquiring more slots than the capacity would block forever, so the call is rejected immediately.

  • Requested: the number of slots the caller asked for.
  • Cap: the semaphore's capacity at the time of the call.

Example:

sem := sema.New(3)
if err := sem.AcquireN(5); err != nil {
    var excErr sema.ErrNExceedsCap
    if errors.As(err, &excErr) {
        fmt.Printf("requested %d but cap is only %d\n",
            excErr.Requested, excErr.Cap)
    }
}

type ErrNoSlot added in v1.1.0

type ErrNoSlot struct {
	Requested int
	Available int
}

ErrNoSlot indicates that a non-blocking acquire (Semaphore.TryAcquireWith or Semaphore.TryAcquireNWith) could not claim the requested number of slots because the semaphore did not have enough free capacity at the moment of the attempt.

  • Requested: the number of slots the caller asked for.
  • Available: the number of free slots at the time of the attempt.

func (ErrNoSlot) Error added in v1.1.0

func (e ErrNoSlot) Error() string

Error implements the [error] interface.

func (ErrNoSlot) Is added in v1.1.0

func (e ErrNoSlot) Is(target error) bool

Is supports matching via errors.Is by type.

func (ErrNoSlot) String added in v1.1.0

func (e ErrNoSlot) String() string

ErrNoSlot is returned by [semaphore.TryAcquireWith] and [semaphore.TryAcquireNWith] when the semaphore does not have enough free slots to satisfy a non-blocking acquire.

  • Requested: the number of slots the caller asked for.
  • Available: the number of free slots at the time of the attempt.

Example:

err := sem.TryAcquireNWith(ctx, 3)
var slotErr sema.ErrNoSlot
if errors.As(err, &slotErr) {
    fmt.Printf("wanted %d slots, only %d free\n",
        slotErr.Requested, slotErr.Available)
}

type ErrRecovered added in v1.1.0

type ErrRecovered struct {
	Cause   any
	AsError error
}

ErrRecovered wraps a panic that was caught and converted to an error during a multi-slot acquire operation. It preserves both the raw panic value (which may be any type) and, when possible, an error-typed version for use with errors.Unwrap, errors.Is, and errors.As.

  • Cause: the raw value recovered from the panic (any type).
  • AsError: the Cause cast to error, or nil if Cause does not implement the error interface. Used by the Unwrap method.

func (ErrRecovered) Error added in v1.1.0

func (e ErrRecovered) Error() string

Error implements the [error] interface.

func (ErrRecovered) Is added in v1.1.0

func (e ErrRecovered) Is(target error) bool

Is supports matching via errors.Is by type.

func (ErrRecovered) String added in v1.1.0

func (e ErrRecovered) String() string

ErrRecovered is returned when [semaphore.AcquireN] (or a related method) catches a panic during execution and wraps it as an error.

  • Cause: the raw value recovered from the panic (interface{}).
  • AsError: the Cause converted to an error, if possible, for use with errors.Unwrap.

ErrRecovered implements errors.Unwrap via AsError, so callers can use errors.Is and errors.As to inspect the original panic value when it satisfies the error interface.

Example:

if err := sem.AcquireN(2); err != nil {
    var recErr sema.ErrRecovered
    if errors.As(err, &recErr) {
        fmt.Printf("panic recovered: %v\n", recErr.Cause)
    }
}

func (ErrRecovered) Unwrap added in v1.1.0

func (e ErrRecovered) Unwrap() error

Unwrap returns the panic value as an error (if it implements [error]), enabling errors.Is and errors.As chains through the recovered panic.

type ErrReleaseExceedsCount added in v1.1.0

type ErrReleaseExceedsCount struct {
	Attempted int
	Current   int
}

ErrReleaseExceedsCount indicates that a Release or ReleaseN call attempted to free more slots than are currently held. This typically signals a mismatched acquire/release pair, a double release, or a release after Semaphore.Drain / Semaphore.Reset has already cleared the slots.

  • Attempted: the number of slots the caller tried to release.
  • Current: the number of slots actually held at call time.

func (ErrReleaseExceedsCount) Error added in v1.1.0

func (e ErrReleaseExceedsCount) Error() string

Error implements the [error] interface.

func (ErrReleaseExceedsCount) Is added in v1.1.0

func (e ErrReleaseExceedsCount) Is(target error) bool

Is supports matching via errors.Is by type.

func (ErrReleaseExceedsCount) String added in v1.1.0

func (e ErrReleaseExceedsCount) String() string

ErrReleaseExceedsCount is returned by [semaphore.Release] and [semaphore.ReleaseN] when the caller attempts to release more slots than are currently held. This typically indicates a mismatched acquire/release pair, or a release after [semaphore.Drain] or [semaphore.Reset] has already cleared the slots.

  • Attempted: the number of slots the caller tried to release.
  • Current: the number of slots actually held at the time of the call.

Example:

sem := sema.New(5)
// No slots acquired — releasing is an error.
if err := sem.Release(); err != nil {
    var relErr sema.ErrReleaseExceedsCount
    if errors.As(err, &relErr) {
        fmt.Printf("tried to release %d, but only %d held\n",
            relErr.Attempted, relErr.Current)
    }
}

type Observer added in v1.1.0

type Observer interface {
	// OnAcquire is called immediately after a slot is successfully
	// acquired by any of the acquire methods ([semaphore.Acquire],
	// [semaphore.AcquireWith], [semaphore.TryAcquire],
	// [semaphore.AcquireN], etc.).
	//
	// Parameters:
	//   - count: the number of slots currently held after this
	//     acquisition (equivalent to [semaphore.Len] at call time).
	//   - cap: the total capacity of the semaphore (equivalent to
	//     [semaphore.Cap] at call time).
	//
	// The ratio count/cap gives the instantaneous utilization at the
	// moment of acquisition. Note that for bulk acquires via
	// [semaphore.AcquireN], OnAcquire is called once after all n slots
	// have been claimed, not once per slot.
	//
	// Example usage inside an implementation:
	//
	//	func (o *myObserver) OnAcquire(count, cap int) {
	//	    utilization := float64(count) / float64(cap)
	//	    o.gauge.Set(utilization)
	//	}
	OnAcquire(count, cap int)

	// OnRelease is called immediately after one or more slots are
	// successfully released by [semaphore.Release] or
	// [semaphore.ReleaseN].
	//
	// Parameters:
	//   - count: the number of slots still held after this release
	//     (equivalent to [semaphore.Len] at call time).
	//   - cap: the total capacity of the semaphore (equivalent to
	//     [semaphore.Cap] at call time).
	//
	// Like OnAcquire, for bulk releases via [semaphore.ReleaseN],
	// OnRelease is called once after all n slots have been freed, not
	// once per slot.
	//
	// Example usage inside an implementation:
	//
	//	func (o *myObserver) OnRelease(count, cap int) {
	//	    if count == 0 {
	//	        o.logger.Println("semaphore fully idle")
	//	    }
	//	}
	OnRelease(count, cap int)

	// OnWaitStart is called at the beginning of [semaphore.Wait],
	// before the semaphore checks whether it is already empty. This
	// allows observers to track how often callers enter a wait state
	// and to measure wait duration by pairing with [Observer.OnWaitEnd].
	//
	// OnWaitStart receives no parameters because the wait has not yet
	// inspected semaphore state — use the count and cap values from
	// [Observer.OnAcquire] or [Observer.OnRelease] for utilization data.
	//
	// Example usage inside an implementation:
	//
	//	func (o *myObserver) OnWaitStart() {
	//	    o.waitTimer = time.Now()
	//	    o.waitCount.Inc()
	//	}
	OnWaitStart()

	// OnWaitEnd is called when [semaphore.Wait] returns, whether it
	// completed successfully (all slots drained) or was cancelled by
	// the context.
	//
	// Parameters:
	//   - err: nil if the semaphore reached zero occupancy, or an
	//     [ErrAcquireCancelled] if the context was cancelled or its
	//     deadline expired before the semaphore became empty.
	//
	// By pairing OnWaitEnd with [Observer.OnWaitStart], observers can
	// measure the wall-clock duration of the wait and distinguish
	// successful drains from cancellations.
	//
	// Example usage inside an implementation:
	//
	//	func (o *myObserver) OnWaitEnd(err error) {
	//	    elapsed := time.Since(o.waitTimer)
	//	    o.waitDuration.Observe(elapsed.Seconds())
	//	    if err != nil {
	//	        o.waitCancellations.Inc()
	//	    }
	//	}
	OnWaitEnd(err error)
}

Observer is an interface for receiving lifecycle event notifications from a Semaphore. Implementations can use these callbacks to collect metrics, emit structured logs, trigger alerts, or drive adaptive concurrency control — without modifying the semaphore itself.

An Observer is attached at construction time via NewWithObserver. A semaphore created with New or Must has no observer and incurs no callback overhead.

Concurrency and performance contract

All Observer methods are called synchronously while the semaphore's internal lock is held. Implementations MUST return immediately and MUST NOT:

  • Acquire additional locks that could create a lock-ordering cycle with the semaphore's own mutex.
  • Call back into the same semaphore (e.g. Acquire, Release, SetCap), which would deadlock.
  • Perform blocking I/O, network calls, or any operation with unbounded latency.

If you need to perform expensive work in response to an event, buffer it in a channel or queue and process it asynchronously.

Method summary

Examples

A minimal no-op observer (useful as a base for partial implementations):

type noopObserver struct{}

func (noopObserver) OnAcquire(count, cap int) {}
func (noopObserver) OnRelease(count, cap int) {}
func (noopObserver) OnWaitStart()              {}
func (noopObserver) OnWaitEnd(err error)       {}

A Prometheus-style metrics observer:

type prometheusObserver struct {
    acquireTotal  prometheus.Counter
    releaseTotal  prometheus.Counter
    utilization   prometheus.Gauge
}

func (p *prometheusObserver) OnAcquire(count, cap int) {
    p.acquireTotal.Inc()
    p.utilization.Set(float64(count) / float64(cap))
}

func (p *prometheusObserver) OnRelease(count, cap int) {
    p.releaseTotal.Inc()
    p.utilization.Set(float64(count) / float64(cap))
}

func (p *prometheusObserver) OnWaitStart()        {}
func (p *prometheusObserver) OnWaitEnd(err error) {}

An observer that buffers events for asynchronous processing:

type asyncObserver struct {
    events chan Event
}

func (a *asyncObserver) OnAcquire(count, cap int) {
    select {
    case a.events <- Event{Kind: "acquire", Count: count, Cap: cap}:
    default:
        // Drop event if buffer is full — never block.
    }
}

func (a *asyncObserver) OnRelease(count, cap int) {
    select {
    case a.events <- Event{Kind: "release", Count: count, Cap: cap}:
    default:
    }
}

func (a *asyncObserver) OnWaitStart()        {}
func (a *asyncObserver) OnWaitEnd(err error) {}

type Semaphore

type Semaphore interface {
	// Acquire blocks until a slot is available, then claims it. It waits
	// indefinitely if the semaphore is full. Every successful Acquire
	// must be paired with a [Semaphore.Release] call, typically via defer.
	// Use [Semaphore.AcquireWith] or [Semaphore.AcquireTimeout] when
	// cancellation or a deadline is needed.
	Acquire()

	// AcquireWith blocks until a slot is available or the provided context
	// is cancelled. It returns nil on success, or [ErrAcquireCancelled]
	// wrapping the context error if the context fires first. The underlying
	// cause is accessible via [errors.Unwrap] or [errors.Is] against
	// [context.Canceled] / [context.DeadlineExceeded].
	AcquireWith(ctx context.Context) error

	// AcquireTimeout blocks until a slot is available or the given duration
	// elapses. It is a convenience wrapper around [Semaphore.AcquireWith]
	// with an internally created [context.WithTimeout]. Returns nil on
	// success, or [ErrAcquireCancelled] on timeout.
	AcquireTimeout(d time.Duration) error

	// TryAcquire attempts to claim a slot without blocking. It returns
	// true if a slot was acquired (caller must later call
	// [Semaphore.Release]), or false if the semaphore is currently full.
	// Useful for fast-path checks, load shedding, or fallback logic
	// where blocking is unacceptable.
	TryAcquire() bool

	// TryAcquireWith performs a non-blocking acquire after first checking
	// whether the context is already done. It returns nil on success,
	// [ErrAcquireCancelled] if the context is already cancelled, or
	// [ErrNoSlot] if the semaphore is full. Unlike [Semaphore.AcquireWith],
	// it never blocks waiting for a slot to become available.
	TryAcquireWith(ctx context.Context) error

	// Release frees a single held slot back to the semaphore, waking one
	// or more blocked acquires. It returns [ErrReleaseExceedsCount] if no
	// slots are currently held, indicating a mismatched acquire/release
	// pair or a release after [Semaphore.Drain] / [Semaphore.Reset].
	// The EWMA utilization metric is updated on each successful release.
	Release() error

	// AcquireN acquires n slots from the semaphore, blocking until all n
	// are available. It first attempts a fast non-blocking path and falls
	// back to [Semaphore.AcquireNWith] with a 10-minute internal timeout.
	// Returns [ErrNExceedsCap] if n exceeds the capacity (which would
	// deadlock), or [ErrInvalidN] if n < 1. Partial acquisitions are
	// rolled back automatically on failure.
	AcquireN(n int) error

	// AcquireNWith acquires n slots, blocking until all are claimed or the
	// context is cancelled. If the context fires after some but not all
	// slots have been acquired, the partially acquired slots are released
	// automatically. Returns [ErrAcquireCancelled] on context cancellation,
	// [ErrNExceedsCap] if n exceeds capacity, or [ErrInvalidN] if n < 1.
	AcquireNWith(ctx context.Context, n int) error

	// AcquireNTimeout acquires n slots, blocking until all are claimed or
	// the given duration elapses. It is a convenience wrapper around
	// [Semaphore.AcquireNWith] with an internally created timeout context.
	// Automatic rollback of partial acquisitions applies on timeout.
	AcquireNTimeout(n int, d time.Duration) error

	// TryAcquireN attempts to acquire n slots without blocking. It returns
	// true only if all n slots were claimed atomically; if fewer than n
	// slots are free, no slots are acquired and it returns false. Returns
	// false for invalid n values (n < 1) or if n exceeds the capacity.
	TryAcquireN(n int) bool

	// TryAcquireNWith performs a non-blocking bulk acquire after checking
	// whether the context is already done. It returns nil if all n slots
	// were claimed, [ErrAcquireCancelled] if the context is done,
	// [ErrNExceedsCap] if n exceeds capacity, [ErrInvalidN] if n < 1, or
	// [ErrNoSlot] if not enough slots are currently free.
	TryAcquireNWith(ctx context.Context, n int) error

	// ReleaseN frees n held slots back to the semaphore. It returns
	// [ErrReleaseExceedsCount] if n exceeds the number of currently held
	// slots, or [ErrInvalidN] if n < 1. All blocked acquires are woken
	// after a successful release, and the EWMA utilization metric is
	// updated.
	ReleaseN(n int) error

	// Wait blocks until the semaphore is completely empty (zero slots held)
	// or the context is cancelled. It returns nil when all slots have been
	// released, or [ErrAcquireCancelled] if the context fires first. Wait
	// does not prevent new acquires — coordinate with application-level
	// stop signals to ensure no new work is dispatched while waiting.
	Wait(ctx context.Context) error

	// Drain forcibly removes all held slots from the internal channel.
	// Goroutines that previously acquired slots will receive
	// [ErrReleaseExceedsCount] when they subsequently call
	// [Semaphore.Release]. Use [Semaphore.Wait] to let in-flight work
	// finish gracefully before calling Drain. The EWMA utilization metric
	// is reset to zero. Returns [ErrDrain] if the channel could not be
	// fully emptied.
	Drain() error

	// Reset replaces the internal channel with a fresh one of the same
	// capacity, discarding all held slots and resetting the EWMA metric
	// to zero. Like [Semaphore.Drain], goroutines holding slots from
	// before the reset will receive [ErrReleaseExceedsCount] on their
	// next [Semaphore.Release] call. All blocked acquires are woken and
	// will compete for slots on the new channel.
	Reset() error

	// Len returns the number of slots currently held (acquired but not
	// yet released). This is a point-in-time snapshot and may be stale
	// by the time the caller acts on it. Suitable for monitoring and
	// logging, not for synchronization decisions.
	Len() int

	// Cap returns the total capacity of the semaphore — the maximum
	// number of slots that may be held concurrently. Use
	// [Semaphore.SetCap] to adjust this at runtime.
	Cap() int

	// Utilization returns the instantaneous utilization as a float64 in
	// [0.0, 1.0], computed as Len / Cap. Returns 0 if the capacity is
	// zero. For a temporally smoothed metric, see
	// [Semaphore.UtilizationSmoothed].
	Utilization() float64

	// UtilizationSmoothed returns the exponentially weighted moving
	// average (EWMA) of utilization as a float64 in [0.0, 1.0]. The
	// EWMA is updated on every [Semaphore.Release] and
	// [Semaphore.ReleaseN] call, providing a stable view of usage over
	// time that is useful for adaptive concurrency control and capacity
	// planning decisions.
	UtilizationSmoothed() float64

	// IsEmpty reports whether the semaphore has zero slots currently
	// held (Len == 0). Like [Semaphore.Len], this is a snapshot and
	// may change immediately after the call returns.
	IsEmpty() bool

	// IsFull reports whether all slots are currently held (Len == Cap),
	// meaning any new [Semaphore.Acquire] call would block and any
	// [Semaphore.TryAcquire] call would return false. This is a
	// snapshot and may change immediately after the call returns.
	IsFull() bool

	// SetCap dynamically adjusts the semaphore's capacity to c. Pass -1
	// to reset to [defaultCap]; any other value less than 1 returns
	// [ErrInvalidCap]. If the new capacity is greater than or equal to
	// the current occupancy, all held slots are preserved. If smaller,
	// existing slots are drained (holders will receive
	// [ErrReleaseExceedsCount] on their next release). All blocked
	// acquires are re-evaluated after the resize.
	SetCap(c int) error
}

Semaphore is a concurrency primitive that limits the number of goroutines accessing a shared resource or performing work simultaneously. It operates as a counting semaphore backed by a channel, with support for context-aware acquisition, bulk acquire/release, dynamic capacity changes, graceful draining, and real-time utilization metrics.

Create a Semaphore with New, NewWithObserver, or Must.

Core concept

A Semaphore manages a fixed pool of slots. A goroutine claims a slot by acquiring it and returns the slot by releasing it. When all slots are held, further acquires block (or fail, for try-variants) until a slot is freed. The number of slots is the semaphore's capacity, which can be adjusted at runtime via Semaphore.SetCap.

Concurrency safety

All methods on Semaphore are safe for concurrent use from multiple goroutines. The implementation uses a combination of a mutex, condition variable, and atomic operations to coordinate access.

Method groups

The interface is organized into five groups:

## Single-slot acquisition

These methods acquire or attempt to acquire exactly one slot:

## Multi-slot (bulk) acquisition

These methods acquire or attempt to acquire n slots at once. Partial acquisitions are rolled back automatically on failure or cancellation, so the semaphore is never left in a half-acquired state:

## Waiting and draining

These methods support graceful shutdown and lifecycle management:

  • Semaphore.Wait — blocks until the semaphore is completely empty (all slots released) or the context is cancelled. Useful for waiting on in-flight work before shutting down.
  • Semaphore.Drain — forcibly removes all held slots from the internal channel. Goroutines that previously acquired slots will receive ErrReleaseExceedsCount on their next Release call. Prefer calling Wait first to let in-flight work finish gracefully.
  • Semaphore.Reset — replaces the internal channel with a fresh one of the same capacity, discarding all held slots. Same caveats as Drain regarding in-flight goroutines.

## Introspection

These methods provide read-only snapshots of the semaphore's state. Because the semaphore is concurrent, returned values may be stale by the time the caller acts on them — use them for monitoring, logging, and heuristics rather than synchronization decisions:

  • Semaphore.Len — number of slots currently held.
  • Semaphore.Cap — total capacity (maximum concurrent slots).
  • Semaphore.Utilization — instantaneous utilization as a float64 in [0.0, 1.0], computed as Len / Cap.
  • Semaphore.UtilizationSmoothed — exponentially weighted moving average of utilization, updated on each Release/ReleaseN call. Provides a smoothed view of usage over time for adaptive concurrency control or capacity planning.
  • Semaphore.IsEmpty — true when no slots are held (Len == 0).
  • Semaphore.IsFull — true when all slots are held (Len == Cap).

## Dynamic capacity

  • Semaphore.SetCap — adjusts the semaphore's capacity at runtime. Pass -1 to reset to [defaultCap]. If the new capacity is smaller than the current occupancy, existing slots are drained. All blocked acquires are re-evaluated after a capacity change.

Acquire/release pairing

Every successful acquire (Acquire, AcquireWith, TryAcquire, AcquireN, etc.) must be balanced by a corresponding release (Release or ReleaseN). The idiomatic pattern uses defer:

sem.Acquire()
defer sem.Release()
// ... guarded work ...

For bulk operations:

if err := sem.AcquireN(5); err != nil {
    return err
}
defer sem.ReleaseN(5)
// ... batch work using 5 slots ...

Failing to release a slot permanently reduces the effective capacity of the semaphore. Releasing more slots than were acquired returns ErrReleaseExceedsCount.

Graceful shutdown pattern

A typical shutdown sequence stops accepting new work, waits for in-flight operations to complete, then drains any stragglers:

// 1. Stop dispatching new work (application-specific).
close(stopCh)

// 2. Wait for in-flight work to release its slots.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := sem.Wait(ctx); err != nil {
    log.Printf("timed out waiting for drain: %v", err)
    // 3. Force-drain remaining slots.
    _ = sem.Drain()
}

Adaptive concurrency example

The smoothed utilization metric can drive dynamic capacity adjustments:

go func() {
    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()
    for range ticker.C {
        u := sem.UtilizationSmoothed()
        switch {
        case u > 0.9:
            _ = sem.SetCap(sem.Cap() * 2) // scale up
        case u < 0.3 && sem.Cap() > minCap:
            _ = sem.SetCap(sem.Cap() / 2) // scale down
        }
    }
}()

HTTP middleware example

Semaphore works well as a request-level concurrency limiter:

var reqSem = sema.Must(100)

func LimitMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if err := reqSem.AcquireWith(r.Context()); err != nil {
            http.Error(w, "service busy", http.StatusServiceUnavailable)
            return
        }
        defer reqSem.Release()
        next.ServeHTTP(w, r)
    })
}

Worker pool example

func processItems(ctx context.Context, items []Item) error {
    sem := sema.Must(10)
    var g errgroup.Group

    for _, item := range items {
        item := item
        if err := sem.AcquireWith(ctx); err != nil {
            return err
        }
        g.Go(func() error {
            defer sem.Release()
            return process(item)
        })
    }

    return g.Wait()
}

func Must added in v1.1.0

func Must(c int) Semaphore

Must is a convenience wrapper around New that panics instead of returning an error. It is intended for use in package-level variable declarations and program initialization where an invalid capacity is a programmer error that should be caught immediately.

Must panics with a message prefixed by "sema.Must:" if c is invalid. The same capacity rules as New apply: pass a positive integer for an explicit capacity, or -1 to use [defaultCap].

When to use Must vs New

Use Must when the capacity is a compile-time constant or derived from a trusted configuration value that has already been validated. Use New when the capacity comes from user input, a dynamic configuration source, or any context where a graceful error is preferable to a panic.

Examples

Package-level semaphore with a fixed capacity:

var dbPool = sema.Must(25)

func QueryDB(ctx context.Context, q string) (*Result, error) {
    if err := dbPool.AcquireWith(ctx); err != nil {
        return nil, fmt.Errorf("pool full: %w", err)
    }
    defer dbPool.Release()
    return execQuery(q)
}

Using the default capacity:

var workers = sema.Must(-1)

Panics on invalid input (useful for catching bugs early):

var bad = sema.Must(0) // panics: "sema.Must: sema: capacity must be >= 1 or -1 for default, got 0"

func New

func New(c int) (Semaphore, error)

New creates a new Semaphore with the given capacity c, which defines the maximum number of slots that may be held concurrently. The capacity can be changed later at runtime via [semaphore.SetCap].

The special value -1 may be passed for c to use the package-level [defaultCap]. Any other value less than 1 returns ErrInvalidCap.

The returned Semaphore starts empty (zero slots held) and is immediately ready for use. All methods on the returned value are safe for concurrent use from multiple goroutines.

Capacity semantics

The capacity represents the upper bound on concurrent access. When all c slots are held, calls to [semaphore.Acquire] block and calls to [semaphore.TryAcquire] return false until a slot is freed by [semaphore.Release].

Observability

The semaphore returned by New has no observer attached. Use NewWithObserver to attach an Observer that receives callbacks on acquire, release, and wait events.

Examples

Creating a semaphore with an explicit capacity:

sem, err := sema.New(10)
if err != nil {
    log.Fatalf("failed to create semaphore: %v", err)
}
sem.Acquire()
defer sem.Release()

Using the default capacity:

sem, err := sema.New(-1)
if err != nil {
    log.Fatalf("failed to create semaphore: %v", err)
}
fmt.Printf("default capacity: %d\n", sem.Cap())

Handling invalid input:

sem, err := sema.New(0)
if err != nil {
    // err is ErrInvalidCap{Value: 0}
    var capErr sema.ErrInvalidCap
    if errors.As(err, &capErr) {
        fmt.Printf("invalid capacity: %d\n", capErr.Value)
    }
}

func NewWithObserver added in v1.1.0

func NewWithObserver(c int, obs Observer) (Semaphore, error)

NewWithObserver creates a new Semaphore with the given capacity and attaches an Observer that receives lifecycle callbacks. It is otherwise identical to New — the same capacity rules, error conditions, and concurrency guarantees apply.

The observer is called synchronously under the semaphore's internal lock for acquire and release events, so observer implementations should be fast and non-blocking to avoid degrading throughput.

Observer callbacks

The attached Observer receives the following notifications:

Examples

Attaching a metrics observer:

type metricsObserver struct{}

func (m metricsObserver) OnAcquire(len, cap int) {
    metrics.Gauge("semaphore.utilization").Set(float64(len) / float64(cap))
}
func (m metricsObserver) OnRelease(len, cap int) {
    metrics.Gauge("semaphore.utilization").Set(float64(len) / float64(cap))
}
func (m metricsObserver) OnWaitStart()        {}
func (m metricsObserver) OnWaitEnd(err error)  {}

sem, err := sema.NewWithObserver(50, metricsObserver{})
if err != nil {
    log.Fatalf("failed to create semaphore: %v", err)
}

Attaching a logging observer for debugging:

type debugObserver struct{ logger *log.Logger }

func (d debugObserver) OnAcquire(len, cap int) {
    d.logger.Printf("acquired: %d/%d slots in use", len, cap)
}
func (d debugObserver) OnRelease(len, cap int) {
    d.logger.Printf("released: %d/%d slots in use", len, cap)
}
func (d debugObserver) OnWaitStart()       { d.logger.Println("wait started") }
func (d debugObserver) OnWaitEnd(err error) { d.logger.Printf("wait ended: %v", err) }

sem, err := sema.NewWithObserver(5, debugObserver{logger: log.Default()})
if err != nil {
    log.Fatalf("failed to create semaphore: %v", err)
}

Jump to

Keyboard shortcuts

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