《Linux系统编程》Linux 系统多线程(八): C++ 高并发线程池全链路深度解析与从零手撕实现

🔥小叶-duck个人主页

❄️个人专栏《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》

《Linux操作系统从入门到实践》《Qt从入门到实践》

《算法题讲解指南》--优选算法

《算法题讲解指南》--递归、搜索与回溯算法

《算法题讲解指南》--动态规划算法

未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游


目录

前言

一、池化技术与线程池:为什么我们需要线程池?

[1.1 池化技术的核心思想](#1.1 池化技术的核心思想)

[1.2 线程池的核心定义](#1.2 线程池的核心定义)

[1.3 线程池的核心优势](#1.3 线程池的核心优势)

[1.4 典型应用场景](#1.4 典型应用场景)

二、线程池的核心设计原理:本质是生产者消费者模型

[2.1 线程池的核心组成](#2.1 线程池的核心组成)

[2.2 线程池的核心运行流程](#2.2 线程池的核心运行流程)

[2.3 核心设计要点](#2.3 核心设计要点)

三、手撕线程池:C++源码深度解析

[3.1 基础组件:RAII 风格的互斥锁与条件变量封装](#3.1 基础组件:RAII 风格的互斥锁与条件变量封装)

[3.1.1 互斥锁与锁守卫封装](#3.1.1 互斥锁与锁守卫封装)

[3.1.2 条件变量封装](#3.1.2 条件变量封装)

[3.2 线程池核心类实现](#3.2 线程池核心类实现)

[3.3.1 线程类定义](#3.3.1 线程类定义)

[3.2.2 任务类型定义](#3.2.2 任务类型定义)

[3.2.3 线程池核心类框架(展示核心接口)](#3.2.3 线程池核心类框架(展示核心接口))

[3.2.4 核心成员函数实现(含注释解析)](#3.2.4 核心成员函数实现(含注释解析))

[3.2.4.1 线程池初始化与启动](#3.2.4.1 线程池初始化与启动)

[3.2.4.2 工作线程核心例程(线程池灵魂)](#3.2.4.2 工作线程核心例程(线程池灵魂))

[3.2.4.3 任务投递接口](#3.2.4.3 任务投递接口)

[3.2.4.4 线程池停止与资源回收](#3.2.4.4 线程池停止与资源回收)

[3.3 线程池使用示例(ThreadPool_v1完整版)](#3.3 线程池使用示例(ThreadPool_v1完整版))

四、进阶优化:线程安全的单例模式线程池

[4.1 单例模式的核心要求](#4.1 单例模式的核心要求)

[4.2 饿汉模式 vs 懒汉模式](#4.2 饿汉模式 vs 懒汉模式)

[4.3 线程安全的懒汉单例线程池(双检锁 DCL)](#4.3 线程安全的懒汉单例线程池(双检锁 DCL))

[4.4 单例线程池使用示例](#4.4 单例线程池使用示例)

五、线程池背后的核心安全问题

[5.1 线程安全与函数可重入](#5.1 线程安全与函数可重入)

[5.2 死锁:​ 多线程的头号杀手](#5.2 死锁: 多线程的头号杀手)

[5.2.1 死锁的四个必要条件](#5.2.1 死锁的四个必要条件)

[5.3 STL 容器与智能指针的线程安全](#5.3 STL 容器与智能指针的线程安全)

[5.4 常见锁概念拓展(补充)](#5.4 常见锁概念拓展(补充))

结束语


前言

面向 Linux 后端高并发服务开发时,普遍存在不少典型业务痛点:Web 服务需要承载每秒上千笔客户端请求、日志模块需要异步处理海量持久化任务、大规模计算任务需要并行调度。而如果每当任务抵达才即时创建线程,就会导致持续产生线程创建与销毁的系统开销;并且流量峰值阶段大量线程并发生成,极易加重 CPU 调度负担,极端情况下会引发系统内存溢出(OOM)。

池化技术 专门用来解决上述难题,线程池正是池化思想在多线程领域最经典的工程实现。该方案通过提前创建一批固定数量的工作线程 ,搭配任务队列统一调度 ,依靠线程复用异步执行 业务任务。一方面规避频繁创建线程带来的性能损耗 ,另一方面能够严格限制并发线程数量

本文将从线程池底层原理入手,带领大家从零完成一套工业级 C++ 线程池编码实现,进一步结合单例模式完成方案优化;同时深挖实现过程中涉及的线程同步、死锁隐患、各类锁选型等关键内容,帮助你吃透 Linux 高并发开发中这项核心技术。

一、池化技术与线程池:为什么我们需要线程池?

1.1 池化技术的核心思想

池化技术的本质是 「提前申请、重复利用、统一管理」,和我们生活中的「预制菜」「共享单车」逻辑完全一致:

  • 提前申请资源,避免临时申请的开销;
  • 资源重复利用,最大化资源利用率;
  • 统一管理资源,避免无节制申请导致系统过载。

对应生活中的例子:

  • 预制菜:店家提前备好半成品(提前申请),多桌客人轮流食用(重复利用),控制备货量避免积压浪费(统一管控),对应程序预先创建资源、反复复用、限制资源总量。
  • 共享单车:运营商提前投放车辆(提前申请),多人轮流骑行使用(重复利用),控制投放总量防止城市拥堵(统一管控),和线程池、连接池等 "预创建、循环复用、设上限限流" 逻辑完全一致。

除了线程池,我们熟知的进程池、内存池、连接池、对象池,都是池化思想的落地实现。

1.2 线程池的核心定义

线程池是一种线程使用模式:程序启动时先提前创建一批固定数量的工作线程,当一开始还没有任务发布时这些线程就会休眠等待,只要用户向任务队列发送任务后,这些线程就会被唤醒,循环式从任务队列中获取用户投递的任务并执行;用户无需关心线程的管理细节,只需将任务投递到线程池即可异步执行。

1.3 线程池的核心优势

优势 详细说明
降低系统开销 避免了线程频繁创建和销毁带来的 CPU、内存开销,尤其适合短任务场景
提升响应速度 任务到达时直接复用已有线程执行,无需等待线程创建的耗时,大幅降低任务延迟
控制并发上限 限制工作线程的最大数量,避免大量线程抢占 CPU 导致的调度颠簸,保证系统稳定性
统一线程管理 对工作线程进行统一的分配、调优、监控和异常处理,降低业务代码的复杂度

1.4 典型应用场景

  • Web 后端接口 服务器接收海量 HTTP 请求,用线程池统一调度处理请求,防止无限创建线程导致系统崩溃。
  • 异步任务处理 日志落盘、短信推送、邮件发送,交给线程池异步执行,不阻塞主线业务。
  • 批量文件 IO 批量读写、解压、下载大量文件,用线程池并发执行,控制并发数避免磁盘打满。
  • 数据库批量操作 批量插入、查询数据,连接池搭配线程池,稳定并发访问数据库。
  • 后台定时巡检 服务定时清理缓存、监控采集、数据统计,复用线程池执行周期性任务。

二、线程池的核心设计原理:本质是生产者消费者模型

线程池的底层逻辑,就是一个标准的多生产者 - 多消费者模型

  • 生产者:用户线程,向任务队列投递待执行的任务;
  • 消费者:线程池内的工作线程,循环从任务队列中获取任务并执行;
  • 交易场所 :任务队列,是整个模型的核心临界资源必须保证并发访问的线程安全

2.1 线程池的核心组成

一个完整的线程池,由四大核心模块构成:

  1. 任务队列 :存储用户投递的待执行任务,通常用队列实现 ,是线程池的核心临界资源
  2. 工作线程组:提前创建的固定数量的工作线程,循环竞争任务队列中的任务执行;
  3. 同步互斥机制:互斥锁保护任务队列的并发访问,条件变量实现线程的等待与唤醒,解决生产者与消费者的同步问题;
  4. 线程池状态管理:控制线程池的初始化、运行、停止状态,实现优雅退出,避免任务丢失。

2.2 线程池的核心运行流程

  1. 初始化阶段:构造函数初始化运行标记、互斥锁、条件变量与任务队列,批量创建绑定任务循环的线程对象,线程池暂未启动;
  2. **启动阶段:**调用 Start,将线程池置为运行状态,调用线程 Start 接口创建内核线程,所有工作线程进入任务循环就绪等待;
  3. **任务投递阶段:**用户调用 Enqueue 加锁将任务推入队列,线程池运行且存在休眠线程时,唤醒单个休眠线程,随后释放锁;
  4. **任务执行阶段:**工作线程循环争抢队列,队列为空则休眠等待,被唤醒后取出任务、释放锁,在临界区外执行任务,执行完毕继续循环取任务;
  5. **优雅退出阶段:**调用 Stop 上锁将线程池设为停止状态,不再接收新任务,广播唤醒全部休眠线程,线程执行完队列剩余任务后准备退出;
  6. **线程回收阶段:**调用 Join 阻塞主线程,逐一等待所有工作线程结束回收,全部线程回收完成后释放线程池全部资源。

2.3 核心设计要点

  1. 任务执行必须在临界区外 :工作线程取到任务后立即释放锁,任务执行是线程私有行为,不占用临界区,最大化提升并发度;
  2. 条件变量必须用 while 循环判断: 防止操作系统的伪唤醒 ,保证线程被唤醒后一定会重新检查任务队列是否有任务,避免程序异常;
  3. 优雅退出的双条件判断:只有当「线程池停止运行」且「任务队列为空」同时满足时,工作线程才能退出,保证所有已投递的任务都会被执行完毕,不会出现任务丢失;
  4. 唤醒逻辑优化:只有当有线程处于等待状态时,才发送唤醒信号,避免无效的系统调用,提升程序性能。

三、手撕线程池:C++源码深度解析

我们将基于 Linux 原生 pthread 库,用 C++ 实现工业级线程池,先封装基础的同步互斥组件,再实现线程池核心逻辑,保证代码的可复用性、健壮性和高性能。

3.1 基础组件:RAII 风格的互斥锁与条件变量封装

RAII(资源获取即初始化)是 C++ 管理资源的核心思想,利用对象的生命周期自动管理资源的申请与释放 ,彻底避免资源泄漏

因为这些基础组件的封装在前面的文章中都已经设计过了,这里我们就是直接拿来使用,所以下面的代码只展示了封装设计,对于如何这样设计在前面文章中已经进行了详细的注释讲解,想看细节的可以去看看。

3.1.1 互斥锁与锁守卫封装

《Linux系统编程》Linux 系统多线程(四):<线程同步与互斥>线程互斥-CSDN博客

cpp 复制代码
// 互斥锁的封装
#ifndef MUTEX_HPP
#define MUTEX_HPP

#include <iostream>
#include <pthread.h>
#include <string>

namespace MutexModule
{
    class Mutex
    {
    public:
        Mutex()
        {
            pthread_mutex_init(&_mutex, nullptr);
            // std::cout << "mutex init success" << std::endl;
        }

        void Lock()
        {
            int n = pthread_mutex_lock(&_mutex);
            if (n != 0)
            {
                std::cerr << "pthread_mutex_lock false" << std::endl;
            }
        }

        void Unlock()
        {
            int n = pthread_mutex_unlock(&_mutex);
            if (n != 0)
            {
                std::cerr << "pthread_mutex_unlock false" << std::endl;
            }
        }

        ~Mutex()
        {
            int n = pthread_mutex_destroy(&_mutex);
            if (n != 0)
            {
                std::cerr << "pthread_mutex_destroy false" << std::endl;
            }
            else
            {
                // std::cout << "mutex destroy success" << std::endl;
            }
        }

        pthread_mutex_t *GetMutex()
        {
            return &_mutex;
        }

    private:
        pthread_mutex_t _mutex;
    };

    // RAII风格的互斥锁的封装(实现自动解锁)
    class LockGuard
    {
    public:
        LockGuard(Mutex &mutex) : _mutex(mutex)
        {
            _mutex.Lock();
        }

        ~LockGuard()
        {
            _mutex.Unlock();
        }

    private:
        Mutex &_mutex;
    };
}

#endif

代码解析

  • Mutex 类完整封装了 pthread 互斥量的初始化、加锁、解锁、销毁全生命周期,禁用拷贝避免未定义行为;
  • LockGuard 是 RAII 的核心实现,利用栈对象的生命周期自动管理锁,彻底避免了手动解锁的遗漏,即使临界区内代码抛出异常,也能保证锁被正确释放。

3.1.2 条件变量封装

《Linux系统编程》Linux 系统多线程(五):<线程同步与互斥>线程同步(上):从条件变量到生产者消费者模型详解-CSDN博客

cpp 复制代码
// 条件变量的封装
#ifndef COND_HPP
#define COND_HPP

#include <iostream>
#include <pthread.h>
#include "Mutex.hpp"

using namespace MutexModule;

namespace CondModule
{
    class Cond
    {
    public:
        Cond()
        {
            pthread_cond_init(&_cond, nullptr);
        }

        void Wait(Mutex &mutex)
        {
            int n = pthread_cond_wait(&_cond, mutex.GetMutex());
            if (n != 0)
            {
                std::cerr << "Failed to Wait" << std::endl;
            }
        }

        void Signal()
        {
            int n = pthread_cond_signal(&_cond);
            if (n != 0)
            {
                std::cerr << "Failed to Signal" << std::endl;
            }
        }

        void Broadcast()
        {
            int n = pthread_cond_broadcast(&_cond);
            if (n != 0)
            {
                std::cerr << "Failed to Broadcast" << std::endl;
            }
        }

        ~Cond()
        {
            pthread_cond_destroy(&_cond);
        }

    private:
        pthread_cond_t _cond;
    };
}

#endif

代码解析

  • 条件变量的核心作用是实现线程间的同步,避免任务队列为空时工作线程 CPU 空转;
  • Wait 函数必须和互斥锁配合使用,因为「条件判断」和「进入等待」必须是原子操作,避免解锁后、等待前信号丢失导致线程永久阻塞;
  • Signal 用于任务入队时唤醒单个工作线程,Broadcast 用于线程池退出时唤醒所有等待线程。

3.2 线程池核心类实现

我们用模板类实现线程池,支持任意可调用对象作为任务,先定义任务类型,封装线程再实现线程池核心逻辑。其中我们还使用了日志类,这个我们上一篇对日志系统进行讲解是已经封装好了,并且有点长,这里就不再展示了,感兴趣的可以去看看。

《Linux系统编程》Linux 系统多线程(七): C++ 线程安全日志系统封装:基于策略模式解耦,兼容 glog 使用风格-CSDN博客

3.3.1 线程类定义

《Linux系统编程》Linux 系统多线程(三):线程 ID 本质、进程地址空间布局及线程封装-CSDN博客

cpp 复制代码
// 线程封装
#ifndef __THREAD_HPP
#define __THREAD_HPP

#include <iostream>
#include <string>
#include <cstdio>
#include <functional>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/syscall.h>

enum TSTAYUS
{
    THREAD_NEW,     // 创建但没有运行状态
    THREAD_RUNNING, // 运行状态
    THREAD_STOPPED, // 退出状态
};

static int gnum = 1;
using func_t = std::function<void()>;

namespace ThreadModule
{
    class Thread
    {
    private:
        static void *routine(void *args)
        {
            Thread *self = static_cast<Thread *>(args);
            self->get_pid();
            self->get_lwpid();
            // 获取线程名字:pthread_setname_np
            // int pthread_setname_np(pthread_t thread, const char *name);
            pthread_setname_np(pthread_self(), self->_name.c_str());
            self->_func(); // 回调处理
            return nullptr;
        }

        void get_pid()
        {
            _pid = getpid();
        }

        void get_lwpid()
        {
            _lwpid = syscall(SYS_gettid); // syscall陷入内核获取LWP轻量级进程ID
        }

    public:
        Thread(func_t func)
            : _tid(0), _joinable(true), _status(TSTAYUS::THREAD_NEW), _func(func)
        {
            _name = "thread-" + std::to_string(gnum++);
        }

        bool Start()
        {
            if (_status == TSTAYUS::THREAD_RUNNING)
            {
                std::cerr << "thread is already running" << std::endl;
                return false;
            }
            // 重点
            int n = pthread_create(&_tid, nullptr, routine, this);
            if (n != 0)
            {
                std::cerr << "pthread_create failed" << std::endl;
                return false;
            }
            else
            {
                std::cout << _name << " create success" << std::endl;
                _status = TSTAYUS::THREAD_RUNNING;
                return true;
            }
        }

        void Detach()
        {
            if (_joinable)
            {
                int n = pthread_detach(_tid);
                if (n != 0)
                {
                    std::cerr << "pthread_detach failed" << std::endl;
                }
                else
                {
                    std::cout << _name << " detach success" << std::endl;
                    _joinable = false;
                }
            }
            else // 已经处于分离状态,不可再次分离
            {
                std::cerr << "detach failed, because thread is detached" << std::endl;
            }
        }

        void Stop()
        {
            if (_status == TSTAYUS::THREAD_RUNNING)
            {
                int n = pthread_cancel(_tid);
                if (n != 0)
                {
                    std::cerr << "pthread_cancel failed" << std::endl;
                }
                else
                {
                    std::cout << _name << " stop success" << std::endl;
                    _status = TSTAYUS::THREAD_STOPPED;
                }
            }
        }

        void Join()
        {
            if (_joinable)
            {
                int n = pthread_join(_tid, nullptr);
                if (n != 0)
                {
                    std::cerr << "pthread_join failed" << std::endl;
                }
                else
                {
                    printf("lwp: %d, name: %s, thread join success\n", _lwpid, _name.c_str());
                }
            }
            else // 分离的线程不能join
            {
                std::cerr << "join failed, because thread is detached" << std::endl;
            }
        }

        ~Thread() {}

    private:
        pthread_t _tid;
        pid_t _pid;
        pid_t _lwpid;
        std::string _name;
        bool _joinable;  // 线程可否join(是否分离)
        TSTAYUS _status; // 线程状态
        func_t _func;    // 回调变量
    };
}

#endif

3.2.2 任务类型定义

cpp 复制代码
// 任务类型定义
#ifndef TASK_HPP
#define TASK_HPP

#include <iostream>
#include <functional>
#include <pthread.h>
#include "Log.hpp"

using namespace LogModule;
using task_t = std::function<void()>;

// 1. 全局函数
// 重点在于展示如何在任务内部识别当前正在干活的线程。
void task1()
{
    char name[64];
    // pthread_getname_np 是 Linux 特有的接口,用于获取线程的别名(在 Thread.hpp 中通过 pthread_setname_np 设置)
    // 这在多线程调试时非常关键,能帮你确定任务是否在预期的 Worker 线程中执行。
    pthread_getname_np(pthread_self(), name, sizeof(name));

    LOG(LogLevel::DEBUG) << "执行任务1: 打印消息 |" << name << "|";
}

// 示例任务2:模拟计算型任务
// 模拟一个简单的算术逻辑处理。
void task2()
{
    char name[64];
    // 每一个任务被执行时,实际上都是在某个 Worker 线程的调用栈中运行。
    pthread_getname_np(pthread_self(), name, sizeof(name));

    LOG(LogLevel::DEBUG) << "执行任务2: 计算 1+1 = " << 1 + 1 << " |" << name << "|";
}

#endif

3.2.3 线程池核心类框架(展示核心接口)

这里先展示线程池中最核心的一些函数接口,后续我们还需要对这个框架整体进行优化还会添加部分接口用于调用,但主要接口如下:

cpp 复制代码
// 线程池的封装
#ifndef THREADPOOL_HPP
#define THREADPOOL_HPP

#include <vector>
#include <queue>
#include "Cond.hpp"
#include "Thread.hpp"
#include "Mutex.hpp"

using namespace MutexModule;
using namespace ThreadModule;
using namespace CondModule;

static const int g_nums = 5;

// 线程池核心类 (基于生产者-消费者模型设计)
// 采用模板类 T,以支持不同类型的任务逻辑(通常为 std::function<void()>)
template <class T>
class ThreadPool
{
public:
    // 构造函数
    // 职责:初始化成员变量,并预分配 Thread 对象
    ThreadPool(const nums = g_nums) : _nums(nums);

    // 启动线程池
    // 职责:将状态位改为运行中,并真正调用每个 Thread 的 start() 方法创建内核线程
    void Start();

    // 生产者接口:提交任务
    // 职责:加锁入队,并唤醒正在休眠的消费者线程
    void Enqueue(const T &task);

    // 温和关闭线程池
    // 职责:修改运行状态,并广播(Broadcast)所有线程,确保任务处理完后线程能正常退出
    void Stop();

    // 资源回收接口
    // 职责:循环调用线程对象的 join(),确保主线程在子线程彻底回收后再退出
    void Join();

    // 析构函数
    ~ThreadPool();

private:
    // 线程执行流入口函数,线程一旦创建就会在函数中运行 (核心死循环)
    // 内部包含:加锁、条件变量等待、任务获取、任务执行、状态检测
    void HandlerTask();

private:
    int _nums;                    // 预设线程规模
    std::vector<Thread> _threads; // 线程"工人"管理数组
    bool _isrunning;              // 运行状态标识位,用于一些判断条件的使用

    int _sleeper_num;          // 统计当前在条件变量_cond下处于Wait状态的线程数量,用于判断是否需要唤醒线程
    Mutex _mutex;              // 互斥锁:保证队列操作的原子性
    Cond _cond;                // 条件变量:实现线程间的同步通知
    std::queue<T> _task_queue; // 任务队列:充当生产者与消费者之间的"交易场所"
};

#endif

成员变量解析

  • **_queue:**任务队列,是生产者和消费者的核心共享资源,所有访问必须加锁保护;
  • **_mutex:**互斥锁,保护任务队列、_sleeper_cnt、_isrunning所有共享资源的并发访问;
  • **_sleeper_cnt:**记录等待线程数量,用于优化唤醒逻辑,只有当有线程等待时才发送唤醒信号,避免无效系统调用;
  • **_isrunning:**线程池运行状态标志,控制工作线程的运行与退出,实现优雅关闭。

3.2.4 核心成员函数实现(含注释解析)

3.2.4.1 线程池初始化与启动
cpp 复制代码
public:
    // 构造函数
    // 职责:初始化成员变量,并预分配 Thread 对象
    ThreadPool(const nums = g_nums) : _nums(nums), _isrunning(false), _sleeper_num(0)
    {
        // 循环式创建线程对象,并通过成员变量_threads进行管理
        for (int i = 0; i < nums; i++)
        {
            // _threads.emplace_back(HandlerTask); //error
            // 因为Thread需要传 func_t 类型参数(即无参无返回值函数),而类内非静态成员函数存在隐含的this指针,参数不匹配
            // 所以我们可以使用emplace_back接口直接传入lambda表达式作为参数传入
            // 这样就可以让线程通过回调执行lambda表达式,再在内部执行获取任务函数HandlerTask
            // 但是我们无法直接调用,因为lambda表达式并不是类中成员函数,无法直接访问私有成员
            // 但是表达式是在成员函数中的,所以我们可以this指针进行捕捉,使得 lambda 体内可以访问私有成员 ThreadRoutine

            // 性能优化:emplace_back 直接在 vector 内存中构造 Thread 对象,避免了额外的拷贝或移动开销。
            _threads.emplace_back(
                [this]()
                {
                    // this->HandlerTask();
                    HandlerTask();
                });
        }
    }

    // 启动线程池
    // 职责:将状态位改为运行中,并真正调用每个 Thread 的 start() 方法创建内核线程
    void Start()
    {
        // 先提一下这里函数存在缺陷,后续还需要进行优化,这里看一下逻辑即可

        // 如果当前处于运行状态,直接返回,确保 Start 只能成功执行一次
        if (_isrunning)
            return;
        // 状态翻转:标记线程池已进入运行状态
        _isrunning = true;
        // 遍历管理容器,逐个调用Thread类的Start()封装,内部调用pthread_create创建内核线程执行routine函数
        for (auto &thread : _threads)
        {
            thread.Start();
        }
        // 在routine函数内部进而进行函数回调(self->_func();),
        // 所有子线程将竞相进入 HandlerTask 的while(true)循环工作
    }

代码解析

  • 类的非静态成员函数有隐式的 this 指针,无法直接作为 pthread 的回调函数,因此用 lambda 表达式捕获 this 指针,将类实例传入回调,再调用成员函数;
  • 构造函数内仅创建线程对象,Start设置运行状态,分离初始化和启动逻辑,方便线程池的生命周期管理。
3.2.4.2 工作线程核心例程(线程池灵魂)
cpp 复制代码
private:
    // 线程执行流入口函数,线程一旦创建就会在函数中运行 (核心死循环)
    // 内部包含:加锁、条件变量等待、任务获取、任务执行、状态检测
    void HandlerTask()
    {
        char name[128];
        // 在Thread类中线程执行函数routine中获取线程名字:pthread_setname_np
        // 获取线程名,用于日志输出,方便追踪是哪个"工人"在干活
        pthread_getname_np(pthread_self(), name, sizeof(name));

        while (true)
        {
            T task; // 定义局部任务对象,用于从队列中拷贝获取任务到本地执行流
            // 临界区作用域开始:保证对任务队列的获取任务操作是原子的
            {
                LockGuard lockguard(_mutex); // 加锁保护,RAII机制确保出了这个花括号自动解锁
                // 1. 任务队列为空 && 线程处于运行状态(不退出) --> 要求线程休眠
                // 为什么要用 while 而不是 if?
                // 答:为了应对"虚假唤醒"。即便因为特殊原因在条件不满足的情况下被唤醒,
                // 醒来第一件事必须是再次检查条件,确保真的有任务可领,否则继续睡。
                while (_task_queue.empty() && _isrunning)
                {
                    // 而一旦我们让线程池调用Stop停止运行,则_isrunning为false,
                    // 后续线程则不会在进入循环内部(除已休眠线程),所以在Stop中需要最后一次唤醒全部已休眠线程离开循环
                    LOG(LogLevel::INFO) << "当前没有任务,线程 " << "|" << name << "|" << " 进行休眠";
                    _sleeper_num++;     // 进入休眠状态前,计数器自增
                    _cond.Wait(_mutex); // 核心动作:原子解锁并挂起;被唤醒后自动重新加锁
                    _sleeper_num--;     // 被唤醒后,计数器自减
                    LOG(LogLevel::INFO) << "有任务,线程 " << "|" << name << "|" << " 进行唤醒";
                }

                // 死循环的线程如何退出函数:
                //  2. 任务队列为空 && 线程池不处于运行状态(要退出) --> 要求线程退出
                // 退出的关键判断:只有当"整个线程池停止运行"且"活儿都干完了",线程才允许 break 跳出循环。
                // 换句话也就是说:只要任务队列还存在任务或者线程池依然在运行,所有线程就不允许退出!
                // 这样才能保证所有任务都被获取并且执行完了,没有多余任务的残留
                if (_task_queue.empty() && !_isrunning)
                {
                    LOG(LogLevel::INFO) << "Thread: " << name << " quit";
                    break;
                }

                // 3. 任务队列不为空 && 线程处于运行状态(不退出) --> 线程正常处理任务
                //    任务队列不为空 && 线程不处于运行状态(要退出) --> 线程要先处理完残留的任务才允许退出
                //    到这里了肯定是有任务:执行真正的"领任务"动作
                task = _task_queue.front();
                _task_queue.pop();
            } // 临界区作用域结束,lockGuard 析构,释放互斥锁

            // 核心性能考量点:task()任务处理放在临界区外面来执行。
            // 理由:任务执行相比获取任务一般非常耗时,如果持锁执行,会导致其他线程长时间无法领任务,
            // 整个线程池将退化为串行执行。而释放锁后再处理,能实现多个线程执行任务的并发处理。
            task();
        }
    }

核心设计深度解析

  • while 循环防伪唤醒 : 操作系统可能会无故唤醒等待的线程(伪唤醒),用 while 循环会在唤醒后重新检查任务队列是否有任务,不满足则继续等待,保证程序健壮性,这是 pthread 条件变量的标准使用规范;
  • 优雅退出双条件判断:只有当「线程池停止」且「任务队列为空」时,线程才会退出,保证所有已投递的任务都会被执行完毕,绝对不能用 pthread_cancel 强制终止线程,会导致任务执行中断、资源泄漏;
  • 任务执行在临界区外:取出任务后,锁会在离开作用域时自动释放,耗时的任务执行完全不占用临界区,其他线程可以正常投递和获取任务,最大化并发度,这是线程池高性能的核心设计。
3.2.4.3 任务投递接口
cpp 复制代码
    // 生产者接口:提交任务
    // 职责:加锁入队,并唤醒正在休眠的消费者线程
    void Enqueue(const T &task)
    {
        // 1. 加锁保护:任务队列 (_queue) 是临界资源,必须保证 push 操作的原子性
        LockGuard lockguard(&_mutex);

        // 2. 状态判定:这不仅是逻辑检查,更是安全防线
        // 如果当前处于停止状态,禁止继续加任务,否则标志位没有任何意义
        if (!_isrunning)
            return;

        // 3. 任务入队:将任务拷贝/移动到 STL 队列中
        _task_queue.push(task);
        // 4. 唤醒机制:
        // 当存在一个或多个线程在休眠时,唤醒一个线程来执行任务
        // 性能优化点:按需通知 (Selective Notification)
        // 理由:如果所有线程都已经在忙碌处理任务,调用 Signal 会产生无谓的内核系统调用开销。
        if (_sleeper_num)
        {
            _cond.Signal();
        }
    }

代码解析

  • 任务队列是临界资源,投递任务必须加锁,保证多线程并发投递的线程安全;
  • 线程池停止后禁止投递新任务,避免任务入队后线程已退出导致任务丢失;
  • 仅当有线程处于等待状态时才发送唤醒信号,避免无意义的系统调用,提升性能。
3.2.4.4 线程池停止与资源回收
cpp 复制代码
    // 温和关闭线程池
    // 职责:修改运行状态,并广播(Broadcast)所有线程,确保任务处理完后线程能正常退出
    void Stop()
    {
        // 加锁进入临界区,修改状态位和发送通知必须是原子的,防止错失信号
        LockGuard lockguard(_mutex);
        if (!_isrunning)
            return;

        // 状态翻转:这是逻辑上的"关门",保证 Enqueue 接口将不再接受新任务
        _isrunning = false; // 将状态改为false;
        LOG(LogLevel::INFO) << "关闭线程池";

        // 唤醒所有线程,因为可能存在线程池即使停止了但是仍然还有线程在休眠
        if (_sleeper_num)
        {
            _cond.Broadcast();
            // 为什么是 Broadcast 而不是 Signal?
            // 答:一旦调用Stop则原本不在等待的线程执行完任务后就会退出
            // 但是仍然存在还在while中等待的线程,所以必须最后一次唤醒全部线程,让它们意识到池子已经关闭
            // 唤醒后因为_isrunning为false全部线程就会离开while进而退出函数
        }
    }

    // 资源回收接口
    // 职责:循环调用线程对象的 join(),确保主线程在子线程彻底回收后再退出
    void Join()
    {
        // 问题:这里访问了管理线程数组,其也是临界资源,为什么不加锁保护?
        // 答:可能会导致死锁!
        // 原因:当我们让线程池停止运行也就是调用Stop后,我们就会调用Join让主线程等待所有线程回收
        // 而当我们调用Stop时,上面讲了线程并不会直接退出函数而是先全部唤醒把任务队列中残留任务执行完全再一个一个退出
        // 而每次执行完一个任务就会回到while开头出现申请锁进入,
        // 如果主线程在调用Join时申请锁保护,但是因为还存在线程没有退出,那么主线程就会带着锁等待线程退出!
        // 而因为线程又需要申请成功锁才能进入临界区执行break退出函数,锁又在主线程那无法申请成功 ------> 此时死锁就出现了
        for (auto &thread : _threads)
        {
            thread.Join();
        }
    }

代码解析

  • Stop 函数修改线程池运行状态后,必须用Broadcast 唤醒所有等待的线程 ,让所有线程都能检查退出条件,避免部分线程永久休眠;
  • Wait 函数循环调用 pthread_join 等待所有工作线程退出,保证主线程不会提前终止,导致进程退出、任务未执行完毕。

3.3 线程池使用示例(ThreadPool_v1完整版)

  • Threadpool.hpp
cpp 复制代码
// 线程池的封装
#ifndef THREADPOOL_HPP
#define THREADPOOL_HPP

#include <vector>
#include <queue>
#include "Cond.hpp"
#include "Thread.hpp"
#include "Mutex.hpp"
#include "Log.hpp"

using namespace MutexModule;
using namespace ThreadModule;
using namespace CondModule;
using namespace LogModule;

static const int g_nums = 5;

// 线程池核心类 (基于生产者-消费者模型设计)
// 采用模板类 T,以支持不同类型的任务逻辑(通常为 std::function<void()>)
template <class T>
class ThreadPool // v1版本,部分函数设计存在缺陷需要后续优化
{
public:
    // 构造函数
    // 职责:初始化成员变量,并预分配 Thread 对象
    ThreadPool(const int nums = g_nums) : _nums(nums), _isrunning(false), _sleeper_num(0)
    {
        // 循环式创建线程对象,并通过成员变量_threads进行管理
        for (int i = 0; i < nums; i++)
        {
            // _threads.emplace_back(HandlerTask); //error
            // 因为Thread需要传 func_t 类型参数(即无参无返回值函数),而类内非静态成员函数存在隐含的this指针,参数不匹配
            // 所以我们可以使用emplace_back接口直接传入lambda表达式作为参数传入
            // 这样就可以让线程通过回调执行lambda表达式,再在内部执行获取任务函数HandlerTask
            // 但是我们无法直接调用,因为lambda表达式并不是类中成员函数,无法直接访问私有成员
            // 但是表达式是在成员函数中的,所以我们可以this指针进行捕捉,使得 lambda 体内可以访问私有成员 ThreadRoutine

            // 性能优化:emplace_back 直接在 vector 内存中构造 Thread 对象,避免了额外的拷贝或移动开销。
            _threads.emplace_back(
                [this]()
                {
                    // this->HandlerTask();
                    HandlerTask();
                });
        }
    }

    // 启动线程池
    // 职责:将状态位改为运行中,并真正调用每个 Thread 的 start() 方法创建内核线程
    void Start()
    {
        // 先提一下这里函数存在缺陷,后续还需要进行优化,这里看一下逻辑即可

        // 如果当前处于运行状态,直接返回,确保 Start 只能成功执行一次
        if (_isrunning)
            return;
        // 状态翻转:标记线程池已进入运行状态
        _isrunning = true;
        // 遍历管理容器,逐个调用Thread类的Start()封装,内部调用pthread_create创建内核线程执行routine函数
        for (auto &thread : _threads)
        {
            thread.Start();
        }
        // 在routine函数内部进而进行函数回调(self->_func();),
        // 所有子线程将竞相进入 HandlerTask 的while(true)循环工作
    }

    // 生产者接口:提交任务
    // 职责:加锁入队,并唤醒正在休眠的消费者线程
    void Enqueue(const T &task)
    {
        // 1. 加锁保护:任务队列 (_queue) 是临界资源,必须保证 push 操作的原子性
        LockGuard lockguard(_mutex);

        // 2. 状态判定:这不仅是逻辑检查,更是安全防线
        // 如果当前处于停止状态,禁止继续加任务,否则标志位没有任何意义
        if (!_isrunning)
            return;

        // 3. 任务入队:将任务拷贝/移动到 STL 队列中
        _task_queue.push(task);
        // 4. 唤醒机制:
        // 当存在一个或多个线程在休眠时,唤醒一个线程来执行任务
        // 性能优化点:按需通知 (Selective Notification)
        // 理由:如果所有线程都已经在忙碌处理任务,调用 Signal 会产生无谓的内核系统调用开销。
        if (_sleeper_num)
        {
            _cond.Signal();
        }
    }

    // 温和关闭线程池
    // 职责:修改运行状态,并广播(Broadcast)所有线程,确保任务处理完后线程能正常退出
    void Stop()
    {
        // 加锁进入临界区,修改状态位和发送通知必须是原子的,防止错失信号
        LockGuard lockguard(_mutex);
        if (!_isrunning)
            return;

        // 状态翻转:这是逻辑上的"关门",保证 Enqueue 接口将不再接受新任务
        _isrunning = false; // 将状态改为false;
        LOG(LogLevel::INFO) << "关闭线程池";

        // 唤醒所有线程,因为可能存在线程池即使停止了但是仍然还有线程在休眠
        if (_sleeper_num)
        {
            _cond.Broadcast();
            // 为什么是 Broadcast 而不是 Signal?
            // 答:一旦调用Stop则原本不在等待的线程执行完任务后就会退出
            // 但是仍然存在还在while中等待的线程,所以必须最后一次唤醒全部线程,让它们意识到池子已经关闭
            // 唤醒后因为_isrunning为false全部线程就会离开while进而退出函数
        }
    }

    // 资源回收接口
    // 职责:循环调用线程对象的 join(),确保主线程在子线程彻底回收后再退出
    void Join()
    {
        // 问题:这里访问了管理线程数组,其也是临界资源,为什么不加锁保护?
        // 答:可能会导致死锁!
        // 原因:当我们让线程池停止运行也就是调用Stop后,我们就会调用Join让主线程等待所有线程回收
        // 而当我们调用Stop时,上面讲了线程并不会直接退出函数而是先全部唤醒把任务队列中残留任务执行完全再一个一个退出
        // 而每次执行完一个任务就会回到while开头出现申请锁进入,
        // 如果主线程在调用Join时申请锁保护,但是因为还存在线程没有退出,那么主线程就会带着锁等待线程退出!
        // 而因为线程又需要申请成功锁才能进入临界区执行break退出函数,锁又在主线程那无法申请成功 ------> 此时死锁就出现了
        for (auto &thread : _threads)
        {
            thread.Join();
        }
    }

    // 析构函数
    ~ThreadPool()
    {
    }

private:
    // 线程执行流入口函数,线程一旦创建就会在函数中运行 (核心死循环)
    // 内部包含:加锁、条件变量等待、任务获取、任务执行、状态检测
    void HandlerTask()
    {
        char name[128];
        // 在Thread类中线程执行函数routine中获取线程名字:pthread_setname_np
        // 获取线程名,用于日志输出,方便追踪是哪个"工人"在干活
        pthread_getname_np(pthread_self(), name, sizeof(name));

        while (true)
        {
            T task; // 定义局部任务对象,用于从队列中拷贝获取任务到本地执行流
            // 临界区作用域开始:保证对任务队列的获取任务操作是原子的
            {
                LockGuard lockguard(_mutex); // 加锁保护,RAII机制确保出了这个花括号自动解锁
                // 1. 任务队列为空 && 线程处于运行状态(不退出) --> 要求线程休眠
                // 为什么要用 while 而不是 if?
                // 答:为了应对"虚假唤醒"。即便因为特殊原因在条件不满足的情况下被唤醒,
                // 醒来第一件事必须是再次检查条件,确保真的有任务可领,否则继续睡。
                while (_task_queue.empty() && _isrunning)
                {
                    // 而一旦我们让线程池调用Stop停止运行,则_isrunning为false,
                    // 后续线程则不会在进入循环内部(除已休眠线程),所以在Stop中需要最后一次唤醒全部已休眠线程离开循环
                    LOG(LogLevel::INFO) << "当前没有任务,线程 " << "|" << name << "|" << " 进行休眠";
                    _sleeper_num++;     // 进入休眠状态前,计数器自增
                    _cond.Wait(_mutex); // 核心动作:原子解锁并挂起;被唤醒后自动重新加锁
                    _sleeper_num--;     // 被唤醒后,计数器自减
                    LOG(LogLevel::INFO) << "有任务,线程 " << "|" << name << "|" << " 进行唤醒";
                }

                // 死循环的线程如何退出函数:
                //  2. 任务队列为空 && 线程池不处于运行状态(要退出) --> 要求线程退出
                // 退出的关键判断:只有当"整个线程池停止运行"且"活儿都干完了",线程才允许 break 跳出循环。
                // 换句话也就是说:只要任务队列还存在任务或者线程池依然在运行,所有线程就不允许退出!
                // 这样才能保证所有任务都被获取并且执行完了,没有多余任务的残留
                if (_task_queue.empty() && !_isrunning)
                {
                    LOG(LogLevel::INFO) << "Thread: " << name << " quit, 线程池退出&&任务队列为空";
                    break;
                }

                // 3. 任务队列不为空 && 线程处于运行状态(不退出) --> 线程正常处理任务
                //    任务队列不为空 && 线程不处于运行状态(要退出) --> 线程要先处理完残留的任务才允许退出
                //    到这里了肯定是有任务:执行真正的"领任务"动作
                task = _task_queue.front();
                _task_queue.pop();
            } // 临界区作用域结束,当线程满足条件调用break退出函数,就会自动调用 lockGuard 析构,释放互斥锁

            // 核心性能考量点:task()任务处理放在临界区外面来执行。
            // 理由:任务执行相比获取任务一般非常耗时,如果持锁执行,会导致其他线程长时间无法领任务,
            // 整个线程池将退化为串行执行。而释放锁后再处理,能实现多个线程执行任务的并发处理。
            task();
        }
    }

private:
    int _nums;                    // 预设线程规模
    std::vector<Thread> _threads; // 线程"工人"管理数组
    bool _isrunning;              // 运行状态标识位,用于一些判断条件的使用

    int _sleeper_num;          // 统计当前在条件变量_cond下处于Wait状态的线程数量,用于判断是否需要唤醒线程
    Mutex _mutex;              // 互斥锁:保证队列操作的原子性
    Cond _cond;                // 条件变量:实现线程间的同步通知
    std::queue<T> _task_queue; // 任务队列:充当生产者与消费者之间的"交易场所"
};

#endif
cpp 复制代码
#include "Log.hpp"
#include "Task.hpp"
#include "ThreadPool.hpp"
#include <memory>
#include <unistd.h>

using namespace LogModule;

// 线程池应用示例 (The Driver Program)
// 职责:作为"生产者"线程,负责初始化环境、下发任务并控制整体生命周期。
int main()
{
    // 初始化日志配置:开启控制台输出策略,日志打印到显示器
    ENABLE_CONSOLE_LOG_STRATEGY();

    // 1. 创建线程池对象:
    // 使用 std::unique_ptr 管理线程池,体现了现代 C++ 的 RAII 资源管理思想
    // task_t 是在 Task.hpp 中定义的 std::function<void()> 类型擦除包装器
    std::unique_ptr<ThreadPool<task_t>> tp = std::make_unique<ThreadPool<task_t>>();

    // 2. 启动线程池:
    // 此时底层会真正创建5个(默认值) Worker 线程,并让它们进入空闲休眠状态,等待任务
    tp->Start();
    sleep(1);
    // 3. 生产过程:主线程充当生产者角色
    int cnt = 5;
    while (cnt--)
    {
        // 打印当前循环状态,方便追踪生产进度
        LOG(LogLevel::DEBUG) << "-----------------------: " << cnt;

        // 模拟生产间隔:每秒投放一个任务,让日志打印不至于瞬间刷屏,方便观察
        sleep(1);

        // 向线程池投喂任务1:打印消息任务
        // Enqueue 会自动唤醒一个正在休眠的 Worker 线程来处理
        tp->Enqueue(task1);

        sleep(1);

        // 向线程池投喂任务2:计算任务
        tp->Enqueue(task2);
    }

    // 发出停止指令:
    // 将池子的 _isrunning 设为 false,并广播唤醒所有休眠线程。
    //// 注意:此时队列里可能还有没做完的任务,线程会坚持把活儿干完再退出。
    tp->Stop();
    // 等待回收:
    // 主线程阻塞于此,直到所有 Worker 线程处理完残余任务并正常 join。
    // 这保证了程序退出时,没有任何"僵尸执行流"存在。
    tp->Join();

    // unique_ptr 离开作用域,自动析构 ThreadPool 对象,内存安全释放。
    return 0;
}

四、进阶优化:线程安全的单例模式线程池

在实际的后端开发中,线程池通常是进程内全局唯一的资源,需要用单例模式保证整个程序中只有一个线程池实例,避免资源浪费和管理混乱。

4.1 单例模式的核心要求

  1. 构造函数私有化,外部无法直接创建对象;
  2. 禁用拷贝构造和赋值运算符,防止对象拷贝破坏单例;
  3. 提供全局唯一的实例获取接口,保证实例只被创建一次;
  4. 多线程环境下保证线程安全,避免并发创建多个实例。

4.2 饿汉模式 vs 懒汉模式

  • 饿汉模式:吃完饭立刻洗碗,程序启动时就创建实例,用的时候直接拿。优点是实现简单、天然线程安全;缺点是实例初始化耗时时会拖慢程序启动速度,即使不用也会占用资源。
  • 懒汉模式 :吃完饭先不洗碗,下一顿用的时候再洗,核心是延时加载,第一次使用时才创建实例。优点是不影响程序启动速度,按需加载;缺点是多线程环境下需要解决线程安全问题。

工业级开发中,懒汉模式的使用更广泛,它不会影响服务的启动速度,符合后端服务的设计规范。

4.3 线程安全的懒汉单例线程池(双检锁 DCL)

双检锁 (Double-Check Locking, DCL)是工业界最常用的线程安全懒汉单例实现,完美平衡了安全性和性能。旧版是一个"随用随建的任务工具",单例版是一个"全局唯一的任务调度中心"

cpp 复制代码
// 线程池的封装
#ifndef THREADPOOL_HPP
#define THREADPOOL_HPP

#include <vector>
#include <queue>
#include <memory>
#include "Cond.hpp"
#include "Thread.hpp"
#include "Mutex.hpp"
#include "Log.hpp"

using namespace MutexModule;
using namespace ThreadModule;
using namespace CondModule;
using namespace LogModule;

static const int g_nums = 5;

// 线程池单例模板类
// 采用了"懒汉模式"实现,即在第一次调用 GetInstance 时才进行实例化。
template <class T>
class ThreadPool // 优化:单例模式(v2版本)
{
private:
    // 单例模式防御:私有化构造函数,杜绝外部随意创建对象
    ThreadPool(const int nums = g_nums) : _nums(nums), _isrunning(false), _sleeper_num(0)
    {
        for (int i = 0; i < nums; i++)
        {
            _threads.emplace_back(
                [this]()
                {
                    // this->HandlerTask();
                    HandlerTask();
                });
        }
    }

    // 将拷贝和赋值语句进行禁用,防止外部误操作,保证实例的唯一性
    ThreadPool(const ThreadPool<T> &tp) = delete;

    ThreadPool<T> &operator=(const ThreadPool<T> &tp) = delete;

    // 启动线程池
    void Start()
    {
        // 使用 LockGuard 确保 Start 操作的原子性
        // 防止在多线程环境下该线程池被多次重复调用 Start() 导致逻辑混乱
        LockGuard lockGuard(&_mutex);

        if (_isrunning)
            return;

        _isrunning = true;
        for (auto &thread : _threads)
        {
            thread.Start();
        }
    }

public:
    // 定义成静态的:全局唯一访问点
    // 获取单例对象的静态接口
    // 采用了"双检查锁 (Double-Checked Locking)"机制。
    static ThreadPool<T> *GetInstance()
    {
        // 第一层判断:为了提高性能。如果实例已存在,后续再调用GetInstance则直接返回,避免不必要的加锁开销。
        if (_instance == nullptr)
        {
            // 加锁:保证创建实例过程的原子性,防止多个线程同时执行 new 操作
            LockGuard lockguard(_signalton_lock);
            // 第二层判断:为了保证唯一性。只有第一个拿到锁的线程能进入if语句创建实例,
            // 期间已经在锁上等待的线程,当实例创建成功后即使拿到锁也无法在进入if语句创建实例了,直接返回。
            // 后续再调用GetInstance的线程就直接在第一个if判断结束返回,连加锁过程都不需要了,提高性能
            if (_instance == nullptr)
            {
                LOG(LogLevel::INFO) << "首次创建单例, 创建成功";
                _instance = new ThreadPool<T>(); // 只会创建一次
                _instance->Start();              // 创建出来运行一次
            }
            // 问题:为什么不直接使用前面创建的互斥锁_mutex?
            // 答:GetInstance函数是静态成员函数,不属于某个对象而是这个类
            // 静态成员函数无法访问非静态成员包括互斥锁_mutex,所以要加锁就必须额外加一个静态互斥锁
        }
        return _instance;
    }

    // 后续接口和v1版本线程池完全一样,主要优化在构造上面和部分接口的私有化

    // 生产者接口:提交任务
    void Enqueue(const T &task)
    {
        LockGuard lockguard(_mutex);

        if (!_isrunning)
            return;

        _task_queue.push(task);
        if (_sleeper_num)
        {
            _cond.Signal();
        }
    }

    // 温和关闭线程池
    void Stop()
    {
        LockGuard lockguard(_mutex);
        if (!_isrunning)
            return;

        _isrunning = false; // 将状态改为false;
        LOG(LogLevel::INFO) << "关闭线程池";

        if (_sleeper_num)
        {
            _cond.Broadcast();
        }
    }

    // 资源回收接口
    void Join()
    {
        for (auto &thread : _threads)
        {
            thread.Join();
        }
    }

    // 析构函数
    ~ThreadPool()
    {
    }

private:
    // 线程执行流入口函数,线程一旦创建就会在函数中运行 (核心死循环)
    void HandlerTask()
    {
        char name[128];
        pthread_getname_np(pthread_self(), name, sizeof(name));

        while (true)
        {
            T task; // 定义局部任务对象,用于从队列中拷贝获取任务到本地执行流
            // 临界区作用域开始:保证对任务队列的获取任务操作是原子的
            {
                LockGuard lockguard(_mutex); // 加锁保护,RAII机制确保出了这个花括号自动解锁
                // 1. 任务队列为空 && 线程处于运行状态(不退出) --> 要求线程休眠
                while (_task_queue.empty() && _isrunning)
                {
                    LOG(LogLevel::INFO) << "当前没有任务,线程 " << "|" << name << "|" << " 进行休眠";
                    _sleeper_num++;     // 进入休眠状态前,计数器自增
                    _cond.Wait(_mutex); // 核心动作:原子解锁并挂起;被唤醒后自动重新加锁
                    _sleeper_num--;     // 被唤醒后,计数器自减
                    LOG(LogLevel::INFO) << "有任务,线程 " << "|" << name << "|" << " 进行唤醒";
                }

                // 死循环的线程如何退出函数:
                //  2. 任务队列为空 && 线程池不处于运行状态(要退出) --> 要求线程退出
                if (_task_queue.empty() && !_isrunning)
                {
                    LOG(LogLevel::INFO) << "Thread: " << name << " quit, 线程池退出&&任务队列为空";
                    break;
                }

                // 3. 任务队列不为空 && 线程处于运行状态(不退出) --> 线程正常处理任务
                //    任务队列不为空 && 线程不处于运行状态(要退出) --> 线程要先处理完残留的任务才允许退出
                task = _task_queue.front();
                _task_queue.pop();
            }
            task();
        }
    }

private:
    int _nums;                    // 预设线程规模
    std::vector<Thread> _threads; // 线程"工人"管理数组
    bool _isrunning;              // 运行状态标识位,用于一些判断条件的使用

    int _sleeper_num;          // 统计当前在条件变量_cond下处于Wait状态的线程数量,用于判断是否需要唤醒线程
    Mutex _mutex;              // 互斥锁:保证队列操作的原子性
    Cond _cond;                // 条件变量:实现线程间的同步通知
    std::queue<T> _task_queue; // 任务队列:充当生产者与消费者之间的"交易场所"

    static ThreadPool *_instance; // 全局唯一实例指针
    static Mutex _signalton_lock; // 保护单例实例化的静态锁
};

// 静态成员变量在类外初始化:
// 静态指针在 main 运行前初始化为 nullptr,保证 GetInstance 的逻辑起点正确。
template <class T>
ThreadPool<T> *ThreadPool<T>::_instance = nullptr;

// 护单例实例化的静态锁初始化,自动调用Mutex构造
template <class T>
Mutex ThreadPool<T>::_signalton_lock;

#endif

双检锁核心设计解析

  • 第一重 if 判断:实例创建完成后,所有获取实例的操作都不会进入加锁逻辑,直接返回实例,避免了每次获取实例都加锁的性能开销,这是双检锁的核心优化点;
  • 加锁保护:只有实例为空时才会加锁,保证同一时间只有一个线程能进入实例创建代码块;
  • 第二重 if 判断:防止多个线程同时通过第一重 if 判断,比如线程 A 和 B 同时判断实例为空,A 先拿到锁创建了实例,B 拿到锁后如果没有第二重判断,会再次创建实例,破坏单例模式;
  • 注意事项:C++11 之前需要给 _instance 加上 volatile 关键字,防止编译器指令重排导致实例未初始化完成就被使用;C++11 及之后,静态局部变量的初始化是天然线程安全的,还有更简洁的单例实现方式。

4.4 单例线程池使用示例

cpp 复制代码
#include "Log.hpp"
#include "Task.hpp"
#include "ThreadPool.hpp"
//#include <memory>
#include <unistd.h>

using namespace LogModule;

// 线程池应用示例 (The Driver Program)
// 职责:作为"生产者"线程,负责初始化环境、下发任务并控制整体生命周期。
int main()
{
    // 初始化日志配置:开启控制台输出策略,日志打印到显示器
    ENABLE_CONSOLE_LOG_STRATEGY();

    // std::unique_ptr<ThreadPool<task_t>> tp = std::make_unique<ThreadPool<task_t>>(); // 这个就不行了
    // 为什么 unique_ptr/make_unique 不行了?
    // 答:因为在 ThreadPool_v2 中,我们将构造函数设为了 private。
    // make_unique 内部需要调用 new 来触发构造函数,而外部没有访问类中构造函数的权限。
    // 这正是单例模式的"护城河",防止了程序员在外部不小心创建出第二个池子。

    // 获取全局唯一实例
    // 第一次调用时,会在堆上申请内存初始化,并且开启线程池;
    // 后续无论调用多少次也只会直接返回同一个对象的指针,确保全局只有一套任务队列和线程组。
    ThreadPool<task_t> *tp = ThreadPool<task_t>::GetInstance();

    sleep(1);
    int cnt = 5;
    while (cnt--)
    {
        // 打印当前循环状态,方便追踪生产进度
        LOG(LogLevel::DEBUG) << "-----------------------: " << cnt;

        // 模拟生产间隔:每秒投放一个任务,让日志打印不至于瞬间刷屏,方便观察
        sleep(1);

        // 向线程池投喂任务1:打印消息任务
        // Enqueue 会自动唤醒一个正在休眠的 Worker 线程来处理
        tp->Enqueue(task1);

        sleep(1);

        // 向线程池投喂任务2:计算任务
        tp->Enqueue(task2);
    }

    // 发出停止指令:
    // 将池子的 _isrunning 设为 false,并广播唤醒所有休眠线程。
    //// 注意:此时队列里可能还有没做完的任务,线程会坚持把活儿干完再退出。
    tp->Stop();
    // 等待回收:
    // 主线程阻塞于此,直到所有 Worker 线程处理完残余任务并正常 join。
    // 这保证了程序退出时,没有任何"僵尸执行流"存在。
    tp->Join();

    return 0;
}

五、线程池背后的核心安全问题

线程池的底层是多线程的同步与互斥,只有彻底理解线程安全、死锁等核心问题,才能写出健壮的高并发代码。

5.1 线程安全与函数可重入

核心概念

  • 线程安全: 多个线程并发访问共享资源 时,程序能正确执行,不会出现数据竞争、结果异常 ,就称这个程序 / 函数线程安全的。
  • 可重入:同一个函数不同的执行流调用 ,前一个调用还未执行完,就有其他执行流再次进入,运行结果不会出现任何问题 ,这个函数就是可重入函数

联系与区别

  • 可重入函数一定是线程安全的,线程安全的函数不一定是可重入的;
  • 线程安全描述的是多线程并发访问的运行特性,可重入描述的是函数被重复调用的代码特性
  • 函数不可重入,大概率会导致线程安全问题。

常见不安全场景

  1. 不保护共享全局 / 静态变量的函数;
  2. 调用了 malloc/free、标准 I/O 库函数的函数(内部使用全局数据结构,不可重入);
  3. 返回静态变量指针的函数;
  4. 函数状态随调用发生变化的函数。

5.2 死锁:​ 多线程的头号杀手

死锁是指一组线程各自持有不会释放的资源,又互相申请对方持有的资源,导致所有线程永久阻塞等待的状态。

5.2.1 死锁的四个必要条件

死锁发生时,这四个条件必须同时满足,破坏其中任意一个,就能避免死锁:

  1. 互斥条件:一个资源同一时间只能被一个线程使用,锁的基本特性,无法破坏;

  2. 请求与保持条件 :线程申请新资源阻塞时,不释放已经持有的资源;

  3. 不剥夺条件:线程已持有的资源,在使用完之前不能被其他线程强行剥夺;

  4. 循环等待条件:多个线程形成头尾相接的循环等待资源的关系。

避免死锁的核心方法(破坏上面的四个条件中任意一个即可,互斥最简单,不用就行)

  • 破坏循环等待条件:最常用的方式,保证所有线程加锁顺序完全一致;一次性申请所有需要的资源;用 std::lock 一次性锁定多个互斥锁;
  • 破坏请求与保持条件:申请锁失败时,立即释放已持有的所有锁,使用非阻塞的 trylock 接口;
  • 代码规范:使用 RAII 风格的锁管理,避免忘记解锁;避免临界区内嵌套加锁;避免临界区内执行耗时操作、调用阻塞函数。

5.3 STL 容器与智能指针的线程安全

这是 C++ 多线程编程中最容易踩坑的点:

  • STL 容器默认不是线程安全的:STL 的设计初衷是极致的性能,加锁会带来巨大的性能开销,因此所有 STL 容器(vector、queue、map 等)都不是线程安全的。多线程并发读写同一个容器时,必须由开发者自行加锁保护,否则会出现迭代器失效、数据损坏、程序崩溃等问题。
  • 智能指针的线程安全
    • **unique_ptr:**所有权唯一,只在当前代码块内生效,天然线程安全;
    • shared_ptr: 标准库用原子操作 (CAS) 保证了引用计数的增减是原子的,因此引用计数操作是线程安全的;但指向的对象的并发访问,不是线程安全的,需要自行加锁保护。

5.4 常见锁概念拓展(补充)

  • 悲观锁 vs 乐观锁
    • 悲观锁:我们使用的互斥锁就是典型的悲观锁,每次访问数据前都先加锁,认为数据一定会被修改,适用于写多读少的场景;
    • 乐观锁:访问数据时不加锁,更新时判断数据是否被修改,通过版本号和 CAS 操作实现,适用于读多写少的场景,性能远高于悲观锁。
  • CAS 操作:Compare-And-Swap,比较并交换, 是乐观锁和无锁编程的核心。更新数据时,先判断当前内存值和之前读取的值是否相等,相等则更新,否则重试。现代 CPU 都提供了 CAS 原子指令。
  • 自旋锁:申请锁失败时,线程不会被阻塞挂起,而是循环轮询尝试获取锁。适用于临界区执行时间极短的场景,避免线程切换的开销,缺点是长时间自旋会浪费 CPU 资源。
  • 读写锁:针对读多写少场景优化的锁,读 - 读共享,写 - 写 / 读 - 写互斥。多个线程可以同时持有读锁,写锁同一时间只能被一个线程持有,大幅提升读多写少场景的并发度。

结束语

本文从池化设计思想切入,梳理了线程池整体架构逻辑,从零落地了可用于项目生产的 C++ 线程池完整代码,结合单例模式完成全局复用优化,进一步深挖线程安全、死锁成因、各类同步锁等底层重点知识,完整梳理了 Linux C++ 高并发领域线程池全套知识点。

线程池作为后端高并发开发的基础组件,本质是生产者 - 消费者模型落地到工程代码的经典案例,其设计思路可以总结为:预先创建线程来缩减任务等待耗时、集中管控线程来稳固系统运行状态、复用已有线程来削减频繁创建销毁带来的资源损耗。 线程池的底层实现离不开互斥锁、条件变量等线程同步工具,同时也要求开发者充分理解线程安全、死锁规避等核心问题。

在正式的工程项目中,线程池还会拓展诸多高级特性,例如可伸缩动态线程池、优先级任务队列、线程异常捕获、运行状态监控统计等,但其最核心的基础架构与设计思想始终固定不变。 希望本篇内容能够帮助你吃透线程池相关知识,夯实 Linux C++ 高并发编程的核心功底。

相关推荐
胖大和尚2 小时前
在C++的类中,是否可以把函数声明成__device__
c++·cuda
十年磨剑走天涯2 小时前
C++ STL 容器操作复杂度速查表
开发语言·c++
寒水馨2 小时前
Linux下载、安装protobuf-v35.1(附安装包protoc-35.1-linux-x86_64.zip)
linux·运维·服务器·google·序列化·protobuf·protoc
ziguo11222 小时前
Windows API MessageBox 函数详解
c语言·c++·windows·visualstudio
王老师青少年编程2 小时前
2023年CSP-J初赛真题及答案解析(11-15)
c++·真题·csp-j·答案·csp·初赛·信奥赛
15Moonlight2 小时前
C++进阶(09):特殊类设计
开发语言·c++
王琦03182 小时前
Linux的文件管理
linux·运维·服务器
皓月斯语2 小时前
B3867 [GESP202309 三级] 小杨的储蓄 题解
c++·算法·题解