~3 min read

Threading — WorkerThreadPool and Thread

Parallel tasks in Godot — map generation, data processing, long operations without freezing the UI.

In Godot the main thread handles rendering, physics, and scripts. If you procedurally generate a world map in _ready, the game will freeze for a second or two. Threading is the way to move heavy work to background threads.

Three levels of tools

Node/classPurposeWhen
WorkerThreadPoolA pool of ready worker threads, one per CPU core80% of cases — recommended
ThreadA single manual threadWhen you need full control or long-running work
Mutex / SemaphoreSynchronization between threadsAnywhere with shared data

WorkerThreadPool appeared in Godot 4 and is the main path. It manages the threads for you; you just submit tasks.

Web

Web Workers + SharedArrayBuffer. The difference: a Godot Thread runs at the native level, without serialization overhead.

Unity

WorkerThreadPool — basic example

Task: procedurally generate a 1024×1024 heightmap.

extends Node

var _task_id: int = -1
var _heightmap: PackedFloat32Array

func _ready() -> void:
    # Launch the heavy task in the background.
    _task_id = WorkerThreadPool.add_task(_generate_heightmap, true)
    # The second argument is high_priority. This task goes to the front of the queue.

func _process(_delta: float) -> void:
    if _task_id == -1:
        return
    # Don't block the main thread. Check whether the task is done.
    if WorkerThreadPool.is_task_completed(_task_id):
        WorkerThreadPool.wait_for_task_completion(_task_id)
        _on_heightmap_ready()
        _task_id = -1

func _generate_heightmap() -> void:
    # This method runs on a background thread.
    var data := PackedFloat32Array()
    data.resize(1024 * 1024)
    for y in 1024:
        for x in 1024:
            data[y * 1024 + x] = sin(x * 0.05) * cos(y * 0.05)
    _heightmap = data

func _on_heightmap_ready() -> void:
    print("Heightmap generated! Size: ", _heightmap.size())
    # Apply the result here — the main thread

The main WorkerThreadPool methods:

  • add_task(callable, high_priority = false, description = "") — submit, returns a task_id.
  • is_task_completed(task_id) — check whether it’s done.
  • wait_for_task_completion(task_id) — block the main thread until it finishes (called after is_task_completed for cleanup).
  • add_group_task(callable, elements, tasks_needed, ...) — a parallel “for each”.

Parallel for-each via a group task

The equivalent of IJobParallelFor in Unity — add_group_task divides N iterations among the pool’s workers:

extends Node

var _agents: Array[Node3D] = []

func _ready() -> void:
    # 1000 agents, each an independent update
    var group_id := WorkerThreadPool.add_group_task(
        _update_agent,
        _agents.size(),      # tasks_needed
        -1,                  # tasks_needed (-1 = by core count)
        false,               # high_priority
        "Agent batch update"
    )
    WorkerThreadPool.wait_for_group_task_completion(group_id)

func _update_agent(index: int) -> void:
    # Runs in parallel for different index values. The main rule:
    # each index touches ONLY its own agent, otherwise a race condition.
    var agent := _agents[index]
    var new_pos := agent.global_position + Vector3.RIGHT
    # ! You must NOT change the Node API from a thread. You can only compute values.
    # Apply them on the main thread.
Godot API — main thread only

Most of the Godot API (Node, Transform3D, Image, RenderingServer) is not thread-safe. Modifying nodes or assets from a background thread is a path to a crash or undefined behavior. On a background thread do only computations over data (arrays, PackedArray, your custom structs), and apply the results on the main thread via a signal or a flag.

Exceptions that are explicitly thread-safe: ResourceLoader.load_threaded_*, the physics server with explicit mutexes.

Mutex — protecting shared data

If several workers write to a shared array, you need a Mutex:

extends Node

var _results: Array = []
var _mutex := Mutex.new()

func _calc(index: int) -> void:
    var value := index * 2.0
    _mutex.lock()
    _results.append(value)
    _mutex.unlock()

Without lock, two parallel _calc calls could .append at the same time, corrupting the array’s internal state.

Alternative: give each task its own output array, then merge them on the main thread. This is faster than a mutex — there’s no contention.

Thread — a manual thread

When you need a long-lived background thread (a long-poll server, audio streaming, a custom job queue), use Thread:

extends Node

var _thread: Thread

func _ready() -> void:
    _thread = Thread.new()
    _thread.start(_background_loop)

func _background_loop() -> void:
    while not _should_stop:
        # heavy work
        OS.delay_msec(100)

var _should_stop: bool = false

func _exit_tree() -> void:
    _should_stop = true
    _thread.wait_to_finish()

Unlike WorkerThreadPool, Thread creates a new thread on every start — there’s no pool. Use it only if the task stays alive for a long time.

Resource loading on a thread

The main scenario where threading is “mandatory” is ResourceLoader.load_threaded_request:

ResourceLoader.load_threaded_request("res://big_scene.tscn")

func _process(_delta: float) -> void:
    var status = ResourceLoader.load_threaded_get_status("res://big_scene.tscn")
    match status:
        ResourceLoader.THREAD_LOAD_LOADED:
            var scene = ResourceLoader.load_threaded_get("res://big_scene.tscn")
            get_tree().change_scene_to_packed(scene)

Internally Godot creates its own thread for loading. More details in the Resource loading chapter.

C# in Godot — threading

In the C# variant of Godot you can use the standard .NET threading tools: Task.Run, Parallel.For, async/await, lock. The same restrictions apply: the Godot Node API is main-thread only.

await Task.Run(() => {
    // heavy work in the background
});
// here — back on the main thread, apply the result

When it’s worth moving to a thread

  • Procedural generation of levels, textures, meshes.
  • Pathfinding for a large number of agents (if the NavigationServer is overloaded).
  • Parsing large files (JSON, binary formats).
  • Network requests with a long response (although HTTPRequest is already asynchronous).
  • Audio analysis / DSP in real time.

When it’s NOT worth it

  • Lightweight operations (< 1 ms) — the overhead of creation/synchronization eats up the gain.
  • A constant stream of data into the Node API — the main thread becomes a bottleneck anyway.
  • Prototypes — it adds complexity with no visible benefit.