The Multiplexing (Fan-In) Pattern in Go Concurrency
Hi there! I'm Shrijith Venkatrama, founder of Hexmos. Right now, I’m building LiveAPI, a tool that makes generating API docs from your code ridiculously easy. Imagine you’re building a little server that listens to a bunch of chatty workers—maybe they’re scraping websites, polling sensors, or just spouting random thoughts. Each worker is doing its thing independently, and you need a way to collect all their updates into one neat stream to process. You could write a messy loop checking each worker one by one, but that sounds like a headache. Enter Go’s fan-in pattern—a slick concurrency trick that "multiplexes" multiple channels into one, saving you from the chaos of managing them all yourself. In this post, we’ll dive into what the fan-in pattern is, how it works under the hood, and why it’s such a gem in Go’s concurrency toolbox. We’ll start with a problem that’ll make you itch for a solution, then build up from dead-simple examples to something more real-world. The Problem: Too Many Voices, Not Enough Ears Picture this: you’ve got three goroutines—let’s call them Alice, Bob, and Charlie—each spitting out status updates on their own channels. Alice is counting sheep, Bob’s reciting pi digits, and Charlie’s just yelling "YO!" every few seconds. You’re the poor soul who has to listen to all of them and log what they say. Here’s a naive attempt: func main() { alice := make(chan string) bob := make(chan string) charlie := make(chan string) go func() { for i := 1; ; i++ { alice

