Go语言sync.Map的LoadAndDelete原子操作实现
在并发编程中,从 map 中读取一个键值对并立即将其删除是一个常见的需求。如果使用普通的 map 加锁操作,通常需要两步:先 Load 再 Delete。这不仅繁琐,而且在两步操作之间,其他协程可能会修改该键的值,导致数据不一致。Go 语言的 sync.Map 提供了 LoadAndDelete 方法,将这两个操作合并为一个原子操作,完美解决了“检查后竞争”的问题。本文将深入源码,手把手解析其实现机制。
基本使用场景
在深入底层实现之前,先通过一个简单的代码片段了解其功能。LoadAndDelete 接受一个 key,返回该 key 对应的 value 以及一个表示是否存在的布尔值。如果 key 存在,它会返回值并原子地删除该 key;如果不存在,返回 nil 和 false。
package main
import (
"fmt"
"sync"
)
func main() {
var m sync.Map
// **存储** 键值对
m.Store("key1", "value1")
// **执行** 原子读取并删除
value, loaded := m.LoadAndDelete("key1")
fmt.Println(value, loaded) // 输出: value1 true
// **再次验证** 是否已删除
value, loaded = m.LoadAndDelete("key1")
fmt.Println(value, loaded) // 输出: <nil> false
}
核心数据结构回顾
要理解 LoadAndDelete,必须先搞清楚 sync.Map 内部是如何存储数据的。它主要包含两个部分:read 和 dirty。
read:atomic.Value类型,用于存储只读数据。读取操作通常不加锁,直接从这里读取,性能极高。dirty:普通的map[any]*entry,包含了新写入的数据以及部分read中的数据。操作dirty需要加锁。
最关键的是 entry 结构体。它是一个指针,指向具体的值。这个指针的状态决定了数据的去向:
| 指针状态 | 含义 |
|---|---|
p == nil |
键值对已被删除。 |
p == expunged |
键值对已被删除,且该键只存在于 read 中,不存在于 dirty 中。 |
p == 正常指针 |
键值对正常存在,指向具体的值。 |
LoadAndDelete 的执行流程
LoadAndDelete 的执行逻辑可以分为“快速路径”和“慢速路径”。它总是优先尝试从 read map 中获取,如果失败或发现数据状态不一致,才会回退到加锁操作 dirty map。
1. 快速路径:操作 read map
首先,尝试从 read 中获取 entry 指针。
- 读取
readmap。 - 检查 key 是否存在。
- 判断 entry 指针状态
p:- 如果
p是expunged(已擦除状态):说明数据逻辑上已删除,直接返回 false。 - 如果
p不为空(正常状态):执行 原子操作CompareAndSwap,将p设置为nil。这代表逻辑删除成功。 - 如果
p为nil:说明已经被删除了,返回 false。
- 如果
这一步完全无锁,利用 CAS(Compare-And-Swap)指令保证并发安全。
2. 慢速路径:操作 dirty map
如果 read 中没有找到这个 key,或者 read.amended 标记为 true(表示 dirty 中有 read 没有的数据),则需要进入慢速路径,加锁处理。
- 加锁
sync.Mutex。 - 二次检查
readmap。因为在第一次检查和加锁之间,read可能被其他协程更新(例如通过LoadOrStore触发了dirty提升)。 - 处理 read 中的数据:
- 如果此时在
read中找到了 key,且p不为expunged:将p原子置为nil,并返回值。
- 如果此时在
- 读取
dirtymap:- 如果在
read中还是没找到,则去dirty中查找。 - 如果在
dirty中找到了:直接delete掉这个 key。注意这里不需要像 read 那样置为 nil,而是直接从 map 中移除该 entry。
- 如果在
- 解锁。
逻辑流程图
为了更直观地展示决策过程,下图描绘了完整的判断逻辑:
源码关键点解析
下面我们模拟一段简化版的源码逻辑,重点分析其中的原子操作细节。
func (m *Map) LoadAndDelete(key any) (value any, loaded bool) {
// 第一步:尝试从 read 中获取
read := m.loadReadOnly()
if e, ok := read.m[key]; ok {
// 如果找到了,尝试原子删除
return e.delete()
}
// 如果 read 中没找到,且 amended 为 false,说明 dirty 中也没有,直接返回
if !read.amended {
return nil, false
}
// 第二步:加锁处理 dirty
m.mu.Lock()
// 二次检查,防止加锁期间 read 被提升
read = m.loadReadOnly()
if e, ok := read.m[key]; ok {
// 如果 read 中现在有了,执行删除
return e.delete()
}
// 去 dirty 中查找并删除
e, ok := m.dirty[key]
if !ok {
m.mu.Unlock()
return nil, false
}
delete(m.dirty, key)
m.mu.Unlock()
return e.load(), true
}
// entry 的 delete 方法
func (e *entry) delete() (value any, ok bool) {
for {
p := atomic.LoadPointer(&e.p)
if p == nil || p == expunged {
return nil, false
}
// 核心原子操作:将 p 置为 nil
if atomic.CompareAndSwapPointer(&e.p, p, nil) {
return *(*any)(p), true
}
}
}
关键技术点
-
原子指针操作 (
atomic.LoadPointer,atomic.CompareAndSwapPointer):
在entry.delete()中,使用for循环配合 CAS 操作。为什么需要循环?因为在并发环境下,可能有其他协程正在同时修改这个指针。如果 CAS 失败(说明p被别人改了),需要重新加载p的值并重试,直到成功或确认数据无效。 -
read.amended 标志:
这是一个极其重要的优化。如果dirty包含read中没有的 key,amended为true。如果在read中找不到且amended为false,我们可以跳过加锁操作,直接断定 key 不存在。这避免了在数据一致时的锁开销。 -
双重检查:
在进入慢速路径加锁后,必须再次检查read。这是因为在第一次检查和获取锁之间,可能发生了一个“提升”操作(将dirty提升为read)。如果不二次检查,可能会导致数据逻辑错误或重复删除。
性能优化与适用场景
理解了原理,我们就能更好地判断何时使用 LoadAndDelete。
-
性能优势:
- 无锁读删:对于大多数存在于
read中的 key,操作完全是无锁的,仅涉及几次原子指令,速度极快。 - 减少锁竞争:只有在必须访问
dirty时才加锁,且持有锁的时间非常短。
- 无锁读删:对于大多数存在于
-
适用场景:
- 分片缓存:例如实现一个简单的 LRU 缓存,获取并淘汰数据。
- 一次性任务:注册任务后,执行时将其从注册表中移除,防止重复执行。
- 高并发读:读多写少的场景下,
LoadAndDelete比手动加sync.Mutex性能更好。
总结
LoadAndDelete 通过巧妙利用 read (无锁) 和 dirty (加锁) 的双层结构,结合原子指针操作,实现了高效的“读删一体”原子操作。其核心在于:优先无锁访问 read,遇到 expunged 状态或 amended 标记时才降级处理。在实际开发中,直接使用此方法替代“先 Load 后 Delete”的组合,不仅能减少代码行数,还能显著提升并发安全性。

暂无评论,快来抢沙发吧!