← 文章列表

unordered_map 查得到却取错值,一次静态 lambda 的内存事故

有一种 C++ 问题特别容易把人带偏:unordered_map::find 明明命中了,it->first 也对,可你再看 it->second 里面的字段,像是变成了另一个 key 的数据——这种现象往往让人先怀疑容器本身。

第一反应通常是怀疑 map 坏了,或者 hash 冲突把数据弄乱了。可 unordered_map 没那么玄。查找命中以后,key 和 value 对不上,更常见的原因不是容器失灵,而是你在更新 value 的时候已经写错了地方。

这篇记录的坑,是函数内部的 static lambda 第一次初始化时,通过 [&] 捕获了 current_input 所引用的对象。闭包会被后续调用复用,却不会重新绑定捕获;如果后续输入对象不同,它仍会读第一次调用的对象。对象还活着时会造成旧数据写入,对象已销毁后仍被访问才会形成悬空引用并触发未定义行为。

本文目录

先看那个很像优化的小动作

问题代码大概长这样:

void UpdateCache(std::unordered_map<std::string, Tick>& cache, Tick& current_input) {
    static auto patch = [&](auto it) {
        it->second.price = current_input.price;
        it->second.volume = current_input.volume;
    };

    auto it = cache.find(current_input.symbol);
    if (it != cache.end()) {
        patch(it);
    }
}

写这段代码的人可能不是想偷懒,而是想少创建一次 lambda。patch 看起来只是一个小更新函数,放成 static 好像还能省点开销。

真正危险的是这两个东西叠在一起:

  • static 让这个 lambda 只初始化一次,并且活到进程结束;
  • [&] 让它捕获 current_input 所引用的实体;引用捕获不会延长该对象的生命周期。

第一次调用 UpdateCache 时,patch 被初始化。按 C++ 的引用捕获规则,闭包里的 current_input 指向第一次调用时参数所引用的 Tick 对象,而不是把函数参数这个引用变量本身存进闭包;后续调用也不会重新绑定它。

函数返回后,第一次传入的 Tick 对象是否仍然存活,取决于调用方的对象生命周期。第二次调用时,patch 仍会读取第一次传入的对象:若它还活着,更新会使用过期输入;若它已销毁,再访问才是未定义行为。C++ 工作草案明确规定,对按引用捕获的引用进行 odr-use 时,表达式指向的是该引用所绑定的实体。C++ 工作草案:Lambda captures

为什么现象会像 map 查错了

这个 bug 迷惑人的地方在于,find 可能一直是对的。

你用当前输入的 symbol 去查 map,查到了正确的 iterator。到这一步,容器没有错。错发生在后面的更新函数里:它读到的 current_input.price、current_input.volume 仍来自第一次调用的对象;若该对象已结束生命周期,读取还会触发未定义行为。

如果那块失效栈内存刚好被复用了,你看到的现象就会很奇怪:

  • key 是 A;
  • iterator 也确实指向 A;
  • 但写进去的字段像来自 B;
  • 偶尔还会因为栈布局变化表现不稳定。

这就很容易让人误判成“map 里缓存污染了”。问题不是 map 自己产生的,而是更新逻辑一直读取第一次调用的对象;对象失效后继续读取,才会变成悬空引用问题。

更糟的是,这类问题不一定马上崩。它可能只是偶尔错字段、偶尔错价格、偶尔在高并发或不同编译优化下变得更明显。越不像崩溃,越容易被当成业务数据问题。

不要让 static 闭包捕获当前调用的数据

这条经验可以说得很直接:不要把依赖当前调用输入的带引用捕获 lambda 保存为 static。如果要复用更新逻辑,就让闭包无状态,并通过参数传入依赖。

安全写法是让 lambda 无状态,依赖都通过参数传进去:

void UpdateCache(std::unordered_map<std::string, Tick>& cache, const Tick& current_input) {
    auto patch = [](auto it, const Tick& src) {
        it->second.price = src.price;
        it->second.volume = src.volume;
    };

    auto it = cache.find(current_input.symbol);
    if (it != cache.end()) {
        patch(it, current_input);
    }
}

如果真想避免重复写这段更新逻辑,可以把它提成普通函数,或者写成不捕获任何局部状态的 lambda:

static auto patch = [](auto it, const Tick& src) {
    it->second.price = src.price;
    it->second.volume = src.volume;
};

关键不在于 static 这个词绝对不能出现,而在于它不能把短生命周期对象悄悄带进长生命周期闭包里。

排查时先证明 key 和 value 的身份一致

遇到“查到了但值不对”的问题,我现在会先加几个很笨但有效的检查。

比如更新前后都确认身份:

auto it = cache.find(current_input.symbol);
assert(it != cache.end());
assert(it->first == current_input.symbol);

patch(it, current_input);

assert(it->first == it->second.symbol);

这些断言不是为了修 bug,而是为了缩小现场:到底是 find 找错了,还是更新以后 value 被写错了。

测试环境还应该打开工具:

  • AddressSanitizer 用来抓失效栈内存访问;
  • UndefinedBehaviorSanitizer 用来抓未定义行为;
  • 如果和线程有关,再补 ThreadSanitizer。

如果 sanitizer 一开就报 use-after-scope 或类似问题,就别再围着 hash、bucket、rehash 打转了。容器通常只是案发现场,不一定是凶手。

真正的教训

这类问题的教训不只是“不要写 static lambda”。

更准确地说,是不要让一个长生命周期对象,偷偷依赖调用期数据。static、全局回调、缓存下来的函数对象、异步任务,都会把这个问题放大。

C++ 里很多诡异现象,本质上不是语法难,而是生命周期边界没有写清楚。代码表面看起来像一次小优化,实际可能把第一次调用的输入对象固定进进程级闭包里。

所以以后再遇到 unordered_map 查到了但 value 像串号,我会先问一句:

是不是有人在更新 value 的时候,拿了一个已经不该存在的引用?

这比先怀疑容器本身靠谱得多。

参考资料

写作附记

原始提示词

原始提示词未留存;本文依据现存的问题描述、示例代码和修复方向整理。

目录

评论

评论区将在滚动到此处后加载。