C++ 线程安全日志系统:策略模式解耦输出端,RAII 实现 glog 风格流式日志

📅 2026/7/6 3:13:07 👁️ 阅读次数 📝 编程学习
C++ 线程安全日志系统:策略模式解耦输出端,RAII 实现 glog 风格流式日志

前言

在 Linux 后端开发和多线程服务端编程中,日志系统几乎是每个项目都会遇到的基础设施。

很多初学者一开始会直接使用std::cout打印调试信息。这在单线程、小程序里问题不大,但一旦进入多线程服务场景,就会很快暴露出几个问题:

  • 多个线程同时输出,日志内容可能交错在一起;
  • 输出位置被写死,后续想从控制台切换到文件很麻烦;
  • 没有日志等级,调试信息、普通信息、错误信息混在一起;
  • 缺少时间、文件名、行号等定位信息,排查问题效率很低;
  • 每次手动拼接格式,代码重复且容易写乱。

成熟项目里通常会使用glogspdlogBoost.Log这类日志库。但如果我们从零手搓一个简化版线程安全日志系统,就能把策略模式、RAII、线程互斥、可重入函数、流式接口设计这些 C++ 后端开发里的核心知识点串起来。

本文会实现一个支持如下调用风格的日志系统:

LOG(LogLevel::INFO) << "server start, port: " << 8080;

最终日志格式类似:

[2026-04-16 21:33:18] [INFO] [1030871] [Main.cc] [10] - server start, port: 8080

一、日志系统应该解决什么问题

1.1 一条合格日志应该包含什么?

一条真正可用于排查问题的日志,不能只有一句业务文本。它至少应该包含这些字段:

字段作用
时间戳判断问题发生的具体时间
日志等级区分调试、普通、警告、错误、致命问题
进程 ID / 线程 ID在多进程、多线程环境中定位执行流
文件名和行号直接定位是哪一行代码打印的日志
日志正文用户真正想记录的业务内容

所以日志系统本质上做两件事:

  • 把零散的信息组装成统一格式;
  • 把组装好的日志可靠地写到目标位置。

1.2 为什么不能直接用 cout?

std::cout本身不是一个完整日志系统,它只是一个输出工具。

在多线程场景下,多个线程可能同时执行输出操作:

std::cout << "thread-1 message" << std::endl; std::cout << "thread-2 message" << std::endl;

如果没有额外保护,最终看到的日志可能发生字符级别的交错。更重要的是,cout无法天然解决日志等级、文件落盘、格式统一、策略切换这些工程问题。

因此我们需要把输出封装成一个独立模块。

二、整体架构:格式化与刷新解耦

日志系统可以拆成两个阶段:

  1. 日志形成阶段

    负责把时间戳、等级、PID、文件名、行号、用户内容拼成完整字符串。

  2. 日志刷新阶段

    负责把完整字符串写到某个地方,比如控制台、文件、数据库、网络服务。

这两个阶段最好解耦。日志内容怎么拼,和日志最终写到哪里,本来就不是同一个问题。

这也是为什么本文选择策略模式

  • 控制台输出是一种策略;
  • 文件输出是一种策略;
  • 后续写数据库、发网络、写消息队列,也都可以作为新策略加入。

核心类Logger不需要关心具体输出细节,只需要持有一个策略对象即可。

三、前置模块:互斥锁、时间戳、日志等级

3.1 RAII 风格互斥锁封装

控制台和日志文件都是临界资源。多线程同时写入时,必须加锁保护。

这里基于pthread_mutex_t做一个简单封装:

#ifndef MUTEX_HPP #define MUTEX_HPP #include <pthread.h> class Mutex { public: Mutex() { pthread_mutex_init(&_lock, nullptr); } Mutex(const Mutex&) = delete; Mutex& operator=(const Mutex&) = delete; void Lock() { pthread_mutex_lock(&_lock); } void UnLock() { pthread_mutex_unlock(&_lock); } pthread_mutex_t* Origin() { return &_lock; } ~Mutex() { pthread_mutex_destroy(&_lock); } private: pthread_mutex_t _lock; }; class LockGuard { public: explicit LockGuard(Mutex* lockptr) : _lockptr(lockptr) { _lockptr->Lock(); } LockGuard(const LockGuard&) = delete; LockGuard& operator=(const LockGuard&) = delete; ~LockGuard() { _lockptr->UnLock(); } private: Mutex* _lockptr; }; #endif

这里的重点不是把pthread_mutex_t包一层,而是RAII

  • LockGuard构造时自动加锁;
  • LockGuard析构时自动解锁;
  • 即使中途发生异常或提前返回,也不会忘记释放锁。

这和标准库里的std::lock_guard思想一致。

3.2 线程安全的时间戳

日志必须带时间。常见写法是time + localtime + snprintf

但要注意:普通localtime内部使用静态缓冲区,多线程环境下不安全。这里应该使用可重入版本localtime_r

std::string GetTimeStamp() { time_t current_time = time(nullptr); struct tm data_time; localtime_r(&current_time, &data_time); char buffer[128]; snprintf(buffer, sizeof(buffer), "%4d-%02d-%02d %02d:%02d:%02d", data_time.tm_year + 1900, data_time.tm_mon + 1, data_time.tm_mday, data_time.tm_hour, data_time.tm_min, data_time.tm_sec); return buffer; }

几个容易出错的点:

  • tm_year表示从 1900 年开始的偏移量,所以要加1900
  • tm_mon范围是[0, 11],所以要加1
  • %02d可以保证月、日、时、分、秒不足两位时自动补零;
  • 多线程程序里优先使用localtime_r,不要使用localtime

3.3 类型安全的日志等级

日志等级用enum class表示,比普通枚举更安全:

enum class LogLevel { DEBUG, INFO, WARNING, ERROR, FATAL }; std::string LogLevel2String(LogLevel level) { switch (level) { case LogLevel::DEBUG: return "DEBUG"; case LogLevel::INFO: return "INFO"; case LogLevel::WARNING: return "WARNING"; case LogLevel::ERROR: return "ERROR"; case LogLevel::FATAL: return "FATAL"; default: return "UNKNOWN"; } }

使用enum class的好处是:

  • 不会污染外层命名空间;
  • 不会随意隐式转换成整数;
  • 调用时必须写成LogLevel::INFO,语义更清楚。

四、策略模式:控制台输出与文件输出

4.1 抽象策略基类

日志刷新策略只需要对外暴露一个统一接口:把完整日志字符串刷出去。

class LogStrategy { public: virtual ~LogStrategy() = default; virtual void SyncLog(const std::string& message) = 0; };

这里一定要有虚析构函数。因为后面会用基类指针指向子类对象,如果基类析构函数不是虚函数,通过基类指针释放派生类对象时就可能析构不完整。

4.2 控制台日志策略

控制台策略负责把日志打印到屏幕上。

class ConsoleLogStrategy : public LogStrategy { public: void SyncLog(const std::string& message) override { LockGuard guard(&_mutex); std::cerr << message << std::endl; } private: Mutex _mutex; };

这里建议使用std::cerr,原因是日志通常希望尽快输出,cerr默认不缓冲,更适合错误和运行状态信息。

4.3 文件日志策略

文件策略负责把日志追加写入磁盘。

class FileLogStrategy : public LogStrategy { public: FileLogStrategy(const std::string& logdir = "./log/", const std::string& logfilename = "log.txt") : _logdir(logdir) , _logfilename(logfilename) { LockGuard guard(&_mutex); try { if (!std::filesystem::exists(_logdir)) { std::filesystem::create_directories(_logdir); } } catch (const std::filesystem::filesystem_error& e) { std::cerr << e.what() << std::endl; } } void SyncLog(const std::string& message) override { LockGuard guard(&_mutex); std::string target = _logdir + _logfilename; std::ofstream out(target, std::ios::app); if (!out.is_open()) { return; } out << message << '\n'; } private: std::string _logdir; std::string _logfilename; Mutex _mutex; };

文件策略有几个关键点:

  • 使用std::filesystem::create_directories自动创建日志目录;
  • 使用std::ios::app追加写入,避免覆盖历史日志;
  • 文件写入前后加锁,保证多线程下单条日志不会被拆开;
  • ofstream离开作用域会自动关闭文件,不需要手动管理文件句柄。
```mermaid classDiagram class LogStrategy { <<interface>> +SyncLog(message) } class ConsoleLogStrategy { -Mutex _mutex +SyncLog(message) } class FileLogStrategy { -string _logdir -string _logfilename -Mutex _mutex +SyncLog(message) } LogStrategy <|-- ConsoleLogStrategy LogStrategy <|-- FileLogStrategy ```

五、Logger 主体:RAII 自动刷新与流式拼接

5.1 Logger 的职责

Logger是整个日志系统的入口,它主要做三件事:

  • 保存当前日志刷新策略;
  • 提供接口切换控制台 / 文件输出;
  • 生成单条日志对象LogMessage

最巧妙的地方在LogMessage

5.2 LogMessage 为什么要设计成临时对象?

我们想要这样的调用风格:

LOG(LogLevel::INFO) << "user id: " << uid << ", login success";

这行代码看起来像cout,但它什么时候真正输出呢?

答案是:整条语句结束时,临时对象析构,析构函数里自动刷新日志。

```mermaid sequenceDiagram participant User as 用户代码 participant Macro as LOG 宏 participant Msg as LogMessage 临时对象 participant Strategy as 输出策略 User->>Macro: LOG(INFO) << "hello" Macro->>Msg: 创建对象并写入前缀 User->>Msg: operator<< 追加正文 Msg->>Msg: 语句结束,临时对象析构 Msg->>Strategy: SyncLog(_loginfo) ```

这就是 RAII 在日志系统里的一个经典用法:对象生命周期结束,就代表这一条日志已经拼接完成,可以刷新了。

5.3 Logger 核心代码

class Logger { public: Logger() { UseConsoleLogStrategy(); } void UseConsoleLogStrategy() { _strategy = std::make_unique<ConsoleLogStrategy>(); } void UseFileLogStrategy() { _strategy = std::make_unique<FileLogStrategy>(); } class LogMessage { public: LogMessage(LogLevel level, const std::string& filename, int line, Logger& self) : _logger(self) { std::stringstream ss; ss << "[" << GetTimeStamp() << "] " << "[" << LogLevel2String(level) << "] " << "[" << getpid() << "] " << "[" << filename << "] " << "[" << line << "] " << "- "; _loginfo = ss.str(); } ~LogMessage() { if (_logger._strategy) { _logger._strategy->SyncLog(_loginfo); } } template<class T> LogMessage& operator<<(const T& info) { std::stringstream ss; ss << info; _loginfo += ss.str(); return *this; } private: std::string _loginfo; Logger& _logger; }; LogMessage operator()(LogLevel level, const std::string& filename, int line) { return LogMessage(level, filename, line, *this); } private: std::unique_ptr<LogStrategy> _strategy; };

这里有三处关键设计:

  1. Logger重载operator(),让logger(level, file, line)像函数一样使用;
  2. LogMessage重载operator<<,支持链式拼接任意可输出类型;
  3. LogMessage析构时调用当前策略的SyncLog,实现自动刷新。

5.4 glog 风格宏封装

为了让使用方式更简洁,可以定义宏:

Logger logger; #define LOG(level) logger(level, __FILE__, __LINE__) #define ENABLE_CONSOLE_LOG_STRATEGY() logger.UseConsoleLogStrategy() #define ENABLE_FILE_LOG_STRATEGY() logger.UseFileLogStrategy()

这样用户就不需要手动传文件名和行号:

LOG(LogLevel::ERROR) << "open file failed, path: " << path;

__FILE____LINE__会由编译器自动替换成当前源文件和代码行号。

六、完整代码实现

下面给出一个可以直接放入Logger.hpp的整合版本。

#ifndef LOGGER_HPP #define LOGGER_HPP #include <ctime> #include <filesystem> #include <fstream> #include <iostream> #include <memory> #include <sstream> #include <string> #include <unistd.h> #include "Mutex.hpp" namespace LogModule { std::string GetTimeStamp() { time_t current_time = time(nullptr); struct tm data_time; localtime_r(&current_time, &data_time); char buffer[128]; snprintf(buffer, sizeof(buffer), "%4d-%02d-%02d %02d:%02d:%02d", data_time.tm_year + 1900, data_time.tm_mon + 1, data_time.tm_mday, data_time.tm_hour, data_time.tm_min, data_time.tm_sec); return buffer; } enum class LogLevel { DEBUG, INFO, WARNING, ERROR, FATAL }; std::string LogLevel2String(LogLevel level) { switch (level) { case LogLevel::DEBUG: return "DEBUG"; case LogLevel::INFO: return "INFO"; case LogLevel::WARNING: return "WARNING"; case LogLevel::ERROR: return "ERROR"; case LogLevel::FATAL: return "FATAL"; default: return "UNKNOWN"; } } class LogStrategy { public: virtual ~LogStrategy() = default; virtual void SyncLog(const std::string& message) = 0; }; class ConsoleLogStrategy : public LogStrategy { public: void SyncLog(const std::string& message) override { LockGuard guard(&_mutex); std::cerr << message << std::endl; } private: Mutex _mutex; }; class FileLogStrategy : public LogStrategy { public: FileLogStrategy(const std::string& logdir = "./log/", const std::string& logfilename = "log.txt") : _logdir(logdir) , _logfilename(logfilename) { LockGuard guard(&_mutex); try { if (!std::filesystem::exists(_logdir)) { std::filesystem::create_directories(_logdir); } } catch (const std::filesystem::filesystem_error& e) { std::cerr << e.what() << std::endl; } } void SyncLog(const std::string& message) override { LockGuard guard(&_mutex); std::ofstream out(_logdir + _logfilename, std::ios::app); if (!out.is_open()) { return; } out << message << '\n'; } private: std::string _logdir; std::string _logfilename; Mutex _mutex; }; class Logger { public: Logger() { UseConsoleLogStrategy(); } void UseConsoleLogStrategy() { _strategy = std::make_unique<ConsoleLogStrategy>(); } void UseFileLogStrategy() { _strategy = std::make_unique<FileLogStrategy>(); } class LogMessage { public: LogMessage(LogLevel level, const std::string& filename, int line, Logger& self) : _logger(self) { std::stringstream ss; ss << "[" << GetTimeStamp() << "] " << "[" << LogLevel2String(level) << "] " << "[" << getpid() << "] " << "[" << filename << "] " << "[" << line << "] " << "- "; _loginfo = ss.str(); } ~LogMessage() { if (_logger._strategy) { _logger._strategy->SyncLog(_loginfo); } } template<class T> LogMessage& operator<<(const T& info) { std::stringstream ss; ss << info; _loginfo += ss.str(); return *this; } private: std::string _loginfo; Logger& _logger; }; LogMessage operator()(LogLevel level, const std::string& filename, int line) { return LogMessage(level, filename, line, *this); } private: std::unique_ptr<LogStrategy> _strategy; }; Logger logger; #define LOG(level) logger(level, __FILE__, __LINE__) #define ENABLE_CONSOLE_LOG_STRATEGY() logger.UseConsoleLogStrategy() #define ENABLE_FILE_LOG_STRATEGY() logger.UseFileLogStrategy() } #endif

七、多线程测试与关键问题总结

7.1 测试代码

#include <iostream> #include <pthread.h> #include <unistd.h> #include "Logger.hpp" using namespace LogModule; void* ThreadLogTest(void* arg) { const char* thread_name = static_cast<const char*>(arg); for (int i = 0; i < 5; ++i) { LOG(LogLevel::INFO) << thread_name << " print log, count: " << i; usleep(1000); } return nullptr; } int main() { ENABLE_CONSOLE_LOG_STRATEGY(); LOG(LogLevel::DEBUG) << "debug message, value: " << 3.14; LOG(LogLevel::INFO) << "server start success"; LOG(LogLevel::WARNING) << "config missing, use default value"; LOG(LogLevel::ERROR) << "open file failed"; ENABLE_FILE_LOG_STRATEGY(); LOG(LogLevel::INFO) << "switch to file log strategy"; pthread_t t1, t2, t3, t4; pthread_create(&t1, nullptr, ThreadLogTest, (void*)"thread-1"); pthread_create(&t2, nullptr, ThreadLogTest, (void*)"thread-2"); pthread_create(&t3, nullptr, ThreadLogTest, (void*)"thread-3"); pthread_create(&t4, nullptr, ThreadLogTest, (void*)"thread-4"); pthread_join(t1, nullptr); pthread_join(t2, nullptr); pthread_join(t3, nullptr); pthread_join(t4, nullptr); LOG(LogLevel::INFO) << "multi-thread log test finish"; return 0; }

编译时注意链接 pthread,并开启 C++17:

g++ main.cc -o main -std=c++17 -lpthread

7.2 为什么这套设计是线程安全的?

```mermaid flowchart TD A["多个线程同时调用 LOG"] --> B["各自创建 LogMessage 临时对象"] B --> C["各自在线程栈上拼接日志内容"] C --> D["析构时进入 SyncLog"] D --> E["对控制台/文件加锁"] E --> F["完整写入一条日志"] F --> G["释放锁"] ```

线程安全来自两个层面:

  • 日志格式化阶段:每个线程都有自己的LogMessage临时对象,不共享_loginfo,所以不需要加锁;
  • 日志刷新阶段:控制台和文件是共享资源,必须在SyncLog内部加锁。

这样做的好处是锁的粒度很小,只有真正写控制台或写文件时才加锁,日志拼接过程不会阻塞其他线程。

7.3 为什么析构函数里可以刷新日志?

表达式:

LOG(LogLevel::INFO) << "hello" << 123;

本质上会生成一个LogMessage临时对象。整条语句执行完毕后,临时对象生命周期结束,于是自动调用析构函数。

这时用户内容已经通过多个operator<<拼接完成,所以析构函数正好是刷新日志的最佳时机。

7.4 为什么要用策略模式?

如果不用策略模式,Logger里可能会写很多判断:

if (type == CONSOLE) { // 写控制台 } else if (type == FILE) { // 写文件 } else if (type == NETWORK) { // 写网络 }

这种写法后续每增加一种输出方式,都要修改Logger本身。

策略模式把“怎么输出”交给子类:

  • ConsoleLogStrategy只负责控制台;
  • FileLogStrategy只负责文件;
  • 以后新增NetworkLogStrategy,不需要动现有逻辑。

这就符合开闭原则:对扩展开放,对修改关闭。

八、后续优化方向

当前版本已经具备一个基础日志系统的核心能力,但如果要继续向工业级靠近,还可以扩展这些功能:

优化方向说明
异步日志业务线程只写内存队列,后台线程批量刷盘,降低 I/O 阻塞
日志等级过滤例如生产环境只输出INFO及以上等级
日志滚动按文件大小或日期切分日志,避免单文件过大
线程 ID在日志中加入pthread_self()或系统线程 ID
单例封装避免头文件中定义全局对象导致多翻译单元重复定义问题
宏扩展提供LOG_INFOLOG_ERROR等更短的使用方式
异常安全文件打开失败、磁盘满、目录创建失败时做更完善的兜底

其中最值得继续实现的是异步日志。同步日志每次都可能触发磁盘 I/O,而磁盘 I/O 相比内存操作非常慢。在高并发服务器中,通常会让业务线程把日志写入内存缓冲区,再由后台线程统一刷盘。

结尾

这套日志系统虽然代码量不大,但里面串起了很多后端开发的关键能力:

  • 用 RAII 管理锁和日志刷新时机;
  • 用策略模式解耦日志形成和日志输出;
  • enum class表达类型安全的日志等级;
  • localtime_r保障多线程时间转换安全;
  • 用宏封装__FILE____LINE__,实现接近 glog 的调用体验;
  • 用互斥锁保护控制台和文件这类临界资源。

真正理解这套设计之后,再去看glogspdlog这类成熟日志库,就不会只觉得它们“好用”,而是能看懂它们背后的设计取舍。

日志系统不是简单的cout替代品,它是工程可观测性的入口。服务出了问题时,日志往往就是我们和现场之间最重要的一条线索。