告别数据竞争:Go语言线程安全计数器的三种实现方案


在Go语言的并发编程中,多个goroutine同时修改共享变量时,数据竞争是常见的“坑”。比如统计API请求量的计数器,如果没有同步机制,最终结果可能远小于实际请求数。如何实现一个线程安全的计数器?本文从原理到代码,讲解三种主流方案,并对比它们的适用场景。

随机图片

一、问题根源:无同步的计数器会怎样?

先看一段错误代码:

type Counter struct {
    count int
}

func (c *Counter) Inc() {
    c.count++ // 非原子操作:读→加1→写,可能被打断
}

func (c *Counter) Get() int {
    return c.count
}

当100个goroutine各调用100次Inc(),理想结果是10000,但实际可能只有几千。用Go的race detectorgo run -race main.go)运行,会检测到数据竞争:多个goroutine同时访问共享变量c.count,未同步

二、方案1:互斥锁(sync.Mutex)——通用安全方案

sync.Mutex是Go标准库提供的互斥锁,实现“同一时间只有一个goroutine进入临界区”的效果。临界区即修改共享变量的代码块。

实现代码

import "sync"

type MutexCounter struct {
    count int
    mu    sync.Mutex // 互斥锁
}

// Inc 原子递增:加锁确保独占访问
func (c *MutexCounter) Inc() {
    c.mu.Lock()       // 加锁,阻塞其他goroutine
    defer c.mu.Unlock() // 确保解锁(即使panic也会执行)
    c.count++
}

// Get 安全读取:同样需要加锁
func (c *MutexCounter) Get() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

核心原理

  • Lock():阻塞后续调用者,直到当前持有者执行Unlock()
  • defer Unlock():避免忘记解锁导致死锁,是Go中操作锁的最佳实践

测试验证

100个goroutine各调用100次Inc(),最终Get()返回10000,无数据竞争。

三、方案2:读写锁(sync.RWMutex)——读多写少场景优化

如果计数器的读操作远多于写操作(比如统计在线用户数,每秒读100次、写1次),Mutex的性能会受影响——每次读都要独占锁。此时用sync.RWMutex更高效:它支持读锁共享、写锁独占

实现代码

type RWCounter struct {
    count int
    rw    sync.RWMutex // 读写锁
}

// Inc 写操作:加写锁(独占)
func (c *RWCounter) Inc() {
    c.rw.Lock()
    defer c.rw.Unlock()
    c.count++
}

// Get 读操作:加读锁(共享)
func (c *RWCounter) Get() int {
    c.rw.RLock()
    defer c.rw.RUnlock()
    return c.count
}

核心原理

  • RLock():多个goroutine可同时持有读锁,仅当有写锁时阻塞
  • Lock():写锁独占,持有写锁时所有读/写锁都会阻塞

优势

读多写少场景下,吞吐量比Mutex提升数倍。比如100个读goroutine和1个写goroutine,Mutex串行处理,RWMutex可并行处理读请求。

四、方案3:原子操作(sync/atomic)——无锁高性能方案

对于简单数值操作(int32/int64的加减、加载、存储),Go标准库的sync/atomic包提供了CPU级别的原子指令,无需锁即可保证线程安全,性能最优。

实现代码

import "sync/atomic"

type AtomicCounter struct {
    count int32 // 必须用atomic支持的类型(int32/int64等)
}

// Inc 原子递增:AddInt32返回新值(可选)
func (c *AtomicCounter) Inc() {
    atomic.AddInt32(&c.count, 1)
}

// Get 原子读取
func (c *AtomicCounter) Get() int32 {
    return atomic.LoadInt32(&c.count)
}

注意事项

  1. 仅支持特定类型:int32、int64、uint32、uint64、uintptr、pointer
  2. 仅支持简单操作:加减、加载、存储(无法处理复杂逻辑,比如count = count*2 +1
  3. 函数参数必须是指针,且不能为nil

五、三种方案对比与选择

方案 适用场景 性能 复杂度 限制
Mutex 读写比例均衡、复杂操作 一般 无(通用)
RWMutex 读多写少、简单操作 较好(读多) 读锁不能升级为写锁
Atomic 简单数值操作(加减等) 最优 仅支持特定类型/操作

实际选择举例

  • 电商订单数统计(读多写少):选RWMutex
  • API请求量统计(写多、简单加减):选Atomic
  • 带复杂逻辑的计数器(比如inc后更新其他字段):选Mutex

六、总结

线程安全计数器的核心是同步对共享变量的访问,Go提供了三种成熟方案:

  1. Mutex:通用,适合大多数场景
  2. RWMutex:读多写少场景的性能优化
  3. Atomic:无锁,适合简单数值操作

没有“最好”的方案,只有“最适合”的方案。实际开发中,需结合读写比例、操作复杂度选择,必要时用race detector检测数据竞争,用benchmark测试性能。

关键提醒:永远不要假设“简单操作就是原子的”,Go中只有sync/atomic包的操作是原子的,其他都需要同步机制。

发表评论