Channels: Communication, Buffering and Direction
Module 3: Concurrency | Lesson: m03-l02 | Level: Intermediate After this lesson you will be able to: create, communicate over, buffer and close channels correctly, understand the constraints of unidirectional channels and the causes of deadlocks, and write safe concurrent communication code.
Learning Objectives
- Use
makecorrectly to create unbuffered and buffered channels, and explain the difference in their blocking behavior. - Use
closecorrectly and consume data safely withrange. - Understand the purpose of unidirectional channels and recognize common deadlock scenarios.
- Write concurrent code following the "the sender closes" principle, avoiding goroutine leaks.
Core Concepts
1.1 Channel Basics and Syntax
A channel is Go's core mechanism for communication between goroutines, following the principle: "Do not communicate by sharing memory; instead, share memory by communicating."
Syntax essentials:
- Create: use the make function.
- Send: ch <- value sends value into channel ch.
- Receive: value := <-ch receives a value from channel ch into value; if you only want to block until a value arrives and discard it, write <-ch.
Note:
value <- chis illegal becausevalueis not a channel type. The receive operator<-must come before the channel name.
1.2 Unbuffered vs Buffered
Channels come in two kinds: unbuffered and buffered.
-
Unbuffered channel (
make(chan T)): - Send and receive are synchronous. The sender blocks until a receiver is ready, and the receiver blocks until a sender is ready. - Fits scenarios that need strict synchronization between two goroutines. -
Buffered channel (
make(chan T, n)): - Holds an internal queue of sizen. - When the buffer is not full, sending is non-blocking; when the buffer is not empty, receiving is non-blocking. - When the buffer is full, sending blocks; when the buffer is empty, receiving blocks. - Fits decoupling the send and receive rates, acting as flow control or a buffer.
1.3 Closing Semantics and range
A channel can be closed explicitly to signal that no more data will be sent on it.
- Close: use
close(ch). - Read: a closed channel can still be read, but it returns the zero value and the second return value (ok) is
false. - range:
for v := range chautomatically reads values from the channel until it is closed. This is the most elegant way to consume a channel.
Important rules:
- Never send on a closed channel: it raises a panic.
- Never close a receive-only channel (<-chan T): a compile error.
- A send-only channel (chan<- T) can be closed — this is what makes the "sender closes" principle work in practice.
1.4 Blocking Semantics and Deadlock Detection
When a goroutine tries to send on a full buffered channel, or receive from an empty unbuffered/buffered channel, the goroutine enters the blocked state.
If all goroutines are blocked waiting on channel operations and no goroutine can unblock them, the Go runtime detects that every goroutine is stuck and raises a fatal error (not a recoverable panic), and the process exits. This is a deadlock.
1.5 Unidirectional Channels and Channels as Parameters
In a function signature, you can declare a channel as unidirectional to restrict its direction of use:
- chan<- int: a send-only channel; it can only be used to send data.
- <-chan int: a receive-only channel; it can only be used to receive data.
In practice: unidirectional channels are mainly used for function parameters, explicitly expressing "this function only reads" or "this function only writes", improving readability and type safety. Note that unidirectional types only appear in function parameters or variable declarations; you cannot convert them back to a bidirectional channel.
1.6 A Preview of select
The select statement listens on multiple channel operations at once and is the core tool for multiplexing. It is covered in detail in the next lesson.
Example Code
Example 1: Blocking Behavior of Unbuffered and Buffered Channels
This example shows the synchronous blocking of unbuffered channels and the blocking of buffered channels when the buffer is full.
package main
import (
"fmt"
)
func main() {
// 1. Unbuffered channel: sender and receiver must both be ready
chSync := make(chan string)
// start a goroutine to receive
go func() {
// this blocks until main sends
msg := <-chSync
fmt.Println("Received:", msg)
}()
// send; the receiver is already ready, so this does not block for long
chSync <- "Hello Sync"
fmt.Println("Sent to sync channel")
// 2. Buffered channel: sending blocks when the buffer is full
chBuf := make(chan string, 2)
chBuf <- "A"
chBuf <- "B"
fmt.Println("Buffer full, sending C will block")
// use select to attempt a non-blocking send
select {
case chBuf <- "C":
fmt.Println("Sent C")
default:
fmt.Println("Buffer is full, send would block")
}
// drain the buffer
<-chBuf
<-chBuf
fmt.Println("Buffer cleared")
}
Expected output:
Sent to sync channel
Received: Hello Sync
Buffer full, sending C will block
Buffer is full, send would block
Buffer cleared
Example 2: Closing a Channel and Consuming with range
This example shows the sender closing the channel and safely consuming all values with range.
package main
import (
"fmt"
"time"
)
func producer(ch chan<- int) {
defer close(ch) // the sender is responsible for closing the channel
for i := 1; i <= 3; i++ {
ch <- i
time.Sleep(100 * time.Millisecond) // simulate slow work
}
}
func main() {
ch := make(chan int)
// start the producer
go producer(ch)
// range iterates automatically until the channel is closed
for v := range ch {
fmt.Println("Received:", v)
}
fmt.Println("Channel closed, main exiting")
}
Expected output:
Received: 1
Received: 2
Received: 3
Channel closed, main exiting
Common Mistakes & Pitfalls
-
Wrong assumption: sending on a closed channel is safe - Why: sending on a closed channel immediately raises a
panicand crashes the program. - Correct approach: make sure the channel is not closed before sending, or usedefer close(ch)at the end of the producing function. -
Bad code: reversed receive syntax - Bad code:
value <- ch- Why:valueis a variable, not a channel, and cannot receive data. - Correct approach:value := <-ch -
Wrong assumption: unidirectional channels can be converted back to bidirectional - Bad code: trying to convert
chan<- inttochan intinside a function. - Why: Go does not allow converting a unidirectional channel back to a bidirectional one. - Correct approach: use the unidirectional type only as a constraint in the function signature; inside the function, just use the passed-in bidirectional channel variable (because a bidirectional channel can be assigned to a unidirectional parameter).
Best Practices
- The sender closes:
closeshould be called by the goroutine that sends data, signaling "no more sends". Receivers should not close the channel unless they are also the only sender. - Avoid "whoever creates it closes it": the creator of a channel is not necessarily the sender. For example, if a receiver creates the channel and passes it to a sender, the sender should close it.
- Consume with
range: when you know the channel will be closed,for v := range chis the cleanest and safest way to iterate. - Size buffers carefully: buffered channels ease blocking, but an oversized buffer hides design flaws (such as a slow consumer). Prefer unbuffered channels for synchronization; add buffering only when decoupling is needed.
- Watch for deadlocks: make sure every goroutine can be woken. If a goroutine waits forever on a channel while all others have exited, you have a deadlock.
Exercises
- Concept question: explain why sending on a closed channel panics, but reading from a closed channel does not.
- Coding exercise: write a function
sum(ch chan<- int)that receives integers from the channel and returns their total. Inmain, start a goroutine that sends 1 through 10 and callsumfor the result. - Advanced/debugging exercise: analyze the following code, point out its latent problem (deadlock or leak), and fix it. ```go package main
import "fmt"
func main() { ch := make(chan int) go func() { ch <- 1 ch <- 2 }() fmt.Println(<-ch) } ```
Reference Answers
Reference Answers (click to expand / collapse)
Question 1
Sending on a closed channel panics because closing means "no more receives"; sending violates that contract and the Go runtime must forbid the illegal operation. Reading from a closed channel does not panic because reading is still legal — it just returns the zero value and false, signaling the end of the data stream.
Question 2
package main
import (
"fmt"
)
func sum(ch chan<- int) int {
var total int
for v := range ch {
total += v
}
return total
}
func main() {
ch := make(chan int)
go func() {
defer close(ch)
for i := 1; i <= 10; i++ {
ch <- i
}
}()
result := sum(ch)
fmt.Println("Sum:", result)
}
Explanation: range handles channel closing automatically, keeping the code concise and safe.
Question 3
Problem analysis: the code does not deadlock, but it has data loss and a goroutine leak risk. main receives only the value 1 and exits, so the second value 2 is discarded. If main did not exit, the goroutine would block on ch <- 2, leaking. Also, the channel is never closed, so a later range would block forever.
Fixed code:
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
defer close(ch) // the sender closes the channel
ch <- 1
ch <- 2
}()
// receive all data with range
for v := range ch {
fmt.Println(v)
}
}
Lesson Summary
- Channels are the core of Go concurrency and come in unbuffered and buffered flavors.
- Unbuffered channels synchronize; buffered channels decouple.
- The sender closes the channel, and receivers consume safely with
range. - Sending on a closed channel panics; reading from one returns the zero value.
- Unidirectional channels constrain function parameters and prevent misuse.
Next lesson preview: the select statement in depth — multiplexing and timeout handling.