Go 的 sync 包是并发编程的核心基础,提供了原子操作、互斥锁、条件变量、等待组等关键同步原语。理解这些原语的底层实现和适用场景,是编写高效并发代码的前提。
一、Mutex:互斥锁
1.1 基本用法
var ( mu sync.Mutex value int)
func update(newVal int) { mu.Lock() defer mu.Unlock() value = newVal}
func read() int { mu.Lock() defer mu.Unlock() return value}1.2 底层实现
// sync.Mutex 源码简化// 源码参考:https://github.com/golang/go/blob/go1.25.0/src/sync/mutex.gotype Mutex struct { state int32 // 32 位状态 sema uint32 // 信号量}
// 状态位const ( mutexLocked = 1 << iota // 1: 锁被持有 mutexWoken // 2: 有等待者被唤醒 mutexStarving // 4: 饥饿模式 mutexWaiterShift = iota // 等待者数量偏移)1.3 饥饿模式
Go 1.9 引入饥饿模式来解决锁竞争问题:
// 正常模式(默认):等待者 FIFO,先来先服务// 饥饿模式:锁直接交给等待队列尾部//// 切换条件:// - 等待超过 1ms 切换到饥饿模式// - 等待队列空了切换回正常模式Mutex 状态机:
Mutex Lock/Unlock 流程:
1.4 使用注意
// 常见错误:在锁外部访问共享数据mu.Lock()value = 100 // 这里有锁fmt.Println(value)mu.Unlock()
value = 200 // 这里没有锁!危险!
// 另一个常见错误:读也在锁外,同样是数据竞争mu.Lock()value = 100mu.Unlock()fmt.Println(value) // 锁外读取也有竞争!另一个 goroutine 可能正在写入
// 正确做法:所有对共享变量的读写都必须在锁内mu.Lock()value = 100v := value // 在锁内拷贝mu.Unlock()fmt.Println(v) // 使用局部副本,安全1.5 Mutex 不能复制
sync.Mutex、sync.RWMutex、sync.WaitGroup、sync.Once 内部都嵌入了 noCopy 字段,这是一个占位类型,本身不占内存,唯一作用是让 go vet 能检测出这些类型的值复制:
type Mutex struct { state int32 sema uint32 // noCopy noCopy // 隐藏在源码里,go vet 据此报警}复制锁会把锁的状态字节原样拷贝一份,新旧两个锁共享同一个信号量却各自有独立的 state,互斥语义彻底失效。典型场景是把含锁的结构体放进切片、作为值接收者传参、或用 *sync.Mutex 的值而非指针嵌入到结构体。go vet 在编译期就能发现这类错误:
$ go vet ./...example.go:12:7: assignment copies lock value to z: sync.Mutex规避方法是把锁声明为指针(mu *sync.Mutex),或者让持有锁的结构体只通过指针传递,永远不做值拷贝。
二、RWMutex:读写锁
2.1 基本用法
var ( mu sync.RWMutex data map[string]string)
func read(key string) string { mu.RLock() // 读锁 defer mu.RUnlock() return data[key]}
func write(key, value string) { mu.Lock() // 写锁 defer mu.Unlock() data[key] = value}2.2 适用场景
// 读多写少场景:RWMutex 优于 Mutex// 读写锁:多个读者或单一写者// 互斥锁:多个写者或单一写者RWMutex 读写模式:
RWMutex 状态转换:
2.3 底层实现
// 源码参考:https://github.com/golang/go/blob/go1.25.0/src/sync/rwmutex.gotype RWMutex struct { w Mutex // 写锁入口 writerSem uint32 // 写者信号量 readerSem uint32 // 读者信号量 readerCount atomic.Int32 // 当前读者数 + 等待写者标记 readerWait atomic.Int32 // 写者需要等待的读者数}关键字段含义:
readerCount:正常值为当前活跃读者数。但写锁获取时会将readerCount减去rwmutexMaxReaders(= 2^30),这使得readerCount变为负数,同时作为一个”写锁已获取”的标记。新读者看到负数就知道有写锁,不会增加计数,而是等待。readerWait:写锁获取时记录当前还有多少读者没退出。当最后一个读者RUnlock将readerWait减到 0 时,唤醒写者。
写锁获取的核心逻辑:
func (rw *RWMutex) Lock() { // 先获取 w 锁,防止多个写者竞争 rw.w.Lock() // 标记"写锁已获取",阻止新读者进入 r := rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders if r != 0 { // 还有读者没退出,等待 rw.readerWait.Store(r) runtime_Semacquire(&rw.writerSem) }}读锁释放时的唤醒逻辑:
func (rw *RWMutex) RUnlock() { r := rw.readerCount.Add(-1) if r < 0 { // readerCount 为负数,说明有写者在等 if rw.readerWait.Add(-1) == 0 { // 最后一个等待的读者退出了,唤醒写者 runtime_Semrelease(&rw.writerSem, false, 0) } }}这个设计的精妙之处:readerCount 同时承担了两个职责(活跃读者计数 + 写锁标记),通过加减 rwmutexMaxReaders 在正负数之间切换,用一个原子变量实现了两种语义的统一。
三、WaitGroup:等待组
3.1 基本用法
var wg sync.WaitGroup
func main() { for i := 0; i < 3; i++ { wg.Add(1) // 计数 +1 go func(id int) { defer wg.Done() // 计数 -1 process(id) }(i) }
wg.Wait() // 阻塞直到计数归零 fmt.Println("All done")}3.2 常见模式
// 模式 1:启动 workersfunc processAll(items []Item) error { var wg sync.WaitGroup errCh := make(chan error, len(items))
for _, item := range items { wg.Add(1) go func(item Item) { defer wg.Done() if err := process(item); err != nil { errCh <- err } }(item) }
wg.Wait() close(errCh)
var errs []error for err := range errCh { errs = append(errs, err) } return errors.Join(errs...)}// 模式 2:扇出func fanOut(ctx context.Context, tasks []Task) error { var wg sync.WaitGroup for _, task := range tasks { wg.Add(1) go func(t Task) { defer wg.Done() execute(ctx, t) }(task) } wg.Wait() return nil}3.3 底层实现
WaitGroup 内部只有一个 state 字段(atomic.Uint64),高位 32 位存储计数器(counter),低位 32 位存储等待者数量(waiter):
type WaitGroup struct { noCopy noCopy // go vet 据此禁止值复制,原因见 1.5 节 state atomic.Uint64 // 高 32 位: counter, 低 32 位: waiter sema uint32}Add(delta) 操作用原子加更新 counter 部分:
func (wg *WaitGroup) Add(delta int) { state := wg.state.Add(uint64(delta) << 32) v := int32(state >> 32) // counter w := uint32(state) // waiter
if v < 0 { panic("sync: WaitGroup is reused before previous Wait has returned") } // counter 归零且有等待者,释放信号量 if v == 0 && w > 0 { wg.state.Store(0) for ; w != 0; w-- { runtime_Semrelease(&wg.sema, false, 0) } }}Wait() 将 waiter 加 1,然后阻塞在信号量上:
func (wg *WaitGroup) Wait() { for { state := wg.state.Load() v := int32(state >> 32) if v == 0 { return // counter 已经是 0,无需等待 } // 尝试增加 waiter 计数 if wg.state.CompareAndSwap(state, state+1) { runtime_Semacquire(&wg.sema) return } }}高地位复用的好处:counter 和 waiter 的更新只需要一次原子操作,避免了用两个原子变量时的竞态问题。如果 counter 和 waiter 是分开的字段,在 Add(-1) 将 counter 减为 0 时,waiter 可能还没来得及加 1,导致 Wait() 永远阻塞。把它们合在一个 64 位原子变量中,Add 和 Wait 的竞争被原子操作天然解决。
3.4 注意事项
// 常见错误:Add 和 Done 不配对func wrong() { wg.Add(1) // +1 go func() { defer wg.Done() // -1 // ... }() // 如果 goroutine panic,Done 不会执行 wg.Wait() // 永远等待!}// 正确做法:使用 deferfunc correct() { wg.Add(1) go func() { defer wg.Done() // 确保执行 // ... }() wg.Wait()}WaitGroup 计数器变化:
WaitGroup 与 Channel 模式对比:
四、Once:单次执行
4.1 基本用法
var ( once sync.Once config *Config)
func getConfig() *Config { once.Do(func() { config = loadConfig() // 只执行一次 }) return config}4.2 实现原理
// 源码参考:https://github.com/golang/go/blob/go1.25.0/src/sync/once.gotype Once struct { done atomic.Uint32 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 { // 双重检查 f() o.done.Store(1) }}4.3 实际应用
// 应用 1:单例模式type DB struct { conn *sql.DB}
var ( db *DB dbOnce sync.Once)
func GetDB() *DB { dbOnce.Do(func() { db = &DB{conn: connect()} }) return db}// 应用 2:初始化type Service struct { once sync.Once client *http.Client}
func (s *Service) init() { s.once.Do(func() { s.client = &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ MaxIdleConns: 100, }, } })}五、Cond:条件变量
5.1 基本用法
var ( cond = sync.NewCond(&sync.Mutex{}) ready bool)
func waitForReady() { cond.L.Lock() for !ready { cond.Wait() // 阻塞并释放锁 } cond.L.Unlock() fmt.Println("Ready!")}
func setReady() { cond.L.Lock() ready = true cond.L.Unlock() cond.Signal() // 唤醒一个等待者 // 或 cond.Broadcast() 唤醒所有}5.2 Wait 的原理
// cond.Wait() 内部操作:// 1. 释放锁// 2. 阻塞等待信号// 3. 重新获取锁
// 因此必须在循环中使用:for !condition { cond.Wait() // 自动释放和重新获取锁}Cond Wait/Signal 流程:
Signal vs Broadcast:
5.3 实际应用
// 应用:实现简单的信号量type Semaphore struct { cond *sync.Cond count int max int}
func NewSemaphore(max int) *Semaphore { return &Semaphore{ cond: sync.NewCond(&sync.Mutex{}), max: max, }}
func (s *Semaphore) Acquire() { s.cond.L.Lock() for s.count >= s.max { s.cond.Wait() } s.count++ s.cond.L.Unlock()}
func (s *Semaphore) Release() { s.cond.L.Lock() s.count-- s.cond.Signal() // 唤醒一个等待者 s.cond.L.Unlock()}六、Pool:对象池
6.1 基本用法
var bufferPool = sync.Pool{ New: func() any { return &bytes.Buffer{} },}
func getBuffer() *bytes.Buffer { buf := bufferPool.Get().(*bytes.Buffer) buf.Reset() // 重置状态 return buf}
func putBuffer(buf *bytes.Buffer) { bufferPool.Put(buf)}6.2 使用场景
sync.Pool 的设计目标是减少 GC 压力,而不是做连接池。它适合高频分配/释放的轻量级临时对象:
- bytes.Buffer 重用:HTTP handler 中频繁创建的 Buffer,用 Pool 复用可避免反复分配
- 临时解析器:JSON/XML 解析中间态对象,用完即还
- 连接缓冲:单次请求内使用的 I/O 缓冲区
不适合需要保持状态的对象:数据库连接(用 sql.DB 内置池)、需要长期持有的缓存对象。因为 Pool 中的对象可能在任意 GC 周期被清除,Get() 返回的对象也可能被其他 goroutine 拿走。
6.3 注意事项
Pool 中的对象在每次 GC 时可能被清除(先转移到 victim cache,下次 GC 再清空 victim)。这意味着:
- 不要依赖 Pool 中对象的长期存在,GC 触发后对象就没了
- 不要用 Pool 做连接池,连接丢失会导致服务异常
- Get() 后必须 Reset,拿到的对象可能携带上次使用的脏数据
// 错误:用 Pool 缓存数据库连接var connPool = sync.Pool{ New: func() any { return createExpensiveConnection() },}// GC 时连接被清除,下次 Get() 创建新连接,连接池形同虚设
// 正确:用专门的连接管理(sql.DB、grpc 连接池等)sync.Pool 生命周期:
Pool Get/Put 详细流程:
七、sync.Map
7.1 基本用法
var m sync.Map
// 存储m.Store("key", "value")
// 读取value, ok := m.Load("key")
// 删除m.Delete("key")
// 遍历m.Range(func(key, value any) bool { fmt.Println(key, value) return true // 返回 false 停止遍历})
// 加载或存储value, loaded := m.LoadOrStore("key", "newValue")// 如果 key 存在,loaded=true,value 是旧值// 如果 key 不存在,loaded=false,value 是新值7.2 适用场景
sync.Map 内部采用读写分离设计:读操作走只读 map(无锁),写操作走 dirty map(加锁),dirty 提升为 read 时需要全量复制。这决定了它的适用场景:
7.2 内部结构
type Map struct { mu Mutex read atomic.Value // 存储 readOnly 结构体 dirty map[any]*entry // 加锁访问的 dirty map misses int // 计数:read 未命中次数}
type readOnly struct { m map[any]*entry amended bool // dirty 中有 read 中不存在的 key}
type entry struct { p atomic.Pointer[any] // 指向实际值,或 nil(已删除),或 expunged(已清除)}read 存储的是 readOnly 结构体,其中 m 是只读 map,amended 标记 dirty 中是否有 read 没有的 key。entry.p 有三种状态:
- 正常指针:key 存在,值有效
nil:key 已被逻辑删除(Delete将 p 设为 nil)expunged:key 已被清除(dirty 提升为 read 时,nil 的 entry 会被标记为 expunged,表示”这个 key 在 dirty 中也不存在了”)
7.3 Load 流程
func (m *Map) Load(key any) (value any, ok bool) { read := m.read.Load().(readOnly) e, ok := read.m[key] if !ok && read.amended { // read 中没有,但 dirty 可能有 m.mu.Lock() read = m.read.Load().(readOnly) e, ok = read.m[key] if !ok && read.amended { e, ok = m.dirty[key] m.missLocked() // 增加未命中计数 } m.mu.Unlock() } if !ok { return nil, false } return e.load()}missLocked 是关键:当 read 未命中次数超过 dirty 的大小,就把 dirty 提升为 read(m.read.Store(readOnly{m: m.dirty}),然后清空 dirty)。这保证了 read map 会逐渐收敛到包含所有热 key。
7.4 适用场景
- 读多写少且 key 相对稳定:读操作无锁,性能远超 Mutex+map
- 多个 goroutine 独立读写不同的 key:不同 key 的写操作互不干扰
- 需要并发安全的 map:不想自己加锁管理
不适合的场景:需要对同一个 key 频繁读写(dirty 提升开销大)、写多读少(用 Mutex + map)、需要原子组合操作如 LoadAndDelete。
7.5 性能对比
基准测试数据(8 CPU,读写比例 9<1>1>,key 数量 100):
// 读多写少:sync.Map 比 Mutex+map 快 3-10 倍// 写多读少:Mutex+map 比 sync.Map 快 2-5 倍//// 原因:sync.Map 读操作直接访问 read map(atomic load),// 而 Mutex+map 每次读都要加锁。但 sync.Map 写操作需要// 提升 dirty → read,涉及全量复制,写多时开销惊人。八、atomic 原子操作
8.1 常用操作
import "sync/atomic"
var counter atomic.Int64
// 原子加counter.Add(1)
// 原子加载n := counter.Load()
// 原子存储counter.Store(100)
// 原子交换(返回旧值)old := counter.Swap(200)
// 原子比较并交换swapped := counter.CompareAndSwap(100, 200)// 只有当 counter == 100 时才设置为 2008.2 指针操作
type Config struct { Timeout int}
var config atomic.Value
// 存储config.Store(&Config{Timeout: 30})
// 加载cfg := config.Load().(*Config)8.3 实战应用
// 应用 1:基于 CAS 的无锁栈(Treiber Stack)type Node[T any] struct { val T next atomic.Pointer[Node[T]]}
type LockFreeStack[T any] struct { top atomic.Pointer[Node[T]]}
func (s *LockFreeStack[T]) Push(val T) { node := &Node[T]{val: val} for { old := s.top.Load() node.next.Store(old) if s.top.CompareAndSwap(old, node) { return } }}
func (s *LockFreeStack[T]) Pop() (T, bool) { for { old := s.top.Load() if old == nil { var zero T return zero, false } next := old.next.Load() if s.top.CompareAndSwap(old, next) { return old.val, true } }}// 应用 2:实现计数器type Counter struct { count atomic.Int64}
func (c *Counter) Inc() { c.count.Add(1)}
func (c *Counter) Get() int64 { return c.count.Load()}九、Map 与 sync.Map 对比
| 场景 | 内置 map + Mutex | sync.Map |
|---|---|---|
| 读多写少 | 一般 | 更好 |
| 写多读少 | 更好 | 一般 |
| 读写都多 | 更好 | 一般 |
| Key 稳定 | 一般 | 更好 |
| Key 动态变化 | 更好 | 一般 |
| 需要原子操作 | 更好 | 不支持 |
十、常见问题
Q1:Mutex 和 RWMutex 怎么选?
读写比例是关键:读多写少用 RWMutex(多个读者可并发),写多或读写相当用 Mutex(RWMutex 的写锁开销更大)。基准测试显示读<写>写> > 10<1>1> 时 RWMutex 有明显优势。
Q2:sync.Once 和 init 有什么区别?
init 在程序启动时执行,无法延迟或条件执行。sync.Once 在首次调用时执行,支持延迟初始化和错误重试。需要运行时初始化(如数据库连接)用 Once。
Q3:sync.Pool 的对象什么时候会被回收?
Pool 中的对象在每次 GC 时可能被清除。这意味着 Pool 不适合长期持有对象,只适合复用临时对象。GC 间隔影响 Pool 的命中率。
Q4:sync.Map 和 map+mutex 怎么选?
读多写少且 key 相对固定用 sync.Map(读操作无锁),写多读少用 map+RWMutex(sync.Map 的写操作需要 dirty 提升,开销大)。
小结
sync 包的各个原语是一条从“最轻量”到“最重量”的光谱:atomic 适合单一变量的无锁操作,Mutex 适合保护一小段临界区,RWMutex 在读多写少时减少锁争用,sync.Map 把读写分离做到极致但代价是写操作更重。选择的核心逻辑是用最轻量的原语满足正确性,能用 atomic 解决的不要上 Mutex,能用 Mutex 搞定的不要换 RWMutex(RWMutex 的写锁路径比 Mutex 更复杂),能用 map+Mutex 的不要用 sync.Map(sync.Map 的 dirty 提升机制在写多时开销惊人)。sync.Pool 看起来是”对象池”,实质上是GC 压力调节器,它让短命对象在 GC 之间被复用,但代价是对象可能在任意 GC 周期被清除,所以绝不能用它管理连接池或持状态对象。Mutex 的饥饿模式容易被忽略却值得理解<等待超过>等待超过> 1ms 就切换到 FIFO 模式,这是 Go 在”吞吐”和”公平”之间的务实折中,大多数场景用正常模式追求吞吐,极端竞争时用饥饿模式防止活锁。
sync 原语选择决策图:
参考资料
- Go sync 包源码 - Mutex/RWMutex 底层实现
- Go sync.Pool 源码 - 对象池的完整实现,包含 victim cache 机制
- Go sync.Once 源码 - 双重检查锁定模式实现
- Go 1.9 Mutex 饥饿模式提案 - 饥饿模式的设计讨论
- Go Blog: Go’s March to Low-Latency GC - sync.Pool 与 GC 的关系说明
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






