上一篇 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_list 或 err_list 里; 循环里没有 erase + insert, 没有 push_back(*it), 也没有人显式 copy KeyEvictInfo. 如果只盯着循环体, 这段代码确实像是安全的. 但问题恰恰就出在这种“看上去全程都在 splice”的错觉上: 真正把 iter_ 搞坏的, 不是循环, 而是最后那句 return std::make_pair(ok_list, err_list);。
真正的问题在 return
ok_list 和 err_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 这种“编译通过也照样能把程序跑坏”的问题.