引擎里需要并发的场景很多:资源后台加载、物理 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。
结构示意
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(线程数探测),均有删减。