Linux 线程日志系统设计:从策略模式、RAII 到 pthread 线程安全与内核写入路径|附源码
本文介绍了一个基于C++17的多线程日志系统实现,采用策略模式设计,支持控制台和文件两种日志输出方式。系统核心特点包括:1. 结构化日志格式,包含时间戳、日志等级、进程PID、文件名和行号等关键信息;2. 策略模式实现输出目标解耦,便于扩展新的输出方式;3. RAII机制自动管理日志刷新,通过临时LogMessage对象实现流式输出;4. 线程安全设计,使用Mutex和LockGuard保护共享资
上篇热文:Linux 线程同步硬核解析:从条件变量、阻塞队列到信号量环形队列
目录
8. 文件日志策略:从 C++ 代码到 Linux 内核写入路径
源码
Logger.hpp:
#ifndef __LOGGER_HPP
#define __LOGGER_HPP
#include <iostream>
#include <cstdio>
#include <string>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <unistd.h>
#include <memory>
#include "Mutex.hpp"
namespace LogModule
{
// 获取时间
std::string GetTimeStamp()
{
time_t timestamp = time(nullptr);
struct tm data_time;
localtime_r(×tamp, &data_time);
char data_time_str[128];
snprintf(data_time_str, sizeof(data_time_str), "%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 data_time_str;
}
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 &logmessage) = 0;
};
// 子类:继承纯虚接口类
// 策略1
class ConsoleLogStrategy : public LogStrategy
{
public:
ConsoleLogStrategy() {}
~ConsoleLogStrategy() {}
void SyncLog(const std::string &logmessage) override
{
LockGuard lockguard(&_mutex);
std::cout << logmessage << std::endl;
}
private:
Mutex _mutex;
};
static const std::string glogdir = "./log/";
static const std::string glogfilename = "log.log";
// 子类:继承纯虚接口类
// 策略2
class FileLogStrategy : public LogStrategy
{
public:
FileLogStrategy(const std::string &dir = glogdir, const std::string &filename = glogfilename)
: _logdir(dir), _logfilename(filename)
{
// 创建目录
LockGuard lockguard(&_mutex);
if (std::filesystem::exists(_logdir))
{
return;
}
else
{
try
{
std::filesystem::create_directories(_logdir);
}
catch (const std::filesystem::filesystem_error &e)
{
std::cerr << e.what() << '\n';
}
}
}
~FileLogStrategy()
{
}
void SyncLog(const std::string &logmessage) override
{
LockGuard lockguard(&_mutex);
std::string logfilename = _logdir + _logfilename;
std::ofstream out(logfilename, std::ios::app); // 追加写入文件
if (!out.is_open())
{
return;
}
out << logmessage << "\n";
out.close();
}
private:
std::string _logdir;
std::string _logfilename;
Mutex _mutex;
};
// 日志类
class Logger
{
public:
Logger()
{
UseConsoleLogStrategy();
}
~Logger()
{
}
void UseConsoleLogStrategy()
{
_strategy = std::make_unique<ConsoleLogStrategy>();
}
void UseFileLogStrategy()
{
_strategy = std::make_unique<FileLogStrategy>();
}
// 内部类
// 将类变为string
class LogMessage
{
public:
LogMessage(LogLevel level, std::string &filename, int line, Logger&self)
: _level(level), _curr_time(GetTimeStamp()), _pid(getpid()), _filename(filename), _line(line), _logger(self)
{
std::stringstream ss;
ss << "[" << _curr_time << "]"
<< "[" << LogLevel2String(_level) << "]"
<< "[" << _pid << "]"
<< "[" << _filename << "]"
<< "[" << _line << "]"
<< "-";
_loginfo = ss.str();
}
template<typename T>
LogMessage &operator << (const T &info)
{
std::stringstream ss;
ss << info;
_loginfo += ss.str();
return *this;
}
~LogMessage() // RAII风格的日志刷新
{
if(_logger._strategy)
{
_logger._strategy->SyncLog(_loginfo);
}
}
private:
LogLevel _level; // 日志等级
std::string _curr_time; // 当前时间
pid_t _pid; // 进程pid
std::string _filename; // 文件名
int _line; // 行号
std::string _loginfo; // 一条完整日志
Logger &_logger; // 外部类的引用
};
// Logger 对象打印日志的时候,故意返回一个临时的LogMessage对象
LogMessage operator()(LogLevel level, 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
main.cc:
#include "Logger.hpp"
#include <iostream>
#include <thread>
#include <chrono>
using namespace LogModule;
int main()
{
ENABLE_CONSOLE_LOG_STRATEGY();
LOG(LogLevel::DEBUG) << "CONSOLE hello world" << " xxxx, " << 3.14 << " C";
LOG(LogLevel::INFO) << " CONSOLE hello world" << " xxxx, " << 3.14 << " C";
LOG(LogLevel::WARNING) << " CONSOLE hello world" << " xxxx, " << 3.14 << " C";
LOG(LogLevel::ERROR) << "CONSOLE hello world" << " xxxx, " << 3.14 << " C";
LOG(LogLevel::FATAL) << "CONSOLE hello world" << " xxxx, " << 3.14 << " C";
ENABLE_FILE_LOG_STRATEGY();
LOG(LogLevel::DEBUG) << "FILE hello world" << " xxxx, " << 3.14 << " C";
LOG(LogLevel::INFO) << " FILE hello world" << " xxxx, " << 3.14 << " C";
LOG(LogLevel::WARNING) << " FILE hello world" << " xxxx, " << 3.14 << " C";
LOG(LogLevel::ERROR) << "FILE hello world" << " xxxx, " << 3.14 << " C";
LOG(LogLevel::FATAL) << "FILE hello world" << " xxxx, " << 3.14 << " C";
// std::string message = "consule hello log, hello world!";
// std::unique_ptr<LogStrategy> strategy = std::make_unique<ConsoleLogStrategy>();
// strategy->SyncLog(message);
// strategy->SyncLog(message);
// strategy->SyncLog(message);
// strategy->SyncLog(message);
// strategy->SyncLog(message);
// strategy->SyncLog(message);
// std::string message1 = "file hello log, hello world!";
// strategy = std::make_unique<FileLogStrategy>();
// strategy->SyncLog(message1);
// strategy->SyncLog(message1);
// strategy->SyncLog(message1);
// strategy->SyncLog(message1);
// strategy->SyncLog(message1);
// std::cout << LogLevel2String(LogLevel::DEBUG) << std::endl;
// std::cout << LogLevel2String(LogLevel::WARNING) << std::endl;
// std::cout << LogLevel2String(LogLevel::INFO) << std::endl;
// std::cout << LogLevel2String(LogLevel::ERROR) << std::endl;
// std::cout << LogLevel2String(LogLevel::FATAL) << std::endl;
// std::cout << "Testing Logger Module" << std::endl;
// std::cout << "=====================" << std::endl;
// // Test GetTimeStamp
// std::cout << "\n1. GetTimeStamp:" << std::endl;
// std::cout << "Timestamp: " << LogModule::GetTimeStamp() << std::endl;
// // Test multiple timestamps
// std::cout << "\n2. Multiple Timestamps (1s apart):" << std::endl;
// for (int i = 0; i < 3; ++i)
// {
// std::cout << "Timestamp " << i << ": " << LogModule::GetTimeStamp() << std::endl;
// std::this_thread::sleep_for(std::chrono::seconds(1));
// }
return 0;
}
Makefile:
CXX = g++
CXXFLAGS = -std=c++17 -Wall -g
TARGET = main
SRCS = main.cc
OBJS = $(SRCS:.cc=.o)
$(TARGET): $(OBJS)
$(CXX) $(CXXFLAGS) -o $@ $^
%.o: %.cc Logger.hpp
$(CXX) $(CXXFLAGS) -c -o $@ $<
clean:
rm -f $(OBJS) $(TARGET)
.PHONY: clean
Mutex.hpp:
#ifndef __MUTEX_HPP
#define __MUTEX_HPP
#include <iostream>
#include <pthread.h>
class Mutex
{
public:
Mutex()
{
pthread_mutex_init(&_lock, nullptr);
}
void Lock()
{
pthread_mutex_lock(&_lock);
}
pthread_mutex_t *Orgin()
{
return &_lock;
}
void Unlock()
{
pthread_mutex_unlock(&_lock);
}
~Mutex()
{
pthread_mutex_destroy(&_lock);
}
private:
pthread_mutex_t _lock;
};
class LockGuard
{
public:
LockGuard(Mutex *lockp): _lockp(lockp)
{
_lockp->Lock();
}
~LockGuard()
{
_lockp->Unlock();
}
private:
Mutex *_lockp;
};
#endif
1.线程模块必须先引入日志系统
在多线程程序中,日志不是附属功能,而是基础设施。
单线程程序出现问题时,通常还能通过断点、输出变量、单步调试定位。但多线程程序的问题往往和时序有关:线程什么时候启动、什么时候阻塞、哪个线程取到了任务、线程池是否退出、条件变量是否被唤醒、任务是否被正确消费,这些状态很难靠调试器稳定复现。
因此,线程系统里必须有日志。
线程日志至少要回答几个问题:
什么时候发生的?
是什么级别的问题?
哪个进程发生的?
哪个线程发生的?
在哪个源文件、哪一行发生的?
具体发生了什么?
日志应该输出到哪里?
我们目标写出的日志格式为:
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [16] - hello world
这是一个基础但完整的日志结构。它包含时间戳、日志等级、进程PID、文件名、行号和日志正文。
在多线程场景中,可以再补充线程信息:
[时间] [等级] [pid] [tid/lwp] [文件] [行号] - 内容
因为多个线程共享同一个进程PID,仅有PID无法定位到底是哪个线程打印的日志。在Linux上,线程本质是轻量级进程,内核为每个线程分配一个task_struct,也有自己的内核线程ID。可以通过:
syscall(SYS_gettid);获取当前线程在Linux内核中的LWP/TID。
2.策略模式在日志系统中的作用
设计模式是针对常见场景总结出来的一套可复用解决方案。日志系统很适合使用策略模式。
原因:日志内容的构建逻辑基本固定,但日志的输出方式可能变化。
常见输出策略:
输出到控制台
输出到文件
输出到网络
输出到消息队列
输出到 syslog
输出到异步后台线程
如果把这些输出方式全部写进一个 Logger 类里,代码会变成大量 if-else:
if (mode == CONSOLE) {
...
} else if (mode == FILE) {
...
} else if (mode == NETWORK) {
...
}
这种设计扩展性差,后续新增日志输出目标时,必须修改核心类。
策略模式的做法是:定义一个统一的策略接口,不同输出方式实现这个接口。
class LogStrategy
{
public:
virtual ~LogStrategy() = default;
virtual void SyncLog(const std::string& logmessage) = 0;
};
这个类的意义:它不干活,只定规矩:控制台日志,文件日志,网络日志,数据库日志都必须继承它,并且必须实现 SyncLog。
控制台日志策略用于实现它:
class ConsoleLogStrategy : public LogStrategy
{
public:
void SyncLog(const std::string& logmessage) override
{
LockGuard lockguard(&_mutex);
std::cout << logmessage << std::endl;
}
private:
Mutex _mutex;
};
文件日志策略也实现它:
class FileLogStrategy : public LogStrategy
{
public:
void SyncLog(const std::string& logmessage) override
{
LockGuard lockguard(&_mutex);
std::string logfilename = _logdir + _logfilename;
std::ofstream out(logfilename, std::ios::app);
if (!out.is_open()) {
return;
}
out << logmessage << "\n";
}
private:
std::string _logdir;
std::string _logfilename;
Mutex _mutex;
};
这样 Logger 不需要关心日志最终写到哪里。它只需要持有一个策略对象:
std::unique_ptr<LogStrategy> _strategy;
切换策略时只需要替换_stratrgy:
void UseConsoleLogStrategy()
{
_strategy = std::make_unique<ConsoleLogStrategy>();
}
void UseFileLogStrategy()
{
_strategy = std::make_unique<FileLogStrategy>();
}
这就是策略模式在日志系统中的核心价值:
日志构建逻辑稳定
日志输出策略可替换
新增输出方式不破坏 Logger 主体
3.日志等级
日志等级定义如下:
enum class LogLevel
{
DEBUG,
INFO,
WARNING,
ERROR,
FATAL
};
不同等级表达含义:
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";
}
}
工程化日志系统通常还会做“日志等级过滤”。例如:生产环境只打印INFO以上,调试环境打印DEBUG。这可以避免大量低价值日志拖慢程序。
可以设计一个全局阙值:
LogLevel g_level = LogLevel::INFO;
只有当前日志等级大于等于阙值时才真正输出。
4.时间戳:localtime_r
日志时间戳获取代码如下:
std::string GetTimeStamp()
{
time_t timestamp = time(nullptr);
struct tm data_time;
localtime_r(×tamp, &data_time);
char data_time_str[128];
snprintf(data_time_str, sizeof(data_time_str),
"%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 data_time_str;
}
这里有两个关键点。
第一,tm_year表示从 1900 年开始的偏移量,所以必须加 1900。
第二,tm_mon 范围是 0 到 11,所以必须加 1。
第三,多线程程序中应该使用 localtime_r,不要使用 localtime。
- localtime 返回的是指向静态内部缓冲区的指针,多线程同时调用时可能发生数据竞争。localtime_r 是线程安全版本,由调用者传入 struct tm 存储结果。
struct tm data_time;
localtime_r(×tamp, &data_time);
线程日志中这种细节很重要。日志系统本身必须线程安全,否则它会成为排查问题时的新问题来源。
5.Mutex和LockGuard:日志输出必须互斥
代码中封装了 pthread mutex:
class Mutex
{
public:
Mutex()
{
pthread_mutex_init(&_lock, nullptr);
}
void Lock()
{
pthread_mutex_lock(&_lock);
}
pthread_mutex_t* Orgin()
{
return &_lock;
}
void Unlock()
{
pthread_mutex_unlock(&_lock);
}
~Mutex()
{
pthread_mutex_destroy(&_lock);
}
private:
pthread_mutex_t _lock;
};
它的作用:封装 Linux 原生互斥锁,提供加锁、解锁接口。
以及 RAII 风格的锁守卫:
class LockGuard
{
public:
LockGuard(Mutex* lockp) : _lockp(lockp)
{
_lockp->Lock();
}
~LockGuard()
{
_lockp->Unlock();
}
private:
Mutex* _lockp;
};
它的作用:拿到一把锁,构造自动加锁,析构自动解锁。
日志输出为什么需要加锁?
因为控制台和文件都是共享资源。多个线程同时执行:
std::cout << logmessage << std::endl;
可能导致输出交错:
[2024-08-04 12:00:00] [INFO] threa[2024-08-04 12:00:00] [DEBUG] ...
文件输出也一样。多个线程同时打开同一个文件、追加写入,如果没有进程内互斥,日志行可能交错,甚至因为缓冲区刷新时机不同导致顺序混乱。
因此每一种日志策略内部都应该保护自己的输出临界区。
控制台策略:
void SyncLog(const std::string& logmessage) override
{
LockGuard lockguard(&_mutex);
std::cout << logmessage << std::endl;
}
文件策略:
void SyncLog(const std::string& logmessage) override
{
LockGuard lockguard(&_mutex);
std::ofstream out(_logdir + _logfilename, std::ios::app);
if (!out.is_open()) {
return;
}
out << logmessage << "\n";
}
RAII 的价值在这里很明确:只要离开作用域,就自动释放锁。即使中间出现 return,也不会忘记解锁。
6.RAII日志对象
为什么LOG(INFO) << "xxx"能工作?
因为我的Logger内部定义了一个LogMessage类:
class LogMessage
{
public:
LogMessage(LogLevel level, std::string& filename, int line, Logger& self)
: _level(level),
_curr_time(GetTimeStamp()),
_pid(getpid()),
_filename(filename),
_line(line),
_logger(self)
{
std::stringstream ss;
ss << "[" << _curr_time << "]"
<< "[" << LogLevel2String(_level) << "]"
<< "[" << _pid << "]"
<< "[" << _filename << "]"
<< "[" << _line << "]"
<< "-";
_loginfo = ss.str();
}
template<typename T>
LogMessage& operator<<(const T& info)
{
std::stringstream ss;
ss << info;
_loginfo += ss.str();
return *this;
}
~LogMessage()
{
if (_logger._strategy) {
_logger._strategy->SyncLog(_loginfo);
}
}
private:
LogLevel _level;
std::string _curr_time;
pid_t _pid;
std::string _filename;
int _line;
std::string _loginfo;
Logger& _logger;
};
核心设计是:构造时生成日志头,operator<< 不断追加日志正文,析构时自动刷新。
外部调用:
LOG(LogLevel::INFO) << "thread start, id=" << id;
宏展开后类似:
logger(LogLevel::INFO, __FILE__, __LINE__) << "thread start, id=" << id;
logger(...) 返回一个临时 LogMessage 对象。后续的 << 操作都在这个临时对象上追加内容。当整条语句结束时,临时对象生命周期结束,析构函数自动执行,最终调用策略对象输出日志。
这是一种典型的 RAII 日志写法:
构造:生成日志头
流式写入:追加日志正文
析构:自动刷新日志
它的好处是调用端非常简洁:
LOG(LogLevel::DEBUG) << "get task";
LOG(LogLevel::ERROR) << "open file failed: " << filename;
不需要显式调用 Flush() 或 Submit()。
7.宏封装:获取文件名和行号的关键
日志系统通常会使用宏,而不是普通函数,原因是宏可以在调用点展开 __FILE__ 和 __LINE__。
#define LOG(level) logger(level, __FILE__, __LINE__)
调用:
LOG(LogLevel::WARNING) << "queue is empty";
会自动携带当前源文件名和行号。如果不用宏,而是写成函数:
Log(LogLevel::WARNING, "queue is empty");
函数内部拿到的是日志函数所在文件和行号,而不是真实调用日志的位置。这样定位问题就失去了价值。
策略切换也可以用宏封装:
#define ENABLE_CONSOLE_LOG_STRATEGY() logger.UseConsoleLogStrategy()
#define ENABLE_FILE_LOG_STRATEGY() logger.UseFileLogStrategy()
调用端:
ENABLE_CONSOLE_LOG_STRATEGY();
LOG(LogLevel::DEBUG) << "CONSOLE hello world";
LOG(LogLevel::INFO) << "CONSOLE hello world";
ENABLE_FILE_LOG_STRATEGY();
LOG(LogLevel::DEBUG) << "FILE hello world";
LOG(LogLevel::INFO) << "FILE hello world";
提供选择使用何种日志策略的方法。
8. 文件日志策略:从 C++ 代码到 Linux 内核写入路径
文件日志策略看起来只是:
std::ofstream out(logfilename, std::ios::app);
out << logmessage << "\n";
out.close();
但它背后的执行路径很长。在 Linux 上,大致流程是:
std::ofstream 构造
|
v
C++ 标准库打开文件
|
v
open/openat 系统调用
|
v
VFS 层解析路径
|
v
文件系统查找 inode/dentry
|
v
获得 file 对象和文件描述符
|
v
write 系统调用写入数据
|
v
数据进入 page cache
|
v
内核异步回写到磁盘
|
v
close 关闭文件描述符
注意:out << logmessage 并不等于数据立刻落盘。大多数情况下,数据先进入用户态缓冲区,再通过系统调用进入内核 page cache。进入 page cache 后,也不代表已经写入物理磁盘。内核通常会在合适时机进行回写。
如果日志需要强持久化,例如崩溃前最后一条错误必须落盘,需要考虑:fsync(fd);
或者至少 flush 文件流。但这会明显降低性能。高性能日志系统通常会在可靠性和吞吐之间做权衡。
std::ios::app 表示追加写入,底层通常对应追加模式。对于多进程同时写同一个日志文件,单靠进程内 mutex 不够,因为 mutex 只能保护当前进程内的线程,不能保护其他进程。
如果是多进程日志,需要考虑:
O_APPEND
文件锁
单独日志进程
按进程拆分日志文件
集中式日志服务
9. 控制台日志策略:终端也是临界资源
控制台输出不是“天然线程安全”的业务抽象。多个线程同时写标准输出或标准错误,可能造成行级交错。
控制台日志策略中加锁:
class ConsoleLogStrategy : public LogStrategy
{
public:
void SyncLog(const std::string& logmessage) override
{
LockGuard lockguard(&_mutex);
std::cout << logmessage << std::endl;
}
private:
Mutex _mutex;
};
这保证在当前进程内,一条日志从开始输出到结束换行期间不会被其他线程插入。
如果输出到终端,最终也会走系统调用,例如 write,再进入 tty 驱动或管道/伪终端实现。std::cout 本身还有 C++ iostream 缓冲和同步机制,但业务上不应该依赖它来保证整条日志完整。显式加锁是更明确的设计。
实际工程中,日志一般更推荐输出到 std::cerr,因为错误流通常不做完全缓冲,更适合调试场景。但文件日志仍然是服务端程序的主路径。
10.线程安全不只在策略内部,还包括策略切换
当前设计中,每个策略对象内部有自己的 mutex:
ConsoleLogStrategy::_mutex
FileLogStrategy::_mutex
这能保证同一种策略内部输出安全。但还存在一个需要明确的工程问题:Logger::_strategy 本身的切换没有加锁。
例如一个线程正在打印日志:
_logger._strategy->SyncLog(_loginfo);
另一个线程同时执行:
logger.UseFileLogStrategy();
这会修改:
_strategy = std::make_unique<FileLogStrategy>();
如果两个操作并发发生,就可能出现数据竞争:一个线程正在使用旧策略对象,另一个线程释放并替换它。
因此当前版本有一个隐含前提:
日志策略只在程序启动阶段配置,不在多线程运行过程中动态切换。
如果需要运行时动态切换策略,必须保护 _strategy。
一种简单方案是在 Logger 内部增加一把锁:
class Logger
{
public:
void UseFileLogStrategy()
{
LockGuard lock(&_mutex);
_strategy = std::make_unique<FileLogStrategy>();
}
void Flush(const std::string& msg)
{
LockGuard lock(&_mutex);
if (_strategy) {
_strategy->SyncLog(msg);
}
}
private:
Mutex _mutex;
std::unique_ptr<LogStrategy> _strategy;
};
但是这样会导致日志构建和输出串行化,性能较低。
更好的工程方案可以使用:
std::shared_ptr<LogStrategy>
读写锁
原子 shared_ptr
启动期固定策略
11.日志系统和线程池的关系
下篇文章我们将进入线程池。
线程池运行时需要记录:
线程池构造
线程初始化
线程启动
worker 进入运行循环
任务入队
线程取到任务
线程池停止
线程退出
例如线程池日志场景:
LOG(LogLevel::INFO) << "ThreadPool Construct()";
LOG(LogLevel::INFO) << "init thread " << thread.Name() << " done";
LOG(LogLevel::INFO) << "start thread " << thread.Name() << " done";
LOG(LogLevel::DEBUG) << name << " get a task";
LOG(LogLevel::DEBUG) << "任务入队列成功";
LOG(LogLevel::DEBUG) << "线程池退出中...";
这些日志直接帮助定位线程池状态。如果没有日志,线程池出问题时只能猜:
任务有没有入队?
线程有没有启动?
线程是不是阻塞在条件变量?
Stop 后有没有 NotifyAll?
任务队列是否已经消费完?
Join 卡住是哪个线程没有退出?
有日志后,这些问题可以通过时间线分析。线程日志的价值不是“输出点信息”,而是构建并发程序的可观测性。
12.日志输出性能问题
当前文件策略每写一条日志都会:
打开文件 写入内容 关闭文件
对应代码:
std::ofstream out(logfilename, std::ios::app);
out << logmessage << "\n";
out.close();
优点是简单,且每次写入后文件句柄立即释放。缺点是性能较低。
每条日志都 open/close,会带来大量系统调用:
openat
write
close
在高并发线程池中,如果每个任务都打印日志,这个开销会很明显。
更高性能的设计通常是:
Logger 持久持有 ofstream
多线程写入时加锁
定期 flush
或者使用异步日志线程
异步日志的典型结构是:
业务线程生成日志
|
v
日志消息进入阻塞队列
|
v
专门日志线程批量写文件
这又回到了生产者消费者模型。业务线程是日志生产者,日志线程是消费者,日志队列是交易场所。
异步日志的优势:
业务线程不直接执行慢速磁盘 IO
可以批量写入,减少系统调用次数
日志模块对主业务延迟影响更小
但异步日志也有代价:
程序崩溃时队列中日志可能丢失
退出时必须 flush 队列
实现复杂度更高
工程阶段需要根据性能目标升级为异步日志。
13.内核视角:日志写入可能拖慢多线程程序
日志输出最终通常涉及系统调用。系统调用会从用户态切换到内核态,成本比普通函数调用高。
一次文件日志可能包括:
- 用户态格式化字符串
- 获取互斥锁
- 打开文件
- 写入 page cache
- 关闭文件
- 释放互斥锁
其中任何一步都可能成为瓶颈。
加锁竞争严重时,线程可能阻塞在 mutex 上。Linux pthread mutex 底层通常基于 futex:
无竞争:用户态原子操作加锁成功
有竞争:futex_wait 进入内核睡眠
解锁时:futex_wake 唤醒等待线程
文件写入进入内核后,也不是简单“把字符串放到磁盘”。它会经过:
系统调用入口
文件描述符表
VFS
具体文件系统
page cache
块层
磁盘驱动
存储设备
通常写入先进入 page cache,然后由内核后台回写线程异步刷盘。如果日志量很大,page cache 压力、IO 调度、磁盘带宽都会影响程序。
所以多线程服务中不能无节制打印日志。尤其是 DEBUG 级别日志,如果在高频路径中大量输出,会改变程序时序,甚至掩盖原本的并发问题。
14.总结
这套日志系统由几个核心组件组成:
- LogLevel:定义日志等级
- GetTimeStamp:生成线程安全时间戳
- LogStrategy:日志输出策略接口
- ConsoleLogStrategy:控制台输出策略
- FileLogStrategy:文件输出策略
- Logger:日志系统入口
- LogMessage:RAII 日志消息对象
- LOG 宏:自动注入文件名和行号
- Mutex / LockGuard:保证输出线程安全
完整调用链如下:
LOG(LogLevel::INFO) << "message"
|
v
宏展开,注入 __FILE__ 和 __LINE__
|
v
Logger::operator()
|
v
构造临时 LogMessage
|
v
operator<< 追加日志正文
|
v
语句结束,LogMessage 析构
|
v
调用当前 LogStrategy::SyncLog
|
v
控制台输出或文件输出
这是一个清晰的同步日志框架。
它的优点:
接口简洁
支持流式输入
支持控制台/文件策略切换
使用 RAII 自动刷新
使用锁保证单策略内线程安全
自动记录文件名和行号
它的工程注意点:
策略切换本身需要线程安全约束
文件日志每条 open/close 性能较低
多进程写同一日志文件时进程内锁不够
高并发场景应考虑异步日志
线程日志建议补充 tid/lwp
编译时应使用 -pthread 和 -std=c++17
线程日志系统不是简单的 cout 封装。一个合格的线程日志模块至少要处理四个层次的问题。
第一层是日志内容结构:
时间、等级、pid、tid、文件、行号、正文
第二层是代码设计:
策略模式解耦输出目标
RAII 管理日志消息生命周期
宏注入调用点元信息
第三层是线程安全:
控制台和文件都是临界资源
输出过程必须互斥
localtime_r 替代 localtime
策略切换需要额外同步或启动期固定
第四层是系统与内核行为:
mutex 竞争可能进入 futex
文件写入会经过系统调用、VFS、page cache
日志不一定立即落盘
高频日志会影响线程调度和程序时序
从线程池、阻塞队列、条件变量到日志系统,本质上都围绕同一个主题:多线程程序必须具备可控的同步关系和可观测的运行状态。
日志模块提供的就是可观测性。没有日志,多线程程序的问题只能靠猜;有了结构化、线程安全、可扩展的日志系统,才能基于时间线分析并发行为,定位线程启动、任务调度、锁竞争、线程退出等关键路径。
因此,在设计线程池之前先设计日志模块,是正确的工程顺序。日志不是辅助代码,而是多线程系统的基础设施。
本章完。
更多推荐



所有评论(0)