Threads, Mutexes & WorkerThreadPool

Use threads for heavy work that would otherwise freeze the game: procedural generation, pathfinding over huge graphs, file I/O, decompression.

34.1 When to use threads

Use threads for heavy work that would otherwise freeze the game: procedural generation, pathfinding over huge graphs, file I/O, decompression. The scene tree is not thread-safe: don't add/remove/modify nodes in the tree from other threads — compute data on the thread, then apply it on the main thread with call_deferred().

34.2 Thread

thread.start(callable, priority) returns an Error; wait_to_finish() returns the callable's return value.

var thread := Thread.new()

func _ready() -> void:
	thread.start(_generate_world.bind(12345))

func _generate_world(seed_value: int) -> void:
	var rng := RandomNumberGenerator.new()
	rng.seed = seed_value
	var heights := PackedFloat32Array()
	heights.resize(512 * 512)
	for i in heights.size():
		heights[i] = rng.randf()
	_apply_world.call_deferred(heights)       # back to the main thread

func _apply_world(heights: PackedFloat32Array) -> void:
	print("World ready: ", heights.size())

func _exit_tree() -> void:
	if thread.is_started():
		thread.wait_to_finish()               # always join threads

34.3 Mutex and Semaphore

mutex.try_lock() returns true if it acquired the lock without blocking.

var mutex := Mutex.new()
var semaphore := Semaphore.new()
var queue: Array = []
var running := true
var worker := Thread.new()

func _ready() -> void:
	worker.start(_worker_loop)

func push_job(job) -> void:
	mutex.lock()
	queue.append(job)
	mutex.unlock()
	semaphore.post()                          # wake the worker

func _worker_loop() -> void:
	while true:
		semaphore.wait()                      # sleep until there's work
		mutex.lock()
		var should_exit := not running
		var job = queue.pop_front() if not queue.is_empty() else null
		mutex.unlock()
		if should_exit: break
		if job != null: _process_job(job)

func _process_job(job) -> void:
	pass

func _exit_tree() -> void:
	mutex.lock(); running = false; mutex.unlock()
	semaphore.post()
	worker.wait_to_finish()

34.4 WorkerThreadPool

A built-in pool — simpler than managing threads yourself:

is_task_completed(id) / is_group_task_completed(id) let you poll instead of blocking. Note that wait functions block the calling thread.

var results := PackedFloat32Array()

func _ready() -> void:
	# Single task
	var id := WorkerThreadPool.add_task(_heavy_task)
	# ... do other things ...
	WorkerThreadPool.wait_for_task_completion(id)

	# Group task: the callable runs once per index, in parallel
	results.resize(1000)
	var group := WorkerThreadPool.add_group_task(_compute_element, results.size())
	WorkerThreadPool.wait_for_group_task_completion(group)
	print(results[999])

func _heavy_task() -> void:
	pass

func _compute_element(index: int) -> void:
	results[index] = sqrt(float(index))     # each index writes its own slot: no lock needed

34.5 Thread-safety tips

Safe from threads Your own data (with mutexes) Adding/removing/changing nodes in the active tree

Most Node methods on nodes inside the tree Servers (many functions are thread-safe if *Thread Model* allows)

Signals that touch the tree (emit via call_deferred) Creating nodes not yet in the tree

Accessing the same Array/Dictionary from two threads without locking

Debug thread issues with the debugger's thread selector and keep threaded code small and well-isolated.