Mooncake Evict: 一次 std::make_pair 让 iter_ 悄悄失效

Posted by w@hidva.com on April 22, 2026

上一篇 Mooncake 统一内存池:从默认 Evict 到 Linux Reclaim 主要讲的是 unify memory pool 下 evict 的整体语义、冷热链表、以及为什么要借 Linux reclaim 的职责划分. 但那篇文章里有一条工程红线, 我当时只是一笔带过: KeyEvictInfo.iter_ 必须始终指向它当前所在 list 的 iterator. 只要这条不变量破了, 后面 EraseEvictInfo()PromoteEvictInfo()ShrinkActiveList() 这些依赖 iter_ 的操作都会出问题.

这条红线看上去并不复杂. std::list 节点在不同 list 之间搬迁时, 只要坚持使用 splice, iterator 就会继续指向原 node. 所以在 Review AI 生成的 SwapOut() 时, 也理直气壮地觉得这段代码肯定没问题:

auto MasterService::SwapOut(KeyEvictInfo::ListType& pre_evict)
    -> std::pair<KeyEvictInfo::ListType, KeyEvictInfo::ListType> {
    KeyEvictInfo::ListType ok_list;
    KeyEvictInfo::ListType err_list;
    while (!pre_evict.empty()) {
        auto current = pre_evict.begin();
        const auto& einfo = *current;
        auto res = TryRemoteSwapOut(einfo);
        if (!res) {
            res = TryOffloadSwapOut(einfo);
        }
        if (res.has_value()) {
            ok_list.splice(ok_list.end(), pre_evict, current);
        } else {
            err_list.splice(err_list.end(), pre_evict, current);
        }
    }
    return std::make_pair(ok_list, err_list);
}

毕竟确实 pre_evict 里的每个 item 都是通过 splice 挪到 ok_listerr_list 里; 循环里没有 erase + insert, 没有 push_back(*it), 也没有人显式 copy KeyEvictInfo. 如果只盯着循环体, 这段代码确实像是安全的. 但问题恰恰就出在这种“看上去全程都在 splice”的错觉上: 真正把 iter_ 搞坏的, 不是循环, 而是最后那句 return std::make_pair(ok_list, err_list);

真正的问题在 return

ok_listerr_list 是两个左值. std::make_pair(ok_list, err_list) 并不会返回 pair<list&, list&>, 而是先做一轮 decay, 最终构造出一个 pair<list, list>. 于是这里发生的就不是“把两个 list 的所有权挪进返回值”, 而是“用两个左值 list 去构造返回值里的两个 list 成员”. 对 std::list 来说, 这一步的语义是 copy, 不是 move.

也就是说, 这段代码真正做的事情更接近:

return std::make_pair(ok_list, err_list);  // copy list
// 而不是
return std::make_pair(std::move(ok_list), std::move(err_list));  // move list

我把这件事一路追到了反汇编. 不贴大段汇编了, 真正决定性的其实就下面这几行符号:

std::make_pair<list&, list&>(list&, list&)
std::pair<list, list>::pair<list&, list&, true>(list&, list&)
std::get<0>(std::pair<...>&&)
std::get<1>(std::pair<...>&&)

前两行已经足够说明问题了: make_pair 收到的是两个左值引用, 最终构造的是 pair<list, list>, 也就是 copy 进返回值. 后面调用方看到的 std::get<0> / std::get<1>, 只是把那个返回出来的 pair<list, list> 再拆开用而已.

这次 bug 最坑的一点在于, 它不是那种“看代码就能一眼看出哪里 copy 了元素”的问题. list 里装的是 std::shared_ptr<KeyEvictInfo>. 当 ok_list / err_list 被 copy 到返回值里时, shared_ptr 确实还是指向同一批 KeyEvictInfo; KeyEvictInfo 自己也没有被复制. 所以如果只盯着“payload 有没有被 copy”, 很容易误判成无事发生.

KeyEvictInfo.iter_ 缓存的从来都不是 shared_ptr 的地址, 而是它所在 list node 的 iterator. std::list 一旦 copy, 新 list 会重新分配一批 node, 再把原 list 里的元素逐个复制过去. 于是复制之后虽然元素值一样, shared_ptr 里指向的对象也一样, 但 node 已经不是同一批 node 了. iter_ 里记着的还是 callee 栈上 ok_list / err_list 那批旧 node 的 iterator. 等 SwapOut() 返回, 这两个局部 list 析构, iter_ 就当场悬空了.

所以这次问题根本不是 shared_ptr 生命周期不够长, 也不是 KeyEvictInfo 自己被拷坏了. 真正失效的是 list node identity. 这恰好是最不容易被肉眼直接看出来的那一层.

又一次不够所见即所得

我之前在 小心! 编译器会创建临时对象 里就吐槽过, C++ 很多时候并不所见即所得. 代码表面上只是把一个 const& 传下去, 编译器却会悄悄先给你造个临时对象. 这次也差不多. 你肉眼看到的是“循环里全是 splice, 没有 copy”; 真正出问题的却是 return 路径上那层很不显眼的模板推导与值类别规则.

我在 C++: is_move_constructible 里还专门感叹过一句: “C++ 并没有过真正的 move 语义”. 这次也正好又印证了一遍. 你明明觉得“我就是想把两个局部 list 返回出去”, 但只要写法落成了两个左值传给 make_pair, 最终就还是 copy. 代码字面上并不会把这个语义落差直接写在你脸上.

再往前在 C++ 的心智负担 – Integral promotion 里, 我也说过 C++ 有太多这种需要额外背规则的地方. 这次的 std::make_pair(ok_list, err_list) 其实也属于同一类心智负担: 编译通过, 肉眼扫过去也像对的, 但语义是否安全取决于你是否记得“左值进 make_pair 不是 move, list copy 会复制 node, iterator 缓存的是 node 身份”.

所以我在 Mooncake 统一内存池:AI Vibe Coding 与 Rust 末尾说 C++ 不太适合 AI, 现在看又多了一个现成例子. 这类问题的麻烦不在于它有多高深, 而在于规则太细, 太隐式, 太不写在表面上. 人会忘, AI 更容易在上下文不完整时踩坑. 尤其这里受影响的又恰好是 iterator invalidation 这种“编译通过也照样能把程序跑坏”的问题.