
Go 并发控制是指用 Go 语言提供的 goroutine、channel 和同步原语,把多个任务的执行顺序、资源共享和退出时机管理起来的做法。你写 Go 服务时,往往一边接收请求一边查数据库,一旦多个 goroutine 同时改同一块内存,就会出数据错乱。本文基于 Go 1.22 官方运行时与 sync 包实践,讲清三种最常用的并发控制方式——goroutine 配合 channel、sync.WaitGroup 等待、sync.Mutex 加锁,并附上可运行示例与踩坑清单。看完你能在不同场景下选出对的工具。今天这篇文章,编程狮就把这块讲透。
一、Go 并发控制到底是什么
用一句判断句给定义:Go 并发控制就是在多个 goroutine 同时运行的环境下,保证共享数据安全和任务协同的一组机制。
打个比方,goroutine 像一群同时干活的人,channel 是它们之间传活儿的管道,Mutex 是只有一个人的卫生间——进去要锁门。三者分工不同,但目标都是别让协作变成互相踩踏。
1.1 触发条件
当程序出现“多个任务同时做”且“它们共享同一份数据或需要按序汇合”时,就必须做并发控制。典型场景:并发抓取多个网页、批量写数据库、限流打点。
⚠️ 注意:只要出现 race(竞态),结果就不可复现,单元测试可能本地通过、线上崩。并发安全不是“大概率没事”,而是必须靠同步原语兜底。
1.2 常见误解
有人认为“Go 自动帮你处理并发安全”。其实 Go 只负责把 goroutine 调度起来,数据安全完全靠你写的同步代码。想打牢语法基础,可先过一遍 Go 语言教程。
1.3 为什么“共享内存”是事故温床
多核时代,CPU 并不是按顺序执行你的代码,而是把变量缓存到各自的核心缓存里。一个 goroutine 写了 count,另一个核心可能还读着旧值,这种不一致不会报错,只会在某次特定调度下吐出错误结果。所以并发控制的第一原则不是“加锁”,而是“尽量别共享”——能用 channel 把数据从 A 传到 B,就不要让 A 和 B 同时盯着同一块内存。
二、方法一:goroutine 加 channel 传递数据
最推荐的做法是“不要通过共享内存来通信,而要通过通信来共享内存”。
package main
import "fmt"
func main() {
ch := make(chan int, 3)
for i := 0; i < 3; i++ {
go func(n int) {
ch <- n * n // 把结果发到 channel
}(i)
}
for i := 0; i < 3; i++ {
fmt.Println(<-ch) // 从 channel 取结果
}
}
上面这段做的是启动 3 个 goroutine 各自算平方,主协程从带缓冲 channel 收结果。预期输出(未在本机执行)是 0、1、4 三行,顺序取决于调度。这种写法把数据通过 channel 通道传递,天然避免共享内存竞争。
💡 小提示:无缓冲 channel 会阻塞到双方就绪,缓冲 channel 在满之前不阻塞,选型看是否需要限流。
2.1 无缓冲还是有缓冲
无缓冲 channel 像面对面交货:发送方和接收方必须同时到场,任一方没准备好就阻塞,这天生保证了“交接”的顺序。缓冲 channel(如 make(chan int, 3))则像信箱,发送方投进去就走,收件人之后来取,适合生产快于消费的场景。缓冲大小选错也会出问题:太大占内存、太小照样阻塞,一般按“一批最多产生多少”来估。

