C++ 中如何多线程写日志

最后更新日期:2022-07-03

  日志是几乎每个程序都需要的核心组件,用过 C++ 的 std::cout 的朋友都知道,它不是线程安全的!所以多个线程同时用 std::cout 输出的话,输出可能会错乱。有没有简单的解决方案?

  如果你在网上搜索,或者去看一些专门的日志库(如 glog)是如何解决这个问题的,不外乎这几种方法:

  1. 加一个全局锁
  2. 加一个多生产者-单消费者队列,其他线程写日志的时候先放入队列,单独开一个线程把队列里的日志一条一条写出来。

  第一种加锁的方案,增加了一个全局状态,我觉得不是很漂亮。方案二就更不用说了……我就是写个日志而已,有必要引入一个队列再加一个线程吗?有没有更简单的方案?

  我写 C++ 程序一般是不用 std::cout 来格式化输出的,而是用更底层的系统调用,也就是 write(2) 或 WriteFile / WriteConsole ,所以我们可以跳过 stdio / iostream 内部状态的同步问题,直接从操作系统层面考虑以下问题:

  如果有多个线程 / 进程同时调用 write(2) 或 WriteFile 写一个文件描述符或内核句柄,它们写入的内容会互相覆盖吗?

  用常识来想,操作系统层面难道没有任何机制来保证写操作的原子性吗?

  于是我们很自然地会问出以下问题:

  上述讨论的结论是:

  1. 对 Linux ,POSIX 规范保证用 O_APPEND 模式打开的文件,如果一次写入的内容不超过 PIPE_BUF(一般为 4096)字节,那么就是原子的1
  2. 对 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_gettime(CLOCK_REALTIME, &t); // 忽略错误
    {
        struct tm tm;
        localtime_r(&t.tv_sec, &tm);
        out << std::put_time(&tm, "%F %T");
    }
    char fill = out.fill();
    out << '.' << std::setfill('0') << std::setw(3) << t.tv_nsec / 1000000 << std::setfill(fill);
}

template<class... Args>
void plog(Args&&... args) {
    using _expander = int[];
    std::stringstream buf;
    // 先输出时间,精确到毫秒
    appendTimestampMs(buf);
    buf << ' ';
    (void)_expander{ (void(buf << std::forward<Args>(args)), 0)... };
    buf << '\n';
    std::string str = buf.str();
    write(STDOUT_FILENO, str.data(), str.size());
}

  使用:

plog("[INFO] test: ", 10);

  输出:

2022-05-06 20:47:43.725 [INFO] test: 10

脚注: