How spdlog Asynchronous Logging Works Internally: Lock-Free Queues and Thread Pools
spdlog implements asynchronous logging using a lock-free multi-producer multi-consumer (MPMC) queue and a dedicated thread pool that decouples log message formatting from I/O operations, allowing producer threads to return immediately after enqueuing messages.
The gabime/spdlog library achieves high-throughput logging by moving expensive sink operations to background worker threads. Understanding how spdlog asynchronous logging works internally reveals an architecture built on lock-free data structures, atomic operations, and careful resource lifecycle management. This design ensures that calls to logger->info() or logger->error() introduce minimal latency to your application's critical path.
Core Architecture Components
spdlog's asynchronous system consists of three tightly integrated components that work together to buffer and process log messages without blocking producers.
The Lock-Free MPMC Queue
At the heart of the system lies spdlog::details::mpmc_blocking_q<log_msg>, implemented in include/spdlog/details/mpmc_blocking_q.h. This circular buffer uses atomic head and tail indices to enable multiple threads to enqueue log_msg objects simultaneously without mutex contention.
The queue operates as a ring buffer where producers push formatted messages via queue.push_back(msg). When the queue reaches capacity, producers block (or drop messages depending on configuration) until worker threads consume entries via queue.pop_front(). This lock-free design eliminates lock contention between application threads and logging threads.
The Thread Pool
The spdlog::details::thread_pool class, defined in include/spdlog/details/thread_pool.h, manages one or more worker threads that continuously monitor the MPMC queue. You initialize this pool via spdlog::init_thread_pool(queue_size, thread_count), which creates workers that run a tight loop waiting on a condition variable.
Each worker repeatedly calls queue.pop() to retrieve pending messages, then forwards them to the underlying synchronous logger's sink_it_() method. The thread count and queue size are configurable parameters that let you balance memory usage against throughput requirements.
The Async Logger Implementation
The spdlog::async_logger class, declared in include/spdlog/async_logger.h, inherits from the base spdlog::logger but overrides critical virtual methods sink_it_() and flush_(). Instead of writing directly to sinks, these methods enqueue log_msg objects into the shared thread pool's queue.
Each async logger maintains a pointer to a synchronous "worker" logger that performs the actual formatting and sink dispatching once the message reaches a background thread.
Message Flow Through the Async Pipeline
When your application calls a logging method, the message traverses a specific pipeline designed to minimize blocking:
-
Formatting: The user thread constructs a
log_msgobject (defined ininclude/spdlog/details/log_msg.h) containing the pre-formatted message, timestamp, logger name, and severity level. -
Enqueuing: The
async_logger::sink_it_()method pushes thislog_msginto the MPMC queue usingqueue.push_back(msg). This operation uses atomic compare-and-swap instructions rather than locks. -
Immediate Return: The producer thread returns control to the application immediately, having performed only memory allocation and atomic operations—no disk I/O or console flushing.
-
Background Processing: A worker thread from the pool wakes up (signaled by the queue's condition variable), pops the message via
queue.pop_front(), and calls_worker->sink_it_(msg)on the synchronous logger. -
Sink Output: The synchronous logger performs final formatting and writes to the configured sinks (files, consoles, or custom destinations).
Configuring and Using Async Loggers
You create asynchronous loggers through the public API in include/spdlog/async.h. The following example demonstrates initializing a thread pool and creating a rotating file logger:
#include <spdlog/spdlog.h>
#include <spdlog/async.h>
int main()
{
// Initialize shared thread pool: queue size 8192, 2 worker threads
spdlog::init_thread_pool(8192, 2);
// Create async logger with rotating file sink
auto async_file = spdlog::create_async<spdlog::sinks::rotating_file_sink_mt>(
"async_file", "logs/app.log", 1048576 * 5, 3);
// High-speed logging from main thread returns instantly
for (int i = 0; i < 10000; ++i)
async_file->info("Message #{} - result {}", i, i * 42);
// Graceful shutdown flushes remaining messages
spdlog::shutdown();
}
The create_async template function constructs an async_logger instance that binds to the global thread pool. The include/spdlog/details/registry.h header tracks all created loggers to ensure proper cleanup during program termination.
Graceful Shutdown and Resource Management
The spdlog::details::registry singleton maintains ownership of all logger instances, including async loggers and their associated thread pools. When spdlog::shutdown() is called—or when the registry destructor runs during program exit—it stops the thread pool's worker loops and ensures all queued messages are flushed to their respective sinks.
This mechanism prevents log truncation during application shutdown. The registry coordinates with thread_pool to drain the MPMC queue completely before destroying the async logger objects, guaranteeing that no messages are lost due to premature termination.
Summary
- spdlog asynchronous logging uses a lock-free MPMC queue in
include/spdlog/details/mpmc_blocking_q.hto buffer messages between producer and consumer threads. - The
thread_poolclass manages configurable background workers that dequeue messages and delegate formatting to synchronous loggers. - The
async_loggeroverridessink_it_()to enqueue messages rather than performing immediate I/O, eliminating blocking on the hot path. - Resource cleanup is handled automatically by the registry singleton, which ensures all queued messages flush during
spdlog::shutdown().
Frequently Asked Questions
What happens when the async queue fills up?
By default, producers block on queue.push_back() until space becomes available, ensuring no log messages are silently dropped. You can configure alternative policies such as dropping overflow messages by specifying different queue behaviors when initializing the thread pool, though blocking is the recommended default for reliability.
How many threads should I allocate to the spdlog thread pool?
For most applications, one or two worker threads suffice because logging is I/O-bound rather than CPU-bound. Allocating more threads than available CPU cores typically yields diminishing returns and increases context-switching overhead. Monitor your specific workload—if the queue consistently grows despite high thread counts, you may need faster storage rather than more threads.
Can async loggers share the same thread pool?
Yes. Multiple async loggers created via spdlog::create_async() share the global thread pool initialized by spdlog::init_thread_pool(). This design conserves system resources while allowing different loggers with varying sink configurations to utilize the same background processing infrastructure.
Is the MPMC queue truly lock-free?
The mpmc_blocking_q implementation uses atomic operations for enqueue and dequeue, making it lock-free for the core data structure operations. However, it uses a condition variable for blocking semantics when the queue is empty or full, which involves kernel-level synchronization. Therefore, the queue is lock-free in the contended case but not wait-free—it may block threads under specific boundary conditions.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →