C++26 (DR23) - 加速std::print

这篇文章是对已经通过的提案P3107的讲解,即针对std::print(...)std::print(FILE*, ...)两个重载的优化(不过由于C++26还没有定稿,这一部分可能还会有小改动)。相关提案P3235也迅速获得了通过,最后会说一下。目前这两个提案大概率会变为DR23,见https://github.com/cplusplus/papers/issues/1906

C++20引入了std::format,C++23又基于此引入了print functions,大大加速了原先基于流运算符的输出(当然print functions仍然提供了流的重载),很多时候甚至可以比C函数printf更快。同时,流运算符输出在并行时会导致错位当然C++20引入的syncstream解决了这个问题,我们不对其进行讨论。,而std::print也克服了这个问题。例如:

std::cout << "Hello " << 1; // 两个线程可能输出Hello Hello 11
std::print("Hello {}", 1); // 两个线程必然输出Hello 1Hello 1

在C++23,这种特性是通过下面的伪代码来实现的:

void print(fmt, args...)
{
    // 当然有一些编译期的检查...
    std::string result{ std::vformat(fmt, args...) };
    WriteToCFILE(stdout, result); // 当然也可以对其他C File stream
}

但事实上C标准规定C stream有锁N2310(C draft),P233 https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2310.pdf,因此如果只是为了达成无错位输出,没必要先format到string再输出到C stream,而是直接锁上C stream的锁,然后format到C stream的buffer就行。经测试,后者可提速20%,甚至可以比先format到栈再打印更快(当然更是快于printf)。显然这是无需分配另一个buffer再拷贝到C stream内置的buffer导致的加速效果。

对内存有严格限制的环境,尤其是连堆分配都不能的环境,这个优化也很重要。

那么是不是直接把标准的wording改改,让标准库厂商自己把这个加上就好了呢?事实上并不,因为这个改动有两个副作用:

  1. 以前的printvformat结束前不会输出任何字符(例如用户抛异常时),因为还没到WriteToCFILE这一步;而DR之后有可能有部分输出。但是通常情况下多打印点字符也没事,而且printparse是编译期进行evaluate,走了抛异常的分支会导致编译错误,更进一步限缩了这种问题。
  2. 可能造成死锁,这才是最重要的问题。作者举了一个例子:
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();

死锁的机制是:

  • 在线程内部,d.mutex会被上锁;在print时,stream会被上锁。
  • 在主线程,format的过程中就会对stream上锁,在deadlockableformatter中又对d.mutex上锁了。

也就是说,在两个线程里锁是非同一顺序锁的,这自然可能引起死锁。或者直接从伪代码看,原来的操作是:

std::string result = std::vformat(fmt, args...); // 这时d.mutex上锁
// d.mutex已解锁
std::lock_guard _{ streamBufferLock }; // stream上锁
WriteToStreamBuffer(stream, result);

而现在变成了:

std::lock_guard _{ streamBufferLock }; // stream上锁
std::vformat_to(streamBuffer, args);   // 直接写到streamBuffer里,无需result,但是造成d.mutex和stream同时上锁。

线程内d.mutex -> stream lock,主线程stream lock -> d.mutex,这这死锁就要来玩阴的了。尽管deadlockableformatter的实现对性能不友好,不是好的设计,但是为了对用户least astonishing,这个优化默认是关闭的,需要通过加入一个模板特化开启:

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

一般只要内部没锁,都可以写这么一个优化。P3235就是给标准库的各种类型加了优化,即:

  • 所有的基本类型(包括字符串)都具有这个优化
  • chrono中时间相关的具有和底层表示一样的优化(一般底层表示都是int、double之类的,所以也是开启的)
  • pair和tuple一类当且仅当所有元素都开启优化时进行开启
  • 其他range_formatter默认是关闭的(即容器、range一类的打印),防止死锁之类的问题。