三、方法二:WaitGroup 等待一组任务
当你只关心“这一批都做完了吗”,用 sync.WaitGroup 比手数更稳。
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
urls := []string{"a", "b", "c"}
for _, u := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
fmt.Println("fetch", u) // 实际项目里这里是发请求
}(u)
}
wg.Wait() // 阻塞直到所有 Done 被调用
fmt.Println("all done")
}
这一步的预期结果是打印三条 fetch 后输出 all done,保证主协程不提前退出。想理解底层调度模型,可看 Go Internals 教程。
3.1 WaitGroup 的三个坑
第一,Add 的调用时机:必须在启动 goroutine 之前 Add,否则主协程可能先 Wait 返回,子任务还没登记。第二,Done 要用 defer,确保哪怕函数 panic 也会减一,否则永久阻塞。第三,WaitGroup 是一次性的值,复用前必须等上一次 Wait 结束,不能并发地 Add 和 Wait。
四、方法三:Mutex 保护共享变量
当多个 goroutine 必须改同一块内存(如计数器),用 sync.Mutex 串行化访问。
package main
import (
"fmt"
"sync"
)
var (
mu sync.Mutex
count int
)
func incr() {
mu.Lock()
defer mu.Unlock()
count++ // 临界区,同一时刻只有一个 goroutine 能进
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() { defer wg.Done(); incr() }()
}
wg.Wait()
fmt.Println(count) // 预期输出 100
}
上面这段做的是 100 个 goroutine 抢着加一,靠 Go 互斥锁保证最终 count 正好是 100。少了锁,结果会随机小于 100。
4.1 临界区越小越好
锁保护的代码越短,goroutine 互相等待的时间越少。常见错误是把一整段逻辑都锁住,包括本不需要保护的计算,结果并发退化成串行,还不如单线程。经验法则是:只锁“读/写共享变量”那两三行,锁外做计算、锁内只做最必要的读写。
| 方法 | 适用场景 | 优点 | 代价 |
|---|---|---|---|
| channel | 数据流转、任务传递 | 符合 Go 哲学、少共享 | 结构稍复杂 |
| WaitGroup | 等一批任务结束 | 写法简单 | 不保护数据 |
| Mutex | 共享变量改写 | 直观可控 | 用错易死锁 |

五、踩坑清单
- 现象:程序提前退出没跑完。原因:主协程没 Wait。修复:用 WaitGroup 或 channel 收尾。
- 现象:偶尔结果不对。原因:竞态写共享变量。修复:加 Mutex 或改用 channel 传值。
- 现象:卡住不动。原因:channel 没人收或死锁。修复:检查收发是否配对,必要时用 select 加 default,可参考 Go select 并发。
- 现象:WaitGroup 计数变负 panic。原因:Done 调用次数多于 Add。修复:核对每个 goroutine 只 defer 一次 Done,不要在循环里漏写或重复写。
总结
Go 并发控制靠 goroutine 承载任务,靠 channel、WaitGroup、Mutex 三种手段分别解决“传数据、等结束、保安全”的问题。落地记住:能用 channel 传递就别共享内存;只等结束用 WaitGroup;改共享变量用 Mutex。下一步可以先顺着 Go 语言教程把协程基础打牢。
要点带走:
- channel 负责通信,WaitGroup 负责等待,Mutex 负责互斥,三者不互斥可组合;
- 竞态的结果不可复现,必须用同步原语兜底;
- 选错工具比不控制更危险,先想清“是传数据还是改数据”。
延伸学习
想把这块知识系统补齐,可以按这个顺序来:
- 想看并发安全的底层工具,读 Golang 原子操作;
- 理解锁的取舍,参考 并发控制策略;
- 学 Go 工程规范,看 Uber Go 规范。
常见问题
Q:channel 和 Mutex 该选哪个?
A:看你要的是“传数据”还是“改数据”。任务之间要传递结果、信号,用 channel 更符合 Go 风格;多个协程改同一变量,用 Mutex。两者也能组合,比如用 channel 发任务、用 Mutex 保护累加器。
Q:WaitGroup 能不能复用?
A:可以,但必须等上一批 Wait 返回后再重新 Add。在 Wait 还没结束时就 Add 会触发 panic,正确做法是每批新建或严格串行,避免并发调用 Add 与 Wait。
Q:怎么发现隐藏的竞态?
A:用官方 race 检测器编译运行:go run -race main.go。它会在真正发生数据竞争时打印冲突的读写栈,是上线前必跑的检查。这一条命令能拦掉绝大多数偶发线上 bug。

TRAE-AI编程



