Go 线程安全与同步:从 happens-before 到并发所有权
面向有 Java 经验的开发者,基于 Go 1.26。
并发程序最难的部分通常不是“同时做几件事”,而是回答三个问题:
- 哪些数据会被多个 goroutine 访问?
- 写入何时对另一个 goroutine 可见?
- 谁负责结束、释放和回收?
Go 的 goroutine 很轻,创建它并不难;真正决定程序是否可靠的,是同步关系和所有权。代码偶尔跑对,或者一次压测没有报错,都不能证明它不存在数据竞争。要判断并发代码是否正确,必须从 Go 内存模型出发,再看 sync、sync/atomic 和竞争检测分别建立了什么保证。
目录
1. 先区分并发、并行与线程安全
- 并发描述程序结构:多个任务的生命周期重叠。
- 并行描述运行状态:多个任务在不同处理器上同一时刻执行。
- 线程安全描述对象或操作:在约定的并发调用方式下,结果仍符合规范。
goroutine 不是 Java Thread 的一一映射。它由 Go 运行时调度到数量较少的操作系统线程上,阻塞后可能换一条线程继续执行。因此,线程本地变量、线程 ID,以及“锁必须由原线程释放”之类的直觉,都不能直接搬过来。sync.Mutex 甚至明确允许一个 goroutine 加锁、另一个 goroutine 解锁;至于工程上是否值得这样设计,则是另一回事。
一个类型是不是线程安全,必须连同不变量一起说明:
type Counter struct {
mu sync.Mutex
n int64 // 只能在持有 mu 时访问
}这条注释不是装饰,它写明了 n 的同步协议。任何绕过方法直接访问 n 的代码,都会破坏这个协议。
2. 内存模型与 happens-before
2.1 为什么“先写后读”还不够
看起来有先后顺序的源码,不一定在两个 goroutine 之间建立可见性:
var data string
var ready bool
go func() {
data = "completed"
ready = true
}()
for !ready {
}
fmt.Println(data)这段代码有竞争。编译器和处理器可以重排内存访问,循环也没有义务观察到另一个 goroutine 的写入。即使某台机器每次都打印正确,也不能依赖。
Go 内存模型关心的是 happens-before:如果写操作 W happens-before 读操作 R,R 才被保证能观察到 W 或更晚的写入。它是由“goroutine 内的程序顺序”和“同步事件”共同形成的偏序关系,不是墙上时钟的时间顺序。
2.2 常用同步边
工程代码里经常用到这些同步关系:
- goroutine 的创建发生在该 goroutine 开始执行之前;
- channel 的第
n次成功发送发生在对应接收完成之前; - channel 的关闭发生在接收到“因关闭而返回零值”之前;
- 无缓冲 channel 的接收发生在对应发送完成之前;
- 容量为
C的 channel,第k次接收发生在第k+C次发送完成之前; Mutex.Unlock发生在之后成功的Lock返回之前;RWMutex、Once、WaitGroup、Pool和sync.Map各自的文档定义了更具体的同步边;- 若原子操作 B 观察到原子操作 A 的效果,A synchronizes-before B;所有原子操作表现为某个顺序一致的总序。
用 channel 修复前例:
data := ""
ready := make(chan struct{})
go func() {
data = "completed"
close(ready)
}()
<-ready
fmt.Println(data)data 本身没有放进 channel,但 close(ready) 与 <-ready 建立了同步边,所以关闭前的写入对接收后的代码可见。
2.3 DRF-SC
Go 对无数据竞争程序提供一个非常实用的保证:data-race-free sequential consistency,即没有数据竞争的程序可以按 goroutine 操作交错执行来理解。反过来说,一旦代码存在竞争,不要靠“在 64 位机器上一次写入应该是原子的”推理业务正确性。
3. data race 到底是什么
当两个 goroutine 并发访问同一内存位置,其中至少一个是写,并且这些访问没有通过同步协调时,就发生数据竞争;sync/atomic 提供的原子访问除外。
3.1 复合操作不是一次操作
counter++ // 读取、加一、写回即使 int64 的单次读写在目标平台上不可撕裂,++ 仍是读改写序列。两个 goroutine 都读到 10,再分别写 11,就丢了一次更新。
3.2 map 的并发规则
普通 map 允许多个 goroutine 并发只读,前提是没有任何 goroutine 写。只要存在并发写,就必须同步。运行时有时会报 concurrent map read and map write,但这个报错不是安全机制,也不能覆盖所有竞争。
3.3 切片和接口也会竞争
切片头包含指针、长度和容量;接口值包含动态类型和动态值。一个 goroutine 写切片变量或接口变量,另一个同时读取,可能观察到不一致的多字状态。共享底层数组时,即使两个切片变量不同,访问相同元素也仍会竞争。
3.4 逻辑竞争不一定是 data race
下面的代码每一步都加锁,不会被 -race 报告,却可能超卖:
if store.Stock(id) > 0 {
store.Decrease(id)
}“检查库存”和“扣减库存”必须成为同一个临界区或一个原子业务操作。race detector 检测内存访问竞争,不理解业务不变量。
4. 并发所有权:优先减少共享
锁不是第一步。第一步是确定谁拥有数据。
常见模式有:
- goroutine confinement:状态只由一个 goroutine 修改,其他 goroutine 通过 channel 发命令;
- immutable snapshot:更新时创建新值,读者只读快照;
- partitioning:按用户、分片或键分区,每份数据只有一个写者;
- scoped ownership transfer:把缓冲区通过 channel 交给下游,发送后上游不再访问;
- shared mutable state:确实需要共享时,明确由哪一把锁保护。
例如 actor 风格计数器:
type request struct {
delta int
reply chan int
}
func runCounter(reqs <-chan request) {
n := 0
for req := range reqs {
n += req.delta
req.reply <- n
}
}这个模型让状态所有权十分清楚,但不代表 channel 永远比锁好。高频、极短的内存操作用 Mutex 往往更直接。选择依据是状态模型和可维护性,不是口号。
5. Mutex:最朴素也最可靠的锁
5.1 零值可用,不要复制
type Ledger struct {
mu sync.Mutex
balance map[string]int64
}
func (l *Ledger) Transfer(from, to string, amount int64) error {
l.mu.Lock()
defer l.mu.Unlock()
if amount <= 0 {
return errors.New("amount must be positive")
}
if l.balance[from] < amount {
return errors.New("insufficient balance")
}
l.balance[from] -= amount
l.balance[to] += amount
return nil
}Mutex 的零值是未锁状态,不必构造。第一次使用后不得复制,因此含锁结构通常使用指针接收者,也不要按值放进会复制元素的容器。go vet 的 copylocks 分析能发现一部分问题。
5.2 临界区保护的是不变量
锁住的不是某一行代码,而是一组必须同时成立的关系。上例中两个账户的余额变化必须一起完成,所以不能为了“缩短锁时间”拆成两次加锁。
临界区应尽量避免:
- 网络请求和磁盘 I/O;
- 无上限阻塞的 channel 操作;
- 调用未知的用户回调;
- 再去获取顺序不明确的另一把锁。
可以先在锁内复制快照,再在锁外做慢操作:
func (r *Registry) NotifyAll() {
r.mu.Lock()
listeners := append([]func(){}, r.listeners...)
r.mu.Unlock()
for _, notify := range listeners {
notify()
}
}5.3 不要暴露受保护的可变对象
func (s *Store) Items() map[string]int {
s.mu.Lock()
defer s.mu.Unlock()
return maps.Clone(s.items)
}若直接返回 s.items,调用方会在锁外修改内部状态。切片、map、指针字段都要考虑深浅复制。
6. RWMutex 与 TryLock
6.1 RWMutex 不是自动优化
RWMutex 允许多个读锁并存,写锁独占:
type Cache struct {
mu sync.RWMutex
m map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.m[key]
return v, ok
}
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[key] = value
}只有读占绝大多数、临界区不太短、且确有并行读收益时,它才可能优于 Mutex。读锁也有原子计数和调度成本;写竞争存在时,吞吐与尾延迟必须用基准测试验证。
Go 的 RWMutex 不支持锁升级。持有 RLock 时再 Lock 会死锁;先 RUnlock 再 Lock 又会出现检查与修改之间的窗口。需要“存在则返回,不存在则创建”时,在写锁内重新检查:
func (c *Cache) GetOrCreate(key string, build func() string) string {
c.mu.RLock()
v, ok := c.m[key]
c.mu.RUnlock()
if ok {
return v
}
c.mu.Lock()
defer c.mu.Unlock()
if v, ok = c.m[key]; ok {
return v
}
v = build()
c.m[key] = v
return v
}如果 build 很慢,还要进一步设计“同一个 key 只构建一次”,不能简单长期持有全局锁。
6.2 TryLock 很少是正确答案
Mutex.TryLock、RWMutex.TryLock、TryRLock 成功时和普通加锁一样建立同步关系;失败时不建立任何同步关系。失败后不能据此安全读取受保护状态。
适用场景通常是“拿不到就放弃”的非关键工作,例如尽力而为地收集诊断快照。用它绕开锁顺序问题、忙等重试或模拟超时,往往说明设计需要调整。锁本身不支持 context 超时;需要可取消等待时,更适合 channel、信号量或重新切分临界区。
7. Once、OnceFunc、OnceValue 与 OnceValues
7.1 Once
type Client struct {
once sync.Once
conn *Connection
}
func (c *Client) init() {
c.once.Do(func() {
c.conn = dial()
})
}Do(f) 返回发生在 f 返回之后;因此 f 写入的状态对所有已经从 Do 返回的 goroutine 可见。如果 f panic,本次仍被视为“已经执行”,以后不会重试。
Once 适合进程生命周期内不可逆的一次初始化,不适合“失败后下次重试”或“测试中重置”。后两者应显式建模状态机。
7.2 函数式封装
loadConfig := sync.OnceValues(func() (*Config, error) {
return readConfig("config.json")
})
cfg, err := loadConfig()OnceFunc(func()) func():返回并发安全的只执行一次函数;OnceValue(func() T) func() T:缓存单个结果;OnceValues(func() (T1, T2)) func() (T1, T2):缓存两个结果,常用于(value, error)。
若原函数 panic,返回的包装函数以后每次调用都会以同一个值 panic。若第一次返回错误,错误同样被缓存,不会自动重试。
8. WaitGroup 与 WaitGroup.Go
WaitGroup 等待一组任务结束,它不传播结果、不取消任务,也不自动处理错误。
Go 1.25 起优先使用 Go:
var wg sync.WaitGroup
for _, job := range jobs {
job := job
wg.Go(func() {
process(job)
})
}
wg.Wait()传给 wg.Go 的函数必须不 panic。服务边界如果允许第三方回调,应在任务内部明确恢复、记录或转换,否则不要声称满足这个前提。
传统写法仍很常见:
var wg sync.WaitGroup
for _, job := range jobs {
job := job
wg.Add(1) // 必须在创建 goroutine 之前
go func() {
defer wg.Done()
process(job)
}()
}
wg.Wait()把 Add(1) 放进新 goroutine 会让 Wait 有机会先看到计数器为零并提前返回。计数器变成负数会 panic。WaitGroup 第一次使用后不能复制;复用它等待下一批任务时,要等上一批所有 Wait 都返回后再开始新一轮。
任务的 Done 或 wg.Go 中函数返回,synchronizes-before 它所释放的 Wait 返回。因此等待后读取任务已经完成的写入是安全的;任务之间同时写共享对象仍需要自己的同步。
9. Cond:等待状态改变
sync.Cond 是条件变量,适合多个 goroutine 等待同一状态变化:
type Queue struct {
mu sync.Mutex
cond *sync.Cond
items []string
closed bool
}
func NewQueue() *Queue {
q := &Queue{}
q.cond = sync.NewCond(&q.mu)
return q
}
func (q *Queue) Pop() (string, bool) {
q.mu.Lock()
defer q.mu.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
}Wait 会原子地解锁并挂起,唤醒后重新加锁。必须用 for 重新检查条件,因为唤醒只表示“状态可能变了”,不保证当前 goroutine 获得资源。Signal 唤醒一个,Broadcast 唤醒全部。
许多场景用“关闭 channel 广播一次事件”更简单;Cond 的价值在于同一条件可反复变化,或者需要在锁保护下精细协调。
10. sync/atomic:低层原子操作
优先使用类型化原子类型:
type Metrics struct {
requests atomic.Uint64
healthy atomic.Bool
}
func (m *Metrics) Record() {
m.requests.Add(1)
}
func (m *Metrics) Snapshot() (uint64, bool) {
return m.requests.Load(), m.healthy.Load()
}还有 atomic.Int32、Int64、Uint32、Uint64、Uintptr 和 Pointer[T]。它们提供 Load、Store、Swap、CompareAndSwap,整数类型还有 Add 以及按位操作。
原子操作适合:
- 独立计数器;
- 单个状态位;
- 发布不可变对象的指针;
- 已经充分论证的无锁数据结构。
它不适合维护多个字段的不变量:
// balance 与 version 必须对应时,分别原子写仍可能读到混合状态。
balance.Store(newBalance)
version.Store(newVersion)此时用锁,或把两者组成不可变结构并原子替换指针。
不要把原子访问和普通访问混用。若某字段由 atomic 管理,所有并发访问都通过 atomic。类型化原子值也不得在首次使用后复制。
10.1 CAS 循环
func AddUnlessTooLarge(v *atomic.Int64, delta, limit int64) bool {
for {
old := v.Load()
if old+delta > limit {
return false
}
if v.CompareAndSwap(old, old+delta) {
return true
}
}
}竞争激烈时 CAS 会反复失败并消耗 CPU。无锁不等于无等待,更不等于更快;普通锁可能公平得多。
11. atomic.Value 与不可变快照
atomic.Value 可以原子发布任意类型的完整值:
type ConfigStore struct {
current atomic.Value // 存储 *Config
}
func NewConfigStore(cfg *Config) *ConfigStore {
s := &ConfigStore{}
s.current.Store(cfg)
return s
}
func (s *ConfigStore) Load() *Config {
return s.current.Load().(*Config)
}第一次 Store 决定具体类型,以后存入不同具体类型会 panic;不能存 nil。最重要的是,原子发布的指针指向的对象应当视为不可变。若读者拿到指针后,写者继续修改其中的 map 或切片,仍然会竞争。
泛型代码通常更喜欢 atomic.Pointer[T]:
type ConfigStore struct {
current atomic.Pointer[Config]
}更新时构造新 Config,完整初始化后一次 Store;老读者继续使用旧快照,新读者看到新快照。
12. sync.Map:有明确适用面的并发 map
sync.Map 是 map[any]any 风格的专用并发容器,不是普通 map 加锁的通用替代品。官方文档给出的两个典型场景是:
- 某个键只写一次、之后多次读取,例如只增长缓存;
- 多个 goroutine 读写互不相交的键集合。
var cache sync.Map
actual, loaded := cache.LoadOrStore(key, computed)
if loaded {
return actual.(*Entry)
}
return computedLoadOrStore 能原子地完成“存在则取,否则存”。CompareAndSwap、CompareAndDelete、Swap、LoadAndDelete 用于单键状态转换,Clear 清空所有项。
Range 不是一致性快照:遍历过程中并发修改可能以任意时点的值出现。即使回调很快返回,最坏也可能是 O(N)。键和值使用 any,类型安全需要自己封装:
type StringCache struct {
m sync.Map
}
func (c *StringCache) Load(key string) (string, bool) {
v, ok := c.m.Load(key)
if !ok {
return "", false
}
return v.(string), true
}需要复合不变量、稳定快照或类型安全时,map[K]V + Mutex/RWMutex 通常更清楚。
13. sync.Pool:降低临时分配
sync.Pool 保存可随时丢弃的临时对象:
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() <= 1<<20 {
buffers.Put(buf)
}
}()
if err := json.NewEncoder(buf).Encode(v); err != nil {
return nil, err
}
return bytes.Clone(buf.Bytes()), nil
}关键规则:
- GC 可以在任何时候清除池,不能把它当缓存或对象仓库;
Get返回任意可用对象,也可以像池为空一样调用New;Put(x)synchronizes-before 某次返回同一个x的Get;- 归还后调用方不得继续使用对象;
- 放回前重置敏感数据,超大缓冲区通常不要放回;
- 只有测量证明分配和 GC 是瓶颈时才引入。
不要池化数据库连接等有身份、有生命周期、必须可靠归还的资源;它们需要明确的资源池实现。
14. 组合多个同步原语
14.1 分片锁
单锁竞争过高时,可以按键分片:
const shardCount = 64
type shard struct {
mu sync.RWMutex
m map[string]int
}
type ShardedMap struct {
shards [shardCount]shard
}分片数是结构的一部分。跨分片操作要按固定顺序加锁,避免死锁;哈希函数必须稳定。先测量再做,因为分片会显著增加维护复杂度。
14.2 锁与 channel 的边界
一种常见架构是:
- channel 负责生命周期、背压和所有权转移;
- mutex 负责进程内短临界区;
- atomic 负责独立指标和不可变快照发布;
- context 负责取消和截止时间。
不要拿着锁向可能阻塞的 channel 发送。如果接收者为了继续接收又需要同一把锁,就形成环路等待。
14.3 复制锁外计算,版本号校验
慢计算可采用乐观模式:
- 锁内复制输入和版本号;
- 锁外计算;
- 再加锁,确认版本未变后提交,否则重试或放弃。
这不是万能优化。冲突率高时会重复计算,仍应以基准和业务语义决定。
15. 死锁、活锁、饥饿和伪共享
15.1 死锁
两个 goroutine 以相反顺序获取 A、B 会死锁。解决原则是建立全局锁顺序,例如始终按账户 ID 从小到大加锁。不要依赖 TryLock 循环碰运气。
Go 运行时只能在“所有 goroutine 都无法继续”时报告全局死锁;服务还有网络监听 goroutine 时,局部死锁可能永久沉默。生产环境要依靠超时、goroutine dump、mutex/block profile 和指标定位。
15.2 活锁与饥饿
活锁中 goroutine 一直运行、退让、重试,却没有进展。饥饿则是某个任务长期拿不到资源。无界 CAS、自制自旋锁和不公平的任务队列都可能造成这些问题。
15.3 cache line 与伪共享
高频更新的两个独立原子计数器若落在同一 cache line,不同 CPU 核心会反复争夺缓存所有权。只有性能剖析证明这是瓶颈后才考虑填充或分片;手工依赖缓存行尺寸可移植性差,不应成为默认设计。
16. race detector 与测试策略
16.1 常用命令
go test -race ./...
go test -race -count=20 ./internal/store
go run -race ./cmd/server
go build -race ./cmd/serverrace detector 只会报告本次执行实际覆盖到的冲突路径。没有报告不等于没有竞争,因此要让测试覆盖高并发、取消、错误返回和关闭路径。必要时对带 -race 的二进制做短期预发布流量验证。
报告通常包含:
- 当前冲突访问的堆栈;
- 之前冲突访问的堆栈;
- 相关 goroutine 的创建位置。
先找到共享内存和它应有的同步协议,不要仅靠加 time.Sleep 让报告消失。
16.2 GORACE
可通过环境变量调整:
GORACE="halt_on_error=1 strip_path_prefix=/workspace/" go test -race ./...-race 会增加时间和内存开销,不一定适合永久跑在所有生产实例上,但非常适合 CI 的核心测试和预发布环境。
16.3 让并发测试可重复
- 用 channel、barrier 或测试钩子控制关键交错,不靠毫秒级睡眠;
- 循环执行并配合
-count; - 用
t.Cleanup确保 goroutine 退出; - 超时只作为防挂死的保险,不作为正确性的证明;
- 测试结束前等待所有后台任务,避免污染下一测试;
- 同时运行
go vet,检查复制锁等静态问题。
17. 性能与基准测试
同步优化先看证据:
go test -bench=. -benchmem ./internal/cache
go test -run=^$ -bench=BenchmarkCache -cpu=1,2,4,8,16
go test -mutexprofile=mutex.out -blockprofile=block.out ./...
go tool pprof mutex.out关注:
- 吞吐量与 P95/P99 延迟;
- 临界区长度;
- 锁竞争时间而不是仅看调用次数;
- 读写比例;
- 分配量和 GC;
- 真实键分布,尤其热点键。
常见优化顺序:
- 缩小共享状态;
- 将慢 I/O 移出临界区;
- 批量操作,减少加锁频率;
- 使用不可变快照;
- 必要时分片;
- 最后才考虑 atomic/CAS 的无锁结构。
RWMutex、sync.Map、Pool 和原子操作都不是看到并发就自动套用的优化。
18. 与 Java 并发工具的对照
| Go | Java 近似概念 | 重要差异 |
|---|---|---|
sync.Mutex | synchronized / ReentrantLock | 不可重入,无锁所有者身份 |
sync.RWMutex | ReentrantReadWriteLock | 不支持升级和可重入 |
sync.Cond | Condition / wait-notify | 明确绑定 Locker,仍须循环检查条件 |
sync.Once | 初始化锁 / holder idiom | 一旦函数 panic 也算执行过 |
WaitGroup | CountDownLatch | 可复用但必须遵守轮次规则;1.25 起有 Go |
sync.Map | ConcurrentHashMap | 类型为 any,适用面更窄,遍历非快照 |
atomic.* | AtomicLong、volatile | Go 原子操作为顺序一致语义 |
| channel | BlockingQueue 加同步语义 | 同时表达传值、所有权、背压和同步 |
Java 开发者最容易踩的是可重入直觉。Go 的 Mutex 不可重入,同一 goroutine 再次 Lock 会死锁。Go 也没有 volatile 关键字;可见性必须来自 channel、锁或 sync/atomic。
19. 常见误区
误区 1:只有写写并发才算竞争
写读并发同样是竞争。
误区 2:单核机器不会竞争
goroutine 可以在任意访问点交错;竞争不要求物理同时执行。
误区 3:加了 WaitGroup,结果切片就能并发 append
WaitGroup 只负责等待结束。多个任务同时 append 同一切片仍会竞争。可以按索引写互不重叠的预分配元素,或由单一汇总 goroutine 收集。
误区 4:锁内返回引用也安全
锁释放后引用仍指向共享可变数据。返回副本或只读快照。
误区 5:失败的 TryLock 能说明对象正在被安全修改
失败不建立任何 happens-before,不能读取状态。
误区 6:atomic 让整个结构线程安全
它只保证被原子访问的那个值和相应顺序关系,不自动维护跨字段业务不变量。
误区 7:sync.Map 总比 map 加锁快
它只为特定访问模式优化,还牺牲静态类型和一致快照能力。
误区 8:race detector 没报就没问题
它是动态检测,只看到执行过的路径;逻辑竞争也不在检测范围内。
误区 9:复制带锁结构只是复制数据
这会复制锁状态,通常直接破坏同步协议。
误区 10:close channel 可以和 send 随便并发
关闭与发送之间没有天然顺序,可能 panic,也可能被 race detector 报告。应由单一所有者关闭,并在协议上保证所有发送已结束。
20. 速查表
| 需求 | 首选 |
|---|---|
| 保护多个字段的不变量 | sync.Mutex |
| 读多写少且测量有收益 | sync.RWMutex |
| 一次初始化 | sync.Once / OnceValue(s) |
| 等待一组无返回值任务 | sync.WaitGroup.Go |
| 等待可重复变化的条件 | sync.Cond |
| 独立计数器、状态位 | 类型化 atomic |
| 发布不可变配置快照 | atomic.Pointer[T] / atomic.Value |
| 写一次读多次或键集合互不相交 | sync.Map |
| 降低大量同类临时对象分配 | sync.Pool |
| 转移所有权、背压、取消协议 | channel + context |
| 检测实际执行路径的数据竞争 | go test -race |
这些同步对象还有几条共同规则:
- 零值通常可用;
- 首次使用后通常不可复制;
- 文档中的 “synchronizes before” 是正确性契约;
- 不要在没有基准和剖析时用复杂原语替换简单锁。
可运行示例
三个程序分别使用 Mutex、atomic 和 Once。选择同步原语时,不应只看哪段代码最短,更要看它能否准确表达共享状态的不变量与所有权。
示例一:Mutex 保护复合状态
先保护复合不变量。 map 与“读取后加一”的复合操作都不是并发安全的。Ledger 把 map 和锁封装在同一对象中,让所有访问都经过同一个同步边界。
package main
import (
"fmt"
"sync"
)
type Ledger struct {
mu sync.Mutex
balances map[string]int
}
func NewLedger() *Ledger {
// 返回指针让常规传递共享同一个 Ledger,减少无意复制 Mutex 的机会。
// 这不是强制保护:调用方仍不应解引用后复制;Mutex 首次使用后绝不能复制。
return &Ledger{balances: make(map[string]int)}
}
func (l *Ledger) Deposit(account string, amount int) {
l.mu.Lock()
defer l.mu.Unlock()
l.balances[account] += amount
}
func (l *Ledger) Balance(account string) int {
l.mu.Lock()
defer l.mu.Unlock()
return l.balances[account]
}
func main() {
ledger := NewLedger()
var wg sync.WaitGroup
for range 100 {
wg.Add(1)
go func() {
defer wg.Done()
ledger.Deposit("alice", 1)
}()
}
wg.Wait() // 建立 happens-before,确认所有写入结束后再读取。
fmt.Println("balance:", ledger.Balance("alice"))
}运行:
go run ./examples/ch13/mutex-ledger预期输出:
balance: 100锁保护的是完整关系。
- 构造函数返回指针,让常规传递共享同一个
Ledger,减少无意复制sync.Mutex的机会;调用者仍不能通过copied := *ledger复制已经使用过的锁。 - 写入和读取都持有同一把锁;只给写操作加锁仍会与无锁读取竞争。
Wait确保所有存款完成,再读取最终余额。
让 race detector 验证一次。 运行 go run -race ./examples/ch13/mutex-ledger;随后临时删除 Balance 中的锁,并在存款尚未结束时并发读取,让 race detector 指出共享访问问题。
示例二:atomic 适合独立计数器
原子操作只负责一个值。 请求总数是独立整数,不需要维护跨字段约束。atomic.Int64 可以用较小的接口完成无竞争的读改写,但它不是任意共享状态的替代品。
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type Metrics struct {
requests atomic.Int64
}
func (m *Metrics) RecordRequest() {
// Add 是单个计数器的原子读改写,不会产生数据竞争。
// 若多个字段必须共同满足不变量,应改用 Mutex,而不是堆叠多个 atomic。
m.requests.Add(1)
}
func (m *Metrics) Requests() int64 {
return m.requests.Load()
}
func main() {
var metrics Metrics
var wg sync.WaitGroup
for range 1000 {
wg.Add(1)
go func() {
defer wg.Done()
metrics.RecordRequest()
}()
}
wg.Wait()
fmt.Println("requests:", metrics.Requests())
}运行:
go run ./examples/ch13/atomic-counter预期输出:
requests: 1000两个工具各管一件事。
- 每个 goroutine 用
Add(1)原子更新同一计数器。 WaitGroup管的是 goroutine 生命周期,atomic 管的是计数器访问,两者职责不同。- 最终使用
Load,而不是绕过类型读取内部值。
把问题扩展到两个字段。 增加 successes 计数,并规定它不能超过 requests。分别原子更新两个字段后再判断:单字段都无竞争,是否就能保证读者看到一致快照?需要维护跨字段不变量时,应改用 Mutex。
示例三:OnceValue 发布只读配置
只执行一次还不够。 多个 goroutine 同时首次读取配置时,昂贵加载只能执行一次,所有读者还必须看到完整的初始化结果。sync.OnceValue 同时处理执行一次、内存可见性和结果缓存。
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type Config struct {
Endpoint string
}
func main() {
var loads atomic.Int64
// OnceValue 把“只执行一次”和结果缓存封装在一起。
// 返回值应视为只读;若调用方并发修改同一个对象,Once 并不会提供保护。
loadConfig := sync.OnceValue(func() Config {
loads.Add(1)
return Config{Endpoint: "https://internal.example"}
})
const readers = 8
var wg sync.WaitGroup
results := make(chan Config, readers)
for range readers {
wg.Add(1)
go func() {
defer wg.Done()
results <- loadConfig()
}()
}
wg.Wait()
close(results)
allSame := true
for config := range results {
allSame = allSame && config.Endpoint == "https://internal.example"
}
fmt.Printf("loads=%d allSame=%t\n", loads.Load(), allSame)
}运行:
go run ./examples/ch13/once-config预期输出:
loads=1 allSame=true这个保证包含三部分。
- 八个 goroutine 并发调用同一个
loadConfig。 OnceValue只执行一次函数,其余调用等待并复用返回值。- 示例返回值是值类型并按只读方式使用;如果缓存指针后并发修改对象,Once 不会继续保护它。
再观察失败语义。 把函数改成会 panic,观察 OnceValue 如何对后续调用重复同一 panic。需要缓存 (value, error) 时可以使用 sync.OnceValues,同时要明确失败是否允许重试。
21. 练习
- 写一个线程安全的 LRU 缓存。列出由锁保护的不变量,并解释为什么返回值不会泄露内部可变状态。
- 分别用
Mutex、atomic.Uint64实现计数器,对 1、4、16 个并发调用者做基准测试。 - 构造一个
WaitGroup.Add放错位置的测试,再改成WaitGroup.Go。 - 用不可变快照和
atomic.Pointer[Config]实现配置热更新,保证旧读者不受新配置修改影响。 - 写一个故意有切片并发
append的测试,用-race定位,再用“预分配后按索引写”和“单汇总 goroutine”两种方式修复。 - 对同一数据访问模式比较
sync.Map与分片map + RWMutex,记录键分布、读写比和尾延迟。 - 实现一个支持关闭的有界队列:先用
sync.Cond,再用 channel,比较状态表达和取消处理。 - 找出“余额检查和扣减分别加锁”的逻辑竞争,设计一个真正原子的业务 API。