Go 基础体系 · 第 20/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。
Go sync 与 atomic:Mutex、RWMutex、WaitGroup、Once 和 Cond
本文所有 API 与行为以 Go 1.26.4 为基准。sync 和 sync/atomic 解决的是多个 goroutine 访问共享内存时的同步与可见性。原语本身只能提供特定的 happens-before 关系,不能替业务定义正确性:真正需要保护的是“余额与流水同时更新”“状态为 ready 时配置一定完整”这类不变量,而不是孤立变量名。
选择原语前先写清共享状态、允许并发的操作、必须原子发生的状态转换和对象生命周期。简单、短小的临界区优先 Mutex;读写特征明确且基准证明有收益时考虑 RWMutex;独立计数或不可变快照可用 atomic;任务完成用 WaitGroup;一次初始化用 Once;条件等待才用 Cond。channel 更适合传递工作和所有权,不需要强行二选一。
1. 数据竞争与同步保证
两个 goroutine 并发访问同一内存位置,至少一个是写,并且没有同步顺序,就是数据竞争。数据竞争使程序行为不受 Go 内存模型保证;“在我的机器上总是先写后读”不是证明。
Mutex.Unlock 与之后成功的 Lock、channel 通信、atomic 操作等会建立同步关系。下面程序中,读者获得锁后可观察到完整更新:
type Config struct {
mu sync.Mutex
host string
port int
}
func (c *Config) Set(host string, port int) {
c.mu.Lock()
defer c.mu.Unlock()
c.host, c.port = host, port
}
func (c *Config) Get() (string, int) {
c.mu.Lock()
defer c.mu.Unlock()
return c.host, c.port
}
锁两侧必须保护同一个逻辑状态。写方加锁而读方不加,仍是竞态。多个字段分别用 atomic 虽无数据竞争,也可能读到不属于任何有效事务的组合,这是逻辑竞态。
2. Mutex:围绕不变量设计临界区
sync.Mutex 的零值可用。Lock 获得互斥访问,Unlock 释放;锁不与 goroutine 绑定,因此一个 goroutine 可加锁、另一个解锁,但这种设计通常很难审查。解锁未锁定的 Mutex 会触发不可恢复的运行时错误。
type Account struct {
mu sync.Mutex
balance int64
entries []int64
}
func (a *Account) Apply(delta int64) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.balance+delta < 0 {
return errors.New("insufficient funds")
}
a.balance += delta
a.entries = append(a.entries, delta)
return nil
}
检查余额、修改余额、追加流水必须在同一临界区,否则不变量会被并发穿透。defer Unlock 清晰且能覆盖提前返回;极热点小函数可用 benchmark 判断手写解锁是否真的值得,不能牺牲错误路径正确性换想象中的性能。
不要在持锁期间做网络 I/O、等待 channel、调用未知回调或执行无上限工作。这会放大竞争和尾延迟,也可能让回调重入同一对象造成死锁。常见做法是在锁内复制必要快照或取出待处理项,解锁后执行外部操作,再以版本号或状态检查提交结果。
3. Mutex 没有可依赖的公平与重入语义
Go 的 Mutex 会结合自旋、唤醒和饥饿处理优化常见负载,但 API 不承诺严格 FIFO。程序不能依赖某个等待者一定是下一个获得锁者,也不应以调度细节实现优先级。
Mutex 不是可重入锁:持锁函数再次调用一个会锁同一 Mutex 的方法会永久等待。解决办法是整理方法边界,例如公开方法加锁后调用命名为 fooLocked 的私有辅助函数,并约定调用时锁已持有;不要尝试记录 goroutine ID 模拟重入。
TryLock 只在成功时获得锁;失败不建立任何同步关系。它偶尔适用于可以跳过的维护工作,但经常是设计气味:循环 TryLock 会忙等,失败后读取受保护状态仍然错误。正常业务流程应优先阻塞 Lock 或重新设计排队协议。
4. RWMutex:并发读并非免费优化
RWMutex 允许多个读锁并存,写锁仍独占。零值可用,但不能升级或降级:持 RLock 再 Lock 会死锁,持写锁再 RLock 也不是合法的重入方式。等候中的写者会阻止新的读者持续闯入,以便写者最终推进,因此递归读锁也不可靠。
type Cache struct {
mu sync.RWMutex
items map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
value, ok := c.items[key]
c.mu.RUnlock()
return value, ok
}
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = value
}
RWMutex 有更多状态和原子操作。临界区很短、并发不高、写入频繁或缓存行争用明显时,它可能比 Mutex 更慢。只有读占绝大多数、读临界区足够长且确实并行时才可能获益,必须用代表生产读写比与 CPU 数的 benchmark 验证。
返回 map、slice 或指针会把可变状态泄出锁外。应复制数据、返回不可变对象,或规定调用者的所有权;解锁后仍握着内部 slice 并访问,并不受曾经持有的读锁保护。
5. 锁复制、值接收者与锁顺序
Mutex、RWMutex、WaitGroup、Once、Cond、Map 以及 atomic 类型在首次使用后都不应复制。复制会产生两个看似独立但保护同一或部分共享状态的同步对象。包含这些字段的结构方法通常使用指针接收者,结构也不要按值放入会移动复制的 API。
go vet ./... # copylocks 等静态检查
go test -race ./... # 执行到的动态竞态
go test -run TestTransfer -count=100 ./...
需要同时持多把锁时必须规定全局顺序,例如始终按账户 ID 从小到大加锁。对象地址顺序可能随实现变化,业务稳定 ID 更易审查。更好的方案常是让一个更高层锁保护跨对象事务,或把事务交给唯一拥有者处理。defer 的 LIFO 可帮助逆序解锁,但前提是加锁顺序统一。
6. WaitGroup:等待任务,不传播错误
WaitGroup 是计数闩锁。Go 1.26.4 提供两种常用方式。传统写法在启动前 Add,任务退出时 Done:
var wg sync.WaitGroup
for _, job := range jobs {
wg.Add(1)
go func(job Job) {
defer wg.Done()
process(job)
}(job)
}
wg.Wait()
WaitGroup.Go(f) 会启动 f 并在返回时完成计数:
var wg sync.WaitGroup
for _, job := range jobs {
job := job
wg.Go(func() { process(job) })
}
wg.Wait()
传给 Go 的函数不应 panic。WaitGroup 不收集返回值、不传播 error,也不取消同组任务;需要这些能力时应在其上建立明确协议,或使用适合项目依赖策略的任务组。
当计数为零时发生的正数 Add 必须在 Wait 前完成,不能在新 goroutine 内 Add,因为 Wait 可能先返回。计数变成负数会 panic。一个 WaitGroup 可在上一轮 Wait 返回后复用,但新一轮 Add 不能与上一轮 Wait 混杂。任务内部可以安全地继续 Go 新任务,只要组尚未完成。
7. Once、OnceFunc 与初始化失败
Once.Do(f) 保证在所有调用中 f 至多执行一次。f 的完成 synchronizes-before 任一 Do 返回,因此其他调用者能看到初始化结果。若 f panic,这次仍被视为已经执行,后续 Do 不会重试。
type Loader struct {
once sync.Once
cfg Config
err error
}
func (l *Loader) Load() (Config, error) {
l.once.Do(func() {
l.cfg, l.err = readConfig()
})
return l.cfg, l.err
}
这里把错误缓存下来,表达“一次尝试”。若初始化失败后必须重试、刷新或退避,Once 不合适,应设计带锁状态机和明确的并发等待规则。
sync.OnceFunc 把无参函数包装成只执行一次的函数,并在原函数 panic 时让每次调用以相同值 panic;OnceValue、OnceValues 分别缓存一个或两个返回值。它们减少样板,但仍是永久缓存,不能用于需要失效的配置。
8. Cond:等待条件变化必须使用循环
sync.Cond 绑定一个 Locker。Wait 会原子地解锁、挂起,醒来后重新加锁再返回。它不会承诺条件已经为真:其他 goroutine 可能先修改状态,Signal 也可能只表示“值得重新检查”。因此检查必须放在循环中。
type Queue struct {
cond *sync.Cond
items []string
closed bool
}
func NewQueue() *Queue {
return &Queue{cond: sync.NewCond(&sync.Mutex{})}
}
func (q *Queue) Pop() (string, bool) {
q.cond.L.Lock()
defer q.cond.L.Unlock()
for len(q.items) == 0 && !q.closed {
q.cond.Wait()
}
if len(q.items) == 0 {
return "", false
}
item := q.items[0]
q.items = q.items[1:]
return item, true
}
改变条件状态时持锁,之后 Signal 唤醒一个等待者,Broadcast 唤醒全部。调用 Signal/Broadcast 不强制持锁,但持锁修改条件使协议更易推理。Signal 不会因为某个等待者优先级高而保证唤醒它。
Cond 没有 context 版 Wait。为每次等待另起 goroutine 再 select 往往会泄漏。需要可取消等待时,channel 关闭广播或围绕状态变化设计事件 channel 通常更合适;Cond 适用于进程内部共享状态、等待者众多且条件谓词复杂的场景。
9. Pool:临时对象缓存,不是资源池
sync.Pool 缓存可在任意调用者之间复用的临时对象。Get 可能返回任意已放入对象,也可能返回 nil;GC 可以随时移除池中内容。它不能存连接、锁、请求状态或必须被归还的业务资源。
var buffers = sync.Pool{New: func() any {
return new(bytes.Buffer)
}}
func encode(v any) ([]byte, error) {
buf := buffers.Get().(*bytes.Buffer)
buf.Reset()
defer func() {
if buf.Cap() <= 64<<10 {
buffers.Put(buf)
}
}()
if err := json.NewEncoder(buf).Encode(v); err != nil {
return nil, err
}
return bytes.Clone(buf.Bytes()), nil
}
返回前必须复制结果,否则 buffer 放回池后会被另一个 goroutine覆盖。放回前清除敏感数据和引用,限制超大对象回池,避免偶发大请求永久抬高常驻内存。Pool 是否有效要看 allocs/op、GC CPU 与尾延迟;逃逸、重置和缓存污染可能抵消收益。
10. sync.Map 的专门用途
sync.Map 是类型不安全的并发 map,零值可用。它针对两类场景优化:键只写一次却读很多次,例如只增长缓存;或不同 goroutine 操作互不相交的键集合。普通业务 map、需要多字段不变量或需要一次事务操作多个键时,map[K]V 加 Mutex 通常更清晰且类型安全。
Go 1.26 的常见操作包括 Load、Store、LoadOrStore、LoadAndDelete、Swap、CompareAndSwap、CompareAndDelete、Range 和 Clear。Range 不提供一致快照;遍历期间每个键可能反映不同时间点。比较操作要求参与比较的动态值可比较,否则会 panic。
不能把 Load 后计算再 Store 当作原子读改写。计数值可存 *atomic.Int64 并用 LoadOrStore 发布,复杂更新仍需锁或唯一拥有者。
11. atomic:单字段状态与不可变快照
Go 1.26.4 应优先使用类型化原子类型,例如 atomic.Bool、Int32、Int64、Uint32、Uint64、Uintptr 和 Pointer[T]。它们支持 Load/Store/Swap/CompareAndSwap,整数还支持 Add、And、Or 等。Go 的 atomic 操作按顺序一致方式表现,可建立同步顺序,但这不自动组合多个位置。
type Metrics struct {
requests atomic.Uint64
stopped atomic.Bool
}
func (m *Metrics) Record() bool {
if m.stopped.Load() {
return false
}
m.requests.Add(1)
return true
}
这段代码无数据竞争,却不保证 Stop 返回后计数绝不再增加:Record 可能先读到 false,随后 Stop 存 true,最后 Record Add。这是协议层竞态。若停止和接纳必须是一个不可分割状态转换,应使用同一把锁或单个编码状态的 CAS 循环。
原子类型也不可在首次使用后复制。要注意 32 位架构上的对齐问题;类型化原子字段由实现负责自身对齐,比直接使用旧函数和裸整数稳妥。
12. CAS 循环与 ABA 边界
Compare-and-swap 只在当前值等于旧值时写入新值,适合简单状态机:
const (
stateOpen int32 = iota
stateClosing
stateClosed
)
var state atomic.Int32
if state.CompareAndSwap(stateOpen, stateClosing) {
beginClose()
}
复杂无锁结构需要处理 ABA:某个值从 A 变 B 又回到 A,CAS 只看当前位模式,会误以为什么都没变。内存回收、指针生命周期、退避和活锁也非常棘手。除非已有成熟算法、测量证明锁是瓶颈并能承受验证成本,业务代码不应自制无锁队列。
atomic.Value 可发布动态类型一致的不可变快照。第一次 Store 决定具体类型,Store nil 或之后存不同具体类型会 panic。类型化 atomic.Pointer[T] 通常更明确。无论哪种方式,发布后都不能原地修改快照内部的 map/slice;更新者应复制、修改副本、一次 Store 新指针。
13. 死锁、锁竞争与错误模式
常见错误可分为四类:
- 锁顺序环:路径 A 先锁 X 再锁 Y,路径 B 相反。
- 锁内阻塞:持锁等待 channel、I/O、WaitGroup 或回调,对方需要这把锁才能推进。
- 遗漏同步:写路径加锁,调试/指标读取直接访问字段。
- 过度拆锁:分别保护必须共同变化的字段,组合状态失去一致性。
另有复制锁、忘记 Done、在 goroutine 内 Add、Cond 用 if 代替 for、Pool 对象未 Reset、atomic 复合操作被拆开等典型问题。-race 只报告执行到的数据竞争,不报告所有死锁和逻辑竞态;无竞态不是并发正确性的充分条件。
14. 诊断与测试工具
出现尾延迟或吞吐下降时,先用指标确认是锁等待而非业务计算。Mutex profile 采样竞争锁的累计等待,block profile 还覆盖 channel、Cond 等阻塞;采样率会有开销,应按环境控制。
go test -race ./...
go test -bench=. -benchmem -cpu=1,4,16 ./...
go test -run TestConcurrent -count=100 ./...
go tool pprof http://127.0.0.1:6060/debug/pprof/mutex
go tool pprof http://127.0.0.1:6060/debug/pprof/block
测试不应只断言最终数值。应覆盖并发关闭、错误返回、重复调用、读写混合、取消期间的状态转换和 API 零值。benchmark 要模拟真实临界区长度、读写比、键分布、CPU 数与共享热点;均匀随机键可能掩盖生产中的热门键竞争。
goroutine dump 会显示 [sync.Mutex.Lock]、[sync.Cond.Wait] 等等待点。抓取多个时点并结合 mutex profile 才能区分短暂峰值与持续死锁。线上可记录操作等待直方图,但不要在锁内做高成本日志。
15. 性能与生产设计
优化锁竞争优先缩短临界区和减少共享,而不是直接换 RWMutex 或 atomic。可按独立键分片,但分片数、哈希开销和跨分片事务会增加复杂度;只在 profile 显示单锁热点后采用。不可变快照适合读多写少配置,代价是每次更新复制和旧快照等待 GC。
避免伪共享:多个高频 atomic 字段即使逻辑独立,落在同一缓存行也会互相拖慢。不过手工填充结构依赖架构和布局,应以硬件计数器或可靠 benchmark 为依据。锁指标本身也可能改变时序,采样比逐次埋点更稳妥。
代码审查时应要求每个共享字段注明由哪把锁、哪种 atomic 或哪个 goroutine 所有;锁顺序写在离结构定义近的位置;公共方法说明返回值是否为快照;关闭过程说明新工作如何与停止原子协调。
16. 可运行综合示例:原子发布的配置快照
下面程序使用 atomic.Pointer 为大量读者发布不可变快照,使用 Mutex 串行化更新,使用 WaitGroup 管理读者。更新者复制 map 后一次发布,因此读者无需锁,也绝不观察半完成配置。
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type Snapshot struct {
Version uint64
Values map[string]string
}
type Store struct {
updateMu sync.Mutex
current atomic.Pointer[Snapshot]
}
func NewStore() *Store {
store := &Store{}
store.current.Store(&Snapshot{Values: map[string]string{}})
return store
}
func (s *Store) Load(key string) (string, bool, uint64) {
snapshot := s.current.Load()
value, ok := snapshot.Values[key]
return value, ok, snapshot.Version
}
func (s *Store) Set(key, value string) uint64 {
s.updateMu.Lock()
defer s.updateMu.Unlock()
old := s.current.Load()
values := make(map[string]string, len(old.Values)+1)
for k, v := range old.Values {
values[k] = v
}
values[key] = value
next := &Snapshot{Version: old.Version + 1, Values: values}
s.current.Store(next)
return next.Version
}
func main() {
store := NewStore()
store.Set("region", "ap-southeast")
var wg sync.WaitGroup
for id := range 4 {
id := id
wg.Go(func() {
value, ok, version := store.Load("region")
fmt.Printf("reader=%d value=%q ok=%t version=%d\n", id, value, ok, version)
})
}
store.Set("region", "eu-west")
wg.Wait()
}
读者可能看到版本 1 或版本 2,这取决于调度,但每个结果都来自一个完整快照。若业务要求所有读者在 Set 返回后才开始,调用方还需要建立启动顺序;atomic 负责安全发布,不替代业务时序。
系列导航与关联阅读
- 系列入口:Go 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go select、超时、Timer 与 Ticker:协调多个并发事件
- 下一篇:Go 并发模式:Worker Pool、Pipeline、Fan-out 与背压
- 延伸:Go 内存模型与数据竞争:happens-before 才是并发正确性
- 延伸:Go channel 完整基础:发送、接收、缓冲、关闭与所有权
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论