Skip to content

修复 ObjectIDAllocator::alloc 中的线程安全隐患、误报 Bug 及冗余原子开销#514

Open
yuanjay07 wants to merge 2 commits into
BoomingTech:mainfrom
yuanjay07:fix-id-allocator-concurrency
Open

修复 ObjectIDAllocator::alloc 中的线程安全隐患、误报 Bug 及冗余原子开销#514
yuanjay07 wants to merge 2 commits into
BoomingTech:mainfrom
yuanjay07:fix-id-allocator-concurrency

Conversation

@yuanjay07

@yuanjay07 yuanjay07 commented Jul 13, 2026

Copy link
Copy Markdown

1. 修复线程安全隐患并采用 Relaxed 内存序优化

问题描述

原版代码将 ID 计数器 m_next_id 的读取和递增分为了两步执行:

std::atomic<GObjectID> new_object_ret = m_next_id.load(); // 步骤 1
m_next_id++; // 步骤 2

这两步操作在多线程环境下不具备原子性。如果线程 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 导致引擎无故崩溃。

修复方案与边界详解

改为基于当前线程本地持有的安全值进行对比:

if (new_object_ret >= k_invalid_gobject_id - 1)

该判断仅依赖于本线程私有栈上的 new_object_ret,杜绝了多线程插队带来的“虚假警报”风险,且省去了对原子变量的第二次同步读取开销。

💡 关于边界条件 - 1 的设计妙处(为什么-1不能少,以及为什么不能放在前面写成 + 1):

  1. 为什么 - 1 不能少?
    因为 new_object_ret 获得的是自增前的 ID。
    我们要判断的是自增后的值是否踩到了“雷区”(即自增后 >= k_invalid_gobject_id)。因此,只要自增前的值已经等于 k_invalid_gobject_id - 1,在完成自增后它就已经变成了无效 ID。故这里的 - 1 是边界判断中绝不可少的安全阈值。

  2. 为什么不能将减法移到等式左边,写成 if (new_object_ret + 1 >= k_invalid_gobject_id)?

    • 无符号数溢出回绕(Unsigned Wrap-around)漏洞
      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. 消除局部变量的冗余原子开销

问题描述

原代码中,用于暂存返回值的局部变量被声明为了原子类型:

std::atomic<GObjectID> new_object_ret = ...;

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;
 }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant