【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 不是线程安全的

相关推荐
Zhao·o3 小时前
KafkaMQ采集指标日志
运维·中间件·kafka
wanhengidc3 小时前
云手机中都有哪些安全保护措施?
安全·智能手机
P***25393 小时前
MCP负载均衡
运维·负载均衡
SAP庖丁解码4 小时前
【SAP Web Dispatcher负载均衡】
运维·前端·负载均衡
码上上班4 小时前
ubuntu 安装ragflow
linux·运维·ubuntu
HIT_Weston4 小时前
38、【Ubuntu】【远程开发】拉出内网 Web 服务:构建静态网页(一)
linux·前端·ubuntu
百***86464 小时前
服务器部署,用 nginx 部署后页面刷新 404 问题,宝塔面板修改(修改 nginx.conf 配置文件)
运维·服务器·nginx
XH-hui4 小时前
【打靶日记】HackMyVm 之 hunter
linux·网络安全·hackmyvm·hmv
渡我白衣5 小时前
五种IO模型与非阻塞IO
运维·服务器·网络·c++·网络协议·tcp/ip·信息与通信
xu_yule5 小时前
Linux_15(多线程)线程安全+线程互斥(加锁)+死锁
linux·运维·服务器