Go-16 sync 并发原语与原子操作
Go 的并发哲学是「不要通过共享内存来通信,而要通过通信来共享内存」。channel 是首选,但当我们确实需要共享状态时,sync 与 sync/atomic 包提供的并发原语就是绕不开的基础设施。本章系统梳理 Mutex、RWMutex、WaitGroup、Once、Cond、Pool、Map 以及原子操作的用法与底层原理,并结合 Go 内存模型讲清「为什么需要同步」。
1. 数据竞争与 -race 检测
1.1 什么是数据竞争
数据竞争(data race)是指:两个及以上的 goroutine 并发访问同一内存地址,其中至少有一个是写操作,且没有任何同步机制保护。数据竞争的行为是未定义的(undefined behavior),可能导致脏读、数据错乱甚至程序崩溃。
package main
import "sync"
func main() {
var counter int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // 数据竞争:读-改-写三步操作非原子
}()
}
wg.Wait()
// counter 大概率小于 1000,且每次运行结果不同
println(counter)
}
counter++ 看似一条语句,实际对应「读取 counter → 加 1 → 写回」三个步骤。多个 goroutine 交叉执行时,会互相覆盖对方的写入,导致丢失更新。
1.2 用 -race 检测
Go 内置了竞争检测器(Race Detector),只需在编译或运行时加 -race 标志:
go run -race main.go # 运行时检测
go test -race ./... # 测试时检测(最常用)
go build -race # 构建带检测的二进制
输出示例:
==================
WARNING: DATA RACE
Read at 0x00c000012028 by goroutine 8:
main.main.func1()
/path/main.go:12 +0x3c
Previous write at 0x00c000012028 by goroutine 7:
main.main.func1()
/path/main.go:12 +0x56
==================
它会精确指出竞争发生的内存地址、涉及的 goroutine 以及读写的代码行。
1.3 -race 原理简述
竞争检测基于 Google 的 ThreadSanitizer(TSan) 算法,采用「happens-before」有向图 + 影子内存(shadow memory):
- 编译器在每一处内存读写前后插桩(instrumentation),调用运行时函数记录访问。
- 运行时为每个内存地址维护「影子状态」,记录最近访问它的 goroutine 和逻辑时钟(向量时钟)。
- 若发现两次访问之间不存在 happens-before 关系(即没有同步原语建立顺序),则报告竞争。
代价说明:
| 维度 | 开销 |
|---|---|
| 内存 | 5~10 倍 |
| CPU | 2~20 倍 |
| 适用 | 仅测试/预发环境,不要用于生产 |
注意:
-race只能检测实际发生的竞争。如果某条竞争路径在本次运行没被触发,检测器就发现不了。因此要配合充分的并发测试用例。
2. sync.Mutex 互斥锁
2.1 基本用法
sync.Mutex 是最基础的互斥锁,保证同一时刻只有一个 goroutine 能进入临界区。
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Inc() {
c.mu.Lock() // 加锁
defer c.mu.Unlock() // defer 确保解锁,即使 panic 也能释放
c.value++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
func main() {
c := &Counter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Inc()
}()
}
wg.Wait()
fmt.Println(c.Value()) // 稳定输出 1000
}
要点:
Mutex的零值即可用,无需初始化。- 通常将
Mutex作为结构体字段,紧挨着它保护的数据。 Mutex不可复制(复制会带上锁状态),传递时应使用指针。go vet会检测复制锁的行为。
2.2 底层 state 字段位含义
sync.Mutex 的结构非常精简:
type Mutex struct {
state int32 // 锁状态,多个标志位复用
sema uint32 // 信号量,用于阻塞/唤醒 goroutine
}
state 这个 32 位整数被拆成多个部分:
31 3 2 1 0
+------------------------------------+---+---+---+
| waiter 等待者数量(29 位) | S | W | L |
+------------------------------------+---+---+---+
L (mutexLocked = 1): 锁是否被持有
W (mutexWoken = 2): 是否有 goroutine 被唤醒
S (mutexStarving = 4): 是否处于饥饿模式
高 29 位: 阻塞在这把锁上的等待者数量
通过位运算,Go 用一个 int32 就表达了「是否上锁、是否有唤醒、是否饥饿、有多少等待者」四类信息,并配合 CAS 原子操作修改,极致省内存。
2.3 正常模式 vs 饥饿模式
Go 1.9 之后 Mutex 引入了两种模式,用来平衡「吞吐量」和「公平性」。
正常模式(Normal):
- 等待者按 FIFO 排队,但新来的 goroutine 会先尝试抢锁(它正在 CPU 上运行,有优势)。
- 被唤醒的等待者要和新来者竞争,往往抢不过 → 可能被反复插队。
- 优点:减少上下文切换,吞吐量高。
饥饿模式(Starvation):
- 锁的所有权直接从解锁者移交给队首等待者。
- 新来的 goroutine 不抢锁,直接排到队尾。
- 保证公平,避免长尾延迟。
两种模式的切换条件:
正常模式 ──> 饥饿模式:当某个等待者等待时间超过 1ms(starvationThresholdNs)
饥饿模式 ──> 正常模式:当队首等待者拿到锁后,发现自己是最后一个等待者,
或它的等待时间小于 1ms
图示:
wait > 1ms
┌─────────┐ ───────────────> ┌─────────┐
│ Normal │ │ Starve │
│ HiThru │ <─────────────── │ HiFair │
└─────────┘ queue empty/<1ms └─────────┘
2.4 自旋(spin)
在正常模式下,当一个 goroutine 尝试加锁但锁被占用时,如果满足条件,它会自旋而非立即阻塞:
- 自旋即空转几十个 CPU 周期,期望锁很快被释放,避免昂贵的 goroutine 挂起/唤醒。
- 自旋条件苛刻:多核 CPU、自旋次数小于 4、当前 P 的本地运行队列为空等。
- 若自旋无果,才通过信号量
sema真正挂起。
自旋是「乐观」策略:赌锁马上就释放。适合临界区极短的场景。
2.5 不可重入
Go 的 Mutex 不可重入(不支持递归加锁)。同一个 goroutine 连续两次 Lock 会导致死锁:
var mu sync.Mutex
func f() {
mu.Lock()
defer mu.Unlock()
g() // 死锁!
}
func g() {
mu.Lock() // 已被 f 持有,且 Mutex 不记录持有者,永久阻塞
defer mu.Unlock()
}
原因:Mutex 不记录「谁持有了锁」,无法判断是否是同一个 goroutine 重入。设计者认为可重入锁会掩盖代码结构问题。正确做法是拆分出不加锁的内部函数:
func f() {
mu.Lock()
defer mu.Unlock()
gLocked() // 调用不加锁版本
}
func gLocked() { /* 假设调用方已持锁 */ }
3. sync.RWMutex 读写锁
3.1 用法
sync.RWMutex 是读写锁,允许多个读并发,但写独占。适合读多写少的场景。
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // 读锁,多个 goroutine 可同时持有
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
func (c *Cache) Set(key, val string) {
c.mu.Lock() // 写锁,独占
defer c.mu.Unlock()
c.data[key] = val
}
四个方法:
| 方法 | 含义 |
|---|---|
RLock / RUnlock |
获取/释放读锁,可并发 |
Lock / Unlock |
获取/释放写锁,独占 |
3.2 并发规则
读锁 写锁
读锁 兼容 互斥
写锁 互斥 互斥
即:读与读兼容,读与写、写与写都互斥。
3.3 写优先,防止写饥饿
RWMutex 内部基于一个 Mutex 加计数器实现:
type RWMutex struct {
w Mutex // 写锁复用 Mutex,保证写者之间互斥
writerSem uint32 // 写者等待读者完成的信号量
readerSem uint32 // 读者等待写者完成的信号量
readerCount atomic.Int32 // 正在读的 reader 数量
readerWait atomic.Int32 // 写者需要等待的 reader 数量
}
关键设计:写者到达时会阻止后续新读者。
- 当写者调用
Lock,它会先拿到内部w这把 Mutex,然后把readerCount减去一个大数(rwmutexMaxReaders),使其变负。 readerCount为负,意味着「有写者在等待」,此后新来的RLock会被阻塞。- 写者只需等待它到达之前已经在读的那些 reader(
readerWait)读完,即可获得写锁。
这样保证了写者不会因为读者源源不断而永久饥饿,是一种「写优先」策略。
3.4 适用场景与注意
- 适用:读操作远多于写操作,且临界区有一定耗时(比如查 map、解析)。
- 不适用:临界区极短时,RWMutex 的额外记账开销可能比普通 Mutex 还慢,直接用 Mutex 更快。
- RWMutex 同样不可重入、不可复制。
- 读锁内不要升级为写锁(会死锁)。
4. sync.WaitGroup
4.1 用法
WaitGroup 用于等待一组 goroutine 全部完成。
func main() {
var wg sync.WaitGroup
urls := []string{"a", "b", "c"}
for _, url := range urls {
wg.Add(1) // 每启动一个 goroutine 前 +1
go func(u string) {
defer wg.Done() // 完成时 -1,等价于 wg.Add(-1)
fetch(u)
}(url)
}
wg.Wait() // 阻塞,直到 counter 归零
fmt.Println("全部完成")
}
func fetch(u string) { /* ... */ }
三个方法:
| 方法 | 作用 |
|---|---|
Add(delta int) |
计数器增加 delta(可负) |
Done() |
计数器减 1 |
Wait() |
阻塞直到计数器为 0 |
Go 1.22 起,
for range每次迭代变量都是新的,for _, url := range urls直接闭包捕获url也安全,无需再传参。上面显式传参是更保险的写法。
4.2 内部原理
WaitGroup 内部用一个 64 位值同时存 counter(高 32 位)和 waiter 数(低 32 位),配合信号量实现:
type WaitGroup struct {
noCopy noCopy // 标记不可复制,go vet 会检查
state atomic.Uint64 // 高 32 位 counter,低 32 位 waiter 数
sema uint32
}
Wait 时若 counter 不为 0,就把 waiter 数加 1 并阻塞在 sema 上;Done 使 counter 归零后,唤醒所有 waiter。
4.3 常见误用
误用 1:Add 放在 goroutine 内部
// 错误:Wait 可能在 Add 执行前就返回
for i := 0; i < 10; i++ {
go func() {
wg.Add(1) // 太晚了!
defer wg.Done()
work()
}()
}
wg.Wait()
Add 必须在 go 语句之前调用,否则 Wait 可能在 goroutine 还没来得及 Add 时就发现 counter 为 0 而提前返回。
误用 2:复制 WaitGroup
func run(wg sync.WaitGroup) { // 错误:值传递复制了内部状态
defer wg.Done()
}
WaitGroup 必须以指针传递。go vet 会报 “passes lock by value”。
误用 3:Add 的负数导致 counter 变负
counter 变为负数会直接 panic:sync: negative WaitGroup counter。通常是 Done 调用次数多于 Add。
误用 4:复用未 Wait 完成的 WaitGroup
一轮 Wait 未返回前又 Add,行为未定义。应等本轮结束后再复用。
4.4 Go 1.25 的 WaitGroup.Go
Go 1.25 新增了 WaitGroup.Go 方法,把「Add + go + Done」三步合一,减少误用:
// Go 1.25+
var wg sync.WaitGroup
for _, u := range urls {
wg.Go(func() { // 内部自动 Add(1),函数返回时自动 Done
fetch(u)
})
}
wg.Wait()
它等价于:
wg.Add(1)
go func() {
defer wg.Done()
f()
}()
写法更简洁,也从根本上避免了 Add 位置错误和忘记 Done 的问题。
5. sync.Once
5.0 从零推导:单例模式的五次演进
为什么单例最终要用 sync.Once?把演进过程走一遍就明白了——这是一道经典面试题。
饿汉式(程序启动即创建,天然并发安全):
var instance = new(singleton) // 全局变量初始化
func GetInstance() *singleton { return instance }
// 或用 init():func init() { instance = new(singleton) }
简单安全,但不管用不用都会创建,无法延迟。于是有了懒汉式(用到才创建),但要小心并发:
懒汉 v1——裸判断,❌ 不安全:
func GetInstance() *singleton {
if instance == nil { // 高并发下多个 goroutine 同时判 nil
instance = new(singleton) // → 创建出多个实例
}
return instance
}
懒汉 v2——整个方法加锁,安全但慢:
func GetInstance() *singleton {
lock.Lock(); defer lock.Unlock()
if instance == nil { instance = new(singleton) }
return instance
}
// 每次获取都要加解锁,实例创建后仍在抢锁,性能被拖垮
懒汉 v3——锁移进 if 内,❌ 又不安全:
func GetInstance() *singleton {
if instance == nil { // 代码1
lock.Lock() // 代码2
instance = new(singleton) // 执行完代码1、进代码2前,别的 goroutine 可能已创建
lock.Unlock()
}
return instance
}
懒汉 v4——双重检查锁(DCL),✅ 安全又高效:
func GetInstance() *singleton {
if instance == nil { // 第一次检查:无锁,快
lock.Lock()
if instance == nil { // 第二次检查:拿锁后再确认
instance = new(singleton)
}
lock.Unlock()
}
return instance
}
懒汉 v5——sync.Once,✅ 最终形态:
func GetInstance() *singleton {
once.Do(func() { instance = new(singleton) })
return instance
}
sync.Once 把 v4 的「double-check + 锁 + 原子标志」全部封装好,代码更短、语义更清晰。它内部正是 v4 的实现(见 5.2)。结论:单例的正确写法就是 v4(DCL)或 v5(Once),生产中优先 Once。
5.1 单例场景
sync.Once 保证某段逻辑在整个程序生命周期内只执行一次,最常用于单例初始化。
type DB struct{ /* ... */ }
var (
instance *DB
once sync.Once
)
func GetDB() *DB {
once.Do(func() {
instance = &DB{} // 无论多少 goroutine 并发调用,只初始化一次
fmt.Println("init db")
})
return instance
}
无论多少个 goroutine 同时调用 GetDB,Do 里的函数只会执行一次,且其他 goroutine 会阻塞等待这一次执行完成后才返回,保证拿到已初始化的实例。
5.2 Do 的原理:double-check + atomic + mutex
type Once struct {
done atomic.Uint32 // 0 未执行,1 已执行
m Mutex
}
func (o *Once) Do(f func()) {
// 快路径:原子读,绝大多数调用走这里,无锁开销
if o.done.Load() == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
// 双重检查:拿到锁后再判断一次
if o.done.Load() == 0 {
defer o.done.Store(1) // 保证 f 执行完才置 1
f()
}
}
设计精妙之处:
- 快路径用 atomic.Load:已初始化后,后续调用只做一次原子读,几乎零开销。
- 慢路径用 Mutex + double-check:只有第一次并发调用才会走加锁路径,锁内再判断一次防止重复执行。
defer o.done.Store(1):必须等f()真正执行完才把标志置 1。如果先置 1 再执行,别的 goroutine 会在快路径直接返回,却拿到还没初始化好的实例。
为什么不用
done bool+ 直接判断?因为无锁读一个普通 bool 而写用锁保护,会构成数据竞争。必须用 atomic 保证可见性。
5.3 Go 1.21 的 OnceFunc / OnceValue
Go 1.21 新增三个便捷函数:
// OnceFunc:返回一个只会执行 f 一次的函数
init := sync.OnceFunc(func() { fmt.Println("init") })
init(); init() // 只打印一次
// OnceValue:惰性求值,只计算一次并缓存结果
getConfig := sync.OnceValue(func() Config {
return loadConfig()
})
c := getConfig() // 首次调用才 loadConfig,后续返回缓存
// OnceValues:支持返回两个值
getData := sync.OnceValues(func() ([]byte, error) {
return os.ReadFile("data.json")
})
如果 f panic,后续调用会重新 panic(记录了 panic 信息),不会再执行 f。
6. sync.Cond 条件变量
6.1 概念
sync.Cond 是条件变量,用于让 goroutine 等待或宣告某个「条件」的发生。适合「一个/多个 goroutine 等待某状态,另一个 goroutine 改变状态并通知」的场景。
三个核心方法:
| 方法 | 作用 |
|---|---|
Wait() |
释放锁并阻塞,被唤醒后重新加锁 |
Signal() |
唤醒一个等待者 |
Broadcast() |
唤醒所有等待者 |
6.2 使用示例
package main
import (
"fmt"
"sync"
"time"
)
func main() {
c := sync.NewCond(&sync.Mutex{})
ready := false
// 三个消费者等待条件就绪
for i := 0; i < 3; i++ {
go func(id int) {
c.L.Lock()
for !ready { // 必须用 for 循环判断条件,不能用 if
c.Wait() // 释放锁并阻塞;被唤醒后重新持锁
}
c.L.Unlock()
fmt.Printf("worker %d 开始工作\n", id)
}(i)
}
time.Sleep(time.Second)
c.L.Lock()
ready = true // 改变条件
c.L.Unlock()
c.Broadcast() // 唤醒所有等待者
time.Sleep(time.Second)
}
6.3 关键注意点
- Wait 前必须持有锁:
Wait内部会先解锁、阻塞,被唤醒后再重新加锁。 - 条件判断必须用
for而非if:存在「虚假唤醒」以及被唤醒后条件又被其他 goroutine 改变的可能,被唤醒后要重新检查条件。 Signal/Broadcast前不一定要持锁,但通常在锁内改变条件、锁外或锁内发信号都可以。- 现实中 Cond 用得较少,很多场景可以用 channel 优雅替代。但生产者-消费者、连接池「等待有可用连接」这类多等待者广播场景,Cond 仍然合适。
7. sync.Pool 对象复用
7.1 用途
sync.Pool 是一个临时对象池,用来复用那些频繁创建又频繁丢弃的对象,从而减轻 GC 压力。
var bufPool = sync.Pool{
New: func() any { // 池空时的构造函数
return new(bytes.Buffer)
},
}
func handle(data []byte) {
buf := bufPool.Get().(*bytes.Buffer) // 取出,可能是复用的也可能是 New 的
defer func() {
buf.Reset() // 关键:归还前必须重置状态
bufPool.Put(buf) // 放回池中
}()
buf.Write(data)
// ... 使用 buf ...
}
7.2 GC 时清理
sync.Pool 里的对象随时可能被 GC 回收,这是它与普通对象池最大的区别:
- 每次 GC 会清理 Pool 中的对象(配合 victim cache,见下)。
- 因此 Pool 只适合存放「无状态、可重建」的临时对象,绝不能用它做需要长期存活的连接池(比如数据库连接)。
7.3 P 本地池 + victim cache 原理
sync.Pool 为了减少锁竞争,采用了每 P 一个本地池的设计(P 是 GMP 模型里的处理器):
Pool
┌───────┬───────┬───────┐
│ P0 │ P1 │ P2 │ 每个 P 有独立的 poolLocal
├───────┼───────┼───────┤
│private│private│private│ 私有对象,仅本 P 可无锁访问
│shared │shared │shared │ 共享队列,可被其他 P "偷取"
└───────┴───────┴───────┘
- private:每个 P 私有一个对象,Get/Put 优先操作它,无需加锁(因为同一时刻一个 P 只运行一个 goroutine)。
- shared:一个双端队列,本 P 从头部存取,其他 P 空闲时可从尾部「窃取」(work-stealing),保证负载均衡。
- victim cache(Go 1.13 引入):GC 时不直接清空 Pool,而是先把当前对象移到 victim(受害者)区,下一轮 GC 才真正丢弃。这样对象能多存活一个 GC 周期,避免 GC 后缓存瞬间清零导致的性能抖动(缓存命中率骤降)。
Get 的查找顺序:本 P private → 本 P shared → 窃取其他 P 的 shared → victim cache → 调用 New。
7.4 标准库中的应用
fmt包:打印时用 Pool 复用pp(printer)结构体。encoding/json:复用编码缓冲区。net/http:复用请求处理相关的临时对象。
7.5 注意事项
- Get 后必须判断/重置:复用的对象带有上次的数据,务必
Reset或重新赋值。 - 不要 Put 大对象或数量不定的对象:可能白白占内存又频繁被 GC。
- 不保证 Get 一定复用:高并发下也可能每次都 New,Pool 只是「尽力复用」。
- Pool 本身不可复制。
一个简单 benchmark 对比:
func BenchmarkWithPool(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
buf.WriteString("hello world")
bufPool.Put(buf)
}
})
}
func BenchmarkNoPool(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
buf := new(bytes.Buffer)
buf.WriteString("hello world")
_ = buf
}
})
}
// 典型结果:WithPool 分配次数近乎为 0,NoPool 每次都分配,
// 在高并发大对象场景下 Pool 版本 GC 停顿显著更低。
8. sync.Map
8.1 为什么需要它
普通 map 并发读写会 panic(fatal error: concurrent map read and map write),最简单的方案是 Mutex + map。但在读多写少、或 key 集合稳定的场景下,全局锁竞争激烈。sync.Map 用无锁读优化了这类场景。
8.2 用法
var m sync.Map
m.Store("a", 1) // 写入
v, ok := m.Load("a") // 读取
m.LoadOrStore("b", 2) // 不存在则存入,返回实际值和是否已存在
m.LoadAndDelete("a") // 读取并删除
m.Delete("a") // 删除
m.Range(func(k, v any) bool { // 遍历,返回 false 停止
fmt.Println(k, v)
return true
})
注意 sync.Map 的 key/value 都是 any,没有泛型,使用时需类型断言。
8.3 read/dirty 双 map + amended 原理
sync.Map 内部维护两个 map:
type Map struct {
mu Mutex
read atomic.Pointer[readOnly] // 只读 map,无锁访问
dirty map[any]*entry // 脏 map,含最新写入,访问需加锁
misses int // read 未命中次数
}
type readOnly struct {
m map[any]*entry
amended bool // true 表示 dirty 中有 read 里没有的 key
}
核心思想:
┌─────────────┐ ┌─────────────┐
│ read map │ │ dirty map │
│(atomic read)│ │(mutex guard)│
│ lock-free │ │ new keys │
└─────────────┘ └─────────────┘
│ │
Load 先查 read ──miss──> 加锁查 dirty
- 读:先无锁访问
read。若命中,直接返回,完全无锁。这是 sync.Map 快的核心。 - read 未命中且
amended=true:说明可能在 dirty 里,加锁去 dirty 查,并累加misses。 - misses 达到 dirty 长度:把 dirty 提升为新的 read(dirty 置空,
amended归 false),后续读又回到无锁快路径。 - 删除:用 tombstone 标记(
entry置为特殊的expunged/nil),而非立刻从 map 删除,避免频繁重建。
entry 里存的是指针,通过原子操作 CAS 更新值,使得「更新已存在 key 的值」也能无锁完成。
写放大的成因:dirty 提升为 read 后
amended=false,此时若新插入一个 read 中没有的 key,会先触发一次「把整个 read 完整复制回新的 dirty」再插入。也就是说,每次「新增 key」都可能引发一次全量拷贝。这正是 sync.Map 在写多、尤其频繁插入新 key 场景下性能差的根本原因——它的设计假设是 key 集合基本稳定。
8.4 适用场景与对比
| 场景 | 推荐 |
|---|---|
| 读多写少,key 相对稳定 | sync.Map |
| 读写均衡 / 写多 | Mutex + map |
| 需要频繁新增大量新 key | Mutex + map(sync.Map 写会不断重建 dirty) |
| 需要泛型、明确类型 | Mutex + map[K]V |
benchmark 直觉:
// 读多写少(如 90% 读)场景,sync.Map 因无锁读吞吐远高于 Mutex+map;
// 写密集场景,sync.Map 反而更慢(dirty 提升、entry 拷贝开销大)。
结论:不要默认用 sync.Map,它是针对特定读多写少场景的优化,用错场景反而更慢。
9. atomic 原子操作
9.1 基本操作
sync/atomic 提供对整数和指针的原子操作,底层由 CPU 指令(如 x86 的 LOCK 前缀、ARM 的 LL/SC)保证,比锁更轻量。
import "sync/atomic"
var counter int64
atomic.AddInt64(&counter, 1) // 原子加
v := atomic.LoadInt64(&counter) // 原子读
atomic.StoreInt64(&counter, 100) // 原子写
old := atomic.SwapInt64(&counter, 200) // 原子交换,返回旧值
ok := atomic.CompareAndSwapInt64(&counter, 200, 300) // CAS
用原子操作重写第 1 节的计数器,无竞争:
func main() {
var counter int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1) // 原子操作,安全
}()
}
wg.Wait()
fmt.Println(atomic.LoadInt64(&counter)) // 稳定 1000
}
9.2 Go 1.19 新类型
Go 1.19 引入了原子类型,把「变量 + 原子操作」封装成一个类型,更安全、更易读,避免忘记用原子函数访问:
var counter atomic.Int64
counter.Add(1)
counter.Load()
counter.Store(100)
counter.CompareAndSwap(100, 200)
var flag atomic.Bool
flag.Store(true)
if flag.Load() { /* ... */ }
var p atomic.Pointer[Config] // 原子指针,无需 unsafe.Pointer
p.Store(&Config{})
cfg := p.Load()
var v atomic.Value // 可存任意类型(但每次 Store 的动态类型必须一致)
v.Store("hello")
这些类型自带 noCopy,go vet 能检测误复制。推荐新代码优先使用这些类型而非裸函数。
9.3 无锁编程与 CAS 自旋
CAS(Compare-And-Swap)是无锁编程的基石:「如果当前值等于期望值,就更新为新值,返回是否成功」。它是原子的。
用 CAS 自旋实现无锁自增:
func atomicInc(addr *int64) {
for {
old := atomic.LoadInt64(addr)
newVal := old + 1
if atomic.CompareAndSwapInt64(addr, old, newVal) {
return // 成功
}
// 失败说明期间被别人改了,重试(自旋)
}
}
无锁配置热更新的经典模式:
var config atomic.Pointer[Config]
func Reload(newCfg *Config) {
config.Store(newCfg) // 写者原子替换整个指针
}
func Current() *Config {
return config.Load() // 读者无锁读,读到的要么是旧的完整配置,要么是新的
}
读者永远不会读到「一半新一半旧」的配置,因为交换的是整个指针。
9.4 ABA 问题
CAS 有一个经典陷阱:ABA 问题。值从 A 变成 B 又变回 A,CAS 检查「还是 A」就认为没变过,但其实已经历了变化。
线程1 读到 A,准备 CAS(A -> C)
线程2 把 A -> B
线程2 又把 B -> A(比如对象被回收又复用了同一地址)
线程1 CAS(A -> C) 成功,但中间发生的变化被忽略了
在 Go 里做无锁栈/队列这类涉及指针复用的结构时要小心。常见解法:
- 加版本号/标记位(tagged pointer),把值和一个递增计数器一起 CAS,A 到 A 但版本号不同,CAS 就会失败。
- 用 Go 的 GC 特性避免地址被立即复用(持有引用则对象不会被回收),很多场景下天然规避了 ABA。
对大多数「计数器、标志位、指针替换」的日常场景,ABA 不构成问题;只有实现精细的无锁数据结构时才需要重点关注。
10. Go 内存模型简介
10.1 为什么需要同步
现代 CPU 和编译器会对指令重排序,每个核心还有自己的缓存。这意味着一个 goroutine 的写入,不保证立即对另一个 goroutine 可见,且执行顺序可能与代码顺序不同。
var a, done = 0, false
func setup() {
a = 42 // 写 1
done = true // 写 2
}
func main() {
go setup()
for !done { // 可能永远读到 false(编译器缓存 done),或读到 done=true 但 a 还是 0
}
println(a) // 不保证是 42
}
上面的代码有数据竞争,a 的值和 done 的可见性都没有保证。这不是 bug,而是内存模型允许的行为。
10.2 happens-before
Go 内存模型用 happens-before 关系定义可见性:如果事件 A happens-before 事件 B,那么 A 的内存效果对 B 可见。
建立 happens-before 的手段(同步原语的意义正在于此):
| 同步方式 | 建立的顺序 |
|---|---|
| channel 发送 | 发送 happens-before 对应接收完成 |
| channel 接收 | 无缓冲 channel 的接收 happens-before 发送完成 |
| Mutex | Unlock happens-before 后续的 Lock |
| WaitGroup | Done happens-before Wait 返回 |
| Once | Do 中 f 的执行 happens-before 任何 Do 返回 |
| atomic | 一个原子写 happens-before 观察到该写的原子读 |
| go 语句 | go f() happens-before f 开始执行 |
10.3 修正示例
用 atomic 建立 happens-before:
var a int
var done atomic.Bool
func setup() {
a = 42 // 普通写
done.Store(true) // 原子写:作为同步点
}
func main() {
go setup()
for !done.Load() { // 原子读:一旦读到 true,
}
println(a) // 保证是 42:Store(true) 之前的写对 Load 到 true 之后可见
}
核心结论:同步原语不仅仅是「互斥」,更重要的是建立 happens-before 关系,保证内存写入的可见性和顺序。这就是为什么必须用同步原语,而不能靠「感觉应该没问题」。
11. 优化与最佳实践
11.1 锁粒度
- 减小临界区:只在真正需要保护共享数据时持锁,把耗时的 I/O、计算移出临界区。
- 分片锁(sharding):把一个大 map 拆成 N 个带独立锁的小 map,用
hash(key) % N定位分片,大幅降低锁竞争。
type ShardedMap struct {
shards [256]struct {
mu sync.RWMutex
m map[string]int
}
}
func (s *ShardedMap) shard(key string) *struct {
mu sync.RWMutex
m map[string]int
} {
h := fnv32(key)
return &s.shards[h%256]
}
11.2 读写锁选择
- 读远多于写、临界区不是极短 → RWMutex。
- 临界区极短(几个字段读写)→ 普通 Mutex 往往更快,因为 RWMutex 记账开销更大。
- 只是一个整数/指针 → 直接用 atomic,无锁最快。
11.3 channel 还是 mutex
| 需求 | 推荐 |
|---|---|
| 传递数据所有权、任务分发、流水线 | channel |
| 保护共享状态(缓存、计数器、状态机字段) | Mutex / atomic |
| 一次性通知、扇出 | channel(close) |
经验法则:channel 用于「传递」,mutex 用于「保护」。别为了保护一个字段的读写去套 channel,那样又慢又绕。
11.4 atomic vs mutex 性能
- 单个基础类型的读写/累加:atomic 通常比 Mutex 快数倍(无需挂起 goroutine)。
- 需要保护「多个字段的一致性」或复杂逻辑:必须用 Mutex,atomic 做不到多变量的原子组合。
- 高并发下 atomic 也会因缓存行争用(false sharing)而变慢,可用缓存行填充(padding)缓解。
11.5 用 sync.Pool 减少 GC
高频短生命周期对象(buffer、临时切片、请求上下文)用 Pool 复用,能显著降低 GC 频率和停顿。但记得:归还前重置、不放大对象、不做长连接池。
12. 高频面试题
Q1:Mutex 的正常模式和饥饿模式有什么区别?为什么要设计两种?
正常模式下等待者 FIFO 排队,但新来的 goroutine 可以直接抢锁(因为它在 CPU 上,抢锁成本低),吞吐量高但可能造成队列中的等待者长期抢不到锁。当某等待者等待超过 1ms,切换到饥饿模式:锁直接移交给队首等待者,新来者不抢锁排队尾,保证公平。当队列清空或等待时间小于 1ms 时切回正常模式。这是吞吐量与公平性的权衡。
Q2:RWMutex 的原理?如何防止写饥饿?
RWMutex 内部用一个 Mutex 保证写者互斥,用 readerCount 记录读者数。写者到来时把 readerCount 减去一个大数使其变负,从而阻止后续新读者进入,只等待此前已经在读的读者读完,即可获得写锁。这样即使读者源源不断,写者也不会饿死,是「写优先」策略。
Q3:WaitGroup 有哪些常见误用?
(1)Add 放在 goroutine 内部,导致 Wait 提前返回;Add 必须在 go 之前。(2)值传递复制 WaitGroup。(3)Done 多于 Add 使 counter 变负而 panic。(4)在上一轮 Wait 未返回时又复用。Go 1.25 的 wg.Go(f) 可规避前两类问题。
Q4:sync.Once 如何保证只执行一次?为什么用 double-check?
内部有原子标志 done 和一个 Mutex。Do 先原子读 done,为 1 则直接返回(无锁快路径);为 0 则加锁,在锁内再判断一次(double-check),确实未执行才运行 f 并在其返回后(defer)把 done 置 1。快路径用 atomic 避免每次加锁,慢路径用锁保证互斥,double-check 防止并发下重复执行。特别注意标志必须在 f 执行完后才置位,否则别的 goroutine 会拿到未初始化的实例。
Q5:sync.Map 的原理和适用场景?
内部有 read(原子指针,无锁读)和 dirty(Mutex 保护,含最新写入)两个 map,加 amended 标志表示 dirty 是否有 read 没有的 key。读优先无锁访问 read,命中直接返回;未命中且 amended 为真时加锁查 dirty 并计数 misses,misses 累积到阈值就把 dirty 提升为 read。适用于读多写少或 key 稳定的场景;读写均衡或写密集时不如 Mutex + map。
Q6:atomic 和 mutex 的区别,什么时候用哪个?
atomic 是基于 CPU 指令的无锁操作,适合单个基础类型(整数、指针、bool)的原子读写,性能高。mutex 适合保护一段临界区或多个变量的一致性。原则:能用 atomic 表达的单变量操作优先 atomic;涉及多个变量组合或复杂逻辑,必须用 mutex。
Q7:什么是 CAS?有什么问题?
CAS(比较并交换):当内存值等于期望值时才更新为新值,是原子操作,无锁编程的基石。常配合自旋重试实现无锁累加/更新。问题是 ABA:值 A→B→A 后 CAS 仍认为未变。解法是加版本号(tagged pointer)或依赖 GC 防止地址被立即复用。
Q8:如何检测数据竞争?原理是什么?
用 go test -race / go run -race。编译器在内存读写处插桩,运行时用向量时钟(基于 happens-before)跟踪每个内存地址的访问历史,发现两次访问间不存在 happens-before 关系(无同步)就报告竞争。代价是内存 5~10 倍、CPU 2~20 倍,仅用于测试环境,且只能检测本次运行实际触发的竞争。
Q9:Mutex 可重入吗?为什么?
不可重入。Mutex 不记录持有者,同一 goroutine 二次 Lock 会死锁。设计上认为可重入锁会掩盖代码结构问题,应通过拆分「加锁版本」和「不加锁的内部函数」来解决嵌套调用。
Q10:sync.Pool 里的对象会一直存在吗?victim cache 是什么?
不会。每次 GC 都会清理 Pool。为避免 GC 后缓存瞬间清零导致的性能抖动,Go 1.13 引入 victim cache:GC 时先把对象移到 victim 区,下一轮 GC 才真正回收,让对象多存活一个 GC 周期。因此 Pool 只适合可重建的临时对象,不能做长连接池。
小结
本章围绕 Go 的共享内存同步机制,梳理了从检测到原语再到内存模型的完整链路:
- 数据竞争与 -race:并发访问同一地址且至少一写、无同步即为竞争;
-race基于 happens-before + 插桩检测,仅用于测试。 - Mutex:state 位复用表达锁状态,正常/饥饿双模式平衡吞吐与公平,自旋优化短临界区,不可重入、不可复制。
- RWMutex:读并发写独占,通过让写者阻止新读者实现写优先,防止写饥饿;临界区极短时不如 Mutex。
- WaitGroup:Add 必须在 go 之前,不可复制,Go 1.25 的
Go方法更安全。 - Once:atomic 快路径 + Mutex + double-check,标志在 f 执行后才置位。
- Cond:条件变量,Wait 前持锁、条件用 for 判断,适合多等待者广播。
- Pool:每 P 本地池 + work-stealing + victim cache,复用临时对象减 GC,随时可能被回收。
- sync.Map:read/dirty 双 map + amended,读无锁,适合读多写少。
- atomic:CPU 指令级无锁操作,Go 1.19 原子类型更安全,CAS 自旋要防 ABA。
- 内存模型:同步原语的本质是建立 happens-before,保证可见性与顺序,而不只是互斥。
选型口诀:能 atomic 不 mutex,能 channel 传递不用锁保护,读多写少考虑 RWMutex 或 sync.Map,高频临时对象用 Pool,一切并发代码都要过一遍 -race。
xingliuhua