The Best Go Libraries for Concurrency Primitives Compared
A practical comparison of Go concurrency libraries: x/sync, x/time, conc, ants, goleak, gobreaker and go-deadlock, with a runnable x/sync example.

Go's standard library covers a surprising amount of concurrency, and the ecosystem around it fills the gaps in a few well-known places. Questions like "which library should I use for a worker pool" tend to get answers that depend more on the answerer's habits than on a comparison. This article tries to be a comparison. It describes what each widely used library is for, what it costs in dependencies and in concepts, and when plain Go is the better choice.
Tested with Go 1.25.6, golang.org/x/sync v0.19.0 run; the other libraries were read from their documentation and not run here
One disclosure first, in the spirit of the site's rules about evidence. I ran golang.org/x/sync in this article, because it is maintained by the Go team and is a safe dependency to pull into a lab. I did not run the third-party libraries; what I say about them comes from their documentation and READMEs, which I link, and you should check the current version before depending on any claim about behavior. The article belongs to the concurrency patterns cluster, and each pattern mentioned has a full treatment elsewhere on the site.
How to choose
Four questions decide whether a library earns its place:
- Does the standard library already do it?
sync,sync/atomic,context,timeand channels cover locks, once, pools, timers and cancellation. A dependency that wraps them thinly saves nothing. - Who maintains it?
golang.org/x/...packages come from the Go team and follow the same compatibility culture. A popular package with one maintainer and no releases in two years is a risk. - What does it cost in concepts? A library that adds its own pool type, its own error model and its own context handling asks every reader of your code to learn it.
- Can you delete it later? The better the library maps onto an idiom the language already has, the cheaper it is to remove.
The ones to know first: golang.org/x
golang.org/x/sync is four small packages and the first dependency I add to a concurrent project.
errgroupruns functions in goroutines, returns the first error and cancels a shared context;SetLimitbounds concurrency (errgroup, SetLimit).semaphoreprovides a weighted semaphore, for limits where tasks cost different amounts (semaphores).singleflightcollapses concurrent calls for the same key into one (singleflight).syncmapis a historical package; usesync.Map.
golang.org/x/time/rate is a token-bucket rate limiter with Allow, Wait and Reserve, and it is the standard answer for client-side and server-side rate limiting (the token bucket, rate limiting). It is a few hundred lines you could write yourself and should not.
The three x/sync packages combine well. This runnable example is a lookup service: singleflight makes concurrent requests for the same key share one backend call, a weighted semaphore limits the total number of backend calls in flight, and an errgroup runs a batch and gathers the first error:
package main
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/semaphore"
"golang.org/x/sync/singleflight"
)
type Backend struct {
calls atomic.Int32
peak atomic.Int32
live atomic.Int32
}
func (b *Backend) Lookup(key string) string {
b.calls.Add(1)
n := b.live.Add(1)
for {
p := b.peak.Load()
if n <= p || b.peak.CompareAndSwap(p, n) {
break
}
}
time.Sleep(30 * time.Millisecond)
b.live.Add(-1)
return "value-of-" + key
}
type Service struct {
backend *Backend
flight singleflight.Group
limit *semaphore.Weighted
}
func (s *Service) Get(ctx context.Context, key string) (string, error) {
v, err, _ := s.flight.Do(key, func() (any, error) { // concurrent callers for key share this call
if err := s.limit.Acquire(ctx, 1); err != nil {
return nil, err
}
defer s.limit.Release(1)
return s.backend.Lookup(key), nil
})
if err != nil {
return "", err
}
return v.(string), nil
}
func main() {
svc := &Service{backend: &Backend{}, limit: semaphore.NewWeighted(3)}
// 40 callers, but only 10 distinct keys: singleflight turns 40 lookups into 10.
var g errgroup.Group
var mu sync.Mutex
got := map[string]string{}
for i := range 40 {
g.Go(func() error {
key := fmt.Sprintf("k%d", i%10)
v, err := svc.Get(context.Background(), key)
if err != nil {
return err
}
mu.Lock()
got[key] = v
mu.Unlock()
return nil
})
}
if err := g.Wait(); err != nil {
panic(err)
}
fmt.Println("distinct results:", len(got))
fmt.Println("backend calls for 40 requests over 10 keys:", svc.backend.calls.Load())
fmt.Println("most backend calls in flight at once:", svc.backend.peak.Load(), "(limit 3)")
}distinct results: 10
backend calls for 40 requests over 10 keys: 10
most backend calls in flight at once: 3 (limit 3)Forty callers over ten keys made exactly ten backend calls here, and never more than three were in flight at once. (A caller that arrives after a key's call has finished starts a new one, so the count can be higher on a slower machine.) Three small packages, about thirty lines of glue, and no bespoke concurrency code. That is the benchmark for any other library: does it make something like this shorter or safer?
Worker pools and structured concurrency
github.com/sourcegraph/conc (docs) packages structured-concurrency helpers: a WaitGroup that propagates panics to the waiter, a pool package with bounded pools that can return results and errors, an iter package for parallel map and for-each over slices, and a stream package that runs tasks concurrently and delivers results in order. The attraction is that it does the boring parts that people get wrong: recovering a panic in a goroutine and re-panicking in the caller with the original stack, keeping results in input order, and giving a typed generic API. If you write the same errgroup plus indexed-results code in many places, conc removes the repetition. If you write it twice, the standard tools are fine. The cost is a dependency and a second vocabulary for pools.
github.com/panjf2000/ants (docs) is a goroutine pool: it keeps worker goroutines alive and reuses them for submitted functions, with options for pool size, expiry and non-blocking submission. The pitch is avoiding goroutine creation cost. In Go that cost is around a microsecond, so a pool pays off only at very high task rates with tiny tasks, and the bounded parallelism benchmark shows a hand-written pool is already the cheapest of the four standard patterns. Ants is a reasonable choice when you need its specific features (a shared pool across the program, dynamic resizing, panic handlers) and not when "worker pool" is the whole requirement.
Related: golang.org/x/sync/errgroup with SetLimit already replaces most pool libraries for finite batches, and a loop over a jobs channel replaces them for streams. Check that before adding either.
Testing aids
go.uber.org/goleak (docs) fails a test if it leaves goroutines running. goleak.VerifyNone(t) at the end of a test, or goleak.VerifyTestMain(m) for a whole package, is one line and catches the bug that no other tool reports (goleak). It has options to ignore known background goroutines. I would add it to every package that starts a goroutine.
Fake clocks such as github.com/jonboulle/clockwork and github.com/benbjohnson/clock provide controllable time. Since Go 1.25, testing/synctest covers many of the same cases with no interface changes (synctest), so the case for a library is mostly projects that need to support older Go versions.
Resilience
github.com/sony/gobreaker (docs) is a circuit breaker with the classic three states, closed, open and half-open, and configurable thresholds. Write your own only to learn (circuit breakers); use a library in production because the edge cases (half-open probe concurrency, rolling windows) are fiddly. For retries with backoff, github.com/cenkalti/backoff is the long-standing choice, though a dozen lines of loop do the same for simple cases (retries).
Debugging aids
github.com/sasha-s/go-deadlock (docs) is a drop-in replacement for sync.Mutex and sync.RWMutex that reports lock-order inversions and locks held longer than a timeout, printing the stacks involved. It is a development and staging tool; the replacement is slower and I would not ship it in a hot path. Use it when you have a deadlock you cannot find from a goroutine dump (goroutine dumps).
Versions, licenses and upkeep
Before adopting any of these, spend five minutes on the same checks. Read the module's go.mod for the minimum Go version it requires, because a library that needs a newer toolchain than your fleet runs will fail at build time. Check the license against your policy; the libraries above use permissive licenses (BSD, MIT, Apache 2.0), which is typical but worth confirming. Look at the date of the last release and the open issue count, and read the issues titled "panic" or "leak" before you read the README. And pin the version in go.mod: a concurrency library that changes behavior in a minor release changes your program's timing, which is a hard thing to review. The x/sync module is a good example of the opposite, a small surface with stable semantics, and it is why I treat it as part of the toolbox instead of a dependency.
The same discipline applies in the other direction. If a library you already use provides a feature that the standard library has since gained, such as an atomic wrapper type or a signal-handling helper, schedule the removal. Concurrency code has enough moving parts without a second implementation of the same idea.
What to skip
A few categories attract dependencies that rarely pay off. Wrappers around sync.WaitGroup that add nothing the standard type lacks. Generic "concurrent map" packages when sync.Map or a locked map would do (safe maps). Atomic wrapper libraries from before Go 1.19, because sync/atomic now has typed values. Pub/sub or event-bus packages for in-process use, where a channel or a small broker is easier to reason about (event buses). And any library that hides goroutines behind an API without a way to stop them.
A short recommendation
For a new project: standard library, golang.org/x/sync, golang.org/x/time/rate, goleak in tests. Add conc if panic propagation and ordered results become a repeated chore. Add a circuit breaker library when you call something unreliable. Reach for the rest when a profile or an incident gives you a reason. The package documentation is the place to confirm current behavior: errgroup, semaphore, singleflight and rate.


