【Linux】手写日志与固定线程池:任务队列、工作线程和安全退出

🔥个人主页:爱和冰阔乐

📚专栏传送门:《数据结构与算法》C++《Linux操作系统》

🐶学习方向:C++方向学习爱好者

⭐人生格言:得知坦然 ,失之淡然


🏠博主简介

文章目录

  • 前言
  • 一、先把日志模块准备好
    • [1.1 为什么不只使用 `printf`](#1.1 为什么不只使用 printf)
    • [1.2 生成日志与刷新日志分开](#1.2 生成日志与刷新日志分开)
    • [1.3 为什么日志输出也要加锁](#1.3 为什么日志输出也要加锁)
    • [1.4 时间函数的线程安全](#1.4 时间函数的线程安全)
  • 二、实现一个简单日志器
    • [2.1 输出策略](#2.1 输出策略)
    • [2.2 `Logger` 与临时消息对象](#2.2 Logger 与临时消息对象)
    • [2.3 简单测试](#2.3 简单测试)
  • 三、为什么需要线程池
    • [3.1 来一个任务就创建一个线程的问题](#3.1 来一个任务就创建一个线程的问题)
    • [3.2 线程池仍然是生产者消费者模型](#3.2 线程池仍然是生产者消费者模型)
    • [3.3 固定线程数怎么选](#3.3 固定线程数怎么选)
  • 四、固定线程池完整实现
    • [4.1 线程池需要哪些状态](#4.1 线程池需要哪些状态)
    • [4.2 `ThreadPool.hpp`](#4.2 ThreadPool.hpp)
    • [4.3 为什么使用 `unique_lock`](#4.3 为什么使用 unique_lock)
    • [4.4 为什么任务必须在锁外执行](#4.4 为什么任务必须在锁外执行)
    • [4.5 为什么捕获任务异常](#4.5 为什么捕获任务异常)
  • 五、线程池怎样安全退出
    • [5.1 工作线程的三种状态](#5.1 工作线程的三种状态)
    • [5.2 `Stop` 的执行顺序](#5.2 Stop 的执行顺序)
    • [5.3 排空退出和立即退出](#5.3 排空退出和立即退出)
    • [5.4 不要让工作线程销毁自己的线程池](#5.4 不要让工作线程销毁自己的线程池)
  • 六、运行线程池
    • [6.1 测试代码](#6.1 测试代码)
    • [6.2 可以继续扩展哪些功能](#6.2 可以继续扩展哪些功能)
  • 总结
  • 参考资料

前言

前面已经把互斥锁、条件变量和生产者消费者模型写完了,这一篇把它们真正组合起来:先实现一个够用的日志模块,再实现固定数量线程的线程池。

这里不追求一次写出工业级日志库和通用线程池。我们主要把几个关键问题想清楚:任务为什么不能来一个就创建一个线程,工作线程没有任务时怎么等待,任务应该在锁内还是锁外执行,以及线程池析构时怎样让所有线程正常退出。

本文使用的接口与范围

示例使用 C++17 的 std::threadstd::condition_variablestd::filesystem,编译时需要添加 -pthread。文中的线程池用于理解任务队列、等待谓词和停止流程,不是直接替代成熟线程池库的生产版本。若编译器较旧,std::filesystem 可能还需要额外链接选项,应以实际工具链为准。


一、先把日志模块准备好

1.1 为什么不只使用 printf

以前写测试代码时,直接使用 printfcout 没有问题。但项目运行时间一长,只在显示器上打印很难排查问题:终端关闭后消息就没了,也不知道某条输出来自哪个文件、哪一行、哪个线程。

一条实用日志至少应包含:

  • 时间;
  • 日志等级;
  • 具体内容。

文件名、行号、进程 ID 和线程 ID 不是每个场景都必须有,但调试并发程序时非常有用。

常见等级可以这样理解:

等级 适合记录的内容
DEBUG 调试阶段需要的细节
INFO 程序正常运行过程中的关键事件
WARNING 出现异常迹象,但程序还能继续工作
ERROR 当前操作失败,需要排查
FATAL 关键功能无法继续,程序通常需要退出

等级名称只是一套约定。真正重要的是团队对每个等级的使用范围保持一致,不能把所有消息都打成 ERROR

1.2 生成日志与刷新日志分开

我原稿把日志分成两个动作,这个思路是对的:

  1. 组装一条完整日志;
  2. 把日志刷新到终端或文件。

第二步可以用策略模式实现。日志对象只负责形成字符串,具体输出位置由 ConsoleLogStrategyFileLogStrategy 决定。这样切换输出位置时,不需要修改每个 LOG(...) 调用。

1.3 为什么日志输出也要加锁

多个工作线程可能同时写日志。如果每条日志由多次 operator<< 拼接,线程间输出可能交叉,最终一行同时包含两条消息。

因此刷新策略要保护真正的输出动作。锁的粒度应是一条完整日志,而不是每写一个字符就加一次锁。对于文件输出,目录只需要初始化一次,后续以追加方式打开文件。

1.4 时间函数的线程安全

localtime 可能返回指向共享静态对象的指针,多个线程同时调用时会互相覆盖。Linux/POSIX 环境可以使用 localtime_r,让调用者提供 struct tm 存储空间。

cpp 复制代码
inline std::string CurrentTime()
{
    std::time_t now = std::time(nullptr);
    std::tm local_time{};
    localtime_r(&now, &local_time);

    char buffer[32];
    std::snprintf(buffer, sizeof(buffer),
                  "%04d-%02d-%02d %02d:%02d:%02d",
                  local_time.tm_year + 1900,
                  local_time.tm_mon + 1,
                  local_time.tm_mday,
                  local_time.tm_hour,
                  local_time.tm_min,
                  local_time.tm_sec);
    return buffer;
}

二、实现一个简单日志器

2.1 输出策略

cpp 复制代码
#pragma once
#include <filesystem>
#include <fstream>
#include <iostream>
#include <memory>
#include <mutex>
#include <string>

class LogStrategy
{
public:
    virtual ~LogStrategy() = default;
    virtual void Write(const std::string &message) = 0;
};

class ConsoleLogStrategy final : public LogStrategy
{
public:
    void Write(const std::string &message) override
    {
        std::lock_guard<std::mutex> lock(_mutex);
        std::cout << message << '\n';
    }

private:
    std::mutex _mutex;
};

class FileLogStrategy final : public LogStrategy
{
public:
    explicit FileLogStrategy(std::string file = "./log/thread_pool.log")
        : _file(std::move(file))
    {
        std::filesystem::path path(_file);
        if (path.has_parent_path())
            std::filesystem::create_directories(path.parent_path());
    }

    void Write(const std::string &message) override
    {
        std::lock_guard<std::mutex> lock(_mutex);
        std::ofstream out(_file, std::ios::app);
        if (out)
            out << message << '\n';
    }

private:
    std::string _file;
    std::mutex _mutex;
};

基类析构函数必须是虚函数,否则以后通过基类指针销毁派生策略时会出问题。策略对象内部各自持有一把锁,保证同一种输出目标上的一条日志不会被拆开。

2.2 Logger 与临时消息对象

cpp 复制代码
#pragma once
#include <cstdio>
#include <ctime>
#include <memory>
#include <mutex>
#include <sstream>
#include <string>
#include <sys/syscall.h>
#include <unistd.h>

enum class LogLevel { Debug, Info, Warning, Error, Fatal };

inline const char *ToString(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";
    }
    return "UNKNOWN";
}

class Logger
{
public:
    Logger() : _strategy(std::make_shared<ConsoleLogStrategy>()) {}

    void UseConsole()
    {
        std::lock_guard<std::mutex> lock(_strategy_mutex);
        _strategy = std::make_shared<ConsoleLogStrategy>();
    }

    void UseFile(const std::string &file)
    {
        auto next = std::make_shared<FileLogStrategy>(file);
        std::lock_guard<std::mutex> lock(_strategy_mutex);
        _strategy = std::move(next);
    }

    class Message
    {
    public:
        Message(Logger &logger, LogLevel level,
                const char *file, int line)
            : _logger(logger)
        {
            _stream << '[' << CurrentTime() << ']'
                    << '[' << ToString(level) << ']'
                    << "[pid:" << getpid() << ']'
                    << "[tid:" << syscall(SYS_gettid) << ']'
                    << '[' << file << ':' << line << "] ";
        }

        template <class T>
        Message &operator<<(const T &value)
        {
            _stream << value;
            return *this;
        }

        ~Message()
        {
            _logger.Flush(_stream.str());
        }

    private:
        Logger &_logger;
        std::ostringstream _stream;
    };

    Message operator()(LogLevel level, const char *file, int line)
    {
        return Message(*this, level, file, line);
    }

private:
    void Flush(const std::string &message)
    {
        std::shared_ptr<LogStrategy> strategy;
        {
            std::lock_guard<std::mutex> lock(_strategy_mutex);
            strategy = _strategy;
        }
        strategy->Write(message);
    }

private:
    std::mutex _strategy_mutex;
    std::shared_ptr<LogStrategy> _strategy;
};

inline Logger logger;
#define LOG(level) logger(level, __FILE__, __LINE__)

使用临时 Message 对象的好处是可以保留熟悉的流式写法:

cpp 复制代码
LOG(LogLevel::Info) << "task " << task_id << " finished";

整条语句结束时,临时对象析构,已经拼好的字符串一次性交给刷新策略。这里没有让多个线程共同修改同一个字符串流,每个线程在栈上创建自己的 Message

策略切换也要同步。代码先把当前 shared_ptr 复制到局部变量,再释放策略锁并真正输出,避免文件 I/O 长时间占着 _strategy_mutex

2.3 简单测试

cpp 复制代码
int main()
{
    LOG(LogLevel::Debug) << "console message";
    logger.UseFile("./log/thread_pool.log");
    LOG(LogLevel::Info) << "file message";
    LOG(LogLevel::Warning) << "queue is almost full";
}

这个实现没有日志轮转、异步写盘、过滤器和批量刷新,因此不应替代 spdlogglog 等成熟库。自己写一遍的目的,是为后面的线程池准备输出工具,并把多线程日志中最基本的锁范围想明白。


三、为什么需要线程池

3.1 来一个任务就创建一个线程的问题

如果每收到一个短任务就 pthread_create,处理完再 pthread_join 或分离线程,线程创建、栈空间准备、内核调度对象管理和销毁成本会反复发生。请求突然增多时,线程数量也可能跟着失控。

线程池的做法是提前创建固定数量的工作线程。任务到来后只放进队列,空闲线程从队列取任务执行,不再为每个任务重新创建线程。

我原稿用餐厅预制菜类比池化:资源提前准备好,需要时直接复用。这个类比抓住了核心,但线程池不是"任务提前算好",而是执行任务的线程提前创建好

3.2 线程池仍然是生产者消费者模型

提交任务的线程是生产者,任务队列是交易场所,工作线程是消费者:

  1. 外部线程把任务放入队列;
  2. 条件变量唤醒一个工作线程;
  3. 工作线程取走任务;
  4. 在队列锁外执行任务;
  5. 再回到队列等待下一个任务。

线程池只限制工作线程数量。若任务提交速度长期大于处理速度,无界队列仍会不断增长。真正用于服务端时,要考虑有界队列、拒绝策略或上游限流。

3.3 固定线程数怎么选

没有一个线程数适合所有程序。CPU 密集任务通常从硬件并发数附近开始测试;任务经常等待磁盘或网络时,可以适当多一些线程。但线程越多不等于越快,过多线程会增加切换、栈内存和缓存失效成本。

最可靠的办法仍然是结合任务类型、机器配置和实际负载压测,而不是抄一个固定公式。


四、固定线程池完整实现

4.1 线程池需要哪些状态

一个最小可用线程池需要:

  • 工作线程数组;
  • 任务队列;
  • 保护队列和状态的互斥锁;
  • 队列为空时供工作线程等待的条件变量;
  • 是否停止接收任务的标记。

退出时采用"排空队列再结束"的策略:调用 Stop 后不再接收新任务,已经进入队列的任务继续执行;队列为空以后,工作线程退出循环。

4.2 ThreadPool.hpp

cpp 复制代码
#pragma once
#include <condition_variable>
#include <cstddef>
#include <functional>
#include <mutex>
#include <queue>
#include <stdexcept>
#include <thread>
#include <utility>
#include <vector>

class ThreadPool
{
public:
    using Task = std::function<void()>;

    explicit ThreadPool(std::size_t thread_count)
    {
        if (thread_count == 0)
            throw std::invalid_argument("thread_count must be positive");

        _workers.reserve(thread_count);
        for (std::size_t i = 0; i < thread_count; ++i)
            _workers.emplace_back(&ThreadPool::WorkerLoop, this, i);
    }

    ~ThreadPool()
    {
        Stop();
    }

    ThreadPool(const ThreadPool &) = delete;
    ThreadPool &operator=(const ThreadPool &) = delete;

    void Submit(Task task)
    {
        {
            std::lock_guard<std::mutex> lock(_mutex);
            if (_stopping)
                throw std::runtime_error("submit on stopped thread pool");
            _tasks.push(std::move(task));
        }
        _condition.notify_one();
    }

    void Stop()
    {
        {
            std::lock_guard<std::mutex> lock(_mutex);
            if (_stopping)
                return;
            _stopping = true;
        }

        _condition.notify_all();
        for (std::thread &worker : _workers)
        {
            if (worker.joinable())
                worker.join();
        }
    }

private:
    void WorkerLoop(std::size_t worker_id)
    {
        LOG(LogLevel::Info) << "worker " << worker_id << " started";

        while (true)
        {
            Task task;
            {
                std::unique_lock<std::mutex> lock(_mutex);
                _condition.wait(lock, [this] {
                    return _stopping || !_tasks.empty();
                });

                if (_stopping && _tasks.empty())
                    break;

                task = std::move(_tasks.front());
                _tasks.pop();
            }

            try
            {
                task();
            }
            catch (const std::exception &error)
            {
                LOG(LogLevel::Error)
                    << "worker " << worker_id
                    << " task failed: " << error.what();
            }
            catch (...)
            {
                LOG(LogLevel::Error)
                    << "worker " << worker_id
                    << " task failed: unknown exception";
            }
        }

        LOG(LogLevel::Info) << "worker " << worker_id << " stopped";
    }

private:
    std::vector<std::thread> _workers;
    std::queue<Task> _tasks;
    std::mutex _mutex;
    std::condition_variable _condition;
    bool _stopping{false};
};

4.3 为什么使用 unique_lock

std::condition_variable::wait 需要在等待时释放锁,醒来后再加锁,因此它接收 std::unique_lock<std::mutex>。普通 lock_guard 不提供这种可解锁、再加锁的控制接口。

谓词写成 _stopping || !_tasks.empty(),表示有任务或者线程池开始停止时,工作线程都应该醒来继续判断。

4.4 为什么任务必须在锁外执行

工作线程只在锁内完成三件事:检查状态、取队头任务、删除队头任务。拿到局部 task 后立刻离开作用域,释放队列锁,然后再执行任务。

如果把 task() 放在锁内,某个任务执行 3 秒,其他工作线程就会 3 秒都拿不到队列锁。虽然创建了多个线程,任务仍然接近串行执行。

队列锁保护的是线程池内部状态,不应该保护任务自己的业务过程。任务若访问其他共享数据,应使用业务自己的同步手段。

4.5 为什么捕获任务异常

若异常一直逃出工作线程入口,程序通常会调用 std::terminate。一个任务抛错不应该直接带走整个线程池,所以这里在工作循环中捕获异常并写日志。

但不能空着 catch (...) 什么也不做。至少要记录任务失败,否则线程池表面还在运行,错误却完全丢失。


五、线程池怎样安全退出

5.1 工作线程的三种状态

线程池运行期间,工作线程大致处于三种状态:

  1. 队列为空,在条件变量下等待;
  2. 拿到队列锁,正在取任务;
  3. 已经释放队列锁,正在执行任务。

退出不能直接把这些线程"一股脑取消"。异步取消可能让线程停在持锁、分配内存或修改业务状态的中间位置,资源很难收拾。

5.2 Stop 的执行顺序

本文采用下面的退出顺序:

  1. 在锁内把 _stopping 设为 true
  2. notify_all 唤醒所有仍在等待的工作线程;
  3. 工作线程继续取完队列中的旧任务;
  4. 观察到"停止且队列为空"后退出循环;
  5. Stop 对每个工作线程执行 join

只修改标记却不广播,空闲工作线程可能永远睡在条件变量上,析构函数会卡在 join。只广播却不修改谓词,线程醒来后又会发现队列为空,重新等待。

5.3 排空退出和立即退出

线程池通常要明确两种语义:

退出方式 已排队任务 适合场景
排空退出 全部执行完 正常停机、希望不丢任务
立即退出 放弃尚未开始的任务 故障止损、任务已失去意义

本文代码是排空退出。若要立即退出,工作线程的判断和任务队列清理都要一起修改,不能只把某个条件改掉。

5.4 不要让工作线程销毁自己的线程池

如果某个任务直接析构它所在的线程池,Stop 可能尝试 join 当前线程自己,形成死锁或错误。线程池的所有权应由更外层对象管理,停止动作也应在工作线程之外发起。


六、运行线程池

6.1 测试代码

cpp 复制代码
#include <chrono>
#include <iostream>
#include <thread>
#include "Log.hpp"
#include "ThreadPool.hpp"

int main()
{
    logger.UseConsole();
    ThreadPool pool(3);

    for (int task_id = 0; task_id < 8; ++task_id)
    {
        pool.Submit([task_id] {
            LOG(LogLevel::Info) << "task " << task_id << " begin";
            std::this_thread::sleep_for(std::chrono::milliseconds(120));
            LOG(LogLevel::Info) << "task " << task_id << " end";
        });
    }

    pool.Stop();
    std::cout << "all tasks finished\n";
}
bash 复制代码
g++ -std=c++17 -O2 -pthread main.cc -o thread_pool
./thread_pool

运行这段程序时,不同任务的开始顺序和结束顺序可能变化,这是正常的调度结果。观察重点不是固定输出顺序,而是每条日志是否完整、8 个任务是否都执行结束,以及三个工作线程能否在 Stop 后退出。

6.2 可以继续扩展哪些功能

这个版本跑通以后,可以逐步增加:

  • 有界任务队列与提交超时;
  • 返回 future 的任务提交接口;
  • 任务优先级;
  • 运行中任务数、排队数和拒绝数统计;
  • 日志文件轮转与异步写入;
  • 明确区分排空退出和立即退出。

扩展时不要一次把所有功能塞进去。先固定线程池最基本的状态机,再加监控和策略,否则退出路径很容易失控。

总结

日志模块负责把并发程序中的关键状态留下来,线程池负责复用一组工作线程处理任务。线程池本质上仍是生产者消费者模型:提交方生产任务,工作线程消费任务,队列和条件变量协调二者速度。

实现时最容易写错的地方不是 pthread_create,而是锁的范围和退出过程:队列状态在锁内修改,任务在锁外执行;停止时先修改谓词,再唤醒全部线程,最后逐个 join

下一篇收尾线程安全专题,继续讨论单例模式、可重入函数、死锁和 STL/智能指针的线程安全边界。

参考资料

【Linux】信号量到底在数什么:从PV操作到RingQueue环形队列生产者消费者模型

【Linux】多线程抢票为什么会出错?从 mutex、futex 到 RAII,讲清线程互斥

【Linux】epoll 真的是 O(1) 吗?从阻塞 I/O、select/poll 到 LT/ET 与 Reactor 一次讲透

相关推荐
All for pursuit.1 小时前
【矩阵-2】240.搜索二维矩阵 II
数据结构·c++·算法·leetcode
程序猿阿森1 小时前
C语言预处理完全指南:宏定义、条件编译与头文件规范,一篇搞懂工程化编程
c语言·c++·编译
艾莉丝努力练剑1 小时前
【AI大模型接入SDK】LLMManager类架构与智能指针选型
网络·c++·人工智能·学习·面试·架构
Gl�ria1 小时前
Hadoop MapReduce / Hive 场景:ReduceTask 热点 Key 排查
运维·hadoop·数据倾斜
6Hzlia2 小时前
【Classic 150 刷题计划】 LeetCode 209. 长度最小的子数组 | C++ 滑动窗口(毛毛虫算法)经典模板
c++·算法·leetcode
inkuu2 小时前
WSL启动慢问题分析与修复
linux
像风一样自由20202 小时前
29.Redis在大模型应用中有哪些用途缓存会话与限流
数据库·人工智能·redis·缓存·大模型·milvus·智能体
snow@li2 小时前
服务器运维:K3S 下 Jenkins ↔ GitLab 的 CI/CD 闭环 / 访问成功
运维·gitlab·jenkins
6Hzlia2 小时前
【Classic 150 刷题计划】 LeetCode 28. 找出字符串中第一个匹配项的下标 | C++ 滑动窗口与子串比对
c++·算法·leetcode