Skip to Content
Course content

37: Channel Direction and Closing Channels

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

Why should I bother with channel directions if bidirectional channels just work?

I've seen this a lot. When you're first starting out, adding <- to your channel types feels like extra typing for no reason. After all, a bidirectional channel can do everything. But here's the thing: in a professional codebase, we use channel directions to communicate intent and to let the compiler catch our mistakes.

Imagine you have a function that's supposed to just log data coming out of a channel. If you pass it a bidirectional channel, that function could accidentally send data back into the channel, potentially causing a deadlock that's a nightmare to debug. By specifying <-chan, you're telling the compiler, "This function is a consumer. If I try to send something, blow up right now during compilation."

type LogMessage string

// This function only receives. It cannot accidentally send.
func logger(logs <-chan LogMessage) {
    for msg := range logs {
        fmt.Println("LOG:", msg)
    }
}

// This function only sends. It cannot accidentally read.
func producer(logs chan<- LogMessage) {
    logs <- "System started"
    logs <- "Connecting to DB..."
}

It's essentially like marking a variable as const or readonly. It makes your API honest.

How do I actually tell if a channel was closed or if it's just empty?

This is a classic Go "gotcha." If you read from a closed channel, Go doesn't panic. Instead, it immediately returns the zero value for the channel's type. If you're expecting an integer, you'll get a 0. The problem is, was that 0 actually sent by the producer, or is the channel just closed?

To solve this, Go gives us the "comma-ok" idiom. The second return value is a boolean that tells you if the value was delivered by a successful send operation or if it's a zero value because the channel is closed.

val, ok := <-ch
if !ok {
    fmt.Println("Channel is closed and empty!")
}

Now, if you're just draining a channel until it's empty, the for range loop is much cleaner because it handles this check for you under the hood. It will keep looping until the channel is closed and all remaining buffered values have been read.

Who is actually supposed to close the channel?

The rule of thumb I follow—and the one you should too—is that the sender should close the channel. Never close a channel from the receiver side, and never close it from a third party that doesn't "own" the sending logic.

Why? Because sending to a closed channel causes a panic. If the receiver closes the channel while the sender is still trying to push data, your whole program crashes. The sender, however, knows exactly when there is no more data to provide. Once the sender is done, they call close(ch), and the receiver sees the ok == false signal and shuts down gracefully.

I'll give you a quick example of this flow in a real-world scenario: a simple task distributor.

func worker(tasks <-chan int) {
    for t := range tasks {
        fmt.Printf("Processing task %d\n", t)
    }
    fmt.Println("Worker: No more tasks, shutting down.")
}

func main() {
    tasks := make(chan int, 10)
    go worker(tasks)

    for i := 1; i<= 3; i++ {
        tasks <- i
    }
    
    // The sender closes the channel
    close(tasks) 
    
    // We need a way to wait here, otherwise main exits immediately
    time.Sleep(time.Second) 
}



📋 Practical Task

Implement a Concurrent Order Pipeline

You are building a simplified order processing system. Your goal is to ensure the system shuts down cleanly without panicking or leaking goroutines.

  • Create a function produceOrders(orders chan<- int) that sends 5 order IDs (1 through 5) into the channel and then closes it.
  • Create a function processOrders(orders <-chan int) that reads from the channel using a for range loop and prints "Processing order X" for each.
  • In your main function, initialize the channel, start the processor in a goroutine, and call the producer.
  • Challenge: Use a sync.WaitGroup in main to ensure the program doesn't exit until the processOrders function has finished draining the channel and exited its loop.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.