前言
在 Linux 后端开发和多线程服务端编程中,日志系统几乎是每个项目都会遇到的基础设施。
很多初学者一开始会直接使用 std::cout 打印调试信息。这在单线程、小程序里问题不大,但一旦进入多线程服务场景,就会很快暴露出几个问题:
- 多个线程同时输出,日志内容可能交错在一起;
- 输出位置被写死,后续想从控制台切换到文件很麻烦;
- 没有日志等级,调试信息、普通信息、错误信息混在一起;
- 缺少时间、文件名、行号等定位信息,排查问题效率很低;
- 每次手动拼接格式,代码重复且容易写乱。
成熟项目里通常会使用 glog、spdlog、Boost.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 无法天然解决日志等级、文件落盘、格式统一、策略切换这些工程问题。
因此我们需要把输出封装成一个独立模块。
二、整体架构:格式化与刷新解耦
日志系统可以拆成两个阶段:
-
日志形成阶段
负责把时间戳、等级、PID、文件名、行号、用户内容拼成完整字符串。
-
日志刷新阶段
负责把完整字符串写到某个地方,比如控制台、文件、数据库、网络服务。
这两个阶段最好解耦。日志内容怎么拼,和日志最终写到哪里,本来就不是同一个问题。
这也是为什么本文选择 策略模式:
- 控制台输出是一种策略;
- 文件输出是一种策略;
- 后续写数据库、发网络、写消息队列,也都可以作为新策略加入。
核心类 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(¤t_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离开作用域会自动关闭文件,不需要手动管理文件句柄。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;
};
这里有三处关键设计:
Logger重载operator(),让logger(level, file, line)像函数一样使用;LogMessage重载operator<<,支持链式拼接任意可输出类型;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(¤t_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_INFO、LOG_ERROR 等更短的使用方式 |
| 异常安全 | 文件打开失败、磁盘满、目录创建失败时做更完善的兜底 |
其中最值得继续实现的是 异步日志。同步日志每次都可能触发磁盘 I/O,而磁盘 I/O 相比内存操作非常慢。在高并发服务器中,通常会让业务线程把日志写入内存缓冲区,再由后台线程统一刷盘。
结尾
这套日志系统虽然代码量不大,但里面串起了很多后端开发的关键能力:
- 用 RAII 管理锁和日志刷新时机;
- 用策略模式解耦日志形成和日志输出;
- 用
enum class表达类型安全的日志等级; - 用
localtime_r保障多线程时间转换安全; - 用宏封装
__FILE__和__LINE__,实现接近 glog 的调用体验; - 用互斥锁保护控制台和文件这类临界资源。
真正理解这套设计之后,再去看 glog、spdlog 这类成熟日志库,就不会只觉得它们"好用",而是能看懂它们背后的设计取舍。
日志系统不是简单的 cout 替代品,它是工程可观测性的入口。服务出了问题时,日志往往就是我们和现场之间最重要的一条线索。