Skip to content

Instantly share code, notes, and snippets.

@hlolli
Last active July 18, 2026 11:06
Show Gist options
  • Select an option

  • Save hlolli/c15e97cf5faf8357d8832871c09622b6 to your computer and use it in GitHub Desktop.

Select an option

Save hlolli/c15e97cf5faf8357d8832871c09622b6 to your computer and use it in GitHub Desktop.
Locks, Clocks, and Sound Blocks: a developer field guide to Csound7 realtime concurrency

Locks, Clocks, and Sound Blocks

A developer field guide to concurrency in Csound7

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.

Csound realtime lock map

Open the interactive lock map. Select a path to isolate the audio thread, init work, diskin2, the file registry, or the required lock order.


1. Why a synthesizer needs locks

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.

A small C vocabulary

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.

2. Mutex, spinlock, try-lock, and atomic

These tools solve related problems, but they are not interchangeable.

Mutex

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.

Spinlock

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.

Try-lock

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.

Atomic operation

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

3. Two execution worlds

Non-realtime rendering

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 performance

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().


4. The lock atlas

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

4.1 alloc_queue_spinlock: publish complete work

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.

4.2 init_pass_threadlock: one conductor for init

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.

4.3 alloc_spinlock: protect the active stage

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.

4.4 instance_spinlock: one owner per reusable instance

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.

4.5 async_ref_spinlock: do not remove the chair while someone is sitting

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, or RECLAIM).
  • 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.*

4.6 rt_event_spinlock: a short appointment book lock

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.

4.7 diskin2_async_lock and entry->spinlock: borrow, unlock, read

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.

4.8 open_files_lock: retire now, close later

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.


5. Lock ordering and deadlock

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.


6. Five failure stories

Understanding the failure is often more useful than memorizing the lock name.

Story 1: the half-published event

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.

Story 2: init meets turnoff

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.

Story 3: the disk worker keeps an old pointer

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.

Story 4: close while traversing

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.

Story 5: the audio thread waits politely, forever

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.


7. How to review a realtime critical section

Ask these questions in order:

  1. What exact invariant does the lock protect? Name the fields and lists.
  2. Which threads can reach them? Include cleanup, reset, reinit, UDOs, and workers.
  3. Can the audio thread enter this code? If yes, a mutex is already suspicious.
  4. Is the work bounded? Pointer swaps are bounded. Allocation and file I/O are not.
  5. Can work be deferred? Prefer try-lock plus retry for maintenance that can wait one block.
  6. What is the nesting order? Compare every path that acquires two locks.
  7. Who owns object lifetime? A protected pointer is useless if its target can be freed after unlock.
  8. What happens during shutdown? Stop new work, drain published work, join workers, then destroy locks and memory.
  9. What happens on error? A failed deinit should not skip every later cleanup callback.
  10. Is there a test for the interleaving? A test should make the dangerous window wide enough to fail before the fix.

8. Small exercises

  1. In the queue pseudocode, move queue_items++ after unlock(). Explain how the consumer can miss work or observe publication out of order.
  2. 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?
  3. Change init_running from a counter to a boolean. Trace two queued reinit passes and a turnoff between their completions.
  4. Hold entry->spinlock during disk_read(). The data race disappears. What realtime failure did you introduce?
  5. Remove the io_readers pin from the file worker. Draw the exact point where csoundFileClose() may free current.

Appendix A: glossary

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.

Appendix B: source map

