Go RWMutex 不能升级读锁:释放、重查与缓存加载的并发边界
缓存查询先拿 RLock,发现没有值,再调用 Lock 准备写入,程序就卡住了。读写锁不会把“当前读者想升级”自动理解成特殊情况:写锁要等全部读者退出,而当前 goroutine 自己还占着一个读锁。
Go 的 sync.RWMutex 不支持从读锁直接升级为写锁,也不支持把写锁降级成读锁。正确做法需要释放读锁,再获取写锁;两步之间状态可能变化,所以后续必须重新检查条件。
下面固定 Go 1.24.1,用一个最小缓存把这条规则落到代码里。示例不真的启动一个永远无法结束的死锁,而用 TryLock 验证读锁存在时写锁不能取得,再用受控并发展示为什么要双重检查。
读锁放开以后,刚才的查询结果就可能过期
“查过没有”只是读锁保护期间的事实。释放读锁以后,另一个 goroutine 可能已经加载并写入相同键;若再拿到写锁就直接覆盖,可能重复发布、覆盖新值,或者产生多份本应共享的对象。
完整保存为 main.go:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type Cache struct {
mu sync.RWMutex
values map[string]string
}
func NewCache() *Cache {
return &Cache{values: make(map[string]string)}
}
func (cache *Cache) GetOrLoad(key string, load func() (string, error)) (string, error) {
cache.mu.RLock()
existing, found := cache.values[key]
cache.mu.RUnlock()
if found {
return existing, nil
}
// 慢操作不占着全局锁;它可能被并发调用多次。
candidate, err := load()
if err != nil {
return "", err
}
cache.mu.Lock()
defer cache.mu.Unlock()
if existing, found := cache.values[key]; found {
return existing, nil
}
cache.values[key] = candidate
return candidate, nil
}
func main() {
var lock sync.RWMutex
lock.RLock()
if lock.TryLock() {
lock.Unlock()
panic("write lock acquired while a reader exists")
}
lock.RUnlock()
if !lock.TryLock() {
panic("write lock not available after releasing reader")
}
lock.Unlock()
fmt.Println("reader prevents writer; releasing reader allows writer")
cache := NewCache()
entered := make(chan struct{}, 2)
release := make(chan struct{})
results := make(chan string, 2)
failures := make(chan error, 2)
var calls atomic.Int32
loader := func() (string, error) {
calls.Add(1)
entered <- struct{}{}
<-release
return "loaded-value", nil
}
var workers sync.WaitGroup
for index := 0; index < 2; index++ {
workers.Add(1)
go func() {
defer workers.Done()
value, err := cache.GetOrLoad("key", loader)
if err != nil {
failures <- err
return
}
results <- value
}()
}
<-entered
<-entered
close(release)
workers.Wait()
close(results)
close(failures)
for err := range failures {
panic(err)
}
count := 0
for value := range results {
if value != "loaded-value" {
panic("unexpected result")
}
count++
}
if count != 2 || calls.Load() != 2 {
panic("controlled load race did not occur")
}
fmt.Println("two concurrent loads, one published cache entry")
}
运行 go run -race main.go。第一条说明读者持有期间不能取得写锁;第二条确认两个加载器都开始以后才被统一放行,因此两次加载是确定出现的,不是偶然调度出来的结果。
双重检查只保证发布时不盲目覆盖已经存在的条目,不保证 load 只执行一次。示例明确允许重复加载,并且载荷只是无外部副作用的字符串。若 load 会扣款、创建远程资源或发送消息,这个合同显然不够。
不要在持有读锁时再拿一次读锁赌运气
RWMutex 的读锁也不是可递归锁。一旦有写者在等待,后来的读锁获取会受到限制,以便写者有机会前进。如果某个 goroutine 持有读锁,又在调用链深处再次 RLock,就可能与等待中的写者形成无法完成的依赖。
因此,不能只规定“读函数用 RLock,写函数用 Lock”,还让这些函数任意互相调用。加锁责任应当有清楚层次:外层拥有锁时调用不加锁的内部辅助方法,或让每个公开方法独立拥有完整短作用域,避免嵌套取得同一把锁。
锁不与某个 goroutine 身份绑定,不代表应用可以忽视拥有者。谁获取、在哪些路径释放、调用方是否还持有锁,仍然必须能从函数合同读出来。把解锁随便交给另一个 goroutine,也会增加状态解释难度。
load 放在锁内还是锁外,要按成本选择
把 load 放进写锁,可以避免相同缓存实例内的并发加载,却会让其他键的读取和写入一起等待。慢网络调用、磁盘读取或用户回调尤其不适合无条件占着全局锁,因为这些动作的耗时和可重入行为不受缓存控制。
放在锁外则需要接受重复工作,并在发布时重查。若不能接受同键重复,但又不想阻塞其他键,可引入按键协调或显式的加载状态;错误、取消和状态清理都要一起设计。不能只多建一个 map 存“正在加载”,失败以后却永远不删除标记。
候选对象若有自己的资源,落选后也要清理。字符串没有关闭动作,但数据库连接、文件句柄和后台工作者不一样。第二次检查发现别的值已经发布,直接返回现有值,会让刚创建的候选失去拥有者;这条路径必须在资源类型确定后补清楚。
缓存若支持删除、失效或版本更新,还要考虑加载期间的状态变化。先读到未命中,加载较久,管理员已经发布新版本,旧加载结果随后回来能否写入,需要版本或代际约束。双重检查一个“目前是否存在”的布尔条件,不能表达所有失效策略。
错误结果是否缓存也要单独决定。当前load返回错误时直接向调用者返回,map里不记录失败,因此下一次请求会重新尝试。这适合可恢复失败,却可能在持续故障时产生大量重复加载。若要缓存失败,应该定义错误有效期与恢复方式,不能顺手把空字符串当成“已经加载成功”的值,否则读取端无法区分合法空内容和失败占位。
TryLock 是观察工具,不是升级协议
示例使用 TryLock 是为了让错误条件不导致进程永久阻塞。生产代码不能在持有读锁时不断 TryLock,期待某次突然升级成功;只要自己的读锁还在,写锁的条件就没有满足。
即使某次 TryLock 失败,也不应该从这个失败推导出受保护数据的具体状态。它只说明本次没有取得锁,不带来读取共享字段所需的同步关系。失败后绕过锁直接检查 map,会把“没有阻塞”换成数据竞争。
读多写少也不自动说明 RWMutex 一定优于 Mutex。临界区非常短、读写比例不同、锁内工作过多时,实际成本需要基于真实负载观察。先把加锁路径写正确,再比较锁类型,通常比在一条会自锁的路径上纠结读锁是否更快有用。
排查卡住的缓存更新时,沿当前 goroutine 已持有的锁和正在等待的锁画出依赖,比只看“这是读操作还是写操作”更容易发现根因。锁负责保护状态,升级策略必须由应用自己完成,不能寄希望于方法名字替你识别意图。