sync.rwmutex不是无锁读的真正解法,因其rlock仍需原子修改计数器并可能触发调度,且写操作阻塞新读者;真正的无锁读要求读路径不依赖互斥、不修改状态、不被写中断。

为什么 sync.RWMutex 不是无锁读的真正解法
很多人以为用 sync.RWMutex 加读锁、写锁分离,就能实现“无锁读”——其实不是。RWMutex 的 RLock() 仍会原子修改内部计数器、可能触发调度器参与(尤其在 contention 高时),且写操作必须阻塞所有新读者。真正的无锁读(lock-free read)要求:读路径不依赖任何互斥原语,不修改共享状态,也不被写操作中断。
双缓冲切换的核心模式:两份数据 + 原子指针交换
Go 中最轻量、可落地的无锁读方案,是维护两份完整数据副本(buffer A 和 B),配合 unsafe.Pointer 或 atomic.Value 做指针级切换。读端永远通过原子加载拿到当前有效 buffer 地址,全程无锁;写端在更新时构造新副本、再原子替换指针。
关键点:
-
atomic.Value更安全:它能存任意类型,且对Store/Load做了内存序保证,比裸用unsafe.Pointer+atomic.LoadPointer更不易出错 - 写操作必须「先构造、后切换」:不能就地修改旧 buffer,否则读端可能看到中间态(如 slice len 已变但底层数组未填完)
- 旧 buffer 的内存回收需谨慎:Go 没有引用计数,只要没 goroutine 持有其指针,GC 自动回收;但若读 goroutine 持有时间长(比如处理耗时逻辑),应避免高频写导致内存短暂堆积
一个最小可行示例:atomic.Value 切换 []int
以下代码演示如何安全支持并发读、低频写:
type IntList struct {
data atomic.Value // 存 *[]int
}
func NewIntList() *IntList {
l := &IntList{}
l.data.Store(&[]int{}) // 初始化空切片指针
return l
}
func (l *IntList) Load() []int {
p := l.data.Load().(*[]int)
return **p // 解引用两次:*[]int → []int
}
func (l *IntList) Store(new []int) {
// 构造新副本(关键!)
copyBuf := make([]int, len(new))
copy(copyBuf, new)
l.data.Store(©Buf)
}
注意:Store 中必须 make 新底层数组并 copy,不能直接 l.data.Store(&new)——因为 new 是栈变量或外部传入,生命周期不可控。
容易踩的坑:指针逃逸、结构体字段非原子、GC 压力
常见错误包括:
- 把
atomic.Value嵌在非导出字段里却暴露方法返回内部 slice —— 外部可能直接修改,破坏双缓冲语义 - 用
struct{ items []int }当 buffer 类型,但只原子切换 struct 指针:如果items是共享底层数组,写端仍可能破坏读端看到的数据 - 高频写(比如每毫秒一次)导致短时间内大量 buffer 分配,虽 GC 能回收,但会抬高 GC 频率和 STW 时间
- 读端逻辑过重(如遍历后做复杂计算),延长 buffer 引用时间,拖慢写端切换节奏,间接增加内存占用
双缓冲不是银弹:它用空间换并发安全,写吞吐越低、读越频繁,收益越明显;一旦写变多,就得权衡是否该换用分段锁或 sync.Map 这类更均衡的结构。











