Linux之日志和线程池、内存池

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
  • 职责:将日志追加写入指定磁盘文件
  • 核心执行流程:
    1. 构造函数接收目录路径与文件名
    2. 使用 std::filesystem::exists() 判断目录是否存在;若不存在,调用 std::filesystem::create_directories() 递归创建多级目录(替代系统调用 mkdir,无需手动处理层级创建)
    3. 调用 open() 系统调用打开文件,标志位为 O_APPEND | O_WRONLY | O_CREAT,权限设为八进制 0666
    4. message() 接口中调用 write() 系统调用写入日志内容
    5. 析构函数中调用 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 特性自动刷新日志
  • 核心机制:
    1. LOG 宏调用 Log::operator(),构造一个 LogmeassageManager 临时对象
    2. 构造函数中预组装日志前缀:[时间][级别][进程号][文件名][行号] -
    3. 重载 operator<<,每次调用将任意类型内容转为字符串追加到日志消息中
    4. 语句结束时临时对象析构,析构函数自动调用策略的 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 流式输出的正确性约束
  1. 禁止使用 std::endl :内部类是将内容写入 std::stringendl 的 flush 语义无效,且会插入多余换行符破坏格式
  2. 必须用 .str() 提取完整内容std::stringstream>> 运算符会按空格分割字符串,导致日志内容缺失;必须调用 .str() 获取完整字符串
  3. 通用类型支持operator<< 内部通过临时 stringstream 转换任意类型,保证所有可流输出类型都能直接使用
1.4.4 线程安全保障
  • 每个策略实例内置互斥锁,输出操作全程加锁,保证单条日志的原子性
  • 日志前缀组装在临时对象内完成,属于线程私有数据,无并发竞争
  • 文件写入使用 O_APPEND 模式,内核保证追加操作的原子性,进一步降低多进程写入风险

1.5 核心避坑指南

  1. 全局对象重定义 :头文件中仅用 extern Log log; 声明全局日志对象,定义放在独立的.cpp 文件中,避免多编译单元包含头文件导致重定义错误
  2. 函数调用括号不能省getpid() 是系统调用函数,必须加括号,否则会输出函数地址而非进程 ID
  3. 文件权限注意open 的 0666 权限会受系统 umask 掩码影响,最终权限为 0666 & ~umask
  4. 异常安全std::filesystem::create_directories 可能抛出异常,必须通过 try-catch 捕获,避免程序异常终止
  5. 路径分隔符 :使用宏 sep 统一分隔符,便于跨平台适配

1.6 现存问题与优化方向

  1. 命名规范问题Stategy 拼写错误,应为 StrategyLogmeassageManager 应为 LogMessageManager
  2. 日志无自动换行 :当前输出的日志末尾无换行符,多条日志会首尾相连,应在输出时追加 \n
  3. 缺少级别过滤:无法设置最低输出级别,生产环境无法关闭 DEBUG 日志,可增加全局级别判断
  4. 文件无滚动机制:长期运行会导致单个日志文件过大,可新增按大小 / 按天滚动切分功能
  5. 策略切换不安全:切换策略时未加锁,多线程并发切换可能导致野指针访问
  6. 性能优化 :内部类可持有一个 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 优雅退出机制

线程停止时,工作线程可能处于三种状态:等待锁、执行任务中、等待条件变量,退出机制需覆盖所有场景:

  1. 锁内将状态设为 STOP,保证原子性
  2. 调用 _con.broadcast() 唤醒所有等待条件变量的线程
  3. 各线程退出路径:
    • 等待条件变量的线程:被唤醒后,while 条件不成立,继续执行,判断满足退出条件则解锁退出
    • 执行任务中的线程:执行完当前任务后回到循环开头,判断状态与队列,满足条件则退出
    • 等待锁的线程:拿到锁后,判断状态与队列,满足条件则退出
  4. 析构函数自动调用 stop() + join(),保证对象销毁时线程资源正确回收

2.5 并发安全设计

2.5.1 线程安全保障
  • 所有共享资源(任务队列、状态变量)的读写都在互斥锁保护范围内
  • 状态修改与判断都在锁内执行,保证原子性
  • 单例创建锁与线程池内部锁分离,避免锁嵌套风险
2.5.2 死锁规避设计

死锁的四个必要条件:互斥、请求与保持、不剥夺、循环等待。本设计通过以下方式规避死锁:

  1. 锁粒度最小化:任务执行在锁外,大幅缩短锁持有时间
  2. 单一锁原则:内部仅一把互斥锁,不存在多锁嵌套,从根源避免循环等待
  3. RAII 守卫锁:自动解锁,避免异常分支忘记释放锁
  4. 固定状态变更顺序:先改状态再广播,避免状态不一致导致永久等待

扩展:多锁场景下可使用 std::lock(m1, m2) 同时加锁,保证原子性;也可通过资源一次性分配、超时机制破坏死锁条件;银行家算法则是从系统全局角度避免死锁的经典算法

2.6 核心避坑指南

  1. 虚假唤醒必须处理 :条件变量 wait 必须放在 while 循环中,这是 POSIX 标准的强制要求
  2. 广播必须在改状态之后:如果先广播再改状态,线程醒来时状态仍是 RUNNING,会再次进入等待,导致线程无法退出
  3. 禁止任务中调用 stop/join:工作线程内部调用停止 / 等待接口,会导致自己等待自己退出,产生死锁
  4. 线程先构造后启动 :构造函数只创建线程对象,start() 才真正启动线程,避免构造未完成时线程运行访问未初始化成员
  5. 移动语义合理使用 :取任务时用 std::move 转移资源,避免任务对象的深拷贝开销
  6. STL 容器非线程安全std::queuestd::vector 本身不是线程安全的,所有访问必须加锁保护

第三部分:整体设计原则与共性理论

3.1 贯穿的设计原则

  1. RAII 资源管理:日志临时对象、守卫锁、文件句柄、线程对象全部利用对象生命周期自动管理资源释放,从根源避免泄漏与死锁
  2. 单一职责:每个类只负责一项职责,策略类只管输出、Log 类只管格式与策略切换、线程池只管线程调度
  3. 开闭原则:日志策略可扩展,新增输出方式无需修改核心代码
  4. 性能优先:池化复用资源、锁粒度最小化、移动语义减少拷贝、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

也可通过条件编译控制线程池默认线程数、日志默认路径等配置,适配不同运行环境。

相关推荐
笨蛋不要掉眼泪2 小时前
Java虚拟机:对象复活、引用强度与Stop-The-World
java·开发语言·jvm
布朗克1682 小时前
Go 入门到精通-33-unsafe 与 CGO
开发语言·后端·golang·unsafe·cgo
凤山老林3 小时前
SpringBoot实战:构建优雅的全局异常处理机制
java·springboot·异常处理
157092511343 小时前
【无标题】
开发语言·python·算法
小Ti客栈3 小时前
Spring Boot 整合 Swagger2 和 Knife4j实现接口文档与可视化调试
java·spring boot·后端
梅头脑3 小时前
ThreadLocalMap里几十万个死Entry——排查了半天OOM,根因就一行finally没写
java
xieliyu.3 小时前
MySQL 存储过程详解:概念、创建与删除全教程
开发语言·数据库·mysql
2zcode3 小时前
项目文档:基于MATLAB最小二乘支持向量机的睡眠分期智能监测系统设计与实现
开发语言·支持向量机·matlab
twcc_come3 小时前
安全策略配置实验报告
开发语言·php