In Go’s concurrent programming, channels act as the bridge connecting goroutines. Many beginners encounter a “deadlock” error when using unbuffered channels, or they feel confused about the channel blocking mechanism.
Through a specific case, this article compares two different channel usage scenarios to deeply analyze the blocking principles of Go channels and the logic behind main thread termination.
1. Case Background
We simulate a classic producer-consumer model:
1. writeData goroutine: Responsible for writing data to intChan.
2. readData goroutine: Responsible for reading data from intChan.
3. exitChan channel: Used to notify the main thread that all tasks are complete.
Reference: Go Review Notes: Goroutine and Channel Collaboration (Solving Locking and Sleep Pitfalls)
package main
import (
"fmt"
// 此处无需引入time包,彻底摆脱手动休眠的依赖
)
// writeData 协程:向intChan写入50个整数(生产者)
func writeData(intChan chan int) {
// 循环写入50个整数,逻辑简单但需注意循环边界
for i := 1; i <= 50; i++ {
// 向管道写入数据,有缓冲管道会自动调节写入节奏
intChan <- i
fmt.Println("writeData ", i)
// 此处可注释time.Sleep,验证Channel的缓冲特性,无需手动控制写入速度
// time.Sleep(time.Second)
}
// 关键操作:写入完成后关闭管道,告知readData协程“数据已写完”
close(intChan)
}
// readData 协程:从intChan读取数据,读取完成后向exitChan发送信号(消费者)
func readData(intChan chan int, exitChan chan bool) {
// 无限循环读取管道数据,直到管道关闭且无数据可读
for {
// 核心语法:v接收数据,ok判断管道是否关闭(true=有数据/未关闭,false=管道关闭且无数据)
v, ok := <-intChan
if !ok { // 管道关闭且无数据,说明读取完成,退出循环
break
}
fmt.Printf("readData 读到数据=%v\n", v)
}
// readData 读取完数据后,即任务完成
exitChan <- true
// 关闭exitChan(可选,此处关闭是为了让主线程读取信号后正常退出,避免阻塞)
close(exitChan)
}
func main() {
// 创建两个管道
// 1. 创建数据管道intChan:有缓冲管道,容量设为10,平衡读写节奏
// 复盘:容量可根据实际需求调整,此处10足够承载writeData的写入速度,避免频繁阻塞
intChan := make(chan int, 10)
// 2. 创建信号管道exitChan:用于传递“协程完成”信号,容量设为1即可(仅需传递1个信号)
exitChan := make(chan bool, 1)
// 启动两个协程,共享同一个intChan,实现协同工作
go writeData(intChan)
go readData(intChan, exitChan)
// 主线程监听exitChan,等待readData协程发送完成信号,精准退出
// 复盘:此处替代了此前的time.Sleep,彻底解决休眠时间估算不准的问题
for {
_, ok := <-exitChan
if !ok { // exitChan关闭且无信号,说明所有协程都已完成
break
}
}
// 主线程退出前可添加提示,验证协同效果
fmt.Println("所有协程执行完成,主线程正常退出")
}2. Scenario 1: Deadlock Caused by a Missing Consumer
First, let’s look at the first scenario. In the main function, we only start the write goroutine and comment out the read goroutine:
// go readData(intChan, exitChan)
The execution result is as follows:
/app/go-atguigu/channel-apply # go run main.go
writeData 1
writeData 2
writeData 3
writeData 4
writeData 5
writeData 6
writeData 7
writeData 8
writeData 9
writeData 10
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.main()
/app/go-atguigu/channel-apply/main.go:54 +0x8b
goroutine 7 [chan send]:
main.writeData(0x2e915bc56000)
/app/go-atguigu/channel-apply/main.go:13 +0x39
created by main.main in goroutine 1
/app/go-atguigu/channel-apply/main.go:48 +0x7c
exit status 2
Cause Analysis:
1. Characteristics of unbuffered channels: intChan is an unbuffered channel. This means the send operation (write) and the receive operation (read) must both be ready simultaneously to complete. If there is only a sender and no receiver, the sender will block permanently.
2. Blockage of the writer: When the writeData goroutine writes the 11th piece of data to the channel (or when the buffer is full or there is no receiver, depending on the specific implementation), it blocks on the send operation because there is no readData goroutine receiving on the other end.
3. Blockage of the main thread: The main thread executes <-exitChan to wait for an exit signal. Because writeData is blocked, it can never reach the step to close the channel or send an exit signal; and since readData is not started at all, it naturally will not send a signal either.
4. Deadlock detection: The Go runtime detects that all active goroutines (the main thread and writeData) are in a blocked waiting state, with no possibility of being woken up, and thus throws fatal error: all goroutines are asleep – deadlock!.
Conclusion: When using unbuffered channels, you must ensure that a corresponding receiver is running; otherwise, the send operation will cause the program to crash.
3. Scenario 2: Asynchronous Processing with Mismatched Read/Write Speeds
In the second scenario, we start the readData goroutine normally, but we add time.Sleep(time.Second) into the read logic to simulate a scenario of “fast writing, slow reading.” The execution result is as follows:
/app/go-atguigu/channel-apply # go run main.go
writeData 1
writeData 2
writeData 3
writeData 4
readData read data=1
writeData 5
writeData 6
writeData 7
writeData 8
writeData 9
writeData 10
writeData 11
readData read data=2
writeData 12
readData read data=3
writeData 13
readData read data=4
writeData 14
readData read data=5
writeData 15
readData read data=6
writeData 16
readData read data=7
writeData 17
readData read data=8
writeData 18
readData read data=9
writeData 19
readData read data=10
writeData 20
readData read data=11
writeData 21
readData read data=12
writeData 22
readData read data=13
writeData 23
readData read data=14
writeData 24
readData read data=15
writeData 25
readData read data=16
writeData 26
readData read data=17
writeData 27
readData read data=18
writeData 28
readData read data=19
writeData 29
readData read data=20
writeData 30
readData read data=21
writeData 31
readData read data=22
writeData 32
readData read data=23
writeData 33
readData read data=24
writeData 34
readData read data=25
writeData 35
readData read data=26
writeData 36
readData read data=27
writeData 37
readData read data=28
writeData 38
readData read data=29
writeData 39
readData read data=30
writeData 40
readData read data=31
writeData 41
readData read data=32
writeData 42
readData read data=33
writeData 43
readData read data=34
writeData 44
readData read data=35
writeData 45
readData read data=36
writeData 46
readData read data=37
writeData 47
readData read data=38
writeData 48
readData read data=39
writeData 49
readData read data=40
writeData 50
readData read data=41
readData read data=42
readData read data=43
readData read data=44
readData read data=45
readData read data=46
readData read data=47
readData read data=48
readData read data=49
readData read data=50
All goroutines finished execution, main thread exited normally
Phenomenon Interpretation:
1. Why is there no deadlock?
Although reading is slow, the readData goroutine is indeed running. Whenever readData takes a piece of data out of the channel, writeData’s blockage is lifted, allowing it to continue writing the next piece of data. This “handshake” mechanism ensures the flow of the program.
2. The secret behind the output order:
You will notice that the output is not strictly “write one, read one.” This is because writeData executes much faster than readData.
– writeData quickly writes data and attempts to write the next piece. If the previous piece of data has not been taken away, it will block and wait at the channel send operation.
– Once readData wakes up and takes the data, writeData immediately resumes and might instantly write several more pieces of data until it blocks again.
– This visual effect of alternating execution reflects the Go scheduler switching between different goroutines.
3. Why can the main thread exit normally?
The key lies in exitChan.
– After writeData finishes writing all the data, it closes intChan.
– readData uses a range loop or detects the channel closure, and after processing all remaining data, it sends a signal to exitChan.
– After the main thread receives the signal from <-exitChan, it is no longer blocked, smoothly executes the subsequent code, and exits.
4. Summary of Core Concepts
1. Synchronization and communication: Unbuffered channels are not just pipelines for data transmission, but also synchronization tools. They force the sender and receiver to “meet” at the same moment.
2. The essence of deadlock: Deadlock usually occurs during “circular waiting” or when “all goroutines are waiting for an event that can never happen.” In channel operations, the most common cause is the lack of a paired receiver or sender.
3. Production environment recommendations:
– Avoid performing potentially blocking channel operations directly in the main thread, unless you are certain that a background goroutine is cooperating.
– If you want to decouple the speeds of production and consumption, consider using a buffered channel (make(chan int, buffer_size)). This allows the producer to continuously write until the buffer is full, improving concurrent throughput.
– Always design a proper exit mechanism (such as using exitChan or context.Context) to prevent goroutine leaks or infinite waiting in the main thread.
5. Conclusion
Through this simple comparative experiment, we can see the power and rigor of Go’s channel mechanism. Understanding that “blocking” is not a bad thing—it is the foundation for Go to achieve efficient concurrent synchronization. As long as you properly pair goroutines and channels, you can build concurrent programs that are both safe and efficient.
