How the spdlog Thread Pool Handles Log Messages: Inside Async Logging Architecture
spdlog's thread pool decouples log generation from I/O operations by enqueuing async_msg objects into a lock-free multi-producer-multi-consumer queue, allowing dedicated worker threads to format and dispatch messages to sinks asynchronously.
The gabime/spdlog library provides high-performance asynchronous logging through a dedicated thread pool that minimizes latency in the calling thread. Understanding how the spdlog thread pool handles log messages is essential for optimizing application performance and configuring overflow policies correctly. This article examines the internal implementation, from message construction to worker thread processing, based on the actual source code in the v1.x branch.
Thread Pool Architecture and Components
The asynchronous logging subsystem centers on spdlog::details::thread_pool, defined in include/spdlog/details/thread_pool.h. This class manages a pool of std::thread objects and a lock-free mpmc_blocking_queue that serves as the communication channel between application threads and background workers.
When you create an async logger, you provide a shared pointer to this thread pool. The logger holds a weak reference to avoid circular dependencies, while the pool owns the queue and worker threads. The queue capacity and thread count are fixed at construction time to avoid dynamic memory allocation during hot paths.
Message Lifecycle: From Enqueue to Sink
Message Construction and Enqueueing
When an application calls a logging method like logger->info(), the async logger constructs an async_msg object. According to the definition in include/spdlog/details/thread_pool.h (lines 25-70), this structure encapsulates:
- The
log_msgcontaining the actual log content and metadata - A pointer to the originating logger instance
- The message type enum (
log,flush, orterminate)
The logger then invokes thread_pool::post_log() (or post_flush() for explicit flush operations). Inside include/spdlog/details/thread_pool-inl.h (lines 57-62), post_log() constructs the async_msg and delegates to the private post_async_msg_() method, which attempts to enqueue the message into the mpmc_blocking_queue at lines 81-88.
The queue's behavior depends on the configured overflow policy:
block– Waits until space is available using condition variablesoverrun_oldest– Removes the oldest message from the queue to accommodate the new entrydiscard_new– Drops the incoming message immediately if the queue is full
Worker Thread Processing Loop
During thread pool construction (thread_pool-inl.h lines 26-32), the constructor launches a configurable number of std::thread instances, each executing the worker_loop_() method. This loop runs indefinitely until a terminate signal is received.
The worker_loop_() method (lines 91-94) repeatedly calls process_next_msg_(), which blocks on the queue until an async_msg becomes available. When a message arrives, the worker determines its type and acts accordingly (lines 100-115):
- Log messages: Calls
worker_ptr->backend_sink_it_(incoming_async_msg), which formats the message and writes it to the underlying sink(s) - Flush messages: Invokes
worker_ptr->backend_flush_()to ensure all buffered output is synchronized - Terminate messages: Sets the internal running flag to false, causing the worker thread to exit gracefully after dequeuing
Graceful Shutdown Mechanics
When the thread pool destructor executes (thread_pool-inl.h lines 44-53), it posts a terminate message for each worker thread to ensure orderly shutdown. The destructor then joins all threads, guaranteeing that all previously queued messages are processed before the pool releases its resources.
Key Source Files and Implementation Details
include/spdlog/details/thread_pool.h: Declares thethread_poolclass,async_msgstruct, and theasync_overflow_policyenuminclude/spdlog/details/thread_pool-inl.h: Implements thread creation (worker_loop_), enqueue logic (post_async_msg_), and dispatch logic (process_next_msg_)include/spdlog/async_logger.h: Defines the async logger facade that forwards logging calls to the thread poolsrc/async.cpp: Provides theinit_thread_poolconvenience function for creating global thread pools with default settings
Practical Usage Example
#include "spdlog/spdlog.h"
#include "spdlog/async.h"
#include "spdlog/sinks/stdout_sinks.h"
// Create a thread pool with queue size of 8192 and 8 worker threads
auto tp = std::make_shared<spdlog::details::thread_pool>(8192, 8);
// Create async logger with block policy (wait if queue is full)
auto logger = std::make_shared<spdlog::async_logger>(
"my_async_logger",
tp,
spdlog::sinks::stdout_sink_mt::instance(),
spdlog::async_overflow_policy::block
);
// This call returns immediately after enqueueing
logger->info("Non-blocking log call: {}", 42);
// Request flush - posts a flush message to the pool
logger->flush();
// The thread pool destructor automatically posts terminate messages
// and joins worker threads when the last logger reference is released
Summary
- spdlog thread pool uses a lock-free
mpmc_blocking_queueto passasync_msgobjects between application threads and background workers, eliminating lock contention in the hot path - Message types include
log(standard logging),flush(buffer synchronization), andterminate(graceful shutdown signaling) - Three overflow policies control queue-full behavior:
block(wait for space),overrun_oldest(drop oldest), anddiscard_new(drop newest) - Worker threads run in
worker_loop_(), processing messages viaprocess_next_msg_()and delegating actual I/O tobackend_sink_it_()andbackend_flush_() - Graceful shutdown ensures all queued messages are processed before destruction via terminate messages and thread joining mechanisms
Frequently Asked Questions
How does spdlog prevent log messages from blocking the main application thread?
The spdlog thread pool operates on a producer-consumer pattern where the calling thread only executes post_log(), which constructs an async_msg and enqueues it into the lock-free mpmc_blocking_queue. This operation is typically wait-free or briefly blocking depending on the overflow policy, but never involves formatting or I/O operations. The actual disk write or console output happens later in the worker thread's process_next_msg_() function.
What happens when the async queue fills up?
Behavior depends on the async_overflow_policy passed to the logger constructor. With block, the enqueueing thread waits until space is available. overrun_oldest removes the oldest message from the queue to accommodate the new one, while discard_new simply drops the incoming message. You can configure this per-logger when binding it to the thread pool.
How many threads should I allocate to the spdlog thread pool?
The optimal thread count depends on your sink configuration. For single-file logging, one thread is usually sufficient because disk I/O is typically sequential. However, if you use multiple sinks or network-based sinks, allocating 2-4 threads can improve throughput by parallelizing formatting and output operations. The constructor std::make_shared<spdlog::details::thread_pool>(queue_size, thread_count) accepts any positive value, but creating more threads than sinks rarely improves performance.
Is it safe to destroy the thread pool while loggers still reference it?
Yes, because async loggers hold weak pointers to the thread pool. When the pool destructor runs, it posts terminate messages to all worker threads and joins them, ensuring all pending async_msg objects are processed. Loggers that attempt to log after the pool is destroyed will safely fail to lock the weak pointer and drop the message rather than causing undefined behavior.
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 →