Skip to content
FutureBlitz

    Install

    Spec chapter 10

    Concurrency

    The model

    Games need concurrency for three different jobs. Each gets its own tool:

    JobExampleToolRuns on
    Do heavy work fasterpathfinding, particles, mesh buildingSpawn, Parallel Forworker threads, in parallel
    Wait without freezingloading a file, network requestAsync / Awaitmain thread; waiting costs nothing
    Do things over timedoor opens, waits 2s, closes; cutscenesAsync coroutinesmain thread, resumed each frame

    Jobs 2 and 3 are the same mechanism: a coroutine is an Async function that awaits time instead of I/O.

    Thread layout

    MAIN THREAD     user's loop: input → update → UpdateWorld → RenderWorld → Flip → resume coroutines
                    (owns the engine: entities, rendering, audio)
    
    WORKER POOL     [task][task][parallel-for chunk][task]...      one thread per core, minus 1
                    (pure computation, no engine calls)
    
    DEDICATED       Thread.Start(...) for long-running blocking loops (e.g. a network listener)
    THREADS         so they don't tie up a pool worker

    Most game code never leaves the main thread.

    • The pool starts its workers the first time it’s used, so a program that never uses it has only the main thread. It has one per core but one, and at least one.
    • A worker that waits for the pool’s work, like a task’s Wait() or a Parallel For, runs the work it started itself meanwhile, so a task that waits for tasks of its own doesn’t leave the pool a worker short. It runs no one else’s, which could wait for it in turn. A thread waiting on a Channel runs nothing, so a task that waits on one holds its worker: work that mostly waits belongs on a Thread.
    • When top-level code ends, the program waits for the pool’s work to finish, then releases the Globals and ends.
    • Lines that Print writes from different threads don’t mix, and a panic on any thread ends the whole program at once (see 09).

    Tasks and parallel loops

    Local t: Task<Mesh> = Spawn(Function() => BuildNavMesh(level))
    ...
    Local nav = t.Wait()                 // blocks; re-throws the task's error
    
    Parallel For i = 0 Until particles.Count
        particles[i].Step(dt)            // iterations must be independent
    End For
    • Work-stealing thread pool in the runtime.
    • Thread.Start(fn) for long-lived blocking work.
    • Spawn(work) runs the work on the pool and gives its Task<T>, where T is what the work gives; work that gives nothing gives a Task. t.Wait() returns once it’s done, with its result, and t.IsDone() says whether it is. Thread.Start(work) gives a Thread, with Join() and IsDone(), and runs the work on a thread of its own.
    • The work is a lambda or a Function’s name, which takes no arguments, or a Sendable Func (see 05), since no other function value can go to the other thread. A Task<T> expected says what the lambda gives, as in Local t: Task<Int64> = Spawn(Function() => 5), so neither takes type arguments.
    • What the work makes is its own: it reaches only Sendable values from outside it, so its result moves safely to the thread that waits for it, even an object or an array.
    • A Task or Thread stays on the thread that started it, so it isn’t Sendable, and only that thread waits for it.
    • The program waits for every Task and Thread when its top-level code ends. A panic in one ends the whole program.
    • The work’s lambda can’t throw, like any function value’s. A Task whose Wait throws comes from an Async Function with a Throws, in M4 step 9.
    • Parallel For i = a Until b, or To b, runs its passes on the pool at once, in chunks, with the calling thread taking some, and the next statement waits for them all. It counts integers up by 1, with no Step, through its type’s last value too, as For does. The calling thread waits only for the chunks being run: a worker busy elsewhere joins in only if chunks are left.
    • What the body uses from outside it is a copy, made once, which it can’t change, and which is Sendable. An array from outside whose elements are Sendable is used only through its elements and its Count: each pass changes an element only at its own index, a[i], so no two passes change the same element, and a body that changes an array’s elements reads that array only there too, since another pass may be changing any other element at once; it reads any element of an array it doesn’t change. A ByRef Method can change a[i] there, since the array can’t grow meanwhile, and a lambda in the body can’t take the array.
    • Continue goes on to the next index; Break and Return can’t leave the body, and neither can an error. The body is work on other threads, so the main-thread rules hold for it.

    Async / Await

    Stackless coroutines on the same scheduler.

    Async Function LoadLevel(path: String): Level Throws FileError
        Const bytes = Await File.ReadAsync(path)                   // game keeps running
        Local nav = Await Spawn(Function() => BuildNavMesh(bytes)) // heavy part on a worker
        Return Level(bytes, nav)                           // back on the main thread
    End Function
    • Calling an Async Function starts it and gives its Task<T>, where T is its return type, or a Task when it returns nothing. It runs on the main thread until it first Awaits, then the call returns. a?.M() on an Async Method gives a Task<T>?, which is Null when a is.
    • Await t in an Async Function waits for a Task, a Spawn’s or another Async Function’s, and gives its result. Meanwhile the main thread goes on; the Function’s Locals and parameters wait with it.
    • The main thread runs the waiting Async Functions once top-level code ends, until none is left, and whenever it waits for a Task with Wait(), or with an Await in top-level code, which waits as Wait() does. A wait, and the result it gives, are the Task’s that the expression gave when the wait began, even if what runs meanwhile changes the variable it came from. Their code only ever goes on on the main thread: the runtime panics where it would go on anywhere else. Their code never runs on another thread, so work on another thread can’t call one, and an Async lambda isn’t work for Spawn or Thread.Start.
    • Await goes only in the code of an Async Function, Method, or lambda, or in top-level code, not in a lambda inside one, and not in a Lock block, which would hold its Mutex while it waits.
    • An Async Function’s errors go in its Task, so its call doesn’t throw, but Await t and t.Wait() re-throw them, each time, and need Try, Try?, or a Try block, as a call that throws does. The Task’s type says so: Task<Level> Throws FileError, which can be written as a type. After a function’s result, Throws is the function’s, so a Task’s type there goes in parentheses, Function Start(): (Task<Level> Throws FileError), and so it does at the end of a Func that’s the result: Function Make(): Func(): Task<Int> Throws FileError can throw itself. A Func type’s result is the same, so a Func value that gives a throwing Task is Func(): (Task<Int> Throws FileError). Its Throws lists Classes as a function’s does, so it goes in parentheses before a ? or [] too, and where a , goes on to anything but a parameter, like a tuple’s next element or the next type argument; in a Func type’s or a lambda’s parameters, a , always goes on to the next parameter.
    • Its error type can be a type parameter that a constraint says is an error, E: Error (see 07), which each call or instance has its own of: Async Function WhenAll<T, E: Error>(tasks: (Task<T> Throws E)[]): T[] Throws E passes the Tasks’ errors on, and a generic Class’s Async Method can throw its own E.
    • A Task that throws converts to one of the same result that throws what covers its errors, as an Override’s Throws is covered by its Method’s: a Task<Int> Throws FileError is a Task<Int> Throws Error. A Task that throws and one that doesn’t don’t convert, either way, since only one of them keeps an error.
    • A Method can be Async, but not a ByRef Method or Destroy. An Override of an Async Method is Async, and an Async Method overrides only an Async one. An Interface’s Method can be Async, a required one or a default, and what implements an Async one is Async too, while an Async Method implements only an Async one: a call through the Interface’s value, or generic code, starts it as any Async Method’s call does, and gives its Task, so Await reader.ReadAsync(buffer) waits for whatever Class the Reader is. The Task is the caller’s, so what implements one gives just the type the Interface’s gives, not a Struct or Class implementing the Iterator it gives, which a plain Method’s result can be. A Static one can be Async too, which generic code calls on its type. An Interface that redeclares one of an Interface it extends is Async where that one is, and only there.
    • Async Function(x) => ... is an Async lambda, whose call gives a Task, through a function value too: Func(Int): Task<Int>. Making one is main-thread code, since its code runs there.
    • An Async Function’s parameters can’t be ByRef, and it isn’t a function value; a lambda can call it.

    Coroutines

    Async Function DoorSequence(door: Door)
        door.Open()
        Await Wait(2.Seconds)
        door.Close()
    End Function
    
    DoorSequence(door)    // calling without Await starts it on the main scheduler

    Scheduling

    Confirmed 2026-10-21, one rule for every program, game or not: a coroutine resumes on the thread it started on whenever that thread hands control to the runtime, which does the resuming; nothing is ticked by hand. The main thread hands it over at an Await (in an Async Function, or in top-level code), at Wait(), once top-level code ends (the runtime then runs until nothing waits, as Node does), and at Flip(), which a game’s frame loop calls anyway to show the frame. A blocking call, like reading a file, doesn’t, so no other code runs in the middle of one. Main-thread code stays single-threaded across an Await, as in C#‘s UI apps, Unity, and JavaScript; CPU-heavy work goes to Spawn and Parallel For, on every core.

    Main scheduler, ticked by Flip(). After presenting, Flip() resumes ready coroutines, so they run at the start of the next frame before update code. Nothing extra goes in the loop:

    Graphics3D(1280, 720)
    DoorSequence(door)
    
    While !KeyHit(KEY_ESCAPE)
        UpdateGame()
        UpdateWorld()
        RenderWorld()
        Flip()                      // coroutines resume here
    End While

    Custom schedulers, ticked manually, for pausing and time scaling:

    Global gameplay = Scheduler()
    gameplay.Run(DoorSequence(door))
    
    While running
        If !paused Then gameplay.Tick(dt * timeScale)
        UpdateWorld()
        RenderWorld()
        Flip()                      // main scheduler (UI coroutines) still runs
    End While

    Rules:

    • Until the engine’s Flip(), the main scheduler ticks once top-level code ends, until no coroutine waits on it, and whenever the main thread waits for a Task with Wait(). Its time is the real clock’s.
    • Once top-level code ends, a coroutine that can never go on, since it waits for a Task on a Scheduler that nothing ticks any more, or for one that waits for such a Task, is destroyed before the Globals are released, as when its Scheduler goes. So is one that a Destroy starts while the Globals are released, at its first Await, since the program has ended. A Task from NextFrame, Wait, or RealWait that nothing awaits is never done.
    • Scheduler() makes one, only on the main thread, since its coroutines run there, and it isn’t Sendable. s.Tick(seconds) moves its time on by that much and resumes what can go on, and s.IsIdle() says whether no coroutine waits on it.
    • An Async call starts on the scheduler running the code. s.Run(task) moves its coroutine to s, with the coroutines and timers it waits for. A Task that’s done, or a Spawn’s, has nothing to move.
    • Await NextFrame() resumes at the next tick of the owning scheduler.
    • Await Wait(time) uses the scheduler’s time (respects pause and time scale), and Await RealWait(time) the real clock’s, ignoring both. A time is std.time’s Duration, like 2.Seconds or 500.Milliseconds, so either one needs Import std.time. NextFrame, Wait, and RealWait give a Task that the scheduler running the code finishes, so only the main thread calls them.
    • An I/O await resumes on the scheduler that started it, at its next tick. Coroutine code never runs on a background thread. A coroutine that starts waiting during a tick waits for the next one, even for a Task that’s done.
    • Await MainThread() hops back to the main thread. Coroutine code is always there already, so MainThread() gives a Task that’s done, and the Await goes on at the next tick.
    • A coroutine still waiting on a Scheduler when the Scheduler goes is destroyed: it lets go of what it holds, without running its Defers or Finally blocks, and its Task is never done.

    Communication

    ToolUse
    Channel<T>(capacity)one thread sends, another receives
    Mutex<T>shared data behind a lock
    Atomic<T>shared counters and flags
    Const ch = Channel<Packet>(64)
    Spawn(Function() => NetLoop(ch))
    While ch.TryReceive(ByRef pkt)
        Handle(pkt)
    End While
    
    Global score = Mutex<Int>(0)
    Lock score As s                 // guard released at End Lock
        s += 10
    End Lock
    
    Global frames = Atomic<Int>(0)
    frames.Add(1)

    Channel

    • Channel<T>(capacity) makes a Channel of a Sendable T with room for that many, 1 or more. It’s Sendable itself, so tasks share one, through a Const or a parameter.
    • ch.Send(value) waits while it’s full, and panics once it’s closed. ch.TrySend(value) gives False instead of waiting, when it’s full or closed.
    • ch.Receive() waits while it’s empty, and gives a T?, which is Null once it’s closed and empty. ch.TryReceive(ByRef value) gives False instead of waiting, and sets the variable when it gives True; ch.TryReceive() gives a T?, Null when there’s nothing, as Receive gives.
    • ch.Close() lets the receivers take what’s left. For x In ch receives until then. ch.IsClosed() says whether it’s closed, though it may have values left, so code that waits on several Channels at once, with TryReceive, knows when one won’t get more.
    • Values come out in the order they went in, and what’s left when the Channel goes is released.

    Atomic and Mutex

    • An Atomic<T> holds an integer or a Bool. Load(), Store(value), Exchange(value), and CompareExchange(expected, desired), which says whether it held expected and now holds desired, work on it at once, in one order every thread sees. An integer’s also has Add(amount) and Subtract(amount), which wrap around. Exchange, Add, and Subtract give what it held before.
    • A Mutex<T> holds a Sendable T. Its value is used only in Lock m As value ... End Lock, where value is the Mutex’s own, like a ByRef parameter, which a lambda can’t capture. Another thread’s Lock of it waits until the block is done, however it ends: at End Lock, or by Return, Break, Continue, or an error. A thread that Locks a Mutex it holds already panics, since it would wait for itself forever.
    • Both stay where they’re made, so every thread shares the one: a Global, a Static Field, or a Class’s Field. A Struct is copied, so it can’t hold one. Code can pass one ByRef, but can’t copy, assign, or give one back, and neither can be part of another type, like an array.
    • Atomic<Int>(0) and Mutex<Stats>(Stats()) make one, as what a Global or Field starts with. Without a type argument, the T is the value’s type: Mutex(Stats()). A Field of one starts at its zero, or an Atomic’s at a value known at compile time; New can change a Mutex Field’s value in a Lock block.
    • Both are Sendable. A For Field loop leaves them out, since f.Get(value) would copy one.

    Safety: data races are compile errors

    Rule: an object belongs to one thread unless its type says otherwise.

    Kind of dataThread ruleRefcount
    Normal classesstay on one threadnon-atomic (fast)
    Sendable datamay cross threadsn/a
    Shared Classmay cross threads; fields must be Atomic, Mutex, or ReadOnlyatomic

    Sendable covers: primitives, Strings (immutable, with atomic refcounts), Structs (copied) whose fields are all Sendable, ReadOnly/frozen data, Channel, Mutex, Atomic, Callback, and Shared Class instances.

    Shared Class Hub
        ReadOnly Field Title: String
        Field Visits: Atomic<Int>
        Field Log: Mutex<String>
    End Class
    • A Shared Class’s own Fields are Atomic, Mutex, or ReadOnly, and a ReadOnly one is Sendable, so no two threads change one at once. Its Methods can run on any thread.
    • It extends only a Shared Class, and only a Shared Class extends it, so a variable of one always holds a Shared Class’s object. A generic one says its type parameters are Sendable, as in Shared Class Box<T: Sendable>, for a ReadOnly Field of type T.
    • Its objects count their owners atomically, Weak references too, and whichever thread lets go last runs its Destroy(). An Interface’s value can hold either kind of object, so the runtime tells them apart by their Class.

    The compiler checks every Spawn capture, Parallel For body, and channel send.

    • Sendable is an Interface in std/core/interfaces.fb that the compiler decides from a type’s parts: numbers, Bools, Strings, Chars, Enums, Callbacks, Sendable Funcs, Channels, Mutexes, Atomics, and a Shared Class’s objects are Sendable, and so is a Weak<T> of a Shared Class, whose objects count Weak references atomically too, or of a type parameter that’s Class & Sendable, which stands for one, and so are the Structs, tuples, and fixed arrays whose fields and elements all are, and a T? of any of them. A Ptr or CString isn’t, since nothing keeps what it points at for another thread.
    • Classes, arrays, other function values, and other Weak<T>s aren’t, since an object’s refcount isn’t atomic: an object stays on the thread that made it. Neither is a Struct that holds one.
    • Generic code says a type parameter is Sendable with a constraint, <T: Sendable>, and an instance like Box<Int> is Sendable when its fields are.
    • No type has to list it. One that does, Struct Packet Implements Sendable, or that lists an Interface extending it, has the compiler check that its fields are; a generic one needs <T: Sendable> for a field of type T.
    • It’s only a constraint, so it isn’t a type of its own. An Interface that extends it can be one.
    • An error says which part isn’t: “Holder isn’t Sendable, which ‘Send’ needs for T: its field ‘Target’ is Enemy?, and a Class’s objects stay on the thread that made them”.

    Captures across threads. Closures capture by reference (05), so sharing a mutable Local with another thread would be a data race. A closure passed to Spawn or Thread.Start, the body of a Parallel For, or a lambda that’s a Sendable Func, can only capture what can’t change, holding a Sendable value: a Const, a parameter, a loop variable, or a Self that’s Sendable.

    Const enemy = Enemy("grunt")
    Spawn(Function() => enemy.Think())
    // ✗ compile error: Enemy is not Sendable
    
    Const pos = enemy.pos                   // a read-only Vec3
    Spawn(Function() => ComputePath(pos))   // ✓
    
    Local target = enemy.pos
    Spawn(Function() => ComputePath(target))
    // ✗ compile error: a Local can't be captured by another thread; make it a Const

    Main-thread rules

    • Engine APIs (rendering, audio, entities) are marked @MainThread. Calling them from a worker is a compile error.
    • Mutable Globals are main-thread-only unless they are Atomic, Mutex, or ThreadLocal, each thread’s own (see 02).
    Spawn(Function() => PositionEntity(cam, 0, 0, 0))   // ✗ compile error: @MainThread
    • Work on another thread is a Spawn’s or Thread.Start’s, a Sendable Func’s, or a Callback’s code, which C can call on any thread (see 12), or a ThreadLocal Global’s initializer, which runs on each thread that uses it (see 02), with everything it can run: what it calls, its lambdas, what overrides the Methods it calls, and what implements the Interface Methods it calls, with each New’s Class’s Destroy(). A Shared Class’s Destroy() is too, since whichever thread lets go last runs it.
    • That code can’t use a Global or Static Field unless it’s an Atomic, a Mutex, a ThreadLocal Global, or a Static ReadOnly Field of a Sendable type, which keeps the value it starts with, or a top-level Const that isn’t Sendable, or call a @MainThread Function or Method. Async code, the timers, and Schedulers are the main thread’s too. A top-level Const that’s Sendable is fine, and so is all of it in top-level code, which runs on the main thread.
    • The error says how the work gets there: “this work runs on another thread, but it calls ‘Bump’, which uses the Global ‘score’, which only the main thread can, unless it’s an Atomic or a Mutex”.

    Scope for v1

    • Ship: Spawn, Task, Parallel For, Async/Await + coroutines, Scheduler, Channel, Mutex, Atomic, Thread.Start, Sendable checking.
    • Defer: lock-free collections, actors, GPU compute from the language. These can be added as libraries later without language changes.

    Report an issue with this page or with FutureBlitz on GitHub.