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更快。同时,流运算符输出在并行时会导致错位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有锁printf)。显然这是无需分配另一个buffer再拷贝到C stream内置的buffer导致的加速效果。
对内存有严格限制的环境,尤其是连堆分配都不能的环境,这个优化也很重要。
那么是不是直接把标准的wording改改,让标准库厂商自己把这个加上就好了呢?事实上并不,因为这个改动有两个副作用:
- 以前的
print在vformat结束前不会输出任何字符(例如用户抛异常时),因为还没到WriteToCFILE这一步;而DR之后有可能有部分输出。但是通常情况下多打印点字符也没事,而且print的parse是编译期进行evaluate,走了抛异常的分支会导致编译错误,更进一步限缩了这种问题。 - 可能造成死锁,这才是最重要的问题。作者举了一个例子:
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上锁,在
deadlockable的formatter中又对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,这这死锁就要来玩阴的了。尽管deadlockable的formatter的实现对性能不友好,不是好的设计,但是为了对用户least astonishing,这个优化默认是关闭的,需要通过加入一个模板特化开启:
struct Foo {};
template<>
inline constexpr bool enable_nonlocking_formatter_optimization<Foo> = true;
一般只要内部没锁,都可以写这么一个优化。P3235就是给标准库的各种类型加了优化,即:
- 所有的基本类型(包括字符串)都具有这个优化
- chrono中时间相关的具有和底层表示一样的优化(一般底层表示都是int、double之类的,所以也是开启的)
- pair和tuple一类当且仅当所有元素都开启优化时进行开启
- 其他range_formatter默认是关闭的(即容器、range一类的打印),防止死锁之类的问题。