std::ostream不能直接多线程共用,因其内部缓冲区非线程安全,多线程并发调用operator

为什么 std::ostream 不能直接多线程共用
因为 std::ostream 内部缓冲区(比如 std::stringbuf 或 std::filebuf)不是线程安全的,多个线程同时调用 operator 或 flush() 会触发未定义行为——常见表现是日志乱序、内容截断、甚至程序崩溃。标准库只保证对不同 std::ostream 对象的并发操作安全,不保证对同一对象的并发写入安全。
所以别试图给全局 std::ofstream 加个 std::mutex 然后锁住整个 链式调用——那会严重拖慢性能,且无法解决流缓冲区内部状态竞争。
- 真正要保护的是「日志消息的完整采集」和「落盘前的缓冲区写入」两个阶段,不是整个流操作
- 异步日志的核心思路是:线程只负责快速拷贝日志内容到无锁队列,由单独的后台线程消费并写入文件
- 流缓冲(
std::streambuf)可以重载,但必须确保sputn/overflow等关键虚函数在单线程上下文中被调用
怎么实现一个无锁日志缓冲队列(基于 moodycamel::ConcurrentQueue)
不用自己造轮子,moodycamel::ConcurrentQueue 是目前 C++ 中最轻量、可靠、且支持内存序精细控制的无锁队列。它比 std::queue + std::mutex 快 5–10 倍,也比很多自研环形缓冲更少出错。
关键不是“队列有多快”,而是它能让你把「日志格式化」和「日志写入」彻底解耦:前端线程只做字符串拼接或 fmt::format_to 到栈上 buffer,然后 move 进队列;后端线程批量取、批量刷盘。
- 队列元素建议用
std::string或自定义小结构体(含时间戳、level、tid、message),避免裸指针或引用 - 不要存
std::stringstream对象进队列——构造/析构开销大,且可能隐式分配 - 设置合理容量上限(如
1024),配合try_enqueue+ 丢弃策略,防止 OOM(尤其在磁盘卡住时) - 后台线程用
while (running) { queue.try_dequeue(item); ... },别用wait_dequeue——它依赖平台 futex,Windows 下不可靠
std::streambuf 子类怎么对接异步写入而不阻塞
你可以继承 std::streambuf 并重写 sync() 和 overflow(),但注意:这些函数默认在调用线程中执行。如果在里面直接 push 到队列还行,但若尝试等 flush 完再返回,就退化成同步日志了。
正确做法是让 overflow() 把当前缓冲区内容打包进队列,然后清空本地 buffer;sync() 只负责触发一次强制提交(比如日志 level=ERROR 时),不等待落盘完成。
-
setp(buf, buf + size)设置本地缓冲区,避免频繁分配;每次overflow()触发时,把pbase()到pptr()的内容 move 出去 - 不要在
sync()里调用write()或fsync()——那是后台线程的事 - 如果要用
std::ostream接口(比如logger ),就绑定这个自定义streambuf到std::ostream实例,但该ostream必须是每个线程独享的(比如 TLS) - 别忘了重载
xsputn提升批量写入效率,否则每个字符都走overflow
异步日志里最容易被忽略的三个坑
不是性能瓶颈,而是上线后才暴露的隐蔽问题。
-
__FILE__和__LINE__在宏展开时被求值,但如果日志宏里用了 lambda 或异步转发,它们会固定为宏定义处的位置,不是调用处——得用LOG(INFO, "msg")这种带显式__FILE__/__LINE__参数的封装 - 后台线程 crash 时,未 flush 的日志永远丢失;需在
std::at_quick_exit或信号处理中强制 dump 队列(但注意:此时不能再 new/malloc) - 文件滚动(rotate)必须由后台线程完成,且要原子替换(
rename()),不能让多个线程同时 open/close 同一个 fd——否则 Linux 下可能丢失 write 缓冲
异步日志最难的从来不是“怎么快”,而是“怎么不丢、不错、不崩”。缓冲区大小、队列策略、崩溃恢复、时钟精度——每个点都要结合你的业务吞吐和可靠性要求来调,没有通用最优解。
C++免费学习笔记(深入):立即使用
在学习笔记中,你将探索 C++ 的入门与实战技巧!











