Skip to content
C++
Library
since C++20
Intermediate

std::osyncstream

C++20 synchronized output stream that atomically transfers buffered content to a wrapped ostream, eliminating interleaved output in multithreaded programs.

std::osyncstreamsince C++20

A stream wrapper that buffers all output locally and atomically transfers it to an underlying std::ostream upon destruction or an explicit emit() call, guaranteeing no interleaving with output from other osyncstream instances wrapping the same stream.

Overview

Before C++20, writing to shared output streams like std::cout from multiple threads produced unpredictable, interleaved output. The only remedy was an external std::mutex guarding every write site β€” fragile and easy to forget. C++20 introduced <syncstream> to address this at the stream level.

The facility consists of two cooperating components:

  • std::basic_syncbuf<CharT, Traits, Allocator> β€” a std::basic_streambuf that accumulates characters in an internal buffer and, when told to emit, acquires a per-wrapped-stream lock and atomically transfers the entire buffer to the wrapped streambuf.
  • std::basic_osyncstream<CharT, Traits, Allocator> β€” a std::basic_ostream that owns a basic_syncbuf, giving it the familiar << interface. The standard provides two ready-made specialisations:
cpp
using osyncstream  = basic_osyncstream<char>;     // C++20
using wosyncstream = basic_osyncstream<wchar_t>;  // C++20

The atomicity guarantee is at the transfer level: one emit() call transfers the complete buffer in one indivisible step relative to other osyncstream instances targeting the same underlying stream. The ordering between concurrent emissions is unspecified, but output units are never split or interleaved.

Syntax

cpp
#include <syncstream>  // C++20

// Construction
std::osyncstream(std::ostream& wrapped);
std::osyncstream(std::ostream& wrapped, const Allocator& a);
std::osyncstream(std::osyncstream&&) noexcept;

// Key members
void        emit();          // atomically transfer buffer now; clears buffer
syncbuf_type* rdbuf() const; // pointer to the internal basic_syncbuf
std::streambuf* get_wrapped() const; // wrapped stream's streambuf, or nullptr if moved-from

osyncstream is not copyable. Move construction transfers ownership of the syncbuf (and any buffered content); the moved-from object is left with no wrapped stream and any subsequent writes are silently discarded.

Examples

Thread-safe logging without a mutex

cpp
#include <syncstream>
#include <iostream>
#include <thread>
#include <vector>
#include <format>  // C++20

void worker(int id, int iterations) {
    for (int i = 0; i < iterations; ++i) {
        // Each osyncstream scope is one atomic output unit.      // C++20
        std::osyncstream out(std::cout);
        out << std::format("[thread {:02d}] iteration {}\n", id, i);
        // out's destructor calls emit() β€” buffer transferred atomically here.
    }
}

int main() {
    constexpr int kThreads = 8;
    std::vector<std::thread> threads;
    threads.reserve(kThreads);
    for (int i = 0; i < kThreads; ++i)
        threads.emplace_back(worker, i, 100);
    for (auto& t : threads)
        t.join();
}

Without osyncstream, std::cout writes from different threads interleave character-by-character (or at best, call-by-call). Here every complete line from a single iteration is guaranteed to appear contiguously in the output.

Controlling the emission boundary

The scope of the osyncstream object defines what counts as one atomic unit. Multiple << calls on the same object accumulate into a single emission:

cpp
#include <syncstream>
#include <iostream>
#include <chrono>

void report(int task_id, double elapsed_ms) {
    std::osyncstream out(std::cout);  // C++20
    out << "task=" << task_id
        << " elapsed=" << elapsed_ms << "ms"
        << " status=ok\n";
    // All four << calls land together β€” no other thread can inject between them.
}

If you need to emit mid-function and then continue writing a second atomic unit:

cpp
void two_phase_log(std::ostream& sink, int id) {
    std::osyncstream out(sink);  // C++20

    out << "[" << id << "] phase 1 starting\n";
    out.emit();  // atomic transfer β€” buffer is now clear

    // ... do work ...

    out << "[" << id << "] phase 2 complete\n";
    // destructor emits the second chunk atomically
}

emit() clears the internal buffer and resets the stream's error state flags relative to the syncbuf. The osyncstream remains usable after emit().

Wide-character output

cpp
#include <syncstream>
#include <iostream>
#include <thread>

void wide_worker(int id) {
    std::wosyncstream wout(std::wcout);  // C++20
    wout << L"Thread " << id << L" reporting\n";
}

std::wosyncstream targets std::wostream and wraps std::basic_syncbuf<wchar_t>. All synchronization semantics are identical to the char specialisation.

Wrapping a file stream

osyncstream works with any std::ostream, not just std::cout:

cpp
#include <syncstream>
#include <fstream>
#include <thread>
#include <vector>

int main() {
    std::ofstream log_file("app.log");

    auto worker = [&](int id) {
        std::osyncstream out(log_file);  // C++20
        out << "worker " << id << " done\n";
    };

    std::vector<std::thread> ts;
    for (int i = 0; i < 16; ++i)
        ts.emplace_back(worker, i);
    for (auto& t : ts)
        t.join();
}

The underlying log_file must outlive all osyncstream objects that wrap it β€” the osyncstream holds a non-owning reference to the wrapped stream's streambuf.

Best Practices

One osyncstream per logical output unit. The object's lifetime defines the atomic unit. If you create one osyncstream per function and use it for several logically unrelated writes, those writes are merged into one emission β€” which may be what you want, or may be an accident. Be intentional.

Never share an osyncstream across threads. osyncstream itself is not thread-safe. Each thread should construct its own instance wrapping the same underlying ostream. The synchronisation happens inside syncbuf::emit(), not at the osyncstream level.

No external mutex needed when all writers use osyncstream. The internal per-stream lock inside basic_syncbuf is sufficient. Adding an external mutex is redundant and may cause deadlock if emit() also tries to acquire it.

Prefer scope-based emission. Relying on the destructor is simpler and exception-safe. Reserve explicit emit() for cases where you genuinely need two separate atomic output units from a single function.

Common Pitfalls

Mixing direct writes with osyncstream writes. Writing directly to std::cout (bypassing osyncstream) while other threads use osyncstream on std::cout is well-defined but ordering is unspecified. Direct writes are not subject to syncbuf's lock, so they may appear between β€” or even within β€” an osyncstream emission on some implementations.

Atomicity is at the streambuf boundary, not the OS boundary. emit() atomically transfers to the wrapped streambuf's internal buffer. Whether that buffer's subsequent write to the OS is atomic is the underlying stream's concern, not syncbuf's. In practice this is irrelevant for most use cases, but matters if you are mixing osyncstream with POSIX write() calls on the same file descriptor.

Moved-from objects silently discard writes. After a move, get_wrapped() returns nullptr. Any << operations on the moved-from object succeed without error but produce no output. This is easy to trigger accidentally when storing osyncstream in a container and later moving from it.

Error propagation is deferred. If emit() fails (e.g., the underlying stream has badbit set), failbit is set on the osyncstream. Errors from the underlying stream are not visible until after the transfer. Check out.good() after emit() or after the destructor runs (via a wrapper) if correctness depends on the write succeeding.

How Atomicity Works Internally

Understanding the mechanism behind osyncstream's guarantee makes it easier to use correctly and to reason about edge cases.

The local buffer and the transfer step

When you write to an osyncstream, characters go into a local intermediate buffer owned by the embedded basic_syncbuf. Nothing reaches the wrapped ostream yet. On emit() β€” either explicit or via the destructor β€” the syncbuf does three things in sequence while holding a per-wrapped-stream internal lock:

  1. Calls sync() on its own local buffer (flushing any overflow area into the held sequence).
  2. Writes the entire accumulated character sequence to the wrapped streambuf in one call.
  3. Clears the local buffer so the next write starts fresh.

Because the lock is acquired before any character is transferred and released after all characters are transferred, no other syncbuf targeting the same underlying streambuf can interleave characters with that transfer. This is the atomicity guarantee. The ordering between concurrent emissions is unspecified β€” threads may win the lock in any order β€” but the output units themselves are never split.

The internal lock is keyed on the identity of the wrapped streambuf pointer, not the ostream object. Two osyncstream objects wrapping different ostream wrappers that happen to share the same underlying streambuf will still synchronise correctly, because they key on the same pointer.

emit() versus destructor flush

emit() and the destructor both call the same internal transfer logic. The practical difference is timing and granularity:

cpp
#include <syncstream>
#include <iostream>

void demonstrate_emit_granularity(int id) {
    std::osyncstream out(std::cout);  // C++20

    // First atomic unit
    out << "[" << id << "] begin\n";
    out.emit();  // transfers "begin" line atomically, clears buffer

    // ... some computation ...

    // Second atomic unit β€” a separate lock acquisition
    out << "[" << id << "] end\n";
    // destructor transfers "end" line atomically
}

Each emit() call is an independent, indivisible output unit. Two calls from the same thread on the same osyncstream object are not guaranteed to appear adjacent in the output; another thread's emission may appear between them.

If emit() encounters an error writing to the wrapped stream, it sets failbit on the osyncstream but does not throw. Errors in the underlying stream (such as badbit on std::cout due to a broken pipe) are therefore visible only indirectly. Check out.good() after emit() in code that must detect write failures.

std::basic_syncbuf in Depth

std::osyncstream is a thin ostream adapter; the actual work happens in std::basic_syncbuf<CharT, Traits, Allocator>. Four standard aliases cover the common cases:

cpp
// Defined in <syncstream>                               // C++20
using std::syncbuf   = std::basic_syncbuf<char>;
using std::wsyncbuf  = std::basic_syncbuf<wchar_t>;
using std::osyncstream  = std::basic_osyncstream<char>;
using std::wosyncstream = std::basic_osyncstream<wchar_t>;

Direct construction

You can construct a syncbuf directly and install it into any ostream whose streambuf you want to replace temporarily:

cpp
#include <syncstream>
#include <iostream>
#include <ostream>

void write_atomic_block(std::ostream& dest, std::string_view text) {
    std::syncbuf sbuf(dest.rdbuf());  // C++20 β€” wraps dest's streambuf
    std::ostream proxy(&sbuf);
    proxy << text;
    sbuf.emit();  // atomic transfer; sbuf destructor also calls emit
}

This is useful when you need to pass a streambuf* to a third-party function that accepts one, but you still want the output to be synchronised.

set_emit_on_sync

basic_syncbuf has a flag β€” off by default β€” that controls what happens when the stream machinery calls sync() (i.e., when you flush with std::flush or "\n" through a unitbuf stream):

cpp
std::syncbuf sbuf(std::cout.rdbuf());  // C++20
sbuf.set_emit_on_sync(true);   // std::flush now triggers emit()
std::ostream proxy(&sbuf);
proxy << "hello\n" << std::flush;  // emit() called here if flag is true

When set_emit_on_sync is false (the default), std::flush and '\n' with std::unitbuf only flush the local buffer area; no transfer to the wrapped stream occurs. This is deliberate: '\n' sent to std::osyncstream does not emit, because that would fragment multi-line output units into one-line-per-emit pieces.

When set_emit_on_sync is true, every std::flush triggers an emit(). This is useful if you want std::flush to have its usual "push output to the device" semantics, at the cost of finer-grained atomic units.

The default behaviour surprises developers who expect out << "msg\n" << std::flush; to produce visible output immediately. With osyncstream, the flush only takes effect at emit() time.

Comparison with std::mutex

The classic pre-C++20 pattern for thread-safe output is an external mutex:

cpp
#include <iostream>
#include <mutex>
#include <thread>

std::mutex cout_mutex;

void mutex_worker(int id) {
    std::lock_guard<std::mutex> lock(cout_mutex);  // C++11
    std::cout << "thread " << id << " reporting\n";
}

This is correct when done consistently, but has three friction points:

Concernstd::mutex approachstd::osyncstream approach
Scope disciplineEvery call site must hold the same mutex β€” easy to missEach osyncstream object enforces the boundary by construction
ComposabilityCalling a logging helper that locks the same mutex from inside a lock causes deadlockosyncstream objects compose freely; each uses the same internal lock
Granularity controlOne lock scope per lock_guardOne lock scope per emit() call or destructor
OverheadOne OS-level mutex acquire/release per log callOne internal lock acquire/release per emit(); similar cost

For simple, single-site logging the mutex approach is fine. osyncstream pays off when:

  • Multiple call sites write to the same stream and you want the atomicity guarantee enforced at the type level rather than by convention.
  • You are writing library code that cannot assume the caller's mutex conventions.
  • You want to avoid the deadlock risk of a mutex that might be re-entered through a callback.

One important difference: a std::mutex guard serializes the entire critical section, including any computation inside it. osyncstream only serializes the transfer step β€” the formatting work (calling operator<<, std::format, etc.) happens outside the lock, in the local buffer. This makes osyncstream more scalable under high concurrency, because threads build their output in parallel and only briefly contend at emit() time.

Wide Character Support

All std::osyncstream facilities have wide-character counterparts:

cpp
#include <syncstream>
#include <iostream>
#include <thread>

void wide_worker(int id) {
    std::wosyncstream wout(std::wcout);  // C++20
    wout << L"Thread " << id << L" βœ“\n";  // Unicode check mark
    // destructor emits atomically
}

std::wsyncbuf (basic_syncbuf<wchar_t>) and std::wosyncstream (basic_osyncstream<wchar_t>) have identical semantics to their char counterparts. The internal lock is keyed on the wrapped wstreambuf*, so wosyncstream objects wrapping std::wcout synchronise with each other but are independent from osyncstream objects wrapping std::cout.

Wide and narrow streams are separate synchronisation domains β€” mixing them on the same underlying device requires care at the OS level regardless of syncstream.

Performance Considerations

osyncstream is not a zero-cost abstraction. Understanding its costs helps you make an informed choice:

Buffer allocation. Each osyncstream construction allocates a local buffer (backed by a std::string-like sequence via the Allocator template parameter). For very short-lived objects in a tight loop, this allocation may be measurable. You can supply a custom allocator or reuse the syncbuf manually to amortize the cost.

Lock contention. The internal per-stream lock is a std::mutex (implementation-defined, but in practice a mutex or a spin-lock in most standard library implementations). Under high thread counts all emitting to the same stream, this becomes a bottleneck. A benchmark with 16 threads emitting 100 000 messages each to std::cout will show clear lock saturation. At that scale, consider:

  • A dedicated logging thread with a lock-free queue (the writer pattern).
  • A library like spdlog or Quill, which uses asynchronous queues and avoids the per-message lock.
  • Per-thread output files, merged post-run.

When osyncstream is the right tool. It is well-suited for:

  • Diagnostic and debug output where correctness (no interleaving) matters more than maximum throughput.
  • Tests and utilities where adding a third-party logging library would be heavyweight.
  • Programs with moderate thread counts (up to 8–16 threads) where the lock cost is acceptable.
  • Code that must remain within the standard library for portability reasons.

Compiler support. As of 2024, <syncstream> is supported by GCC 11+, Clang 14+ (with libc++ 14+), and MSVC 19.29+ (VS 2022 16.10+). Check __cpp_lib_syncbuf >= 201803L with <version> to feature-test at compile time.

cpp
#include <version>
#ifdef __cpp_lib_syncbuf
#  include <syncstream>
#  define USE_OSYNCSTREAM 1
#else
#  include <mutex>
#  define USE_OSYNCSTREAM 0
#endif

Performance Characteristics

osyncstream is faster under contention than a naively-placed mutex, but slower than a lock-free logger. The key insight is that formatting work happens outside the internal lock: threads build their complete output string in a private buffer, then contend only during the brief atomic transfer. A single mutex wrapped around std::cout serialises both formatting and I/O, whereas osyncstream serialises only the I/O step.

A rough mental model for latency per log call:

ApproachLock scope coversPer-call overhead (rough)
std::mutex + std::coutformatting + writemutex acquire + all operator<< calls
std::osyncstreamtransfer onlybuffer alloc + all operator<< + one lock acquire
spdlog (sync mode)queue pushone enqueue + one dequeue on logger thread
spdlog (async mode)queue push onlyone lock-free enqueue
std::print (C++23)per-call (impl-defined)potentially narrower lock scope than osyncstream

When osyncstream wins. For programs with up to ~16 threads each emitting dozens of messages per second, osyncstream outperforms the mutex approach because parallel threads format simultaneously in private buffers and only briefly queue up at emit() time. At higher emit rates the internal lock becomes the bottleneck regardless of formatting parallelism.

cpp
// C++20 β€” benchmark harness skeleton
#include <syncstream>
#include <iostream>
#include <thread>
#include <chrono>
#include <vector>
#include <format>

void bench_osyncstream(int threads, int iters) {
    auto worker = [&](int id) {
        for (int i = 0; i < iters; ++i) {
            std::osyncstream out(std::cout);  // C++20
            out << std::format("t={} i={}\n", id, i);
            // emit() at destructor β€” formatting was outside the lock
        }
    };
    std::vector<std::thread> ts;
    for (int t = 0; t < threads; ++t) ts.emplace_back(worker, t);
    for (auto& t : ts) t.join();
}

When to reach for alternatives. If throughput or latency is critical β€” millions of messages per second, real-time deadlines, or more than 32 concurrent writers β€” prefer a dedicated async logging library (spdlog with async mode, Quill, NanoLog). If you only need C++23 and do not need multi-call atomic units, std::print/std::println offer a simpler interface with implementation-defined (but typically adequate) interleave prevention. osyncstream occupies the sweet spot where standard-library-only code needs correct, non-interleaved output without bringing in a third-party dependency.

Using basic_syncbuf Directly

std::basic_syncbuf is the engine; osyncstream is just a convenience wrapper around it. There are two reasons to use basic_syncbuf directly: injecting synchronised output into code that accepts a raw streambuf*, and building a custom ostream subclass that inherits the synchronisation semantics.

cpp
// C++20 β€” inject a syncbuf into an existing ostream temporarily
#include <syncstream>
#include <iostream>
#include <ostream>
#include <string_view>

// Writes text to dest atomically relative to other syncbuf users on dest.
void atomic_write(std::ostream& dest, std::string_view text) {
    std::syncbuf sbuf(dest.rdbuf());  // C++20
    std::ostream proxy(&sbuf);
    proxy << text;
    sbuf.emit();  // explicit emit before sbuf destructor also emits
}

When sbuf is destroyed it calls emit() again, but because emit() is idempotent on an empty buffer the double-emit is harmless. You can skip the explicit sbuf.emit() and rely on the destructor if you prefer.

cpp
// C++20 β€” custom ostream subclass backed by a syncbuf
#include <syncstream>
#include <iostream>
#include <ostream>
#include <string>

class PrefixedSyncStream : public std::ostream {
    std::syncbuf sbuf_;           // C++20
    std::string  prefix_;
public:
    explicit PrefixedSyncStream(std::ostream& dest, std::string prefix)
        : std::ostream(nullptr)
        , sbuf_(dest.rdbuf())
        , prefix_(std::move(prefix))
    {
        rdbuf(&sbuf_);
    }

    // Writes prefix + msg as one atomic unit.
    void log(std::string_view msg) {
        *this << prefix_ << msg << '\n';
        sbuf_.emit();
    }

    ~PrefixedSyncStream() { sbuf_.emit(); }
};

// Usage
void worker(int id) {
    PrefixedSyncStream out(std::cout, std::format("[thread {:02d}] ", id));
    out.log("starting");
    out.log("done");
}

basic_syncbuf accepts an Allocator template parameter defaulting to std::allocator<CharT>. Supplying a pool allocator eliminates the per-construction heap allocation in hot paths.

emit() and Partial Writes

emit() is the primitive that defines what counts as one indivisible output unit. Every call to emit() β€” whether explicit or via the destructor β€” acquires the per-stream internal lock, drains the entire accumulated buffer to the wrapped streambuf in one shot, releases the lock, and resets the local buffer. No other syncbuf targeting the same underlying stream can write between the drain and the reset.

