没有不值得去解决的问题,也没有不值得去学习的技术!

A Deep Dive Into Go Channel Blocking: From Deadlocks to Asynchronous Processing

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)

Go
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:

Plaintext
/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:

Plaintext
/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.

系列导航

关于作者 · 持续学习与技术实践

我是一名拥有 15 年以上开发经验的技术从业者,自 2013 年起持续运营个人技术博客,记录工作与个人项目中遇到的真实问题,以及解决问题过程中的探索、实践和思考。

除了技术写作,我也在持续开发和维护自己的独立项目,探索新技术和新工具在实际场景中的应用。我始终相信,没有不值得去解决的问题,也没有不值得去学习的技术。

如果这篇文章对你有所帮助,欢迎浏览博客中的其他文章,也欢迎交流技术经验与想法。

关于我  ·  GitHub  ·  邮件联系