Skip to content

Go 线程安全与同步:从 happens-before 到并发所有权

面向有 Java 经验的开发者,基于 Go 1.26。

并发程序最难的部分通常不是“同时做几件事”,而是回答三个问题:

  1. 哪些数据会被多个 goroutine 访问?
  2. 写入何时对另一个 goroutine 可见?
  3. 谁负责结束、释放和回收?

Go 的 goroutine 很轻,创建它并不难;真正决定程序是否可靠的,是同步关系和所有权。代码偶尔跑对,或者一次压测没有报错,都不能证明它不存在数据竞争。要判断并发代码是否正确,必须从 Go 内存模型出发,再看 syncsync/atomic 和竞争检测分别建立了什么保证。

目录

1. 先区分并发、并行与线程安全

  • 并发描述程序结构:多个任务的生命周期重叠。
  • 并行描述运行状态:多个任务在不同处理器上同一时刻执行。
  • 线程安全描述对象或操作:在约定的并发调用方式下,结果仍符合规范。

goroutine 不是 Java Thread 的一一映射。它由 Go 运行时调度到数量较少的操作系统线程上,阻塞后可能换一条线程继续执行。因此,线程本地变量、线程 ID,以及“锁必须由原线程释放”之类的直觉,都不能直接搬过来。sync.Mutex 甚至明确允许一个 goroutine 加锁、另一个 goroutine 解锁;至于工程上是否值得这样设计,则是另一回事。

一个类型是不是线程安全,必须连同不变量一起说明:

go
type Counter struct {
	mu sync.Mutex
	n  int64 // 只能在持有 mu 时访问
}

这条注释不是装饰,它写明了 n 的同步协议。任何绕过方法直接访问 n 的代码,都会破坏这个协议。

2. 内存模型与 happens-before

2.1 为什么“先写后读”还不够

看起来有先后顺序的源码,不一定在两个 goroutine 之间建立可见性:

go
var data string
var ready bool

go func() {
	data = "completed"
	ready = true
}()

for !ready {
}
fmt.Println(data)

这段代码有竞争。编译器和处理器可以重排内存访问,循环也没有义务观察到另一个 goroutine 的写入。即使某台机器每次都打印正确,也不能依赖。

Go 内存模型关心的是 happens-before:如果写操作 W happens-before 读操作 RR 才被保证能观察到 W 或更晚的写入。它是由“goroutine 内的程序顺序”和“同步事件”共同形成的偏序关系,不是墙上时钟的时间顺序。

2.2 常用同步边

工程代码里经常用到这些同步关系:

  • goroutine 的创建发生在该 goroutine 开始执行之前;
  • channel 的第 n 次成功发送发生在对应接收完成之前;
  • channel 的关闭发生在接收到“因关闭而返回零值”之前;
  • 无缓冲 channel 的接收发生在对应发送完成之前;
  • 容量为 C 的 channel,第 k 次接收发生在第 k+C 次发送完成之前;
  • Mutex.Unlock 发生在之后成功的 Lock 返回之前;
  • RWMutexOnceWaitGroupPoolsync.Map 各自的文档定义了更具体的同步边;
  • 若原子操作 B 观察到原子操作 A 的效果,A synchronizes-before B;所有原子操作表现为某个顺序一致的总序。

用 channel 修复前例:

go
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 复合操作不是一次操作

go
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 报告,却可能超卖:

go
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 风格计数器:

go
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 零值可用,不要复制

go
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 vetcopylocks 分析能发现一部分问题。

5.2 临界区保护的是不变量

锁住的不是某一行代码,而是一组必须同时成立的关系。上例中两个账户的余额变化必须一起完成,所以不能为了“缩短锁时间”拆成两次加锁。

临界区应尽量避免:

  • 网络请求和磁盘 I/O;
  • 无上限阻塞的 channel 操作;
  • 调用未知的用户回调;
  • 再去获取顺序不明确的另一把锁。

可以先在锁内复制快照,再在锁外做慢操作:

go
func (r *Registry) NotifyAll() {
	r.mu.Lock()
	listeners := append([]func(){}, r.listeners...)
	r.mu.Unlock()

	for _, notify := range listeners {
		notify()
	}
}

5.3 不要暴露受保护的可变对象

go
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 允许多个读锁并存,写锁独占:

go
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 会死锁;先 RUnlockLock 又会出现检查与修改之间的窗口。需要“存在则返回,不存在则创建”时,在写锁内重新检查:

go
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.TryLockRWMutex.TryLockTryRLock 成功时和普通加锁一样建立同步关系;失败时不建立任何同步关系。失败后不能据此安全读取受保护状态。

适用场景通常是“拿不到就放弃”的非关键工作,例如尽力而为地收集诊断快照。用它绕开锁顺序问题、忙等重试或模拟超时,往往说明设计需要调整。锁本身不支持 context 超时;需要可取消等待时,更适合 channel、信号量或重新切分临界区。

7. Once、OnceFunc、OnceValue 与 OnceValues

7.1 Once

go
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 函数式封装

go
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

go
var wg sync.WaitGroup

for _, job := range jobs {
	job := job
	wg.Go(func() {
		process(job)
	})
}
wg.Wait()

传给 wg.Go 的函数必须不 panic。服务边界如果允许第三方回调,应在任务内部明确恢复、记录或转换,否则不要声称满足这个前提。

传统写法仍很常见:

go
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 都返回后再开始新一轮。

任务的 Donewg.Go 中函数返回,synchronizes-before 它所释放的 Wait 返回。因此等待后读取任务已经完成的写入是安全的;任务之间同时写共享对象仍需要自己的同步。

9. Cond:等待状态改变

sync.Cond 是条件变量,适合多个 goroutine 等待同一状态变化:

go
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:低层原子操作

优先使用类型化原子类型:

go
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.Int32Int64Uint32Uint64UintptrPointer[T]。它们提供 LoadStoreSwapCompareAndSwap,整数类型还有 Add 以及按位操作。

原子操作适合:

  • 独立计数器;
  • 单个状态位;
  • 发布不可变对象的指针;
  • 已经充分论证的无锁数据结构。

它不适合维护多个字段的不变量:

go
// balance 与 version 必须对应时,分别原子写仍可能读到混合状态。
balance.Store(newBalance)
version.Store(newVersion)

此时用锁,或把两者组成不可变结构并原子替换指针。

不要把原子访问和普通访问混用。若某字段由 atomic 管理,所有并发访问都通过 atomic。类型化原子值也不得在首次使用后复制。

10.1 CAS 循环

go
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 可以原子发布任意类型的完整值:

go
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]

go
type ConfigStore struct {
	current atomic.Pointer[Config]
}

更新时构造新 Config,完整初始化后一次 Store;老读者继续使用旧快照,新读者看到新快照。

12. sync.Map:有明确适用面的并发 map

sync.Mapmap[any]any 风格的专用并发容器,不是普通 map 加锁的通用替代品。官方文档给出的两个典型场景是:

  1. 某个键只写一次、之后多次读取,例如只增长缓存;
  2. 多个 goroutine 读写互不相交的键集合。
go
var cache sync.Map

actual, loaded := cache.LoadOrStore(key, computed)
if loaded {
	return actual.(*Entry)
}
return computed

LoadOrStore 能原子地完成“存在则取,否则存”。CompareAndSwapCompareAndDeleteSwapLoadAndDelete 用于单键状态转换,Clear 清空所有项。

Range 不是一致性快照:遍历过程中并发修改可能以任意时点的值出现。即使回调很快返回,最坏也可能是 O(N)。键和值使用 any,类型安全需要自己封装:

go
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 保存可随时丢弃的临时对象:

go
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 某次返回同一个 xGet
  • 归还后调用方不得继续使用对象;
  • 放回前重置敏感数据,超大缓冲区通常不要放回;
  • 只有测量证明分配和 GC 是瓶颈时才引入。

不要池化数据库连接等有身份、有生命周期、必须可靠归还的资源;它们需要明确的资源池实现。

14. 组合多个同步原语

14.1 分片锁

单锁竞争过高时,可以按键分片:

go
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 复制锁外计算,版本号校验

慢计算可采用乐观模式:

  1. 锁内复制输入和版本号;
  2. 锁外计算;
  3. 再加锁,确认版本未变后提交,否则重试或放弃。

这不是万能优化。冲突率高时会重复计算,仍应以基准和业务语义决定。

15. 死锁、活锁、饥饿和伪共享

15.1 死锁

两个 goroutine 以相反顺序获取 AB 会死锁。解决原则是建立全局锁顺序,例如始终按账户 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 常用命令

bash
go test -race ./...
go test -race -count=20 ./internal/store
go run -race ./cmd/server
go build -race ./cmd/server

race detector 只会报告本次执行实际覆盖到的冲突路径。没有报告不等于没有竞争,因此要让测试覆盖高并发、取消、错误返回和关闭路径。必要时对带 -race 的二进制做短期预发布流量验证。

报告通常包含:

  • 当前冲突访问的堆栈;
  • 之前冲突访问的堆栈;
  • 相关 goroutine 的创建位置。

先找到共享内存和它应有的同步协议,不要仅靠加 time.Sleep 让报告消失。

16.2 GORACE

可通过环境变量调整:

bash
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. 性能与基准测试

同步优化先看证据:

bash
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;
  • 真实键分布,尤其热点键。

