随机数生成器在多线程 Job 中的独立状态维护与确定性种子

封面信息图

在单线程游戏逻辑里,产生随机数通常只需要调用一次全局的 Random.Range 或者 C 标准库的 rand()。但在 Unity DOTS/ECS 和 C# Job System 这类面向数据的多线程并行模拟体系下,全局随机数生成器会瞬间成为两类致命 Bug 的策源地:多线程并发数据竞争(Data Race)网络同步确定性(Determinism)彻底丧失

当几十个工作线程(Worker Threads)在 IJobEntity 中并发访问同一个随机数内部状态种子时,不仅会引发昂贵的原子锁竞争或内存穿透,还会因为操作系统调度时序的微小抖动,导致不同客户端在同一逻辑帧产生完全不同的随机序列,让帧同步(Lockstep)与回放系统在第一秒内发生严重 Desync。


多线程伪随机数生成器的确定性危机

确定性模拟的核心法则:在相同的输入与相同的逻辑帧序号下,系统必须产生完全一致的状态输出

传统的 LCG(线性同余生成器)或 Mersenne Twister(梅森旋转算法)依赖一个隐式的内部状态迭代器:
$$X_{n+1} = (a X_n + c) \pmod m$$

如果多个线程共享这套迭代状态,先到达线程 A 消耗了 $X_1$,后到达线程 B 消耗了 $X_2$;下一局回放时由于 CPU 负载轻微变化,线程 B 先执行拿到了 $X_1$,线程 A 后执行拿到了 $X_2$。微观上的数值错位会随着物理模拟积分和战斗判定迅速放大为宏观灾难。

因此,在 Job System 中使用随机数,必须满足三个硬性约束:

  1. 零全局共享状态:每个并行实体(Entity)或每个 Job 线程必须持有独立的伪随机数生成器状态实例。
  2. 种子可追溯且无依赖:实体的局部随机种子必须由全局逻辑帧序号(Tick)、**实体全局唯一 ID(Entity ID)以及算法固定盐值(Salt)**通过哈希确定性推导得出。
  3. 极小内存开销与 SIMD 友好:随机数生成器内部状态不能超过 16 字节,能够直接嵌入 IComponentData 并不产生任何托管堆分配。
          ┌─────────────────────────────────────────────────┐
          │  帧序号 (Tick Index)  +  实体 ID (Entity ID)     │
          └────────────────────────┬────────────────────────┘
                                   │ MurmurHash3 / SplitMix64
                                   ▼
          ┌─────────────────────────────────────────────────┐
          │  确定性局部种子 (Deterministic Entity Seed)       │
          └────────────────────────┬────────────────────────┘
                                   │ 注入独立状态机
                ┌──────────────────┴──────────────────┐
                ▼                                     ▼
        ┌──────────────┐                      ┌──────────────┐
        │ Worker Job 0 │                      │ Worker Job 1 │
        │ (XorShift32) │                      │ (XorShift32) │
        └──────────────┘                      └──────────────┘

基于 XorShift / PCG 的无锁组件化实现

在实时渲染与大规模群体仿真中,Unity.Mathematics.Random(基于 Xoroshiro128+)是标准选择。但如果需要极小状态开销,我们可以实现一个轻量级的 32 位 XorShift 确定性随机数结构体:

using System;
using System.Runtime.CompilerServices;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;

// 嵌入 Entity 的无状态随机种子组件
public struct DeterministicRandomComponent : IComponentData
{
    public uint State;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static DeterministicRandomComponent Create(uint entityId, uint frameIndex, uint salt = 0x9E3779B9)
    {
        // 采用 MurmurHash3 风格的雪崩混淆,避免低位相关性
        uint h = entityId ^ (frameIndex * salt);
        h ^= h >> 16;
        h *= 0x85ebca6b;
        h ^= h >> 13;
        h *= 0xc2b2ae35;
        h ^= h >> 16;
        
        // 确保状态非 0(XorShift 状态为 0 会死锁)
        return new DeterministicRandomComponent { State = h == 0 ? 0x6a09e667 : h };
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public uint NextUInt()
    {
        uint x = State;
        x ^= x << 13;
        x ^= x >> 17;
        x ^= x << 5;
        State = x;
        return x;
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public float NextFloat(float min, float max)
    {
        // 将 32 位整数映射到 [0, 1) 的 IEEE 754 浮点数区间
        float normalized = (NextUInt() & 0x00FFFFFF) * (1.0f / 16777216.0f);
        return min + normalized * (max - min);
    }
}

ECS Job 中的并行随机调度模式

在多线程系统调度中,绝不能在 Job 内部使用 [NativeDisableParallelForRestriction] 去修改一个全局的 NativeArray<Unity.Mathematics.Random>。正确的做法有两种:

方案一:实体私有状态持有(适用于有连续随机上下文的实体)

每个 Entity 自带 DeterministicRandomComponent,Job 在遍历该实体时只修改属于自己的私有状态:

[BurstCompile]
public partial struct EntityStochasticSimulationJob : IJobEntity
{
    public float DeltaTime;

    private void Execute(ref DeterministicRandomComponent rng, ref DynamicVelocity velocity)
    {
        // 纯局部状态迭代,线程安全且完全确定
        float jitterX = rng.NextFloat(-1.0f, 1.0f);
        float jitterZ = rng.NextFloat(-1.0f, 1.0f);

        velocity.Value += new float3(jitterX, 0f, jitterZ) * DeltaTime;
    }
}
方案二:按 NativeThreadIndex 线程隔离生成器(适用于瞬态粒子/碎屑)

对于不需要持久化随机状态的瞬态效果(如爆炸碎屑初速度),可以预分配与工作线程数相等的随机数数组,利用 [NativeSetThreadIndex] 安全访问:

[BurstCompile]
public struct TransientDebrisSpawnJob : IJobParallelFor
{
    [NativeDisableParallelForRestriction]
    public NativeArray<Unity.Mathematics.Random> ThreadRandoms;
    
    [NativeSetThreadIndex]
    private int _threadIndex;

    public NativeArray<float3> OutputVelocities;

    public void Execute(int index)
    {
        // 提取当前线程独占的随机数实例
        var rng = ThreadRandoms[_threadIndex];
        
        OutputVelocities[index] = rng.NextFloat3Direction() * rng.NextFloat(5.0f, 15.0f);
        
        // 写回更新后的内部状态
        ThreadRandoms[_threadIndex] = rng;
    }
}

确定性验证与线上避坑军规

  1. 绝对禁止跨逻辑帧保留 ThreadIndex 随机状态:线程分配是由 OS 内核线程池动态决定的,同一实体在第 10 帧由 Thread 2 处理,在第 11 帧可能被调度到 Thread 5。如果随机数状态绑定在 Thread 上,不同实体的随机序列就会被乱序交叉污染。
  2. 随机数调用次数必须严格确定:在条件分支(if-else)中消费随机数时必须极其谨慎。如果逻辑分支依赖了未同步的本地表现层数据(如当前摄像机距离),就会导致不同端调用的随机数次数不一致,从而污染后续所有逻辑判定的随机数序列。
  3. 浮点数截断必须跨平台统一:不同平台(ARM64 vs x86_64)的 FMA 指令集可能对 NextFloat() 计算产生 1 ULP(最小精度单位)的微小差异。在严格帧同步项目中,建议所有逻辑判定直接基于 uint 范围判定(如判定概率 30% 即 NextUInt() < (uint.MaxValue * 0.3)),彻底规避浮点数跨平台精度陷阱。

让每一个并发线程拥有清晰独立的状态空间,用确定性哈希锚定种子来源,多线程的高性能与网络同步的确定性才能在同一个引擎架构中和谐共存。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