文章目录

Go语言sync.Map的LoadAndDelete原子操作实现

发布于 2026-04-27 00:29:23 · 浏览 2 次 · 评论 0 条

Go语言sync.Map的LoadAndDelete原子操作实现

在并发编程中,从 map 中读取一个键值对并立即将其删除是一个常见的需求。如果使用普通的 map 加锁操作,通常需要两步:先 LoadDelete。这不仅繁琐,而且在两步操作之间,其他协程可能会修改该键的值,导致数据不一致。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 内部是如何存储数据的。它主要包含两个部分:readdirty

  • readatomic.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 指针。

  1. 读取 read map。
  2. 检查 key 是否存在。
  3. 判断 entry 指针状态 p
    • 如果 pexpunged(已擦除状态):说明数据逻辑上已删除,直接返回 false。
    • 如果 p 不为空(正常状态):执行 原子操作 CompareAndSwap,将 p 设置为 nil。这代表逻辑删除成功。
    • 如果 pnil:说明已经被删除了,返回 false。

这一步完全无锁,利用 CAS(Compare-And-Swap)指令保证并发安全。

2. 慢速路径:操作 dirty map

如果 read 中没有找到这个 key,或者 read.amended 标记为 true(表示 dirty 中有 read 没有的数据),则需要进入慢速路径,加锁处理。

  1. 加锁 sync.Mutex
  2. 二次检查 read map。因为在第一次检查和加锁之间,read 可能被其他协程更新(例如通过 LoadOrStore 触发了 dirty 提升)。
  3. 处理 read 中的数据:
    • 如果此时在 read 中找到了 key,且 p 不为 expunged p 原子置为 nil,并返回值。
  4. 读取 dirty map:
    • 如果在 read 中还是没找到,则去 dirty 中查找。
    • 如果在 dirty 中找到了:直接 delete 掉这个 key。注意这里不需要像 read 那样置为 nil,而是直接从 map 中移除该 entry。
  5. 解锁

逻辑流程图

为了更直观地展示决策过程,下图描绘了完整的判断逻辑:

graph TD A["Start: LoadAndDelete(key)"] --> B["Lookup in read map"] B -- Found, p == expunged --> C["Return: nil, false"] B -- Found, p is valid --> D["CAS p to nil (Delete)"] D --> E["Return: value, true"] B -- Not found --> F{Is amended true?} F -- No --> G["Return: nil, false"] F -- Yes --> H["Lock Mutex"] H --> I["Double check read map"] I -- Found --> J["CAS p to nil"] J --> K["Return: value, true"] I -- Not found --> L["Lookup in dirty map"] L -- Found --> M["Delete key from dirty"] M --> N["Unlock Mutex"] N --> O["Return: value, true"] L -- Not found --> P["Unlock Mutex"] P --> Q["Return: nil, false"]

源码关键点解析

下面我们模拟一段简化版的源码逻辑,重点分析其中的原子操作细节。

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
        }
    }
}

关键技术点

  1. 原子指针操作 (atomic.LoadPointer, atomic.CompareAndSwapPointer)
    entry.delete() 中,使用 for 循环配合 CAS 操作。为什么需要循环?因为在并发环境下,可能有其他协程正在同时修改这个指针。如果 CAS 失败(说明 p 被别人改了),需要重新加载 p 的值并重试,直到成功或确认数据无效。

  2. read.amended 标志
    这是一个极其重要的优化。如果 dirty 包含 read 中没有的 key,amendedtrue。如果在 read 中找不到且 amendedfalse,我们可以跳过加锁操作,直接断定 key 不存在。这避免了在数据一致时的锁开销。

  3. 双重检查
    在进入慢速路径加锁后,必须再次检查 read。这是因为在第一次检查和获取锁之间,可能发生了一个“提升”操作(将 dirty 提升为 read)。如果不二次检查,可能会导致数据逻辑错误或重复删除。


性能优化与适用场景

理解了原理,我们就能更好地判断何时使用 LoadAndDelete

  • 性能优势

    • 无锁读删:对于大多数存在于 read 中的 key,操作完全是无锁的,仅涉及几次原子指令,速度极快。
    • 减少锁竞争:只有在必须访问 dirty 时才加锁,且持有锁的时间非常短。
  • 适用场景

    • 分片缓存:例如实现一个简单的 LRU 缓存,获取并淘汰数据。
    • 一次性任务:注册任务后,执行时将其从注册表中移除,防止重复执行。
    • 高并发读:读多写少的场景下,LoadAndDelete 比手动加 sync.Mutex 性能更好。

总结

LoadAndDelete 通过巧妙利用 read (无锁) 和 dirty (加锁) 的双层结构,结合原子指针操作,实现了高效的“读删一体”原子操作。其核心在于:优先无锁访问 read,遇到 expunged 状态或 amended 标记时才降级处理。在实际开发中,直接使用此方法替代“先 Load 后 Delete”的组合,不仅能减少代码行数,还能显著提升并发安全性。

评论 (0)

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

扫一扫,手机查看

扫描上方二维码,在手机上查看本文