mcpField
M
multithreading

multithreading

github.com/jame581开发889154 次安装5 天前更新
代码仓库

关于 multithreading

面向使用 GDScript 或 C# 的 Godot 开发者的多线程技能,讲解如何将高开销工作移出主线程,并避免破坏场景树或共享数据。

主要功能

  1. 01WorkerThreadPool 任务模式
  2. 02Thread、Mutex 和 Semaphore 指引
  3. 03延迟执行场景树更新
  4. 04多线程资源加载
  5. 05C# Task 与异步处理指引

适用场景

  1. 01并行处理开销较大的实体逻辑
  2. 02加载资源以避免阻塞帧
  3. 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 之前先轮询状态

评论

支持 Markdown