module
Docs::I_CONCURRENCY_FIBERS_AND_THREAD_SAFETY
Overview
I. Concurrency, Threading & Memory Architecture
This section provides a comprehensive architectural guide on Crystal's concurrency models (Fibers, Channels, Mutexes, and Multi-Threading) and how they integrate safely with the Godot Engine 4.x runtime.
1. Executive Summary & Core Rules
- SceneTree is Strictly Single-Threaded:
Never invoke
add_child,remove_child,reparent, orqueue_freefrom a background thread or fiber. Modifying the active scene hierarchy outside Godot's Main Thread causes memory corruption and engine crashes. - Cooperative Fibers Require Yield Points:
Godot controls the OS main loop. Spawned fibers (
spawn do ... end) will not execute unless the main thread cooperatively yields viaFiber.yieldin_process(delta). - Avoid Blocking Sleep in Fibers:
Standard
sleep(duration)relies on Crystal's event loop (LibEvent / IOCP), which is not pumped by Godot's host process. Use Godot'sget_tree.create_timer(duration)or delta accumulators instead. - Use
Channel(T)for Background Processing (Actor Pattern): Offload CPU-intensive tasks to background worker threads (Thread.new). Workers push results to aChannel(T), and the Main Thread drains the channel non-blockingly during_process(delta). - Cross-Thread Method Dispatch:
When background threads need to notify Godot nodes, use
node.call_deferred("method_name", *args). Godot buffers deferred calls into its thread-safeMessageQueueand dispatches them on the main thread during the next frame.
2. Crystal Concurrency Models vs. Godot Engine Architecture
Concurrency Matrix
| Feature | Crystal Mechanism | Godot Threading Model | Potential Hazard | Solution / Best Practice |
|---|---|---|---|---|
| Fibers | M:1 cooperative coroutines; lightweight stacks. | Godot Main Thread runs engine iterations. | Fibers starve if main loop never yields; blocking sleep hangs. |
Call Fiber.yield in _process; use frame deltas instead of sleep. |
OS Threads (Thread.new) |
1:1 kernel threads; registered with Boehm GC. | Godot spawns Server & Worker threads. | Race conditions in Crystal data structures; off-thread Node access. | Protect shared state with ::Thread::Mutex; pass data via Channel(T). |
Multi-Threading (-Dpreview_mt) |
M:N fibers across CRYSTAL_WORKERS threads; work stealing. |
Godot multithreaded rendering/physics. | Concurrent access to @@alive_instances or global caches. |
Bridge uses ::Thread::Mutex for alive_instances; confine SceneTree to Main Thread. |
Channels (Channel(T)) |
CSP message passing with thread-safe mutex and queue. | Single-threaded Main Loop. | Deadlock if blocking receive is called on the Main Thread. |
Use non-blocking receive? or select polling in _process. |
3. Execution Contexts & Thread-Local Storage (TLS)
Main Thread vs. Foreign Engine Threads
- Main Thread Initialization:
When Godot loads
game.dll, Godot invokescrystal_godot_initon the Main Thread. Crystal initializes its runtime (Crystal.init_runtime), sets up main thread TLS (Thread.current,Fiber.current), and boots the Boehm GC (GC.init). - Foreign Engine Threads (WorkerThreadPool / Audio / Physics):
Godot creates worker threads natively in C++.
- If a foreign engine thread directly calls an exported Crystal C callback, Crystal's TLS
(
Thread.current,Fiber.current) is uninitialized. - Raising an exception or attempting fiber operations on an unregistered foreign thread can trigger an immediate Access Violation / Segmentation Fault (0xC0000005 / SIGSEGV).
- Rule: All GDExtension class lifecycle methods (
_ready,_process, exported properties) must be dispatched from the Main Thread.
- If a foreign engine thread directly calls an exported Crystal C callback, Crystal's TLS
(
4. Boehm Garbage Collector (BDWGC) Under Concurrency
1. Stack Scanning & Thread Registration
- Boehm GC stops the world and scans the call stacks of all registered threads to identify root pointers.
- Threads created through Crystal's
Thread.neware automatically registered with BDWGC. - If an unregistered thread executes Crystal code and allocates heap memory (e.g. strings, arrays, objects), BDWGC cannot scan its stack. Live objects held only on that stack could be prematurely freed, resulting in silent heap corruption or use-after-free.
2. Stop-The-World (STW) Pauses
- During a GC collection, BDWGC halts all registered threads (using
SuspendThreadon Windows or signals on POSIX). - To minimize GC pause times in 60 FPS / 120 FPS games:
- Avoid allocating temporary heap objects inside
_processor_physics_process. - Use value types (
struct Vector2,struct Vector3,struct Transform3D). - Pre-allocate arrays and reuse object pools.
- Avoid allocating temporary heap objects inside
3. Instance Rooting & Thread-Safe Registry
In src/libgodot/bridge.cr, the bridge maintains @@alive_instances (Hash(Void*, Godot::Object)) to root
active Crystal nodes and prevent premature GC deallocation.
Bridge.alive_instancesis protected byBridge.alive_mutex(Thread::Mutex.new).- Registration (
register_alive_instance) and unregistration (unregister_alive_instance) are 100% thread-safe against concurrent scene deserialization or background worker instantiations.
5. Recommended Architecture: The 3-Tier Concurrency Model
Tier 1: Cooperative Main-Thread Fibers (Gameplay & UI)
Best for gameplay scripts, cutscenes, state machines, and dialog systems.
class QuestManager < Godot::Node
def _ready
# Spawn a cooperative gameplay sequence
spawn do
Godot.print "Quest started!"
# Wait 3 seconds using Godot SceneTreeTimer
get_tree.create_timer(3.0)
Godot.print "3 seconds elapsed, advancing quest!"
end
end
def _process(delta : Float64)
# Grant cooperative execution slices to spawned fibers
Fiber.yield
end
end
Tier 2: Background Workers + Channel Message Passing (Actor Pattern)
Best for procedural generation, pathfinding grids, network requests, and heavy math.
class WorldGenerator < Godot::Node
@result_channel = Channel(Array(Godot::Vector3)).new(1)
@worker : Thread? = nil
def start_generation
@worker = Thread.new do
# Heavy background computation off-thread
points = Array(Godot::Vector3).new
100_000.times do |i|
points << Godot::Vector3.new(i.to_f32, 0.0_f32, i.to_f32)
end
# Send immutable data back to main thread
@result_channel.send(points)
end
end
def _process(delta : Float64)
# Non-blocking poll on main thread
select
when points = @result_channel.receive
apply_world_mesh(points)
else
# Work still in progress
end
end
private def apply_world_mesh(points : Array(Godot::Vector3))
# Safe to mutate SceneTree here on Main Thread!
Godot.print "Received #{points.size} points from background worker!"
end
end
Tier 3: Thread-Safe Deferred Dispatch (call_deferred)
Best for fire-and-forget notifications from background threads to engine objects.
Thread.new do
# Do background work...
result = compute_heavy_score()
# Safely marshal back to Godot Main Thread via MessageQueue
hud_node.call_deferred("update_score", result)
end
6. Anti-Patterns & Common Pitfalls
| Anti-Pattern | Why It Fails | Correct Approach |
|---|---|---|
node.add_child(c) inside Thread.new |
SceneTree is not thread-safe. Causes race condition in Godot child list. | Marshal to main thread using Channel(T) or call_deferred. |
sleep(1.second) inside a spawn fiber |
Crystal event loop is not pumped by Godot; fiber sleeps indefinitely. | Use get_tree.create_timer(1.0) or frame delta accumulators. |
sleep(duration) inside Thread.new |
Top-level sleep yields a fiber to an ExecutionContext; raw threads lack context, raising NilAssertionError: Fiber#execution_context cannot be nil. |
Use Crystal::System::Thread.sleep(duration) for true OS thread sleeps. |
Unsynchronized global state (@@my_cache[k] = v) |
Crystal Hash and Array are not thread-safe under concurrent writes. |
Wrap access in ::Thread::Mutex.new (mutex.synchronize { ... }). |
| Keeping raw pointers to freed objects | Accessing deleted C++ memory causes undefined behavior. | Use monotonic instance IDs and check is_valid? or catch DisposedObjectError. |
Anti-Pattern 1: SceneTree Mutation from Background Threads
Modifying Godot's active scene hierarchy (add_child, remove_child, reparent, queue_free)
from a background thread or fiber causes memory corruption in engine child vectors.
Bad Code:
# BAD: Calling add_child directly from an OS background thread
Thread.new do
new_enemy = Godot.create(Godot::Node2D)
new_enemy.name = "OffThreadEnemy"
parent_node.add_child(new_enemy) # CRASH: SceneTree race condition in engine child list!
end
Good Code (Approach A — Channel to Main Thread):
# GOOD: Compute off-thread, instantiate and add on Main Thread via Channel
class Spawner < Godot::Node
@spawn_channel = Channel(String).new(10)
def spawn_async
Thread.new do
enemy_data = compute_enemy_stats()
@spawn_channel.send(enemy_data)
end
end
def _process(delta : Float64)
select
when enemy_data = @spawn_channel.receive
enemy = Godot.create(Godot::Node2D)
enemy.name = enemy_data
add_child(enemy) # SAFE: Executed on Godot Main Thread!
else
# No new spawn events this frame
end
end
end
Good Code (Approach B — call_deferred):
# GOOD: Use call_deferred to safely marshal node attachment to the Main Thread
Thread.new do
new_enemy = Godot.create(Godot::Node2D)
new_enemy.name = "DeferredEnemy"
parent_node.call_deferred("add_child", new_enemy) # SAFE: Routed through thread-safe MessageQueue!
end
Anti-Pattern 2: Blocking sleep Inside a spawn Cooperative Fiber
In standard standalone Crystal programs, sleep registers with the event loop. In Godot GDExtension,
Godot owns the main loop and does not pump Crystal's event loop. Calling sleep(1.second) inside a
fiber on the main thread causes the fiber to suspend indefinitely.
Bad Code:
# BAD: Calling sleep inside a spawned fiber in GDExtension
spawn do
Godot.print "Quest Step 1"
sleep 2.seconds # HANG: Crystal event loop is not pumped by Godot!
Godot.print "Quest Step 2" # NEVER REACHED!
end
Good Code:
# GOOD: Use Godot's SceneTreeTimer or delta accumulators with Fiber.yield
class QuestSequencer < Godot::Node
def start_quest
spawn do
Godot.print "Quest Step 1"
timer = get_tree.create_timer(2.0)
while timer.time_left > 0.0
Fiber.yield # Cooperatively yield execution slices
end
Godot.print "Quest Step 2 reached safely!"
end
end
def _process(delta : Float64)
Fiber.yield # Grant execution slices to spawned fibers each frame
end
end
Anti-Pattern 3: Calling Fiber sleep Inside a Raw OS Thread.new
In Crystal 1.20+, top-level sleep yields the current fiber to an ExecutionContext.
Raw OS threads created with Thread.new do not run inside an ExecutionContext, causing an
immediate runtime exception: NilAssertionError: Fiber#execution_context cannot be nil.
Bad Code:
# BAD: Calling top-level sleep inside an OS thread
Thread.new do
10.times do
sleep 10.milliseconds # CRASH: NilAssertionError: Fiber#execution_context cannot be nil!
end
end
Good Code:
# GOOD: Use Crystal::System::Thread.sleep for genuine OS thread sleeps
Thread.new do
10.times do
Crystal::System::Thread.sleep 10.milliseconds # SAFE: Native OS kernel sleep!
end
end
Anti-Pattern 4: Unsynchronized Shared Mutable State Across Threads
Crystal's standard Hash and Array collections are not thread-safe. Concurrent mutations
corrupt internal hash buckets, cause infinite loops, or trigger memory access violations.
Furthermore, using Crystal's standard Mutex alias (Sync::Mutex) from a raw thread fails under
contention because Sync::Mutex suspends fibers via ExecutionContext. Always use ::Thread::Mutex.
Bad Code:
# BAD: Modifying shared Hash from multiple threads without synchronization
class ScoreCache
@@scores = Hash(String, Int32).new
def self.record(player_id : String, score : Int32)
Thread.new do
@@scores[player_id] = score # CORRUPTION: Data race on Hash buckets!
end
end
end
Good Code:
# GOOD: Protect shared mutable collections with ::Thread::Mutex
class ScoreCache
@@scores = Hash(String, Int32).new
@@mutex = ::Thread::Mutex.new # OS kernel mutex (CRITICAL_SECTION / pthread_mutex)
def self.record(player_id : String, score : Int32)
Thread.new do
@@mutex.synchronize do
@@scores[player_id] = score # SAFE: Atomic and thread-safe!
end
end
end
def self.get(player_id : String) : Int32?
@@mutex.synchronize do
@@scores[player_id]?
end
end
end
Anti-Pattern 5: Retaining Raw Pointers to Freed Engine Objects
When a Godot object is destroyed (via GDScript queue_free(), target.free(), or engine scene reload),
the underlying C++ heap memory is deallocated. Calling methods on an unvalidated wrapper invokes
undefined behavior and native crashes (0xC0000005).
Bad Code:
# BAD: Holding node references without checking engine liveness
class CombatTracker
property cached_target : Godot::Node2D? = nil
def attack_target
if target = @cached_target
# If target was freed by GDScript via queue_free(), calling methods crashes!
target.call("apply_damage", 25) # CRASH: ACCESS_VIOLATION / SIGSEGV!
end
end
end
Good Code:
# GOOD: Validate engine liveness with alive? or catch DisposedObjectError
class CombatTracker
property cached_target : Godot::Node2D? = nil
def attack_target
if target = @cached_target
if target.alive? # Checks ObjectDB 64-bit instance validity in O(1)
target.call("apply_damage", 25)
else
@cached_target = nil # Clean up dead reference
end
end
rescue ex : Godot::DisposedObjectError
Godot.print_warn "Target was disposed: #{ex.message}"
@cached_target = nil
end
end
7. Awaiting Signals and Timers: The await Pattern
In Godot, asynchronous sequencing for cutscenes, dialogue, animations, and cooldowns
is customarily performed using GDScript's await keyword.
LibGodot provides a first-class, type-safe await system designed for Crystal's
cooperative fibers. It provides two fully supported signal awaiting styles:
- First-Class Bound Signals (
await(enemy.died)orenemy.died.await): Synthesized automatically by thesignalmacro andGodot::Object#signal. Provides compile-time checking, IDE auto-completion, and direct.connect/.emitmethods. - Classic Target & String Identifier (
await(enemy, "died")orenemy.await_signal("died")): The traditional Godot pattern. Indispensable when signal names are computed dynamically at runtime (e.g., from network RPC packets, configuration files, or GDScript dynamic events). - SceneTreeTimers (
await(timer.timeout)orawait(timer)). - Cooperative Durations (
await(2.5)orawait(3.seconds)).
Comparison: GDScript vs. LibGodot Crystal
| Operation | GDScript | LibGodot Crystal |
|---|---|---|
| Await Signal (Bound) | await target.died |
await(target.died) or target.died.await |
| Await Signal (Classic String) | await target.died |
await(target, "died") or target.await_signal("died") |
| Await with Arguments | var health = await player.health_changed |
args = await(player.health_changed) or await(player, "health_changed") |
| Await Timer | await get_tree().create_timer(2.0).timeout |
await(get_tree.create_timer(2.0).timeout) or await(timer) |
| Await Duration | await get_tree().create_timer(1.5).timeout |
await(1.5) or await(1.5.seconds) |
| Await with Timeout | Manual timer racing | await(target.died, timeout_sec: 5.0) or await(target, "event", timeout_sec: 5.0) |
| Connect Directly | target.died.connect(...) |
target.died.connect { |args| ... } or target.connect("died", callback) |
Comprehensive Cutscene & Gameplay Example
The following example demonstrates a Boss battle cinematic sequence authoring cooperative fibers, signal emissions, timer awaits, and dead-pointer safety:
node BossFightController < Godot::Node do
@[Export]
property cutscene_speed : Float32 = 1.0_f32
signal battle_started
signal battle_won
def _ready : Void
# Launch cutscene sequence in a cooperative fiber
spawn do
run_intro_cinematic
end
end
def _process(delta : Float64) : Void
# CRITICAL: Cooperatively yield execution slices each frame to advance awaiting fibers!
Fiber.yield
end
private def run_intro_cinematic : Void
Godot.print("Cinematic starting: Camera pan...")
# 1. Non-blocking delay: wait 2.0 seconds for camera transition
await(2.0)
Godot.print("Spawn Boss entity...")
boss = get_node_as(Godot::CharacterBody3D, "Boss")
# 2. Await a SceneTreeTimer via .timeout bound signal
await(get_tree.create_timer(1.5).timeout)
Godot.print("Boss roaring animation finished!")
emit_battle_started
# 3. Await custom signal on boss using first-class BoundSignal syntax
begin
Godot.print("Awaiting boss defeat signal...")
# Returns Array(String) of signal arguments (e.g. loot drop ID, score)
args = await(boss.boss_defeated, timeout_sec: 120.0)
Godot.print("Victory! Boss dropped rewards: #{args}")
emit_battle_won
rescue ex : Godot::DisposedObjectError
Godot.print_warn("Boss was prematurely destroyed: #{ex.message}")
end
end
end
Dead-Pointer Safety During await
In dynamic multi-language games, an entity being awaited could be freed prematurely
by GDScript (e.g. enemy.queue_free()) or engine level unloading.
LibGodot's await validates #alive? on every frame slice:
- If the target is destroyed while a fiber is awaiting its signal,
awaitimmediately raisesGodot::DisposedObjectError.new(target.instance_id). - This guarantees that awaiting fibers never hang indefinitely on dead objects and cannot trigger native segmentation faults.