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 spawn calls (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: when main returns, 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 200ms spawned sleep started just before main returns 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-println can 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 asynchronouslyspawn 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 is reef_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 calls reef_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)
  • spawn on an Active Object method is rejected at typecheck — spawn a free proc/fn only
  • main returning calls reef_machine_exit() (see reef_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