最后更新日期:2022-07-03
日志是几乎每个程序都需要的核心组件,用过 C++ 的 std::cout 的朋友都知道,它不是线程安全的!所以多个线程同时用 std::cout 输出的话,输出可能会错乱。有没有简单的解决方案?
如果你在网上搜索,或者去看一些专门的日志库(如 glog)是如何解决这个问题的,不外乎这几种方法:
- 加一个全局锁
- 加一个多生产者-单消费者队列,其他线程写日志的时候先放入队列,单独开一个线程把队列里的日志一条一条写出来。
第一种加锁的方案,增加了一个全局状态,我觉得不是很漂亮。方案二就更不用说了……我就是写个日志而已,有必要引入一个队列再加一个线程吗?有没有更简单的方案?
我写 C++ 程序一般是不用 std::cout 来格式化输出的,而是用更底层的系统调用,也就是 write(2) 或 WriteFile / WriteConsole ,所以我们可以跳过 stdio / iostream 内部状态的同步问题,直接从操作系统层面考虑以下问题:
如果有多个线程 / 进程同时调用 write(2) 或 WriteFile 写一个文件描述符或内核句柄,它们写入的内容会互相覆盖吗?
用常识来想,操作系统层面难道没有任何机制来保证写操作的原子性吗?
于是我们很自然地会问出以下问题:
- Is file append atomic in UNIX? - Stack Overflow
- Is appending to a file atomic with Windows/NTFS? - Stack Overflow
- Are Files Appends Really Atomic? | Not The Wizard
上述讨论的结论是:
- 对 Linux ,POSIX 规范保证用 O_APPEND 模式打开的文件,如果一次写入的内容不超过 PIPE_BUF(一般为 4096)字节,那么就是原子的1
- 对 Win32 的 WriteFile ,如果打开文件时添加了 FILE_APPEND_DATA 参数那么也可以保证追加操作是原子的
所以我对于多线程写日志的解决方案是:每一行单独写入一个 buffer ,然后一次性调用 write(2) 写入,程序日志绝大多数情况下不会超过 PIPE_BUF 。
如果你还在用 C/C++ 自带的 IO 函数,它们的内部存在我们无法控制的缓冲区(stdio buffering),这种方法不一定奏效。所以,直接用系统调用保平安。如果你还是想用 C/C++ 自带的格式化函数,一个简单的方法是先用 snprintf / sstream 把要输出的内容格式化到一个 buffer ,再用系统调用输出。
一个极简的 C++ 11 线程安全日志库(POSIX only):
#include <sstream>
#include <iomanip>
#include <unistd.h>
#include <time.h>
/**
* 在 out 后面追加时间戳,格式 YYYY-MM-DD HH:MM:SS.mmm
*/
void appendTimestampMs(std::ostream& out) {
{};
timespec t (CLOCK_REALTIME, &t); // 忽略错误
clock_gettime{
struct tm tm;
(&t.tv_sec, &tm);
localtime_r<< std::put_time(&tm, "%F %T");
out }
char fill = out.fill();
<< '.' << std::setfill('0') << std::setw(3) << t.tv_nsec / 1000000 << std::setfill(fill);
out }
template<class... Args>
void plog(Args&&... args) {
using _expander = int[];
std::stringstream buf;
// 先输出时间,精确到毫秒
(buf);
appendTimestampMs<< ' ';
buf (void)_expander{ (void(buf << std::forward<Args>(args)), 0)... };
<< '\n';
buf std::string str = buf.str();
(STDOUT_FILENO, str.data(), str.size());
write}
使用:
("[INFO] test: ", 10); plog
输出:
2022-05-06 20:47:43.725 [INFO] test: 10
脚注: