This guide is for Go developers who want several goroutines to consume work from one channel—go channel multiple readers, golang multiple consumers, or a small worker pool—without assuming broadcast semantics. It explains how receives compete, how that differs from pub-sub, how to close a shared jobs channel safely, and how to collect results. For channel basics, see channels in Go and goroutines; for merge patterns after workers produce results, see fan-out fan-in in Go. For cooperative shutdown, context pairs with select on done.
Tested with Go 1.24 on Linux.
Quick answer: many goroutines, one receiver per value
Yes: multiple goroutines may read from the same channel. Each value sent on the channel is delivered to exactly one receive operation—whichever waiting receiver runs first—so golang multiple channel receivers share work; they do not each get a private copy of the same message. That behavior is what people mean by fan-out from one jobs channel to several workers.
Multiple receivers vs broadcast
| Model | What happens to one ch <- v |
|---|---|
Several goroutines receiving on ch |
One goroutine receives v; the others keep waiting |
| Broadcast / pub-sub | Every subscriber would need its own copy of v—not what chan T does |
If every consumer must observe every event, use multiple channels, a fan-out router, or a library pattern for pub-sub—not a single shared receive competing for one value.
Fan-out: several workers on one jobs channel
The Go blog on pipelines describes fan-out as running multiple goroutines reading from the same channel until that channel is closed. Below, three workers all range on jobs; the producer sends integers 1–6, then closes jobs so workers exit after draining.
package main
import (
"fmt"
"sync"
"time"
)
func main() {
jobs := make(chan int)
var wg sync.WaitGroup
for w := 1; w <= 3; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for n := range jobs {
fmt.Printf("worker %d received %d\n", id, n)
time.Sleep(20 * time.Millisecond)
}
}(w)
}
go func() {
for i := 1; i <= 6; i++ {
jobs <- i
}
close(jobs)
}()
wg.Wait()
}You should see six lines, each with a different received number 1–6 exactly once across workers (which worker prints which value can vary by scheduling).
Worker pool pattern and limiting goroutines
A worker pool fixes how many goroutines consume a channel instead of spawning one goroutine per item. That bounds memory and scheduling pressure when input is large (for example when people search for golang limit goroutines). The fan-out example above is already a small worker pool: a fixed number of goroutines all range on jobs until close(jobs).
A semaphore is a different limiter: you may still launch one goroutine per item, but only N of them may enter the critical section at once. Below, at most three goroutines hold a token from sem at a time; wg waits for every launched goroutine to finish.
package main
import (
"fmt"
"sync"
)
func main() {
jobs := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
sem := make(chan struct{}, 3)
var wg sync.WaitGroup
for _, j := range jobs {
j := j
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
fmt.Println("processing", j)
}()
}
wg.Wait()
}You should see ten processing lines (order can vary). Use a worker pool when work is naturally pulled from one channel. Use a semaphore when you already spawn per-item goroutines but need a hard cap on how many run concurrently.
Buffered vs unbuffered channels
Buffering changes when a send blocks, but it does not change how many receivers get each value.
| Kind | Behavior sketch |
|---|---|
Unbuffered make(chan T) |
Send blocks until another goroutine receives |
Buffered make(chan T, n) |
Send blocks only when the buffer is full |
With an unbuffered channel, the sender and receiver meet at the same time. With a buffered channel, the sender can place values into the buffer first, up to the channel capacity.
For more details on capacity and blocking behavior, see buffered channels in Go.
Example flow with a buffered channel of size 2:
channel capacity = 2
send job-1 -> stored in buffer
send job-2 -> stored in buffer
send job-3 -> blocks until one receiver takes a valueIf three workers are reading from the same buffered channel, each queued value is still received by only one worker:
job-1 -> worker-2
job-2 -> worker-1
job-3 -> worker-3It does not mean this:
job-1 -> worker-1, worker-2, worker-3That would be broadcast or pub-sub, not normal channel receive behavior.
Short rule:
Buffering changes waiting behavior.
It does not turn one sent value into many received values.Closing the shared jobs channel
Only the side that will not send anymore should close the channel—typically the producer after enqueueing all jobs. Receivers then observe the closed channel: for v := range jobs exits after the channel is closed and all queued values are drained.
Example: the producer closes jobs after sending; workers must not exit before they drain the channel—here main waits on WaitGroup so the process does not end early.
package main
import (
"fmt"
"sync"
)
func main() {
jobs := make(chan int)
var wg sync.WaitGroup
for w := 1; w <= 3; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for job := range jobs {
fmt.Println("worker", id, "processing", job)
}
fmt.Println("worker", id, "done")
}(w)
}
for i := 1; i <= 5; i++ {
jobs <- i
}
close(jobs)
wg.Wait()
}You should see five processing lines and three done lines (one per worker).
In this flow:
producer sends jobs
producer closes jobs
workers finish remaining jobs
for range exits automatically
workers returnThe workers do not close jobs; they only receive from it.
This matters because multiple workers read the same channel while the producer may still be sending. If a receiver closes the channel while another goroutine sends on it, the program can panic.
Wrong ownership pattern:
worker closes jobs
producer still sends another job
send on closed channel panicBetter rule:
The goroutine that owns sending should close the channel.
Receivers should only observe that the channel is closed.If shutdown is signaled early, prefer canceling a context or a separate done channel in addition to normal close rules. Closing the jobs channel means "no more jobs will be sent"; cancellation means "stop early even if more work could exist."
Multiple receivers and result collection
Workers often read jobs from one channel and write results to another. Close jobs from the producer; use a WaitGroup (or similar) so one coordinator waits for all workers, then close(results) so the merger or main goroutine can range results without hanging.
package main
import (
"fmt"
"sync"
)
func main() {
jobs := make(chan int)
results := make(chan int, 10)
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
results <- j * 2
}
}()
}
for _, v := range []int{1, 2, 3, 4} {
jobs <- v
}
close(jobs)
go func() {
wg.Wait()
close(results)
}()
sum := 0
for r := range results {
sum += r
}
fmt.Println("sum", sum)
}You should see sum 20 (twice the sum of 1..4). The results buffer gives workers slack so slow consumers are less likely to deadlock before the closer runs.
Mistakes to avoid
Expecting every worker to see every job—only one receive takes each value.
Closing the jobs channel from a worker while the producer might still send.
Starting one goroutine per row of a huge dataset with no bound—use a pool or semaphore.
Forgetting to wait for workers before closing the result channel they still write to.
Ignoring cancellation when the consumer stops early—pair channels with context or done so sends do not block forever.
Sending the same pointer on the jobs channel to several workers who mutate it without synchronization—use copies, immutability, or a mutex.
Cheat sheet
| Situation | Behavior / approach |
|---|---|
Many goroutines receive on same chan |
Allowed; values split across receivers |
One send |
Exactly one receive gets it |
| Everyone must see every item | Not default chan semantics—design broadcast separately |
| Bounded workers | Fixed pool all ranging on jobs |
| Producer done enqueueing | Producer closes jobs |
Workers write results |
Coordinator closes results after WaitGroup.Wait |
| Buffered channel | Queue capacity only; still one receiver per value |
| Huge input | Avoid unbounded goroutines |
| Early shutdown | context + select, avoid blocked sends |
Summary
Multiple goroutines may read from the same channel; golang channel multiple readers compete so each sent value is consumed once—useful for worker pools and fan-out. A channel is not broadcast: duplicate delivery needs another design. The producer that owns sending should close the jobs channel; workers should not close it. Collecting results usually means a separate results channel and waiting for all senders before closing it. Buffered channels change blocking, not the one-to-one send/receive pairing. Watch for races if jobs carry pointers to shared mutable data.
References
- Go Blog: Pipelines and cancellation
- Go spec — Channel types
- Concurrency in Go (O’Reilly) — channels chapter