常见优化顺序:

  1. 缩小共享状态;
  2. 将慢 I/O 移出临界区;
  3. 批量操作,减少加锁频率;
  4. 使用不可变快照;
  5. 必要时分片;
  6. 最后才考虑 atomic/CAS 的无锁结构。

RWMutexsync.MapPool 和原子操作都不是看到并发就自动套用的优化。

18. 与 Java 并发工具的对照

GoJava 近似概念重要差异
sync.Mutexsynchronized / ReentrantLock不可重入,无锁所有者身份
sync.RWMutexReentrantReadWriteLock不支持升级和可重入
sync.CondCondition / wait-notify明确绑定 Locker,仍须循环检查条件
sync.Once初始化锁 / holder idiom一旦函数 panic 也算执行过
WaitGroupCountDownLatch可复用但必须遵守轮次规则;1.25 起有 Go
sync.MapConcurrentHashMap类型为 any,适用面更窄,遍历非快照
atomic.*AtomicLongvolatileGo 原子操作为顺序一致语义
channelBlockingQueue 加同步语义同时表达传值、所有权、背压和同步

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 和锁封装在同一对象中,让所有访问都经过同一个同步边界。

go
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"))
}

运行:

bash
go run ./examples/ch13/mutex-ledger

预期输出:

text
balance: 100

锁保护的是完整关系。

  • 构造函数返回指针,让常规传递共享同一个 Ledger,减少无意复制 sync.Mutex 的机会;调用者仍不能通过 copied := *ledger 复制已经使用过的锁。
  • 写入和读取都持有同一把锁;只给写操作加锁仍会与无锁读取竞争。
  • Wait 确保所有存款完成,再读取最终余额。

让 race detector 验证一次。 运行 go run -race ./examples/ch13/mutex-ledger;随后临时删除 Balance 中的锁,并在存款尚未结束时并发读取,让 race detector 指出共享访问问题。

示例二:atomic 适合独立计数器

原子操作只负责一个值。 请求总数是独立整数,不需要维护跨字段约束。atomic.Int64 可以用较小的接口完成无竞争的读改写,但它不是任意共享状态的替代品。

go
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())
}

运行:

bash
go run ./examples/ch13/atomic-counter

预期输出:

text
requests: 1000

两个工具各管一件事。

  1. 每个 goroutine 用 Add(1) 原子更新同一计数器。
  2. WaitGroup 管的是 goroutine 生命周期,atomic 管的是计数器访问,两者职责不同。
  3. 最终使用 Load,而不是绕过类型读取内部值。

把问题扩展到两个字段。 增加 successes 计数,并规定它不能超过 requests。分别原子更新两个字段后再判断:单字段都无竞争,是否就能保证读者看到一致快照?需要维护跨字段不变量时,应改用 Mutex。

示例三:OnceValue 发布只读配置

只执行一次还不够。 多个 goroutine 同时首次读取配置时,昂贵加载只能执行一次,所有读者还必须看到完整的初始化结果。sync.OnceValue 同时处理执行一次、内存可见性和结果缓存。

go
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)
}

运行:

bash
go run ./examples/ch13/once-config

预期输出:

text
loads=1 allSame=true

这个保证包含三部分。

  • 八个 goroutine 并发调用同一个 loadConfig
  • OnceValue 只执行一次函数,其余调用等待并复用返回值。
  • 示例返回值是值类型并按只读方式使用;如果缓存指针后并发修改对象,Once 不会继续保护它。

再观察失败语义。 把函数改成会 panic,观察 OnceValue 如何对后续调用重复同一 panic。需要缓存 (value, error) 时可以使用 sync.OnceValues,同时要明确失败是否允许重试。

21. 练习

  1. 写一个线程安全的 LRU 缓存。列出由锁保护的不变量,并解释为什么返回值不会泄露内部可变状态。
  2. 分别用 Mutexatomic.Uint64 实现计数器,对 1、4、16 个并发调用者做基准测试。
  3. 构造一个 WaitGroup.Add 放错位置的测试,再改成 WaitGroup.Go
  4. 用不可变快照和 atomic.Pointer[Config] 实现配置热更新,保证旧读者不受新配置修改影响。
  5. 写一个故意有切片并发 append 的测试,用 -race 定位,再用“预分配后按索引写”和“单汇总 goroutine”两种方式修复。
  6. 对同一数据访问模式比较 sync.Map 与分片 map + RWMutex,记录键分布、读写比和尾延迟。
  7. 实现一个支持关闭的有界队列:先用 sync.Cond,再用 channel,比较状态表达和取消处理。
  8. 找出“余额检查和扣减分别加锁”的逻辑竞争,设计一个真正原子的业务 API。

22. 官方资料

以 Go 官方规范与标准库文档为准,示例面向 Go 1.26。