Go 语言并发编程实战:从 goroutine 到 channel 的深度应用
结论先行:在 Go 并发编程中,理解 goroutine 的生命周期管理和 channel 的阻塞机制是避免死锁和内存泄漏的关键,而 select 和 sync 包则是构建安全并发程序的必备工具。
1. 用 sync.WaitGroup 掌控 goroutine 的生命周期
新手最容易犯的错:启动 goroutine 后主函数直接退出,子 goroutine 还没执行完就戛然而止。我自己也踩过这个坑,后来才学会用 sync.WaitGroup 来优雅地等待所有 goroutine 完成。
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // 必须 defer,避免遗漏
fmt.Printf("Worker %d 开始\n", id)
time.Sleep(time.Second)
fmt.Printf("Worker %d 结束\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // 在启动前加 1
go worker(i, &wg)
}
wg.Wait() // 阻塞直到计数器归零
fmt.Println("所有 worker 已完成")
}
注意:
wg.Add(1)必须在go关键字之前调用,否则 goroutine 执行太快会导致wg.Wait()提前返回。我第一次写的时候就把顺序搞反了,结果程序总是提前结束,排查了半天才发现这个细节。
2. 有缓冲 vs 无缓冲 channel——阻塞是设计不是 bug
无缓冲 channel 要求发送和接收必须同时发生,否则会阻塞。有缓冲 channel 允许发送方填满缓冲区后再阻塞。很多人刚接触时觉得阻塞是烦恼,但实际上这正是 Go 并发模型优雅的地方。
无缓冲:同步通信
ch := make(chan int)
go func() {
ch <- 42 // 发送,但没人接收时会阻塞
}()
val := <-ch // 接收,同时解除上面 goroutine 的阻塞
fmt.Println(val)
有缓冲:异步队列
ch := make(chan string, 2)
ch <- "A" // 不阻塞
ch <- "B" // 不阻塞
// ch <- "C" // 缓冲区满,会阻塞
fmt.Println(<-ch) // A
fmt.Println(<-ch) // B
实战经验:有缓冲 channel 适合生产-消费速率不匹配的场景,但缓冲区大小要根据流量估算,过大浪费内存,过小导致频繁阻塞。我曾在线上遇到过因为缓冲区设得太小而造成的性能瓶颈,后来调整为动态估算后问题才解决。
3. select:并发编程的瑞士军刀
select 可以同时监听多个 channel 的读写操作,配合 time.After 实现超时控制,配合 default 实现非阻塞尝试。
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() { time.Sleep(2 * time.Second); ch1 <- "来自 ch1" }()
go func() { time.Sleep(1 * time.Second); ch2 <- "来自 ch2" }()
select {
case msg := <-ch1:
fmt.Println(msg)
case msg := <-ch2:
fmt.Println(msg)
case <-time.After(500 * time.Millisecond):
fmt.Println("超时,两个都没准备好")
default:
fmt.Println("非阻塞:立即执行,无数据")
}
}
诚实记录:我第一次写 select 时忘记加 default,导致 goroutine 永远阻塞。后来学会了用 for + select 循环监听多个 channel,配合 time.NewTicker 做定时任务,才真正体会到 select 的威力。
4. 实战:一个健壮的工作池(Worker Pool)
下面是一个从任务队列读取、并行处理、错误收集的完整例子,涵盖了所有核心技巧。这个模式在我的很多项目里都用过,非常稳定。
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
type Task struct {
ID int
Data string
}
type Result struct {
TaskID int
Output string
Err error
}
func worker(id int, tasks <-chan Task, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for task := range tasks { // 当 tasks 被关闭后自动退出循环
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
// 模拟处理,可能出错
if rand.Float32() < 0.2 {
results <- Result{TaskID: task.ID, Err: fmt.Errorf("处理失败")}
continue
}
results <- Result{TaskID: task.ID, Output: task.Data + "_processed"}
}
}
func main() {
const numWorkers = 3
const numTasks = 10
tasks := make(chan Task, numTasks)
results := make(chan Result, numTasks)
var wg sync.WaitGroup
// 启动 workers
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go worker(i, tasks, results, &wg)
}
// 发送任务
for i := 0; i < numTasks; i++ {
tasks <- Task{ID: i, Data: fmt.Sprintf("数据%d", i)}
}
close(tasks) // 关闭输入 channel,通知 workers 没有新任务了
// 等待所有 worker 完成(并发关闭结果 channel 的 goroutine)
go func() {
wg.Wait()
close(results) // 确保所有结果都已写入后关闭
}()
// 收集结果
for res := range results {
if res.Err != nil {
fmt.Printf("任务 %d 失败: %v\n", res.TaskID, res.Err)
} else {
fmt.Printf("任务 %d 成功: %s\n", res.TaskID, res.Output)
}
}
}
关键点:
- 使用 close(tasks) 让 workers 自然退出,避免 goroutine 泄漏。
- 结果 channel 必须由单独的 goroutine 在 wg.Wait() 之后关闭,否则主 goroutine 会死锁。这个顺序我一开始也弄反过,导致程序卡死。
- 带缓冲的 channel 可以减少阻塞,但缓冲区大小要根据任务量设置合理值。
5. 踩坑实录
坑 1:向 nil channel 发送或接收
var ch chan int // nil channel
// ch <- 1 // 永远阻塞
// <-ch // 永远阻塞
解决:永远不要使用未初始化的 channel,全部用 make 初始化。
坑 2:忘记 close channel 导致死锁
ch := make(chan int)
go func() {
ch <- 1
// 忘记 close(ch)
}()
for val := range ch { // 永远不退出,一直等待
fmt.Println(val)
}
解决:当发送方明确不会再发送数据时,一定要调用 close(ch),并在接收方使用 for range 循环。
坑 3:goroutine 泄漏——只开不关
func leak() {
ch := make(chan int)
go func() {
for {
select {
case <-ch:
// 处理
}
}
}()
// 函数返回,但 goroutine 还活着
}
解决:使用 context 或一个停止 channel 显式通知 goroutine 退出。
func noLeak(ctx context.Context) {
ch := make(chan int)
go func() {
for {
select {
case <-ch:
case <-ctx.Done():
return
}
}
}()
// ...
}
6. 进阶:使用 context 优雅取消
context.WithCancel 可以广播取消信号,特别适合同时终止多个 goroutine。
func main() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(2 * time.Second)
cancel() // 发送取消信号
}()
work(ctx)
}
func work(ctx context.Context) {
for i := 0; ; i++ {
select {
case <-ctx.Done():
fmt.Println("收到取消信号,退出")
return
default:
fmt.Println("工作...", i)
time.Sleep(500 * time.Millisecond)
}
}
}
与 WaitGroup 的区别:WaitGroup 用于等待 goroutine 结束,context 用于通知 goroutine 结束。生产环境中通常组合使用:用 context 发起取消,用 WaitGroup 等待所有 goroutine 清理完毕。这种组合方式我在微服务架构中用得最多,既保证了及时终止,又防止了资源泄漏。
延伸思考
- errgroup:
golang.org/x/sync/errgroup包将 WaitGroup 与 context 结合,还能收集第一个错误,适合多个并行子任务。我经常用它来并发发起多个 RPC 调用并处理第一个错误。 - 扇入/扇出模式:多个输入 channel 汇合到一个输出(扇入),或一个任务被多个 worker 并发处理(扇出),它们是对 channel 和 select 更高级的组合。
- sync.Mutex vs channel:保护共享内存时用 Mutex;传递数据所有权时用 channel。Go 的哲学是“不要通过共享内存来通信,而要通过通信来共享内存”,但 Mutex 在保护复杂数据结构时更直接。选择标准:数据流向明确时用 channel,只是简单变量同步时用 Mutex。
你已经掌握了 goroutine + channel 的核心用法,下一步就是在真实项目中反复练习,并留意 pprof 和 goleak 工具来检测并发问题。相信我,一旦你熟练了这些模式,Go 的并发编程会成为最让你觉得舒服的部分。

评论已关闭!