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
| Lane | What it is | Started with | Ends when |
|---|---|---|---|
| main | the script itself: init, advance,
draw and the other hooks | the file | the script goes |
| job | a plain function that computes a result from its arguments | Jobs.run | it returns, fails or is stopped |
| worker | an async function that lives across frames and keeps its state | Worker.spawn | its promise settles or it is stopped |
| audio processor | a class whose
process renders blocks of audio | Audio.start | it is stopped, fails or overruns its hard limit |
Pick a lane by the shape of the work:
- A job fits work with a clear input and output, such as decoding a tile or skinning a mesh. Each run starts fresh, so a job keeps nothing from one run to the next.
- A worker fits a service that keeps its state and answers requests over many frames. Several workers can also split one computation and meet at a barrier.
- An audio processor renders sound on the audio thread, one block at a time.
What each lane can reach
| Lane | Can reach |
|---|---|
| main | everything: the host, the artboard, other lanes |
| job | its arguments, shared memory, pure standard library code, console logs, timers and the clock |
| worker | the same as a job, plus awaits on signals, ports and sleeps |
| audio processor | its 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:
- numbers, bools and strings
ArrayBuffer, typed arrays, andArrayandStaticArrayof types that cross- your own classes whose fields all cross, copied field by field
- the shared memory handles, which cross as the id of the same object rather than as a copy
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.
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
@jobmarks a function thatJobs.runmay run. It must be exported under its own name. It must not be generic or a generator, and it must return a value.@workermarks an async function thatWorker.spawnmay spawn. It must be exported under its own name and must not be a generator.@sendablemarks the standard library types that cross by id, such asRing,BlobandPort. You never write it on your own classes.
An audio processor needs no decorator. It is a class that extends
AudioProcessor<A>, exported under its own name.
What each lane may create
| Lane | May create |
|---|---|
| main | any shared object, and start jobs, workers and processors |
| job | blobs only, such as a large result |
| worker | any shared object, which belongs to the script that spawned the worker |
| audio processor | nothing |
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:
runningis true until the lane is done, fails or stops.statesays where the lane is in its life:starting,running,done,failedorstopped. TheLaneStatevalues import fromrive/lane_codes.failedsays why the lane failed, or is null when it did not.
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:
- Each call the host makes into the main lane, such as
init,advanceordraw, has a budget of 200 ms. - A wait on the main lane, such as a job’s
join, never outlasts that budget. A join that runs out answers null, and the job keeps running on the pool. A call without a budget is the exception: there a join waits for its job to end. - Jobs and workers have no time limit. A job runs until it returns or is stopped, and a worker until its promise settles or it is stopped.
- An audio processor has a hard limit of 2 blocks of audio. A block that runs past it takes that processor out of the mix.
- A file and its lanes hold at most 512 MB of shared memory at once, and at most 65536 live shared objects.
- Natively, the job pool and the worker pool each have one thread fewer than the device has cores, at most 6 for jobs and 4 for workers.
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
- Native (desktop, mobile, consoles): jobs and workers run on pool threads, and audio processors on the engine’s audio thread. A stopped job is interrupted at once. A stopped worker that never awaits again is interrupted after a short grace.
- Web with threads: jobs and workers run on web workers, and processors in an audio worklet. A build with threads loads only on a page served with cross origin isolation, the two headers a browser needs before it shares memory between threads. Each worker keeps one thread for its whole life, for up to about 8 of a file’s workers at once.
- Web without threads: each job runs on the
page’s own thread at the next advance, and workers step between
frames. Audio processors fail as they start, and
failedon the handle says why. - NRDP: as the web, except that a build with threads needs no cross origin isolation, and processors run on NRDP’s own audio worklet thread.
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
- Jobs: running a function on the pool and picking up its result.
- Workers & Barriers: long lived services, and one computation split over several workers.
- Shared Memory: blobs, regions, rings, snapshots, signals and ports.
- Audio Processors: rendering sound on the audio thread.
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.