How spdlog Asynchronous Logging Works: Internal Architecture and Overflow Policies Explained

spdlog uses a global thread‑pool with a lock‑free multi‑producer‑multi‑consumer queue to offload log writes from the calling thread, offering two overflow policies—block (default) and overrun_oldest—that determine behavior when the queue saturates.

The spdlog library implements asynchronous logging through a carefully designed two‑stage pipeline: log messages are enqueued by producer threads and later drained by dedicated worker threads. This architecture minimizes latency in hot paths while providing configurable backpressure handling through its async_overflow_policy mechanism.

Core Components of spdlog's Async Subsystem

The Global Thread Pool

In include/spdlog/details/thread_pool.h, the thread_pool class manages one or more worker threads and a bounded mpmc_blocking_q queue. The pool is instantiated lazily via spdlog::init_thread_pool() or automatically when the first async_logger is created.

The queue stores async_msg objects (defined in thread_pool.h) containing:

  • A formatted log_msg structure
  • A std::shared_ptr<logger> reference to prevent premature logger destruction

Worker threads continuously call pop() and invoke the underlying sink(s) synchronously—sinks themselves remain unaware of the async machinery.

The Async Logger Wrapper

include/spdlog/async_logger.h defines async_logger, a thin subclass of logger that overrides sink_it_(). Instead of writing directly to sinks, it calls thread_pool::push(), forwarding the message to the shared queue.

The async_overflow_policy enum is declared here:

enum class async_overflow_policy {
    block,          // Default: wait until queue space available
    overrun_oldest  // Non-blocking: discard oldest message
};

Factory and Policy Injection

include/spdlog/async.h provides async_factory_impl<OverflowPolicy>, a template factory that bakes the overflow policy into the logger type at compile time:

using async_factory = async_factory_impl<async_overflow_policy::block>;
using async_factory_nonblock = async_factory_impl<async_overflow_policy::overrun_oldest>;

The create_async() and create_async_nb() convenience functions use these aliases.

How Messages Flow Through the System

  1. Producer path: logger->info(...) → async_logger::sink_it_() → thread_pool::push(async_msg, policy)
  2. Queue storage: mpmc_blocking_q (in include/spdlog/details/mpmc_blocking_q.h) handles lock‑free enqueue/dequeue
  3. Consumer path: Worker thread → thread_pool::pop() → async_logger::backend_sink_it_() → sink output

The include/spdlog/details/registry.h singleton holds the thread_pool instance under tp_mutex, ensuring thread‑safe initialization and shared access across all async_logger instances.

Overflow Policy Options Explained

When the bounded queue reaches capacity, spdlog's behavior is determined by the template‑selected policy:

block (Default Policy)

The calling thread waits on a condition variable until a consumer frees a slot. Implemented in src/async.cpp within the push() method.

Characteristics:

  • Guarantees zero message loss
  • Introduces unbounded latency under heavy load
  • Safe for critical audit trails
// Blocking async logger (default behavior)
auto logger = spdlog::create_async<spdlog::sinks::stdout_color_sink_mt>(
    "blocking_logger");

logger->warn("This may pause if the queue is full");

overrun_oldest (Non‑Blocking Policy)

The queue discards its oldest entry via pop_front() before enqueueing the new message. The producer thread never stalls.

Characteristics:

  • Bounded latency regardless of consumer performance
  • Oldest messages sacrificed during bursts
  • Ideal for telemetry where fresh data matters more than completeness
// Non-blocking async logger (drops oldest on overflow)
auto nb_logger = spdlog::create_async_nb<spdlog::sinks::stdout_color_sink_mt>(
    "nonblock_logger");

nb_logger->info("Oldest entries are discarded if queue saturates");

Customizing Thread Pool Configuration

By default, the lazy‑initialized pool uses 8192 queue slots and 1 worker thread. Override this with explicit initialization:

// Configure before creating any async loggers
spdlog::init_thread_pool(16384, 4);  // 16K slots, 4 threads

auto custom = spdlog::create_async<spdlog::sinks::basic_file_sink_mt>(
    "custom_logger", "app.log");

// All subsequent async loggers share this pool

The init_thread_pool() function is defined in include/spdlog/async.h and locks registry::tp_mutex_ during setup.

Key Implementation Files

File Responsibility
include/spdlog/async.h Factory templates, init_thread_pool(), policy aliases
include/spdlog/async_logger.h async_logger class, async_overflow_policy enum
include/spdlog/details/thread_pool.h Worker thread management, async_msg structure
include/spdlog/details/mpmc_blocking_q.h Lock‑free bounded queue implementation
include/spdlog/details/registry.h Singleton thread‑pool storage, tp_mutex
src/async.cpp Concrete push()/pop() with policy‑dependent logic

Summary

  • spdlog's asynchronous logging decouples formatting from I/O via a shared thread_pool and lock‑free mpmc_blocking_q
  • Two compile‑time overflow policies control saturation behavior: block (wait for space) or overrun_oldest (discard oldest)
  • The async_factory_impl<> template injects policy into logger types; use create_async() or create_async_nb() for convenience
  • Global pool defaults (8192 slots, 1 thread) can be overridden via spdlog::init_thread_pool()
  • All async loggers share the same registry‑managed pool, minimizing resource overhead

Frequently Asked Questions

What happens if I don't call init_thread_pool() before creating an async logger?

The first create_async() call automatically constructs a default pool with 8192 queue slots and 1 worker thread. This is thread‑safe due to registry::tp_mutex_ but offers no customization. Explicit initialization is recommended for production deployments.

Can I mix blocking and non‑blocking async loggers in the same program?

No—all async_logger instances share the global thread_pool, which is created with a single policy. The async_overflow_policy is a template parameter of async_factory_impl, so the first logger created effectively locks the policy for the process. Choose based on your most stringent requirement.

How does overrun_oldest affect performance compared to block?

overrun_oldest eliminates producer‑side contention and latency spikes at the cost of message durability. In src/async.cpp, the policy check occurs before the condition variable wait—avoiding the std::unique_lock and notify overhead entirely when the queue is full. Benchmarks typically show 10–100× lower tail latency under saturation.

Is the queue truly lock‑free?

The mpmc_blocking_q in include/spdlog/details/mpmc_blocking_q.h uses atomic operations for enqueue/dequeue, but blocking policies introduce condition variables for backpressure. Thus: data structure operations are lock‑free, but synchronization primitives are used for flow control when block is selected.

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 →