Go 协程同步完全指南
同步的必要性
协程的轻量性让并发变得廉价,但多个协程同时访问共享资源时,如果没有正确的同步,程序的行为将不可预测。数据竞争、死锁、协程泄漏——这些问题的根源往往不在于协程本身,而在于协程之间缺乏协调。
Go 提供了两套互补的同步体系:通道用于在协程间传递数据和信号,sync 包及 sync/atomic 用于保护共享内存。选择哪一套,取决于问题的本质是传递所有权还是保护状态。
通道:通信即同步
Go 的并发哲学是通过通信来共享内存。通道不仅是数据传输管道,本身也是一种同步机制。
无缓冲通道的发送和接收必须同时就绪,否则发送方阻塞。这一特性让无缓冲通道成为协程间精确同步的工具:一个协程完成工作后向通道发送信号,另一个协程从通道接收即表示等待结束。缓冲通道则解耦了发送和接收的时机,发送方在缓冲区未满时不会阻塞,适合生产者快于消费者的场景。
用通道做同步的典型模式是退出信号。启动协程时传入一个只读通道,协程在循环中监听该通道,主协程需要停止时关闭通道,所有监听者同时收到信号。关闭通道是广播语义,而向通道发送只能唤醒一个接收者,这是选择关闭而非发送的关键理由。
select 让一个协程同时等待多个通道操作,配合 default 实现非阻塞尝试,配合 time.After 实现超时。在同步场景中,select 常用于等待结果或取消的二选一结构。
互斥锁与读写锁
当多个协程需要读写同一块内存时,通道并非总是最自然的表达。缓存、计数器、配置状态这类场景,用锁保护临界区往往更直接。
sync.Mutex 保证同一时刻只有一个协程进入临界区。标准用法是 Lock 后紧跟 defer Unlock,确保即使 panic 也能释放。关键细节:锁保护的是数据,不是代码。如果某个协程操作共享数据时没有获取同一把锁,锁就形同虚设。
sync.RWMutex 区分读锁和写锁:多个读者可以同时持有读锁,但写者独占。读多写少的场景下,RWMutex 能显著提升并发度。不过 RWMutex 本身有额外开销,Go 官方建议除非读写比例悬殊且读操作耗时较长,否则先用 Mutex 并做基准测试验证收益。
一个容易被忽视的陷阱是复制含锁的结构体。sync.Mutex 一旦使用就不能复制,复制后的锁是两个独立的状态,互斥性完全失效。包含锁的结构体应使用指针接收者,go vet 能检测出大部分复制问题。
等待组
sync.WaitGroup 解决的是等待一组协程全部完成的问题。它不是锁,也不保护数据,纯粹用于计数和阻塞。
使用规则有三条必须遵守。第一,Add 必须在启动协程之前调用,如果在协程内部调用 Add,主协程可能先执行到 Wait 而错过计数。第二,Done 建议用 defer 包裹,确保协程即使异常退出也能递减。第三,WaitGroup 不可复用,一轮 Wait 结束后才能开始下一轮。
WaitGroup 的典型场景是扇出扇入:主协程启动 N 个工作协程,每个协程完成时调用 Done,主协程 Wait 后继续。配合通道使用时,通常由最后一个完成的协程关闭结果通道,或者用一个额外的协程在 Wait 后关闭。
条件变量
sync.Cond 是 Go 中最少被使用的同步原语,但在特定场景下不可替代:一个或多个协程需要等待某个条件成立,而条件的检查需要持锁进行。
Cond 的 Wait 方法做三件事:释放关联的锁、将当前协程挂起、被唤醒后重新获取锁。正因为 Wait 返回时锁已重新持有,但条件未必为真(可能存在虚假唤醒或条件被其他协程再次改变),调用方必须用循环包裹 Wait:for 循环判断条件不成立则继续 Wait。
Signal 唤醒一个等待者,Broadcast 唤醒全部。如果等待者只有一个,两者效果相同;如果有多个且条件只允许一个继续,用 Signal;如果条件变化对所有等待者都成立,用 Broadcast。Go 官方文档指出,简单场景下通道通常比 Cond 更清晰,Cond 的价值在于需要与 Mutex 紧密配合的复杂条件等待。
原子操作
对于单个标量变量的并发读写,锁的粒度太重。sync/atomic 提供 CPU 指令级别的原子操作,无锁且高效。
Go 1.19 起引入了类型化的原子类型:atomic.Int64、atomic.Bool、atomic.Pointer 等,比传统的原子函数调用更安全、更易读。核心方法包括 Add、Load、Store、Swap 和 CompareAndSwap。
原子操作适用于计数器、标志位、一次性初始化等场景。但有一条原则:原子操作保护单个变量,锁保护一组需要保持一致性的变量。如果两个字段必须一起更新,原子操作无法保证它们之间的原子性,必须用锁。
sync.Once 是基于原子操作实现的一次性执行原语,标准用法是在 Do 中传入初始化函数,无论多少协程并发调用,函数只执行一次。Go 1.21 起还提供了 sync.OnceFunc 和 sync.OnceValue,将一次性逻辑封装为可调用的函数,更简洁。
选择准则
通道和锁并非对立,而是各有适用边界。Go 官方的建议是:传递数据所有权、分发工作单元、通信异步结果用通道;保护缓存、状态、配置用锁。如果你发现 Mutex 的加锁规则变得过于复杂,应该考虑通道是否更简单;反之,如果通道的收发逻辑缠绕不清,锁可能更直接。
一个实用的判断方法:如果同步的目的是告诉另一个协程某件事发生了,用通道;如果目的是防止多个协程同时弄坏一块数据,用锁。
常见陷阱
Sleep 不是同步。用 time.Sleep 等待协程完成,将正确性绑定在不可靠的时长估计上。应使用 WaitGroup 或通道进行确定性同步。
协程泄漏。每个启动的协程都应有明确的退出路径。监听 ctx.Done、在通道关闭时结束 range、或用 WaitGroup 协调,都是可接受的机制。没有退出路径的协程会持续占用栈和引用内存。
锁内做阻塞操作。持有锁时进行网络调用、IO 或 time.Sleep,会将所有等待该锁的协程一起拖住。临界区应只包含内存操作,阻塞逻辑移到锁外。
多锁顺序不一致。两个协程以不同顺序获取两把锁,可能形成 AB-BA 死锁。如果必须持有多把锁,全局约定统一的加锁顺序。
总结
通道处理协程间的信号与数据传递,Mutex 和 RWMutex 保护共享状态的完整性,WaitGroup 协调批量协程的完成,原子操作用于单标量的无锁访问,Cond 处理需要持锁等待的复杂条件。理解每一类工具解决的具体问题,比记住语法更重要。在编写并发代码时,先问同步要达成什么目的,再选择最贴合该目的的原语,通常就是正确的选择。
- 点赞
- 收藏
- 关注作者
评论(0)