问题的历史
在C++20之前,C++标准没有提供线程安全的格式化输出共享流的方法,开发者需要手动同步。虽然std::cout可以通过sync_with_stdio与C流同步,但这阻止了缓冲,并导致了严重的性能下降。开发者通常在每个插入周围包裹std::mutex,但这样完全串行化了线程,并且如果在某个代码路径中忘记加锁,就无法防止交错。对更高级别抽象的需求,以原子方式批量输出变得关键,尤其是在高吞吐量的多线程日志记录场景中。
问题
标准流插入操作不是原子的。对于复杂类型的单个operator<<调用可能触发多个sputc或sputn对底层std::streambuf的调用,并且并发线程可以在这个粒度级别上相互交错。现有的解决方法如std::stringstream后面再加锁输出需要额外复制整个缓冲区。手动加锁增加了样板代码,并且如果在mutex解锁之前发生异常,存在死锁的风险。
解决方案
C++20引入了std::basic_osyncstream(对char的别名是std::osyncstream),它封装了一个std::basic_syncbuf。这个内部缓冲区在本地累积所有格式化输出。当调用emit()时——无论是显式调用还是由析构函数调用——它会获取与包装的std::streambuf关联的mutex,并将所有累积的内容在一次连续写入中传输。这将细粒度字符级加锁转变为粗粒度消息级加锁,确保在发射期间没有其他线程可以插入字符。
#include <syncstream> #include <iostream> #include <thread> #include <vector> void worker(int id) { std::osyncstream synced_out(std::cout); synced_out << "线程 " << id << " 正在处理数据 "; // 这里自动调用emit(),原子写入完整行 } int main() { std::vector<std::jthread> threads; for (int i = 0; i < 10; ++i) { threads.emplace_back(worker, i); } }
一个分布式数据库系统需要从多个事务线程向中央审计日志写入JSON提交记录。每个记录包含事务ID、时间戳和状态标志。如果没有原子发射,不同线程的花括号和引号会混合在一起,导致JSON损坏,下游分析管道无法解析,导致夜间批处理作业失败。
考虑的一种解决方案是一个全局std::shared_mutex,允许并发读取但独占写入。优点:熟悉的同步模式。缺点:写入者在整个JSON格式化期间仍然是串行的;在提交风暴期间的高争用导致延迟峰值;如果持有锁的线程在解锁之前抛出异常,则存在死锁风险。
另一种考虑的方法是每个线程的日志文件由一个后台线程合并。优点:写入路径的零争用;在事务处理中不需要加锁。缺点:复杂的日志轮换和文件管理;跨线程的时间顺序丢失;多个文件句柄增加了磁盘I/O;如果合并线程崩溃,则可能会丢失日志。
团队采用了std::osyncstream。每个事务范围创建一个本地std::osyncstream,包装共享的审计std::ofstream。JSON在内部缓冲区构建,并在范围退出时原子发射。这将锁保持时间从毫秒(JSON格式化持续时间)减少到微秒(缓冲区复制),完全消除了损坏,并保持了时间顺序,因为**emit()**序列化了对底层文件缓冲区的访问。
结果:该系统持续超过100,000次提交每秒,没有日志损坏,并且调试变得可行,因为记录保持完好和有序。
为什么std::osyncstream的析构函数无条件调用emit(),如果底层流抛出异常,异常安全的含义是什么?
析构函数通过调用emit()确保没有缓冲数据丢失。如果emit()抛出异常(例如,磁盘已满),并且由于析构函数隐式是noexcept,则立即调用std::terminate。候选人通常认为异常会传播,或者缓冲区会被静默丢弃。正确的细节是std::basic_syncbuf提供了一个emit_on_flush标志和可通过**get_wrapped()**访问的错误状态,但析构函数优先考虑程序终止,而不是静默的数据丢失或异常从析构函数泄漏。
显式刷新操作(std::flush或std::endl)如何与std::osyncstream的内部缓冲进行交互,为什么误用会将性能恢复到像mutex那样的级别?
许多候选人认为std::flush会立即写入底层设备。在std::osyncstream中,std::flush会触发内部syncbuf准备发射,但不会直接调用emit();它只是设置emit_on_flush状态。如果用户在每个插入后手动调用emit()(模仿单位缓冲),则会强迫每条消息加锁,打破批处理优化。效率依赖于将多条插入批量合并为在范围退出时的单个**emit()**调用。
什么生命周期约束将包装的std::streambuf与std::osyncstream绑定,如果在emit()之前销毁了包装的缓冲区,会发生什么未定义行为?
std::osyncstream存储到包装的std::streambuf的指针,而不是所有权。如果你创建一个临时的std::ostringstream,将其rdbuf()传递给std::osyncstream,而ostringstream在osyncstream之前超出范围,则后续的emit()调用将对悬空指针解引用。候选人通常假设引用计数,或者认为osyncstream复制了缓冲区。标准规定程序员必须确保包装的streambuf的生命周期长于syncstream,类似于std::string_view依赖于底层字符串存储。