BeeTool.Dev - Essential Tools for developers

Stories.Tool Tabs VaultPersonal Documents
BeeTool.Dev

Essential tools, knowledge, and playgrounds for developers.

Explore

  • Stories
  • Personal Documents
  • Features
  • Beetool for Mac
  • Algorithms Playground

Tool Tabs

  • JSON Compare
  • SQL Formatter
  • Text Diff
  • All tools →

Learn

  • Sorting algorithms
  • Searching algorithms
  • Algorithm Race Mode
© 2024 – 2026 BeeTool.Dev
AboutContactPrivacy policyTerms
All stories
GolangDesign Pattern

Advanced Concurrency Patterns in Go

DUKE320 viewsJuly 21, 2026
Advanced Concurrency Patterns in Go

Goroutines and channels are the raw materials; concurrency patterns are the recipes experienced Go programmers reach for again and again. In this article we'll walk through seven patterns — generator, fan-in, fan-out, pipeline, worker pool, queuing, and semaphore — then look at how to make each of them cancelable and production-ready.

Here's a roadmap of when to reach for each one: use a generator when you need to produce a lazy, on-demand sequence of values; fan-in when several independent producers' results need merging into a single stream; fan-out when one stream of incoming work needs to be spread across several concurrent consumers; pipeline when the work naturally decomposes into sequential transformation stages; worker pool when you have many homogeneous jobs and want a fixed number of goroutines processing them; queuing when you need to buffer work and decouple a fast producer from a slower consumer, with backpressure kicking in once the buffer fills; and semaphore when you need to cap how many goroutines may enter some region of code at once, independent of how the work itself is structured.

In real systems these patterns rarely show up alone — they get chained together, one pattern's output channel feeding the next pattern's input channel:

Generator → Queue → Fan-out / Worker Pool → Processing Pipeline → Fan-in / Aggregator → Result

Read that chain as a story, with a concrete mission: import one million orders from a CSV file into the database before tomorrow's launch.

The story starts at the generator — a goroutine opens the CSV file and reads it line by line, emitting each raw order into a channel. It never loads all million lines into memory; it only reads as fast as the stages behind it can consume. Right behind it sits the queue — a 1,000-slot buffered channel acting as the pace-setter: when the disk reads faster than the database writes, the buffer fills up and the generator is automatically slowed down (backpressure) instead of drowning everything downstream; first-come-first-served ordering is preserved. From the queue, a worker pool of 20 workers pulls orders and spreads the load — 20 orders in flight at once, no more, because that's all the connections the database can take. In each worker's hands, an order flows through the pipeline: parse the CSV line into a struct → validate product codes and amounts → enrich with customer data from a cache. Once processed, results from all 20 workers are gathered by fan-in into a single channel, and at the very end stands the aggregator — a batcher that collects 500 orders (or waits at most 500ms) and fires one batch insert into the database, instead of a million individual INSERTs. Next morning: one million orders sit safely in the database, and the program's memory never held more than a few thousand orders in flight.

Go implements every stage of that chain with the same small toolkit: goroutines, channels, context, sync.WaitGroup, and errgroup.

Generator

go-concurrency-advanced-generator.png

A generator is a function that returns a channel and produces a sequence of values on demand, similar to yield in JavaScript or Python. It works because channel sends and receives block until both sides are ready: the goroutine inside the generator sits parked on ch <- i until something reads from ch.

func main() {
ch := generator()

for i := 0; i < 5; i++ {
value := <-ch
fmt.Println("Value:", value)
}
}

func generator() <-chan int {
ch := make(chan int)

go func() {
for i := 0; ; i++ {
ch <- i
}
}()

return ch
}
$ go run main.go
Value: 0
Value: 1
Value: 2
Value: 3
Value: 4

The main function pulls exactly five values off the channel and then simply stops reading. Notice the return type <-chan int — the arrow before chan makes this a receive-only channel from the caller's perspective, which is a nice piece of self-documentation: callers of generator() can read from it but can't accidentally send or close it.

⚠️ Goroutine leak: as written, this generator leaks its producing goroutine. The for i := 0; ; i++ loop runs forever, and once main stops calling <-ch after the fifth value, the goroutine blocks forever on ch <- i with nobody left to receive. It's never garbage collected — it just sits there, permanently parked, for the lifetime of the program. In a short-lived main this is harmless, but inside a long-running service that creates generators repeatedly, each one becomes a permanent leak. The fix is to give the goroutine a way to know when to stop, typically a done channel or a context.Context, and to select between sending a value and being told to quit. See "Cancellation with done channels and context" under Cross-cutting concerns below for the corrected version.

Use case: paginated API iterator

The same shape scales to a real source of data: an HTTP API that paginates its results. The generator fetches one page at a time and only calls the API again once the caller has consumed everything from the previous page — the consumer sets the pace, and the API is never asked for more than is actually needed.

func fetchPages(ctx context.Context, url string) <-chan Item {
out := make(chan Item)

go func() {
defer close(out)

for page := 1; ; page++ {
items, hasMore, err := fetchPage(ctx, url, page)
if err != nil {
return
}
for _, item := range items {
select {
case out <- item:
case <-ctx.Done():
return
}
}
if !hasMore {
return
}
}
}()

return out
}

Callers just range over the result: for item := range fetchPages(ctx, url) { ... }, oblivious to pagination entirely. The select against ctx.Done() is what keeps this version safe from the goroutine leak described above — if the caller stops ranging early, cancelling ctx unblocks the send and lets the goroutine return.

More use cases

  • Reading records from a database cursor one at a time
  • Reading lines out of a large CSV file without loading it all into memory
  • Paginating through an API and emitting each item as it arrives
  • Generating the list of URLs a crawler should visit
  • Converting a stream of Kafka messages into an internal Go channel
  • Producing jobs to feed into a worker pool

Tooling

Most generators need no library at all — a goroutine, a channel, and a context.Context cover the vast majority of cases. For heavier stream-processing needs, github.com/reugn/go-streams gives you a stage-based stream-processing framework, and github.com/ReactiveX/RxGo/v2 brings Observables with Map/Filter/FlatMap operators. Whichever you use, remember the reminder from earlier in this section: a generator should always accept a context.Context — if the downstream consumer stops reading, the sending goroutine leaks unless it has a cancellation signal to select against.

Code review: spotting it and avoiding overuse

How to spot it: a function returning <-chan T with an internal goroutine sending in a loop and (hopefully) defer close.

Review checklist:

  • Does it accept a context.Context and select on ctx.Done() when sending?
  • Who closes the channel?
  • What happens if the consumer stops ranging early — is there a leak path?

When NOT to use it: for small, finite, already-in-memory data just return a []T — spinning up a goroutine and a channel to iterate a 10-element slice adds overhead and a leak risk for nothing. Since Go 1.23, iter.Seq covers lazy iteration without any goroutine at all.

// no channel needed
func evens(nums []int) []int {
var out []int
for _, n := range nums {
if n%2 == 0 {
out = append(out, n)
}
}
return out
}

Fan-in

go-concurrency-pattern-fan-in.pngFan-in takes multiple input channels and multiplexes them into a single output channel. It's the pattern you reach for when several independent producers need their results merged into one stream for a single consumer to process. Because the underlying goroutines race to send, the order values appear in the output channel is not guaranteed — don't rely on it.

func main() {
i1 := generateWork([]int{0, 2, 6, 8})
i2 := generateWork([]int{1, 3, 5, 7})

out := fanIn(i1, i2)

for value := range out {
fmt.Println("Value:", value)
}
}

func fanIn(inputs ...<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)

wg.Add(len(inputs))

for _, in := range inputs {
go func(ch <-chan int) {
for {
value, ok := <-ch

if !ok {
wg.Done()
break
}

out <- value
}
}(in)
}

go func() {
wg.Wait()
close(out)
}()

return out
}

func generateWork(work []int) <-chan int {
ch := make(chan int)

go func() {
defer close(ch)

for _, w := range work {
ch <- w
}
}()

return ch
}

Walking through this: generateWork spins up a goroutine per input slice that sends each element and then closes its channel with defer close(ch) when it's done — that closed channel is the signal fan-in relies on. Inside fanIn, one goroutine is launched per input channel; each drains its channel in a loop, forwarding every value onto the shared out channel, and calls wg.Done() the moment its source channel reports ok == false (i.e., closed and drained). A separate goroutine waits on the WaitGroup and closes out only once every input goroutine has finished, which is what lets the for value := range out loop in main terminate cleanly instead of blocking forever.

❌ Deadlock risk if you forget to close out: the whole reason fanIn starts that extra "wait and close" goroutine is so out gets closed automatically. If you strip that goroutine out and never close out, the range out loop in main will block forever after the last value is received — the program hangs with no panic, no error, nothing to grep for in the logs. Any time you write a fan-in (or fan-out, or pipeline stage), ask yourself explicitly: "who closes this output channel, and under what condition?" If the answer is "nobody," you have a deadlock waiting to happen.

Use case: querying multiple replicas

A common production scenario: the same search needs to go out to several backend replicas at once, and you want to react to results as they come back rather than waiting for the slowest one. Fan-in each replica's response into a shared channel and consume as they arrive.

func queryReplicas(ctx context.Context, query string, replicas []string) <-chan Result {
var wg sync.WaitGroup
out := make(chan Result, len(replicas)) // buffered so late replies never block

for _, addr := range replicas {
wg.Add(1)
go func(addr string) {
defer wg.Done()
if res, err := search(ctx, addr, query); err == nil {
out <- res
}
}(addr)
}

go func() {
wg.Wait()
close(out)
}()

return out
}

A caller that only cares about the fastest answer can take the very first value — first := <-queryReplicas(ctx, q, replicas) — and let the rest arrive into the buffer unread; a caller that wants every replica's answer just ranges over the channel like the basic fan-in example.

More use cases

  • Gathering results from several backend APIs into one response
  • Collecting the outputs of file-processing workers
  • Merging several Kafka consumer streams into one
  • Aggregating search results pulled from multiple sources
  • Collecting metrics or logs emitted by many goroutines
  • Combining per-partition results into one final result

Tooling

Most fan-ins need nothing more than a channel plus a sync.WaitGroup, exactly as shown above. When errors need to propagate — if any producer fails, the whole operation should fail — errgroup.WithContext is usually a better fit than a bare WaitGroup, since it gives you both the join point and cancellation in one package. Fan-in is also worth remembering as fan-out's usual partner: the two together are the "scatter-gather" pattern covered later in this article.

Code review: spotting it and avoiding overuse

How to spot it: multiple <-chan parameters merged into one out channel, a sync.WaitGroup, and a separate "wait then close(out)" goroutine.

Review checklist:

  • Who closes out, and under what condition?
  • Are ALL input channels guaranteed to close — otherwise the merging goroutines leak forever?
  • Does any downstream code rely on ordering? Fan-in destroys it.

When NOT to use it: for a small, fixed number of results to collect, an errgroup writing into distinct result variables (or a mutex-protected slice) is simpler and keeps concrete types — don't build a channel merge just to gather 3 known values.

Fan-out

go-advance-pattern-fan-out.pngFan-out is the mirror image of fan-in: instead of merging multiple inputs into one, it splits one input channel across multiple output channels so several consumers can pull work concurrently. This is subtly different from a classic publish/subscribe system — in pub/sub every subscriber gets a copy of every message, whereas in fan-out each value is delivered to exactly one of the output channels, whichever consumer happens to be ready first. That's what makes fan-out useful for distributing work rather than broadcasting events.

func main() {
work := []int{1, 2, 3, 4, 5, 6, 7, 8}
in := generateWork(work)

out1 := fanOut(in)
out2 := fanOut(in)
out3 := fanOut(in)
out4 := fanOut(in)

for range work {
select {
case value := <-out1:
fmt.Println("Output 1 got:", value)
case value := <-out2:
fmt.Println("Output 2 got:", value)
case value := <-out3:
fmt.Println("Output 3 got:", value)
case value := <-out4:
fmt.Println("Output 4 got:", value)
}
}
}

