Advanced Concurrency Basics
10 examples to get you started with Advanced Concurrency - 7 basic and 3 intermediate.
Prerequisites
- Go 1.26.x or later and a module:
go mod init example.com/advconc. - Install extensions:
go get golang.org/x/sync/errgroup golang.org/x/time/rate. - Run examples with
go run .from each snippet's package directory. - Familiarity with goroutines and channels from Concurrency Basics helps.
Basic Examples
1. Fixed worker pool with job channel
Spin up N workers that read jobs until the channel closes.
package main
import (
"fmt"
"sync"
)
func worker(id int, jobs <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
fmt.Printf("worker %d job %d\n", id, j)
}
}
func main() {
const workers = 3
jobs := make(chan int, 10)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go worker(i, jobs, &wg)
}
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
wg.Wait()
}close(jobs)signals workers to exit after draining.- Buffer size decouples producers briefly; zero buffer synchronizes each send.
WaitGroupwaits for workers beforemainreturns.
Related: Worker Pools & Task Queues - sizing and shutdown patterns
2. Semaphore with buffered channel
Limit in-flight goroutines without a dedicated worker set.
package main
import (
"fmt"
"sync"
)
func main() {
sem := make(chan struct{}, 2) // max 2 concurrent
var wg sync.WaitGroup
for i := 0; i < 6; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
fmt.Println("task", n)
}(i)
}
wg.Wait()
}- Send acquires a slot; receive releases it.
- Capacity equals max parallelism - a idiomatic counting semaphore.
- Prefer
golang.org/x/sync/semaphorewhen you needAcquire(ctx)with cancellation.
Related: Rate Limiting & Backpressure - token buckets and shedding
3. errgroup with parallel tasks
Run related goroutines; first error cancels the rest.
package main
import (
"context"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
func main() {
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error {
return fmt.Errorf("task a failed")
})
g.Go(func() error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
return nil
}
})
if err := g.Wait(); err != nil {
fmt.Println("errgroup:", err)
}
}WithContextcancelsctxwhen anyGoreturns non-nil error.- Always respect
ctxin long-runningGofunctions. Waitblocks until all goroutines finish and returns the first error.
Related: errgroup & golang.org/x/sync Extensions - SetLimit and patterns
4. Two-stage pipeline
One goroutine produces, another consumes through a channel.
package main
import (
"fmt"
)
func main() {
out := make(chan int)
go func() {
defer close(out)
for i := 1; i <= 3; i++ {
out <- i * 10
}
}()
for v := range out {
fmt.Println(v)
}
}- Producer owns the channel and must
closeit when done. rangeover channel receives until close.- Add buffer between stages to reduce blocking when rates differ.
Related: Fan-In, Fan-Out & Pipeline Stages - multi-stage graphs
5. Fan-out with WaitGroup
Distribute items to workers, wait for completion.
package main
import (
"fmt"
"sync"
)
func main() {
items := []int{1, 2, 3, 4}
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(v int) {
defer wg.Done()
fmt.Println("process", v)
}(item)
}
wg.Wait()
}- Capture loop variable in the closure parameter to avoid the classic loop-variable bug.
- Unbounded fan-out is fine for tiny N; use a pool for large batches.
- Errors need a separate aggregation strategy (errgroup or error channel).
Related: Production Concurrency: Patterns Beyond Hello Goroutine - when to bound fan-out
6. Context-cancelled worker loop
Stop workers when context is done.
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Println("worker", id, "stop")
return
default:
fmt.Println("worker", id, "tick")
time.Sleep(200 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
go worker(ctx, 1)
<-ctx.Done()
time.Sleep(100 * time.Millisecond)
}selectonctx.Done()is mandatory in production loops.WithTimeoutderives a deadline from the parent context.- Pair with
WaitGroupwhen multiple workers must exit before proceeding.
Related: context Basics - WithCancel and deadlines
7. Atomic counter increment
Lock-free counter for metrics and IDs.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var n atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
n.Add(1)
}()
}
wg.Wait()
fmt.Println("count", n.Load())
}atomic.Int64is typed and clearer than rawint64withAddInt64.- Use atomics for simple counters; use mutex for multi-field structs.
- Run
go test -raceif you mix atomics with non-atomic reads.
Related: sync/atomic Operations - CAS and when atomics win
Intermediate Examples
8. Worker pool with context shutdown
Graceful stop: cancel context, close jobs, wait for workers.
package main
import (
"context"
"fmt"
"sync"
"time"
)
func pool(ctx context.Context, jobs <-chan int, n int) *sync.WaitGroup {
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
fmt.Println(id, "handled", j)
}
}
}(i)
}
return &wg
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
jobs := make(chan int, 4)
wg := pool(ctx, jobs, 2)
for j := 1; j <= 4; j++ {
jobs <- j
}
close(jobs)
cancel()
wg.Wait()
}- Combined
ctx.Done()and!okon jobs covers both cancel and drain paths. - Close the job channel only after producers finish sending.
wg.Wait()before exit confirms workers stopped.
Related: Avoiding Goroutine Leaks & Channel Deadlocks - ownership rules
9. errgroup parallel HTTP-style fetches
Simulate parallel I/O with shared cancel on first failure.
package main
import (
"context"
"errors"
"fmt"
"golang.org/x/sync/errgroup"
)
func fetch(ctx context.Context, name string, fail bool) error {
if fail {
return fmt.Errorf("%s unavailable", name)
}
select {
case <-ctx.Done():
return ctx.Err()
default:
fmt.Println("ok", name)
return nil
}
}
func main() {
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error { return fetch(ctx, "users", false) })
g.Go(func() error { return fetch(ctx, "billing", true) })
if err := g.Wait(); err != nil {
fmt.Println("abort:", err)
}
}- First failing fetch cancels
ctxfor siblings still running. - Map this pattern to
http.NewRequestWithContextin real services. - Use
g.SetLimit(n)when fan-out must respect downstream quotas.
Related: Cancellation Propagation in HTTP Handlers - request-scoped ctx
10. Rate-limited producer
Token bucket limits how fast jobs enter the pool.
package main
import (
"context"
"fmt"
"time"
"golang.org/x/time/rate"
)
func main() {
lim := rate.NewLimiter(rate.Every(100*time.Millisecond), 1)
ctx := context.Background()
for i := 1; i <= 5; i++ {
if err := lim.Wait(ctx); err != nil {
panic(err)
}
fmt.Println("emit", i, time.Now().Format("15:04:05.000"))
}
}Every(d)sets steady rate; burst allows short spikes.Waitblocks;Allowis non-blocking for shed-load paths.- Pass request
ctxintoWaitso cancel stops a throttled producer.
Related: Rate Limiting & Backpressure - semaphores vs token bucket
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).