Spawn Expression
Part of: Reef Language Reference Last reviewed on version: 0.9.0 Status: Implemented
Overview
The spawn expression creates a fire-and-forget concurrent task. The spawned function runs in a separate thread and the caller continues immediately without waiting.
Syntax
spawn function_call()
Basic Usage
proc background_task()
println("Running in background!")
end background_task
proc main()
println("Before spawn")
spawn background_task()
println("After spawn (may print before background finishes)")
end main
Possible output (order may vary):
Before spawn
After spawn (may print before background finishes)
Running in background!
Characteristics
- Fire-and-forget: No way to wait for or get a result from the spawned task
- Detached thread: The spawned thread runs independently
- No return value: spawn expression returns unit (void)
- Concurrent: Truly runs in parallel (not just async/await)
The spawn Contract: No Guarantees
spawn promises nothing beyond "this call starts running, concurrently,
at some point." Concretely:
- No ordering guarantee: a spawned task's output can interleave with
the spawning thread's (and with other spawned tasks') in any order,
differently from run to run. This is not a bug to work around with
timing — there is no supported way to observe or depend on ordering
between independent
spawncalls (BUG-169). - No completion guarantee: a spawned task may run to completion, partway, or not at all before the process exits — there is no wait, no join, and no notification either way.
- No survival past
main's return: whenmainreturns, the process exits immediately. Every still-running spawned task is terminated by process death along with everything else; nothing joins or suspends them first. A 200msspawned sleep started just beforemainreturns will, in practice, never get to run its post-sleep code (BUG-171). - No external kill: conversely, there is also no API to cancel or stop a spawned task early — its only two futures are "runs to completion" or "dies with the process."
- Output can be torn or lost, not just reordered: "no completion
guarantee" includes partial output — a spawned task killed by process
exit mid-
printlncan leave a partially-written line, and a task killed before its first print leaves no trace at all. Do not assume a spawned task's output, if it appears, is complete.
This mirrors Active Oberon's Unix.Machine.Mod lineage, where Shutdown
calls Unix.exit() directly with no join of live activities and no heap
teardown — process death is the teardown, not a special case of it.
If you need lifecycle control — waiting for a result, coordinating
shutdown, or knowing a task actually ran — use an Active Object with
await instead of spawn; see Active Objects
and the Comparison table below.
A spawned thread is registered with the GC's stop-the-world suspend/scan
machinery exactly like an Active Object thread (BUG-172, fixed in 0.8.x):
it is signal-suspended during a collection and its stack is scanned for
roots, so the spawned task's own locals — anything it allocates or
computes after it starts running — are memory-safe to hold across a
concurrent GC cycle. This does not relax any of the "No Guarantees"
above — ordering, completion, survival past main's return, and torn/lost
output are still not promised by spawn; only the underlying memory
safety of GC-managed values changed.
GC-managed arguments are rooted across the handoff by a brief,
synchronous handshake at the spawn point (BUG-174, fixed in 0.8.x) —
for a single GC-managed argument, or for arguments that are already
rooted in the caller's own frame (named locals or parameters, e.g. spawn f(msg, n) where msg is a let-bound local). spawn f(arg) evaluates
arg, creates the spawned thread, and then blocks — briefly, on the
order of a thread-scheduling latency, not on f running at all — until
the spawned thread has copied arg into its own GC roots. Only then does
the spawn statement return control to the caller. Arguments are
transferred synchronously at the spawn point; the call itself still runs
fully asynchronously — spawn does not wait for f to start executing,
finish, or produce any output. This does not change any of the "No
Guarantees" above: still no ordering, no completion guarantee, no
survival past main's return, and no error propagation.
Object-typed spawn arguments are rejected (0.9 O12). A spawned thread
is never the owner of a passive object graph, so spawn f(widget) is a
type error. Pass a value (string, record, enum) instead, and have the
owning thread build widgets from it. See
040_OBJECTS.md.
When to Use Spawn
1. Background Work
proc log_event(msg: string)
// Write to log file, send to network, etc.
end log_event
proc process_request()
// Handle request...
// Log asynchronously (don't block main work)
spawn log_event("Request processed")
// Continue immediately
end process_request
2. Notifications
proc send_notification()
// Send email, push notification, etc.
end send_notification
proc handle_order()
// Process order...
// Send notification without waiting
spawn send_notification()
// Return immediately to user
end handle_order
3. Parallel Tasks
proc task_a()
println("Task A running")
end task_a
proc task_b()
println("Task B running")
end task_b
proc main()
spawn task_a()
spawn task_b()
// Both tasks run in parallel
end main
Comparison with Active Objects
| Feature | spawn | Active Objects |
|---|---|---|
| Synchronization | None | Automatic (exclusive/shared) |
| State | Stateless | Stateful |
| Return value | None | Methods can return |
| Communication | None | Method calls |
| Lifetime | Fire-and-forget | Managed by GC |
Use spawn for: Simple background tasks with no shared state Use Active Objects for: Concurrent objects with state and synchronization
Current Limitations
Important: spawn is deliberately minimal. For concurrent work that needs synchronization, return values, or state, Active Objects are the recommended approach.
1. Functions with Arguments Run Asynchronously Too
Both no-argument and argument-taking calls run in a separate detached thread.
For a call with arguments, the compiler generates a small per-call args
struct and a trampoline thread-entry function that unpacks it and calls the
target. The spawn statement does not wait for the target function to run —
only, if any argument is GC-managed (string, array, or object), for the
spawned thread to finish copying the arguments into its own GC roots, which
happens before the target function is even called:
// ASYNC: No arguments - runs in separate thread, spawn returns immediately
spawn background_task()
// Arguments are copied into a generated struct and the trampoline runs on
// its own thread. process_data/log_message run fully asynchronously; the
// GC-managed argument handoff itself (not the call) is a brief synchronous
// step (BUG-174) before the spawn statement returns.
spawn process_data(input)
spawn log_message("hello")
// Plain scalar arguments (int, bool, float) have nothing to hand off —
// no synchronization, same as the no-argument case above.
spawn set_counter(42)
2. Cannot Spawn an Active Object Method
spawn only accepts a plain proc/fn call (a free function, not a method
on an Active Object instance). Active Object methods already dispatch to the
object's own thread, so spawning one is rejected at typecheck:
active object Worker
count: int
init()
self.count = 0
end init
exclusive proc bump()
self.count = self.count + 1
end bump
end Worker
proc main()
let w = new Worker()
spawn w.bump() // Type Error: Cannot spawn Active Object method
// 'Worker.bump'. AO method calls already dispatch to
// the object's own thread — call it directly, or
// spawn a plain proc.
end main
Call the method directly instead (w.bump() already runs on Worker's own
thread) — spawn is for free procs only.
3. No Return Values
spawn is fire-and-forget. You cannot get a result back.
// WRONG: Can't capture return value
let result = spawn calculate() // spawn returns void, not the result
// RIGHT: Use Active Object for results
let calculator = new Calculator()
calculator.compute(input) // Stores result internally
let result = calculator.get_result() // Retrieve when ready
4. No Cancellation
Once spawned, a task runs to completion or dies with the process — there's no way to cancel it independently.
5. No Error Propagation
Errors in spawned tasks are not propagated to the caller. Handle errors internally:
proc safe_background_task()
// Must handle all errors internally
// Caller will never know if this fails
end safe_background_task
When to Use spawn vs Active Objects
| Use Case | Recommendation |
|---|---|
| Simple background task, with or without arguments | spawn works |
| Need return value | Use Active Object |
| Need synchronization | Use Active Object |
| Need cancellation | Use Active Object |
| Fire-and-forget cleanup | spawn works |
Rule of thumb: If your task needs to report a result back or coordinate with other work, use an Active Object. spawn itself no longer cares whether the call takes arguments.
Example: Logging Service
proc async_log(level: string, message: string)
// Runs on its own detached thread — the level/message arguments are
// copied into a generated args struct for the spawn trampoline.
println("[${level}] ${message}")
end async_log
proc log_info(message: string)
spawn async_log("INFO", message)
end log_info
proc main()
log_info("Application started")
// Main continues immediately
end main
Example: Parallel Initialization
proc init_subsystem_a()
println("Initializing A...")
// ... expensive initialization
println("A ready")
end init_subsystem_a
proc init_subsystem_b()
println("Initializing B...")
// ... expensive initialization
println("B ready")
end init_subsystem_b
proc main()
println("Starting parallel initialization")
// Both run in parallel
spawn init_subsystem_a()
spawn init_subsystem_b()
println("Main continues while subsystems initialize")
end main
Best Practices
1. Use for Truly Independent Work
// GOOD: Independent background task
spawn cleanup_temp_files()
// AVOID: Task that needs synchronization
// Use Active Objects instead
2. Don't Rely on Completion
// WRONG: Assuming spawn completes before next line
spawn prepare_data()
use_data() // Data might not be ready!
// RIGHT: Use Active Objects for coordination
let worker = new DataWorker()
worker.prepare() // Waits for completion
use_data()
3. Handle Errors in Spawned Code
proc safe_background_task()
// Handle errors internally - they won't propagate
// to the caller
end safe_background_task
Implementation Notes
- spawn creates a detached POSIX thread via
reef_platform_thread_create - Every spawned call, argument-carrying or not, routes through a generated
trampoline rather than passing the target function pointer directly to
reef_platform_thread_create— the trampoline's first act isreef_objects_register_spawn(), which puts the new thread on the same GC-visible process list Active Object threads use (BUG-172), before any of the spawned body's own allocation can occur; on return it callsreef_objects_deregister_spawn(). Argless spawns keep this registration step but otherwise stay fire-and-forget with no argument unpacking. - Functions with arguments additionally get a generated args struct that the trampoline unpacks and passes to the target — the call itself is still async, not synchronous
- If any argument is GC-managed, the args struct also carries a one-shot mutex/condvar pair (BUG-174): the caller populates the struct, creates the thread, then blocks until the trampoline signals that it has copied every argument into its own GC-rooted locals. Only then does the caller proceed (destroying the mutex/condvar and freeing the struct) and the trampoline calls the target function. Pure-scalar-argument and no-argument spawns skip this entirely — there is nothing to root, so they keep the original fire-and-forget shape with no synchronization.
- The thread is immediately detached (fire-and-forget)
spawnon an Active Object method is rejected at typecheck — spawn a free proc/fn onlymainreturning callsreef_machine_exit()(seereef_machine.c), which ends the process immediately (normal builds:fflush+_exit, no teardown) — this is what makes "no survival past main's return" true rather than a race that merely favors the process exiting first (BUG-171)
Previous: 065_DEFER.md Next: 075_MODULES.md