
线程安全的核心不是加锁,而是避免共享可变状态
多数人一想到线程安全日志,第一反应是 std::mutex 包裹 std::ofstream 写入——这能跑,但会成为性能瓶颈,尤其在高并发打点场景下。真正轻量且跨平台的做法,是把「写入」和「格式化」拆开:日志调用方只做无锁的队列投递(比如用 moodycamel::ConcurrentQueue 或 std::atomic 环形缓冲区),由单个后台线程消费、格式化、落盘。
- 避免在日志宏里调用
std::time、std::this_thread::get_id等可能触发系统调用的函数——它们不是无锁的,且 Windows/macOS/Linux 下行为不一致 - 时间戳建议由消费者线程统一获取,或用
std::chrono::steady_clock::now()(跨平台、单调、无系统调用) - 日志等级、文件名、行号这些元信息,必须在调用点(即宏展开时)捕获,否则消费者线程拿到的是它自己的上下文
跨平台文件 I/O 要绕开 fopen 和 std::ofstream 的坑
std::ofstream 在 Windows 上默认以 ANSI 编码打开文件,Linux/macOS 默认 UTF-8;同时,它不支持原子追加(std::ios::app 在多进程下可能丢日志)。更稳妥的方式是用 POSIX open() + write()(Linux/macOS)和 Windows API CreateFileW() + WriteFile() 封装一层,统一走二进制追加模式。
- Windows 下必须用
CreateFileW()而非CreateFileA(),否则路径含中文时直接失败,错误码是ERROR_PATH_NOT_FOUND - 所有路径拼接用
std::filesystem::path(C++17),但注意 macOS 的/var/log默认不可写,应 fallback 到$HOME/Library/Logs - 每次
write()前检查返回值,Linux 可能因磁盘满返回-1且errno == ENOSPC,此时要触发日志降级(如切内存 buffer 或丢弃低优先级日志)
日志级别与编译期过滤必须解耦,否则调试和发布版本行为不一致
很多人用 #ifdef DEBUG 控制是否记录 DEBUG 级日志,结果发布版里连 ERROR 都没了——因为宏定义漏了。正确做法是:日志宏本身不参与条件编译,而由一个运行时可调的全局阈值(std::atomic<loglevel></loglevel>)控制是否入队;再配合编译期开关(如 LOG_ENABLE_LEVEL_DEBUG)决定是否把 LOG_DEBUG 宏展开为实际代码,避免无谓的字符串拼接开销。
- 若未定义
LOG_ENABLE_LEVEL_DEBUG,LOG_DEBUG("x = {}", x)应完全不生成任何指令,连x的求值都不发生 - 运行时阈值只影响已入队的日志是否被消费者写入,不影响队列容量和内存占用
- 注意
__FILE__在 GCC/Clang 下是相对路径,MSVC 是绝对路径,统一用std::filesystem::path(__FILE__).filename().string()标准化
异步日志的崩溃安全比性能更重要
后台日志线程挂了,主逻辑不能卡死或崩溃——这是线上服务的基本底线。常见错误是消费者线程在格式化时抛出 std::bad_alloc 或访问野指针,导致整个进程退出。必须确保消费者线程有完整异常捕获,且崩溃后能自动重建。
立即学习“C++免费学习笔记(深入)”;
- 消费者线程入口用
try { ... } catch (...) { /* 记录原始错误到 stderr,重启线程 */ } - 不要在日志库内部使用
std::shared_ptr管理日志消息,引用计数操作非原子,多线程下可能 double-free;改用std::unique_ptr+ 移动语义,或固定大小的栈分配结构体 - 程序收到
SIGSEGV时,标准库的std::cout/std::cerr不可靠,应直接调用write(2, ...)或WriteFile输出原始错误信息到 stderr 文件描述符
跨平台日志最难的从来不是怎么写,而是怎么在 Windows 的 DLL 场景、macOS 的 sandbox、Linux 的 chroot 下都保持行为一致——这意味着每处系统调用都要查文档确认 ABI 兼容性,而不是依赖 STL 封装。