Appendix C: the shortest useful summary

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.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xyflow/react@12.11.2/dist/style.css">
<div id="csound-realtime-lock-map">
<div class="viz-controls" role="group" aria-label="Diagram focus">
<button type="button" class="btn btn-primary" data-focus="all" aria-pressed="true">System</button>
<button type="button" class="btn" data-focus="audio" aria-pressed="false">Audio path</button>
<button type="button" class="btn" data-focus="init" aria-pressed="false">Init path</button>
<button type="button" class="btn" data-focus="disk" aria-pressed="false">Disk I/O</button>
<button type="button" class="btn" data-focus="files" aria-pressed="false">File registry</button>
<button type="button" class="btn" data-focus="order" aria-pressed="false">Lock order</button>
</div>
<div class="lock-map-stage" aria-label="Csound realtime lock relationships"></div>
<div class="lock-map-footer">
<div class="lock-map-legend" aria-label="Legend">
<span><i class="legend-dot spin"></i>Realtime spinlock</span>
<span><i class="legend-dot mutex"></i>Non-audio mutex</span>
<span><i class="legend-dot io"></i>I/O spinlock</span>
<span><i class="legend-line order"></i>Required order</span>
<span><i class="legend-line release"></i>Release before work</span>
</div>
<div class="lock-map-detail" aria-live="polite">Audio invariant: no mutex, disk read, file close, or allocation-heavy init work on the performance thread.</div>
</div>
</div>
<style>
#csound-realtime-lock-map {
color-scheme: light dark;
--background: light-dark(rgb(255 255 255), rgb(24 24 24));
--foreground: light-dark(rgb(26 28 31), rgb(255 255 255));
--card: light-dark(rgb(255 255 255), rgb(39 39 42));
--card-foreground: var(--foreground);
--muted: light-dark(rgb(235 237 240), rgb(53 53 57));
--muted-foreground: light-dark(rgb(105 111 120), rgb(174 174 181));
--border: light-dark(rgb(215 219 225), rgb(69 69 75));
--ring: light-dark(rgb(51 156 255), rgb(131 195 255));
--viz-series-1: light-dark(rgb(51 156 255), rgb(131 195 255));
--viz-series-2: light-dark(rgb(243 136 59), rgb(245 154 86));
--viz-series-3: light-dark(rgb(58 165 90), rgb(116 213 139));
--viz-series-4: light-dark(rgb(235 119 177), rgb(240 143 192));
--viz-series-5: light-dark(rgb(155 121 236), rgb(170 145 239));
color: var(--foreground);
box-sizing: border-box;
width: 100%;
max-width: 1120px;
margin: 0 auto;
padding: 16px;
background: var(--background);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
}
#csound-realtime-lock-map .viz-controls {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-bottom: 10px;
}
#csound-realtime-lock-map .btn {
min-height: 32px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--card);
color: var(--foreground);
font: 500 13px/1 ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0;
cursor: pointer;
}
#csound-realtime-lock-map .btn:hover {
border-color: var(--ring);
}
#csound-realtime-lock-map .btn-primary {
border-color: var(--foreground);
background: var(--foreground);
color: var(--background);
}
#csound-realtime-lock-map .lock-map-stage {
width: 100%;
height: 820px;
min-height: 680px;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: color-mix(in srgb, var(--background) 94%, var(--muted));
}
#csound-realtime-lock-map .react-flow__attribution {
display: none;
}
#csound-realtime-lock-map .react-flow__node {
border: 0;
background: transparent;
font: inherit;
}
#csound-realtime-lock-map .react-flow__edge-text {
fill: var(--foreground);
font-size: 11px;
}
#csound-realtime-lock-map .react-flow__edge-textbg {
fill: var(--background);
fill-opacity: 0.94;
}
#csound-realtime-lock-map .react-flow__edge-path {
stroke: var(--muted-foreground, rgb(110 116 125));
stroke-width: 2.2;
opacity: 0.94;
}
#csound-realtime-lock-map .react-flow__edge.edge-acquire .react-flow__edge-path {
stroke: var(--viz-series-1, rgb(51 156 255));
}
#csound-realtime-lock-map .react-flow__edge.edge-io .react-flow__edge-path {
stroke: var(--viz-series-2, rgb(243 136 59));
}
#csound-realtime-lock-map .react-flow__edge.edge-protect .react-flow__edge-path {
stroke: var(--viz-series-3, rgb(58 165 90));
}
#csound-realtime-lock-map .react-flow__edge.edge-order .react-flow__edge-path {
stroke: var(--muted-foreground);
stroke-width: 1.35;
stroke-dasharray: 6 5;
}
#csound-realtime-lock-map .react-flow__edge.edge-release .react-flow__edge-path {
stroke: var(--viz-series-5, rgb(155 121 236));
stroke-dasharray: 2 5;
}
#csound-realtime-lock-map .react-flow__handle {
width: 5px;
height: 5px;
border: 0;
background: var(--muted-foreground);
opacity: 0.42;
}
#csound-realtime-lock-map .flow-node {
width: 210px;
min-height: 68px;
padding: 10px 11px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card);
color: var(--card-foreground);
box-shadow: 0 7px 20px color-mix(in srgb, var(--foreground) 8%, transparent);
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
#csound-realtime-lock-map .react-flow__node.selected .flow-node {
border-color: var(--ring);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 26%, transparent),
0 9px 24px color-mix(in srgb, var(--foreground) 10%, transparent);
}
#csound-realtime-lock-map .flow-node.actor {
background: color-mix(in srgb, var(--muted) 68%, var(--card));
}
#csound-realtime-lock-map .flow-node.spin {
background: color-mix(in srgb, var(--viz-series-1) 16%, var(--card));
}
#csound-realtime-lock-map .flow-node.mutex {
background: color-mix(in srgb, var(--viz-series-4) 16%, var(--card));
}
#csound-realtime-lock-map .flow-node.io-lock {
background: color-mix(in srgb, var(--viz-series-2) 16%, var(--card));
}
#csound-realtime-lock-map .flow-node.state {
background: color-mix(in srgb, var(--viz-series-3) 11%, var(--card));
}
#csound-realtime-lock-map .flow-node.no-lock {
background: color-mix(in srgb, var(--viz-series-5) 14%, var(--card));
}
#csound-realtime-lock-map .flow-node-head {
display: flex;
align-items: center;
gap: 8px;
}
#csound-realtime-lock-map .flow-node-icon {
display: grid;
flex: 0 0 28px;
width: 28px;
height: 28px;
place-items: center;
border-radius: 7px;
background: color-mix(in srgb, var(--foreground) 7%, transparent);
color: var(--foreground);
}
#csound-realtime-lock-map .flow-node-icon svg {
width: 17px;
height: 17px;
stroke-width: 1.8;
}
#csound-realtime-lock-map .flow-node-copy {
min-width: 0;
}
#csound-realtime-lock-map .flow-node-kicker {
margin-bottom: 2px;
color: var(--muted-foreground);
font-size: 10px;
font-weight: 500;
letter-spacing: 0;
text-transform: uppercase;
}
#csound-realtime-lock-map .flow-node-title {
overflow-wrap: anywhere;
color: var(--foreground);
font-size: 12px;
font-weight: 500;
letter-spacing: 0;
}
#csound-realtime-lock-map .flow-node.lock .flow-node-title {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
#csound-realtime-lock-map .flow-node-subtitle {
margin-top: 7px;
color: var(--muted-foreground);
font-size: 11px;
letter-spacing: 0;
}
#csound-realtime-lock-map .lock-map-footer {
display: grid;
gap: 8px;
padding-top: 10px;
}
#csound-realtime-lock-map .lock-map-legend {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
color: var(--muted-foreground);
font-size: 11px;
}
#csound-realtime-lock-map .lock-map-legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
#csound-realtime-lock-map .legend-dot {
width: 9px;
height: 9px;
border-radius: 50%;
}
#csound-realtime-lock-map .legend-dot.spin {
background: var(--viz-series-1);
}
#csound-realtime-lock-map .legend-dot.mutex {
background: var(--viz-series-4);
}
#csound-realtime-lock-map .legend-dot.io {
background: var(--viz-series-2);
}
#csound-realtime-lock-map .legend-line {
width: 18px;
height: 0;
border-top: 2px solid var(--muted-foreground);
}
#csound-realtime-lock-map .legend-line.order {
border-top-style: dashed;
}
#csound-realtime-lock-map .legend-line.release {
border-top-color: var(--viz-series-5);
border-top-style: dotted;
}
#csound-realtime-lock-map .lock-map-detail {
min-height: 24px;
padding: 8px 10px;
border-left: 3px solid var(--viz-series-1);
background: color-mix(in srgb, var(--muted) 48%, transparent);
color: var(--foreground);
font-size: 12px;
}
@media (max-width: 560px) {
#csound-realtime-lock-map .lock-map-stage {
height: 760px;
}
#csound-realtime-lock-map .flow-node {
width: 195px;
}
}
</style>
<script>
(async () => {
const [ReactModule, ReactDomModule, ReactFlowModule, IconModule] =
await Promise.all([
import('https://esm.sh/react@18.3.1'),
import('https://esm.sh/react-dom@18.3.1/client'),
import('https://esm.sh/@xyflow/react@12.11.2?deps=react@18.3.1,react-dom@18.3.1'),
import('https://esm.sh/lucide-react@0.468.0?deps=react@18.3.1')
]);
const React = ReactModule.default;
const { useEffect, useMemo, useState } = ReactModule;
const { createRoot } = ReactDomModule;
const {
ReactFlow,
Background,
Handle,
MarkerType,
Position
} = ReactFlowModule;
const {
Boxes,
CircleGauge,
Cpu,
FileClock,
HardDrive,
ListRestart,
LockKeyhole,
Network,
RadioTower,
ShieldCheck,
Workflow
} = IconModule;
const rootElement = document.getElementById('csound-realtime-lock-map');
const stageElement = rootElement.querySelector('.lock-map-stage');
const detailElement = rootElement.querySelector('.lock-map-detail');
const modeButtons = Array.from(rootElement.querySelectorAll('[data-focus]'));
const iconByName = {
boxes: Boxes,
gauge: CircleGauge,
cpu: Cpu,
file: FileClock,
disk: HardDrive,
list: ListRestart,
lock: LockKeyhole,
network: Network,
radio: RadioTower,
shield: ShieldCheck,
workflow: Workflow
};
const node = (id, x, y, kind, kicker, title, subtitle, icon, detail) => ({
id,
type: 'csoundNode',
position: { x, y },
data: { kind, kicker, title, subtitle, icon, detail },
draggable: false
});
const baseNodes = [
node('producer', 0, 0, 'actor', 'PRODUCER', 'API / opcode caller', 'Publishes work; never edits queue slots unlocked', 'radio', 'Producers reserve and publish allocation work under alloc_queue_spinlock, then leave immediately.'),
node('audio', 0, 185, 'actor', 'THREAD', 'Audio / performance', 'Renders, expires notes, applies deferred turnoffs', 'gauge', 'The performance thread never takes init_pass_threadlock and uses try-locks where waiting could interrupt audio.'),
node('event', 0, 390, 'actor', 'THREAD', 'Realtime event / init', 'Consumes queued work and runs serialized init passes', 'cpu', 'The event thread owns long init work; it releases alloc_spinlock before calling init opcodes.'),
node('disk', 0, 645, 'actor', 'WORKERS', 'diskin2 scalar + array', 'Borrow opcode state, read, then release ownership', 'disk', 'A worker pins the owning INSDS before reading and drops every lock before disk I/O.'),
node('file', 0, 825, 'actor', 'WORKER', 'Async file I/O', 'Traverses open files using short reader stamps', 'file', 'The file worker protects registry pointers and reader stamps, but never holds open_files_lock across file I/O.'),
node('allocqueue', 295, 0, 'spin lock', 'SPINLOCK', 'alloc_queue_spinlock', 'Allocation ring indices, slots, and item count', 'lock', 'Independent queue lock. Producers and the event thread hold it only long enough to publish or dequeue one item.'),
node('rtevent', 295, 100, 'spin lock', 'TRY ON AUDIO', 'rt_event_spinlock', 'Pending, retired, and recycled realtime events', 'lock', 'The audio thread uses a try-lock. Missing the lock postpones event maintenance instead of blocking a cycle.'),
node('initmutex', 295, 200, 'mutex lock', 'MUTEX', 'init_pass_threadlock', 'Serializes init, reinit, and merged global init', 'lock', 'Only non-audio paths may wait here. The common order is init_pass_threadlock before alloc_spinlock.'),
node('alloc', 295, 300, 'spin lock', 'TRY ON AUDIO', 'alloc_spinlock', 'Active chains and allocation transitions', 'lock', 'Outer realtime state lock. When nested, it is acquired before instance, async-reference, or event locks.'),
node('instance', 295, 400, 'spin lock', 'SPINLOCK', 'instance_spinlock', 'INSTRTXT instance chain and act_instance reuse list', 'lock', 'Prevents two threads from taking, publishing, or recycling the same inactive INSDS.'),
node('asyncref', 295, 500, 'spin lock', 'SPINLOCK', 'async_ref_spinlock', 'Borrow counts and init / turnoff lifecycle state', 'lock', 'Serializes async_ref_count, init_running, sticky turnoff state, and the pending turnoff handoff.'),
node('registry', 295, 620, 'io-lock lock', 'I/O SPINLOCK', 'diskin2_async_lock', 'Active, free, and deferred-close entry lists', 'lock', 'Registry order is diskin2_async_lock before an entry spinlock. No disk read occurs while it is held.'),
node('entry', 295, 720, 'io-lock lock', 'PER-ENTRY', 'entry->spinlock', 'One reader entry: active, borrowed, owner', 'lock', 'Borrow order is async_ref_spinlock before entry->spinlock. The entry lock never spans disk I/O.'),
node('openfiles', 295, 825, 'io-lock lock', 'I/O SPINLOCK', 'open_files_lock', 'Open, retired, and currently borrowed CSFILE nodes', 'lock', 'Pointer changes and reader stamps are protected; close and file I/O happen after releasing this lock.'),
node('queue', 605, 0, 'state', 'PROTECTED STATE', 'Allocation queue', 'Reserve, fill, publish; one bounded transition', 'list', 'The queue lock prevents producers from overwriting each other and rejects overflow rather than corrupting the ring.'),
node('eventlists', 605, 100, 'state', 'PROTECTED STATE', 'Realtime event lists', 'Pending events and reusable EVTNODE storage', 'network', 'Event nodes move between pending, retired, and recycle lists under rt_event_spinlock.'),
node('initwork', 605, 200, 'no-lock', 'LONG WORK', 'Init opcode pass', 'Mutex held; alloc spinlock deliberately released', 'workflow', 'Init remains serialized, but init opcodes may safely take alloc_spinlock themselves.'),
node('activechains', 605, 300, 'state', 'PROTECTED STATE', 'Active instrument chains', 'Activation, expiry, unlink, and final turnoff', 'network', 'alloc_spinlock protects short active-chain transitions shared by performance and event processing.'),
node('freelist', 605, 400, 'state', 'PROTECTED STATE', 'Inactive instance reuse', 'Skip INSDS objects still initializing or borrowed', 'boxes', 'An instance is reusable only when inactive, not initializing, not pending turnoff, and free of async references.'),
node('lifetime', 605, 500, 'state', 'PROTECTED STATE', 'INSDS lifetime handoff', 'init_running + turnoff_pending + async_ref_count', 'shield', 'Turnoff stays sticky until the outermost init finishes; background borrowers keep the INSDS unreclaimable.'),
node('diskregistry', 605, 620, 'state', 'PROTECTED STATE', 'diskin2 entry registry', 'Only active entries are scanned; inactive entries are reused', 'list', 'Registry membership is separate from per-entry borrowing so the worker can scan without retaining dead entries.'),
node('diskio', 605, 720, 'no-lock', 'NO LOCK HELD', 'Disk read and deferred close', 'Potentially blocking work stays outside spinlocks', 'disk', 'The worker snapshots a safe borrow, releases all locks, performs I/O, then reacquires short locks to publish release.'),
node('fileio', 605, 825, 'no-lock', 'NO LOCK HELD', 'File I/O pass', 'Only the current node is reader-pinned', 'file', 'A close retires a currently borrowed node and the worker reclaims it after its reader stamp reaches zero.')
];
const darkTheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
const edgeColor = darkTheme
? {
acquire: 'rgb(131 195 255)',
io: 'rgb(245 154 86)',
protect: 'rgb(116 213 139)',
order: 'rgb(164 164 170)',
release: 'rgb(170 145 239)'
}
: {
acquire: 'rgb(51 156 255)',
io: 'rgb(243 136 59)',
protect: 'rgb(58 165 90)',
order: 'rgb(110 116 125)',
release: 'rgb(155 121 236)'
};
const edge = (id, source, target, kind, modes, label = '') => ({
id,
source,
target,
label,
className: `edge-${kind}`,
type: 'smoothstep',
animated: kind === 'acquire' || kind === 'io',
markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor[kind] },
style: {
stroke: edgeColor[kind],
strokeWidth: kind === 'order' ? 1.6 : 2.2,
strokeDasharray: kind === 'order' ? '6 5' : kind === 'release' ? '2 5' : undefined
},
labelStyle: { fill: 'var(--foreground)', fontWeight: 400 },
labelBgStyle: { fill: 'var(--background)', fillOpacity: 0.92 },
data: { modes }
});
const baseEdges = [
edge('producer-queue', 'producer', 'allocqueue', 'acquire', ['all', 'init']),
edge('queue-state', 'allocqueue', 'queue', 'protect', ['all', 'init']),
edge('event-queue', 'event', 'allocqueue', 'acquire', ['all', 'init'], 'dequeue'),
edge('audio-events', 'audio', 'rtevent', 'acquire', ['all', 'audio'], 'try'),
edge('events-state', 'rtevent', 'eventlists', 'protect', ['all', 'audio']),
edge('audio-alloc', 'audio', 'alloc', 'acquire', ['all', 'audio'], 'try'),
edge('audio-instance', 'audio', 'instance', 'acquire', ['all', 'audio']),
edge('audio-lifetime', 'audio', 'asyncref', 'acquire', ['all', 'audio']),
edge('event-mutex', 'event', 'initmutex', 'acquire', ['all', 'init']),
edge('mutex-work', 'initmutex', 'initwork', 'protect', ['all', 'init']),
edge('event-alloc', 'event', 'alloc', 'acquire', ['all', 'init']),
edge('event-instance', 'event', 'instance', 'acquire', ['all', 'init']),
edge('event-lifetime', 'event', 'asyncref', 'acquire', ['all', 'init']),
edge('alloc-state', 'alloc', 'activechains', 'protect', ['all', 'audio', 'init']),
edge('instance-state', 'instance', 'freelist', 'protect', ['all', 'audio', 'init']),
edge('lifetime-state', 'asyncref', 'lifetime', 'protect', ['all', 'audio', 'init', 'disk']),
edge('disk-registry', 'disk', 'registry', 'io', ['all', 'disk']),
edge('disk-owner', 'disk', 'asyncref', 'io', ['all', 'disk']),
edge('registry-state', 'registry', 'diskregistry', 'protect', ['all', 'disk']),
edge('registry-entry-runtime', 'registry', 'entry', 'io', ['all', 'disk'], 'select'),
edge('ref-entry-runtime', 'asyncref', 'entry', 'io', ['all', 'disk'], 'borrow'),
edge('entry-io', 'entry', 'diskio', 'release', ['all', 'disk'], 'release first'),
edge('file-registry', 'file', 'openfiles', 'io', ['all', 'files']),
edge('file-io', 'openfiles', 'fileio', 'release', ['all', 'files'], 'release first'),
edge('order-init-alloc', 'initmutex', 'alloc', 'order', ['order'], 'before'),
edge('order-alloc-instance', 'alloc', 'instance', 'order', ['order'], 'before'),
edge('order-alloc-async', 'alloc', 'asyncref', 'order', ['order'], 'before'),
edge('order-alloc-event', 'alloc', 'rtevent', 'order', ['order'], 'before'),
edge('order-reg-entry', 'registry', 'entry', 'order', ['order'], 'before'),
edge('order-ref-entry', 'asyncref', 'entry', 'order', ['order'], 'before')
];
const focusNodes = {
all: new Set(baseNodes.map((item) => item.id)),
audio: new Set(['audio', 'rtevent', 'eventlists', 'alloc', 'activechains', 'instance', 'freelist', 'asyncref', 'lifetime']),
init: new Set(['producer', 'event', 'allocqueue', 'queue', 'initmutex', 'initwork', 'alloc', 'activechains', 'instance', 'freelist', 'asyncref', 'lifetime']),
disk: new Set(['disk', 'asyncref', 'lifetime', 'registry', 'diskregistry', 'entry', 'diskio']),
files: new Set(['file', 'openfiles', 'fileio']),
order: new Set(['allocqueue', 'rtevent', 'initmutex', 'alloc', 'instance', 'asyncref', 'registry', 'entry', 'openfiles'])
};
const focusDetail = {
all: 'Audio invariant: no mutex, disk read, file close, or allocation-heavy init work on the performance thread.',
audio: 'The audio path prefers bounded spin sections and try-locks; deferred work remains visible for a later cycle.',
init: 'The event thread holds the init mutex, briefly takes alloc_spinlock, then releases alloc before running init opcodes.',
disk: 'The worker pins INSDS ownership under async_ref_spinlock, releases every lock for the read, then publishes release.',
files: 'open_files_lock protects links and reader stamps; actual file I/O and close operations happen after unlock.',
order: 'Nested order: init mutex → alloc → instance / async-reference / event; diskin registry or async-reference → entry.'
};
function CsoundNode({ data, selected }) {
const Icon = iconByName[data.icon] || LockKeyhole;
const kindClass = data.kind === 'io-lock lock' ? 'io-lock lock' : data.kind;
return React.createElement(
React.Fragment,
null,
React.createElement(Handle, { type: 'target', position: Position.Left }),
React.createElement(
'div',
{ className: `flow-node ${kindClass}${selected ? ' selected' : ''}` },
React.createElement(
'div',
{ className: 'flow-node-head' },
React.createElement('span', { className: 'flow-node-icon' }, React.createElement(Icon, { 'aria-hidden': true })),
React.createElement(
'div',
{ className: 'flow-node-copy' },
React.createElement('div', { className: 'flow-node-kicker' }, data.kicker),
React.createElement('div', { className: 'flow-node-title' }, data.title)
)
),
React.createElement('div', { className: 'flow-node-subtitle' }, data.subtitle)
),
React.createElement(Handle, { type: 'source', position: Position.Right })
);
}
const nodeTypes = { csoundNode: CsoundNode };
function LockMap() {
const [focus, setFocus] = useState('all');
const [flow, setFlow] = useState(null);
const nodes = useMemo(
() => baseNodes.map((item) => ({ ...item, hidden: !focusNodes[focus].has(item.id) })),
[focus]
);
const edges = useMemo(
() => baseEdges.map((item) => ({
...item,
hidden: !item.data.modes.includes(focus) ||
!focusNodes[focus].has(item.source) ||
!focusNodes[focus].has(item.target)
})),
[focus]
);
useEffect(() => {
const selectFocus = (event) => {
const nextFocus = event.currentTarget.dataset.focus;
setFocus(nextFocus);
detailElement.textContent = focusDetail[nextFocus];
modeButtons.forEach((button) => {
const active = button.dataset.focus === nextFocus;
button.setAttribute('aria-pressed', String(active));
button.classList.toggle('btn-primary', active);
});
};
modeButtons.forEach((button) => button.addEventListener('click', selectFocus));
return () => modeButtons.forEach((button) => button.removeEventListener('click', selectFocus));
}, []);
useEffect(() => {
if (!flow) return;
const timer = window.setTimeout(() => {
flow.fitView({ padding: 0.16, duration: 420, maxZoom: focus === 'all' ? 0.92 : 1.08 });
}, 30);
return () => window.clearTimeout(timer);
}, [flow, focus]);
return React.createElement(
ReactFlow,
{
nodes,
edges,
nodeTypes,
onInit: setFlow,
onNodeClick: (_event, selectedNode) => {
detailElement.textContent = selectedNode.data.detail;
},
nodesDraggable: false,
nodesConnectable: false,
elementsSelectable: true,
panOnDrag: true,
zoomOnScroll: true,
zoomOnPinch: true,
minZoom: 0.42,
maxZoom: 1.35,
proOptions: { hideAttribution: true },
colorMode: 'system',
fitView: true,
fitViewOptions: { padding: 0.16, maxZoom: 0.92 }
},
React.createElement(Background, {
gap: 22,
size: 1,
color: 'var(--border)'
})
);
}
createRoot(stageElement).render(React.createElement(LockMap));
})().catch((error) => {
const stage = document.querySelector('#csound-realtime-lock-map .lock-map-stage');
if (stage)
stage.textContent = `Unable to load the interactive diagram: ${error.message}`;
console.error(error);
});
</script>
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment