修复 ObjectIDAllocator::alloc 中的线程安全隐患、误报 Bug 及冗余原子开销#514
Open
yuanjay07 wants to merge 2 commits into
Open
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
1. 修复线程安全隐患并采用 Relaxed 内存序优化
问题描述
原版代码将 ID 计数器 m_next_id 的读取和递增分为了两步执行:
这两步操作在多线程环境下不具备原子性。如果线程 A 在执行完步骤 1 拿到值后、还未执行步骤 2 之前发生线程切换,线程 B 进来也会读取到相同的 m_next_id 值。这会导致不同的游戏对象被分配到完全相同的 ID,使原子性设计失效。
修复方案
使用 std::atomic 的原子自增方法 fetch_add,并显式配合 std::memory_order_relaxed (松散内存序) 进行自增并获取旧值:
GObjectID new_object_ret = m_next_id.fetch_add(1, std::memory_order_relaxed);这能确保整个分配过程绝对原子化,消除了多线程并发下的 ID 重复风险。由于 ID 分配器仅需要计数器自身原子的递增,无需在多线程间建立 acquire-release 的排序依赖,因此使用 relaxed 内存序能实现极致的并发性能。
2. 修复溢出检测的“虚假警报”
问题描述
原版代码在自增后,直接读取原子变量进行溢出检测:
if (m_next_id >= k_invalid_gobject_id)在多线程高并发下,这行代码在执行时,m_next_id 的值可能已经被其他并发线程进一步递增了。这会导致一个分配到安全 ID 的线程在执行该判断时,误以为自己超限,从而触发 LOG_FATAL 导致引擎无故崩溃。
修复方案与边界详解
改为基于当前线程本地持有的安全值进行对比:
该判断仅依赖于本线程私有栈上的 new_object_ret,杜绝了多线程插队带来的“虚假警报”风险,且省去了对原子变量的第二次同步读取开销。
💡 关于边界条件 - 1 的设计妙处(为什么-1不能少,以及为什么不能放在前面写成 + 1):
为什么 - 1 不能少?
因为 new_object_ret 获得的是自增前的 ID。
我们要判断的是自增后的值是否踩到了“雷区”(即自增后 >= k_invalid_gobject_id)。因此,只要自增前的值已经等于 k_invalid_gobject_id - 1,在完成自增后它就已经变成了无效 ID。故这里的 - 1 是边界判断中绝不可少的安全阈值。
为什么不能将减法移到等式左边,写成 if (new_object_ret + 1 >= k_invalid_gobject_id)?
GObjectID 在底层是 std::size_t,这是一个无符号整型。
假设在极端情况下,new_object_ret 已经分配到了无符号整型的最大值(即 std::numeric_limitsstd::size_t::max())。
如果我们在左侧执行 new_object_ret + 1,在计算机中这会发生整型溢出回绕,结果直接变成了 0!
那么判断条件就变成了 0 >= k_invalid_gobject_id(显然不成立),从而让这个已经超限的非法 ID 完美漏掉了 LOG_FATAL 拦截。
将运算移到右侧写成 k_invalid_gobject_id - 1,由于 k_invalid_gobject_id 是最大值,减 1 的操作是绝对安全的,不会发生任何溢出和回绕,完美规避了无符号数加法边界带来的隐患。这是经典的防御性编程技巧。
3. 消除局部变量的冗余原子开销
问题描述
原代码中,用于暂存返回值的局部变量被声明为了原子类型:
new_object_ret 是一个分配在线程私有栈上的局部变量,生存周期仅在 alloc() 函数体内部,完全不存在多线程竞争。将其声明为 std::atomic 会带来不必要的原子包装开销。
修复方案
将局部变量类型还原为普通无符号整数类型 GObjectID(即 std::size_t),去除无谓的性能开销。
修改前后代码对比 (Diff)
GObjectID ObjectIDAllocator::alloc() { - std::atomic<GObjectID> new_object_ret = m_next_id.load(); - m_next_id++; - if (m_next_id >= k_invalid_gobject_id) + // 使用松散内存顺序(std::memory_order_relaxed)执行原子自增并返回旧值,性能更优 + GObjectID new_object_ret = m_next_id.fetch_add(1, std::memory_order_relaxed); + + // 由于 new_object_ret 是自增前的值,判断自增后的值(new_object_ret + 1)是否溢出, + // 等价于判断 new_object_ret 是否大于等于 k_invalid_gobject_id - 1 + if (new_object_ret >= k_invalid_gobject_id - 1) { LOG_FATAL("gobject id overflow"); } return new_object_ret; }