threads

Threads

Move work that is too slow for a frame off the main lane, onto jobs, workers and audio processors.

A script normally runs on the main lane: the thread that advances and draws the artboard. A lane is one place where your code runs. Work that is too slow for a frame, or that has to keep running between frames, can move to one of three other lanes.

Each lane runs in its own instance of the script’s module, with its own memory. So lanes never share variables. They talk by copying values to each other, or through shared memory that every lane can reach.

The four lanes

LaneWhat it isStarted withEnds when
mainthe script itself: init, advance, draw and the other hooksthe filethe script goes
joba plain function that computes a result from its argumentsJobs.runit returns, fails or is stopped
workeran async function that lives across frames and keeps its stateWorker.spawnits promise settles or it is stopped
audio processora class whose process renders blocks of audioAudio.startit is stopped, fails or overruns its hard limit

Pick a lane by the shape of the work:

What each lane can reach

LaneCan reach
maineverything: the host, the artboard, other lanes
jobits arguments, shared memory, pure standard library code, console logs, timers and the clock
workerthe same as a job, plus awaits on signals, ports and sleeps
audio processorits argument, shared memory without locks, pure standard library code and console logs, but no timers

The host is the Rive runtime around your script. Only the main lane reaches it. No other lane can make paths or paints, touch artboards or view models, draw random numbers or start other lanes, and only jobs and workers read the clock. Code in a lane that would do one of those fails the build at the call that reaches the host. The error, E3004, names the chain of functions from the lane’s function to that call. To fix it, do the host’s part on the main lane, such as drawing the random numbers, and pass the values in as arguments.

Globals in a lane

A lane’s module is compiled from the same files as the main module, but only the code the lane’s function or class reaches goes into it. None of the files’ top level code runs in a lane. A global the lane reads still keeps its initializer, so give state its value where it is declared.

A top level statement that changes a global a lane reads fails the build, since the lane would never see the change. The same goes for changes the main lane makes to a global later on: a lane never sees them. Pass such values as arguments instead.

Console output

A lane’s console lines show where the main lane’s do, but not at once. They are handed over at the host’s next poll, which runtimes make before each advance. A line longer than 1024 bytes is cut. A lane that writes a lot between two handovers loses the rest, and the next handover says how many lines it dropped.

What crosses between lanes

Values that go from one lane to another cross as deep copies. A deep copy is a new copy of the value and everything inside it, made in the other lane’s memory. That covers the arguments of Jobs.run, Worker.spawn and Audio.start, a job’s result, a worker’s resolved value, and every message through a ring, snapshot or port. These types can cross:

Your own classes need no decorator. An object crosses as the class its type names, so sending a subclass where its base class is declared throws. Send it as its own type instead. Functions, host objects and values nested too deep cannot cross, and the build says which field is at fault.

tiles.as
import { Layout, Context } from "rive/host";
import { Jobs } from "rive/jobs";

// Crosses field by field, its typed array as one block of bytes.
export class Tile {
    column: i32 = 0;
    row: i32 = 0;
    heights: Float32Array = new Float32Array(0);
}

@job export function buildTile(column: i32, row: i32, size: i32): Tile {
    let tile = new Tile();
    tile.column = column;
    tile.row = row;
    tile.heights = new Float32Array(size * size);
    for (let i = 0; i < size * size; i++) {
        tile.heights[i] = <f32>((column + row + i) % 7);
    }
    return tile;
}

export class Tiles extends Layout {
    tile: Tile? = null;

    override init(context: Context): bool {
        let result = Jobs.run(buildTile, 2, 3, 64).join();
        if (result != null && result.ok) {
            this.tile = result.value;
        }
        return true;
    }
}

The decorators

An audio processor needs no decorator. It is a class that extends AudioProcessor<A>, exported under its own name.

What each lane may create

LaneMay create
mainany shared object, and start jobs, workers and processors
jobblobs only, such as a large result
workerany shared object, which belongs to the script that spawned the worker
audio processornothing

Keeping a lane alive

Starting a lane gives back a handle: a Job, a Worker or an AudioHandle. Keep the handle in a field for as long as the lane should run. A handle that is collected stops its lane, just as calling release() on it does. Shared objects work the same way: each handle on the main lane keeps its object alive until the handle is collected or released.

Every lane handle answers these without waiting:

stop() ends a lane early, and release() gives the handle back now, stopping the lane if it still runs.

Limits

The host that plays a file sets its limits, and content cannot change them. By default:

To try a project on pools of another size, pass --job-threads=<N> or --worker-threads=<N> to rive.

A file’s top level code runs once, when the file loads, under the same budget as any call into the main lane. You can start lanes there, but read their results in init or later.

Lanes in headless runs

Jobs and workers run on wall time, while a headless capture steps its frames as fast as the machine allows. So --screenshot, --semantics, --data-dump and --render-audio wait after the load and after every frame, until each job the scripts started has finished, their workers are idle and their audio processors have started. What a frame starts is then always there in the next frame, and every run captures the same result. --no-settle-lanes turns the waiting off.

Timed waits, such as Worker.sleep, follow the wall clock rather than the capture’s frames. A capture can step many frames in the time a short sleep takes. So in a capture, pace lanes with signals, ports and rings rather than with time.

Platform differences

A browser cannot interrupt a running call. There a lane ends early only at a check of its own, called fuel. Every debug bake has fuel, the live window’s included, and so does an optimized bake with wasmFuel set (see Build Settings). Without fuel, a stopped job ends only once it polls Lanes.stopping(), and a stopped worker only at its next await. Where Scripts Run covers budgets and fuel in more depth.

A browser cannot put its page thread to sleep either. So on the main lane, a join, receiveWithin or waitWithin spins there for up to a tenth of a second, taking a core the pool could use. Where the result can wait a frame, poll running or await promise() instead.

Where to go next

The full guide covers every detail these pages leave out. Read it with rive docs animascript/threading, and the API reference with rive docs animascript/api/lanes.