Csound turns Orchestra code into sound one control block at a time. In ordinary rendering, much of the engine can move in a straight line: read an event, initialize an instrument, perform it, and eventually turn it off. Realtime use adds other workers. MIDI, API calls, dynamic compilation, asynchronous file access, and diskin2 can all be active while the audio thread is trying to meet its next deadline.
This guide explains how Csound coordinates that work. It starts with the relevant C concepts, then follows the locks through event insertion, instrument lifetime, disk streaming, and cleanup.
Note
Source used by this guide: snapshot 473e86135. These are engine internals, not a promise that every identifier will become public API.
Open the interactive lock map. Select a path to isolate the audio thread, init work, diskin2, the file registry, or the required lock order.
Imagine two stagehands changing the same microphone stand at once. One raises it while the other loosens the base. Both actions are reasonable alone, but together the microphone may meet the floor. Shared memory has the same problem.
A thread is one sequence of instructions being executed. Two threads can run at the same time, or the operating system can switch rapidly between them. If both touch the same memory, that memory is shared state.
Here is a simplified queue counter:
int queue_items = 7;
/* Producer A */ /* Producer B */
int next = queue_items + 1; int next = queue_items + 1;
queue_items = next; queue_items = next;Both producers can read 7, both calculate 8, and both store 8. Two items arrived, but the counter says one arrived. This is a race condition. In C, an unsynchronized data race is not merely unlucky. Its behavior is undefined, which means the compiler is not required to preserve the result a human expects.
A critical section is the small piece of code that must behave as one indivisible operation:
lock(&queue_lock);
slot = write_position;
queue[slot] = request;
write_position = next_slot(slot);
queue_items++;
unlock(&queue_lock);The lock does not make the code faster. It makes ownership unambiguous. The important engineering question is not simply "should this be locked?" It is also "which lock owns this data, and how long may that lock be held?"
Important
A lock protects an invariant, not a line of code. For the allocation queue, the invariant is: every published count corresponds to one fully written slot, and no two producers reserve the same slot.
You do not need to be a C expert to read the examples in this guide.
| C term | Meaning here |
|---|---|
struct |
A record containing related fields, such as an instrument instance and its lifecycle flags. |
Pointer (INSDS *ip) |
The address of an object. Multiple threads may hold the same address, which is why lifetime matters. |
NULL |
No object is present. It must be checked before dereferencing a pointer. |
int32_t |
A signed 32-bit integer from <stdint.h>. Fixed widths make engine layouts clearer across platforms. |
size_t |
An unsigned size/index type from <stddef.h>. It is intended for byte counts and object sizes. |
memcpy |
A byte-copy function from <string.h>. It copies memory, but it does not make a concurrent copy safe. |
| Atomic operation | A load, store, increment, or decrement that other threads cannot observe half-completed. |
volatile |
A request that accesses remain observable to the compiler. It is not a replacement for a lock or atomic operation. |
The C standard library supplies basic types and memory functions. Thread support exists in C11 through <threads.h> and atomics through <stdatomic.h>, but platform support varies. Csound therefore uses its own wrappers such as csoundSpinLock, csoundSpinTryLock, and csoundSpinUnLock. Those wrappers let the engine choose suitable platform primitives without scattering operating-system code everywhere.
These tools solve related problems, but they are not interchangeable.
If a mutex is already owned, a waiting thread normally sleeps until it can continue. Sleeping is efficient for long waits, but waking a thread has unpredictable latency.
The branch uses init_pass_threadlock to serialize init and reinit passes. This mutex belongs to non-audio work. It may cover a long init pass because the audio callback must never wait for it.
A spinlock repeatedly checks until ownership becomes available. This avoids a sleep/wake transition, but burns CPU while waiting. It is useful only when the protected operation is very short.
csoundSpinLock(&csound->alloc_queue_spinlock);
/* Copy one queue item and adjust two indices. */
csoundSpinUnLock(&csound->alloc_queue_spinlock);If disk I/O happens inside that section, the spinlock has enrolled in the wrong kind of spin class.
A try-lock asks once and returns immediately if another thread owns the lock.
if (csoundSpinTryLock(&csound->alloc_spinlock) == CSOUND_SUCCESS) {
expire_due_notes(csound);
csoundSpinUnLock(&csound->alloc_spinlock);
}
else {
/* Leave the work pending for the next control cycle. */
}The audio thread uses this pattern for work that can safely wait one control period. A missed maintenance opportunity is preferable to an audio dropout.
An atomic increment is useful for a small, independent value such as a reference count. It does not automatically protect a larger relationship between fields.
/* This update is indivisible. */
ATOMIC_INCR(owner->async_ref_count);If async_ref_count, turnoff_pending, and a list pointer must change as one lifecycle transition, the code still needs async_ref_spinlock around the complete decision.
| Tool | Waiting behavior | Good use | Bad use on audio thread |
|---|---|---|---|
| Mutex | Sleeps or blocks | Long, non-audio serialization | Any path the callback must wait for |
| Spinlock | Busy-waits | Very short pointer/counter update | Disk access, allocation, file close, long traversal |
| Try-lock | Returns immediately | Deferrable audio maintenance | Work that must complete in the current block |
| Atomic | One indivisible value operation | Counters and state publication | Multi-field invariant without another protocol |
In non-realtime mode, score insertion takes the direct path:
score event -> insert() -> init pass -> active instrument -> performance
insert_event() calls insert() directly. There is no realtime allocation queue or event-insert worker. The lifecycle helper functions still exist, but their lock wrappers do nothing when realtime_locks_initialized is false. This keeps one lifecycle implementation without making offline rendering pay for locks that have no competing thread.
diskin2 also chooses synchronous reading outside realtime mode. Its asynchronous worker is selected only when realtime mode is active and forced synchronization is disabled.
This does not mean "thread safety never matters offline." Hosts can still call APIs from other threads, and some explicit asynchronous facilities create workers. It means the normal score-to-init path is serialized and can remain simple.
Realtime mode separates deadline-sensitive work from expensive work:
API / MIDI / opcode producer
|
v
bounded allocation queue
|
v
realtime event/init thread ---- runs init, reinit, and merge work
|
v
active instrument state <------ audio thread performs one block at a time
The audio thread owns the deadline. The event thread owns potentially expensive initialization. Disk and file workers own blocking I/O. Locks connect these worlds only long enough to transfer ownership.
Warning
Realtime rule: no mutex wait, disk read, file close, or allocation-heavy init work belongs on the performance thread. A spinlock is acceptable only for a tightly bounded state change.
The realtime lock set is initialized as one unit. If the platform has no real spin primitive, asynchronous realtime mode fails instead of silently substituting a blocking mutex or a no-op lock. That is a deliberate safety decision in realtime_spin_lock_init().
| Lock | Kind | Protects | Audio-thread policy | Without it |
|---|---|---|---|---|
alloc_queue_spinlock |
Spinlock | Allocation ring slots, read/write positions, item count | Producers hold it briefly; audio-facing callers return on overflow | Two producers overwrite a slot or publish the wrong count |
init_pass_threadlock |
Recursive mutex | Global init/reinit context and merged global init | Never acquired by the audio thread | Two init passes overwrite curip, ids, or engine state |
alloc_spinlock |
Spinlock | Active chains and allocation transitions | Try-lock for deferrable audio work | Insert, expiry, and turnoff corrupt shared instrument links |
instance_spinlock |
Spinlock | INSTRTXT::instance and act_instance reuse lists |
Short list transfer only | The same inactive INSDS can be handed to two callers |
async_ref_spinlock |
Spinlock | Init depth, sticky turnoff state, async borrow count, pending handoff | Short lifecycle transitions | An INSDS is freed while init or disk I/O still uses it |
rt_event_spinlock |
Spinlock | Pending and reusable realtime event nodes | Audio uses try-lock | A producer and consumer break the event list |
diskin2_async_lock |
Spinlock | Active/free/deferred-close reader-entry registries | Worker-only short registry edits | Worker follows removed entries or loses a deferred close |
entry->spinlock |
Per-entry spinlock | One diskin2 reader's active/borrowed/owner fields |
Never spans a disk read | Deinit removes a reader while the worker is borrowing it |
open_files_lock |
Spinlock | Open/retired file lists and reader stamps | Pointer bookkeeping only | A close frees a CSFILE while the worker is reading it |
Realtime producers do not call insert() directly. They copy a request into a bounded ring and publish it. The queue lock makes reservation, copy, and count update one critical section:
lock(queue);
if (queue_items == MAX_ALLOC_QUEUE) {
result = ERROR;
}
else {
queue[write_position] = request;
write_position = wrap(write_position + 1);
queue_items++;
}
unlock(queue);The event thread performs the inverse operation under the same lock. Notice what the lock does not cover: instrument allocation and init. The request is removed first, then the queue lock is released. Otherwise every producer would spin for the duration of an init pass.
With the lock, a count always names a complete item. Without it, a consumer may see an increment before the slot is ready, or two producers may reserve the same index.
Csound init code uses engine-wide context such as csound->curip, csound->ids, and csound->mode. Two concurrent init passes would keep replacing those values under each other.
init_pass_threadlock serializes init, reinit, and merged global init. It is recursive because an init-time operation can enter another init-aware path on the same thread.
The important dance is:
lock init mutex
lock alloc spinlock
prepare shared engine state
unlock alloc spinlock
run init opcodes <- long work, alloc lock is free
lock alloc spinlock
publish the result
unlock alloc spinlock
unlock init mutex
realtime_init_pass() explicitly releases alloc_spinlock before calling init opcodes. That matters because an init opcode may itself need the allocation lock. Holding it would both lengthen contention and invite self-deadlock.
The active instrument chain is read and changed by performance-time expiry, event insertion, and turnoff. A linked-list update usually needs several writes:
previous->next = current->next;
current->previous = NULL;
current->next = free_list;
free_list = current;An interruption halfway through can leave one object in two lists or in no list. alloc_spinlock protects these allocation and active-chain transitions.
This lock is an outer lock when it must be nested. The required order is alloc_spinlock before the instance, async-reference, or realtime-event lock. The audio path usually tries once and defers if the lock is busy.
INSDS is Csound's runtime instrument-instance structure. Inactive instances are reused to avoid repeated allocation. The instance chain records allocated objects and act_instance acts as a reuse list.
Without a separate lock, two threads could inspect the same free-list head, both remove it, and both initialize the same memory for different notes. instance_spinlock makes taking or returning an instance exclusive. Expensive cleanup happens after the instance has been detached and the lock released.
An INSDS can be inactive but still in use by another worker. The branch tracks three related facts:
init_running: a depth counter, because init work can be nested or queued more than once.turnoff_pending: a sticky lifecycle state (NONE,REQUESTED,FINALIZING, orRECLAIM).async_ref_count: background users that must finish before reuse or free.
The simplified state machine is:
begin init:
init_running++
turnoff during init:
turnoff_pending = REQUESTED
init_done = false
finish init:
init_running--
if init_running == 0 and turnoff was requested:
turnoff_pending = FINALIZING
enqueue for the performance thread
performance boundary:
try alloc lock
deactivate and unlink safely
The request remains sticky until the outermost init finishes. A boolean would be insufficient because two overlapping init passes can finish in either order. The counter prevents the first finisher from publishing an instance that the second pass still uses.
The performance thread completes the turnoff because it owns active-chain traversal. This avoids an event thread repurposing nxtact while the audio thread is walking it.*
Init-time opcodes can schedule realtime events while the audio thread checks which events are due. rt_event_spinlock protects the pending list and the pool of reusable event nodes.
The audio thread calls a try-lock helper. If another producer is editing the list, no event node is removed during that cycle. It remains pending and is reconsidered on the next cycle. This is a useful realtime pattern: preserve correctness, tolerate bounded lateness.
Asynchronous diskin2 has a registry of reader entries. The registry lock protects which entries are active, free, or waiting for deferred close. Each entry has its own lock for active, borrowed, and owner fields.
The worker's essential protocol is:
lock(async_reference);
lock(entry);
if (entry_is_live) {
entry->borrowed = 1;
owner->async_ref_count++;
instance = entry->instance;
}
unlock(entry);
unlock(async_reference);
disk_read(instance); /* no spinlock held */
lock(entry);
entry->borrowed = 0;
unlock(entry);
lock(async_reference);
owner->async_ref_count--;
unlock(async_reference);The real implementation also handles two workers, scalar and array output, plus deferred close. The key idea is still small: acquire a safe borrow while locks prove the owner is alive, release every lock for disk I/O, then publish the release.
On turnoff, deinit marks the entry inactive so no new borrow can begin. It does not wait for an in-flight read. The borrow count prevents the owning INSDS from being recycled until that read ends.
Tip
A reference count answers "is somebody still using this?" It does not answer "may a new user start?" The entry's active flag closes the door; the reference count waits for everyone already inside to leave.
The asynchronous file worker traverses CSFILE nodes. A synchronous close cannot free the current node while the worker has a pointer to it.
The branch uses a reader-pin protocol:
under open_files_lock:
choose one async file
io_readers++
without open_files_lock:
perform file I/O
under open_files_lock:
io_readers--
outside the lock:
reclaim retired nodes whose reader count reached zero
Closing a currently borrowed node marks it retired and unlinks it. Actual close and memory release occur only after its reader count reaches zero. This is safer than holding the registry lock across sndfile calls, and much kinder to the realtime path.
file_io_threadlock also appears in this subsystem. It is used as a wake-up/coordination gate for the worker, not as the ownership lock for open_files. Keeping those jobs separate makes the critical section easier to reason about.
Locks solve races and can create deadlocks. The classic failure is an ABBA order:
Thread A: owns alloc, waits for instance
Thread B: owns instance, waits for alloc
Neither can continue.
The branch records these nesting rules:
init_pass_threadlock
-> alloc_spinlock
-> instance_spinlock
-> async_ref_spinlock
-> rt_event_spinlock
diskin2_async_lock -> entry->spinlock
async_ref_spinlock -> entry->spinlock
alloc_queue_spinlock is intentionally independent. Enqueue or dequeue finishes before the engine begins allocation work. open_files_lock is also treated as a short registry lock and is released before file I/O or close.
When reviewing code that needs two locks, write the order in a comment near the data structures, not only near one call site. A future maintainer can then compare both sides of the relationship.
Caution
Never fix a race by extending a spinlock over unknown code. An opcode callback, allocator, logger, or file function can wait, recurse, or take another lock. The "simple fix" may exchange a crash for an audio stall or deadlock.
Understanding the failure is often more useful than memorizing the lock name.
Without queue synchronization: a producer increments the item count before its copied strings and p-fields are complete. The event thread consumes partially initialized data.
With the protocol: copy and publication are one queue-lock transition. Queue overflow returns an error instead of overwriting unread work.
Without lifecycle coordination: an instrument is turned off while its init pass is still using INSDS. The object can enter the reuse list and be initialized for another note while the first init continues.
With the protocol: turnoff becomes sticky, init_done is cleared, and final deactivation waits for init depth to reach zero.
Without borrowing: diskin2 starts a read, the note ends, and cleanup frees its opcode storage. The worker resumes and dereferences freed memory.
With the protocol: the worker increments async_ref_count before releasing the entry lock. Cleanup sees a nonzero count and cannot recycle the owner.
Without reader pins: a worker caches current->next; another thread removes and frees current; the worker reads from released memory.
With the protocol: one node is pinned, I/O happens unlocked, and a concurrent close retires rather than frees the node.
With the wrong lock: the callback blocks on a mutex held across disk I/O. Correctness may survive, but audio timing does not.
With the realtime design: the callback uses a try-lock where work is deferrable. Potentially blocking work belongs to event or I/O workers.
Ask these questions in order:
- What exact invariant does the lock protect? Name the fields and lists.
- Which threads can reach them? Include cleanup, reset, reinit, UDOs, and workers.
- Can the audio thread enter this code? If yes, a mutex is already suspicious.
- Is the work bounded? Pointer swaps are bounded. Allocation and file I/O are not.
- Can work be deferred? Prefer try-lock plus retry for maintenance that can wait one block.
- What is the nesting order? Compare every path that acquires two locks.
- Who owns object lifetime? A protected pointer is useless if its target can be freed after unlock.
- What happens during shutdown? Stop new work, drain published work, join workers, then destroy locks and memory.
- What happens on error? A failed deinit should not skip every later cleanup callback.
- Is there a test for the interleaving? A test should make the dangerous window wide enough to fail before the fix.
- In the queue pseudocode, move
queue_items++afterunlock(). Explain how the consumer can miss work or observe publication out of order. - Replace the audio try-lock with a blocking mutex. What happens if an init opcode keeps the mutex for 20 ms at a 64-sample block size?
- Change
init_runningfrom a counter to a boolean. Trace two queued reinit passes and a turnoff between their completions. - Hold
entry->spinlockduringdisk_read(). The data race disappears. What realtime failure did you introduce? - Remove the
io_readerspin from the file worker. Draw the exact point wherecsoundFileClose()may freecurrent.
| Term | Short definition |
|---|---|
| Audio/performance thread | Runs control and audio-rate opcode work under a hard timing deadline. |
| Event/init thread | Consumes queued realtime work and runs serialized init/reinit passes. |
| Worker thread | Background thread for work such as disk or file I/O. |
INSDS |
Runtime storage and linkage for one instrument instance. |
| Invariant | A relationship that must always be true when a protected operation finishes. |
| Race condition | Result depends on an uncontrolled interleaving of concurrent operations. |
| Data race | Unsynchronized concurrent access where at least one access writes. Undefined behavior in C. |
| Critical section | Code that owns a lock while updating shared state. |
| Deadlock | Threads wait in a cycle and none can progress. |
| Livelock | Threads keep reacting but accomplish no useful work. |
| Starvation | One thread repeatedly loses access and makes no progress. |
| Use-after-free | Code dereferences an object after its storage was released. |
| Reference count | Number of users that currently retain an object. |
| Retirement | Remove an object from new use now, reclaim it after existing readers finish. |
- Realtime lifecycle helpers and allocation queue:
Engine/insert.c - Audio-side try-locks, event lists, startup, and shutdown:
Engine/musmon.c diskin2registry, borrowing, and deferred close:OOps/diskin2.c- Async file retirement and reader pins:
Engine/filesys.c - UDO and subinstrument init bracketing:
Engine/udo.c - Runtime structures and lock-order comments:
include/csoundCore.h - Focused branch tests:
test_async_diskin.csd,test_diskin2_nested_reuse.csd,engine_test.cpp, andio_test.cpp
Protect shared metadata with short critical sections.
Keep one documented order when locks nest.
Use try-locks for deferrable audio work.
Borrow objects before dropping their ownership lock.
Perform init, allocation, disk I/O, and close away from the audio deadline.
Retire first; reclaim only after readers finish.
That is the whole score. The rest is orchestration.
* This is currently not fully implemented on develop. The lifecycle and diskin2 descriptions follow the pinned snapshot above.