
作为一个写了多年代码的Go选手我对sync包的感受很复杂它既是并发编程的基石又是翻车率最高的地方。网上的教程大多只讲用法很少讲清楚“为什么这么设计”和“实际项目中会踩哪些坑”。这篇笔记是基础系列的第十五篇专门把sync包整体过一遍从底层原理到真实工程场景一次性补齐那些年我们对并发原语的理解缺口。1. 为什么并发问题的根子都在sync包上1.1 一次线上数据竞争从现象到根因先讲个真实事故。去年我负责的一个订单服务在高峰期偶尔会出现订单状态错乱——明明用户已经支付了数据库里却是“待支付”。刚开始怀疑是缓存问题后来通过日志发现原因竟然是服务里有一段并发处理逻辑多个goroutine同时读写同一个订单结构体而这个结构体里的状态字段没有任何保护。type Order struct { Status string Amount float64 } // 并发场景下多个goroutine同时读写Order.Status func handleOrder(order *Order, isPaid bool) { if isPaid { order.Status paid // 写 } fmt.Println(order.Status) // 读 }这段代码的问题本质上是数据竞争data race。多个goroutine同时访问同一个内存地址其中至少一个在写且没有同步机制那么结果就完全不可控——可能读到旧值可能读到新值甚至可能读到中间状态。在Go里这种问题用单测很难发现因为只有特定调度时序下才会触发。当时的修复方案很简单给结构体加一把sync.Mutex锁type Order struct { mu sync.Mutex Status string Amount float64 } func (o *Order) MarkPaid() { o.mu.Lock() defer o.mu.Unlock() o.Status paid }这不是什么高深操作但如果不懂sync包背后的原理很容易出现“加了锁还是不对”的情况——比如锁的作用域没覆盖住所有读写路径或者用了锁却复制了锁对象。后面我会详细讲这些坑。1.2 happens-before理解sync的第一性原理要真正理解sync包绕不开Go内存模型里的happens-before原则。简单说如果一个操作A在逻辑上先于操作BA happens-before B那么B一定能看到A所做的写操作。sync包提供的每个原语核心目的就是建立happens-before关系Mutex.Lock()之后的代码能够看到Unlock()之前的所有写操作WaitGroup.Wait()返回后能看到之前Done()之前的所有写操作Once.Do(f)执行完之后能看到f过程中的所有写操作Cond.Wait()返回后能看到对应Signal()或Broadcast()之前的所有写操作Channel的发送与接收之间也有严格的内存序保证。换句话说sync包的各个类型就是帮你把内存屏障memory barrier以API的形式封装好。不懂happens-before就去猜锁的“玄学”懂了之后你会发现所有的并发问题都是可推导的。1.3 channel与sync的边界这其实是Go社区争论很多年的话题。Rob Pike虽然提出了“不要通过共享内存来通信而要通过通信来共享内存”但这并不意味着channel能替代所有sync原语。我个人的选型原则很简单需要传递数据本身比如一个job队列、一段结果用channel需要保护某个共享状态比如计数器、缓存Map、复杂对象用sync.Mutex/RWMutex需要多个任务全部完成后才继续用WaitGroup需要某个初始化逻辑只执行一次用Once需要复用对象以减少GC压力用Pool。channel的底层其实还是用锁实现的它在某些场景如广播、超时控制确实更优雅但性能上通常不如直接上锁。真正的高手是按场景选工具而不是为了“更Go”强行用channel。2. Mutex与RWMutex锁的代价与正确用法2.1 从自旋锁到饥饿模式Mutex的内部演进sync.Mutex在Go 1.18之后经历了比较大的重构现在从源码层面看它有三种模式正常模式非公平新来的锁请求会尝试CAS获取锁抢不到就进入自旋等待runtime_ProcyPin短暂忙等然后进入等待队列饥饿模式公平当一个等待者等待时间超过1ms时Mutex会切换为饥饿模式新来的请求不再参与竞争直接排队。这样能防止“后来者”反复抢走锁导致早期等待者被“饿死”退出饥饿模式当等待者获取到锁后如果它的等待时间不到1ms或者它是队列中最后一个等待者就切回正常模式。实际工程中锁竞争激烈时饥饿模式能显著降低尾延迟。日常开发我们不需要操心底层但有一个结论要知道Mutex的排序是不保证公平的不要依赖它做顺序控制。写一段压力测试来观察锁的性能特征func BenchmarkMutex(b *testing.B) { var mu sync.Mutex counter : 0 b.RunParallel(func(pb *testing.PB) { for pb.Next() { mu.Lock() counter mu.Unlock() } }) }实测下来单线程加锁无竞争时开销大约20ns8线程竞争时单次加锁可能会到100ns以上。锁不是在“做”什么事而是让并发操作“排队”所以锁的粒度设计直接影响吞吐量。2.2 Mutex的保护范围锁什么、怎么锁很多人加锁的姿势不对最常见的就是锁的范围太小// 错误示例锁没有覆盖整个读-改-写过程 func (s *Store) Incr() int { s.mu.Lock() s.count s.mu.Unlock() return s.count // 这里读的是加锁之后的值吗未必有可能被其他goroutine改掉了 }这个return s.count在读的时候没有锁保护虽然它大概率能读到正确值但严格来说是数据竞争。正确做法要么把读也放进锁内要么用atomic包func (s *Store) Incr() int { s.mu.Lock() defer s.mu.Unlock() s.count return s.count }另一个常见误区是锁的粒度太大把不需要同步的IO操作也放进锁里导致吞吐量断崖式下降。比如持有锁期间做网络请求、写文件、甚至Sleep会让所有竞争者排队锁的争用急剧上升。正确做法是只锁内存临界区IO操作挪到锁外。2.3 Copy锁与死锁两个高频坑复制锁这个问题我在代码评审里见过太多次。sync.Mutex结构体内部有一个状态字段如果在锁被持有的状态下复制了Mutex对象那么两个“副本”共享同一个内部状态逻辑上等于同一个锁但代码上已经完全割裂。Go 1.20之前复制Mutex不会报错只会让你疯狂猜测哪里出了问题。好在go vet自带了copylocks检查器能在编译期抓住这种问题go vet ./...我自己还踩过一回通过函数传参复制锁的坑那个项目里一个结构体里嵌了Mutex结构体却经常按值传递结果就是明明上了锁实际保护的却不是同一个锁对象数据照样竞争。排查了两天才定位到根因后来规范就是任何包含锁字段的结构体一律用指针传递永远禁止按值拷贝。死锁就更经典了。两个goroutine互相持有对方需要的锁谁也不释放程序整个卡死。Go检测不出死锁除非触发fatal error: all goroutines are asleep - deadlock!因为死锁通常发生在运行时而不是编译期。我的经验是锁的获取顺序要全局统一。比如多个订单锁时总是先锁ID小的再锁ID大的能有效规避循环等待。2.4 RWMutex适合什么场景读多写少与写锁饥饿sync.RWMutex是Mutex的升级版允许多个读者同时持有读锁写者必须独占。这非常适合读多写少的场景比如配置表、路由表、缓存目录。读读不互斥读写互斥写写互斥——这是RWMutex的基本语义。但这带来一个潜在麻烦如果读锁被长时间持有很多写锁会一直拿不到造成所谓的“写者饥饿”。RWMutex在实现上保证了等待的写者优先于新来的读者也就是说一旦有写者在等新的读请求就会排队避免写者饿死。var rw sync.RWMutex func ReadConfig() map[string]string { rw.RLock() defer rw.RUnlock() return config }性能上RLock/RUnlock比Lock/Unlock略重一点。如果临界区只有几行内存操作而且写操作占比已经很高那么RWMutex并不比普通Mutex快多少。只有当读操作远多于写操作比如几十比一时RWMutex的收益才明显。3. WaitGroup与Once并发生命周期管理3.1 WaitGroup的时序陷阱Add必须在Wait之前WaitGroup是Go里最常用的并发编排原语但它的使用有一个反直觉的坑Add操作必须在Wait之前完成计数至少要保证在逻辑上有happens-before关系。看一个经典错误func main() { var wg sync.WaitGroup for i : 0; i 5; i { go func() { wg.Add(1) // 错误Add在groutine内部执行 defer wg.Done() doWork() }() } wg.Wait() fmt.Println(done) }这段代码的问题是主goroutine可能在这5个子goroutine都还没来得及执行wg.Add(1)时就已经执行到wg.Wait()了。此时计数器是0Wait()直接返回后续Done()又导致计数器归零程序提前退出。特别是当子goroutine创建耗时较长、调度延迟较大时这个问题会稳定复现。正确姿势很无脑但很可靠var wg sync.WaitGroup for i : 0; i 5; i { wg.Add(1) // 在启动goroutine之前Add go func() { defer wg.Done() doWork() }() } wg.Wait()如果确实需要动态增加任务必须在Wait开始之前把Add全部执行完或者用带缓冲的channel来动态通知子goroutine“有活干了”。不要把WaitGroup当线程池用——它的设计目标是固定批次任务的等待。3.2 动态Add的正确姿势与main goroutine等待模式有些场景是任务运行过程中会产生子任务这时WaitGroup的计数会动态变化。标准做法是func processJobs(jobs []Job) { var wg sync.WaitGroup wg.Add(len(jobs)) for _, job : range jobs { go func(j Job) { defer wg.Done() // 过程里开启子任务感觉要Add不应该把子任务也提前规划好 j.Run() }(job) } wg.Wait() }有一条硬规则Add()的调用必须在对应的goroutine启动之前完成这样才能保证Wait()不会提前返回。如果你实在要在coroutine内部Add可以在外面先加一个大计数再内部add是无法保证安全性的。实际项目中我更喜欢把WaitGroup封装到一个结构体里用方法管理Add/Done避免调用方误用type TaskGroup struct { wg sync.WaitGroup } func (g *TaskGroup) Go(f func()) { g.wg.Add(1) go func() { defer g.wg.Done() f() }() } func (g *TaskGroup) Wait() { g.wg.Wait() }3.3 Once的用法与Go 1.20后的变化sync.Once保证某个函数只执行一次这在单例初始化、配置加载、资源池建立时非常有用var ( once sync.Once cfg *Config ) func GetConfig() *Config { once.Do(func() { cfg LoadConfig() }) return cfg }Once.Do有几个值得注意的细节如果f中panic了Once会视为已完成后续的Do调用不会重新执行f。这其实是个大坑f内部不能随便panic否则整个初始化就“半永久”失败了。Once不能被复制和Mutex一样复制之后的结果是两份独立的“单次”保证极容易引发逻辑错误。Go 1.20之后Once提供了一种更保守的做法可以用atomic.Bool或atomic.Pointer替代Once做懒加载因为Once.Do本身也有少量锁竞争。我的经验是Once适合进程生命周期内只需要一次初始化的场景如果初始化可能失败、需要重试就不要用Once自己用Mutex 状态位控制var ( mu sync.Mutex cfg *Config done bool ) func GetConfig() *Config { mu.Lock() defer mu.Unlock() if !done { var err error cfg, err LoadConfig() if err nil { done true } } return cfg }4. Cond与Pool条件同步与对象复用4.1 Cond为什么每次都要用for循环包住Waitsync.Cond在标准库里的存在感不高但处理“等待某个条件成立”的业务场景时它是非常顺手的工具。它的核心API是Wait、Signal和Broadcast。Wait的行为是自动释放底层锁挂起当前goroutine等待被唤醒被唤醒后会重新获取锁并返回。这里有一个几乎所有文档都会强调、但初学者经常忘记的关键点Wait返回后条件不一定成立。原因主要有两个你可能被假唤醒spurious wakeup这是在操作系统层面就可能发生的即使不是假唤醒Signal只唤醒一个goroutine如果多个goroutine在等同一个条件另一个goroutine可能抢先把条件瓜分掉了。所以正确姿势是用for循环而不是ifc.L.Lock() for !condition() { c.Wait() } // 此时条件一定成立 c.L.Unlock()这个for循环实际上是“重新检查条件”的保险丝是Cond使用中最重要的一条铁律。永远不要用if包住Wait这是死代码的温床。4.2 Signal与Broadcast的取舍Signal唤醒一个等待者Broadcast唤醒所有等待者。选择原则当每次状态变化“只能”被一个消费者处理时如任务队列pop用Signal当状态变化需要“所有”消费者都感知时如全局配置刷新、退出信号用Broadcast。举个实际例子我有一个内部消息处理引擎多个worker从任务队列取job当队列为空时所有worker都在c.Wait()生产者在放入job后调用c.Signal()保证只有一个worker被唤醒去取任务。这样能避免惊群效应也就是所有worker都被唤醒但只有一个人抢到锁其他又回去睡觉白白浪费调度。如果生产者一次放了多个job直接Broadcast反而更好因为多个worker可以并行消费。4.3 Pool顺手捡到的性能优化神器sync.Pool的目标是复用临时对象降低GC压力和分配开销。它特别适合大量创建、用完即弃、没有长期持有需求的对象比如JSON编码的bytes.Buffer、数据库连接但连接池通常有更完整的实现、序列化上下文。var bufferPool sync.Pool{ New: func() interface{} { return new(bytes.Buffer) }, } func Marshal(v interface{}) ([]byte, error) { buf : bufferPool.Get().(*bytes.Buffer) defer bufferPool.Put(buf) buf.Reset() if err : json.NewEncoder(buf).Encode(v); err ! nil { return nil, err } return buf.Bytes(), nil }Pool.Get会在池里没有对象时调用New新建一个Put归还对象。但有个反直觉的点Pool里的对象随时可能被GC清理掉。也就是说你不能假设Put进去的对象一定能在后续Get中拿回来。换句话说Pool的设计是“尽力而为”不是高效的cache不适合用来保存有状态的长生命周期对象。如果Put进去的对象内部还有引用的数据比如一段大slice不Reset就放回去下次Get到之后会产生脏数据。所以Put之前一定要把对象恢复到零状态这是Pool使用里最容易被忽略的细节。4.4 Pool的坑Go 1.23之后的实验特性最近有个新东西Go 1.23的GOEXPERIMENTpooldecommit它能让Pool在空闲时把内存归还给OS略微牺牲一点响应速度来降低常驻内存。如果你的服务有大量临时对象且对内存水位敏感可以关注一下这个实验特性。但对于一般业务代码我建议不要过度优化。先用Pool解决明确的、可测量的分配开销问题。grep一下线上profile里的alloc_objects数据再决定要不要上Pool别为提速而抽象的架构。5. sync.Map什么时候值得用5.1 为什么原生map不能并发写Go的原生map在并发写时直接会触发运行时panic——fatal error: concurrent map writes。原因很简单map的底层哈希表扩容、rehash过程是non-atomic的并发写会破坏内部结构。所以并发场景下要么给map加锁要么用sync.Map。我先说结论如果只是普通的并发读写map加Mutex通常比sync.Map更简单高效。sync.Map不是万能药。5.2 sync.Map的空间换时间思路read与dirty两级缓存sync.Map之所以在特定场景下比mapMutex快在于它通过双Map结构read和dirty减少了锁竞争read只读的dirty副本读取时无锁dirty可写的map写入时需要加锁读取时先查readmiss了才查dirty并触发一次missLocked将dirty提升为read删除时使用expunged标记避免立即修改read。所以sync.Map适合**读多写少、key相对稳定不频繁删除**的场景。比如服务里的全局配置字典、id到对象的映射。这时候读几乎无锁性能接近原生map。不合适的场景也明确频繁写入、key生命周期很短经常新建/删除、写多读少的计数器类Map。这些场景直接用mapRWMutex更稳更简单。5.3 什么时候该用sync.Map工程上的判定标准我建议问自己三个问题这个Map的读请求量级是不是远大于写请求key集合是不是基本稳定不会频繁删除插入是否运行在多个goroutine同时访问、且写操作也需要锁保护的场景三个答案全是“是”再用sync.Map。否则老老实实加Mutex代码更清晰review的人也不用来回问。另有一个知识点sync.Map的LoadOrStore是原子操作这在“初始化单例缓存”场景非常有用equivalence用Mutex自己写要小心双重检查锁是否成立。6. sync生态的隐藏成员errgroup与官方扩展6.1 errgroup的基本用法严格来说errgroup不在sync包里它在golang.org/x/sync/errgroup是官方扩展库。但几乎每个用Go做并发编排的项目都会用我把它放进sync笔记里一起讲。errgroup解决的核心问题是一组goroutine并发执行任何一个goroutine返回错误整个组停止并等待所有goroutine结束把第一个错误返回来。g, ctx : errgroup.WithContext(ctx) for _, file : range files { file : file g.Go(func() error { return processFile(ctx, file) }) } if err : g.Wait(); err ! nil { log.Fatal(err) }它与WaitGroup的最大区别Wait()返回error用WithContext时第一个返回错误的goroutine会自动触发context取消从而让其他goroutine收到取消信号优雅退出。6.2 WithContext与取消机制WithContext是errgroup最实用的一面。它把context和错误传播绑定在一起适合做“只要有一步失败就整体失败”的批处理任务比如并发下载多个文件、批量处理订单、同时调用多个上游RPC。具体实现上g.Go启动的goroutine如果返回非nil错误时group会通过context.WithCancel取消整个ctx。这个机制在我们公司内部的服务里是标配批量调用多个下游服务任何一个返回5xx就立刻取消其他还在跑的任务整体响应时间大大降低。6.3 一个常见的坑goroutine内的局部变量errgroup使用时要特别注意闭包捕获问题。Go 1.22之前的循环变量是共享的如果你在for _, file : range files里直接启动goroutinefile会被所有goroutine捕获并最终变成最后一个值。我见过太多因此出现的线上bug。更安全的写法g.Go(func() error { return processFile(ctx, file) // 在for外面先复制 file : file })Go 1.22起循环变量每次迭代都有了独立的新变量问题得到缓解但如果项目还在老版本务必保持file : file的习惯。另外errgroup里Go方法不会阻塞但Wait会阻塞这个顺序不要搞反。7. 说到排查race检测器与go vet7.1 让我抓狂的一次死锁调试经历去年维护的一个任务调度模块突发高频死锁。当时的现象是服务CPU占用不高但大量请求超时goroutine还在持续堆积。抓了pprof一看几十个goroutine全部阻塞在sync.Mutex.Lock()上。进一步查代码发现问题是这样的A方法持有锁M然后调用了B方法B方法内部又对同一个M加锁——也就是重复加同一把非重入锁。sync.Mutex不是可重入锁同一个goroutine重复Lock()会把自己锁死。这个在Java里很常见synchronized是可重入的但在Go里直接翻车。当时修复的思路是要么B方法不要求加锁把B设计成“调用方负责持锁”的私有方法要么拆成两把锁。从架构上我选择了后者因为B是需要独立被调用的。这个教训后来我写进了团队的代码规范里凡是方法名前面加了下划线的都认为是“已持锁”的私有方法禁止再Lock。7.2 go test -race并发问题的照妖镜go test -race是排查数据竞争的最强工具。它通过运行时对每个内存访问插入检测代码能准确报告数据竞争的goroutine栈。经验是所有涉及并发的项目必须在CI里跑-race的单测。go test -race ./...-race对性能影响很大通常慢5-10倍但只跑测试完全没问题。我几乎每次都开着race检测器做联调它能在你还没意识到有问题的时候就报出竞争点省下很多线上修复成本。race报告里会给出两个goroutine的栈帧其中一个写、一个读或者两个写。只要照着栈去锁住对应的代码路径就行。但注意race报告只能证明测试覆盖到的路径有竞争不能证明没有竞争。所以关键代码路径要尽可能设计覆盖并发场景的测试用例。7.3 go vet的copylocks检查go vet默认开启copylocks检查。它通过静态分析找出“复制包含Mutex的结构体”的情况。虽然它不是银弹动态复制的案例还是查不出来但已经把最常见的按值传参问题堵死了。构建时养成习惯go build -vetoff ./... # 或者 go test -vetall ./...不过我的建议是不要跳过vet把一个带copylocks问题的代码build出来没有任何意义提前炸在CI里比线上事故强一百倍。8. 这些trick你越早知道越好8.1 零值可用为什么var mutex sync.Mutex就能用Go语言有个很妙的特性sync.Mutex、RWMutex、WaitGroup、Once、Pool这些类型的零值都是可以直接使用的状态。也就是说var mu sync.Mutex // 直接能用 var wg sync.WaitGroup // 直接能用这是通过内部的小技巧noCopy和合理的初始状态做到的。刚入门的开发者可能会疑惑不需要调用New()或者初始化函数吗确实不需要这是Go的“零值可用”哲学与结构体的普通定义不同它通过go vet来提醒你不要复制类型实例。所以定义包含锁的结构体时直接这样写就行type Cache struct { mu sync.Mutex items map[string]Item }因为Mutex的零值就是解锁状态一旦第一次加锁就不准再复制了。不要额外写初始化函数去new(sync.Mutex)纯属多此一举。8.2 不可复制的锁为什么panic都没报复制锁导致的问题多数时候不会直接panic而是静默地破坏并发安全。比如这个例子type SafeCounter struct { mu sync.Mutex v int } // 错误按值传了一份SafeCounter锁也被复制了 func (c SafeCounter) Incr() { c.mu.Lock() c.v c.mu.Unlock() }Incr一调用给的是c的副本方法里加锁的是副本的锁方法外真正的c.mu根本没有锁。如果多个goroutine同时调用Incr数据照样竞争。解决方法的接收者改成指针func (c *SafeCounter) Incr()同时保证该类型的使用方也都传指针。所有手动定义Mutex结构体的场景接收者一律指针。更隐蔽的一个是go test可能会报mutex is held的vet错误但线上并没有。这就是vet的价值它在编译时把大部分复制锁的问题拦住了。8.3 Locker接口与锁的抽象sync.Locker是包暴露的接口type Locker interface { Lock() Unlock() }一般不要面向它来写业务代码但如果你要自研一些并发控制组件比如接口限流器、租约锁可以考虑让它们实现Locker这样便能无缝嵌入其他需要Locker的第三方库。比如sync.Cond的构造函数就要一个Locker接口你既可以传Mutex指针也可以传RWMutex的读锁。注意RWMutex的RLock不是Locker接口的一部分——因为RLock不满足Lock/Unlock的方法签名。如果想让读写锁实现Locker那就用它的Lock/Unlock。高层的抽象在复杂项目里能减少很多样板代码但别过度锁的抽象容易把简单逻辑搞复杂。8.4 channel与Mutex选型表我把自己这些年做网关、聚合服务总结的并发选型心得列了一个速查表场景推荐方案原因一对多传递数据channel语义清晰支持超时、取消多goroutine共享状态Mutex直接保护变量简单可靠读多写少配置/CacheRWMutex或sync.MapRWMutex更通用sync.Map适合大Map等待一批任务完成WaitGroup轻量、原生支持单例初始化Once保证严格一次条件变量等待Cond避免自旋浪费CPU临时对象复用Pool降低GC与分配开销并发批处理错误传播errgroup自动取消收敛错误这里想多说一句选择并发原语就像选螺丝刀没有哪个是“更高级”的。关键看你的临界区到底在保护什么是数据是合作关系还是计算资源。数据用锁协调用channel批量生命周期用WaitGroup初始化用Once——这些东西组合起来基本能覆盖Go并发编程99%的真实需求。写到这里我顺手去翻了一下之前的笔记第一篇到第十四篇讲的都是基础语法、slice/map、goroutine和channel这些。sync包放在第十五篇其实是把前面零散的并发知识收拢在一起——从“怎么开goroutine”到“怎么让goroutine之间安全协作”中间缺的正是这些同步原语。如果你正处在刚学完channel、却不知道什么时候该上锁的阶段这篇笔记应该能帮你把模糊的边界划清楚。之后有机会我打算再写一篇关于atomic包和内存序的笔记把无锁编程这块也补上。