登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

读写锁

来源:17golang原创

时间:2026-11-11 16:32:37 157浏览 收藏

Go 的 sync.RWMutex 没有“把读锁升级成写锁”的操作。持有 RLock 时直接调用 Lock,当前 goroutine 会等所有读者退出,而它自己还没有调用 RUnlock,于是形成等待环。处理读后写场景时,应先释放读锁,再获取写锁,并在写锁内重新检查一次状态。

要点速览
  • RLock 和 Lock 是两种互斥状态,官方语义不支持直接升级。
  • 正确顺序是:读锁判断、释放读锁、写锁重查;不能把第一次读取的结果直接当成最终结论。
  • 是否使用 RWMutex 要看读写比例、锁等待时间和实际基准,不是锁名字里有“读”就一定更快。

为什么持有 RLock 时再 Lock 会卡住

RWMutex 允许多个读者同时进入,但同一时刻只能有一个写者。它还要保证写者最终能获得机会:当某个 goroutine 已经等待 Lock 时,后续新的 RLock 会被挡住,直到写者完成。

问题在于“升级”并不是把状态上的一个标记改掉。下面的反例在发现缓存缺失后尝试写入:

func (c *Cache) BrokenGet(key string) string {
	c.mu.RLock()
	defer c.mu.RUnlock() // 读锁还没释放,函数返回前才会执行
	if value, ok := c.items[key]; ok {
		return value
	}
	c.mu.Lock() // 这里等待所有读者退出,其中包括当前 goroutine
	defer c.mu.Unlock()
	c.items[key] = loadValue(key)
	return c.items[key]
}

这里不是“偶尔竞争失败”,而是锁的生命周期自相矛盾:Lock 要等当前读者减少,当前 goroutine 却要等 Lock 返回后才有机会执行 RUnlock。Go 官方文档因此明确写出,RLock 不能升级为 Lock,Lock 也不能降级为 RLock。

Go sync.RWMutex 中 RLock、RUnlock、Lock 和等待写者之间的静态关系,展示读锁升级形成的自等待环
图1:读锁与写锁的静态边界说明图;持有 RLock 时直接请求 Lock 会把当前读者留在写者等待条件中。

读后写场景要用“释放、获取、重查”

常见需求不是“升级锁”,而是先用便宜的读锁查一次,只有缺失时才修改共享数据。此时第一次读取只是线索,不能跨越解锁阶段直接复用。一个可控的缓存写入方式如下:

type Cache struct {
	mu    sync.RWMutex
	items map[string]string
}

func (c *Cache) GetOrLoad(key string, load func(string) string) string {
	c.mu.RLock()
	value, ok := c.items[key]
	c.mu.RUnlock() // 先结束读临界区,不能用 defer 把它拖到写锁之后
	if ok {
		return value
	}

	c.mu.Lock()
	defer c.mu.Unlock() // 写入和第二次检查必须处在同一个临界区
	if value, ok = c.items[key]; ok {
		return value // 其他 goroutine 可能已经在空档期完成写入
	}
	value = load(key) // 回调不要再次调用会获取 c.mu 的方法
	if c.items == nil {
		c.items = make(map[string]string)
	}
	c.items[key] = value
	return value
}

第二次检查是这段代码的关键。当前 goroutine 释放读锁到重新获取写锁之间,其他 goroutine 可能已经填充了同一个键;不重查就会重复加载,甚至覆盖更新后的值。若 load 很慢,还应把耗时工作移到锁外,并为结果设计版本或单飞协调机制,避免把慢回调放在写锁里。

Go Cache、RLock、RUnlock、Lock、二次检查和 map 写入之间的静态关系说明图
图2:读后写的结构说明图;第一次读取只负责判断,写锁保护二次检查与 map 更新。

怎样用指标判断 RWMutex 是否值得保留

读写锁的优势来自读者之间可以并行,而不是来自 API 名称。先记录访问路径的读写比例,再分别观察锁等待时间、临界区耗时和整体吞吐;如果写请求频繁、临界区很短,Mutex 可能更简单也更快。

观察项怎么记录它能回答什么
读写比例分别统计 RLock 路径与 Lock 路径的请求数是否存在足够的并行读机会
锁等待在进入临界区前后记录等待时长分布瓶颈是竞争还是临界区逻辑
基准结果固定数据规模,比较 RWMutex 与 Mutex 的 ns/op、allocs/op改锁是否真的改善当前负载
# 只运行基准,不运行普通测试;基准函数应在同一数据规模下比较两种锁
go test -run '^$' -bench='BenchmarkCache' -benchmem ./...

# 多次运行降低单次调度噪声,结果仍要结合业务读写比例解释
go test -run '^$' -bench='BenchmarkCache' -benchmem -count=5 ./...

不要把一次本地 ns/op 直接当成线上结论。基准中要固定 goroutine 数、键分布、命中率和回调耗时;线上则继续看 p95/p99 等等待分位数。这样才能区分“读者并行带来的收益”和“锁内工作变少带来的收益”。

这几个边界容易把问题带回原点

  • 递归读锁:看似只是再次读取,但待写者会阻塞新的读者,递归调用可能永远等不到返回。
  • 锁内回调:回调如果再次访问同一对象并获取同一把锁,会形成隐式自等待;把回调契约写清楚。
  • 跨 goroutine 解锁:RWMutex 不绑定 goroutine,但跨协程交接会让生命周期难以审查,除非所有权边界非常明确。
  • 复制锁对象:RWMutex 第一次使用后不能复制;包含它的结构体也要避免值拷贝。

相关问题

释放 RLock 后还需要重新读取吗?

如果后续要写共享状态,需要重新检查。解锁期间状态可能变化,第一次读到的结果只代表当时的观察。

能不能先 RUnlock,再马上 Lock?

可以,这是避免等待环的必要步骤,但它不是原子升级;在两次调用之间,其他 goroutine 可能先完成写入。

读多写少就一定该用 RWMutex 吗?

不一定。只有在读者并行收益能抵消更复杂的状态维护和竞争成本时才值得采用,最终用固定负载基准确认。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>