DEV Community

Timevolt
Timevolt

Posted on

Channeling My Inner Gandalf: Go Concurrency Patterns

The Quest Begins (The "Why")

I still remember the first time I tried to make a Go program do two things at once. I had a web scraper that needed to fetch dozens of URLs, parse the HTML, and write the results to a file—all without turning my laptop into a space heater. I launched a bunch of goroutines, threw a channel in the middle, and watched the program hang forever. My screen filled with silent goroutines, like a bunch of hobbits waiting for a pizza that never arrived. I spent three hours staring at logs, convinced I’d missed some ancient rune. When the light finally clicked, I felt like I’d just discovered the One Ring of concurrency—except this one actually made my code faster.

If you’ve ever felt that mix of frustration and “wait, why isn’t this working?” when juggling goroutines and channels, you’re in good company. Go’s concurrency model is simple on the surface, but it hides a few quirks that can turn a heroic quest into a tragic comedy if you don’t know them.

The Revelation (The Insight)

Surprise #1: The Nil Channel Is a Black Hole

Most tutorials show you how to create a channel with make(chan int) and then move on. What they rarely mention is that the zero value of a channel is nil. A nil channel doesn’t panic when you use it—it just blocks forever on both sends and receives. That’s why my scraper froze: I accidentally passed a nil channel into a worker goroutine, and the sender waited… and waited… for a receiver that would never show up.

Why does this matter? Because it gives you a powerful tool for conditional concurrency. Want a worker to only start when a certain condition is met? Just keep its input channel nil until you’re ready, then make it and send work. When you’re done, close the channel and let the workers drain.

Surprise #2: select Picks Randomly When Multiple Cases Are Ready

The select statement looks like a polite waiter: “I’ll take whichever case is ready first.” But if more than one case is ready at the same instant, Go doesn’t guarantee which one it chooses—it picks one at random. This can lead to flaky tests if you assume FIFO order.

I learned this the hard way when I built a priority‑job dispatcher. I had two channels: high and low. I thought select { case v := <-high: …; case v := <-low: … } would always drain high‑priority jobs first. Nope. When both had jobs, sometimes a low‑priority job slipped through, causing occasional latency spikes in production. The fix? Either buffer the high‑priority channel and check it first with an extra if len(high) > 0 guard, or use a separate goroutine that only reads from high and fans out to workers.

Surprise #3: Closing a Channel Sends a Silent Broadcast

Closing a channel isn’t just a polite “no more messages”—it closes the read side for everyone listening. A range over a channel (for v := range ch) stops only when the channel is closed, not when it’s empty. If you forget to close, your range loop blocks forever, waiting for a sender that may never come. Conversely, sending on a closed channel panics—a runtime error that’s harder to catch than a syntax bug.

The gotcha here is subtle: you must close the channel exactly once, and only the sender should do it. If multiple goroutines try to close it, you’ll panic. If no one closes it, receivers hang. This pattern is why the classic “worker pool” uses a sync.WaitGroup for the workers and a separate done channel to signal completion.

Wielding the Power (Code & Examples)

Let’s see these ideas in action with a realistic scenario: a concurrent URL fetcher that respects a maximum concurrency limit, reports progress, and cleanly shuts down when all work is done.

The Struggle (What Not to Do)

func fetchURLsBad(urls []string) {
    results := make(chan string) // unbuffered results channel
    for _, url := range urls {
        go func(u string) {
            resp, err := http.Get(u)
            if err != nil {
                results <- fmt.Sprintf("%v: error", u)
                return
            }
            results <- fmt.Sprintf("%v: %d", u, resp.StatusCode)
        }(url)
    }

    // Try to print results as they arrive
    for i := 0; i < len(urls); i++ {
        fmt.Println(<-results) // blocks if a goroutine never sends
    }
}
Enter fullscreen mode Exit fullscreen mode

If any goroutine panics or never sends (say, because of a nil channel somewhere), the main loop hangs forever. No timeout, no cleanup—just a silent freeze.

The Victory (Using the Secrets)

func fetchURLsGood(urls []string, maxWorkers int) {
    // 1️⃣ Work channel – nil until we’re ready to start
    var workChan chan string
    if len(urls) > 0 {
        workChan = make(chan string, len(urls)) // buffered, no blocking on send
        for _, u := range urls {
            workChan <- u
        }
        close(workChan) // <-- close exactly once, signals workers to stop
    }

    // 2️⃣ Results channel – buffered so senders never block on a slow receiver
    results := make(chan string, len(urls))

    // 3️⃣ WaitGroup to know when all workers are done
    var wg sync.WaitGroup
    wg.Add(maxWorkers)

    for i := 0; i < maxWorkers; i++ {
        go func(id int) {
            defer wg.Done()
            for u := range workChan { // stops when workChan is closed
                resp, err := http.Get(u)
                if err != nil {
                    results <- fmt.Sprintf("[worker-%d] %v: error", id, u)
                    continue
                }
                results <- fmt.Sprintf("[worker-%d] %v: %d", id, u, resp.StatusCode)
            }
        }(i)
    }

    // 4️⃣ Close results when all workers finish
    go func() {
        wg.Wait()
        close(results)
    }()

    // 5️⃣ Drain results – stops automatically when results channel is closed
    for r := range results {
        fmt.Println(r)
    }
}
Enter fullscreen mode Exit fullscreen mode

What changed?

  1. Nil‑to‑made channelworkChan starts nil, we only allocate it when there’s work. This mirrors the “start workers only when needed” pattern.
  2. Buffered channels – Both workChan and results are buffered, so sends never block on a slow receiver (unless the buffer fills, which we sized to the total workload).
  3. Exactly‑once close – We close workChan after pushing all URLs; workers exit the range loop cleanly. We close results only after every worker has finished (via wg.Wait()).
  4. Select‑style timeout (optional) – If you wanted a global deadline, you could select on results and time.After(30*time.Second); the random‑pick property of select means you’d need to handle the timeout case explicitly, but the pattern stays the same.

Running this with a list of 100 URLs and maxWorkers = 10 gives you steady, predictable throughput, clean shutdown, and no mysterious hangs.

Why This New Power Matters

Mastering these nuances turns you from a “goroutine user” into a concurrency architect. You’ll:

  • Avoid production‑freezing bugs caused by nil channels or forgotten closes.
  • Design systems that scale because you know when to buffer, when to block, and how to signal completion reliably.
  • Write tests that are deterministic—no more flaky CI runs due to select’s random choice.
  • Leverage Go’s built‑in primitives instead of reaching for heavyweight libraries; the language already gives you the tools for pipelines, fan‑out/fan‑in, worker pools, and timeouts.

In short, you stop fighting the runtime and start letting it work for you—just like Gandalf guiding the Fellowship through Moria, knowing exactly when to shout “You shall not pass!” (or, in our case, when to close a channel).

Your Next Quest

Pick a small project you’ve been putting off—a log parser, a mock API server, or even a concurrent Tic‑Tac‑Toe server. Implement it using the patterns above: a buffered work channel, a pool of workers, and a clean shutdown via close + sync.WaitGroup. When you see the program finish without a single hanging goroutine, take a moment to smile. You’ve just leveled up your Go superpower.

Now go forth, and may your channels always be closed (and never nil)! 🚀

Top comments (0)