CD Code Digest Hub /GTA V

多线程、任务调度与帧内依赖

源码静态分析,2026-10-30。范围:base/src/system,结合 Framework 场景扫描、Suite 动画和 GTA 帧循环。未编译或运行;数字仅代表所引用分支的源码配置。总入口:Summary。

14 分钟阅读 11 个章节 2 张图

1. 设计核心:异步执行必须连同完成条件一起设计#

RAGE 并非只有一个覆盖全部工作的 Job System。这里同时存在操作系统线程与同步封装、带句柄的 sysTaskManager、依赖节点调度器 sysDependencyScheduler、可取消的 sysThreadPool,以及音频、资源 placement、渲染等专用线程。它们解决的工作粒度、平台约束和生命周期不同。

这一设计的主要优势是让紧密依赖平台的工作有明确执行位置,把高频数据计算做成有限输入输出的任务,再由帧阶段负责汇合。代价是线程数、优先级、亲和性、队列容量及等待关系分散在多个系统中;增加某一类 worker 并不必然缩短整帧。

机制适合的工作完成与存活契约
sysIpc*、critical section、event、semaphore长寿命服务与平台同步创建者负责退出、唤醒及 join
sysTaskManager参数化异步计算,兼容 SPU 风格任务Create 后通过 Poll/Wait 处理句柄;参数与缓冲区活到任务结束
sysDependencyScheduler动画、可见性等有依赖的细粒度计算pending 归零后触发后续节点;图内存不能提前释放
sysThreadPool::WorkItem有对象状态、可能需要取消的后台工作Cancel 不等于完成;Pending 为 false 才允许销毁
专用线程读盘、音频、渲染、资源装配各自有提交、完成、主线程发布或帧边界协议

2. sysTaskManager:固定句柄池与消息队列#

task_win32.cpp 中句柄由 Header + Completed event 组成;静态句柄数组和 free-list 避免每次提交都构造堆对象。普通分支是 MaxTasks=256、MaxSchedulers=8,HACK_GTA4 分支另有数值,不能把其中一个上限说成所有 RAGE 平台的固定能力。

每个 scheduler 持有 worker 数组及 sysMessageQueue<handle*, MaxTasks>。线程依据 CPU mask 创建,各自持有 scratch。CheckParamCompatibility 对任务 scratch 要求做检查;64 位路径会扩大 scratch/stack,说明同一任务描述并不意味着跨平台有相同内存成本。

分配、提交和回收路径可见 s_TaskToken 临界区。因此这个接口不应笼统描述成“全无锁任务系统”。大批极小任务可能把有效计算变成句柄管理、排队与 event 的开销;需要实测合批粒度,而不是越细越好。

关闭时先向队列投递空句柄,再等待所有线程退出,最后才销毁队列。这是一个很实用的生命周期规则:消费者仍可能读取队列时,不能先释放队列本身。任务取消、资源卸载、场景退出都应遵守同类规则。

3. 依赖节点的方向:孩子先算,父亲等待#

sysDependency 是 16 字节对齐的工作包,携带回调、10 个参数槽、8 个数据尺寸槽、输入输出标志和 pending 计数。这里的“parent”不是通常流程图中先执行的前驱:parent.AddChild(child) 会记录 child.m_Parents[Strong]=parent,并增加 parent 的 pending;child 完成后减少 parent 的 pending。

每个节点最多保存两个向上连接,分别是 strong 和 weak 槽。weak 仍增加等待计数,不能将其解释成“不参与同步”。这不是任意入度/出度的通用 DAG 数据结构;多处共享结果或多级合流必须按此模型组织。

这是对依赖方向的示意,不是固定的实际动画图。Insert 明确断言只有 m_NumPending == 0 的节点能直接入队。因此若把调用方的树边方向理解反了,任务可能永远没有可运行起点。

4. 调度细节:四级共享队列与就地继续执行#

