WEBKT

Go 并发原语大盘点:从 sync.Mutex 到原子操作的性能对比

6 0 0 0

谈到 Go 语言,逃不开它的杀手锏——goroutine 和 channel。但真正写生产代码时,光靠 channel 还不够,标准库里的 sync 包和 atomic 包才是底层保障。

这篇文章就把常用的几种同步方案拉出来遛遛,看看它们各自的脾气秉性,以及在什么场景下该选谁。

先说结论

不想看长文的直接记这几点:

原语 适用场景 开销等级
atomic 计数器、标志位、单值状态 ★(最低)
sync.Mutex 需要独占的资源保护 ★★★
sync.RWMutex 读多写少场景 ★★☆
channel goroutine 间通信、任务分发 ★★★★
sync.Map 并发 map 操作(特定模式) 看情况

接下来逐个拆解,并附上我跑出来的基准测试数据。


一、atomic — 最轻量的同步方式

atomic 包提供的是硬件级别的原子操作,不涉及操作系统调度,开销最小。它适合的场景是:一个或几个值的简单读写

三种经典用法

// 计数器增量
var counter int64
atomic.AddInt64(&counter, 1)

// 布尔标志位(实现自旋锁)
var locked int32
for !atomic.CompareAndSwapInt32(&locked, 0, 1) {
    runtime.Gosched() // 让出 CPU 时间片
}

// 值替换(适用于配置热更新)
var config atomic.Value
config.Store(someConfig)

为什么 atomic 快

它依赖 CPU 的 CAS(Compare-And-Swap)指令,整个操作在一个 CPU 周期内完成,不会触发内核态切换。拿 mutex 对比就知道了——mutex 加锁失败后会陷入内核,让当前 goroutine 进入休眠,等锁释放了再唤醒。这一来一回,延迟轻松差出几十倍。


二、sync.Mutex — 最通用的互斥方案

sync.Mutex 是 Go 里最常用的锁。当我们需要保护一段共享资源的读写时,它是首选。

基本用法不赘述,注意两点:

var mu sync.Mutex

func protectedWrite() {
    mu.Lock()
    defer mu.Unlock()
    // ... 操作共享资源
}

不要把 Lock 调用放在 if 里,应该用 defer 确保解锁;更不要在持锁期间执行耗时操作,否则所有等待的 goroutine 都得排队挨饿。

Mutex 的内部实现(小知识)

Go 1.9 引入了"饥饿模式"。如果一个 goroutine 等锁超过一定时间还没拿到,就会进入饥饿模式,新来的 goroutine 直接排队而不是抢锁。这样可以防止尾延迟过高,但也意味着高并发下的吞吐量会有所下降。


三、sync.RWMutex — 为读优化的读写锁

如果你的场景是读多写少,RWMutex 比普通 Mutex 更合适。它允许多个读者同时持有读锁,只有写者需要独占。

var rwMu sync.RWMutex
var resource int

func read() int {
    rwMu.RLock()
    defer rwMu.RUnlock()
    return resource
}

func write(val int) {
    rwMu.Lock()
    defer rwMu.Unlock()
    resource = val
}

什么时候 RWMutex 会变慢?

当写操作频繁时,读锁会频繁被阻塞,因为每次只能有一个写者。而且 RWMutex 的实现比 Mutex 重一些,如果纯粹是读多写少,用 atomic + copy-on-write 可能更划算。


四、channel — Go 并发的灵魂

channel 不只是用来传值,它本身就是一种同步机制。发送和接收操作在对方未就绪时会阻塞,这天然形成了一种同步关系。

用 channel 实现互斥(虽然不推荐,但能说明问题)

type Mutex chan struct{}
func (m Mutex) Lock()   { m <- struct{}{} }
func (m Mutex) Unlock() { <-m }

var m Mutex = make(chan struct{}, 1)

func safeOp() {
    m.Lock()
    defer m.Unlock()
}

但说实话,拿 channel 做纯互斥是杀鸡用牛刀。channel 的强项在于传递所有权、分发任务、构建流水线

jobs := make(chan Job, 100)
results := make(chan Result, 100)

for w := range workers {
    go func() {
        for j := range jobs {
            results <- process(j)
        }
    }()
}

channel 的开销在哪?

unbuffered channel 或者 buffer 已满时的发送/接收操作,会触发 goroutine 的切换,这个成本比 mutex 加锁还高。所以用 channel 要注意 buffer 大小,避免过度竞争导致上下文切换爆炸式增长。


五、性能实测:我跑了这些基准测试

测试环境:macOS M2 Pro,16GB RAM,Go 1.22,单机运行多次取中位数。

测试一:高并发累加器(100 万次操作)

BenchmarkAtomicAdd:       ~12ms     (5000万次 ops/s)
BenchmarkMutexAdd:        ~380ms    (260万次 ops/s)
BenchmarkChannelAdd:      ~850ms    (117万次 ops/s)
BenchmarkRWMutexAdd:      ~420ms    (240万次 ops/s)

结论很残酷:atomic 比 mutex 快 20 倍,比 channel 快 70 倍。所以如果是简单的计数器,别犹豫,上 atomic。

测试二:读多写少场景模拟(10% writes / 90% reads,各50万次)

BenchmarkAtomicReadWithCopy:      ~8ms     (6250万次 ops/s)
BenchmarkRWMutexMixed:           ~95ms    (526万次 ops/s)
BenchmarkSyncMapMixed:           ~120ms   (416万次 ops/s)
BenchmarkChannelMixed:           ~310ms   (161万次 ops/s)

这里 atomic + copy-on-write 的模式表现最好。但注意,这种模式的代价是需要复制整个数据结构,如果对象很大反而会适得其反。对于这种场景,我一般会用 RWMutex,除非确认热点对象足够小且读取频率极高,才考虑 atomic + COW 的组合拳。


六、怎么选?给个决策树吧

是否跨多个字段或复杂数据结构?
├── 否 → 是否需要广播通知?
│       ├── 是 → 用 sync.Cond 或 broadcast channel
│       └── 否 → 是否只有单个值/计数器的增减?
│               ├── 是 → atomic(首选)
│               └── 否 → go func + mutex,或者封装成结构体后继续判断...
└── 是 → 数据结构是否可以 copy-on-write?
        ├── 是 → atomic.Value + copy-on-write 设计模式,反之走下一层判断...
        └── 否 → 是否读多写少?
                ├── 是 → RWMutex 或分片 shard map(如 golang.org/x/sync/map)
                └── 否 → 普通 Mutex,考虑缩小临界区范围,或改用 actor 模型将状态内聚到单个 goroutine 中减少外部访问频率。

这个决策树不是绝对的,更多是帮助你在脑子里快速筛选。实际上 benchmark 才最具说服力——有条件的话在自己的业务代码里跑一跑真实 workload,比任何经验法则都管用。顺便提一句,有时候真正的瓶颈不在同步开销,而在你对数据的抽象方式,比如把所有热点都塞进一把大锁里,这才是最要命的。相比之下,换哪种原语可能只带来边际收益。

北木 golang并发编程性能优化

评论点评