目录

Go-16 sync 并发原语与原子操作

Go 的并发哲学是「不要通过共享内存来通信,而要通过通信来共享内存」。channel 是首选,但当我们确实需要共享状态时,syncsync/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 同时调用 GetDBDo 里的函数只会执行一次,且其他 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")

这些类型自带 noCopygo 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