The asynchronous task executor powers every non-blocking logger. It accepts work from multiple producer threads and drains it on a dedicated worker. This document describes how the executor behaves across build configurations, provides guidance on tuning the backpressure policies, and explains the lifetime guarantees that logger integrations rely on.
- Structure: one worker thread (
m_worker_thread) consumes astd::dequeprotected bym_queue_mutex. - Synchronisation: producers and the worker coordinate through
m_queue_conditionand them_stop_flagatomic. - Backpressure policies are implemented literally on the protected deque.
- Intended for environments where a simple mutex-protected queue is sufficient or where the lock-free ring cannot be used.
- Structure: producers push tasks into
m_mpsc_queue, a lock-freeMpscRingAny<std::function<void()>>with a single consumer thread. - Synchronisation primitives:
m_cv+m_cv_mutexcoordinate sleepers for both the worker and producers that wait for capacity duringQueuePolicy::Block.m_queue_conditionwakeswait()callers once the queue drains.m_active_taskstracks in-flight work so thatBlocklimits concurrent execution andwait()can determine quiescence.m_stop_flagterminates the worker and stops accepting new tasks.
- Enables very low producer overhead while maintaining FIFO ordering on the consumer side.
- Structure: single-threaded
std::dequeguarded bym_mutex. - No dedicated worker thread is created. Instead, tasks are drained via
emscripten_async_callscheduled from the main loop. - Not thread-safe — intended for WebAssembly builds where pthreads are not available.
QueuePolicy controls what happens when the queue reaches max_queue_size
(0 means "unbounded").
Block- Uses
m_active_tasksto count in-flight work. If the counter reaches the limit, producers wait. The non-MPSC build waits onm_queue_condition. The MPSC build parks onm_cvwith short sleeps while the worker drains tasks. The sleep interval defaults toLOGIT_TASK_EXECUTOR_BLOCK_WAIT_USECmicroseconds (200 by default) and can be overridden at compile time. This policy avoids loss at the expense of producer-side backpressure.
- Uses
DropNewest- Non-MPSC: the incoming task is discarded when the deque is full.
- MPSC: identical semantics — the incoming task is dropped and
m_dropped_tasksis incremented.
DropOldest- Non-MPSC: the oldest dequeued element is removed, then the incoming task is enqueued, providing literal "drop the oldest" behaviour.
- MPSC: drop-incoming semantics. The executor rejects the incoming task
instead of racing to remove an old element. This preserves the order of
tasks already accepted by the consumer, avoids lock-step coordination
between multiple producers and the worker, and keeps the implementation
TSAN-clean.
m_dropped_tasksstill counts these rejections.
The drop counter is observable via TaskExecutor::dropped_tasks() and exposed to
end users through LOGIT_GET_DROPPED_TASKS().
set_max_queue_size() performs a "hot" resize without tearing down the
application.
m_resizingis set totruewith release semantics.- Producers that already entered
add_task()are allowed to finish or the resize is abandoned after the bounded resize deadline. wait()drains the queue and ensuresm_active_tasks == 0.- The worker is stopped by setting
m_stop_flag, notifying sleepers, and joining the thread so it no longer touchesm_mpsc_queue. - In a single thread the ring is rebuilt with the new capacity. The resize
keeps
m_dropped_tasksintact but resetsm_active_tasksto 0 because the queue is empty. - The worker thread is restarted and the stop flag cleared.
m_resizingflips back tofalseandm_resize_cv.notify_all()wakes producers that parked at the start ofadd_task().
While the resize is in progress, new producers briefly wait on m_resize_cv.
No accepted tasks are lost, and the consumer thread never observes partially
initialised ring buffers. Calling set_max_queue_size() or
set_queue_policy() after shutdown is a no-op; these calls must not restart or
mutate the stopped singleton worker.
- Exactly one consumer thread executes tasks, so work is processed in the order accepted by the consumer.
- When the ring build is enabled,
DropNewestandDropOldestboth drop the incoming task; accepted tasks keep their order. wait()returns once the queue is empty andm_active_tasks == 0, or when a shutdown is requested. In MPSC builds the worker marks a pop attempt active before removing a task, sowait()cannot return in the narrow window between a dequeued cell becoming free and the task body starting.shutdown()blocks until the worker thread terminates. It is safe to call multiple times.
TaskExecutor::get_instance() intentionally stores the singleton inside a
static TaskExecutor* instance = new TaskExecutor();. This lets the executor
outlive static destructors inside logger components. Applications may call
shutdown() explicitly (for example during test teardown), but the singleton
remains valid until the process terminates.
Logger backends with Config::use_dedicated_executor=true own a
SingleThreadExecutor instead of using this singleton. Native builds create one
worker thread per configured logger, while single-threaded Emscripten builds use
a cooperative per-instance queue. Logger::shutdown() calls each backend's
ILogger::shutdown() hook before stopping the global executor so these
logger-owned workers drain and stop cleanly. For SingleThreadExecutor,
wait() waits for already accepted tasks to drain, while shutdown() rejects
new tasks and joins the worker on native builds. Full lifecycle guarantees
(including no-op behaviour after shutdown) are documented in the Doxygen
comments of SingleThreadExecutor.hpp.
When targeting Emscripten without pthread support:
- The executor remains single-threaded and therefore not thread-safe.
Blockis approximated by invokingdrain()from the producer path until the deque has room.DropNewest/DropOldestmirror the deque operations exactly.- Tasks are executed by
emscripten_async_call, which schedules a drain on the browser event loop. This keeps logging compatible with the cooperative execution model used in WebAssembly UI scenarios. - Dedicated logger executors use the same cooperative scheduling model in this build; no OS thread is created.
- Typical use cases: browser-hosted tools or demos that need asynchronous-style logging without pulling in pthread support.
Public methods exposed by TaskExecutor:
set_max_queue_size(std::size_t size)— change the queue capacity (0disables the limit). Trigger a hot resize on MPSC builds.set_queue_policy(QueuePolicy policy)— change overflow behaviour.add_task(std::function<void()> fn)— enqueue work for the background worker.wait()— block until the queue drains or stop is requested.shutdown()— stop the worker thread and release resources.dropped_tasks()andreset_dropped_tasks()— inspect or reset the overflow counter.
Macros in <logit_cpp/logit/log_macros.hpp> map directly onto these calls:
LOGIT_SET_MAX_QUEUE(size)→set_max_queue_size(size)LOGIT_SET_QUEUE_POLICY(mode)→set_queue_policy(mode)LOGIT_QUEUE_BLOCK,LOGIT_QUEUE_DROP_NEWEST,LOGIT_QUEUE_DROP_OLDESTselect the enum value.LOGIT_GET_DROPPED_TASKS()andLOGIT_RESET_DROPPED_TASKS()forward to the counter helpers.
Basic setup using macros:
#include <logit.hpp>
int main() {
LOGIT_ADD_CONSOLE_DEFAULT();
LOGIT_SET_QUEUE_POLICY(LOGIT_QUEUE_BLOCK);
LOGIT_INFO("async logging is live");
LOGIT_WAIT();
}Hot resize while the system is running (only with LOGIT_USE_MPSC_RING):
auto& executor = logit::detail::TaskExecutor::get_instance();
LOGIT_SET_QUEUE_POLICY(LOGIT_QUEUE_BLOCK);
// Later, increase the capacity without losing accepted tasks.
LOGIT_SET_MAX_QUEUE(1024); // producers briefly wait for resize to finishInspecting drops under DropNewest:
LOGIT_SET_QUEUE_POLICY(LOGIT_QUEUE_DROP_NEWEST);
LOGIT_SET_MAX_QUEUE(16);
LOGIT_RESET_DROPPED_TASKS();
for (int i = 0; i < 1000; ++i) {
LOGIT_INFO("burst", i);
}
LOGIT_WAIT();
const auto lost = LOGIT_GET_DROPPED_TASKS();- All public methods on non-Emscripten builds are thread-safe. Producers may
call
add_task()concurrently withset_max_queue_size()andset_queue_policy(). - The hot-resize barrier uses
m_resizing,m_resize_cv, and an active-producer counter so producers never touch a ring buffer that is being rebuilt. This eliminates the data races that TSAN previously reported ontry_pop()vs. buffer assignment. The barrier only proceeds once producers have paused, the worker thread fully stops, and the queue drains; if a sink blocks the worker orQueuePolicy::Blockprevents producers from reaching the pause point for more than one second,set_max_queue_size()abandons the hot resize, clearsm_resizing, and leaves the existing ring untouched so producers cannot wait indefinitely. Non-MPSC builds perform the resize as an atomic update ofm_max_queue_size, so they are not subject to this stall. - Non-MPSC builds rely solely on mutexes and had no known data races.
- The Emscripten path is single-threaded and should not be used concurrently.
QueuePolicy::Blocklimits the number of in-flight tasks tracked bym_active_tasks. Use it to introduce producer-side backpressure when the downstream sinks are expensive.- The worker drains up to
LOGIT_TASK_EXECUTOR_DRAIN_BUDGETtasks per iteration when the ring is enabled. Increase this "budget" inTaskExecutor::worker_function()if your workload generates extremely large bursts and the worker sleeps too often. Reducing it can lower per-iteration latency for latency-sensitive applications. - Adjust
LOGIT_TASK_EXECUTOR_DEFAULT_RING_CAPACITYat compile time to select a different default capacity whenLOGIT_USE_MPSC_RINGis active. - Monitor
dropped_tasks()during load testing to verify that the chosen policy matches the application's tolerance for loss.