本节摘要:channel 是 Go 的第一选择,但等待收口与共享状态两类场景离不开 sync 包:WaitGroup 当签到板收齐员工,Mutex 当钥匙柜守护共享数据,RWMutex 分流读写,Once 保证初始化只跑一次。本节讲清四件工具的正确姿势与选型边界,channel 与锁各安其位。
窗口传话解决"数据流动",可有些时刻员工们必须同步:下班前全员必须到齐(等待)、储物室一次只能进一人(互斥)、白板可以众人同时看但只能一人擦(读写锁)、公告只许张贴一次(单次执行)。sync 包就是这四件工具的官方发放处。3.2 与 3.3 已两次预演了 WaitGroup 与 close 广播,本节把它们补全成体系。
WaitGroup 是一块计数签到板:Add 加人数,Done 划掉一人,Wait 阻塞到清零。三条纪律缺一即翻车:
package main import ( "fmt" "sync" ) func sweep(area string, wg *sync.WaitGroup) { // 必须传指针:签到板全局唯一 defer wg.Done() fmt.Println(area, "清扫完成") } func main() { var wg sync.WaitGroup areas := []string{"机房", "档案室", "天台"} wg.Add(len(areas)) // 纪律一:Add 在 go 之前,一次加齐 for _, a := range areas { go sweep(a, &wg) // 纪律二:传指针,别传副本 } wg.Wait() // 纪律三:Wait 返回前不要复用这块板 fmt.Println("全馆清扫完毕,可以锁门") } // 运行输出: // 机房 清扫完成 // 档案室 清扫完成 // 天台 清扫完成 // 全馆清扫完毕,可以锁门
为什么 Add 要在 go 之前?如果 Add 写在子 goroutine 里,主 goroutine 可能在 Add 执行前就跑到 Wait——计数还是零,Wait 直接放行,等待形同虚设。为什么传指针?副本签到、原板不动,Wait 永远等不到清零。这两条是新手事故榜的前两名。
package main import ( "fmt" "sync" ) func main() { var ( mu sync.Mutex total int wg sync.WaitGroup ) for i := 1; i <= 5; i++ { wg.Add(1) go func(n int) { defer wg.Done() mu.Lock() // 进门前领钥匙 total += n mu.Unlock() // 出门还钥匙 }(i) } wg.Wait() fmt.Println("累计:", total) // 有锁:稳定 15;无锁:结果随交错漂移 } // 运行输出: // 累计: 15
把 mu.Lock 与 mu.Unlock 注释掉再跑,多数时候依然输出 15——这正是竞态的阴险之处:错误只在特定交错下显形。total += n 并非原子操作,它拆成读、加、写三步,两个 goroutine 交错时后写者覆盖前写者。第 4 章会用竞态检测器把这类问题抓个现行。用锁的实务建议:临界区只包真正的共享读写,别把网络请求、耗时计算裹进锁里;Unlock 用 defer 登记防漏。

缓存类负载读多写少,普通 Mutex 会把互不相干的读也排成串行。RWMutex 提供两把钥匙:RLock 允许任意多个读者并持,Lock 独占一切。配上 sync.Once 的"只执行一次",就拼出最常用的懒加载缓存:
package main import ( "fmt" "sync" ) type ConfigCache struct { mu sync.RWMutex data map[string]string } func (c *ConfigCache) Get(key string) string { c.mu.RLock() // 读锁:读者们并行 defer c.mu.RUnlock() return c.data[key] } func (c *ConfigCache) Set(key, val string) { c.mu.Lock() // 写锁:独占 defer c.mu.Unlock() c.data[key] = val } func main() { var once sync.Once var cache *ConfigCache load := func() { // 无论多少人调用,函数体只执行一次 cache = &ConfigCache{data: map[string]string{"timeout": "30"}} fmt.Println("缓存初始化,只打印这一次") } once.Do(load) once.Do(load) cache.Set("retries", "5") fmt.Println(cache.Get("timeout"), cache.Get("retries")) } // 运行输出: // 缓存初始化,只打印这一次 // 30 5
| 场景 | 首选 | 理由 |
|---|---|---|
| 结果汇聚、任务分发、流式处理 | channel | 数据单向流动,所有权随值转移 |
| 等待一组任务收尾 | WaitGroup | 纯同步无数据,签到板最轻 |
| 缓存、计数器等共享读改写 | Mutex 或 RWMutex | 数据方向来回摆,传话反而绕 |
| 只执行一次的初始化 | sync.Once | 语义即工具,手写必有竞态 |
| 保护复杂不变量的结构体 | Mutex | 锁跟数据走,封装在方法里 |
社区有个朴素的口径:用 channel 在 goroutine 之间传数据,用锁保护 goroutine 之内的共享数据。拿不准时问一句"数据是流动的还是驻留的"——流动走窗口,驻留上锁。
背景:日志采集端多个解析 goroutine 同时上报特征串,要求统计每种特征出现的次数,最终输出频次表。
操作:频次 map 是典型"驻留型共享数据",按选型表上锁:一个 Mutex 守护 map 的读写,业务 goroutine 只管调用带锁方法。
package main import ( "fmt" "sort" "sync" ) type Counter struct { mu sync.Mutex m map[string]int } func NewCounter() *Counter { return &Counter{m: make(map[string]int)} } func (c *Counter) Add(key string) { c.mu.Lock() defer c.mu.Unlock() c.m[key]++ } func (c *Counter) Snapshot() map[string]int { c.mu.Lock() defer c.mu.Unlock() out := make(map[string]int, len(c.m)) for k, v := range c.m { out[k] = v // 复制一份快照,调用方随便用 } return out } func main() { c := NewCounter() var wg sync.WaitGroup for _, w := range []string{"超时", "超时", "解析失败", "超时", "解析失败"} { wg.Add(1) go func(k string) { defer wg.Done() c.Add(k) }(w) } wg.Wait() keys := make([]string, 0) for k := range c.Snapshot() { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { fmt.Println(k) } } // 运行输出: // 解析失败 // 超时
结果:并发上报后统计准确,无任何数据竞争。
解读:Counter 把锁封进结构体私有字段,对外只暴露方法——锁与数据同生共死,外部想绕过都绕不过。Snapshot 返回副本而非内部 map,是并发组件的标准自我修养:内部可变状态绝不外泄引用,这个原则在 2.3 的值语义里已有伏笔。
变式:把 Mutex 换成 RWMutex 并给 Snapshot 用 RLock——快照是纯读,能与其他快照并行;再想想 Add 能否也用 RLock?不能,它写 map。做完这组替换,读写锁的边界感就建立起来了。
共享状态有了守护,还剩最后一块拼图:怎么让整条并发链路在最短时间、最体面的方式下统一收工。下一节的 context 下班铃,见。