func fanOut(in <-chan int) <-chan int {
out := make(chan int)

go func() {
defer close(out)

for data := range in {
out <- data
}
}()

return out
}

Each call to fanOut(in) starts its own goroutine reading from the same shared in channel and forwarding whatever it reads to its own private out channel. Because all four goroutines are competing to receive from in, the Go runtime effectively load-balances the eight work items across out1 through out4. In main, the select statement is what makes this fan-out useful: it blocks until whichever of the four output channels has a value ready, so main never favors one output channel over the others and never wastes time polling an empty one.

Use case: thumbnail workers

A classic fan-out job: a channel of uploaded image paths needs to be resized into thumbnails, and you want a fixed number of workers pulling from that single channel rather than one goroutine per upload.

func resizeWorkers(paths <-chan string, n int) {
var wg sync.WaitGroup
wg.Add(n)

for i := 0; i < n; i++ {
go func(id int) {
defer wg.Done()

for path := range paths {
if err := resizeThumbnail(path); err != nil {
log.Printf("worker %d: %s: %v", id, path, err)
}
}
}(i)
}

wg.Wait()
}

All n workers read from the same paths channel, so whichever worker is free next picks up the next upload — the same load-balancing-by-competition that made the fanOut example above work, just collapsed into a single function instead of one fanOut call per consumer.

More use cases

  • Resizing images in parallel
  • Sending emails or push notifications
  • Crawling a list of URLs
  • Validating records from a batch upload
  • Calling multiple external APIs
  • Parsing logs or JSON payloads
  • Processing Kafka messages in parallel
  • Hashing, compressing, or encrypting files

Fan-out fits best when the items being processed are largely independent of one another — no item's processing depends on another's result.

Tooling

The building blocks are channels and goroutines, same as above, but a handful of libraries save you from re-deriving the same boilerplate: errgroup for error propagation plus cancellation; x/sync/semaphore to cap concurrency; sourcegraph/conc for a structured-concurrency API; github.com/alitto/pond/v2 for a bounded worker pool; and panjf2000/ants/v2 for a fixed-capacity, reusable goroutine pool.

Code review: spotting it and avoiding overuse

How to spot it: several goroutines all ranging over the same input channel.

Review checklist:

  • Is the number of consumers bounded, and is that number justified by something real?
  • Does anything downstream assume ordering? Fan-out destroys it.
  • Are items truly independent — no shared mutable state without locking?

When NOT to use it: when per-item work is tiny (microseconds), the channel handoff costs more than the work itself — a plain sequential loop wins. For CPU-bound work, more workers than runtime.NumCPU() just adds scheduling overhead without adding throughput.

Pipeline

go-advance-pattern-pipeline.pngA pipeline chains a series of stages together with channels, where each stage receives values from an upstream channel, does some transformation, and sends the result downstream. The first stage — the one with no upstream input — is called the source or producer; the last stage, which only consumes and doesn't send further, is the sink or consumer. The big payoff of structuring code this way is that each stage can be modified, tested, or replaced independently, and stages become mix-and-match building blocks you can reorder or reuse across pipelines.

func main() {
in := generateWork([]int{0, 1, 2, 3, 4, 5, 6, 7, 8})

out := filter(in) // Filter odd numbers
out = square(out) // Square the input
out = half(out) // Half the input

for value := range out {
fmt.Println(value)
}
}

func filter(in <-chan int) <-chan int {
out := make(chan int)

go func() {
defer close(out)

for i := range in {
if i%2 == 0 {
out <- i
}
}
}()

return out
}

func square(in <-chan int) <-chan int {
out := make(chan int)

go func() {
defer close(out)

for i := range in {
value := math.Pow(float64(i), 2)
out <- int(value)
}
}()

return out
}

func half(in <-chan int) <-chan int {
out := make(chan int)

go func() {
defer close(out)

for i := range in {
value := i / 2
out <- value
}
}()

return out
}
$ go run main.go
0
2
8
18
32

Tracing the numbers through the pipeline explains the output: filter keeps only the even inputs (0, 2, 4, 6, 8), square raises each of those to the power of two (0, 4, 16, 36, 64), and half divides each squared value by two (0, 2, 8, 18, 32). Every stage follows the identical shape — allocate an output channel, launch a goroutine that ranges over its input and defer closes its output when the input is exhausted, and return the output channel immediately without blocking. Because every stage closes its own output the moment it's done, the closing signal cascades naturally down the pipeline: when the source closes, filter finishes ranging and closes its output, which lets square finish and close, and so on, until half's output closes and the range out loop in main exits cleanly.

Use case: log processing pipeline

The same shape wires up a realistic log-processing job: read raw lines, parse each as JSON, keep only the error-level entries, and write those out. Each stage keeps the exact signature style of filter/square/half above — only the wiring in main needs to change.

func readLines(path string) <-chan string                       { /* ... */ }
func parseJSON(in <-chan string) <-chan LogEntry { /* ... */ }
func filterErrors(in <-chan LogEntry) <-chan LogEntry { /* ... */ }
func writeSink(in <-chan LogEntry, w io.Writer) <-chan struct{} { /* ... */ }

func main() {
lines := readLines("access.log")
entries := parseJSON(lines)
errors := filterErrors(entries)
done := writeSink(errors, os.Stdout)

<-done // block until writeSink has drained everything and closed done
}

writeSink is the sink stage: it has nothing downstream to send to, so instead of returning a value channel it returns a <-chan struct{} that closes once every entry has been written — a small variation on the pattern that gives main something to block on.

More use cases

Pipeline stages read naturally as arrow chains — a few representative ones:

Data import:      Read CSV → Parse row → Validate → Transform → Batch insert DB
Image processing: Read → Decode → Resize → Compress → Upload S3
Event processing: Consume Kafka → Deserialize → Validate → Enrich → Business logic → Publish
Log processing: Read → Parse → Filter → Aggregate → Export

Tooling

