【Linux】多线程核心速记:线程池 + 单例模式 + 线程安全 + 死锁 + 智能指针

前言:欢迎 各位光临本博客,这里小编带你直接手撕**,文章并不复杂,愿诸君**耐其心性,忘却杂尘,道有所长!!!!

IF'Maxue个人主页
🔥 个人专栏 :
《C语言》
《C++深度学习》
《Linux》
《数据结构》
《数学建模》

⛺️生活是默默的坚持,毅力是永久的享受。不破不立!

文章目录

    • 深入理解线程池与单例模式:线程安全的设计与实践
      • 一、线程池的退出机制
        • [1. 线程的三种状态](#1. 线程的三种状态)
        • [2. 线程池的退出条件](#2. 线程池的退出条件)
        • [3. 唤醒休眠线程](#3. 唤醒休眠线程)
        • [4. 线程池的暂停与恢复](#4. 线程池的暂停与恢复)
        • [5. 线程池的完整生命周期](#5. 线程池的完整生命周期)
        • [6. 线程池的使用示例](#6. 线程池的使用示例)
      • 二、单例模式:确保唯一实例
        • [1. 单例模式的应用场景](#1. 单例模式的应用场景)
        • [2. 饿汉式实现](#2. 饿汉式实现)
        • [3. 懒汉式实现(线程不安全)](#3. 懒汉式实现(线程不安全))
        • [4. 线程安全的懒汉式单例(双检锁模式)](#4. 线程安全的懒汉式单例(双检锁模式))
        • [5. 单例模式的使用](#5. 单例模式的使用)
        • [6. 单例模式的核心思想](#6. 单例模式的核心思想)
      • 三、线程安全与重入
        • [1. 线程安全 (Thread-Safe)](#1. 线程安全 (Thread-Safe))
        • [2. 可重入 (Reentrant)](#2. 可重入 (Reentrant))
        • [3. 两者关系](#3. 两者关系)
      • 四、死锁 (Deadlock)
        • [1. 死锁的定义](#1. 死锁的定义)
        • [2. 死锁的例子](#2. 死锁的例子)
        • [3. 死锁的四个必要条件](#3. 死锁的四个必要条件)
        • [4. 如何避免死锁](#4. 如何避免死锁)
      • 五、STL、智能指针与线程安全
        • [1. STL容器的线程安全](#1. STL容器的线程安全)
        • [2. 智能指针的线程安全](#2. 智能指针的线程安全)

深入理解线程池与单例模式:线程安全的设计与实践

在多线程编程中,线程池和单例模式是两个非常基础且重要的概念。线程池用于高效地管理和复用线程,而单例模式则确保一个类只有一个实例。本文将结合代码和图示,详细解析线程池的退出机制、单例模式的实现以及线程安全相关的问题。


一、线程池的退出机制

线程池中的线程在运行过程中会处于不同的状态,当我们需要关闭线程池时,必须妥善处理这些线程,以确保程序的健壮性和资源的正确释放。

1. 线程的三种状态

线程池中的线程主要有以下三种状态:

  • 等待状态 (Waiting):线程正在等待任务队列中有新的任务到来。
  • 等待唤醒状态 (Waiting to be awakened):线程因某些条件(如任务队列空)而被阻塞,需要等待一个信号来唤醒。
  • 处理任务状态 (Processing a task):线程正在执行一个具体的任务。
2. 线程池的退出条件

一个设计良好的线程池,其退出应该满足以下核心条件:

  1. 线程池已发出退出信号 (例如,设置一个 is_running 标志为 false)。
  2. 任务队列已为空

只有当这两个条件同时满足时,线程池中的所有线程才能安全地退出。

如上图所示,线程在循环中检查这两个条件。如果线程池正在运行(is_runningtrue)且任务队列为空,线程会进入等待状态。

3. 唤醒休眠线程

当我们决定停止线程池时,仅仅设置 is_runningfalse 是不够的。因为此时可能有多个线程正处于等待(休眠)状态,它们永远不会再去检查 is_running 标志。

因此,在停止线程池时,必须主动唤醒所有正在等待的线程。这通常通过条件变量(Condition Variable)的 notify_all() 方法来实现。

被唤醒的线程会再次检查退出条件。此时 is_runningfalse,并且如果任务队列也为空,线程就会执行退出逻辑。

4. 线程池的暂停与恢复

除了退出,线程池有时也需要支持暂停和恢复功能。

  • 暂停:当线程池暂停时,正在执行任务的线程应该继续执行完当前任务,而处于等待状态的线程则应该保持休眠,不再获取新任务。
  • 恢复:恢复操作则是唤醒所有等待的线程,让它们可以继续从任务队列中获取任务执行。

实现暂停和恢复的关键在于,在线程的主循环中增加对"暂停"标志的检查。

如上图所示,线程在每次循环开始时,会先检查是否处于暂停状态,如果是,则等待恢复信号。

5. 线程池的完整生命周期

将上述逻辑整合起来,线程池的完整生命周期管理(运行、暂停、恢复、停止)可以用下面的流程图来表示。

6. 线程池的使用示例

下面是一个简单的 main 函数,展示了如何创建和使用线程池。

cpp 复制代码
#include "ThreadPool.h"
#include <iostream>
#include <chrono>

void task_func(int id) {
    std::cout << "Thread " << std::this_thread::get_id() << " is processing task " << id << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(1)); // 模拟任务执行
    std::cout << "Task " << id << " completed." << std::endl;
}

int main() {
    // 创建一个拥有4个线程的线程池
    ThreadPool pool(4);

    // 向线程池添加10个任务
    for (int i = 0; i < 10; ++i) {
        pool.add_task(task_func, i);
    }

    std::cout << "All tasks have been added to the pool." << std::endl;

    // 主线程等待一段时间,让任务有机会执行
    std::this_thread::sleep_for(std::chrono::seconds(3));

    std::cout << "Pausing the thread pool..." << std::endl;
    pool.pause(); // 暂停线程池

    // 暂停期间添加的任务会被放入队列,但不会被执行
    pool.add_task(task_func, 100);
    pool.add_task(task_func, 101);

    std::this_thread::sleep_for(std::chrono::seconds(2));

    std::cout << "Resuming the thread pool..." << std::endl;
    pool.resume(); // 恢复线程池

    std::this_thread::sleep_for(std::chrono::seconds(2));

    std::cout << "Stopping the thread pool..." << std::endl;
    pool.stop(); // 停止线程池

    std::cout << "Main thread exiting." << std::endl;

    return 0;
}

二、单例模式:确保唯一实例

单例模式是一种创建型设计模式,它保证一个类在整个应用程序中只有一个实例,并提供一个全局访问点来获取这个实例。

1. 单例模式的应用场景

单例模式通常用于管理共享资源,例如:

  • 配置文件管理器:整个程序只需要一个配置实例。
  • 日志系统:所有日志输出都应该通过同一个日志实例。
  • 数据库连接池:全局只需要一个连接池来管理所有数据库连接。
  • 线程池:如我们上一节所述,线程池通常也设计为单例。
2. 饿汉式实现

饿汉式单例在类被加载时就立即创建实例。

优点

  • 实现简单。
  • 天然是线程安全的,因为实例在程序启动时就已创建,不存在多线程竞争。

缺点

  • 无论实例是否被使用,它都会被创建,可能会造成资源浪费。
3. 懒汉式实现(线程不安全)

懒汉式单例在第一次被使用时才创建实例,实现了"延迟加载"。

优点

  • 节约资源,实例在需要时才创建。

缺点

  • 实现相对复杂,尤其是在多线程环境下。
  • 非线程安全。

问题分析 :当多个线程同时调用 getInstance() 方法时,都可能通过 instance == nullptr 的检查,从而创建多个实例,这违反了单例模式的核心原则。

4. 线程安全的懒汉式单例(双检锁模式)

为了解决懒汉式的线程安全问题,我们需要引入同步机制。

实现步骤

  1. 私有化构造函数、拷贝构造函数和赋值运算符 ,防止外部创建新实例或拷贝。

    cpp 复制代码
    private:
        ThreadPool() {}
        ThreadPool(const ThreadPool&) = delete;
        ThreadPool& operator=(const ThreadPool&) = delete;
  2. 使用静态指针 instance 来持有唯一实例。

  3. 使用静态互斥锁 mutex 来保护实例的创建过程。

  4. 双重检查锁定(Double-Checked Locking, DCL)

    • 第一次检查 instance 是否为 nullptr,如果不为空,直接返回,避免每次都加锁,提高性能。
    • 如果为空,则加锁。
    • 加锁后,再次检查 instance 是否为 nullptr。这是因为在第一次检查和加锁之间,可能有另一个线程已经创建了实例。
    • 第二次检查通过后,才创建实例。
5. 单例模式的使用

一旦单例模式实现,就可以在程序的任何地方通过 getInstance() 方法来获取唯一的实例。

cpp 复制代码
#include "ThreadPool.h"

int main() {
    // 获取线程池的唯一实例
    ThreadPool& pool = ThreadPool::getInstance();

    // 使用线程池
    pool.add_task(...);

    // ...

    return 0;
}
6. 单例模式的核心思想

总结一下,单例模式的核心就是通过一系列机制,强制一个类只能有一个实例,并提供一个统一的访问入口。

当线程池本身被设计为单例时,多线程环境下对它的访问就变得非常自然和安全。


三、线程安全与重入

在多线程编程中,"线程安全"和"可重入"是两个紧密相关但又有所区别的概念。

1. 线程安全 (Thread-Safe)

一个函数或数据结构被称为线程安全的,如果它可以被多个线程同时调用或访问,而不会导致数据竞争、死锁或其他未定义行为。

简单来说:多个线程同时操作同一个对象或函数,结果是正确的、可预测的。

例如,一个线程安全的计数器,多个线程同时调用 increment() 方法,最终的计数值应该是正确的累加结果。要实现线程安全,通常需要使用互斥锁(mutex)来保护临界区代码。

在单例模式的 getInstance() 方法中,我们使用互斥锁来确保实例创建的线程安全。

2. 可重入 (Reentrant)

一个函数被称为可重入的,如果它可以被同一个执行流(线程)在没有完成第一次调用的情况下再次调用,并且不会产生错误。

简单来说:函数可以"打断自己",然后安全地再次进入。

可重入性通常要求函数不使用任何非const的静态或全局变量,所有状态都通过参数传递或存储在调用栈上。

3. 两者关系
  • 一个可重入的函数不一定是线程安全的。例如,一个函数不使用全局变量,但使用了一个共享的、非线程安全的数据结构。它可以被同一个线程重入,但不能被多个线程同时调用。
  • 一个线程安全的函数不一定是可重入的。例如,一个函数使用了全局锁。多个线程可以同时调用它(因为锁保证了互斥),但同一个线程如果在持有锁的情况下再次调用自己,就会导致死锁,因此它不是可重入的。
  • 一个函数可以既是线程安全的,又是可重入的。这通常是最高的要求,意味着函数的设计非常健壮。

结论:可重入性是比线程安全性更严格的要求。


四、死锁 (Deadlock)

死锁是多线程编程中一个经典且棘手的问题。

1. 死锁的定义

死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的状态。若无外力作用,它们都将无法继续执行下去。

2. 死锁的例子

一个经典的例子是"哲学家进餐问题"。这里我们用一个更简单的场景来说明:

假设有两个线程,线程A和线程B,它们都需要同时持有锁1和锁2才能完成任务。

  • 线程A成功获取了锁1。
  • 几乎同时,线程B成功获取了锁2。
  • 接下来,线程A尝试获取锁2,但锁2已被线程B持有,所以线程A阻塞。
  • 线程B尝试获取锁1,但锁1已被线程A持有,所以线程B也阻塞。

此时,线程A和线程B都在等待对方释放自己需要的锁,它们将永远等待下去,这就是死锁。

另一个生活中的例子:

3. 死锁的四个必要条件

死锁的发生必须同时满足以下四个条件,缺一不可:

  1. 互斥条件:资源必须是互斥使用的,即同一时间只能有一个线程占用。
  2. 请求与保持条件:线程已经持有了至少一个资源,但又提出了新的资源请求,而这个资源已被其他线程占用。
  3. 不可剥夺条件:线程已获得的资源在未使用完之前,不能被其他线程强行剥夺。
  4. 循环等待条件:存在一个线程等待序列,使得每个线程都在等待下一个线程所持有的资源,形成一个闭环。
4. 如何避免死锁

避免死锁的关键在于破坏死锁的四个必要条件中的任意一个。在实践中,最常用且最有效的方法是破坏"循环等待条件"

方法:按序申请资源

规定所有线程在获取多个资源时,必须按照一个固定的、全局一致的顺序来申请。

在上面的例子中,如果我们规定"必须先获取锁1,再获取锁2":

  • 线程A获取锁1。
  • 线程B尝试获取锁1,但锁1已被线程A持有,所以线程B阻塞。
  • 线程A获取锁2,执行任务,完成后释放锁2和锁1。
  • 线程B被唤醒,成功获取锁1,然后获取锁2,执行任务。

这样就避免了死锁。

其他避免死锁的方法还包括:

  • 超时等待:当线程尝试获取锁时,设定一个超时时间。如果在超时时间内没有获取到锁,就放弃并释放已持有的资源,然后重试。
  • 银行家算法:一种比较复杂的算法,用于在分配资源前进行安全性检查,确保不会进入死锁状态。

五、STL、智能指针与线程安全

C++标准库(STL)和智能指针在多线程环境下的行为是一个常见的误区。

1. STL容器的线程安全

结论:STL容器本身不是线程安全的。

这意味着:

  • 你不能从多个线程同时对同一个容器进行写入操作(如 push_back, erase, operator[] 等)。
  • 你也不能在一个线程写入的同时,从另一个线程读取。

如果你需要在多线程中共享一个STL容器,你必须自己提供同步机制,例如使用 std::mutex 来保护对容器的所有访问。

2. 智能指针的线程安全

智能指针的线程安全特性与STL容器不同,需要具体分析。

  • std::shared_ptr:

    • 引用计数的修改是线程安全的 。也就是说,多个线程可以同时对同一个 shared_ptr 对象进行拷贝、赋值或销毁操作,这会原子地增加或减少其内部的引用计数。
    • 指向对象的访问不是线程安全的 。如果你有多个线程通过 shared_ptr 访问同一个对象,你仍然需要自己保证对该对象操作的线程安全。
  • std::unique_ptr:

    • 由于 unique_ptr 不允许拷贝,其所有权的转移是通过 std::move 实现的。
    • 它的线程安全特性类似于一个普通的指针。你不应该从多个线程同时对同一个 unique_ptr 对象进行操作(如 reset, release 或移动赋值)。

总结

  • 不要假设STL容器是线程安全的,必须手动加锁保护。
  • std::shared_ptr 的引用计数是线程安全的,但它指向的对象不是。
  • std::unique_ptr 不是线程安全的

相关推荐
Johny_Zhao13 小时前
OpenClaw安装部署教程
linux·人工智能·ai·云计算·系统运维·openclaw
用户9623779544818 小时前
DVWA 靶场实验报告 (High Level)
安全
数据智能老司机1 天前
用于进攻性网络安全的智能体 AI——在 n8n 中构建你的第一个 AI 工作流
人工智能·安全·agent
数据智能老司机1 天前
用于进攻性网络安全的智能体 AI——智能体 AI 入门
人工智能·安全·agent
用户962377954481 天前
DVWA 靶场实验报告 (Medium Level)
安全
red1giant_star1 天前
S2-067 漏洞复现:Struts2 S2-067 文件上传路径穿越漏洞
安全
用户962377954481 天前
DVWA Weak Session IDs High 的 Cookie dvwaSession 为什么刷新不出来?
安全
YuMiao1 天前
gstatic连接问题导致Google Gemini / Studio页面乱码或图标缺失问题
服务器·网络协议
chlk1232 天前
Linux文件权限完全图解:读懂 ls -l 和 chmod 755 背后的秘密
linux·操作系统
舒一笑2 天前
Ubuntu系统安装CodeX出现问题
linux·后端