The logging pattern that benefits most from explicit emit() is one where a single function produces logically separate records that must each appear as whole units, but creating a new osyncstream for every record would be expensive:

cpp
// C++20 β€” one osyncstream, many atomic records
#include <syncstream>
#include <iostream>
#include <span>
#include <string_view>

void log_batch(std::span<const std::string_view> messages, int thread_id) {
    std::osyncstream out(std::cout);  // C++20 β€” allocate buffer once
    for (auto msg : messages) {
        out << "[T" << thread_id << "] " << msg << '\n';
        out.emit();  // each message is its own atomic unit
        // Buffer is now empty; next iteration starts a fresh unit.
    }
    // Destructor emit() on empty buffer is a no-op.
}

Each emit() call is an independent lock acquisition; two consecutive emissions from the same thread are not guaranteed to be adjacent in the output. If you need a batch of messages to be contiguous as a block, accumulate them all before calling emit() once:

cpp
// C++20 β€” emit the whole batch atomically as one unit
void log_batch_atomic(std::span<const std::string_view> messages, int thread_id) {
    std::osyncstream out(std::cout);  // C++20
    for (auto msg : messages)
        out << "[T" << thread_id << "] " << msg << '\n';
    // Single emit() at destructor β€” all messages appear contiguously.
}

Error handling after emit(). If the underlying stream is in a bad state, emit() sets failbit on the osyncstream but does not throw. Check out.fail() immediately after an explicit emit() if partial-write detection is required β€” by the time the destructor runs, the error state may have been obscured by subsequent writes.

Combining with std::format

std::format (C++20) and std::osyncstream are natural partners: format builds the string off the lock, then osyncstream emits it atomically. Because std::format returns a std::string by value, the allocation and all formatting work happens before the osyncstream constructor is even called β€” or alternatively inside the local buffer before emit() β€” keeping the critical section as short as possible.

cpp
// C++20 β€” idiomatic thread-safe formatted logging
#include <syncstream>
#include <iostream>
#include <format>
#include <chrono>
#include <thread>

void log(std::string_view level, std::string_view msg) {
    // std::format runs outside the osyncstream lock.   // C++20
    auto line = std::format("[{:>5}] {}\n", level, msg);
    std::osyncstream(std::cout) << line;
    // Temporary osyncstream destroyed immediately β€” emit() called here.
}

void worker(int id) {
    using namespace std::chrono_literals;
    for (int i = 0; i < 5; ++i) {
        log("INFO", std::format("thread {} iteration {}", id, i));
        std::this_thread::sleep_for(1ms);
    }
}

A temporary osyncstream (without a named variable) works well for single-statement writes: the destructor is called at the semicolon, so the emit scope is exactly the one expression. Name the object only when you need to accumulate several << calls or use explicit emit().

For structured logging where each record is a key-value map, pre-format the entire record into one string and emit once to guarantee the record appears as a unit:

cpp
// C++20 β€” structured log record emitted atomically
#include <syncstream>
#include <iostream>
#include <format>
#include <string_view>
#include <chrono>

struct LogRecord {
    std::string_view level;
    std::string_view component;
    std::string_view message;
    int              thread_id;
};

void emit_record(const LogRecord& r) {
    // Build entire record as one string β€” no lock held during formatting. // C++20
    auto record = std::format(
        R"({{"level":"{}","component":"{}","thread":{},"msg":"{}"}})" "\n",
        r.level, r.component, r.thread_id, r.message
    );
    std::osyncstream(std::cout) << record;
}

When C++23's std::println is available, it provides its own interleave prevention for single calls, but osyncstream remains the correct tool whenever a single atomic output unit must span multiple << expressions or multiple lines.

See Also

  • std::basic_syncbuf β€” the underlying buffer; accessible via osyncstream::rdbuf(), useful when you need to pass syncbuf-level control to code that accepts a streambuf*
  • std::mutex / std::lock_guard β€” the pre-C++20 approach to guarding shared stream access
  • std::atomic β€” orthogonal synchronisation primitive for data rather than stream output