技术博客

多线程基础设施:WorkerThreadPool 与 TaskGroup

第 19 / 149 篇 · 2026-02-01 · godot · 引擎架构多线程源码分析

引擎里需要并发的场景很多:资源后台加载、物理 islands 并行求解、粒子模拟、纹理压缩。Godot 4.x 把它们统一收敛到一个设施:WorkerThreadPool(core/object/worker_thread_pool.h)。自己 Thread 满天飞是 3.x 的做法,4.x 的态度是——线程由池子管,任务由你交。这篇拆它的任务模型和调度逻辑。源码引自 4.2.2-stable。

三个抽象:Task、Group、NativeThreadTask

WorkerThreadPool 对外的接口分三档(worker_thread_pool.h):

class WorkerThreadPool {
    // 1. 独立任务:交了就不管,或事后 wait_for_task_completion
    TaskID add_task(const Callable &p_action, bool p_high_priority = false, const String &p_description = "");

    // 2. 任务组:n 个相似任务,一次 wait 全部完成
    GroupID add_group_task(const Callable &p_action, int p_elements, int p_tasks = -1, ...);

    // 3. 原生线程任务:跑在专属线程上,不占用池
    TaskID add_native_task(void (*p_func)(void *), void *p_userdata, ...);
};

三档对应三种并发需求:

  • Task:一次性杂活("后台解压这个 PCK");
  • Group:数据并行("对这 10000 个顶点做蒙皮",池子自动把元素切片分给多个 worker);
  • NativeThreadTask:长驻或需要精确线程亲和的活(音频线程、专用服务器网络线程),它绕过池子直接起线程,避免占住 worker 饿死其他任务。

add_group_task 的 p_tasks = -1 表示"按可用 worker 数自动分片"。分组后每个子任务拿到 [begin, end) 区间,业务回调自己循环处理。这比"每元素一个任务"省掉任务对象本身的分配开销——任务描述符是池化复用的,但调度元数据再轻也是有成本的。

内部结构:线程数组 + 两级队列

池子在 Main::start() 阶段初始化,worker 数默认是 min(物理核数, 项目设置 worker_thread_pool/worker_count),引擎内部默认取硬件并发数(OS::get_default_thread_pool_size())。

内部实现(worker_thread_pool.cpp)要点:

  • 每个 worker 线程一个 ThreadData,带自己的条件变量;
  • 任务队列分普通优先级和高优先级两级,p_high_priority = true 的任务插队先被取走。渲染管线在每帧末提交的资源上传任务走高优先级队列,避免被后台加载的长任务堵住;
  • Group 任务内部实现为"一个 Group 对象持 N 个子任务 + 完成计数",wait_for_group_task_completion 等的是计数归零;
  • 取任务用 Semaphore 唤醒而不是轮询:队列空时 worker 全部睡眠,零 CPU 空转。

调度不是工作窃取(work stealing)式的——队列是共享的,加锁取任务。这是刻意的简单选择:Godot 的任务粒度普遍较粗(毫秒级),队列竞争不是瓶颈,而 work stealing 的 per-worker deque 实现复杂度和 cache 一致性开销在这种粒度下收不回本。对比参照:Jolt 物理引擎的 JobSystem 是任务窃取式,因为物理约束求解的任务粒度细到微秒级,两种设计各配各的负载。

Group 的完成语义与等待开销

wait_for_group_task_completion(group_id) 的语义是阻塞调用线程直到组内所有子任务完成。这个"阻塞"有个重要细节:等待中的调用者如果自己就是 worker(嵌套提交),它会边等边从队列里偷任务执行,避免线程池死锁——这是 4.2 后加的"合作式等待"(yield while waiting),没有这个机制时,"worker 任务里等待另一个 group"在池子满员时会死锁:所有 worker 都在等,没人干活。

这个死锁模式值得记住,因为它是业务侧最容易踩的池化并发坑的引擎级复刻:

$$ \text{worker 数 } N,\quad \text{若 } N \text{ 个任务全部阻塞等待仅由池中其他任务完成的工作} \implies \text{死锁} $$

引擎的对策是等待时帮忙干活,业务侧的对策是避免在任务里同步等待——把依赖关系改成任务拆分(先交 A,A 完成后回调交 B),而不是 A 里等 B。

结构示意

WorkerThreadPool 的任务流 主线程 / 调用方 add_task / add_group_task 高优先级队列 普通队列 Worker 线程 × N N = 物理核数(可配置) Semaphore 唤醒,空转睡眠 共享队列,加锁取任务 三条使用纪律 1. 长驻循环别进池:用 add_native_task 或独立 Thread,别把 worker 占死 2. 任务里同步等待其他任务有死锁风险(4.2+ 的合作式等待缓解但不消灭) 3. Group 回调拿到的是元素区间 [begin, end),自己循环;别假设一元素一回调 引擎内部用户:资源线程加载、纹理压缩、粒子、导航烘焙、物理 islands 并行

GDScript 侧的完整用法示例

一个真实比例的并行模式——后台生成网格顶点数据,完成后回主线程挂节点:

var _task_id: int

func _start_generation() -> void:
    _task_id = WorkerThreadPool.add_task(_generate_mesh_data)

func _generate_mesh_data() -> void:
    # worker 线程:只做纯数据计算,不碰场景树
    var verts := PackedVector3Array()
    verts.resize(100000)
    for i in verts.size():
        verts[i] = _compute_vertex(i)
    # 回主线程交付结果
    call_deferred("_on_mesh_ready", verts)

func _on_mesh_ready(verts: PackedVector3Array) -> void:
    WorkerThreadPool.wait_for_task_completion(_task_id)  # 幂等保险
    var arrays := []
    arrays.resize(Mesh.ARRAY_MAX)
    arrays[Mesh.ARRAY_VERTEX] = verts
    var arr_mesh := ArrayMesh.new()
    arr_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
    $MeshInstance3D.mesh = arr_mesh

要点全在注释里:worker 线程上只有 PackedArray 和纯数学是安全的;ArrayMesh 的构建和节点赋值必须回主线程;call_deferred 是线程边界上合法的交付机制(MessageQueue 是线程安全的,见场景树篇的延迟调用篇)。

Group 任务在脚本侧的对应物是 WorkerThreadPool.add_group_task,回调签名是 func(index: int)——注意脚本层的 group task 是每元素一次回调(和 C++ 的区间回调不同),脚本 VM 的调度开销让细粒度分片不划算,脚本侧用它处理几百到几千个元素的任务合适,再细就该下沉 C++。

引擎内部的使用现状

翻一遍调用点能对池子的定位有实感:

  • ResourceLoader 的 load_threaded_request:后台加载走 add_task,每资源一个任务;
  • 纹理导入压缩(Basis Universal / BPTC 编码):group task,按 mip 层或分块并行;
  • 粒子系统:每粒子系统一个 group 任务分片;
  • SceneTree 的物理:islands(不连通的约束孤岛)之间无依赖,并行求解;
  • 编辑器资源导入:多文件并行导入。

一个反例也值得说:渲染主提交不进池。RenderingServer 的 RD::submit 这类调用必须在渲染线程(默认主线程)完成,池子管的是"可乱序的后台计算",不是"有时序的提交"。这也是为什么把渲染逻辑扔进 call_deferred + WorkerThreadPool 是错误姿势——下一篇讲服务器线程模式时会展开。

实战推论

  • GDScript 也能用:WorkerThreadPool.add_task(callable) 在脚本层可用,但 Callable 捕获的 GDScript 对象在 worker 线程上执行时,不能碰 SceneTree 和大多数引擎 API(它们非线程安全),只适合纯数据计算。要用脚本做多线程数据并行,优先 call_deferred 回主线程再碰节点。
  • 任务数 ≫ worker 数时才有收益。池子是吞吐量工具不是延迟工具,单个任务的调度延迟在几十微秒量级,比"直接在当前线程算了"贵。小于这个量级的活儿进池是纯开销。
  • 观察池子状态:Performance 监控器和编辑器 Debugger 看不到池内部,但 --verbose 启动日志会打印 worker 数;排查"后台加载慢"时先确认 worker 数没被项目设置压到 1。
  • 等待是幂等的:wait_for_task_completion 和 wait_for_group_task_completion 对已完成/无效 ID 安全返回,交付路径末尾补一次等待没有代价,能挡住"回调比预期早到"的时序边角。
  • 任务优先级不要乱标。高优先级队列是为"帧内必须完成"的任务设计的,把普通后台加载标高优先级会反过来饿死渲染提交,帧时间毛刺就是这么来的。

本文代码引自 Godot 4.2.2-stable 的 core/object/worker_thread_pool.h、core/object/worker_thread_pool.cpp、core/os/os.h(线程数探测),均有删减。