C++26 (DR23) - Accelerating std::print

This blog was originally written in Chinese and translated by Deepseek-v4-flash. Though I've reviewed and polished the translation, feel free to contact me if any phrasing seems unnatural.

This article explains the adopted proposal P3107, which optimizes the two overloads std::print(...) and std::print(FILE*, ...) (though since C++26 has not yet been finalized, there may still be minor changes). The related proposal P3235 was also quickly adopted and will be discussed at the end. Currently, both proposals are likely to become DR23; see https://github.com/cplusplus/papers/issues/1906.

C++20 introduced std::format, and C++23 built on it to introduce print functions, greatly accelerating output that previously relied on stream operators (though print functions still provide stream overloads). In many cases, they can even be faster than the C function printf. At the same time, stream operator output can cause interleaving under concurrencyOf course, C++20 introduced syncstream to solve this problem, but we won't discuss it here., and std::print overcomes this issue as well. For example:

std::cout << "Hello " << 1; // Two threads might output Hello Hello 11
std::print("Hello {}", 1); // Two threads will always output Hello 1Hello 1

In C++23, this feature was implemented using the following pseudocode:

void print(fmt, args...)
{
    // With some compile-time checks, of course...
    std::string result{ std::vformat(fmt, args...) };
    WriteToCFILE(stdout, result); // Can also target other C file streams
}

However, the C standard specifies that C streams have locksN2310 (C draft), P233 https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2310.pdf. Therefore, to achieve interleaving-free output, there is no need to format to a string first and then write to the C stream. Instead, we can lock the C stream’s lock directly and format into the C stream’s buffer. Testing shows this approach yields a 20% speed improvement, and can even be faster than formatting to a stack buffer first (let alone faster than printf). This speedup obviously comes from avoiding the allocation of an extra buffer and the subsequent copy to the C stream’s internal buffer.

For environments with strict memory constraints, especially those where even heap allocation is unavailable, this optimization is also important.

So, is it sufficient to simply modify the wording in the standard and let standard library vendors add this optimization? Not quite, because this change has two side effects:

  1. Previously, print would not output any characters before vformat completed (e.g., if the user threw an exception), because it hadn’t reached the WriteToCFILE step yet; after the DR, some output may occur. However, printing a few extra characters is usually harmless, and print’s parse is evaluated at compile time — any exception branch would cause a compile error, further limiting this kind of problem.
  2. It could cause deadlocks, which is the most important issue. The author gives an example:
struct deadlockable {
    int value = 0;
    mutable std::mutex mutex;
};

template<> struct std::formatter<deadlockable> {
    constexpr auto parse(std::format_parse_context& ctx) {
        return ctx.begin();
    }

    auto format(const deadlockable& d, std::format_context& ctx) const {
        std::lock_guard<std::mutex> lock(d.mutex);
        return std::format_to(ctx.out(), "{}", d.value);
    }
};

deadlockable d;
auto t = std::thread([&]() {
    std::print("start\n");
    std::lock_guard<std::mutex> lock(d.mutex);
    for (int i = 0; i < 1000000; ++i) d.value += 10;
    std::print("done\n");
});
for (int i = 0; i < 100; ++i) std::print("{}", d);
t.join();

The deadlock mechanism is:

  • Inside the thread, d.mutex is locked; during print, the stream is locked.
  • In the main thread, the stream is locked during formatting, and then d.mutex is locked within deadlockable’s formatter.

In other words, the locks are acquired in a different order in the two threads, which naturally can cause deadlock. Or, directly from the pseudocode, the original operation was:

std::string result = std::vformat(fmt, args...); // d.mutex locked here
// d.mutex now unlocked
std::lock_guard _{ streamBufferLock }; // stream locked
WriteToStreamBuffer(stream, result);

But it now becomes:

std::lock_guard _{ streamBufferLock }; // stream locked
std::vformat_to(streamBuffer, args);   // Writes directly to streamBuffer, no result needed, but causes d.mutex and stream to be locked simultaneously.

In the thread: d.mutex -> stream lock; in the main thread: stream lock -> d.mutex. This deadlock is ready to strike. Although the deadlockable formatter implementation is not performance-friendly and is not good design, to be least astonishing to users, this optimization is turned off by default. It must be enabled by adding a template specialization:

struct Foo {};
template<>
inline constexpr bool enable_nonlocking_formatter_optimization<Foo> = true;

In general, as long as there are no internal locks, you can write such an optimization. P3235 adds this optimization for various standard library types, specifically:

  • All fundamental types (including strings) have this optimization enabled
  • Chrono time-related types have the same optimization as their underlying representation (which is typically int, double, etc., so it’s enabled)
  • pair and tuple enable the optimization if and only if all their elements have it enabled
  • Other range_formatter types (i.e., containers, ranges) are disabled by default to prevent issues like deadlocks.