Hi there! I'm Shrijith Venkatrama, founder of Hexmos. Right now, I’m building LiveAPI, a tool that makes generating API docs from your code ridiculously easy.
Imagine you’re building a little server that listens to a bunch of chatty workers—maybe they’re scraping websites, polling sensors, or just spouting random thoughts.
Each worker is doing its thing independently, and you need a way to collect all their updates into one neat stream to process.
You could write a messy loop checking each worker one by one, but that sounds like a headache.
Enter Go’s fan-in pattern—a slick concurrency trick that "multiplexes" multiple channels into one, saving you from the chaos of managing them all yourself.
In this post, we’ll dive into what the fan-in pattern is, how it works under the hood, and why it’s such a gem in Go’s concurrency toolbox.
We’ll start with a problem that’ll make you itch for a solution, then build up from dead-simple examples to something more real-world.
The Problem: Too Many Voices, Not Enough Ears
Picture this: you’ve got three goroutines—let’s call them Alice, Bob, and Charlie—each spitting out status updates on their own channels.
Alice is counting sheep, Bob’s reciting pi digits, and Charlie’s just yelling "YO!" every few seconds.
You’re the poor soul who has to listen to all of them and log what they say.
Here’s a naive attempt:
func main() {
alice := make(chan string)
bob := make(chan string)
charlie := make(chan string)
go func() { for i := 1; ; i++ { alice <- fmt.Sprintf("Sheep %d", i); time.Sleep(500 * time.Millisecond) } }()
go func() { for i := 3; ; i++ { bob <- fmt.Sprintf("Pi digit %d", i); time.Sleep(700 * time.Millisecond) } }()
go func() { for { charlie <- "YO!"; time.Sleep(1 * time.Second) } }()
for {
fmt.Println(<-alice)
fmt.Println(<-bob)
fmt.Println(<-charlie)
}
}
Run this, and you’ll notice a problem fast.
The main
function blocks on alice
, waiting for a sheep count, before it even checks Bob or Charlie.
If Alice is slow, Bob and Charlie’s messages pile up, and you get this rigid, lockstep output.
It’s like forcing three friends to take turns talking when they’re all bursting to speak.
We need a way to hear whoever’s ready, whenever they’re ready. That’s where fan-in swoops in to save the day.
Fan-In Basics: Merging Two Channels
Let’s start small and fun.
The fan-in pattern takes multiple input channels and funnels them into one output channel—think of it like merging streams into a river.
Here’s a basic version with two chatty goroutines:
func boring(name string) <-chan string {
c := make(chan string)
go func() {
for i := 0; ; i++ {
c <- fmt.Sprintf("%s says %d", name, i)
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
}
}()
return c
}
func fanIn(input1, input2 <-chan string) <-chan string {
c := make(chan string)
go func() { for { c <- <-input1 } }()
go func() { for { c <- <-input2 } }()
return c
}
func main() {
c := fanIn(boring("Joe"), boring("Ann"))
for i := 0; i < 10; i++ {
fmt.Println(<-c)
}
fmt.Println("You’re both boring; I’m outta here.")
}
How It Works
-
boring
is a helper that spins up a goroutine spitting out messages on a channel with random delays. -
fanIn
creates a new channelc
and launches two goroutines: one pipesinput1
toc
, the other pipesinput2
toc
. - In
main
, we read fromc
10 times and get a mix of Joe’s and Ann’s messages, like "Joe says 0," "Ann says 0," "Joe says 1," depending on who’s faster.
The magic?
The output channel c
doesn’t care who’s talking—it just delivers whatever it gets, whenever it gets it.
No more waiting for Joe to finish before Ann gets a word in.
This is multiplexing in action: combining multiple signals into one stream.
Leveling Up: Handling More Inputs with Select
The two-channel version is cool, but what if we’ve got Alice, Bob, and Charlie?
Spawning a goroutine per channel works, but it’s a bit clunky for scaling.
Let’s use Go’s select
statement to handle multiple inputs in one goroutine:
func fanIn(inputs ...<-chan string) <-chan string {
c := make(chan string)
go func() {
for {
select {
case s := <-inputs[0]:
c <- s
case s := <-inputs[1]:
c <- s
case s := <-inputs[2]:
c <- s
}
}
}()
return c
}
func main() {
c := fanIn(boring("Alice"), boring("Bob"), boring("Charlie"))
for i := 0; i < 15; i++ {
fmt.Println(<-c)
}
}
Here, fanIn
takes a variadic list of channels (...<-chan string
) and uses select
to listen to all three at once.
When any channel has data, select
picks it and forwards it to c
.
This is more efficient than separate goroutines for each input—fewer resources, same result.
A Catch
This hardcodes three channels. For true flexibility, we’d need to dynamically handle any number of inputs. Hold that thought—we’ll get there!
Real-World Flavor: Cleanup and Robustness
Our examples so far are fun but leaky—those goroutines never stop, even when we’re done listening.
In a real app, you’d want to clean up properly. Let’s make a robust fanIn
that closes its output channel when all inputs are done.
We’ll use a sync.WaitGroup
to track active inputs:
func fanIn(inputs ...<-chan string) <-chan string {
c := make(chan string)
var wg sync.WaitGroup
wg.Add(len(inputs))
// Launch a goroutine per input
for _, input := range inputs {
go func(ch <-chan string) {
defer wg.Done()
for v := range ch {
c <- v
}
}(input)
}
// Close output channel when all inputs are done
go func() {
wg.Wait()
close(c)
}()
return c
}
func main() {
alice := make(chan string)
bob := make(chan string)
go func() { for i := 0; i < 3; i++ { alice <- fmt.Sprintf("Alice %d", i) }; close(alice) }()
go func() { for i := 0; i < 3; i++ { bob <- fmt.Sprintf("Bob %d", i) }; close(bob) }()
c := fanIn(alice, bob)
for msg := range c {
fmt.Println(msg)
}
}
Breaking It Down
- Each input channel gets a goroutine that forwards messages to
c
and callswg.Done()
when the input closes (usingfor ... range
to detect closure). - A separate goroutine waits for all inputs to finish (
wg.Wait()
) and then closesc
. - In
main
, we read withfor ... range
untilc
closes, printing all messages naturally.
Output might look like:
Alice 0
Bob 0
Alice 1
Bob 1
Alice 2
Bob 2
This version is production-ready: no leaks, proper shutdown, and it scales to any number of inputs.
Why Fan-In Rocks: Benefits and Tradeoffs
So why bother with fan-in? Let’s break it down with a table:
Aspect | Benefit | Tradeoff |
---|---|---|
Decoupling | Consumers don’t need to know about producers. | Adds a layer of abstraction. |
Simplicity | One channel to rule them all! | Setup might feel overkill for two inputs. |
Flexibility | Works with 2 or 20 channels. | More inputs = more goroutines (or complexity). |
Concurrency | Non-blocking, whoever’s ready talks. | Order is unpredictable without extra logic. |
Fan-in shines in scenarios like:
- Aggregating logs from multiple services.
- Combining event streams in a game.
- Processing sensor data from IoT devices.
The downside? If you need ordered output or priority (e.g., Alice before Bob), you’ll need to layer on more logic—fan-in gives you raw concurrency, not control.
For a deeper dive, check out Rob Pike’s Go Concurrency Patterns talk.
Conclusion
The fan-in pattern is like a traffic cop for your channels, directing a chaotic chorus of goroutines into one harmonious stream.
We started with a messy problem—listening to Alice, Bob, and Charlie in lockstep—and ended with an elegant, scalable solution. From a basic two-channel merge to a robust, self-cleaning version, fan-in proves Go’s concurrency model is both powerful and approachable.
Next time you’re juggling multiple data sources, give fan-in a spin. Play with the code, tweak the delays, add more inputs—see how it feels.
Got questions or cool twists on this? Drop a comment—I’d love to chat about it!
Happy coding, and may your goroutines always close cleanly.
References
- Go Concurrency Patterns by Rob Pike - The classic talk where fan-in debuted.
- Go Blog: Pipelines - More on channel patterns.
- Effective Go - Official concurrency tips.