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

Go Notes: How Goroutines and Channels Work Together to Avoid Locks and Sleeps

I ran the above code and compiled the core execution results (partial excerpt) for my future review: see Figure 1

In my previous Go concurrency review, I used a factorial example to analyze the drawbacks of “global variable lock synchronization,” focusing on the core issue that “locking plus sleeping still poses hidden risks.” Manually setting a sleep duration cannot precisely match the execution pace of goroutines: too long wastes resources, too short loses results, and even double locking cannot completely eliminate resource contention. In this review, I focus on how Goroutines and Channels work together. Through a complete hands-on example, I reinforce the core logic of combining the two, thoroughly resolve the lingering concurrency synchronization risks, and review the key details of goroutine-channel coordination to solidify my concurrency programming foundation.

1. Core Review Objective: Resolving the Inherent Risks of “Locking Plus Sleeping”
Looking back at the previous factorial example, I found that when using global variable lock synchronization for multi-goroutine communication, the biggest pain point is “synchronizing the main thread with goroutines.” To make the main thread wait for goroutines to finish, I had to manually add time.Sleep(). However, this approach relies entirely on subjective estimation and cannot adapt to actual system load or task complexity changes during runtime. It is essentially a “band-aid” solution.
The coordination of Goroutines and Channels is exactly the native solution Go provides for this problem—no manual locking is needed (Channels are inherently concurrency-safe), and no manual sleeping is needed (precise synchronization is achieved via the blocking characteristics of Channels). This makes communication and synchronization between multiple goroutines more efficient, secure, and flexible. My core objective in this review is to fully grasp the underlying logic of their coordination through a hands-on example and thoroughly resolve the issue that “locking plus sleeping still poses hidden risks.”

2. Coordination Example Requirements and Approach Breakdown (Personal Review Recap)
For this review, I chose a simplified version of the classic “producer-consumer” model. The requirements are clear and practical, quickly tying together the core usage of Goroutines and Channels. The specific requirements are as follows:

  1. Start a writeData goroutine to write 50 integers into the intChan channel (producer);
  2. Start a readData goroutine to read data from the same intChan channel (consumer);
  3. The main thread must wait for both the writeData and readData goroutines to finish their work before exiting normally, eliminating the problem of the main thread exiting before goroutines finish execution.
    From a personal review perspective, the core approach of this example is actually very clear. It essentially uses two major characteristics of Channels to solve synchronization problems. I have outlined two core logic points:
  • Use intChan as a data transfer channel to enable communication between the writeData and readData goroutines. This requires no shared global variables, fundamentally avoiding resource contention and replacing the “global variables + mutex” approach I used previously;
  • Use exitChan as a signal synchronization channel. After the readData goroutine finishes reading all data, it sends a “completion signal” to the main thread. By listening for the signal on exitChan, the main thread precisely waits for all goroutines to finish, replacing the manual sleep approach I used before.
    Here, I want to specifically review one detail: closing the channel (close()) is key to the coordination. After the writeData goroutine finishes writing all data, it must close intChan; otherwise, the readData goroutine will block indefinitely on the read operation, causing a deadlock. Closing exitChan, however, is optional depending on requirements. Its core purpose is to send a “goroutine complete” signal to the main thread, ensuring the main thread exits precisely. This is a point I easily overlooked in previous reviews.

3. Complete Code Implementation and Detail Recap
Based on the above approach, I put together the complete code implementation (using the provided example code) and reviewed the key details, recording the focus points of my review:

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("所有协程执行完成,主线程正常退出")
}

Key Detail Recap (Personal Review Focus)
Combining the code, I outlined a few easily overlooked but crucial details from my review perspective. These details directly determine the stability of goroutine-channel coordination and are pitfalls I encountered in previous reviews:
Detail 1: Channel Close Timing and Purpose
The writeData goroutine must call close(intChan) after writing the 50 integers. I summarized the reason: if the channel is not closed, the readData goroutine will keep executing the for loop, trying to read data from intChan. When intChan has no data, it will block indefinitely, eventually causing the entire program to deadlock.
My own additional recap: close() can only be called by the “writer”; the reader cannot close the channel. After the channel is closed, remaining data can still be read. Once finished, the ok value will become false. This is the core basis for determining whether the channel has been fully read and a syntax detail I need to keep in mind.
Detail 2: The Core Purpose of exitChan—Replacing Manual Sleep
I outlined the core purpose of exitChan: achieving “precise synchronization between the main thread and goroutines.” After the readData goroutine reads all data, it writes true to exitChan. The main thread confirms the readData goroutine is finished by listening for the signal on exitChan. The completion of the writeData goroutine is indirectly confirmed by the closure of intChan (if readData can read all data, it means writeData has finished writing and closed the channel).
This thoroughly resolves the “locking plus sleeping” risk I encountered previously—no need to estimate goroutine execution time. The main thread exits only after receiving the “completion signal,” neither wasting resources nor causing goroutines to be destroyed before execution finishes. This is also one of the core advantages of Channels over global variable locking.
Detail 3: Choosing a Buffered Channel and Its Advantages
In this example, intChan is set as a buffered channel with a capacity of 10, rather than an unbuffered channel. After reviewing, I concluded that the advantage of a buffered channel is “balancing the read/write pace”—writeData can write multiple pieces of data at once (not exceeding capacity) without waiting for readData to read immediately; readData can read data at its own pace, avoiding frequent blocking.
I tested this myself: if changed to an unbuffered channel, writeData would block directly when writing data, only continuing after readData reads the data. Although this also achieves coordination, efficiency decreases. It is more suitable for scenarios with extremely high real-time data requirements, a point I also recorded in my review notes.
Detail 4: The Binding Logic of Goroutines and Channels
Both goroutines operate on the same intChan, which is the core of inter-goroutine communication—Channels act as a “bridge” to achieve safe data transfer. No global variables are shared, so no manual mutex locks are needed, fundamentally avoiding resource contention issues. This is the core advantage of Channels over “global variables + locking” and the core logic I need to reinforce in this review.

4. Execution Results and Problem Verification
I ran the above code and compiled the core execution results (partial excerpt) for my future review: see Figure 1.

I ran the above code and compiled the core execution results (partial excerpt) for my future review: see Figure 1
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
writeData  11
readData 读到数据=1
readData 读到数据=2
readData 读到数据=3
readData 读到数据=4
readData 读到数据=5
readData 读到数据=6
readData 读到数据=7
readData 读到数据=8
readData 读到数据=9
readData 读到数据=10
readData 读到数据=11
readData 读到数据=12
writeData  12
writeData  13
writeData  14
writeData  15
writeData  16
writeData  17
writeData  18
writeData  19
writeData  20
writeData  21
writeData  22
writeData  23
readData 读到数据=13
readData 读到数据=14
readData 读到数据=15
readData 读到数据=16
readData 读到数据=17
readData 读到数据=18
readData 读到数据=19
readData 读到数据=20
readData 读到数据=21
readData 读到数据=22
readData 读到数据=23
readData 读到数据=24
writeData  24
writeData  25
writeData  26
writeData  27
writeData  28
writeData  29
writeData  30
writeData  31
writeData  32
writeData  33
writeData  34
writeData  35
readData 读到数据=25
readData 读到数据=26
readData 读到数据=27
readData 读到数据=28
readData 读到数据=29
readData 读到数据=30
readData 读到数据=31
readData 读到数据=32
readData 读到数据=33
readData 读到数据=34
readData 读到数据=35
readData 读到数据=36
writeData  36
writeData  37
writeData  38
writeData  39
writeData  40
writeData  41
writeData  42
writeData  43
writeData  44
writeData  45
writeData  46
writeData  47
readData 读到数据=37
readData 读到数据=38
readData 读到数据=39
readData 读到数据=40
readData 读到数据=41
readData 读到数据=42
readData 读到数据=43
readData 读到数据=44
readData 读到数据=45
readData 读到数据=46
readData 读到数据=47
readData 读到数据=48
writeData  48
writeData  49
writeData  50
readData 读到数据=49
readData 读到数据=50
/app/go-atguigu/channel-apply # 

All goroutines finished executing, and the main thread exited normally.
From the execution results, I verified the following points, which further reinforced my knowledge:

  1. The writeData goroutine successfully wrote 50 integers, and the readData goroutine successfully read all data. The two coordinated properly, achieving the expected effect;
  2. The main thread did not add any manual sleep. It achieved waiting for both goroutines to finish before exiting solely by listening to the exitChan signal, thoroughly resolving my previous risk of “inaccurate sleep time estimation”;
  3. The entire process required no manual locking, and no resource contention errors occurred. This confirms the concurrency-safe nature of Channels and makes me more confident in their practicality in concurrent programming.
    I also did a supplementary test: if close(intChan) in the writeData goroutine is commented out, running it triggers a deadlock—the readData goroutine will block indefinitely on the read operation, exitChan will never receive the completion signal, and the main thread will also block indefinitely. This once again verifies the importance of “channel close timing” and deepens my memory of this detail.

5. Review Summary and Core Takeaways
Through this review of the Goroutine and Channel coordination example, I not only reinforced their basic usage but, more importantly, thoroughly resolved the core issue from the previous factorial example where “locking plus sleeping still poses hidden risks.” Based on my review, I outlined the following core takeaways:

  1. The coordination of Goroutines and Channels is currently one of the optimal Go concurrency programming solutions I have mastered—Channels enable safe communication between goroutines without sharing global variables or manual locking; their blocking characteristic achieves precise synchronization between goroutines and the main thread without manual sleeping, fundamentally avoiding synchronization risks and solving my previous core pain point.
  2. Closing the channel (close()) is key to coordination. The writer must close the channel after finishing writing; otherwise, the reader will block indefinitely, causing a deadlock. The reader uses “v, ok := <-chan” to determine if the channel is closed, which is the core syntax for achieving coordinated exit. This is a detail I focused on memorizing in this review.
  3. The choice between buffered and unbuffered channels should be based on actual scenarios. I summarized the applicable scenarios: buffered channels are suitable for balancing read/write pace and improving efficiency; unbuffered channels are suitable for scenarios with high real-time requirements, achieving “synchronous blocking” for reads and writes. In future development, I can choose flexibly based on needs.
  4. Compared to the “global variables + locking” approach I used before, the advantage of Channels lies not only in simplifying code and avoiding resource contention, but more importantly in their “precise synchronization” capability, making concurrent programs more stable and maintainable. This also gave me a deeper understanding of Go’s design philosophy of “sharing memory by communicating.”
    In this review, I completed the core hands-on practice of Goroutine and Channel coordination and successfully resolved the lingering concurrency synchronization risks. Next, I will continue to review more complex coordination scenarios (such as multiple producers and multiple consumers models) to further deepen my understanding of advanced Channel usage. This will ensure I can flexibly use both in actual development to write efficient, safe concurrent code and solidify my core foundation in Go concurrency programming.
系列导航

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

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

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

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

关于我  ·  GitHub  ·  邮件联系