The default is still a channel, a goroutine, and a context.Context per stage, exactly as built above. For more complex pipelines — ones with branching, buffering policies, or windowing — reach for go-streams or RxGo, or, once the pipeline spans more than one process, Watermill, which builds message- and event-level pipelines on top of brokers like Kafka, RabbitMQ, or PostgreSQL's Pub/Sub. As a rule of thumb: channel-based pipelines fit work that stays in one process; once a pipeline needs to span services, that's a job for Watermill or a broker instead.

Code review: spotting it and avoiding overuse

How to spot it: a chain of functions shaped func(<-chan T) <-chan U, each starting a goroutine with defer close(out), wired together in main like out = square(filter(in)).

Review checklist:

  • Does every stage close its output channel?
  • Is ctx/done plumbed through EVERY stage — one unkillable stage leaks the whole chain?
  • Which stage is the bottleneck? The chain runs at the slowest stage's speed.

When NOT to use it: this is the classic overuse case — for a simple transformation of a slice already in memory, a plain loop is shorter, faster, allocation-free, and easier to debug:

// A 3-stage channel pipeline to do this is complexity theater:
var evens []int
for _, n := range nums {
if n%2 == 0 {
evens = append(evens, n*n/2)
}
}

Pipelines pay off with streaming data, datasets too large for memory, or stages with genuinely different IO/CPU profiles.

Worker Pool

go-concurrency-advance-pattern-worker-pool.pngA worker pool decouples the number of goroutines doing work from the number of jobs to do, by fixing a small set of long-lived worker goroutines that all pull from a shared jobs channel. This avoids the "one goroutine per job" pattern, which can spawn an unbounded number of goroutines when the job count is large or unpredictable.

const totalJobs = 4
const totalWorkers = 2

func main() {
jobs := make(chan int, totalJobs)
results := make(chan int, totalJobs)

for w := 1; w <= totalWorkers; w++ {
go worker(w, jobs, results)
}

for j := 1; j <= totalJobs; j++ {
jobs <- j
}

close(jobs)

for a := 1; a <= totalJobs; a++ {
<-results
}

close(results)
}

func worker(id int, jobs <-chan int, results chan<- int) {
var wg sync.WaitGroup

for j := range jobs {
wg.Add(1)

go func(job int) {
defer wg.Done()

fmt.Printf("Worker %d started job %d\n", id, job)

result := job * 2
results <- result

fmt.Printf("Worker %d finished job %d\n", id, job)
}(j)
}

wg.Wait()
}

Here, totalWorkers goroutines are started up front, and every job — an int from 1 to totalJobs — is pushed onto the buffered jobs channel. Closing jobs after all jobs have been sent is what lets each worker's for j := range jobs loop terminate once the channel is drained, rather than blocking forever waiting for one more job. Both jobs and results are buffered to totalJobs capacity, so sends into them don't block the sender waiting for a receiver to be ready. As a starting point for sizing, totalWorkers should usually be close to runtime.NumCPU() — see "Sizing a worker pool" under Cross-cutting concerns below for the nuance between CPU-bound and IO-bound work.

Use case: sending 10,000 webhooks

Bounded HTTP fan-out is one of the most common uses of a worker pool: 10,000 webhook URLs need a POST each, but opening 10,000 simultaneous connections would hammer both sides. Fifty workers pulling from a jobs channel of URLs keeps concurrency bounded while still finishing quickly.

const totalWorkers = 50

func sendWebhooks(urls []string) []Result {
jobs := make(chan string, len(urls))
results := make(chan Result, len(urls))

for w := 0; w < totalWorkers; w++ {
go func() {
for url := range jobs {
status, err := postWebhook(url)
results <- Result{URL: url, Status: status, Err: err}
}
}()
}

for _, url := range urls {
jobs <- url
}
close(jobs)

out := make([]Result, 0, len(urls))
for range urls {
out = append(out, <-results)
}
return out
}

This is the exact same shape as the worker/jobs/results example above, just with totalJobs swapped for len(urls) and totalWorkers bumped from 2 to 50 — the pattern doesn't change, only the numbers do.

More use cases

  • Limiting requests sent to a third-party API
  • Capping the number of concurrent database queries
  • Processing uploads: resizing, transcoding, thumbnailing
  • Sending bulk email
  • Handling messages off a Kafka or RabbitMQ topic
  • Running a batch data migration
  • Crawling the web
  • CPU-intensive work such as compression or hashing
  • Running background jobs inside a long-lived service

The key insight worth internalizing: a worker pool isn't really about "going faster" — it's about resource limits. You might have 10,000 jobs to process, but only enough downstream capacity for 20 concurrent HTTP requests, 10 concurrent database queries, or 4 concurrent image encodes at once. The pool exists to protect whatever's on the other end of the work, not to maximize your own throughput.

Tooling

Library

Best fit

errgroup

A finite, known set of tasks — wait for all, cancel everyone on first error

x/sync/semaphore

You just need to cap concurrency, nothing more

pond/v2

A general-purpose bounded worker pool

ants/v2

Huge task volume — reuses goroutines instead of spawning fresh ones

sourcegraph/conc

You want a structured-concurrency API

gammazero/workerpool

A simple pool with its own internal job queue

Code review: spotting it and avoiding overuse

How to spot it: a for w := 1; w <= N loop launching identical workers that read a jobs channel and write a results channel.

Review checklist:

  • What real resource justifies N — DB connections, an API limit, CPU cores?
  • Is the jobs channel closed so workers can terminate?
  • Are results fully drained? Undrained buffered results mean stuck workers or lost data.
  • How do errors get out of the pool?

When NOT to use it: for a small, finite set of tasks, errgroup with SetLimit is five lines and handles errors plus cancellation for you — don't hand-build a pool for 5 tasks, and don't reach for pool libraries until goroutine churn is a measured problem.

Queuing

Queuing means the channel carries the work itself: jobs are the payload, and they flow through the channel from a producer goroutine to a consumer goroutine. The channel's job here is to buffer, preserve roughly FIFO ordering, and decouple how fast the producer can push work from how fast the consumer can drain it — once the buffer fills, sends block and the producer feels backpressure instead of the queue growing without bound. In code this is a chan Job (or chan Item, chan Task — anything with a real payload): the send happens in one goroutine, the receive happens in a different one.

A simple in-memory job queue looks like this: an HTTP handler enqueues work, and a pool of workers dequeues and processes it.

type Job struct {
OrderID string
Payload []byte
}

var jobs = make(chan Job, 1000) // 1000 jobs can wait before a send blocks

func handleUpload(w http.ResponseWriter, r *http.Request) {
job := Job{OrderID: r.FormValue("order_id"), Payload: readBody(r)}

select {
case jobs <- job: // enqueue
w.WriteHeader(http.StatusAccepted)
case <-r.Context().Done():
http.Error(w, "client gone", http.StatusRequestTimeout)
}
}

func startWorkers(n int) {
for i := 0; i < n; i++ {
go func() {
for job := range jobs { // dequeue
process(job)
}
}()
}
}

handleUpload is the producer and the goroutines started by startWorkers are the consumers; the buffered channel between them is doing the actual work of queuing. It smooths out bursts — up to 1,000 uploads can pile up during a traffic spike without anything blocking — and it preserves the order jobs arrived in. Once the buffer is full, jobs <- job blocks: that's backpressure, the queue pushing back on the producer instead of growing without limit.

The classic "queuing" example that shows up in a lot of tutorials — a chan struct{} that a fixed number of goroutines fight over — is actually a semaphore wearing a queuing costume. The telltale sign is right there in the type: no data flows through a chan struct{}, so nothing is actually being queued, only throttled. See the Semaphore section immediately below for that exact example, done honestly.

In-memory queue vs durable queue

The jobs := make(chan Job, 1000) buffered channel used throughout this article is an in-memory queue: producer and consumer live in the same process, jobs are lost if the process crashes or restarts, and it gives you simple buffering plus backpressure for free. A typical flow: an HTTP handler pushes onto a buffered channel, and a pool of workers drains it — the buffered channel is doing double duty as both a queue and a semaphore.

That's the right choice until one of these becomes true, at which point you need a durable or distributed queue instead:

  • Jobs must survive a process restart or crash
  • The producer and the consumer are different services
  • You need retries or delayed/scheduled tasks
  • You want to scale consumers horizontally, across instances
  • You need dead-letter handling for jobs that repeatedly fail
  • You need an event history you can replay

Tool

Fits

Asynq

Redis-based background, delayed, and scheduled tasks

RabbitMQ

Command/task queues, routing, retries, request/reply

Kafka

Event streams, audit logs, event-driven architectures, replay

Watermill

A message-handler/middleware/Pub-Sub abstraction over several brokers

kafka-go

A pure-Go Kafka client

amqp091-go

A RabbitMQ/AMQP client

Code review: spotting it and avoiding overuse

How to spot it: a channel of a real payload type (chan Job, chan Task, ...) crossing a goroutine boundary — sent from one goroutine, received in another, usually buffered.

Review checklist:

  • Is the buffer size justified by something real? What actually happens once it's full — does the producer block, or does something silently drop jobs?
  • If the process crashes, is losing whatever's still sitting in the channel acceptable? If not, this needs to be a durable queue, not an in-memory one.
  • Who closes the channel, and do all the consumer range loops actually see it close and exit?

When NOT to use it: don't reach for a giant buffered channel as a cheap substitute for a durable queue — everything in it disappears the moment the process does. And don't insert a queue between two stages that always run at roughly the same speed; a buffer of zero (or something small) is fine when there's no real speed mismatch to smooth over — an oversized buffer just hides backpressure instead of solving anything.

Semaphore

go-advance-pattern-queuing-semaphore.pngA semaphore is a different tool, even though Go typically builds it out of the exact same channel type queuing uses. Where queuing pushes real data through a channel, a semaphore's channel carries tokens, not data — nothing meaningful flows through it. A semaphore stands outside a region of code, like a gate with a fixed number of keys: acquiring a token lets a goroutine step inside the gated region, and releasing it hands the key back for whoever's waiting next. The acquire and the release happen in the same goroutine, wrapped around whichever section of code needs to be limited — that's the structural difference from queuing, where the send is one goroutine (the producer) and the receive is a different one (the consumer).

A semaphore with exactly one permit is a binary semaphore and behaves much like a mutex, with one interesting difference: a mutex should be unlocked by whoever locked it, while a semaphore permit may legally be released by a different goroutine than the one that acquired it.

Here's that "queuing" example from a lot of tutorials, done honestly — it's a semaphore, so the channel is named sem and holds tokens, not jobs:

const limit = 2
const work = 10

func main() {
var wg sync.WaitGroup

fmt.Println("Semaphore limit:", limit)
sem := make(chan struct{}, limit)

wg.Add(work)

for w := 1; w <= work; w++ {
process(w, sem, &wg)
}

wg.Wait()

close(sem)
fmt.Println("Work complete")
}

func process(work int, sem chan struct{}, wg *sync.WaitGroup) {
sem <- struct{}{} // acquire

go func() {
defer wg.Done()

time.Sleep(1 * time.Second)
fmt.Println("Processed:", work)

<-sem // release
}()
}

There are 10 units of work but only 2 permits in sem. Each call to process first does sem <- struct{}{} — the acquire — and if sem already holds limit tokens, this send blocks until a permit frees up, which is exactly the throttling behavior we want. Once a permit is claimed, the actual work runs in its own goroutine and, when it finishes, does <-sem — the release — to hand the permit back to whoever's waiting next. Notice that the acquire and the release both happen around the same unit of work, in the same goroutine flow: no Job or payload is ever read out of sem, only an empty token meaning "a slot is free." The result is that at most two units of work are ever running simultaneously, even though all ten were submitted immediately.

In Go, the idiomatic lightweight semaphore is exactly that buffered channel of empty structs — worth seeing in its canonical form, with acquire and release paired via defer so release always happens on every exit path:

sem := make(chan struct{}, 3) // a semaphore with 3 permits

func doWork(id int) {
sem <- struct{}{} // Acquire — blocks while 3 goroutines hold permits
defer func() { <-sem }() // Release — always paired via defer

// ... at most 3 goroutines ever run this section at once ...
}

When you need more than a plain counter, the ecosystem's standard answer is golang.org/x/sync/semaphore — a weighted semaphore. Two things make it worth the import: Acquire takes a context.Context, so a goroutine stuck waiting for a permit can give up when the request is cancelled or times out; and permits carry weights, so one heavy task can claim several slots at once:

import "golang.org/x/sync/semaphore"

var sem = semaphore.NewWeighted(100) // e.g. "100 MB of in-flight work"

func handle(ctx context.Context, sizeMB int64) error {
if err := sem.Acquire(ctx, sizeMB); err != nil {
return err // ctx was cancelled or timed out while waiting
}
defer sem.Release(sizeMB)

return process(sizeMB) // total in-flight weight never exceeds 100
}

There's also TryAcquire, which grabs a permit without blocking and returns false instead of waiting — useful for "shed load instead of queueing" behavior.

Choosing between the two: the channel semaphore wins on simplicity when permits are uniform and cancellation isn't needed; reach for semaphore.Weighted when you want context-aware waiting, weighted permits (memory budgets, cost units), or non-blocking attempts.

sem.Release(5) // ❌ panic: "semaphore: released more than held" if you only acquired 3

sem <- struct{}{}
work() // ⚠️ if work() panics, the permit leaks — always release with defer
<-sem

Two classic mistakes: releasing more than you acquired (the weighted semaphore panics loudly), and forgetting to release on an error path — each leaked permit permanently shrinks your effective limit until, eventually, every acquire blocks forever.

Use case: protecting a rate-limited API

A third-party API that rate-limits you is the canonical semaphore use case: cap outbound calls at some fixed concurrency so you never trip the limit, regardless of how many callers inside your service want to hit it at once.

var apiLimiter = semaphore.NewWeighted(10)

func callThirdPartyAPI(ctx context.Context, req Request) (Response, error) {
if err := apiLimiter.Acquire(ctx, 1); err != nil {
return Response{}, err // ctx was canceled while waiting for a slot
}
defer apiLimiter.Release(1)

return httpClient.Do(ctx, req) // at most 10 calls in flight at once
}

Every caller of callThirdPartyAPI, anywhere in the service, shares the same apiLimiter — exactly the "cap concurrency independent of how the work is structured" property that makes a semaphore different from a worker pool.

Code review: spotting it and avoiding overuse

How to spot it: make(chan struct{}, N) with paired sem <- struct{}{} / <-sem around a region, or semaphore.NewWeighted.

Review checklist:

  • Is release guaranteed on EVERY path, including panics and error returns (defer)?
  • Does N trace back to a real, documented limit?
  • Any acquire-inside-acquire that could self-deadlock?

When NOT to use it: if N would be 1 and the goal is exclusive access to shared state, use sync.Mutex instead — it says what it means, and go vet understands it. If the need is "at most N in flight across the app," check whether the resource already enforces that limit (e.g. database/sql's SetMaxOpenConns) before adding your own layer on top.

Scatter-gather

Scatter-gather is fan-out and fan-in combined into a single named pattern: scatter a request out to multiple services, then gather their results back into one combined response.

Request → Scatter to multiple services → Gather results → Combined response

Typical use cases: an API gateway calling out to user, order, and payment services in parallel to assemble one response; searching PostgreSQL, Elasticsearch, and a cache simultaneously; pricing a product by querying several providers; checking inventory across multiple warehouses; fetching the same data from multiple regions.

errgroup is usually the cleanest way to write this — simpler than hand-rolling result channels and a WaitGroup:

var user User
var orders []Order
var payments []Payment

g, ctx := errgroup.WithContext(ctx)

g.Go(func() error {
var err error
user, err = loadUser(ctx, userID)
return err
})
g.Go(func() error {
var err error
orders, err = loadOrders(ctx, userID)
return err
})
g.Go(func() error {
var err error
payments, err = loadPayments(ctx, userID)
return err
})

if err := g.Wait(); err != nil {
return nil, err
}

Code review: spotting it and avoiding overuse

How to spot it: one errgroup (or WaitGroup) with several g.Go calls each writing to its own result variable, followed by a single Wait.

Review checklist:

  • Does each goroutine write ONLY its own variable? Shared writes need locks.
  • What's the partial-failure policy — does the first error cancel everything (errgroup), or are all errors collected?
  • Are there per-call timeouts via context?

When NOT to use it: if call B needs call A's result, they're sequential — scattering them is a bug, not an optimization. And for two fast calls where latency doesn't matter, sequential code is easier to read and debug than a scatter-gather.

Batching

Batching groups many small items together before processing them as one unit:

item → item → item → [batch of 100] → database

Typical use cases: batch inserts into PostgreSQL, batching a Kafka producer's sends, bulk-indexing into Elasticsearch, batching telemetry or metrics, shipping logs, and bulk API requests.

The defining decision in a batcher is when to flush, and the answer is almost always "whichever comes first": the batch reaches a size limit (say, 100 items) or a time limit elapses (say, 500ms) since the last flush — the time limit exists so that low-traffic periods don't leave items waiting indefinitely.

func batcher(in <-chan Item, flush func([]Item)) {
const maxBatch = 100
const maxWait = 500 * time.Millisecond

batch := make([]Item, 0, maxBatch)
ticker := time.NewTicker(maxWait)
defer ticker.Stop()

for {
select {
case item, ok := <-in:
if !ok {
if len(batch) > 0 {
flush(batch)
}
return
}
batch = append(batch, item)
if len(batch) >= maxBatch {
flush(batch)
batch = make([]Item, 0, maxBatch)
}
case <-ticker.C:
if len(batch) > 0 {
flush(batch)
batch = make([]Item, 0, maxBatch)
}
}
}
}

Tooling: a channel plus time.Ticker or time.Timer covers most cases, often paired with a worker pool to process each flushed batch concurrently. Many Kafka client libraries and database drivers already batch internally, so check before building your own layer on top.

Batching typically sits at the end of a pipeline, right before the slow, per-call-expensive resource: Generator → Validate workers → Fan-in → Batcher → Database.

Code review: spotting it and avoiding overuse

How to spot it: a goroutine holding a buf slice, selecting over an items channel and a time.Ticker, flushing on size or on tick.

Review checklist:

  • Is there a FINAL flush when the input channel closes? Missing this is the classic lost-tail bug.
  • Is the buffer copied on flush, or does reusing the same backing array alias data?
  • Is max batch size × item size a bounded, acceptable memory footprint?

When NOT to use it: your DB driver, Kafka producer, or HTTP client often batches already — double batching just adds latency for nothing. Low-volume writes (a few per second) don't need a batcher at all.

Pub/Sub

Pub/Sub delivers one message to every subscriber, not just one:

OrderCreated event → Email service / Analytics service / Loyalty service

The contrast with a worker pool is the whole point: in a worker pool, each task is consumed by exactly one worker; in pub/sub, every subscriber gets its own copy of every message.

Typical use cases: publishing domain events, invalidating caches, sending notifications, audit logging, feeding analytics, keeping a read model in sync, and event-driven microservices generally.

Tooling ranges from in-process to fully distributed: inside a single process, per-subscriber channels or a small event bus are enough; Redis Pub/Sub gives you a lightweight but ephemeral broadcast (no replay, no persistence); Kafka gives you durability and replay; RabbitMQ exchanges give you flexible routing; and Watermill standardizes the publisher/subscriber/middleware API across whichever of those you pick.

Code review: spotting it and avoiding overuse

How to spot it: a registry of subscriber channels plus a publish loop that sends the same message to each one.

Review checklist:

  • What happens when one subscriber is slow — does an unbuffered send in the loop block ALL others?
  • Is there an unsubscribe path? Abandoned subscriber channels leak.
  • Are at-most-once vs. at-least-once delivery expectations documented?

When NOT to use it: with exactly one consumer it's just a channel — don't dress it up as pub/sub. Between two in-process components, a direct function call is clearer. And don't build an in-process bus when the real requirement is durable delivery — that's a broker's job.

Singleflight (request coalescing)

Singleflight collapses many identical concurrent requests into a single execution, and shares that one result with every caller who asked for it:

100 requests for user:123 → 1 database query → result shared with 100 callers

Typical use cases: protecting against a cache-stampede on a concurrent cache miss, coalescing token-refresh calls, coalescing config loads, coalescing exchange-rate fetches — any time an expensive, shared resource might otherwise be hit by a burst of duplicate concurrent requests for the same key.

golang.org/x/sync/singleflight is the standard tool for this:

var g singleflight.Group

func getUser(ctx context.Context, id string) (User, error) {
v, err, _ := g.Do(fmt.Sprintf("user:%s", id), func() (any, error) {
return fetchUserFromDB(ctx, id)
})
if err != nil {
return User{}, err
}
return v.(User), nil
}

This is the same library mentioned briefly in the libraries section below — see that section for how it maps onto the fan-in pattern conceptually.

Code review: spotting it and avoiding overuse

How to spot it: singleflight.Group with g.Do(key, fn).

Review checklist:

  • Is the key granular enough? Too-coarse keys serialize unrelated work.
  • Do callers treat the shared result as READ-ONLY? All callers get the same pointer — mutating it is a race.
  • Cancellation caveat: the first caller's context governs the shared call for everyone.

When NOT to use it: only worth it for expensive, frequently-duplicated reads — wrapping a cheap query adds complexity for nothing. Never use it for writes: collapsing 100 "create order" calls into one execution changes semantics, not just performance.

Rate limiting

Rate limiting controls how many tasks start per unit of time — a different axis from a worker pool, which caps how many tasks are running concurrently at any instant:

Worker pool:    max 20 tasks running at once
Rate limiter: max 100 task starts per second

Typical use cases: respecting an external API's published limit (say, 100 requests/second); staying under an email provider's throughput cap; limiting login attempts; throttling a background migration so it doesn't overwhelm a database; protecting any downstream service from a burst.

golang.org/x/time/rate is the standard tool:

limiter := rate.NewLimiter(rate.Limit(100), 10) // 100 events/sec, burst of 10

func callAPI(ctx context.Context, req Request) (Response, error) {
if err := limiter.Wait(ctx); err != nil {
return Response{}, err // ctx canceled while waiting for a slot
}
return httpClient.Do(ctx, req)
}

In production these two often stack together, each solving a different problem:

Queue → Rate limiter (100 req/s) → Worker pool (max 20 concurrent) → External API

Code review: spotting it and avoiding overuse

How to spot it: rate.NewLimiter with limiter.Wait(ctx) or Allow() guarding an operation.

Review checklist:

  • Is the limiter created ONCE and shared? A limiter created per-request limits nothing — a classic bug.
  • Is burst configured deliberately, not left at a default that doesn't match the real traffic shape?
  • Does Wait receive a real ctx so shutdown isn't blocked indefinitely?

When NOT to use it: internal calls with no external quota rarely need throttling. And if the actual problem is "too many running at once," that's a concurrency cap — a semaphore or pool — not a rate; mixing the two up is the most common misuse of this pattern.

Other patterns

Beyond the seven patterns above, a few closely related patterns are worth knowing by name even if we don't build them out here: a tee channel duplicates every value from one input channel onto two separate output channels, useful when two independent stages both need to see the same stream; a bridge channel flattens a channel-of-channels into a single channel, handy when a sequence of channels is produced dynamically over time; a ring buffer channel wraps a fixed-size circular buffer so producers can overwrite the oldest unread item instead of blocking, which trades data loss for guaranteed non-blocking writes; and bounded parallelism is the general name for the semaphore technique above — capping concurrent goroutines to some fixed N regardless of how many total tasks exist.

Cross-cutting concerns

The three patterns below aren't standalone building blocks like generator or pipeline — they're concerns that apply across whichever pattern you've chosen, and every pattern in this article eventually needs them in production.

Cancellation with done channels and context

The generator pattern shown earlier leaks its goroutine because it has no way to know the receiver has stopped listening. The fix is to give the goroutine a second thing to select on — either a dedicated done channel or a context.Context — so it can bail out instead of blocking forever:

func generator(done <-chan struct{}) <-chan int {
ch := make(chan int)

go func() {
defer close(ch)

for i := 0; ; i++ {
select {
case ch <- i:
// value delivered, keep going
case <-done:
return // caller gave up, stop producing
}
}
}()

return ch
}

func main() {
done := make(chan struct{})
defer close(done)

ch := generator(done)

for i := 0; i < 5; i++ {
fmt.Println("Value:", <-ch)
}
// closing done unblocks the goroutine's next select, and it returns
}

The select inside the loop races two cases: either the value is successfully delivered on ch, or done is closed, in which case the goroutine returns immediately and its deferred close(ch) runs. This same shape — thread a done channel or ctx.Done() through every stage of a fan-out, fan-in, or pipeline, and select on it wherever you'd otherwise block on a channel send — is the standard way to make any of the patterns in this article cancelable. When the source of a pipeline is canceled this way, closing its output channel cascades the shutdown through every downstream stage automatically, the same way normal completion does.

Sizing a worker pool

There's no single right number of workers — it depends on whether the work is CPU-bound or IO-bound. For CPU-bound work (heavy computation, no blocking on network/disk), more workers than runtime.NumCPU() just adds scheduling overhead without adding throughput, since the CPU cores are already saturated; runtime.NumCPU() is a reasonable default. For IO-bound work (HTTP calls, database queries, file I/O), workers spend most of their time blocked waiting on something external rather than using the CPU, so it's normal — and often necessary for throughput — to run many more workers than you have cores, sometimes hundreds, since they're mostly idle from the scheduler's point of view.

For simple "run N things concurrently and wait" cases, it's also worth knowing about golang.org/x/sync/errgroup, which wraps a sync.WaitGroup with error propagation and (via SetLimit) built-in concurrency limiting, often replacing hand-rolled worker-pool boilerplate with a few lines:

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(runtime.NumCPU())

for _, job := range jobs {
job := job
g.Go(func() error {
return process(ctx, job)
})
}

if err := g.Wait(); err != nil {
// first error from any goroutine, others are canceled via ctx
}

Backpressure

An unbuffered channel between two pipeline stages naturally throttles the faster side: a producer stage blocks on its send until the consumer stage is ready to receive, so a fast producer can never race arbitrarily far ahead of a slow consumer and pile up unbounded memory. This is backpressure for free, just from channel semantics. Adding a buffer (as the worker pool and queuing examples do) relaxes that throttling — the producer can get a fixed number of items ahead before blocking — which is a deliberate trade-off between smoothing out bursts and giving up the automatic throttle. When memory or downstream load is a concern, prefer small or zero buffers; when smoothing out bursty producers is the goal, size the buffer deliberately rather than making it arbitrarily large.

How well-known libraries implement these patterns

You rarely have to hand-roll these patterns in production code — the ecosystem has hardened implementations, and reading how they map onto what we just built is the best way to internalize the patterns.

golang.org/x/sync/errgroup — worker pool + fan-out with error handling. The de-facto standard for "run N tasks concurrently, wait for all, and fail together". SetLimit turns it into a bounded worker pool — our semaphore pattern — in one line, and errgroup.WithContext gives you the cancellation wiring we built manually with done channels:

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(4) // bounded parallelism — the semaphore pattern, built in

for _, url := range urls {
g.Go(func() error {
return fetch(ctx, url) // first error cancels ctx for all others
})
}

if err := g.Wait(); err != nil { // the join point
log.Fatal(err)
}

golang.org/x/sync/semaphore — the semaphore pattern, generalized. A weighted semaphore: Acquire(ctx, n) / Release(n) do exactly what our sem <- struct{}{} / <-sem did, but with context-aware cancellation and the ability for one heavy task to claim more than one slot.

golang.org/x/sync/singleflight — collapses identical concurrent requests into one. See the dedicated Singleflight section above for the full example; this is the library behind groupcache's thundering-herd protection.

The standard library itself. net/http's server is the fan-out pattern at planetary scale: one goroutine per connection, spawned from an accept loop. database/sql hides a semaphore inside: SetMaxOpenConns is a semaphore capping concurrent connections, and callers block exactly like our process function did. And since Go 1.23, the iter package (iter.Seq, range-over-func) is the generator pattern formalized into the language — a generator without the leaked-goroutine risk, because iteration is just a function call.

Third-party pools. sourcegraph/conc wraps these patterns in a structured-concurrency API (conc.WaitGroup, pool.Pool with .WithMaxGoroutines(), panic propagation included); panjf2000/ants is a high-performance goroutine pool that reuses goroutines to cut scheduling and stack costs when you're firing millions of small tasks.

Practical guidance: default to errgroup for anything that looks like "N tasks, wait for all, propagate errors" — it's maintained by the Go team and reads like the textbook pattern. Drop down to hand-rolled channels when the data flow is genuinely custom (pipelines, streaming), and reach for conc/ants only when profiling shows goroutine churn itself is the bottleneck.

Choosing a pattern

The patterns above aren't academic exercises — each maps to problems you'll hit in ordinary backend work. Use the table below to go from the need you have to the pattern that fits; the use-case examples woven into each section above show every one of these in more depth.

Need

Pattern

Produce a stream of data

Generator

Spread items across goroutines

Fan-out

Collect results from goroutines

Fan-in

Split processing into stages

Pipeline

Cap concurrent tasks

Worker pool / semaphore

Hold tasks temporarily in-process

Queuing

Tasks must survive restart

Distributed queue

One event to many services

Pub/Sub

Call several services, combine results

Scatter-gather

Group records before insert

Batching

Many identical concurrent requests

Singleflight

Limit throughput over time

Rate limiting

Two rules of thumb when choosing: if the work items are homogeneous and you mainly need to bound concurrency, reach for a worker pool or semaphore. If the work naturally decomposes into stages with different speeds, reach for a pipeline and let channel backpressure balance the stages.

#Backend#Go Advance
DUKE
Written byDUKEView all stories by DUKE

Comments