threads

Shared Memory

Objects that live outside every lane and cross by id, for data too large to copy and for lanes that need to meet.

Shared objects live outside every lane’s memory, in the host, and each lane reaches them by id. Handing one to another lane copies nothing: the other lane gets the id of the same object. Most of them import from rive/shared, and Port imports from rive/ports.

The shared types

TypeWhat it holdsWho waits
Blobbytes fixed once madenobody
SharedRegionbytes behind one lock, plus atomic wordsa lane taking the lock
Ring<T>values in arrival order, up to a byte capacitynobody: a full ring refuses the send
Snapshot<T>the latest value, up to a byte capacitynobody
Signala counter that wakes awaits when it is notifiedan await, or the main lane in waitWithin
Port<T>typed messages over a ring, with awaits for a message or for rooman await, or the main lane in receiveWithin
Barrierthe rounds where workers splitting one computation meet, with a share of floats from eachan await, until the round’s last participant arrives

None of them ever makes a lane wait, except a region’s lock and the two bounded waits that only the main lane has. Audio processors cannot take a region’s lock. A lane holds at most one region’s lock at a time, and can neither join a job nor block on a signal while it holds one. So locks can never deadlock.

As a rule of thumb:

Regions

A SharedRegion is a block of bytes behind one lock, zeroed when it is made. exclusive(fn) runs fn holding the lock, and hands it a view of the bytes. The view reads and writes numbers at byte offsets, with methods such as getF32 and setU32. It is valid only inside exclusive.

The view’s readInto and writeFrom copy a range between the region and a buffer or array you hold, and allocate nothing. Here a worker fills a region and notifies a signal, and the main lane copies the values out once the signal has moved:

filled.as
import { Layout, Context } from "rive/host";
import { Worker } from "rive/workers";
import { SharedRegion, Signal } from "rive/shared";

const kCount: i32 = 256;

@worker export async function fill(region: SharedRegion, ready: Signal): Promise<void> {
    let values = new Float32Array(kCount);
    for (let i = 0; i < kCount; i++) {
        values[i] = <f32>i * 0.5;
    }
    region.exclusive((view) => {
        view.writeFrom(0, values);
    });
    ready.notify();
}

export class Filled extends Layout {
    region: SharedRegion = SharedRegion.create(<u32>kCount * 4);
    ready: Signal = Signal.create();
    values: Float32Array = new Float32Array(kCount);
    seen: f64 = 0;
    worker: Worker? = null;

    override init(context: Context): bool {
        // Read before the worker can notify, so no notify is missed.
        this.seen = this.ready.seq;
        this.worker = Worker.spawn(fill, this.region, this.ready);
        return true;
    }

    override advance(seconds: f64): bool {
        if (this.ready.changedSince(this.seen)) {
            this.seen = this.ready.seq;
            let values = this.values;
            this.region.exclusive((view) => {
                view.readInto(0, values);
            });
        }
        return true;
    }
}

A lock wait never outlasts the caller’s call budget, nor two seconds in a lane without one. Past that, exclusive throws. tryExclusive(fn, seconds) gives up instead, and answers false.

A region also has atomic words. atomicLoad, atomicStore, atomicAdd and atomicCompareExchange work on a 4 byte aligned word without taking the lock. poisoned turns true once a lane trapped while holding the region, so its bytes may be half written.

Capacities and sizing

A ring, port or snapshot holds only what fits its byte capacity, which is set when it is made:

A value counts its encoded size against the capacity. Today that is its encoded bytes, plus 4 for each shared object it names, and 4 more when it names any. In a ring or port, add 4 more for its length. So an f64 needs a ring of 12 bytes. The encoding belongs to the bake that made the file, and a later bake may size values differently. So leave a capacity some room rather than sizing it to the byte.

A value that needs more than the whole capacity never fits. A ring’s send answers false, a snapshot keeps its last value, and a port’s post throws. The console warns the first time it happens, except on an audio processor. Send such data as a blob instead, as Large data shows.

A ring that is only full for now also refuses a send. A port’s post then answers false, and a worker can await writable() and post again.

How long a shared object lives

An object goes once nothing holds it, or with the script that made it.

Moving bytes by range

A blob, or a region under its lock, moves bytes in and out by range, so nothing needs a slice or a staging buffer first.

CallWhat it does
Blob.from(buffer, byteOffset, byteLength)copies part of an ArrayBuffer into a new blob
Blob.fromArray(values, offset, count)copies part of a typed array, or of an Array or StaticArray of numbers, into a new blob. What a job builds becomes a blob in one copy, or two on the web.
blob.readInto(srcOffset, out, dstOffset, count)copies from a blob into part of out, and answers how much it copied: fewer where the blob ends first
view.readInto(offset, out, dstOffset, count)copies from a region into part of out
view.writeFrom(offset, values, srcOffset, count)copies part of values into a region

The buffer or array on the other side is an ArrayBuffer, a typed array, or an Array or StaticArray of numbers. An offset into a blob or a region counts bytes. Offsets and counts on the other side count bytes in an ArrayBuffer, and values in an array. A count left out covers the rest of the buffer or array on the other side. readInto and writeFrom allocate nothing, so audio code reads a blob with readInto.

Straight to the GPU

A blob’s bytes can go to a GPUBuffer without passing through the script’s own memory:

Updating a mutable buffer while a draw still uses it is safe, since that draw keeps the bytes it was given. In this example a job skins a mesh into a blob, and the main lane uploads it once the job is over:

skinned.as
import { Layout, Context } from "rive/host";
import { GPUBuffer } from "rive/gpu";
import { BufferUsage } from "rive/gpu_enums";
import { Job, Jobs } from "rive/jobs";
import { Blob } from "rive/shared";

const kVertexBytes: i32 = 4096;

@job export function skin(pose: Float32Array): Blob {
    // Sized for the worst case; only what was written is sent.
    let vertices = new Float32Array(2 * kVertexBytes / 4);
    for (let i = 0; i < kVertexBytes / 4; i++) {
        vertices[i] = pose[i % pose.length];
    }
    return Blob.from(vertices.buffer, 0, kVertexBytes);
}

export class Skinned extends Layout {
    vertices: GPUBuffer? = null;
    skinning: Job<Blob>? = null;

    override init(context: Context): bool {
        this.vertices = new GPUBuffer(BufferUsage.vertex, <u32>kVertexBytes);
        this.skinning = Jobs.run(skin, new Float32Array(16));
        return true;
    }

    override advance(seconds: f64): bool {
        let skinning = this.skinning;
        if (skinning != null && !skinning.running) {
            this.skinning = null;
            let skinned = skinning.peek()!.value;
            skinning.release();
            this.vertices!.updateFromBlob(0, skinned);
            skinned.release();
        }
        return true;
    }
}

Large data

A value that crosses is copied by the lanes at both ends. The sender encodes it inside the call that sends it, and the reader decodes it inside the call that reads it. So a large result costs the frame that reads it. Numbers are copied as blocks, but every other object is made on its own:

A Blob crosses by id whatever its size, so reading one decodes nothing. For large data:

Large blob assets

A blob asset reaches the lanes through the main lane, since only the main lane reaches the host. context.blob(name) copies the asset’s bytes into the main lane’s memory, and Blob.from copies them again into shared memory. The main lane’s memory never shrinks, so a large asset loaded whole leaves the main lane that much bigger for good.

For now, split a large asset into several blob assets. Load one per advance with context.blob and Blob.from, so the main lane grows by about one part. Release each Blob once the workers that read it have unpacked it.

For every detail of the API, run rive docs animascript/api/shared and rive docs animascript/api/ports. The full guide is rive docs animascript/threading.