Threading model#
libYSE runs on several threads. Your code talks to it from one of them, the
control thread, and the engine takes care of the rest. This page lists
the threads, says what System().update() does on yours, and gives the
rules for calling the engine and for code the engine calls back.
The threads#
Thread |
What it does |
|---|---|
Control thread |
Your thread: the one that calls |
Audio thread |
The audio device’s callback (PortAudio on desktop, Oboe on Android).
Once per block of 128 samples it applies what the control thread
queued, advances clocks, clips and MIDI file playback, and renders the
mix. Under |
Render workers |
A pool that helps the audio thread render each block (see Rendering). They only run render tasks. |
Background pool |
One engine thread for slow work that must stay off the audio thread: opening and decoding sound files, refilling streamed sounds, freeing released objects, closing unused sound files, and patcher file and MIDI-port work. |
Patcher timer thread |
Runs the millisecond |
MIDI threads |
RtMidi runs one input thread per open |
Script thread |
Runs Python live-coding scripts. Only in builds with
|
The engine keeps allocation, locks and waiting off the audio thread and the
render workers. Everything the control thread asks of them travels through
lock-free queues, and everything slow they need done goes to the background
pool. The one exception is a change to the number of speaker outputs, which
resizes the mix buffers on the audio thread (see
setChannelConfiguration() below).
Rendering#
The channel tree is rendered as a graph of small tasks. A channel’s sounds are grouped into one or more voice slices, each a task, and each channel has a mix task that runs once its slices and its child channels are done. The audio thread renders tasks itself and shares them with the render workers, which take whichever tasks are free.
Channels are not threads. A busy channel is split into more slices by itself: the engine measures what each sound costs and opens a new slice when one grows more expensive than waking a worker. Put sounds on the channels you want to mix them on. Adding channels does not make the render faster.
Small scenes stay on one thread. When a whole block costs less than waking a worker, no worker is woken for it: the audio thread renders it, helped only by workers still awake from the block before. After a short spin at the end of a block, idle workers sleep until a block needs them.
The output does not depend on the threads. Slices and channels are always summed in the same order, so the mix is bit-identical whatever the worker count.
Your DSP can run on a worker. A sound’s DSP source, a patcher played as a sound and a channel’s inserts run on the audio thread or on a render worker. Both follow the audio thread’s rules.
System().renderThreads(count) sets the pool size:
|
Workers |
|---|---|
|
Auto: the physical cores the process may use, minus one, at most 8. The audio thread renders too, and your application keeps a core. On a CPU with performance and efficiency cores, workers go to the performance cores first. |
|
None. The audio thread renders everything. Use it on constrained hardware. |
|
Exactly |
Call it before init(). During a session with an audio device the value is
stored and applied by the next init(); with no session, or an offline one,
it applies at once. The setting survives close().
System().activeRenderThreads() returns the number of workers running now.
The C API has the same calls: yse_system_set_render_threads,
yse_system_get_render_threads and yse_system_get_active_render_threads.
What update() drives#
Call System().update() regularly, typically once per frame, from the
thread that called init(). Each call does this on your thread:
Flags an engine update for the audio thread. At its next block the audio thread picks up new and released objects and applies what their setters queued. It hands file loading, freeing of released objects and unused-file cleanup to the background pool, and decides which sounds become virtual. Without
update()a new sound never starts and a released one is never freed.Runs the occlusion callback for every sound that has occlusion enabled (see
System().occlusionCallback). It runs here, never on the audio thread, so it may raycast through your physics engine. Keep it short: it runs insideupdate().Delivers named-bus messages. Values that the audio thread, the timer thread, the script thread or another thread of yours published since the last call reach their subscribers here, on your thread. A publish made on the control thread itself is delivered straight away.
Writes queued log lines from code that may not log directly, such as the patcher’s
.print.Wakes the script thread and passes any script errors to the C API’s error callback (Python builds only).
Rebuilds a lost audio device when the backend reported the loss on a thread of its own.
Runs the device watchdog. It counts
update()calls without an audio callback (System().missedCallbacks()) and, whenSystem().autoReconnect()is on, reopens the device after the delay you set.
init() records which thread is the control thread. Call update() and
close() from that same thread. Calling update() from another thread
still works, but every bus publish then waits for the next update()
instead of being delivered at once.
Rules for the host#
Call the engine from the control thread
Setters on sound, channel, synth, reverb and the other object
classes put a message on that object’s queue. Each queue takes one writer, so
call a given object from one thread, the control thread. These calls are
control-thread only:
System().init(),update()andclose()(see above), andSystem().renderThreads().System().setChannelConfiguration(). It sets the speaker layout that the next audio block switches to. A different number of outputs makes that block reallocate the mix buffers, so change it at setup, not while something audible plays.Domain clocks:
System().createClock(),destroyClock()andsetTempo().beatPosition()andcurrentTempo()may be read from a UI thread, for example to draw a playhead.Clips:
clip::create(),setEvents(),loopLength()andconnect().midiIn::connect(). Do not call it from inside amidiIncallback.Named-bus subscriptions and taps in the C API (
yse_bus_subscribe,yse_bus_tap_createand their release calls). Their callbacks then run on the control thread, and none runs after the release returns.
The patcher is the exception: any number of your threads may edit and drive it at once. Real-time model has its rules.
Never call the engine from the audio thread
Code the engine runs on the audio thread or a render worker — a DSP source, an insert, a patcher object you wrote — must not call the engine’s public API, allocate, lock, log or do I/O. Hand work to another thread through a lock-free queue instead.
Log handlers
A logHandler (YSE::Log().setHandler) or a C log callback
(yse_log_set_callback) receives lines from several engine threads: the
control thread, the background pool, the MIDI threads and the render workers
while they start. The engine delivers one line at a time and holds its log
lock during the call. So a handler:
is never called from two threads at once, and may keep ordinary state without its own locking;
may be called on any of those threads, never on the audio thread;
must not log back through
YSE::Log(). That deadlocks;should not block. Every other thread that logs waits for it, the background pool’s file loading included. Pass slow work to a thread of your own.
Where to go next#
Real-time model: the same model from the patcher’s side, with the timer thread, the background pool and message handlers in detail.
Time, scheduling and clocks: the block clock, the timer thread and domain clocks.
Channels: building a channel tree.