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

Mar 30, 2025 - 18:51
 0
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 <- 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 channel c and launches two goroutines: one pipes input1 to c, the other pipes input2 to c.
  • In main, we read from c 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 calls wg.Done() when the input closes (using for ... range to detect closure).
  • A separate goroutine waits for all inputs to finish (wg.Wait()) and then closes c.
  • In main, we read with for ... range until c 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