Audio Workgroups on macOS: Coordinate Auxiliary Real-Time Threads to a Deadline
Coordinate auxiliary macOS audio threads with Audio Workgroups: select the right deadline, join safely, handle device changes, and avoid priority myths.
An audio render thread can meet its deadline while an auxiliary worker misses the same deadline. The result may be an underrun even though each thread appears to run at real-time priority. On macOS, Audio Workgroups let the system know that a set of real-time threads contributes to a shared audio deadline. The system can use that relationship when balancing power use and rendering capacity.
A workgroup is coordination metadata, not a priority boost, lock, queue, or deadline guarantee. It does not make arbitrary work real-time safe. It helps the system reason about cooperating real-time threads that already have a legitimate reason to run with real-time scheduling. The key design task is choosing the workgroup whose deadline actually matches the work, then joining and leaving it on the participating thread. Audio Workgroups became available in macOS 11; check deployment targets and SDK availability before using these APIs in older-system support paths.
Choose the deadline before choosing the API
Core Audio devices own workgroups associated with their I/O thread. For an auxiliary real-time thread that is triggered by that I/O thread and must finish within the current device cycle, join the device’s workgroup. An Audio I/O unit exposes the associated group as AUAudioUnit.osWorkgroup in the Objective-C API or through kAudioOutputUnitProperty_OSWorkgroup in the Audio Unit v2 C API. A lower-level client can obtain a device’s I/O workgroup through the relevant HAL device property.
Audio Units need a slightly different rule. If a plug-in’s auxiliary worker is dispatched from the plug-in render call and must finish before that render request returns, the host provides a render-context observer that runs before render requests. Use the workgroup supplied through that render context rather than assuming that a device group or previously observed group remains correct. The context’s workgroup can be null for a non-real-time context or an ungrouped real-time thread, and it can change over the unit’s lifetime; check it before attempting to join.
An independent periodic audio workload has no device render cycle to join. Create an os_workgroup_interval_t with AudioWorkIntervalCreate, then have its coordinating thread announce each start and deadline with os_workgroup_interval_start and close the interval with os_workgroup_interval_finish. All threads that perform work for that interval join the interval’s workgroup. Do not create a separate interval for work that is really part of a device or Audio Unit render deadline; that would describe the wrong relationship to the system.
Join from the worker that will do the work
os_workgroup_join applies to the current thread. A control thread cannot join on behalf of a worker that it will create later. Pass a retained workgroup reference and per-thread token to the worker, join at thread entry before dispatching real-time work, and leave on that same thread before it exits. Keep one token per join; do not share a token among simultaneous workers.
#include <os/workgroup.h>
struct WorkerContext {
os_workgroup_t workgroup; // Retained for the worker's lifetime.
};
int AudioWorker(WorkerContext* context) {
os_workgroup_join_token_s token{};
const int joinError = os_workgroup_join(context->workgroup, &token);
if (joinError != 0) {
return joinError; // Let the non-real-time owner report after this exits.
}
while (ShouldProcessAudio()) {
ProcessOneAudioWorkUnit();
}
os_workgroup_leave(context->workgroup, &token);
return 0;
}
This is a lifetime sketch, not a thread-launch wrapper. ShouldProcessAudio, error reporting, and shutdown signaling must themselves fit the execution context; do not add blocking locks, allocation, logging, or synchronous messaging to the render path. Track each successful join with its matching workgroup and token, and leave that membership before releasing the workgroup reference. A join error such as EALREADY indicates that the thread already belongs to a non-nestable workgroup; do not blindly retry or ignore it. Treat setup failure as a policy decision, such as declining to start that auxiliary real-time path.
The framework-managed render thread normally participates in its own workgroup already. If it is the only thread doing the work, adding explicit membership is unnecessary. The point is to add the auxiliary participants that share the deadline, not to repeatedly join a thread that is already managed by the framework.
Keep device and plug-in membership fresh
A device-owned workgroup’s lifetime follows the device. An output unit’s assigned device can change, and the associated workgroup changes with it. Treat route or device reconfiguration as a lifecycle event: stop or quiesce auxiliary workers according to the audio graph’s rules, obtain the currently associated workgroup again, and arrange each worker’s membership accordingly. Retain references while they are in use and release them only after joined workers have left.
For an Audio Unit, implement the render-context observer and consume its current context rather than caching a workgroup from plug-in initialization. The observer can receive a new workgroup as rendering configuration changes. Coordinate changes so the old worker cannot keep using a stale reference after it has left the old group, and so the new worker joins the group that corresponds to its render requests. The host’s render lifecycle remains authoritative; a plug-in should not invent its own device deadline.
These APIs are for real-time threads. Apple marks the relevant workgroup interfaces unavailable to Swift because Swift runtime behavior is not considered safe for this use. A Swift control plane can still configure the wider application, but code that executes on a real-time thread should use an appropriate real-time-safe implementation boundary rather than calling arbitrary Swift facilities from the callback.
Announce asynchronous cycles in one clock domain
An asynchronous interval reports when a batch of work begins and the deadline it intends to meet. AudioWorkIntervalCreate takes a clock identifier, such as OS_CLOCK_MACH_ABSOLUTE_TIME; timestamps passed to the interval APIs must use that same clock domain. They are clock ticks, not sample-frame counts, wall-clock strings, or a duration in seconds. Convert durations with the correct clock timebase outside the critical callback, and derive each cycle’s start and deadline from a monotonic source.
#include <AudioToolbox/AudioWorkInterval.h>
#include <cstdint>
#include <os/object.h>
#include <os/clock.h>
#include <os/workgroup.h>
int RunInterval() {
os_workgroup_interval_t interval = AudioWorkIntervalCreate(
"analysis-render", OS_CLOCK_MACH_ABSOLUTE_TIME, nullptr);
if (interval == nullptr) {
return SetupFailed();
}
os_workgroup_join_token_s token{};
int error = os_workgroup_join(interval, &token);
if (error != 0) {
os_release(interval);
return error;
}
while (RunRequested()) {
const uint64_t start = NextCycleStartMachTicks();
const uint64_t deadline = DeadlineFor(start);
error = os_workgroup_interval_start(interval, start, deadline, nullptr);
if (error != 0) {
HandleIntervalError(error);
break;
}
DispatchAndWaitForCycleWorkers();
error = os_workgroup_interval_finish(interval, nullptr);
if (error != 0) {
HandleIntervalError(error);
break;
}
}
os_workgroup_leave(interval, &token);
os_release(interval);
return error;
}
NextCycleStartMachTicks, DeadlineFor, and dispatch are application-defined. The example omits conversion details deliberately: use mach_timebase_info when converting a duration to Mach absolute ticks, account for overflow, and never mix ticks from different clocks. Keep the loop, error handling, and dispatch bounded and real-time safe; publish diagnostics to a non-real-time control plane instead of logging or taking a blocking lock here. Start and finish must be paired, and the coordinator itself must have joined the interval’s workgroup. Finishing means the cycle’s participating work has completed; it is not just a timer notification. If the workload is irregular, update an active interval’s deadline only according to the API’s supported interval semantics rather than issuing overlapping starts.
Release the created interval after its participants have stopped and left. For an interval shared with workers, ownership must outlive every worker reference and every active cycle. Do not destroy it as soon as a stop request is sent.
Validate the relationship, not just the return code
Test with the actual audio device, sample rates, route changes, and host lifecycle used by the product. Add and remove auxiliary workers while measuring render deadline misses and power impact. Confirm that the worker has joined the expected group, leaves during every shutdown path, and cannot outlive the retained workgroup object. In an Audio Unit, test null and changed render contexts as well as repeated resource allocation.
Audio Workgroups do not excuse unbounded work, priority inversion, blocking I/O, or a missed scheduling budget. Keep the amount of auxiliary work bounded, make cancellation and teardown explicit, and instrument end-to-end render deadlines. If a task does not share the same completion deadline as the audio render cycle, it likely belongs outside that render workgroup.
Related:
- AUv3 on macOS: Extension Discovery and Real-Time Audio Rendering
- AVAudioEngine on macOS: Graph Construction, Format Negotiation, and Recovery
Sources: