Skip to Content
Course content

248: Whiteboard Practice: Implementing Channel-Based Fan-Out/Fan-In

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you're whiteboarding concurrency patterns in an interview or designing a high-throughput pipeline, Fan-Out/Fan-In is usually the gold standard. The idea is simple: you distribute work across multiple workers (Fan-Out) and then consolidate those results into a single stream (Fan-In). But there is a specific "gotcha" with the Fan-In stage that trips up almost everyone the first time they implement it.

Take a look at this attempt to process a list of integers by squaring them. I've set up a producer and multiple workers, but this code will deadlock every single time.

package main

import (
	"fmt"
	"sync"
)

func worker(id int, jobs <-chan int, results chan<- int) {
	for n := range jobs {
		results <- n * n
	}
}

func main() {
	jobs := make(chan int, 100)
	results := make(chan int, 100)

	// Fan-out: Start 3 workers
	for w := 1; w <= 3; w++ {
		go worker(w, jobs, results)
	}

	// Send some work
	for j := 1; j <= 5; j++ {
		jobs <- j
	}
	close(jobs)

	// Fan-in: Collect results
	for i := 1; i <= 5; i++ {
		fmt.Println(<-results)
	}
}

The Leak in the Result Stream

At first glance, this looks fine. We send five numbers, we expect five results. However, notice what's happening with the results channel. The workers are reading from jobs, doing the work, and pushing to results. But who closes the results channel?

In this specific snippet, it "works" only because I hardcoded the loop to i <= 5. But in a real-world scenario, you rarely know the exact number of results you'll get (maybe some jobs fail or are filtered out). If you tried to use for res := range results to collect your data, the program would crash with a deadlock. The range loop would wait forever for a sixth result that never comes, and the workers would be sitting idle, waiting for more jobs.

The mistake here is treating the Fan-In stage as a passive collection point rather than a managed lifecycle. You can't just close the results channel inside the worker function, because you have three workers; if the first one to finish closes the channel, the other two will panic when they try to send their remaining results.

Synchronizing the Merge with WaitGroups

To fix this, we need a way to track when all workers have finished their work before we shut down the result stream. This is where sync.WaitGroup becomes essential. Instead of just launching goroutines and hoping for the best, we wrap the Fan-In logic in a coordinator.

Here is how I would restructure this to be production-ready:

package main

import (
	"fmt"
	"sync"
)

func worker(jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
	defer wg.Done() // Signal this worker is finished
	for n := range jobs {
		results <- n * n
	}
}

func main() {
	jobs := make(chan int, 100)
	results := make(chan int, 100)
	var wg sync.WaitGroup

	// Fan-out
	for w := 1; w <= 3; w++ {
		wg.Add(1)
		go worker(jobs, results, &wg)
	}

	// Producer
	go func() {
		for j := 1; j <= 5; j++ {
			jobs <- j
		}
		close(jobs)
	}()

	// Fan-in: This is the critical part
	go func() {
		wg.Wait()       // Wait for all workers to call wg.Done()
		close(results)  // Now it's safe to close the result channel
	}()

	// Now we can safely range over results without deadlocking
	for res := range results {
		fmt.Println(res)
	}
}

The magic is in that second anonymous goroutine. By moving wg.Wait() and close(results) into their own background routine, we prevent the main thread from blocking. The main thread can immediately start draining the results channel. Once the workers finish and the WaitGroup hits zero, the channel closes, the range loop terminates naturally, and the program exits cleanly.

I've found that the "Wait-and-Close" pattern is the most reliable way to implement Fan-In. It decouples the production of data from the consumption of data, ensuring that you never close a channel while a worker is still trying to write to it.




📋 Practical Task

Exercise: Building a Concurrent URL Status Checker

You need to build a tool that checks the HTTP status of a list of URLs. Because network requests are slow, you must implement a Fan-Out/Fan-In pattern to check multiple URLs concurrently.

Requirements:

  • Create a slice of at least 5 URLs (e.g., "https://google.com", "https://github.com", etc.).
  • Implement a jobs channel that distributes these URLs to 3 worker goroutines.
  • Implement a results channel that collects a string describing the outcome (e.g., "google.com: 200 OK").
  • Use a sync.WaitGroup to ensure the results channel is closed only after all workers have completed their requests.
  • Use range` to print all results from the results channel in the main function.
  • Use the net/http package's http.Get() to perform the checks.

Goal: The program should exit gracefully once all URLs have been processed, without deadlocking or panicking.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.