Go: Go Channel Communication
Channels are the core primitives of Go's concurrency model—"Don't communicate by sharing memory; share memory by communicating." Channels make data transfer between goroutines as safe and elegant as piping.
If goroutines are the "people" of Go concurrency, then channels are the "phone lines" connecting them. In this lesson, you'll master all the core uses of channels and learn about common pitfalls.
1. You will learn
- Creating a channel (
make(chan T)unbuffered /make(chan T, n)buffered) - Sending
ch <- vand receivingv := <-ch - Synchronization behavior of unbuffered channels
- Asynchronous behavior of buffered channels
closecloses the channel andrangestops iterating- Directional restrictions:
chan<-is write-only;<-chanis read-only - Analysis of Common Deadlock Scenarios
- Introduction to Select Multiplexing
- Comprehensive Case Study on the Producer-Consumer Pipeline
2. A True Story of a Backend Engineer
(1) Pain Point: Shared memory + mutex code is ugly and prone to deadlocks
Alice is a واجهة خلفية engineer on the payments team, and she needs to pass transaction data between multiple goroutines:
"I used
sync.Mutexto protect a shared[]Transactionslice, with 5 goroutines writing to it and 3 goroutines reading from it. Out of 100 lines of code, 30 are Lock/Unlock statements. When the reviewer saw it, they immediately said, 'I can't review this code—I wouldn't dare touch a single line of it.'"
Her code looks like this:
// Shared memory approach: lock on every read/write, hard to maintain
type TransactionPool struct {
mu sync.Mutex
items []Transaction
}
func (p *TransactionPool) Add(t Transaction) {
p.mu.Lock()
defer p.mu.Unlock()
p.items = append(p.items, t)
}
func (p *TransactionPool) Get() Transaction {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.items) == 0 {
return Transaction{}
}
item := p.items[0]
p.items = p.items[1:]
return item
}
(2) Solution in Go: Passing data via a channel
// channel_approach.go
package main
import "fmt"
type Transaction struct {
ID سلسلة
Amount float64
}
func main() {
// Create an unbuffered channel
ch := make(chan Transaction)
// Producer goroutine
go func() {
tx := Transaction{ID: "TXN-001", Amount: 99.99}
ch <- tx // Send (blocks until receiver is ready)
fmt.Println("Producer: send complete")
}()
// Consumer
tx := <-ch // Receive (blocks until sender is ready)
fmt.Printf("Consumer: received %s ($%.2f)\n", tx.ID, tx.Amount)
}
Output:
Consumer: received TXN-001 ($99.99)
Producer: send complete
(3) Performance: Channel vs. Shared Memory
| Dimension | Shared Memory + Mutex | Channel |
|---|---|---|
| Code size | 30 lines of قالب code | 1 line ch <- v |
| Thread Safety | Manual Lock/Unlock | Language-Native Guarantees |
| Coupling | High (coupled via shared variables) | Low (depends only on the chan interface) |
| Testability | Requires a mock Mutex | Test directly using a channel |
| Risk of Deadlock | High (Lock Ordering Issues) | Some deadlocks can be detected at compile time |
3. Creating Channels and Basic Operations
(1) Create a channel
package main
import "fmt"
func main() {
// Unbuffered channel (synchronous)
ch1 := make(chan int)
// Buffered channel (asynchronous, capacity 3)
ch2 := make(chan string, 3)
// nil channel (cannot be used directly)
var ch3 chan float64
fmt.Printf("ch1: %T, unbuffered\n", ch1)
fmt.Printf("ch2: %T, buffer=%d\n", ch2, cap(ch2))
fmt.Printf("ch3: %T, nil=%v\n", ch3, ch3 == nil)
}
(2) Sending and Receiving
ch := make(chan int)
// Send: ch <- value
ch <- 42 // Unbuffered: blocks until someone receives
// Receive: value := <-ch
value := <-ch // Blocks until someone sends
// Discard received value
<-ch // Receive only, ignore value (used for synchronization signals)
▶ Example: Synchronization behavior of a unbuffered channel
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
fmt.Println("goroutine: ready to send...")
ch <- "hello" // Blocks until main receives
fmt.Println("goroutine: send complete")
}()
time.Sleep(1 * time.Second) // Simulate delay
fmt.Println("main: ready to receive...")
msg := <-ch
fmt.Printf("main: received %q\n", msg)
}
Output:
goroutine: ready to send...
main: ready to receive...
goroutine: send complete
main: received "hello"
4. Unbuffered vs. Buffered Channels
(1) Asynchronous behavior of buffered channels
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 3) // Capacity 3
go func() {
for i := 1; i <= 5; i++ {
ch <- i
fmt.Printf("Sent %d (len=%d)\n", i, len(ch))
}
close(ch)
}()
time.Sleep(500 * time.Millisecond)
for v := range ch {
fmt.Printf("Received %d\n", v)
time.Sleep(200 * time.Millisecond)
}
}
Output:
Sent 1 (len=0)
Sent 2 (len=1)
Sent 3 (len=2)
Sent 4 (len=3)
Sent 5 (len=4)
Received 1
Received 2
Received 3
Received 4
Received 5
▶ Example: Block when the buffer is full
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int, 2) // Capacity 2
ch <- 1 // ✅ Does not block (has space)
ch <- 2 // ✅ Does not block (has space)
// ch <- 3 // ❌ Blocks! Buffer is full, waiting for receiver
go func() {
time.Sleep(100 * time.Millisecond)
fmt.Println("goroutine: starting to receive")
<-ch // Frees up one slot
}()
fmt.Println("main: sending 3")
ch <- 3 // Now does not block (goroutine has received one)
fmt.Println("main: send complete")
close(ch)
for v := range ch {
fmt.Printf("v=%d\n", v)
}
}
(3) Comparison of Bufferless vs. Buffered
| Feature | Unbuffered make(chan T) |
Buffered make(chan T, n) |
|---|---|---|
| Send Operation | Block until the receiver is ready | Do not block if there is space in the مخزن مؤقت |
| Receive Behavior | Block until the sender is ready | Do not block when data is in the مخزن مؤقت |
| Synch/Async | Synch (Handshake) | Async (Queue) |
| Capacity | 0 | n |
| Typical Scenarios | Synchronization Signals, Coordination Between Goroutines | Task Queues, Pipelines |
5. close and range iterate
(1) close: Close the channel
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch) // Cannot send after closing
// Can still receive remaining data after closing
v1 := <-ch // 1
v2 := <-ch // 2
v3 := <-ch // 0 (zero value, ok=false)
fmt.Println(v1, v2, v3)
}
▶ Example: comma-ok checks whether the channel is closed
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
ch <- 20
close(ch)
// comma-ok: ok=false means channel is closed and has no data
v, ok := <-ch
fmt.Printf("v=%d, ok=%v\n", v, ok) // v=10, ok=true
v, ok = <-ch
fmt.Printf("v=%d, ok=%v\n", v, ok) // v=20, ok=true
v, ok = <-ch
fmt.Printf("v=%d, ok=%v\n", v, ok) // v=0, ok=false (closed and empty)
}
(3) Iterate through the channel using a for-range loop
package main
import "fmt"
func main() {
ch := make(chan string, 3)
ch <- "Alice"
ch <- "Bob"
ch <- "Charlie"
close(ch)
// for range automatically exits when the channel is closed
for name := range ch {
fmt.Printf("Hello, %s!\n", name)
}
// Equivalent to:
// for {
// name, ok := <-ch
// if !ok { break }
// fmt.Println(name)
// }
}
for range handles this automatically. The sender is always responsible for calling close.
6. Directional Restrictions
(1) The دالة parameter specifies the channel direction
package main
import "fmt"
// Write-only channel (can only send)
func producer(out chan<- int) {
for i := 1; i <= 3; i++ {
out <- i // ✅ Can only send
}
close(out)
}
// Read-only channel (can only receive)
func consumer(in <-chan int) {
for v := range in {
fmt.Printf("Received: %d\n", v) // ✅ Can only receive
}
}
func main() {
ch := make(chan int, 3)
go producer(ch) // Automatically converts to chan<- int
consumer(ch) // Automatically converts to <-chan int
}
▶ Example: Directional Constraints in Practice (Pipeline Pattern)
package main
import (
"fmt"
"strings"
)
// Stage 1: Write-only
func stage1(names []سلسلة, out chan<- سلسلة) {
for _, name := range names {
out <- strings.ToUpper(name)
}
close(out)
}
// Stage 2: Read-write (read from in, process, write to out)
func stage2(in <-chan سلسلة, out chan<- سلسلة) {
for name := range in {
out <- "Hello, " + name + "!"
}
close(out)
}
// Stage 3: Read-only
func stage3(in <-chan سلسلة) {
for msg := range in {
fmt.Println(msg)
}
}
func main() {
ch1 := make(chan سلسلة, 3)
ch2 := make(chan سلسلة, 3)
names := []سلسلة{"Alice", "Bob", "Charlie"}
go stage1(names, ch1)
go stage2(ch1, ch2)
stage3(ch2)
}
Output:
Hello, ALICE!
Hello, BOB!
Hello, CHARLIE!
(3) Usage with directional restrictions
| declare | Permissions | Purpose |
|---|---|---|
ch chan T |
Read/Write | Variable Declaration |
ch chan<- T |
Write-only | Producer دالة argument |
ch <-chan T |
Read-only | Consumer دالة argument |
7. Introduction to Select Multiplexing
(1) SELECT Basics
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(100 * time.Millisecond)
ch1 <- "from ch1"
}()
go func() {
time.Sleep(200 * time.Millisecond)
ch2 <- "from ch2"
}()
select {
case msg := <-ch1:
fmt.Println(msg)
case msg := <-ch2:
fmt.Println(msg)
case <-time.After(300 * time.Millisecond):
fmt.Println("Timeout")
}
}
select is similar to switch but is used for channels—whichever channel in a case becomes ready first is executed. If multiple channels become ready at the same time, one is chosen at random. select is the ultimate tool for concurrent programming in Go; we'll cover it in depth in Lesson 15.
▶ Example: Implementing timeout control in SELECT statements
package main
import (
"fmt"
"time"
)
func longOperation(result chan<- سلسلة) {
time.Sleep(3 * time.Second)
result <- "Done"
}
func main() {
result := make(chan سلسلة)
go longOperation(result)
select {
case res := <-result:
fmt.Println(res)
case <-time.After(1 * time.Second):
fmt.Println("Operation timed out!")
}
}
Output:
Operation timed out!
8. Common Deadlock Scenarios
// Deadlock 1: Unbuffered channel with send but no receive
func deadlock1() {
ch := make(chan int)
ch <- 42 // fatal error: all goroutines are asleep - deadlock!
}
// Deadlock 2: main goroutine waiting on itself
func deadlock2() {
ch := make(chan int)
<-ch // fatal error: deadlock
}
// Deadlock 3: Multiple goroutines waiting on each other
func deadlock3() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
<-ch1 // Wait for ch1
ch2 <- 1
}()
<-ch2 // Wait for ch2
ch1 <- 1 // Never reaches this line
}
(1) Quick Reference: 4 Types of Deadlock Scenarios
| Scenario | Cause | Solution |
|---|---|---|
| Send-only, no receive | Non-buffered channel blocking | Ensure there are consumers |
| Receive only, no send | Channel is empty and there is no sender | Ensure there is a producer |
| Goroutines waiting on each other | A waits on B's channel, B waits on A's channel | Redesigning the dependency order |
| Operations on nil channels | Permanently blocking on/from a nil channel | Initializing a channel |
9. Complete Example: Producer-Consumer Pipeline
// pipeline.go
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
// Work unit
type Job struct {
ID int
Payload سلسلة
}
type Result struct {
Job Job
Output سلسلة
Err خطأ
Duration time.Duration
}
// Producer: generates jobs (write-only channel)
func producer(jobs chan<- Job, count int) {
for i := 1; i <= count; i++ {
jobs <- Job{
ID: i,
Payload: fmt.Sprintf("task-%d", i),
}
time.Sleep(time.Duration(rand.Intn(50)) * time.Millisecond)
}
close(jobs)
}
// Consumer: processes jobs (read jobs, write results)
func consumer(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
start := time.Now()
// Simulate processing
time.Sleep(time.Duration(50+rand.Intn(100)) * time.Millisecond)
output := fmt.Sprintf("consumer-%d processed %s", id, job.Payload)
results <- Result{
Job: job,
Output: output,
Duration: time.Since(start),
}
}
}
// Collector: collects and prints results (read-only channel)
func collector(results <-chan Result, done chan<- struct{}) {
var total time.Duration
count := 0
for r := range results {
count++
total += r.Duration
حالة := "OK"
if r.Err != nil {
حالة = "ERR"
}
fmt.Printf("[%s] Job#%d: %s (%v)\n",
حالة, r.Job.ID, r.Output, r.Duration)
}
if count > 0 {
fmt.Printf("\nTotal %d tasks, avg %.2f ms\n",
count, float64(total.Milliseconds())/float64(count))
}
close(done)
}
func main() {
const (
jobCount = 20
workerCount = 3
)
jobs := make(chan Job, 10)
results := make(chan Result, 10)
done := make(chan struct{})
// 1 producer
go producer(jobs, jobCount)
// N consumers
var wg sync.WaitGroup
for w := 1; w <= workerCount; w++ {
wg.Add(1)
go consumer(w, jobs, results, &wg)
}
// Wait for all consumers to finish, then close results
go func() {
wg.Wait()
close(results)
}()
// Collector (main goroutine)
collector(results, done)
<-done
}
Expected Output:
[OK] Job#1: consumer-2 processed task-1 (85ms)
[OK] Job#2: consumer-1 processed task-2 (92ms)
[OK] Job#3: consumer-3 processed task-3 (78ms)
...
[OK] Job#20: consumer-1 processed task-20 (112ms)
Total 20 tasks, avg 87.45 ms
sequenceDiagram
participant P as Producer
participant C as Channel
participant W as Consumer
Note over P: Sender is responsible for close
P->>C: ch <- job (send)
activate C
Note over C: Unbuffered: sync wait<br/>Buffered: enqueue
C->>W: job := <-ch (receive)
deactivate C
Note over W: Process task
W-->>P: Continue loop
Note over P: close(ch)
Note over C: Remaining data readable
Note over W: for range auto-exit
❓ FAQ
v, ok := <-ch, an ok value of false indicates that the channel is closed and has no data left.chan<- and <-chan?hchan struct in the Go source code runtime/chan.go.select use channels?select listens to multiple channels simultaneously; it executes the case for whichever channel becomes ready first. If multiple channels become ready at the same time, it selects one at random. If none of the channels are ready and a default case is specified, the default case is executed; otherwise, the process blocks and waits. select is key to timeout control and non-blocking operations.select with a default clause to implement non-blocking operations; use close to notify the receiver that the channel has been closed.sem := make(chan struct{}, 10), send sem <- struct{}{} before the operation, and receive <-sem upon completion. It blocks when the buffer is full—providing natural rate limiting.📖 Summary
- Creating a channel:
make(chan T)(unbuffered),make(chan T, n)(buffered) - Sending
ch <- vmay block; receivingv := <-chmay block - Unbuffered = synchronous handshake; buffered = asynchronous queue
close(ch)is called by the sender; the receiver iterates over it usingfor rangev, ok := <-chChecks whether the channel is closed- Directional restrictions:
chan<-is write-only,<-chanis read-only (checked at compile time) - Use
selectmultiplexing to listen to multiple channels - Common deadlocks: no counterpart, circular dependencies, nil channel
📝 Exercises
-
Basic Problem (Difficulty ⭐): Write a program that starts a goroutine to send the ten digits 0 through 9 to an unbuffered channel, while the main goroutine receives and prints them. Observe the alternating order of sending and receiving.
-
Advanced Problem (Difficulty ⭐⭐): Implement a fan-out pattern: A producer sends 100 tasks to a channel; launch 5 consumer goroutines to read and process tasks from the same channel, with each consumer printing its own ID and the task number. You must use a buffered channel,
close, andfor range. -
Challenge (Difficulty ⭐⭐⭐): Implement a three-stage pipeline: Stage 1 generates random integers (
[]int); Stage 2 filters out even numbers; Stage 3 calculates the sum of squares. Each stage is an independent goroutine connected via channels. Requirements: (1) Use directional constraints between stages; (2) Support dynamic adjustment of the number of stages; (3) Implement an elegant exit usingselect.