一、输出的问题
在学习多线程编程时,如果使用std::cout进行输出的话,往往发现可能打印的数据会串了行,甚至会出现这个线程的输出结果和另外一个线程的输出结果混在一起了。特别是在实际的应用中,一些多线程中的日志往往更容易出现这种现象,导致开发者得去拆这些日志。
这种现象的原因在操作符"<<"在多线程环境下是安全的(或者说原子的)但往往一个std::cout后面会跟多个"<<",而这个标准就不保障是原子的了。而线程调用器往往又是随机的,所以就会出现上面的那些情况。
二、处理方法
解决这种现象的方法是使用锁或者将数据融合到一个字符串中再打印出来。但它们都各自有各自的问题,前者使用锁导致性能变差,而且这种日志行为一般不是多么重要的场景。而合并打印有很场景是不适合的,而且可能会影响到整体的业务逻辑的进行。
在c++20中,提供了一个新的输出流std::osyncstream,它是一个线程安全的输出流,他可以提供多线程环境下通过不同的std::osyncstream实例向同一流写放时的原子性。其原理是使用"本地缓冲+原子提交"的机制,让整段的逻辑输出作为一个原子操作进入到输出流中。
std::osyncstream是对std::basic_syncbuf的抽象,它先是在内部的私有缓冲区内写入<<操作的数据,而不是直接写入到底层流中。当std::osyncstream对象显式的调用emit()或析构时,私有缓冲区内容才会一次性写入底层流。c++标准保证不同的std::osyncstream实例对象提交的内容不会混杂在一起。其内部实现仍然是基于互斥锁实现的,但底层流的互斥锁只在拷贝过程中应用,不包括格式化等阶段,因此代价相对要低。
这里需要说明,在std::osyncstream中,刷新和写入流是两人回事,刷新通过flush()或std::endl提供,而写入流则通过emit()来实现。这点要弄清楚,不要混在一起。
三、注意点
虽然std::osyncstream提供了这种同步的机制,但实际应用中并没有想象的中的完美。需要注意下面的问题:
- 单线程或者简单的多线程中不建议使用
- 它对每次"<<"仍然不提供原子性,它是从emit()或析构到下一次emit()或析构间的原子性写入
- 同步必须保证所有线程均使用emit()或析构
- 不能长时间持有std::osyncstream,这有可能导致其输出内容的异常延迟
- 不要跨域(特别是函数间)使用std::osyncstream实例,有可能导致更加混乱的提交(提前或延迟),建议是局部变量
- std::cerr没有缓冲,要注意和std::osyncstream使用的时可能会影响实时输出
- 注意'\n'在std::osyncstream中不会产生刷新标记,而std::endl才会
- 它不适合于自定义流的类型,如文件流std::ofstream。仅支持std::basic_ostream相关类型
- 要注意各种平台对std::osyncstream支持的情况,不要盲目应用
也就是说,要想显示内容,需要刷新标记和emit()同时作用,才会达到效果。std::osyncstream更适合于类似低频的日志输出这种显式,而对于高频的,往往可能会有一些细节上的问题。
四、例程
看一个cppreference上的例子:
c
#include <iostream>
#include <syncstream>
int main()
{
{
std::osyncstream bout(std::cout);
bout << "Hello," << '\n'; // no flush
bout.emit(); // characters transferred; cout not flushed
bout << "World!" << std::endl; // flush noted; cout not flushed
bout.emit(); // characters transferred; cout flushed
bout << "Greetings." << '\n'; // no flush
} // destructor calls emit(): characters transferred; cout not flushed
// emit can be used for local exception-handling on the wrapped stream
std::osyncstream bout(std::cout);
bout << "Hello, " << "World!" << '\n';
try
{
bout.emit();
}
catch (...)
{
// handle exceptions
}
}
这个例子就非常经典,简单分析一下代码中bout的注释:
- {}内的bout,首次bout.emit()提交"Hello,\n",缓冲区清空(no flush)
- 写入"World!"和std::endl,此时缓冲区记录刷新标记(flush noted; cout not flushed)
- 再次bout.emit()提交"World!\n" 并执行底层cout的刷新,缓冲区再次清空(characters transferred; cout flushed)
- 继续写入"Greetings."(no flush)
- 直到代码块结束,析构函数最后一次调用emit(),退出作用域(destructor calls emit(): characters transferred; cout not flushed)
- {}作用域外的bout,直接调用emit()
五、总结
其实越对c++学习和理解的深入,越发现其每个技术点都是有着严格的应用场景的。虽然标准在不断的强化c++的各种能力,但不要想当然的认为哪个技术的出现会解决一大片的问题。它只会在某些场景下更适用更方便。这一点要深刻的领悟,不能任意操作。