本节摘要:channel 解决"传话",sync 包解决"守物"。本节从数据竞争的定义出发,覆盖互斥锁 Mutex 与读写锁 RWMutex 的用法与拷贝陷阱、WaitGroup 的计数器语义与 Add/Done/Wait 纪律、Once 的一次性保证、Cond 条件变量与原子操作的轻量路线,最后给出"channel 还是锁"的选型准则。
阅读完本节,你应当能够:
数据竞争的充要条件:两个及以上执行流访问同一内存位置,至少一个是写,且没有同步关系约束顺序。后果不是"算错一次"那么温和——未同步的并发写是未定义行为,可能撕裂读、可能读到半新半旧。Go 内置竞态检测器,测试或运行时加一个标志即可开启:它会用动态 instrumentation 在运行中抓现行并打印两边的栈。任何并发代码合入前跑一遍,是最低门槛的卫生习惯。
counter := 0 for i := 0; i < 1000; i++ { go func() { counter++ }() // 数据竞争:千次并发写无保护 }
这段程序不加同步时结果随缘(很少是 1000)。修复手段就是本章接下来的全部内容。
var ( counter int mu sync.Mutex ) func increment() { mu.Lock() counter++ mu.Unlock() }
Lock 与 Unlock 夹住的部分叫临界区——同一时刻只有一个执行流在里面。工程上更稳的写法是 defer Unlock,保证任何 return 路径都放锁:
func increment() { mu.Lock() defer mu.Unlock() counter++ }
两条铁律。锁不可拷贝:Mutex 的内部状态含"是否被持有"标志,拷贝一份等于两把锁各管各的,保护即刻失效——所以含锁的结构体要按指针传递,内建的静态检查工具会抓这个错误。临界区要小:锁住的是"数据的一致性窗口",不是"整个业务",IO、网络调用、大计算挪出锁外,吞吐立刻回来。
RWMutex 是读多写少场景的升级门:RLock 可多人同时持有,Lock 独占且与读互斥。代价是锁本身更重,写极频繁或临界区极小时反而不如普通锁,动手前用基准测试说话(第 6 章测试工具)。典型客户是"配置缓存":读请求洪峰、写分钟级更新。
三个方法一个模型:Add 加计数、Done 减一(等价 Add 负一)、Wait 阻塞到归零。纪律只有一条:Add 必须在 go 语句之前调用——放进 goroutine 里再 Add,主流程可能先 Wait,计数还是零直接放行,等了个寂寞:
var wg sync.WaitGroup for i := 1; i <= 5; i++ { wg.Add(1) // 先记账再开工 go func(id int) { defer wg.Done() // 收工销账 work(id) }(i) } wg.Wait() // 计数归零放行 fmt.Println("全部完成")
Done 用 defer 挂是最稳的,panic 也能销账。WaitGroup 是"一次性"的:归零后想再用得重新来一轮,别复用旧实例。
Once 保证某函数全局只执行一次,无论多少 goroutine 同时抢:
var once sync.Once var conn *DB func getConn() *DB { once.Do(func() { conn = connect() // 并发调用也只连一次 }) return conn }
单例、配置加载、连接建立的标配,比"自己加锁加布尔标志"简洁且无竞态。
原子操作是计数器类场景的轻量路线,对整数与指针提供原子的加减、读、写、比较交换:
var requests int64 atomic.AddInt64(&requests, 1) // 原子加,无锁开销 n := atomic.LoadInt64(&requests)
一把 Mutex 的加解锁几十纳秒,简单数值统计用原子操作更省;但原子只护住"单个变量的一次操作",护不住"多变量的不变量",后者必须上锁。
条件变量用于"等待某条件成立再继续"的场景:Wait 挂起并放锁、被唤醒后抢回锁再检查条件。它能把"轮询检查"换成"精确唤醒",但极易写错(虚假唤醒、错过信号),现代 Go 代码里绝大多数此类需求被 channel 与 select 取代。看懂老代码即可,新代码优先 channel。
| 维度 | 倾向 channel | 倾向 sync 锁 |
| --- | --- |
| 通信内容 | 数据流、任务、结果 | 共享状态 |
| 结构 | 生产者消费者、管道 | 缓存、计数器、连接池 |
| 语义 | 所有权转移 | 就地保护 |
| 复杂度 | 拓扑清晰但通道变多 | 直接但易临界区膨胀 |
我的经验法则:数据在 goroutine 间流动、所有权清晰 → channel;数据待在原地、多方读写 → 锁。两者混用完全正常——一个带 Mutex 的缓存挂在 channel 管道中间,谁也不抢谁的戏。
💡 关键直觉:锁是卫生间的门(占用期间别人等着),channel 是快递柜(东西放进去人就走)。排队上厕所用门,传送包裹用柜,别用门传包裹。
问答补遗。锁的粒度怎么拿捏? 保守起步一粗锁,剖析证明竞争激烈再细化;先细后粗比重构容易,但过早细化会把不变量撕碎到多个锁里,死锁风险上升。死锁怎么排查? 运行时能检测"全阻塞"的死锁并打印所有 goroutine 栈;部分死锁(有人在空转)要靠超时与监控发现。trylock 有吗? 标准库近年补了尝试加锁的接口,但"拿不到就跳过"的语义极易藏 bug,用之前想清楚拿不到锁时业务到底该怎么办。
下一节把取消与超时串成全链路:Context。