关于 multithreading
面向使用 GDScript 或 C# 的 Godot 开发者的多线程技能,讲解如何将高开销工作移出主线程,并避免破坏场景树或共享数据。
主要功能
- 01WorkerThreadPool 任务模式
- 02Thread、Mutex 和 Semaphore 指引
- 03延迟执行场景树更新
- 04多线程资源加载
- 05C# Task 与异步处理指引
适用场景
- 01并行处理开销较大的实体逻辑
- 02加载资源以避免阻塞帧
- 03安全地将工作线程结果交回场景树
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ jame581/ godotprompter --skill multithreading安装后在 Claude Code 中输入/multithreading确认已加载。
附带文件references/pitfalls.md
详细介绍
多线程
将高开销工作放到主线程之外运行,同时避免破坏场景树。短期并行任务优先使用 WorkerThreadPool;只有需要长期运行的工作线程时,才使用 Thread/Mutex/Semaphore。
**相关技能:**在启用多线程前进行性能分析,请参阅 godot-optimization;有关资源导入,请参阅 assets-pipeline;有关 C# 的具体说明,请参阅 csharp-godot;有关 async/await 的陷阱,请参阅 gdscript-advanced。
1. 线程模型与安全规则
主线程拥有场景树——与活动场景树交互不是线程安全的。 请遵循以下基于文档的规则:
- Servers(RenderingServer、PhysicsServer)只有在项目设置中启用线程支持后才是线程安全的(
Rendering > Driver > Threads > Thread Model = Separate——在 4.3 中为Multi-Threaded,且属于实验性功能;Physics > {2D,3D} > Run on Separate Thread)。Servers 能很好地处理由线程驱动的数千个实例。 - NavigationServer2D/3D 是线程安全且适合多线程使用的(支持真正的并行查询);可调整
Navigation > Pathfinding > Max Threads(4.4+)。 - AStar2D/3D/Grid2D 不是线程安全的——每个对象只能由一个专用线程使用;在多个线程间共享同一个对象会破坏数据。
- **GDScript
Array/Dictionary:**跨线程读取/写入现有元素没有问题;调整大小(添加/移除)需要使用Mutex。 - 不要在主线程以外执行 GPU 工作(创建纹理、读取/修改图像)——这会导致 RenderingServer 同步停顿。
- 在工作线程中在线程树外构建场景块,然后在主线程中通过
add_child.call_deferred()添加——仅限单个加载器线程(多个线程可能修改同一个缓存资源,导致崩溃)。
**黄金法则:**只在主线程中修改场景树。从工作线程将结果通过
call_deferred/set_deferred传回。
2. WorkerThreadPool(首选)
WorkerThreadPool 是一个全局单例,线程在启动时分配。常规任务(add_task)由一个工作线程运行;组任务(add_group_task)会将任务分配给多个工作线程,对每个元素索引重复调用 Callable——非常适合遍历大量元素。每个任务都必须等待完成(wait_for_task_completion / wait_for_group_task_completion),否则其已分配的资源会泄漏。分发开销低的工作可能会降低性能——只有在工作确实开销较大时才使用它。
GDScript
var enemies = [] # Filled with enemies elsewhere.
func process_enemy_ai(enemy_index):
var processed_enemy = enemies[enemy_index]
# Expensive per-enemy logic...
func _process(delta):
var task_id = WorkerThreadPool.add_group_task(process_enemy_ai, enemies.size())
# ... other main-thread work ...
WorkerThreadPool.wait_for_group_task_completion(task_id)
# Safe to read results now.
C# 等效写法
private List<Node> _enemies = new(); // Filled with enemies elsewhere.
private void ProcessEnemyAI(int enemyIndex)
{
Node processedEnemy = _enemies[enemyIndex];
// Expensive per-enemy logic...
}
public override void _Process(double delta)
{
long taskId = WorkerThreadPool.AddGroupTask(Callable.From<int>(ProcessEnemyAI), _enemies.Count);
// ... other main-thread work ...
WorkerThreadPool.WaitForGroupTaskCompletion(taskId);
// Safe to read results now.
}
这依赖于多线程处理期间元素数量保持不变。
3. Thread / Mutex / Semaphore
实际签名:Thread.start(callable: Callable, priority := PRIORITY_NORMAL)、wait_to_finish()(会阻塞;释放前必须先等待线程结束)、is_alive()。Mutex 是可重入的(lock/unlock/try_lock)。Semaphore 提供 wait() / post()(4.4 起支持 post(count := 1))。
GDScript
规范的信号量生产者/消费者与安全关闭惯用写法:
var counter := 0
var mutex: Mutex
var semaphore: Semaphore
var thread: Thread
var exit_thread := false
func _ready():
mutex = Mutex.new()
semaphore = Semaphore.new()
thread = Thread.new()
thread.start(_thread_function)
func _thread_function():
while true:
semaphore.wait() # Block until there is work.
mutex.lock()
var should_exit = exit_thread
mutex.unlock()
if should_exit:
break
mutex.lock()
counter += 1
mutex.unlock()
func increment_counter():
semaphore.post() # Wake the worker.
func _exit_tree():
mutex.lock()
exit_thread = true
mutex.unlock()
semaphore.post() # Unblock so it can see exit_thread.
thread.wait_to_finish() # Join.
C# 等效写法
Godot.Mutex/Godot.Semaphore 也存在,但在 C# 中通常使用 System.Threading:
using Godot;
using System.Threading;
public partial class Worker : Node
{
private int _counter;
private readonly object _lock = new();
private readonly SemaphoreSlim _semaphore = new(0);
private Thread _thread;
private volatile bool _exitThread;
public override void _Ready()
{
_thread = new Thread(ThreadFunction) { IsBackground = true };
_thread.Start();
}
private void ThreadFunction()
{
while (true)
{
_semaphore.Wait(); // Block until there is work.
if (_exitThread) break;
lock (_lock) { _counter++; }
}
}
public void IncrementCounter() => _semaphore.Release(); // Wake the worker.
public override void _ExitTree()
{
_exitThread = true;
_semaphore.Release(); // Unblock so it can see _exitThread.
_thread.Join(); // Join.
}
}
创建线程速度较慢(尤其是在 Windows 上)——应在繁重工作开始前预先创建,而不是等到需要时才创建。过度使用互斥锁也会带来较大开销。
4. 将结果传回:call_deferred / set_deferred
GDScript
# Unsafe from a worker thread:
world.add_child(enemy)
# Safe:
world.add_child.call_deferred(enemy)
C# 等效写法
// Unsafe from a worker thread:
world.AddChild(enemy);
// Safe — use the MethodName StringName constant, NOT "AddChild":
world.CallDeferred(Node.MethodName.AddChild, enemy);
在 C# 中,CallDeferred("AddChild") 会失败——延迟调用、Call 和 Connect API 使用 Godot 的蛇形命名。建议使用 Node.MethodName.* 常量(可避免此问题并减少一次分配)。
5. 线程化资源加载
ResourceLoader.load_threaded_request(path) 会开始加载。每帧轮询 load_threaded_get_status(path, progress)(progress[0] 是 0–1 的比例);当状态为 THREAD_LOAD_LOADED 时,调用 load_threaded_get(path)。如果加载尚未完成,load_threaded_get 会像 load() 一样阻塞——务必先轮询。状态包括:THREAD_LOAD_INVALID_RESOURCE / THREAD_LOAD_IN_PROGRESS / THREAD_LOAD_FAILED / THREAD_LOAD_LOADED。
GDScript
const SCENE_PATH := "res://enemy.tscn"
var _progress: Array = []
func _ready():
ResourceLoader.load_threaded_request(SCENE_PATH)
func _process(_delta):
var status := ResourceLoader.load_threaded_get_status(SCENE_PATH, _progress)
match status:
ResourceLoader.THREAD_LOAD_IN_PROGRESS:
$ProgressBar.value = _progress[0] * 100.0
ResourceLoader.THREAD_LOAD_LOADED:
var scene: PackedScene = ResourceLoader.load_threaded_get(SCENE_PATH)
add_child(scene.instantiate())
set_process(false)
ResourceLoader.THREAD_LOAD_FAILED, ResourceLoader.THREAD_LOAD_INVALID_RESOURCE:
push_error("Threaded load failed: %s" % SCENE_PATH)
set_process(false)
C# 等效实现
private const string ScenePath = "res://enemy.tscn";
private readonly Godot.Collections.Array _progress = new();
public override void _Ready() => ResourceLoader.LoadThreadedRequest(ScenePath);
public override void _Process(double delta)
{
var status = ResourceLoader.LoadThreadedGetStatus(ScenePath, _progress);
switch (status)
{
case ResourceLoader.ThreadLoadStatus.InProgress:
GetNode<ProgressBar>("ProgressBar").Value = (double)_progress[0] * 100.0;
break;
case ResourceLoader.ThreadLoadStatus.Loaded:
var scene = (PackedScene)ResourceLoader.LoadThreadedGet(ScenePath);
AddChild(scene.Instantiate());
SetProcess(false);
break;
case ResourceLoader.ThreadLoadStatus.Failed:
case ResourceLoader.ThreadLoadStatus.InvalidResource:
GD.PushError($"Threaded load failed: {ScenePath}");
SetProcess(false);
break;
}
}
Godot 4.7+: 4.7 修复了若干线程加载正确性问题 ——
load_threaded_get()死锁(GH-119757、GH-120077)、load_threaded_request()中的竞态(GH-118824),以及load_threaded_get()返回的资源永远不会被卸载(GH-119394)。API 没有变化;如果你为早期版本中罕见的线程加载挂起或泄漏保留了变通方案,请先在 4.7 上重新测试,再决定是否保留。上文中的先轮询后获取规则仍然适用。
6. C# 并发:Task 与 Godot 线程
在 C# 中,对于即发即忘的 CPU 工作,优先使用 System.Threading.Tasks.Task.Run / async-await;绝不要从后台线程访问 Godot 对象或 await ToSignal(...) —— 应通过 CallDeferred 将结果送回。当你想要 Godot 的线程池和引擎集成时,使用 WorkerThreadPool;当你想要 .NET 惯用法时,使用 Task。(GDScript 用户:使用上文各节中的 WorkerThreadPool 或 Thread。)
public override void _Process(double delta)
{
if (Input.IsActionJustPressed("compute"))
{
_ = System.Threading.Tasks.Task.Run(() =>
{
int result = ExpensiveComputation(); // Pure CPU work, no Godot objects.
CallDeferred(MethodName.OnComputed, result); // Marshal back to main thread.
});
}
}
private void OnComputed(int result) => GD.Print($"Done: {result}");
深入阅读: 有关数据竞态、
ERR_BUSY嵌套等待死锁以及多线程何时有害,请参阅 陷阱与死锁。
实现检查清单
- 先进行性能分析 —— 确认该工作确实是 CPU 密集型(参见 godot-optimization)
- 场景树变更仅发生在主线程(
call_deferred/set_deferred) - 每个
WorkerThreadPool任务都被等待(wait_for_*_completion) - 共享状态由
Mutex/lock保护;容器扩容已加锁 - 在所属节点释放前,线程已加入(
wait_to_finish/Join) - 没有 GPU 调用,没有 AStar 共享,没有跨线程加载同一资源
- 线程加载在调用
load_threaded_get之前先轮询状态