Go

How do semaphore.Weighted and rate.Limiter use context, and what subtle bugs appear with them?

Question 293HardGo 1.22 to 1.25

Both packages in golang.org/x use a context to make waiting cancelable: (*semaphore.Weighted).Acquire(ctx, n) and (*rate.Limiter).Wait(ctx) block until a slot or token is available, or until ctx is done.

type Svc struct {
	sem *semaphore.Weighted // e.g. semaphore.NewWeighted(10)
	lim *rate.Limiter       // e.g. rate.NewLimiter(50, 10): 50 req/s, burst 10
}

func (s *Svc) Call(ctx context.Context, req Req) error {
	// Wait for a rate token before taking a concurrency slot,
	// so the slot isn't held while sleeping.
	if err := s.lim.Wait(ctx); err != nil {
		return fmt.Errorf("rate limit: %w", err)
	}
	if err := s.sem.Acquire(ctx, 1); err != nil {
		return err // ctx.Err(); semaphore unchanged
	}
	defer s.sem.Release(1) // only after a successful Acquire
	return s.do(ctx, req)
}

Subtle bugs:

  • Releasing after a failed Acquire. If you put defer s.sem.Release(1) before checking the error, a canceled Acquire leads to a panic: "semaphore: released more than held". On failure, Acquire returns ctx.Err() and leaves the semaphore unchanged.
  • rate.Limiter.Wait fails early and with its own error. If the context has a deadline and the wait needed would pass it, Wait returns at once with an error like "rate: Wait(n=1) would exceed context deadline". That error is not context.DeadlineExceeded, so errors.Is(err, context.DeadlineExceeded) is false. Classify it separately in metrics.
  • Wrong order. Taking the semaphore first and then waiting on the limiter holds a scarce slot while sleeping, which lowers throughput.
  • Acquiring n larger than the semaphore's total size can never succeed. It just waits until ctx is done, so a context without a deadline hangs forever.

More on Context

All 35 Context questions