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

Go sync.Map.LoadOrStore 的 loaded 为什么要单独判断

来源:17golang原创

时间:2026-10-13 12:12:33 308浏览 收藏

我在把一个“按 key 惰性创建对象”的缓存从互斥锁改成 sync.Map 时,第一次最容易混淆的并不是并发安全,而是 LoadOrStore 的两个返回值。它返回的 actual 不一定是本次传入的对象,loaded 也不是“调用是否成功”的标志。

判断当前 goroutine 是否真正把 value 放进了 Map,要看 loaded == false;如果 loaded == true,actual 才是已经存在的旧值,当前传入的 value 没有成为结果。

官方文档地址:https://pkg.go.dev/sync#Map.LoadOrStore

先记住返回值:actual 是最终值,loaded 是来源

LoadOrStore(key, value) 做的是一次原子决策:key 已经存在时,返回已有值;key 不存在时,写入传入值并返回它。第一个返回值 actual 表示这次调用最终应当使用的值,第二个返回值 loaded 表示这个值是不是从 Map 中已经加载出来的。

package main

import (
    "fmt"
    "sync"
)

func main() {
    var cache sync.Map

    // 第一次调用没有 key,传入的对象会成为最终值。
    actual, loaded := cache.LoadOrStore("user:42", "first")
    fmt.Printf("actual=%v loaded=%v\n", actual, loaded)

    // 第二次调用不会覆盖旧值,actual 是已有值,loaded 表示已命中。
    actual, loaded = cache.LoadOrStore("user:42", "second")
    fmt.Printf("actual=%v loaded=%v\n", actual, loaded)
}

这个例子的输出逻辑是:第一次得到 first false,第二次得到 first true。第二次传入的 second 只是候选值,并没有覆盖已经存在的内容。

sync.Map LoadOrStore 从空键到已存在键的返回值流程说明图
图1:LoadOrStore 在空键和已存在键两条数据路径上的返回值说明图,不是运行截图。

沿着数据生命周期看三种结果

我后来不再把 LoadOrStore 记成“带默认值的 Load”,而是把一次调用拆成候选值、原子决策和最终值三个阶段,这样在并发场景里更不容易读错。

Map 中没有 key:候选值完成写入

当 key 不存在时,方法会把 value 存入 Map,并返回同一个逻辑结果。此时 loaded == false,它告诉调用方:这次调用完成了“占位”或“首次登记”。如果后续还要执行昂贵初始化,这个分支通常就是初始化责任的归属点。

Map 中已有 key:已有值覆盖候选值

当 key 已经存在时,方法不覆盖旧值,返回的 actual 是 Map 里的已有对象,loaded == true。因此不能写成“无论如何都使用本地创建的对象”,否则本地对象可能已经做过构造或打开了资源,却没有进入缓存。

多个 goroutine 同时到达:只有一个调用拿到 false

并发竞争时,多个 goroutine 都可以先准备候选值,但对同一个 key 的原子决策只有一个会走 loaded == false。其他调用会拿到同一个已存入的 actual,并看到 loaded == true。这也是它适合做一次性占位的原因。

多个 goroutine 竞争同一 sync.Map 键时的候选值与最终值关系图
图2:并发初始化中多个候选值汇聚到一个 actual 的关系图,不是运行截图。

把 loaded 接到惰性初始化流程

真正写业务代码时,我会先决定“创建对象是否便宜”。如果只是轻量配置,可以每个 goroutine 都创建候选值;如果创建动作会连接外部服务、打开文件或启动后台任务,就应该把昂贵动作放在只有赢家执行的路径中。

package cache

import (
    "fmt"
    "sync"
)

type Client struct {
    Address string
}

var clients sync.Map

func GetClient(address string) (*Client, error) {
    // 先构造候选值;这里只放轻量字段,不在这里建立外部连接。
    candidate := &Client{Address: address}
    actual, loaded := clients.LoadOrStore(address, candidate)

    // loaded 为 true 时必须使用缓存中的 actual,而不是丢弃它。
    client, ok := actual.(*Client)
    if !ok {
        return nil, fmt.Errorf("cache value for %q has unexpected type", address)
    }
    if loaded {
        return client, nil
    }

    // 只有 loaded 为 false 的调用负责首次登记后的初始化动作。
    // 如果初始化可能失败,应配合失败清理或单独的状态对象处理。
    return client, nil
}

这里有一个很实际的取舍:LoadOrStore 能保证“哪个值进入 Map”的原子性,但不会自动回滚初始化失败,也不会把“正在初始化”和“初始化完成”区分开。如果初始化失败后不能保留半成品,就需要把状态字段、错误值或一个可等待的 promise 放进 Map,而不是只存一个裸指针。

几个容易把结果读反的边界

  • 不要把 loaded 当成成功标志。 两条路径都可能正常返回,loaded 只说明最终值来自已有条目还是本次存入。
  • 不要忽略 actual。 当 loaded == true 时,真正应该交给调用方的是 actual,而不是本地候选值。
  • nil 也要结合类型设计。 如果存入的是接口包装后的 nil 指针,Map 里仍可能存在这个 key;读取后再做类型断言和空指针判断。
  • Delete 会开启下一轮竞争。 删除 key 后,下一次 LoadOrStore 可能再次出现 loaded == false,因此初始化代码必须允许生命周期重复发生。
  • 它不是普通 map 的通用替代品。 官方文档建议只有在写入一次多次读取,或不同 key 被不同 goroutine 读写等场景中考虑 sync.Map;需要强类型和多个字段一起维护时,普通 map 配合锁通常更清楚。

我的结论:先问“谁拥有最终值”

现在遇到 LoadOrStore,我会先看两个问题:最终要使用哪个对象,以及这次调用是否拥有初始化责任。答案分别由 actual 和 loaded 给出:actual 是最终对象,loaded == false 才代表本次调用完成了首次存入。

如果只是做并发去重或缓存占位,这个 API 很顺手;如果还要处理初始化失败、重试、过期和关闭,就把这些状态显式建模,再让 Map 保存状态对象。这样既保留原子占位,也不会把“已写入”误认为“已完成初始化”。

相关问题

LoadOrStore 会覆盖已经存在的值吗?

不会。key 已存在时返回旧值并把 loaded 设为 true,传入值不会替换旧值。

为什么不能只调用 Load 再决定是否 Store?

因为两个调用之间存在竞争窗口,多个 goroutine 可能同时看到 key 不存在。LoadOrStore 把判断和写入合并成一次原子操作。

LoadOrStore 能保证初始化函数只执行一次吗?

不能直接保证。它只保证最终存入哪个值;如果候选值的构造本身很昂贵,应把初始化状态或一次性执行器作为值来管理。

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