21 KiB
Threading Model
This document describes CubicSDR's threading architecture, synchronization mechanisms, and thread lifecycle management.
Thread Infrastructure
IOThread Base Class
File: src/IOThread.h
All worker threads inherit from IOThread, which provides:
- Lifecycle management:
stopping(atomic bool,protected) for async termination;terminated(atomic bool,private) for completion. The destructor sets both flags totrue. - Named queue bindings:
setInputQueue(name, queue)/setOutputQueue(name, queue)with string-keyed maps - Thread entry:
threadMain()resets both flags tofalseat entry, wrapsrun()in try/catch, sets bothterminatedandstoppingtotrueon exit (and re-throws exceptions after setting flags) - Spin-wait sleep:
isTerminated(timeout)busy-waits with 5ms sleep (SPIN_WAIT_SLEEP_MS) between checks. Returns immediately whenwaitMs == 0; waits indefinitely whenwaitMs < 0. - Timer:
gTimer(protected) is available for subclass use
Thread Creation Pattern
CubicSDR does not use wxThread. The standard pattern is std::thread:
threadObject = new ThreadClass(...);
t_stdThread = new std::thread(&ThreadClass::threadMain, threadObject);
Exception: on macOS, DemodulatorInstance::run() creates DemodulatorPreThread and DemodulatorThread via pthread_create with ~2MB stack sizes (2048000 bytes) to control the stack size directly. The demodulator thread .cpp files install only the pthread_setschedparam priority logic, not the stack size.
Thread Inventory
| Thread | Class | File | Purpose |
|---|---|---|---|
| Main (wxWidgets UI) | AppFrame |
src/AppFrame.h |
Event loop, OpenGL rendering, UI interaction |
| SDR Thread | SDRThread |
src/sdr/SoapySDRThread.h |
Reads IQ samples from SoapySDR hardware |
| SDR Post-Processing | SDRPostThread |
src/sdr/SDRPostThread.h |
DC blocking, channelization, IQ distribution |
| SDR Enumerator | SDREnumerator |
src/sdr/SDREnumerator.h |
Background device discovery |
| Spectrum Visual | SpectrumVisualDataThread |
src/process/SpectrumVisualDataThread.h |
FFT processing for main spectrum display |
| Demod Visual | SpectrumVisualDataThread |
src/process/SpectrumVisualDataThread.h |
FFT processing for demod spectrum display |
| Waterfall FFT | FFTVisualDataThread |
src/process/FFTVisualDataThread.h |
Rate-limited waterfall FFT data |
| Demodulator Pre-Thread (N) | DemodulatorPreThread |
src/demod/DemodulatorPreThread.h |
Frequency shifting, resampling, modem switching |
| Demodulator Worker (N) | DemodulatorWorkerThread |
src/demod/DemodulatorWorkerThread.h |
Offloaded modem/filter creation |
| Demodulator Thread (N) | DemodulatorThread |
src/demod/DemodulatorThread.h |
Core demodulation, squelch, signal levels |
| Audio Thread (N) | AudioThread |
src/audio/AudioThread.h |
Per-demod audio processing and device binding |
| Audio Controller | AudioThread |
src/audio/AudioThread.h |
Per-device RtAudio stream owner and mixer |
| Audio Sink (N) | AudioSinkThread |
src/audio/AudioSinkThread.h |
Abstract base with its own IOThread run loop; subclasses implement sink() and inputChanged(). Input queue bounded at 1000 items. Has its own std::recursive_mutex m_mutex |
| Audio Sink File (N) | AudioSinkFileThread |
src/audio/AudioSinkFileThread.h |
Audio file recording |
| Rig Control (optional) | RigThread |
src/rig/RigThread.h |
Hamlib CAT control |
Communication Patterns
Pattern 1: Queue-Based Data Flow (Primary)
All data-carrying threads communicate via ThreadBlockingQueue<T>:
- Blocking push / timed pop for critical data (demod pre-thread output, worker commands) — pushes block indefinitely; pops use a short heartbeat timeout
- Non-blocking try_push/try_pop for visualization and the demod→audio output legs (data loss acceptable)
- Most queue items are
std::shared_ptr<T>(IQ data, audio data); worker command/result queues use value types
Pattern 2: Atomic Flags for Control
Atomic variables signal parameter changes between UI and worker threads. Boolean flags (all std::atomic_bool unless noted) are polled each iteration by the worker:
- SDRThread:
freq_changed,rate_changed,offset_changed,antenna_changed,ppm_changed,device_changed,agc_mode_changed,gain_value_changed,setting_value_changed,frequency_locked,frequency_lock_init,iq_swap,hasPPM,hasHardwareDC,agc_mode; also carries state instd::atomicvalues:deviceConfig(pointer),deviceInfo(pointer),sampleRate,frequency,offset,lock_freq(atomic_llong),ppm,numElems,mtuElems,numChannels(atomic_int) - DemodulatorPreThread:
frequencyChanged,bandwidthChanged,sampleRateChanged,audioSampleRateChanged,demodTypeChanged,modemSettingsChanged,initialized - DemodulatorInstance:
active,muted,deltaLock,recording,follow,tracking; alsodeltaLockOfs,currentOutputDevice(atomic_int),currentAudioGain(atomic<float>),label(atomic pointer tostd::string),user_label(atomic pointer tostd::wstring) - DemodulatorThread:
muted,squelchEnabled,squelchBreak(atomic<bool>),squelchLevel,signalLevel,signalFloor,signalCeil(atomic<float>) - SDRPostThread:
doRefresh— signals the channelizer to re-initialize on the next processing loop; alsochanMode(atomic_int) for channelizer type selection - CubicSDR:
devicesReady,devicesFailed,soloMode,shuttingDown; alsofrequency_locked,deviceSelectorOpen,sampleRateInitialized,useLocalMod - AudioThread:
active,outputDevice(atomic_int) - FFTVisualDataThread:
linesPerSecond(atomic_int),lpsChanged - RigThread:
freqChanged,setOneShot,controlMode,followMode,centerLock,followModem,errorState - AppFrame:
deviceChanged,modemPropertiesUpdated - The polling thread checks flags each iteration and applies changes
Pattern 3: Controller/Bound Audio Mixing
- One AudioThread per physical output device owns the RtAudio stream (controller)
- Other AudioThreads bind to the controller via
bindThread() - The
audioCallback(real-time context) iterates bound threads, pops audio viatry_pop(), and mixes into the output buffer - The controller
AudioThreaddestructor callscontrollerThread->join()without acquiringm_mutex— intentional to avoid deadlocks; safe because it only runs after all bound threads have detached - The
audioCallbackacquiresstd::recursive_mutex(the controller'sm_mutexand each bound thread'sm_mutex) — this is a potential priority inversion risk in the real-time audio callback - A static
std::recursive_mutex m_device_mutexprotects the globaldeviceControllermap anddeviceSampleRatemap across all AudioThread instances. When a controller's sample rate changes, it stops/closes/reopens the RtAudio stream and notifies all demodulators of the rate change
Pattern 4: Worker Thread for Expensive Operations
DemodulatorWorkerThreadhandles modem creation and filter building- PreThread sends commands via bounded queue (max 2 items)
- Worker responds with results via separate queue (max 100 items)
- Prevents expensive liquid-dsp initialization from blocking the data path
Pattern 5: Callback Notification (Worker → UI)
- SDRThread and SDREnumerator call
sdrThreadNotify()/sdrEnumThreadNotify()onCubicSDRfrom worker threads. SDRThread calls both methods for different purposes: it usessdrEnumThreadNotifyfor progress messages (e.g. "Initializing device") andsdrThreadNotifyfor final states (SDR_THREAD_INITIALIZED,SDR_THREAD_FAILED). SDREnumerator calls onlysdrEnumThreadNotify. - These methods store messages in
std::string notifyMessageprotected bystd::mutex notify_busy sdrEnumThreadNotifysetsstd::atomic_boolflags (devicesReadyonSDR_ENUM_DEVICES_READY,devicesFailedonSDR_ENUM_FAILED);sdrThreadNotifystores messages but does not set these flags- The UI polls these in
SDRDevicesdialog viagetNotification() - Note:
sdrThreadNotifywithSDR_THREAD_INITIALIZEDalso callsappframe->initDeviceParams()directly — a cross-thread write to internal AppFrame state (pointer +deviceChangedatomic flag, no UI widget manipulation) - Worker threads also set atomic flags for UI updates:
DemodulatorWorkerThreadcallsnotifyUpdateModemProperties()which setsAppFrame::modemPropertiesUpdated; UI polls this inOnIdle()
Pattern 6: VisualProcessor Pipeline (Threaded Distribution)
VisualProcessor<Input, Output>bridges FFT threads to UI canvases- Each processor has one input queue and N output queues
- Two distribution strategies:
VisualDataDistributor— zero-copy shared pointer forwardingVisualDataReDistributor— deep-copy viaReBufferpooling
- Protected by
std::mutex busy_updatefor queue list mutations
Pattern 7: UI-Thread Processing (ScopeVisualProcessor)
ScopeVisualProcessorhas no dedicated thread — it runs on the wxWidgets main thread viaAppFrame::handleScopeProcessor(), called fromAppFrame::OnIdle()- Data path:
DemodulatorThread→pipeAudioVisualDataqueue →ScopeVisualProcessor::process()(UI thread) →ScopeCanvas::inputDataqueue →ScopeCanvas::OnPaint() process()uses non-blockingtry_pop(), so the UI thread never blocks waiting for audio data- This differs from
SpectrumVisualProcessorandFFTVisualDataThread, which each run on dedicatedIOThreadinstances
Synchronization Mechanisms
| Mechanism | Location | Purpose |
|---|---|---|
ThreadBlockingQueue<T> |
Throughout | Primary inter-thread data transfer |
std::condition_variable_any |
ThreadBlockingQueue |
Enables blocking push/pop with SpinMutex |
SpinMutex |
ThreadBlockingQueue, ReBuffer, DemodulatorThread (m_mutexAudioVisOutputQueue), GLFont |
Lightweight lock for high-frequency queue operations and dynamic rebinding |
std::atomic<T> |
SDRThread, DemodulatorPreThread, DemodulatorThread, DemodulatorInstance, CubicSDR, IOThread, AudioThread, AppFrame, FFTVisualDataThread, WaterfallCanvas, RigThread | Lock-free parameter change signaling and state management |
std::recursive_mutex |
AudioThread (m_mutex), AudioSinkThread, DemodulatorInstance (m_thread_control_mutex), DemodulatorMgr, BookmarkMgr |
Protecting shared mutable state with re-entrant access |
std::mutex |
IOThread (m_queue_bindings_mutex), SDRThread (setting_busy, gain_busy), VisualProcessor (busy_update), SpectrumVisualProcessor (busy_run), DeviceConfig (busy_lock), WaterfallCanvas (tex_update), DigitalConsole (stream_busy), ModemDigitalOutputConsole (stream_busy), DemodulatorThread (static squelchLockMutex), CubicSDR (notify_busy) |
Protecting infrequent mutations and visualization state |
std::recursive_mutex (static) |
AudioThread (m_device_mutex) |
Protects the global deviceController map and deviceSampleRate map across all AudioThread instances |
Heartbeat Period
All IOThread subclasses that use a timed pop() in their main loop share a consistent 50ms heartbeat timeout (HEARTBEAT_CHECK_PERIOD_MICROS = 50 * 1000). This ensures the stopping atomic flag is checked at ~20Hz, enabling responsive shutdown without dedicated interrupt mechanisms. Threads that do not use a timed pop — SDRThread (blocking readStream), SDREnumerator (single-pass run()), RigThread (uses sleep_for(150ms)), and the visual data threads SpectrumVisualDataThread/FFTVisualDataThread (sleep-looped) — rely on their own blocking/sleep behavior for shutdown. The constant is defined locally in each translation unit (not shared), which is a minor duplication but has no behavioral impact.
SpinMutex
File: src/util/SpinMutex.h
Non-recursive spinlock using std::atomic_flag with acquire/release memory ordering. Used as the internal lock for ThreadBlockingQueue and ReBuffer due to its low overhead for short critical sections.
ReBuffer Pooling
File: src/IOThread.h
ReBuffer<T> is a shared_ptr-based buffer pool for minimizing allocation overhead in hot data paths. The pool's getBuffer() method is protected by SpinMutex. For the full pool mechanics (GC thresholds, reuse logic, warning thresholds), see signal-flow.md "Buffer Management".
VisualProcessor Pipeline
File: src/process/VisualProcessor.h
VisualProcessor<Input, Output> is a template base class for the visualization pipeline. Two threading models exist for VisualProcessor subclasses:
- Dedicated thread:
SpectrumVisualProcessorruns onSpectrumVisualDataThread(sleep-loop callingsproc.run()periodically).FFTVisualDataThreadruns bothFFTDataDistributorandSpectrumVisualProcessoron a singleIOThread. - UI thread:
ScopeVisualProcessorruns on the wxWidgets main thread viaAppFrame::handleScopeProcessor()→process()(non-blockingtry_pop). See Pattern 7.
For the full pipeline internals (VisualProcessor API, distribution modes, FFT processing, ScopeVisualProcessor processing), see visual-data-pipeline.md.
SpectrumVisualProcessor busy_run
File: src/process/SpectrumVisualProcessor.h
spectrumVisualProcessor uses std::mutex busy_run to serialize FFT computation against parameter changes. All setter/getter methods (called from the UI thread) acquire this mutex. For the two-phase locking algorithm narrative (process() internals), see visual-data-pipeline.md "SpectrumVisualProcessor — Full Per-Frame Pipeline".
SDREnumerator One-Shot Spawning
SDREnumerator threads are spawned as needed (device refresh, remote add, re-enumeration) without joining the previous instance. Each call to threadMain performs a single enumeration pass and exits. The old thread pointer is overwritten without cleanup — a fire-and-forget pattern (no join or delete of the previous instance). Since delete is never called on the old pointer, the std::thread destructor is never invoked (so std::terminate() is not triggered by the overwrite itself). The actual consequences are a memory leak of the orphaned std::thread object and a potentially still-running thread cleaned up only at process exit. The real thread-safety issue is a data race on the t_SDREnum pointer if addRemote() or reEnumerateDevices() are called concurrently. Additionally, OnExit() does not join or delete t_SDREnum/sdrEnum, unlike every other thread pair in the shutdown sequence.
Thread Lifecycle
Startup Sequence
In CubicSDR::OnInit() (src/CubicSDR.cpp):
- Queue plumbing — all global queues created and wired
SpectrumVisualDataThreadstartedDemodVisualDataThreadstarted (if enabled)SDRPostThreadstartedSDREnumeratorobject created (but thread not yet started)AppFramecreated (wxWidgets main window) — its constructor creates aFFTVisualDataThreadfor the waterfall display and starts it immediatelySDREnumeratorthread started- Device selection triggers
SDRThreadstart (inCubicSDR::setDevice())
Per-Demodulator Startup
When DemodulatorInstance::run() is called (protected by m_thread_control_mutex):
AudioThreadstartedDemodulatorPreThreadstartedDemodulatorThreadstarted
An AudioSinkFileThread may also be started on-demand when recording is activated (startRecording()). It is not part of the standard startup — it is created, joined, and deleted entirely within the startRecording()/stopRecording() pair.
Shutdown Sequence
In CubicSDR::OnExit():
stopRig()— callsRigThread::terminate()(sets atomic flag), thenisTerminated(1000)andjoin()s and deletes the rig thread andRigThreadobjects (if rig is active)SDRThread::terminate()— stops producing IQ data (waited up to 3s)SDRPostThread::terminate()— stops channelizing (waited up to 3s)DemodulatorMgr::terminateAll()— terminates all demodulator instances (queues flushed inside eachDemodulatorInstance::terminate())- Visual processor threads terminated (spectrum and demod, waited up to 1s each)
- All
std::threadobjects joined and deleted (t_SDR,t_PostSDR,t_DemodVisual,t_SpectrumVisual); corresponding thread objects deleted AudioThread::deviceCleanup()— deletes controller AudioThreads for all devices
The waterfall FFTVisualDataThread (created inside AppFrame) is terminated and joined in AppFrame::~AppFrame(), which runs when wxWidgets destroys the frame after OnExit() returns.
If any termination step times out, the application calls ::exit() with a step-specific error code rather than risk hanging indefinitely (11 = SDR thread, 12 = SDR post-thread, 13 = visual processor threads).
Note: t_SDREnum and sdrEnum are not joined or deleted in OnExit(). They rely on process exit for cleanup.
Per-Demodulator Shutdown
DemodulatorInstance::terminate() (notably not protected by m_thread_control_mutex, unlike run() and isTerminated()):
AudioThread::terminate()— stops consuming audioDemodulatorThread::terminate()— stops demodulatingDemodulatorPreThread::terminate()— stops resampling (also terminates worker thread with a 5000ms timeout, then joins and deletes it)- If recording is active,
stopRecording()— detaches theAudioSinkFileThreadoutput queue, joins and deletes the sink thread - All queues flushed (
pipeIQInputData,pipeAudioData,pipeIQDemodData) to unblock pending pushes
The actual thread join/cleanup happens in isTerminated(), which is called from the destructor in a spin-wait loop with a 3000ms timeout (20ms intervals). The destructor holds m_thread_control_mutex throughout. If isTerminated() returns true within the timeout, threads are joined and deleted. If the timeout expires, the destructor exits without cleanup — thread objects are leaked.
Known Issues
macOS audio thread join bug: In DemodulatorInstance::isTerminated(), the macOS cleanup path for the audio thread (DemodulatorInstance.cpp) calls pthread_join(t_PreDemod, NULL) — a copy-paste error where t_PreDemod was used instead of t_Audio. In the typical shutdown order t_PreDemod has already been joined and set to nullptr, so this calls pthread_join(NULL, ...) which returns ESRCH (no thread found). If pre-demod has not yet terminated, the call blocks until the pre-demod thread finishes, then the pre-demod block attempts a redundant join on the next call. Either way, the audio thread's std::thread object is never joined or deleted. On macOS, t_PreDemod and t_Demod are pthread_t (not pointers), while t_Audio is std::thread* on all platforms — so even with the correct variable name, pthread_join would be the wrong API. The non-macOS path (t_Audio->join() / delete t_Audio) is correct.
SDREnumerator not cleaned up on exit: t_SDREnum and sdrEnum are never joined or deleted in OnExit() or anywhere else in the codebase. They rely on process exit for cleanup.
Waterfall thread not deleted in AppFrame destructor: AppFrame::~AppFrame() calls waterfallDataThread->terminate() and t_FFTData->join(), but neither the std::thread* object nor the FFTVisualDataThread* are deleted — a memory leak on shutdown.
DemodulatorInstance destructor timeout: If ~DemodulatorInstance() exceeds 3000ms waiting for termination, the spin-wait loop exits and thread objects (demodulatorPreThread, demodulatorThread, audioThread, audioSinkThread) are never deleted — a resource leak.
Thread Priorities (macOS)
On macOS, threads are assigned scheduling priorities:
| Thread | Policy | Priority |
|---|---|---|
| SDR Post-Processing | SCHED_FIFO |
sched_get_priority_max |
| Demodulator Pre-Thread | SCHED_FIFO |
max - 1 |
| Demodulator Thread | SCHED_FIFO |
max - 1 |
| Audio Thread (controller & bound) | SCHED_RR |
max - 1 |
| Audio Sink Thread | SCHED_RR |
max - 1 |
Note: SDR Thread has SCHED_FIFO priority code but the entire #ifdef __APPLE__ block is commented out (as is the main wxWidgets thread's SCHED_RR block in CubicSDR.cpp).
Windows and Linux use default thread priorities.
wxWidgets Integration
The UI thread is primarily pull-based:
AppFrame::OnIdle()is called continuously by the wx event loop and handles device params, modem properties, and UI state- Each canvas registers its own
EVT_IDLEhandler independently — canvases poll their input queues viatry_pop()inOnIdle()(WaterfallCanvas) orOnPaint()(SpectrumCanvas, ScopeCanvas). See visual-rendering.md "OnIdle Processing" for per-canvas details. - Visual data flows are pull-based: worker threads push to queues, UI canvases pull via
try_pop() - Shared state uses
std::atomicvariables (frequency, signal levels, mute state) - UI-initiated changes go through atomic variables and flags, not wx events
- Exception:
SDRThreadtriggersrefreshGainUI()from the worker thread vianotifyMainUIOfDeviceChange(), which rebuildsGainCanvaspanels and triggersRefresh()— a cross-thread UI mutation outside the pull-based pattern