Chain 包含四条 sysLockFreeRing<sysDependency, 2048>,分别服务 low、medium、high、critical。Pop 从高到低扫描;Push 失败会循环重试。

worker 执行过程如下:

  1. 从共享链取最高优先级就绪任务。
  2. 执行前先复制两个 parent 指针,避免回调结束后的节点生命周期变化影响遍历。
  3. 若需要 ALLOC0,把第一个参数指向该线程的 scratch。
  4. 调回调;成功时原子减少 parent.pending。
  5. 刚变为 ready 的 parent 若优先级不低于当前任务,可直接在本 worker 上继续;其余重新入队。
  6. 回调返回 false,则按比节点声明优先级低一级的队列重新入队。

第 5 步减少队列往返并有利于缓存延续。源码注释用了 “steal next a parent”,但这里不是经典的“每线程双端队列+从别的 worker 尾部窃取”算法。准确说法是 共享优先级队列+就绪父节点的本地续跑。

失败重排仅改变这次 chain.Push 的优先级参数,没有在此处修改 dep->m_Priority。因此不能写成“每次重试持续降级直到最低级”。也看不到在这段代码中保证重试次数、超时或公平老化的机制。

5. 唤醒和容量不是实现杂项#

期望活跃 worker 数按 ceil(pending / 3) 估算,critical 任务额外增加一个,再由最大 worker 数截断。worker 空闲时先原子清除 active、再次 Pop,仍无任务才等 event;这次二次检查是避免提交与休眠之间丢失工作的重要窗口控制。

PC 配置给出最多 64 个 worker 存储槽,实际活跃表达式为 max(GetNumThreads()-2, 1);非 PC 配置为 4。这里的 64 是结构容量,不等于游戏总线程数,更不等于每次都启动 64 个线程。初始化中 PC 一般分支的亲和性还用 (i % 5) + reserved,而特定主机分支使用固定映射。直接套在现代多 CCD、混合大小核 CPU 上,需要重新审核拓扑与操作系统调度关系。

64 位 worker scratch 配置是 96 KiB,其他路径 24 KiB。BANK 构建允许超大需求走堆回退,其他路径有断言或终止约束。由此可推导一个测试要求:开发版本能够完成的大任务,不足以证明相同内容满足发行版的 scratch 上限。

队列满时 Push 自旋,属于容量压力下的主动等待。若生产者本身占据了所有可消费的执行资源,可能形成严重的进展问题;本次未构造运行案例,不能据此宣称现有游戏必然死锁。新系统应明确选择扩容、帮助执行、限流或拒绝提交,并记录队列高水位与满队列停留时间。

6. 无锁 ring 的真实契约#

sysLockFreeRing 使用 head/tail CAS 争夺槽位,每槽 sequence 区分可写和可读世代,槽数要求是 2 的幂。写入指针后发布 sequence,消费后推进 sequence 使槽位重新可写。

IsEmpty() 仅比较 head 与 tail,注释明确说不是百分之百可靠。它只能作为快速探测,不能独立承担完成判断。类名也不证明任意调度组合都是 wait-free:CAS 竞争、满队列自旋和线程抢占仍会让单个调用等待很久。

移植时要审核 sysInterlocked* 在目标平台的 acquire/release 或更强屏障语义,不应机械地把 volatile 换成普通字段。sequence 的发布顺序、任务输出写入与 pending 原子递减之间的可见性共同决定正确性。

7. 帧循环中的并行与汇合#

GTA 的 CApp 先执行 CSystem::BeginUpdate、CGame::Update,再进入渲染线程协作阶段。GPU_IdleSection 和 Synchronise 后,输入更新与 CGame::Render 的绘制列表/上下文构建可以重叠;随后等待输入,最后还有有条件的音频 FinishUpdate。

图中任务提交表示系统协作,不意味着所有 W 任务都在同一位置提交或只在帧尾等待。应针对每个消费者查自己的 join,不能从入口调用顺序推出整帧没有并行,也不能从存在线程推出各阶段完全重叠。

尤其需要修正初步目录报告可能带来的印象:game/system/TaskScheduler.cpp:17 将 USE_TASK_SCHEDULER 定义为 __XENON。非该分支 AddTask 直接调用函数,Flush/Wait/StartBatching 对应代码被条件编译移除。因此 CApp::StartBatchingTasks(5) 不是 PC 上启用通用批调度的证据;PC 并行应从 RAGE task/dependency、各专用线程和真实调用点证明。

8. 场景扫描展示了可复用的阶段协议#

fwScan 有 pending/current 两组结果,StartScan 提交依赖,JoinScan 等待完成并转交结果。场景图还有 LockSceneGraph/UnlockSceneGraph;它们只是普通整数递增递减与断言,不提供互斥和跨线程发布。

这种设计依赖“扫描阶段禁止结构修改、结果完成后再被消费”的上层协议。它降低了遍历时每实体加锁的成本,但意味着实体删除、容器扩容、场景卸载必须在合法窗口进行。Lock 命名不能替代线程安全审计。

资源系统则展示另一种协议:后台 placement 完成后,主线程执行 SetResource,再标记 Loaded。两者共同说明,异步系统的最终边界是 数据何时对其他模块合法可见。

9. 限制与新项目设计建议#

源码特征收益需要显式处理的限制新项目可借鉴的做法
有界工作包与固定 scratch少分配,内存成本可预测大任务、深图、资源峰值可能越界在内容构建时估算,运行时记录高水位
高优先级先取降低关键任务延迟低优先级长期等待,不能证明公平增加截止时间、等待年龄或预算,而非无限抬优先级
同 worker 续跑父节点缓存延续、减少排队一条长链限制并行宽度同时查看关键路径长度与 ready 宽度
多套线程服务工作隔离,平台适配清晰过量线程、跨队列优先级倒置统一 CPU 预算与追踪 ID,允许专用 IO 服务
阶段保护和快照热路径免细粒度锁不允许任意并发结构修改定义读写阶段,延迟销毁,显式发布 fence
协作式取消不粗暴中断不可中断操作Cancel 后仍会访问对象取消令牌与完成令牌分离

现代项目不必复刻 SPU job 格式,但应保留“输入输出边界、临时内存预算、完成责任人”。若引入 fiber 等待,重点验证 worker 等待自己依赖的子任务时仍能推进,而不是仅更换任务 API。

10. 推荐验证实验(本次未运行)#

  • 固定总计算量,改变任务数和批大小:测提交 CPU 时间、队列等待、scratch 高水位与帧 p99,找到合理粒度。
  • 给动画、物理、扫描分别制造长链和宽图:区分“核心不够”和“依赖不能并行”。
  • 让 placement、音频与动画同时到达峰值:记录实际 runnable 线程数、核亲和性及等待谁。
  • 在取消、卸载和场景退出时反复切换:检查 Pending 期间的对象生命期、队列销毁顺序和资源引用。
  • 比较开发/发行配置的 scratch 与统计开销;不使用 BANK 能成功作为发行安全证明。

11. 源码证据与阅读顺序#

文件关键位置与证据
dependency.h24–94 工作包;134–147 child/parent 计数方向
dependencyscheduler.cpp35–91 四级 ring;201–253 入队/唤醒;289–419 执行与续跑
dependency_config.h10–43 平台、worker 和 scratch 配置
lockfreering.h15–137 有界 FIFO、CAS、sequence、IsEmpty 契约
task_win32.cpp46–177 句柄池、队列、关闭;508–650 创建、回收、等待
threadpool.h118–165 取消与 Pending/OnFinished 生命周期
TaskScheduler.cpp17、47–130 Xenon 分支与同步回退
app.cpp1344–1384 帧循环与同步点
Scan.cpp124 起任务输入;243–299 Start/Join 与结果交接
SceneGraph.h128–131 阶段计数

关联:流式加载、动画、物理、性能。