mcpField
M
multithreading

multithreading

github.com/jame581開発889154 回インストール4 日前に更新
リポジトリ

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. スレッドモデルと安全性のルール

メインスレッドがシーンツリーを所有します。アクティブなシーンツリーとのやり取りはスレッドセーフではありません。 ドキュメントに基づく以下のルールを守ってください。

  • サーバー(RenderingServer、PhysicsServer)は、プロジェクト設定で有効にした場合にのみスレッドセーフです(Rendering > Driver > Threads > Thread Model = Separate — 4.3 では Multi-Threaded で、実験的な機能です。また、Physics > {2D,3D} > Run on Separate Thread)。サーバーは、スレッドで駆動される数千のインスタンスを適切に処理できます。
  • NavigationServer2D/3D はスレッドセーフで、スレッドに適しています(クエリを真の並列処理で実行できます)。Navigation > Pathfinding > Max Threads を調整してください(4.4 以降)。
  • AStar2D/3D/Grid2D はスレッドセーフではありません。オブジェクトごとに専用スレッドを1つだけ使用してください。1つのオブジェクトを複数のスレッドで共有すると、データが破損します。
  • GDScript の Array/Dictionary: 既存の要素をスレッド間で読み書きするのは問題ありません。サイズ変更(追加/削除)には Mutex が必要です。
  • メインスレッド以外では GPU 処理を行わないでください(テクスチャの作成、画像の読み取り/変更)。RenderingServer の同期による停止が発生します。
  • シーンチャンクはツリー外でスレッドを使って構築し、その後、メインスレッドで add_child.call_deferred() を使って追加してください。ただし、ローダースレッドは1つだけにしてください(複数のスレッドで同じキャッシュ済みリソースを変更すると、クラッシュするおそれがあります)。

基本ルール: シーンツリーを変更するのはメインスレッドだけにしてください。ワーカーからは call_deferred/set_deferred を使って結果を渡してください。


2. WorkerThreadPool(推奨)

WorkerThreadPool は、起動時にスレッドが割り当てられるグローバルシングルトンです。通常のタスク(add_task)は1つのワーカーで実行されます。グループタスク(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()(ブロックするため、解放前に join してください)、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)。必要になる直前ではなく、負荷の高い処理を始める前に作成してください。Mutex の過剰なロックにもコストがかかります。


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 の snake_case 名が使われます。この落とし穴を避け、割り当ても発生させない 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](https://github.com/godotengine/godot/pull/120077)、`load_threaded_request()` の競合状態([GH-118824](https://github.com/godotengine/godot/pull/118824)、および load_threaded_get() が返したリソースが解放されない問題([GH-119394](https://github.com/godotengine/godot/pull/119394)です。API に変更はありません。以前のバージョンでまれに発生するスレッド読み込みの停止やメモリリークに対処する回避策を使っている場合は、そのまま残す前に 4.7 で再テストしてください。前述の、取得前にポーリングするルールは引き続き適用されます。


6. C# の並行処理:Tasks と 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 を呼び出す前にステータスをポーリングする

コメント