Linux C++ 基础组件设计细则(日志库 + 线程池)
本文档基于提供的代码实现与设计思路,从架构思想、类结构、关键实现、避坑要点等维度进行完整拆解,覆盖日志库的策略模式设计、线程池的池化与单例设计,以及并发编程的核心理论约束。
第一部分:日志库(Log)设计细则
thread/Log/Log.hpp · bksczm/Linux - 码云 - 开源中国
一定边看代码边进看分析,后面线程池的代码也是如此
1.1 设计目标
实现一个可扩展、线程安全、支持流式语法的轻量日志组件:
- 支持控制台、文件两种输出方式,运行时可动态切换
- 自动携带时间、日志级别、进程号、文件名、行号等定位信息
- 利用 RAII 机制自动刷新输出,无需手动调用打印接口
- 多线程环境下保证单条日志的完整性,不出现内容交错
1.2 核心架构:策略模式
将「日志输出方式」与「日志格式组装、对外接口」完全解耦,是典型的策略模式应用:
- 抽象策略基类:定义统一的输出接口,所有输出方式都遵循该规范
- 具体策略类:控制台输出、文件输出分别实现各自的输出逻辑
- 上下文类(Log):持有策略基类指针,负责日志格式组装、策略切换、对外提供调用入口
- 设计收益:符合开闭原则,新增输出方式(如网络日志、滚动文件日志)只需新增派生类,无需修改 Log 核心代码稀土掘金
1.3 类结构与职责划分
1.3.1 抽象策略基类 Stategy
class Stategy {
public:
virtual void message(const std::string& message) = 0;
virtual ~Stategy() = default;
mutex _mutex;
};
- 职责:定义所有日志策略的统一输出接口,内置互斥锁保障输出操作的线程安全
- 强制约束:必须声明虚析构函数,否则通过基类智能指针释放派生类对象时,派生类析构函数不会执行,造成文件句柄泄漏等问题
1.3.2 控制台输出策略 ConsoleLogStrategy
- 职责:将格式化后的日志输出到标准输出流
std::cout - 实现细节:输出前后加解锁,保证多线程环境下控制台输出不出现行交错
1.3.3 文件输出策略 FileLogStrategy
- 职责:将日志追加写入指定磁盘文件
- 核心执行流程:
- 构造函数接收目录路径与文件名
- 使用
std::filesystem::exists()判断目录是否存在;若不存在,调用std::filesystem::create_directories()递归创建多级目录(替代系统调用mkdir,无需手动处理层级创建) - 调用
open()系统调用打开文件,标志位为O_APPEND | O_WRONLY | O_CREAT,权限设为八进制 0666 message()接口中调用write()系统调用写入日志内容- 析构函数中调用
close()关闭文件描述符,自动回收资源
- 线程安全:继承基类互斥锁,写入操作全程加锁
1.3.4 上下文类 Log
- 职责:管理当前输出策略、提供策略切换接口、对外暴露日志调用入口
- 核心成员:
std::shared_ptr<Stategy> _sta_ptr:指向当前输出策略的智能指针,负责生命周期管理std::string _path:日志文件完整路径
- 核心接口:
UseConsoleLogStrategy():切换为控制台输出UseFileLogStrategy():切换为文件输出operator():重载函数调用运算符,接收日志级别、文件名、行号,返回内部类临时对象,开启流式日志组装
1.3.5 内部类 LogmeassageManager
- 设计目的:实现
LOG(level) << "msg"的流式语法,同时利用临时对象的 RAII 特性自动刷新日志 - 核心机制:
LOG宏调用Log::operator(),构造一个LogmeassageManager临时对象- 构造函数中预组装日志前缀:
[时间][级别][进程号][文件名][行号] - - 重载
operator<<,每次调用将任意类型内容转为字符串追加到日志消息中 - 语句结束时临时对象析构,析构函数自动调用策略的
message()接口输出完整日志
- 设计优势:相比把日志属性直接放在 Log 类中,内部类天然管理单条日志的生命周期,无需手动 flush,也避免多条日志串味
1.4 关键技术实现细节
1.4.1 宏封装与位置信息定位
#define LOG(level) bksw::log(level, __FILE__, __LINE__)
__FILE__、__LINE__是编译器内置宏,分别在预处理阶段替换为当前文件名、当前代码行号- 核心约束:这两个宏必须在调用点展开,不能封装到函数内部使用,否则会永远输出函数所在的文件与行号,失去定位能力
1.4.2 时间格式化与格式化符区别
使用线程安全的 localtime_r 获取本地时间,通过 snprintf 格式化输出:
snprintf(timebuffer, sizeof(timebuffer), "%4d-%02d-%02d %02d:%02d:%02d", ...);
格式化符精确说明:
%4d:宽度为 4,右对齐,不足位补空格,用于年份%02d:宽度为 2,右对齐,不足位补 0,用于月、日、时、分、秒,保证固定两位格式%2d:宽度为 2,右对齐,不足位补空格%-2d:宽度为 2,左对齐,不足位补空格%-02d:左对齐时补 0 标志无效,等价于%-2d- 注意:
tm_mon取值范围为 0-11,格式化时必须 +1;tm_year为 1900 年至今的年数,需 +1900
1.4.3 流式输出的正确性约束
- 禁止使用
std::endl:内部类是将内容写入std::string,endl的 flush 语义无效,且会插入多余换行符破坏格式 - 必须用
.str()提取完整内容 :std::stringstream的>>运算符会按空格分割字符串,导致日志内容缺失;必须调用.str()获取完整字符串 - 通用类型支持 :
operator<<内部通过临时stringstream转换任意类型,保证所有可流输出类型都能直接使用
1.4.4 线程安全保障
- 每个策略实例内置互斥锁,输出操作全程加锁,保证单条日志的原子性
- 日志前缀组装在临时对象内完成,属于线程私有数据,无并发竞争
- 文件写入使用
O_APPEND模式,内核保证追加操作的原子性,进一步降低多进程写入风险
1.5 核心避坑指南
- 全局对象重定义 :头文件中仅用
extern Log log;声明全局日志对象,定义放在独立的.cpp 文件中,避免多编译单元包含头文件导致重定义错误 - 函数调用括号不能省 :
getpid()是系统调用函数,必须加括号,否则会输出函数地址而非进程 ID - 文件权限注意 :
open的 0666 权限会受系统 umask 掩码影响,最终权限为0666 & ~umask - 异常安全 :
std::filesystem::create_directories可能抛出异常,必须通过 try-catch 捕获,避免程序异常终止 - 路径分隔符 :使用宏
sep统一分隔符,便于跨平台适配
1.6 现存问题与优化方向
- 命名规范问题 :
Stategy拼写错误,应为Strategy;LogmeassageManager应为LogMessageManager - 日志无自动换行 :当前输出的日志末尾无换行符,多条日志会首尾相连,应在输出时追加
\n - 缺少级别过滤:无法设置最低输出级别,生产环境无法关闭 DEBUG 日志,可增加全局级别判断
- 文件无滚动机制:长期运行会导致单个日志文件过大,可新增按大小 / 按天滚动切分功能
- 策略切换不安全:切换策略时未加锁,多线程并发切换可能导致野指针访问
- 性能优化 :内部类可持有一个
stringstream成员,避免每次<<都构造临时对象
第二部分:线程池(pthread_pool)设计细则
thread/pthread_pool/pthread_pool.hpp · bksczm/Linux - 码云 - 开源中国
2.1 设计目标
实现一个线程安全、单例全局唯一、支持优雅退出的固定线程数线程池:
- 预创建固定数量工作线程,复用线程资源,避免频繁创建销毁线程的系统开销
- 支持多生产者并发提交任务,工作线程自动消费任务队列
- 停止时保证已入队任务全部执行完成,再回收线程资源
- 单例模式保证全局唯一实例,避免线程数失控
2.2 核心设计思想
2.2.1 池化机制
池化的本质是资源预分配 + 复用:
- 无池化时,每次执行任务都要创建线程(陷入内核分配栈空间、创建调度实体),任务结束再销毁,单次开销大、延迟高
- 线程池预先创建一批线程,任务到来时存入队列,空闲线程取出执行,单个线程生命周期内可执行多个任务
- 收益:大幅降低任务启动延迟,减少系统调用开销,同时控制最大并发数,避免系统过载
2.2.2 单例模式
保证线程池全局唯一,采用懒汉模式实现:
- 饿汉模式:程序启动时就创建实例,简单但资源提前占用
- 懒汉模式:首次使用时才创建实例,实现延时加载
- 延时加载优势:资源申请的总开销无法避免,但可以推迟到真正使用时再发生;对于启动后一段时间才用到线程池的场景,避免了前期的资源闲置浪费
- 实现约束:构造函数私有化,显式删除拷贝构造与赋值运算符(
= delete),禁止外部生成多个实例
2.3 类结构与核心成员
template<typename T>
class pthread_pool {
private:
std::vector<bksw::pthread> _vp; // 工作线程对象数组
std::queue<T> _qt; // 任务队列
int _thread_num; // 固定线程数量
mutex _mutex; // 保护共享资源的互斥锁
cond _con; // 条件变量,实现等待/唤醒
statue _st; // 线程池状态:NEW/RUNNING/STOP
static pthread_pool<T>* _sppt; // 单例实例指针
static mutex _lock; // 单例创建保护锁
void Execute(); // 工作线程主循环
public:
static pthread_pool* GetInstance(int);
template<typename Func, typename... Args>
bool assign(Func&&, Args&&...);
void start();
void stop();
void join();
~pthread_pool();
};
- 模板参数
T:任务类型,要求是可调用对象(支持operator()) - 状态机:NEW(新建未启动)→ RUNNING(运行中)→ STOP(停止中),单向流转
2.4 关键流程实现
2.4.1 单例创建:双重检查锁定(DCL)
static pthread_pool<T>* GetInstance(int thread_num = default_thread_num) {
if (_sppt == nullptr) { // 外层检查:避免每次调用都加锁
guidemutex guard(_lock); // RAII守卫锁,自动加解锁
if (_sppt == nullptr) { // 内层检查:加锁后再次判断,保证只创建一次
_sppt = new pthread_pool<T>(thread_num);
_sppt->start();
}
}
return _sppt;
}
- 外层判断:99% 以上的调用路径直接返回,无需加锁,保证性能
- 内层加锁判断:多线程并发首次调用时,保证只有一个线程能创建实例
- RAII 守卫锁:利用对象生命周期自动解锁,即使中间异常也能释放锁,避免死锁
- 创建后立即启动线程池,对外透明
2.4.2 工作线程主循环(Execute)
这是线程池最核心的逻辑,严格遵循「加锁 → 判断状态 → 取任务 → 解锁 → 执行任务」的范式:
void Execute() {
while (true) {
_mutex.lock();
// while循环:规避虚假唤醒,同时持续判断状态
while (_qt.empty() && _st == statue::RUNNING) {
_con.wait(_mutex);
}
// 退出条件:停止状态 且 队列为空
if (_st == statue::STOP && _qt.empty()) {
_mutex.unlock();
break;
}
// 取任务后立即解锁,任务执行不占用锁
T task = std::move(_qt.front());
_qt.pop();
_mutex.unlock();
task(); // 锁外执行任务
}
}
- 为什么用 while 而不是 if:POSIX 条件变量存在「虚假唤醒」(无 signal/broadcast 也可能从 wait 返回),while 循环会重新判断条件,保证队列真的有任务才继续执行
- 为什么取完任务立即解锁:任务执行时长不可控,如果持有锁执行任务,其他线程无法取任务、无法提交任务,会严重降低并发度
- 退出条件设计:必须同时满足「停止状态」和「队列为空」,保证所有已提交的任务都执行完毕,实现优雅退出
2.4.3 任务提交(assign)
template<typename Func, typename... Args>
bool assign(Func&& func, Args&&... args) {
_mutex.lock();
if (_st != statue::RUNNING) {
_mutex.unlock();
return false;
}
// 完美转发 + bind包装,emplace入队减少拷贝
_qt.emplace(std::bind(std::forward<Func>(func), std::forward<Args>(args)...));
_mutex.unlock();
_con.signal(); // 只唤醒一个线程,避免惊群
return true;
}
- 完美转发(
std::forward):保留参数的值类别,减少不必要的拷贝构造 std::bind:将函数与参数绑定为可调用对象,适配任务队列类型emplace入队:直接在队列尾部构造对象,比push少一次拷贝 / 移动signal单唤醒:只唤醒一个等待线程,避免「惊群效应」(多个线程被唤醒但只有一个能拿到任务,其余再次休眠,造成无效上下文切换)
2.4.4 优雅退出机制
线程停止时,工作线程可能处于三种状态:等待锁、执行任务中、等待条件变量,退出机制需覆盖所有场景:
- 锁内将状态设为
STOP,保证原子性 - 调用
_con.broadcast()唤醒所有等待条件变量的线程 - 各线程退出路径:
- 等待条件变量的线程:被唤醒后,while 条件不成立,继续执行,判断满足退出条件则解锁退出
- 执行任务中的线程:执行完当前任务后回到循环开头,判断状态与队列,满足条件则退出
- 等待锁的线程:拿到锁后,判断状态与队列,满足条件则退出
- 析构函数自动调用
stop()+join(),保证对象销毁时线程资源正确回收
2.5 并发安全设计
2.5.1 线程安全保障
- 所有共享资源(任务队列、状态变量)的读写都在互斥锁保护范围内
- 状态修改与判断都在锁内执行,保证原子性
- 单例创建锁与线程池内部锁分离,避免锁嵌套风险
2.5.2 死锁规避设计
死锁的四个必要条件:互斥、请求与保持、不剥夺、循环等待。本设计通过以下方式规避死锁:
- 锁粒度最小化:任务执行在锁外,大幅缩短锁持有时间
- 单一锁原则:内部仅一把互斥锁,不存在多锁嵌套,从根源避免循环等待
- RAII 守卫锁:自动解锁,避免异常分支忘记释放锁
- 固定状态变更顺序:先改状态再广播,避免状态不一致导致永久等待
扩展:多锁场景下可使用
std::lock(m1, m2)同时加锁,保证原子性;也可通过资源一次性分配、超时机制破坏死锁条件;银行家算法则是从系统全局角度避免死锁的经典算法
2.6 核心避坑指南
- 虚假唤醒必须处理 :条件变量
wait必须放在 while 循环中,这是 POSIX 标准的强制要求 - 广播必须在改状态之后:如果先广播再改状态,线程醒来时状态仍是 RUNNING,会再次进入等待,导致线程无法退出
- 禁止任务中调用 stop/join:工作线程内部调用停止 / 等待接口,会导致自己等待自己退出,产生死锁
- 线程先构造后启动 :构造函数只创建线程对象,
start()才真正启动线程,避免构造未完成时线程运行访问未初始化成员 - 移动语义合理使用 :取任务时用
std::move转移资源,避免任务对象的深拷贝开销 - STL 容器非线程安全 :
std::queue、std::vector本身不是线程安全的,所有访问必须加锁保护
第三部分:整体设计原则与共性理论
3.1 贯穿的设计原则
- RAII 资源管理:日志临时对象、守卫锁、文件句柄、线程对象全部利用对象生命周期自动管理资源释放,从根源避免泄漏与死锁
- 单一职责:每个类只负责一项职责,策略类只管输出、Log 类只管格式与策略切换、线程池只管线程调度
- 开闭原则:日志策略可扩展,新增输出方式无需修改核心代码
- 性能优先:池化复用资源、锁粒度最小化、移动语义减少拷贝、DCL 减少加锁次数
3.2 核心并发概念澄清
线程安全 vs 可重入
- 可重入函数:同一函数在多个执行流中交错调用,仍能正确执行,不依赖全局共享状态
- 线程安全:多线程并发访问共享资源时,执行结果始终符合预期
- 关系:
- 可重入函数一定是线程安全的
- 线程安全的函数不一定可重入(例如加锁的函数是线程安全的,但在信号处理函数中重入可能导致死锁,因此不可重入)
智能指针的线程安全
unique_ptr:仅属于单个线程,所有权唯一,不存在线程安全问题shared_ptr:引用计数的增减是原子操作,引用计数本身线程安全;但指向的对象的读写操作不是线程安全的,仍需加锁保护
3.3 条件编译的工程化应用
可通过条件编译实现日志开关、调试信息控制、默认参数配置:
#ifdef DEBUG
#define LOG(level) bksw::log(level, __FILE__, __LINE__)
#else
// 发布版本编译期消除日志,零运行时开销
#define LOG(level) while(false) bksw::log(level, __FILE__, __LINE__)
#endif
也可通过条件编译控制线程池默认线程数、日志默认路径等配置,适配不同运行环境。