Golang Channel with Multiple Receivers: Workers, Fan-Out, and Safe Closing

Tech reviewed: Deepak Prasad
Golang Channel with Multiple Receivers: Workers, Fan-Out, and Safe Closing

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.

go
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()
}
Output

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.

go
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()
}
Output

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:

text
channel capacity = 2

send job-1  -> stored in buffer
send job-2  -> stored in buffer
send job-3  -> blocks until one receiver takes a value

If three workers are reading from the same buffered channel, each queued value is still received by only one worker:

text
job-1 -> worker-2
job-2 -> worker-1
job-3 -> worker-3

It does not mean this:

text
job-1 -> worker-1, worker-2, worker-3

That would be broadcast or pub-sub, not normal channel receive behavior.

Short rule:

text
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.

go
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()
}
Output

You should see five processing lines and three done lines (one per worker).

In this flow:

text
producer sends jobs
producer closes jobs
workers finish remaining jobs
for range exits automatically
workers return

The 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:

text
worker closes jobs
producer still sends another job
send on closed channel panic

Better rule:

text
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.

go
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)
}
Output

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


Frequently Asked Questions

1. Can multiple goroutines read from the same channel in Go?

Yes. Many goroutines may execute receive on the same channel; each sent value is delivered to exactly one of those receivers, whichever is ready first.

2. Does each receiver get a copy of every value sent on the channel?

No. A channel multiplexes competing receivers: one send matches one receive. For every subscriber to see every event, use a fan-out broadcast pattern or separate channels.

3. Who should close a shared jobs channel?

The sender side that will not send anymore—usually the producer goroutine—should close the channel so all worker receivers exit their range loops.

4. How is a worker pool related to multiple receivers?

A fixed pool of goroutines all ranging on the same jobs channel is the usual pattern: workers compete for jobs until the channel is closed and drained.

5. Does a buffered channel broadcast one value to all readers?

No. Buffering only adds queue capacity between send and receive; each value is still consumed by a single receive operation.

6. What goes wrong if workers mutate the same pointer from different jobs?

Data races unless synchronized. Prefer sending immutable values, separate copies per job, or protect shared state with a mutex or other synchronization.
Antony Shikubu

Systems Integration Engineer

Highly skilled software developer with expertise in Python, Golang, and AWS cloud services.

  • Mastering Ansible Automation
  • Docker and Kubernetes: The Complete Guide
  • Go: Data Structures, Algorithms and Design Patterns With Go
  • Go (programming language)
  • Python (programming language)
  • Amazon Web Services