
各位 C/C++ 开发者小伙伴们,在实现线程池的过程中,日志系统是不可或缺的一环 —— 它能监控线程池的运行状态、记录任务执行的异常信息、帮助我们快速定位线上问题。而如何让日志系统灵活支持控制台输出、文件持久化甚至后续的网络日志等多种输出方式?策略模式就是解决这个问题的最优解之一。 今天这篇文章,我们就从线程池的实际开发需求出发,一步步实现一个基于策略模式的可扩展日志系统,不仅会讲清楚日志系统的核心设计要点,还会深入理解策略模式的设计思想,让你的线程池日志既好用又易扩展!下面就让我们正式开始吧!

在开始编码之前,我们先想清楚一个问题:C/C++ 有 printf、cout 这些输出方式,为什么还要专门为线程池设计日志系统?
线程池作为多线程并发的核心组件,其运行过程有高并发、多线程、需追溯的特点,原生输出方式完全满足不了需求,具体体现在这几点:
简单来说,一个合格的线程池日志系统,必须满足:线程安全、信息完整、输出可配、等级分明这四个核心要求。而策略模式的引入,能让我们的日志系统在满足这些要求的同时,拥有极佳的可扩展性—— 后续想加新的日志输出方式(比如输出到数据库、网络),无需修改原有代码,直接新增策略即可。
在实现日志系统前,我们先快速搞懂策略模式—— 这是一种行为型设计模式,也是开发中最常用的设计模式之一,核心思想非常简单。
策略模式的核心是:将算法(行为 / 策略)封装成独立的类,使它们可以相互替换,算法的变化不会影响使用算法的客户端。
通俗点说,就是把 “做什么” 和 “怎么做” 分离开:
当你的业务满足以下特点时,非常适合使用策略模式:
if-else/switch判断,让代码更优雅;对应到我们的日志系统:
SyncLog);搞懂了策略模式,接下来我们就开始一步步实现基于策略模式的线程池日志系统。
结合线程池的使用场景,我们先明确日志系统的核心设计规格,做到 “先设计,后编码”,避免后续反复修改。
一个完整的日志条目,必须包含必备信息,可选信息可根据需求添加,我们设计的日志格式如下(空格分隔,可读性拉满):
[时间戳] [日志等级] [进程ID] [文件名] [行号] - 日志内容示例:
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [62] - ThreadPool Construct()
[2024-08-04 15:09:29] [DEBUG] [206342] [ThreadPool.hpp] [109] - 任务入队列成功
[2024-08-04 15:09:39] [ERROR] [206342] [Task.cpp] [36] - 任务执行失败:参数错误YYYY-MM-DD HH:MM:SS,方便日志追溯;std::mutex);std::filesystem,实现日志目录的自动创建(编译时需加-std=c++17)。 我们的日志系统采用模块化设计,所有代码封装在LogModule命名空间中,分为以下几个部分:
LogStrategy);ConsoleLogStrategy、文件日志FileLogStrategy);Logger),持有策略接口,管理策略切换;LogMessage),支持日志内容拼接; 同时,我们会结合之前实现的互斥量封装类(Mutex、LockGuard)保证线程安全,如果你还没有封装互斥量,文末会给出基础的封装代码。
首先创建日志系统的头文件Log.hpp,引入所需的头文件,定义命名空间LogModule,并引入锁的命名空间(保证线程安全)。
// Log.hpp 基于策略模式的线程池日志系统
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <memory>
#include <ctime>
#include <sstream>
#include <filesystem> // C++17 文件系统,需编译参数 -std=c++17
#include <unistd.h> // getpid() 获取进程ID
#include "Lock.hpp" // 引入自定义的互斥量封装类
// 日志模块命名空间
namespace LogModule
{
// 引入锁的命名空间,使用自定义的Mutex和LockGuard
using namespace LockModule;
// 日志默认配置:日志目录、日志文件名
const std::string default_log_path = "./log/";
const std::string default_log_name = "thread_pool_log.txt";首先定义枚举类型的日志等级,避免魔法数字;然后实现两个基础工具函数:日志等级转字符串、获取格式化的当前时间戳,这两个函数是日志格式化的基础。
// 日志等级:DEBUG(调试) < INFO(信息) < WARNING(警告) < ERROR(错误) < FATAL(致命)
enum class LogLevel
{
DEBUG,
INFO,
WARNING,
ERROR,
FATAL
};
// 工具函数:日志等级转换为字符串,方便日志输出
std::string LogLevelToString(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";
}
}
// 工具函数:获取格式化的当前时间戳 YYYY-MM-DD HH:MM:SS
std::string GetCurrentTime()
{
// 获取当前时间的时间戳
time_t now = time(nullptr);
// 转换为本地时间,使用localtime_r(线程安全),避免localtime(线程不安全)
struct tm local_tm;
localtime_r(&now, &local_tm);
// 格式化时间戳,使用snprintf保证缓冲区安全
char time_buf[64] = {0};
snprintf(time_buf, sizeof(time_buf),
"%4d-%02d-%02d %02d:%02d:%02d",
local_tm.tm_year + 1900, // 年份从1900开始,需加1900
local_tm.tm_mon + 1, // 月份从0开始,需加1
local_tm.tm_mday,
local_tm.tm_hour,
local_tm.tm_min,
local_tm.tm_sec);
return std::string(time_buf);
}关键注意点:
enum class定义日志等级,是强类型枚举,避免和其他变量重名,比普通枚举更安全;localtime_r而非localtime,因为localtime是线程不安全的,多线程环境下会导致时间错乱;snprintf,避免sprintf的缓冲区溢出问题,保证代码安全性。 策略接口是策略模式的核心,我们定义一个纯虚类LogStrategy,声明唯一的纯虚方法SyncLog,该方法接收格式化后的日志字符串,负责实际的日志输出操作。
所有具体的日志策略(控制台、文件、网络)都必须继承该接口,并实现SyncLog方法。
// 策略模式:日志策略接口,声明统一的日志输出方法
class LogStrategy
{
public:
// 虚析构函数:保证子类析构时能正确调用自身的析构函数
virtual ~LogStrategy() = default;
// 纯虚方法:同步输出日志,子类必须实现
// 参数:格式化后的完整日志字符串
virtual void SyncLog(const std::string& log_msg) = 0;
};关键注意点:
SyncLog(同步日志),因为线程池的日志要求实时刷新,暂不实现异步日志(异步日志可后续扩展)。 接下来实现两个最常用的具体策略类:控制台日志策略(ConsoleLogStrategy)和文件日志策略(FileLogStrategy),均继承自LogStrategy接口,实现SyncLog方法,并保证线程安全。
控制台日志策略的核心是将日志输出到标准错误流std::cerr(而非std::cout),因为cerr是无缓冲的,日志会实时输出,而cout是有缓冲的,可能会出现日志延迟。
同时,使用互斥量保证多线程下的输出安全,避免日志交叉。
// 具体策略1:控制台日志策略 - 日志输出到控制台,方便调试
class ConsoleLogStrategy : public LogStrategy
{
public:
// 实现策略接口的SyncLog方法:控制台输出日志
void SyncLog(const std::string& log_msg) override
{
// RAII风格的锁:自动加锁,析构时自动解锁,保证线程安全
LockGuard lock(_mutex);
// 输出到标准错误流,实时刷新,无缓冲
std::cerr << log_msg << std::endl;
}
private:
Mutex _mutex; // 互斥量,保证多线程控制台输出的线程安全
};文件日志策略的核心是将日志追加写入到指定的日志文件中,核心功能包括:
SyncLog方法中以追加模式打开日志文件,写入日志; // 具体策略2:文件日志策略 - 日志持久化到文件,线上环境使用
class FileLogStrategy : public LogStrategy
{
public:
// 构造函数:初始化日志路径和文件名,自动创建日志目录
FileLogStrategy(const std::string& log_path = default_log_path,
const std::string& log_name = default_log_name)
: _log_path(log_path), _log_name(log_name)
{
// 加锁保证目录创建的线程安全
LockGuard lock(_mutex);
// 判断日志目录是否存在,不存在则创建(递归创建,支持多级目录)
if (!std::filesystem::exists(_log_path))
{
try
{
std::filesystem::create_directories(_log_path);
}
catch (const std::filesystem::filesystem_error& e)
{
// 目录创建失败,输出错误信息到控制台
std::cerr << "创建日志目录失败:" << e.what() << std::endl;
}
}
}
// 实现策略接口的SyncLog方法:将日志追加写入文件
void SyncLog(const std::string& log_msg) override
{
// RAII风格的锁:保证多线程文件写入的线程安全
LockGuard lock(_mutex);
// 拼接完整的日志文件路径
std::string log_file = _log_path + _log_name;
// 以追加模式打开文件:std::ios::app,不存在则创建,存在则追加
std::ofstream log_fs(log_file, std::ios::app);
if (!log_fs.is_open())
{
std::cerr << "打开日志文件失败:" << log_file << std::endl;
return;
}
// 写入日志并换行
log_fs << log_msg << std::endl;
// 手动刷新缓冲区,保证日志实时写入文件
log_fs.flush();
}
private:
std::string _log_path; // 日志目录
std::string _log_name; // 日志文件名
Mutex _mutex; // 互斥量,保证多线程文件操作的线程安全
};关键注意点:
std::filesystem::create_directories而非create_directory,前者支持递归创建多级目录(比如./log/2024/08/),后者只能创建单级目录;std::ios::app,保证日志追加写入,不会覆盖原有内容;flush()手动刷新缓冲区,保证日志实时写入文件,避免程序崩溃时日志丢失; 日志核心类Logger是策略模式的客户端,它持有策略接口LogStrategy的智能指针(std::unique_ptr),负责:
LogMessage),封装日志的格式化逻辑; 同时,我们将Logger的构造函数默认初始化为控制台日志策略,方便调试。
// 日志核心类:策略模式的客户端,持有策略接口,管理策略切换,创建日志对象
class Logger
{
public:
// 构造函数:默认使用控制台日志策略,方便开发调试
Logger()
{
UseConsoleStrategy();
}
// 析构函数:默认即可,智能指针自动释放策略对象
~Logger() = default;
// 切换策略:使用控制台日志
void UseConsoleStrategy()
{
_log_strategy = std::make_unique<ConsoleLogStrategy>();
}
// 切换策略:使用文件日志,支持自定义日志路径和文件名
void UseFileStrategy(const std::string& log_path = default_log_path,
const std::string& log_name = default_log_name)
{
_log_strategy = std::make_unique<FileLogStrategy>(log_path, log_name);
}
// 内部类:RAII风格的日志格式化类,下文实现
class LogMessage;
// 创建日志格式化对象,作为日志输出的入口
// 参数:日志等级、文件名、行号(后续通过宏自动传入)
LogMessage operator()(LogLevel level, const std::string& file_name, int line_num);
private:
// 持有日志策略接口的智能指针,支持动态切换策略
// std::unique_ptr:独占所有权,避免策略对象被多次拷贝
std::unique_ptr<LogStrategy> _log_strategy;
};关键注意点:
std::unique_ptr持有策略对象,而非原始指针,利用智能指针的RAII 机制自动释放资源,避免内存泄漏;UseConsoleStrategy/UseFileStrategy通过std::make_unique创建具体的策略对象,赋值给策略接口指针,实现策略的动态替换;LogMessage实现,让职责更单一,符合单一职责原则。 LogMessage是Logger的内部类,采用RAII 风格设计,是日志系统的格式化核心,核心职责:
SyncLog方法,输出格式化后的完整日志。这种设计的好处是:日志对象创建时格式化头部,内容拼接完成后,对象析构时自动输出日志,无需手动调用刷新方法,使用非常简洁。
// 内部类:RAII风格的日志格式化类 - 构造格式化头部,析构自动输出日志
class Logger::LogMessage
{
private:
LogLevel _level; // 日志等级
std::string _time; // 格式化后的时间戳
pid_t _pid; // 进程ID
std::string _file_name; // 日志打印的文件名
int _line_num; // 日志打印的行号
Logger& _logger; // 引用外部的Logger对象,用于调用策略的SyncLog
std::string _log_msg; // 格式化后的完整日志字符串
public:
// 构造函数:自动格式化日志头部信息
LogMessage(LogLevel level, const std::string& file_name, int line_num, Logger& logger)
: _level(level), _file_name(file_name), _line_num(line_num), _logger(logger)
{
// 初始化基础信息
_time = GetCurrentTime();
_pid = getpid(); // 获取当前进程ID
// 格式化日志头部:[时间] [等级] [PID] [文件] [行号] -
std::stringstream ss;
ss << "[" << _time << "] "
<< "[" << LogLevelToString(_level) << "] "
<< "[" << _pid << "] "
<< "[" << _file_name << "] "
<< "[" << _line_num << "] - ";
// 将头部信息存入完整日志字符串
_log_msg = ss.str();
}
// 重载<<运算符:支持任意类型的日志内容拼接,返回自身引用支持链式调用
template <typename T>
LogMessage& operator<<(const T& content)
{
std::stringstream ss;
ss << content;
_log_msg += ss.str();
return *this;
}
// 析构函数:RAII核心 - 自动调用策略的SyncLog方法,输出完整日志
~LogMessage()
{
if (_logger._log_strategy) // 策略对象不为空时才输出
{
_logger._log_strategy->SyncLog(_log_msg);
}
}
};
// 实现Logger的operator()方法:创建LogMessage对象
inline Logger::LogMessage Logger::operator()(LogLevel level, const std::string& file_name, int line_num)
{
// 返回LogMessage临时对象,RAII风格
return LogMessage(level, file_name, line_num, *this);
}关键注意点:
_logger是Logger的非 const 引用,保证能调用到 Logger 的策略对象;LogMessage临时对象的生命周期结束时(比如一行日志拼接完成),析构函数自动调用SyncLog输出日志,无需手动操作;inline,避免链接错误(内部类的外部方法实现需要内联)。 现在我们的日志系统已经实现了核心功能,但调用时需要手动传入文件名和行号,非常繁琐。我们可以通过C/C++ 的预定义宏实现自动获取文件名和行号,并封装成简洁的日志宏,让日志调用像cout一样简单。
同时,封装策略切换的宏,一键切换控制台 / 文件日志。
// 定义全局的Logger对象,整个线程池共用一个日志实例
Logger g_logger;
// 日志宏:自动获取文件名(__FILE__)和行号(__LINE__),简化调用
// 用法:LOG(LogLevel::INFO) << "线程池启动成功,线程数:" << 10;
#define LOG(level) g_logger(level, __FILE__, __LINE__)
// 策略切换宏:一键启用控制台日志/文件日志
#define ENABLE_CONSOLE_LOG() g_logger.UseConsoleStrategy()
#define ENABLE_FILE_LOG(path, name) g_logger.UseFileStrategy(path, name)
// 重载:使用默认路径和文件名的文件日志
#define ENABLE_FILE_LOG_DEFAULT() g_logger.UseFileStrategy()
} // end of namespace LogModuleC/C++ 预定义宏说明:
__FILE__:当前源文件的完整路径和文件名(可通过编译器参数简化为文件名);__LINE__:当前代码的行号,整数类型;日志调用示例:
LOG(LogLevel::INFO) << "线程池初始化完成,核心线程数:" << 10;
LOG(LogLevel::DEBUG) << "任务入队成功,任务ID:" << 1001;
LOG(LogLevel::ERROR) << "任务执行失败,原因:" << "参数为空"; 是不是非常简洁?和cout的使用方式几乎一致,还自带所有日志头部信息!
如果你的项目中还没有封装互斥量,这里给出基础的Mutex和LockGuard封装代码(Lock.hpp),保证日志系统的线程安全,也是后续线程池开发的基础。
// Lock.hpp 互斥量封装类,RAII风格,保证线程安全
#pragma once
#include <iostream>
#include <pthread.h>
namespace LockModule
{
// 互斥量封装类
class Mutex
{
public:
// 禁用拷贝和赋值:互斥量不能被拷贝
Mutex(const Mutex&) = delete;
Mutex& operator=(const Mutex&) = delete;
// 构造函数:初始化互斥量
Mutex()
{
if (pthread_mutex_init(&_mutex, nullptr) != 0)
{
std::cerr << "互斥量初始化失败!" << std::endl;
exit(EXIT_FAILURE);
}
}
// 加锁
void Lock()
{
if (pthread_mutex_lock(&_mutex) != 0)
{
std::cerr << "互斥量加锁失败!" << std::endl;
exit(EXIT_FAILURE);
}
}
// 解锁
void Unlock()
{
if (pthread_mutex_unlock(&_mutex) != 0)
{
std::cerr << "互斥量解锁失败!" << std::endl;
exit(EXIT_FAILURE);
}
}
// 析构函数:销毁互斥量
~Mutex()
{
pthread_mutex_destroy(&_mutex);
}
private:
pthread_mutex_t _mutex; // 原生POSIX互斥量
};
// RAII风格的锁守卫:构造加锁,析构解锁,避免忘记解锁
class LockGuard
{
public:
// 禁用拷贝和赋值
LockGuard(const LockGuard&) = delete;
LockGuard& operator=(const LockGuard&) = delete;
// 构造函数:传入互斥量引用,加锁
explicit LockGuard(Mutex& mutex) : _mutex(mutex)
{
_mutex.Lock();
}
// 析构函数:解锁
~LockGuard()
{
_mutex.Unlock();
}
private:
Mutex& _mutex; // 互斥量引用,避免拷贝
};
} // end of namespace LockModule 日志系统实现完成后,使用非常简单,我们写一个测试程序log_test.cpp,演示日志的调用、策略的切换,以及多线程下的线程安全测试。
// log_test.cpp 日志系统测试程序
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include "Log.hpp"
#include "Lock.hpp"
using namespace LogModule;
using namespace LockModule;
// 多线程测试函数:多个线程同时打印日志,测试线程安全
void* log_thread_func(void* arg)
{
std::string thread_name = (char*)arg;
for (int i = 0; i < 5; ++i)
{
LOG(LogLevel::DEBUG) << thread_name << " 打印调试日志,次数:" << i;
LOG(LogLevel::INFO) << thread_name << " 打印信息日志,次数:" << i;
usleep(100000); // 休眠100ms,模拟业务逻辑
}
return nullptr;
}
int main()
{
// 1. 默认使用控制台日志,打印测试日志
std::cout << "===== 控制台日志测试 =====" << std::endl;
LOG(LogLevel::INFO) << "日志系统启动成功,默认使用控制台日志";
LOG(LogLevel::WARNING) << "这是一条警告日志,测试等级输出";
LOG(LogLevel::ERROR) << "这是一条错误日志,测试内容拼接:" << 123 << " " << 3.14 << " " << 'c';
LOG(LogLevel::FATAL) << "这是一条致命错误日志,测试进程ID:" << getpid();
// 2. 切换为文件日志(默认路径./log/,文件名thread_pool_log.txt)
std::cout << "\n===== 切换为文件日志 =====" << std::endl;
ENABLE_FILE_LOG_DEFAULT();
LOG(LogLevel::INFO) << "成功切换为文件日志,日志文件路径:./log/thread_pool_log.txt";
// 3. 多线程日志测试,创建3个线程同时打印日志,测试线程安全
std::cout << "\n===== 多线程日志测试 =====" << std::endl;
pthread_t t1, t2, t3;
pthread_create(&t1, nullptr, log_thread_func, (void*)"Thread-1");
pthread_create(&t2, nullptr, log_thread_func, (void*)"Thread-2");
pthread_create(&t3, nullptr, log_thread_func, (void*)"Thread-3");
// 等待线程结束
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
// 4. 切换回控制台日志
std::cout << "\n===== 切换回控制台日志 =====" << std::endl;
ENABLE_CONSOLE_LOG();
LOG(LogLevel::INFO) << "日志系统测试完成,所有日志打印正常";
return 0;
} 由于我们使用了C++17 的文件系统,编译时需要添加-std=c++17和-lpthread(POSIX 线程库)参数:
# 编译命令
g++ log_test.cpp Lock.hpp Log.hpp -o log_test -std=c++17 -lpthread
# 运行程序
./log_test./log/目录,并生成thread_pool_log.txt文件,文件中会保存切换为文件日志后的所有日志; 将日志系统集成到线程池非常简单,只需在 ThreadPool 的头文件中引入Log.hpp,并在关键节点打印日志即可,比如:
INFO日志,提示线程池初始化;INFO日志,提示线程初始化完成;INFO日志,提示线程开始运行;DEBUG日志,提示任务入队成功;DEBUG日志,提示线程获取到任务;INFO日志,提示线程池开始退出,线程正常退出。线程池集成日志的示例代码:
// ThreadPool.hpp 线程池中集成日志系统
#include "Log.hpp"
using namespace LogModule;
// 线程池构造函数
ThreadPool(int thread_num = 10) : _thread_num(thread_num)
{
LOG(LogLevel::INFO) << "ThreadPool 构造,核心线程数:" << _thread_num;
// 初始化线程...
}
// 线程任务处理函数
void HandlerTask()
{
std::string thread_name = "Thread-" + std::to_string(pthread_self() % 100);
LOG(LogLevel::INFO) << thread_name << " 开始运行,等待任务";
while (true)
{
// 加锁获取任务...
LOG(LogLevel::DEBUG) << thread_name << " 获取到任务,开始执行";
// 执行任务...
}
}
// 任务入队函数
bool Enqueue(Task& task)
{
// 加锁入队...
LOG(LogLevel::DEBUG) << "任务入队成功,当前任务队列大小:" << _task_queue.size();
return true;
}集成后,线程池的运行过程会被全程记录,线上运行时只需启用文件日志,即可通过日志文件快速定位线程池的问题,比如线程启动失败、任务入队异常、任务执行失败等。当然我们目前还没有实现完整的线程池,后续会为大家详细介绍。
我们实现的日志系统是可扩展、可优化的,后续可根据实际需求进行升级,推荐的扩展方向:
LogStrategy接口,实现SyncLog方法即可,无需修改原有代码;INFO及以上等级的日志,关闭DEBUG日志,减少日志量;__FILE__宏会返回完整路径,可通过字符串处理只保留文件名,让日志更简洁;pthread_self()),多线程环境下更易定位问题。通过本次日志系统的实现,我们可以发现:设计模式不是 “花里胡哨” 的技巧,而是解决特定问题的 “最佳实践”,其本质是解耦—— 将变化的部分和不变的部分分离,让代码更易扩展、更易维护。 策略模式分离了策略的定义和策略的实现,让日志的输出方式可以自由变化,而不影响日志的格式化和调用方式;RAII 机制分离了资源的申请和资源的释放,让代码无需手动管理资源,避免内存泄漏和锁未释放的问题。 这些设计思想不仅适用于日志系统,也适用于线程池、网络框架等所有 C/C++ 并发开发场景,掌握这些思想,才能写出优雅、安全、可扩展的工业级代码。 后续我们会基于这个日志系统,继续实现线程池的核心功能,包括任务队列、线程管理、任务调度等,关注我,不迷路! 最后,如果你在实现过程中有任何问题,欢迎在评论区留言讨论,一起学习,一起进步!💪