Skip to Content
Course content

30: Channels and Select

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

I want to start today with a piece of code that looks perfectly reasonable at first glance. You've probably written something like this if you've started playing with goroutines. We're trying to fetch a result from a worker, but we want to make sure the program doesn't hang forever if the worker gets stuck.

package main

import (
	"fmt"
	"time"
)

func fetchData(ch chan string) {
	time.Sleep(2 * time.Second) // Simulate a slow API call
	ch <- "Data received!"
}

func main() {
	ch := make(chan string)

	go fetchData(ch)

	select {
	case res := <-ch:
		fmt.Println(res)
	case <-time.After(1 * time.Second):
		fmt.Println("Timeout reached!")
	}
}

The Invisible Goroutine Leak

If you run this, you'll see "Timeout reached!" printed to the console. You might think, "Great, my timeout works." But there is a serious bug here that wouldn't show up in a small script, but would crash a production server over time: a goroutine leak.

Here is what's happening: fetchData is using an unbuffered channel. In Go, sending to an unbuffered channel blocks the sender until a receiver is ready to take the value. In our main function, the select statement waited for one second, hit the timeout case, and then the main function exited (or moved on).

When fetchData finally finishes its sleep after two seconds and tries to send "Data received!" into ch, there is no one left to receive it. That goroutine will stay blocked forever, clinging to its memory, waiting for a receiver that will never come. I've spent hours debugging "mysterious" memory growth in Go apps only to find thousands of these zombie goroutines.

Fixing the Leak with Buffered Channels

The simplest fix here is to give the channel a buffer. By changing the initialization to make(chan string, 1), we tell Go: "Allow one value to be sent into this channel even if no one is reading it yet."

// The fix: add a buffer of 1
ch := make(chan string, 1)

Now, when fetchData sends its result, it drops the value into the buffer and exits immediately. It doesn't matter if the main function has already timed out and stopped listening; the sender is no longer blocked. Always ask yourself: "If the receiver stops listening, will the sender be stuck forever?"

Multiplexing with Select

The select statement is essentially a switch for channels. It lets a goroutine wait on multiple communication operations. The first one that becomes "ready" wins, and its block is executed. If multiple are ready, Go picks one at random.

This is incredibly powerful for patterns like "first-response wins." Imagine you're querying three different DNS servers; you don't care which one answers first, you just want the fastest result. You can spin up three goroutines, have them all send to the same channel, and use select to grab the first one that arrives.

Handling Non-Blocking Operations

By default, select blocks until one of its cases can run. But sometimes, you don't want to wait. You want to check if a channel has a value, and if it doesn't, move on immediately. This is where the default case comes in.

select {
case msg := <-ch:
    fmt.Println("Received:", msg)
case <-time.After(100 * time.Millisecond):
    fmt.Println("Too slow")
default:
    fmt.Println("No data available right now")
}

Adding a default case makes the select non-blocking. If no channels are ready to communicate at the exact microsecond the code hits the select, it executes the default block. Use this sparingly; putting a non-blocking select inside a for loop without a sleep or a throttle is a great way to accidentally pin your CPU to 100%.




📋 Practical Task

Exercise: Implementing a Concurrent API Race

You need to build a system that fetches a "Price Quote" from three different simulated providers. Since these providers can be flaky, your goal is to take the result from the first provider that responds, but stop waiting after a total of 500 milliseconds.

Requirements:

  • Create a function fetchQuote(name string, delay time.Duration, ch chan string) that sleeps for the given duration and then sends a string (e.g., "Price from Provider A: $100") into the channel.
  • In main, initialize a buffered channel to prevent goroutine leaks.
  • Launch three goroutines calling fetchQuote with different delays:
    • Provider A: 200ms
    • Provider B: 600ms
    • Provider C: 100ms
  • Use a select block to print the first result that arrives.
  • Include a timeout case that prints "All providers timed out" if no one responds within 500ms.

Hint: Ensure your channel buffer is large enough to handle all providers sending their results, even if you only read one of them.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.