How spdlog's Thread Pool Enables Asynchronous Logging: Architecture Deep Dive

spdlog implements asynchronous logging by off-loading sink I/O operations to a dedicated thread pool, allowing application threads to queue log messages and return immediately without blocking on disk or network writes.

The gabime/spdlog library is a fast C++ logging library whose async logging support is built on a custom thread_pool class. This architecture separates the latency-critical logging API from the potentially slow sink operations, achieving high throughput through lock-free queueing and configurable worker threads.

Core Components of the Async Logging Pipeline

The spdlog thread pool implementation consists of four tightly integrated components:

Component Role Key Source File
async_logger Public API that queues log messages and flush requests include/spdlog/async_logger.h
thread_pool Manages worker threads and the bounded queue include/spdlog/details/thread_pool.h
mpmc_blocking_queue Lock-free ring buffer for async_msg objects include/spdlog/details/mpmc_blocking_q.h
async_msg Wrapper for log messages, flush commands, or termination signals include/spdlog/details/thread_pool.h

How Messages Flow Through the System

When your application calls logger->info(), the data flows through three distinct stages:

  1. Capture — async_logger packages the message without I/O
  2. Queue — thread_pool inserts the message into the mpmc_blocking_queue
  3. Process — Worker threads dequeue and execute sink writes

Queueing Log Calls: The Non-Blocking Frontend

The async_logger class overrides sink_it_() to intercept all log calls. Instead of writing directly to sinks, it delegates to the thread pool:

pool_ptr->post_log(shared_from_this(), msg, overflow_policy_);

In include/spdlog/async_logger-inl.h, this path creates an async_msg of type log containing:

  • A std::shared_ptr to the originating logger
  • The complete log_msg payload (timestamp, level, message, source location)
  • The message type discriminator

The thread_pool::post_async_msg_() method then enqueues this message according to the overflow policy (block, overrun_oldest, or discard_new). The application thread returns immediately—no disk flush, no mutex contention on sink output, no network latency.

This implementation appears in include/spdlog/details/thread_pool-inl.h at lines 57–63.

Worker Thread Architecture

The thread_pool constructor spawns N worker threads (default 1) that execute a continuous processing loop:

while (process_next_msg_()) { /* loop */ }

Each thread in include/spdlog/details/thread_pool-inl.h (lines 91–115) performs:

  1. Dequeue — Block on mpmc_blocking_queue until a message arrives
  2. Dispatch — Switch on async_msg::msg_type:
    • log → worker_ptr->backend_sink_it_(msg)
    • flush → worker_ptr->backend_flush_()
    • terminate → Return false to exit loop

The backend_sink_it_() function iterates over all attached sinks, checks their individual log levels, and forwards formatted messages. This is the only code path where actual I/O occurs—file writes, console output, or network operations all happen inside these worker threads.

Backend Execution and Sink Interaction

The backend functions defined in include/spdlog/async_logger-inl.h (lines 62–80) transform the queued async_msg back into sink operations:

// Conceptual flow inside backend_sink_it_
for (auto& sink : sinks_) {
    if (sink->should_log(msg.level)) {
        sink->log(msg);  // Actual I/O happens here
    }
}

This design achieves thread safety without per-log mutexes: the MPMC queue handles synchronization, and individual sinks operate exclusively within a single worker thread context. Sinks that require internal locking (like stdout_color_sink_mt) remain correct, while lock-free sinks achieve maximum performance.

Graceful Shutdown Mechanics

When the thread_pool destructor runs, it guarantees no log messages are lost:

// From thread_pool-inl.h lines 43-53
for (size_t i = 0; i < threads_.size(); ++i) {
    post_async_msg_(async_msg{async_msg_type::terminate}, 
                    async_overflow_policy::block);
}
for (auto& t : threads_) {
    t.join();
}

Each worker receives a terminate message, processes any remaining queued messages, then exits cleanly. The join() ensures the destructor blocks until all pending I/O completes.

Configurable Overflow Policies

The async_overflow_policy enum controls queue-full behavior:

Policy Behavior Use Case
block Caller blocks until queue space available Reliability-critical applications
overrun_oldest Replace oldest message with newest Latency-sensitive, can tolerate drops
discard_new Silently drop newest message High-throughput, loss-tolerant logging

Policy enforcement occurs in thread_pool::post_async_msg_() at lines 81–88 of include/spdlog/details/thread_pool-inl.h, using the mpmc_blocking_queue methods enqueue(), enqueue_nowait(), and enqueue_if_have_room().

Complete Working Example

#include <spdlog/spdlog.h>
#include <spdlog/async_logger.h>
#include <spdlog/details/thread_pool.h>
#include <spdlog/sinks/basic_file_sink.h>

int main() {
    // Create thread pool: 8192-item queue, 2 worker threads
    auto tp = std::make_shared<spdlog::details::thread_pool>(
        8192,  // queue_max_items
        2      // worker_threads
    );

    // Build file sink
    auto file_sink = std::make_shared<spdlog::sinks::basic_file_sink_mt>(
        "async_log.txt"
    );

    // Create async logger with block-on-full policy
    auto logger = std::make_shared<spdlog::async_logger>(
        "file_async",
        file_sink,
        tp,
        spdlog::async_overflow_policy::block
    );

    // Log from main thread — returns immediately, I/O happens in workers
    logger->info("Processing request id={}", 42);
    logger->warn("High latency detected: {}ms", 150);

    // Async flush — posts flush command to queue
    logger->flush();

    // Graceful shutdown: destroy pool, wait for workers
    tp.reset();  // or let it go out of scope
    return 0;
}

Key Source Files Reference

File Purpose
include/spdlog/details/thread_pool.h thread_pool class declaration, async_msg struct, queue types
include/spdlog/details/thread_pool-inl.h Worker thread creation, message posting, dispatch loop, shutdown
include/spdlog/async_logger.h Public async_logger API and overflow policy definitions
include/spdlog/async_logger-inl.h Backend sink iteration and flush implementation
include/spdlog/details/mpmc_blocking_q.h Lock-free bounded MPMC queue implementation

Summary

  • spdlog's thread pool decouples logging API calls from sink I/O through a bounded lock-free queue
  • async_logger packages messages without blocking; thread_pool workers execute all sink operations
  • Worker threads consume async_msg objects and invoke backend_sink_it_() or backend_flush_() exclusively
  • Overflow policies (block, overrun_oldest, discard_new) let applications trade reliability for latency
  • Graceful shutdown guarantees all queued messages are processed before destruction via terminate messages and join()

Frequently Asked Questions

How does spdlog prevent log message loss during high load?

spdlog's thread pool uses a bounded queue with configurable overflow policy. The default block policy causes the calling thread to wait until queue space is available, ensuring no messages are dropped. For loss-tolerant scenarios, overrun_oldest or discard_new policies apply backpressure by removing messages rather than blocking producers.

What is the optimal number of worker threads for spdlog's async logging?

For single-file sinks, one worker thread is optimal—multiple threads would contend for the same file lock. For multiple distinct sinks or network sinks, match thread count to the number of independent I/O channels. The constructor default of 1 thread suits most workloads; profile your specific sink mix to determine if additional threads improve throughput without increasing contention.

Can I use the same thread pool for multiple async loggers?

Yes. A single thread_pool instance can service multiple async_logger objects, which is the recommended pattern to control total thread count. The pool's MPMC queue handles concurrent enqueues from any number of loggers, and worker threads dispatch to the appropriate logger's backend via the shared_ptr stored in each async_msg.

How does spdlog's async performance compare to synchronous logging?

For fast sinks (memory buffers, null_sink), sync logging avoids queue overhead and is faster. For slow sinks (rotating files, network endpoints, remote syslog), async logging prevents application threads from stalling on I/O latency. The lock-free MPMC queue minimizes enqueue overhead to approximately 50-100 nanoseconds on modern hardware, making async preferable when sink latency exceeds 1 microsecond.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →