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,Acquirereturnsctx.Err()and leaves the semaphore unchanged. rate.Limiter.Waitfails early and with its own error. If the context has a deadline and the wait needed would pass it,Waitreturns at once with an error like "rate: Wait(n=1) would exceed context deadline". That error is notcontext.DeadlineExceeded, soerrors.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
nlarger than the semaphore's total size can never succeed. It just waits untilctxis done, so a context without a deadline hangs forever.
More on Context
- Q291What does this print? (Cause across WithoutCancel)
- Q292How do you make a range-over-func iterator (iter.Seq) respect context cancellation, and why is it better than a channel-based generator?