前言

线程池是一种管理线程的机制,它可以在需要时自动创建和销毁线程,以及分配和回收线程资源。线程池的主要优点:减少了频繁创建和销毁线程所带来的开销,提高了系统的稳定性和可扩展性。此外,线程池还可以有效地控制线程的数量,避免过多线程导致的资源竞争和系统过载

线程池的概念

池化技术

所谓的 线程池 就是 提前创建一批线程,当任务来临时,线程直接从任务队列中获取任务执行,可以提高整体效率;同时一批线程会被合理维护,避免调度时造成额外开销

像这种把未来会高频使用到,并且创建较为麻烦的资源提前申请好的技术称为 池化技术池化技术 可以极大地提高性能,最典型的就是 线程池,常用于各种涉及网络连接相关的服务中,比如 MySQL 连接池HTTP 连接池、Redis 连接池 等 

除了线程池外还有内存池,比如 STL 中的容器在进行空间申请时,都是直接从 空间配置器 allocator 中获取的,并非直接使用系统调用来申请空间

池化技术 的本质空间换时间

池化技术就好比:你把钱从银行提前取出一部分放在支付宝中,可以随时使用,十分方便和高效,总不至于需要用钱时还得跑到银行排队取钱 

线程池的优点

线程池 的优点在于 高效、方便

  • 线程在使用前就已经创建好了,使用时直接将任务交给线程完成
  • 线程会被合理调度,确保 任务与线程 间能做到负载均衡

线程池 中的线程数量不是越多越好,因为线程增多会导致调度变复杂,具体创建多少线程取决于具体业务场景,比如 处理器内核、剩余内存、网络中的 socket 数量等

线程池 还可以配合 「生产者消费者模型」 一起使用,做到 解耦与提高效率

  • 可以把 任务队列 换成 「生产者消费者模型」

线程池的应用场景

  • 存在大量且短小的任务请求。比如 Web 服务器中的网页请求,使用 线程池 就非常合适,因为网页点击量众多,并且大多都没有长时间连接访问
  • 对性能要求苛刻,力求快速响应需求。比如游戏服务器,要求对玩家的操作做出快速响应
  • 突发大量请求,但不至于使服务器产生过多的线程。短时间内,在服务器创建大量线程会使得内存达到极限,造成出错,可以使用 线程池 规避问题

线程池的实现

线程池V1版

v1版:实现最基本的线程池功能,直接使用系统提供的接口

所谓v1版就是不加任何优化设计,只实现 线程池 最基础的功能,便于理解 线程池

创建 ThreadPool_v1.hpp 头文件

将 线程池 实现为一个类,提供接口供外部调用

首先要明白 线程池 的两大核心:一批线程 与 任务队列,客户端发出请求,新增任务,线程获取任务,执行任务,因此 ThreadPool_v1.hpp 的大体框架如下:

  • 一批线程,通过容器管理
  • 任务队列,存储就绪的任务
  • 互斥锁
  • 条件变量

互斥锁 的作用是 保证多个线程并访问任务队列时的线程安全,而 条件变量 可以在 任务队列 为空时,让一批线程进入等待状态,也就是线程同步

注:为了方便实现,直接使用系统调用接口及容器,比如 pthread_tvectorqueue 等

#pragma once

#include <vector>
#include <string>
#include <queue>
#include <memory>
#include <unistd.h>
#include <pthread.h>

namespace Yohifo
{
#define THREAD_NUM 10

    template<class T>
    class ThreadPool
    {
    public:
        ThreadPool(int num = THREAD_NUM)
            :_threads(num), _num(num)
        {
            // 初始化互斥锁和条件变量
            pthread_mutex_init(&_mtx, nullptr);
            pthread_cond_init(&_cond, nullptr);
        }

        ~ThreadPool()
        {
            // 互斥锁、条件变量
            pthread_mutex_destroy(&_mtx);
            pthread_cond_destroy(&_cond);
        }

        void init()
        {
            // 其他信息初始化(当前不需要)
        }

        void start()
        {
            // 启动线程池
            // ...
        }

        // 提供给线程的回调函数
        static void *threadRoutine(void *args)
        {
            // 业务处理
            // ...
        }
        
    private:
        std::vector<pthread_t> _threads;
        int _num; // 线程数量
        std::queue<T> _tasks; // 利用 STL 自动扩容的特性,无需担心容量
        pthread_mutex_t _mtx;
        pthread_cond_t _cond;
    };
}

 注意:

  • 需要提前给 vector 扩容,避免后面使用时发生越界访问
  • 提供给线程的回调函数需要设置为静态,否则线程调不动(参数不匹配)

static void *threadRoutine(void *args)为什么是静态的呢?

  • 线程入口函数要求pthread_create 要求线程入口函数必须是一个静态函数(或者全局函数),因为它需要传递一个 void* 类型的参数,这样可以传递任意类型的数据。如果函数是非静态成员函数,this 指针就会成为函数的一个隐式参数,导致编译错误。

  • 静态成员函数不包含 this 指针: 静态成员函数不像非静态成员函数那样包含 this 指针,它的调用不依赖于类的实例,因此可以被 pthread_create 函数正确调用。

  • 避免隐式 this 指针: 非静态成员函数的第一个参数是 this 指针,它表示当前对象。如果 threadRoutine 不是静态的,那么 pthread_create 就无法调用它,因为它不符合 pthread_create 对线程函数参数类型的要求。

填补函数体

初始化线程池 init() — 位于 ThreadPool 

当前场景只需要初始化 互斥锁 和 条件变量,在 构造函数 中完成就行了,所以这里的 init() 函数不需要补充

启动线程池 start() — 位于 ThreadPool  类

启动 线程池 需要先创建出一批线程,这里直接循环创建即可

void start()
{
    // 创建一批线程并启动
    for(int i = 0; i < _num; i++)
        pthread_create(&_threads[i], nullptr, threadRoutine, nullptr); // (存疑)
}

线程的回调函数 threadRoutine() — 位于 ThreadPool  类

这里进行简单测试,打印当前线程的线程 ID 就行了,并且直接 detach,主线程无需等待次线程运行结束 pthread_detach(pthread_self())  主要用于将当前线程与其父线程分离。

// 提供给线程的回调函数
static void *threadRoutine(void *args)
{
    // 避免等待线程,直接剥离
    pthread_detach(pthread_self());

    while (true)
    {
        std::cout << "Thread Running... " << pthread_self() << std::endl;
        sleep(1);
    }
}

创建 main.cc 源文件,测试线程池的代码 

#include "ThreadPool_V1.hpp"
#include <memory>

int main()
{
    std::unique_ptr<Yohifo::ThreadPool<int>> ptr(new Yohifo::ThreadPool<int>());

    ptr->init();
    ptr->start();

    // 还有后续动作

    return 0;
}

编译并运行代码,可以看到 确实创建了一批线程,当主线程退出后,其他次线程也就跟着终止了


线程池 还需要提供一个重要的接口 pushTask(),将用户需要执行的业务装载至 任务队列 中,等待线程执行

装载任务 pushTask()  — 位于 ThreadPool  类

// 装载任务
void pushTask(const T& task)
{
    // 本质上就是在生产商品,需要加锁保护
    pthread_mutex_lock(&_mtx);
    _tasks.push(task);

    // 唤醒消费者进行消费
    pthread_cond_signal(&_cond);
    pthread_mutex_unlock(&_mtx);
}

装载任务的本质就是在生产任务,相当于用户充当生产者,通过这个接口将任务生产至任务队列中,而线程充当消费者,从任务队列中获取任务并消费 

为什么装载任务的时候不让消费者去拿呢?

所以线程的回调函数需要从 任务队列 中获取任务,进行消费

  1. 检测是否有任务
  2. 有 -> 消费
  3. 没有 -> 等待

 线程回调函数 threadRoutine() — 位于 ThreadPool  类

// 提供给线程的回调函数
static void *threadRoutine(void *args)
{
    // 避免等待线程,直接剥离
    pthread_detach(pthread_self());

    while (true)
    {
        // 任务队列是临界资源,需要保护
        pthread_mutex_lock(&_mtx);

        // 等待条件满足
        while(_tasks.empty())
            pthread_cond_wait(&_cond, &_mtx);

        T task = _tasks.front();
        _tasks.pop();

        // task(); // 进行消费(存疑)

        pthread_mutex_unlock(&_mtx);
    }
}
  1. 等待任务

    • 线程首先获取互斥锁 _mtx 来保护队列 _tasks,确保线程安全。

    • 然后检查队列是否为空:如果队列为空,线程会进入等待状态,直到有新任务被生产者推送到队列中。

  2. 获取任务并消费

    • 一旦有任务,消费者线程通过 front() 获取队列中的第一个任务。

    • 接着,通过 pop() 移除任务,表示该任务已被取出并准备执行。

    • 如果注释被去掉,task() 会被执行,完成任务的消费。

  3. 释放锁

    • 完成任务消费后,消费者线程会释放互斥锁 _mtx,让其他线程可以安全地访问队列。

注意: 判断任务队列是否为空需要使用 while,确保在多线程环境中不会出现问题

因为 任务队列、互斥锁、条件变量 是类内成员,而这里的 threadRoutine()  函数是一个静态函数,并没有 this 指针以访问类内成员,可以采取传递 this 指针的方式解决问题

启动线程池 start() — 位于 ThreadPool  类

void start()
{
    // 创建一批线程并启动
    for(int i = 0; i < _num; i++)
        pthread_create(&_threads[i], nullptr, threadRoutine, this); // 传递 this 指针
}

线程回调函数 threadRoutine() — 位于 ThreadPool  类

// 提供给线程的回调函数
static void *threadRoutine(void *args)
{
    // 避免等待线程,直接剥离
    pthread_detach(pthread_self());

    auto ptr = static_cast<ThreadPool<T>*>(args);

    while (true)
    {
        // 任务队列是临界资源,需要保护
        pthread_mutex_lock(&ptr->_mtx);

        // 等待条件满足
        while(ptr->_tasks.empty())
            pthread_cond_wait(&ptr->_cond, &ptr->_mtx);

        T task = ptr->_tasks.front();
        ptr->_tasks.pop();

        //task(); // 进行消费(存疑)

        pthread_mutex_unlock(&ptr->_mtx);
    }
}

为了使得提高代码的可阅读性及可拓展性,这里将会封装一批接口,供函数调用

加锁、解锁 — 位于 ThreadPool  类

void lockQueue()
{
    pthread_mutex_lock(&_mtx);
}

void unlockQueue()
{
    pthread_mutex_unlock(&_mtx);
}

等待、唤醒 — 位于 ThreadPool  类

void lockQueue()
{
    pthread_mutex_lock(&_mtx);
}

void unlockQueue()
{
    pthread_mutex_unlock(&_mtx);
}

 判空、获取任务 — 位于 ThreadPool  类

bool isEmpty()
{
    return _tasks.empty();
}

T popTask()
{
    T task = _tasks.front();
    _tasks.pop();

    return task;
}

接口封装完毕后,可以顺便修改之前的代码,比如 装载任务 pushTask()

装载任务 pushTask() — 位于 ThreadPool  类

// 装载任务
void pushTask(const T& task)
{
    // 本质上就是在生产商品,需要加锁保护
    lockQueue();
    _tasks.push(task);

    // 唤醒消费者进行消费
    threadWakeUp();
    unlockQueue();
}

 线程回调函数 threadRoutine() — 位于 ThreadPool  类

// 提供给线程的回调函数
static void *threadRoutine(void *args)
{
    // 避免等待线程,直接剥离当前线程,使其在结束后自动清理资源
    pthread_detach(pthread_self());
    // 将传递的参数 args 转换为指向 ThreadPool<T> 对象的指针
    auto ptr = static_cast<ThreadPool<T>*>(args);
    while (true)
    {
        // 任务队列是临界资源,需要保护,使用 lockQueue 来加锁
        ptr->lockQueue();
        // 如果队列为空,等待条件变量通知
        while(ptr->isEmpty())
            ptr->threadWait(); // 阻塞等待直到有任务
        // 从队列中取出任务
        T task = ptr->popTask();
        // 解锁任务队列
        pthread_mutex_unlock(&ptr->_mtx);
        // 执行任务:假设 task 是一个可调用对象(例如函数对象或 lambda 表达式)
        // 注意:一个任务只能被一个线程消费,不需要额外加锁
        task(); // 执行任务
    }
}

细节: 轮到线程执行任务时,不需要加锁,这就好比你买桶泡面回家,是不必担心别人会和你争抢,可以慢慢消费;同样的,你也不应该占用锁资源,主动让出锁资源以提高整体效率

task() 表示执行任务,这里实际是一个 operator()() 的重载

任务类 Task.hpp

#pragma once

#include <string>

namespace Yohifo
{
    // 支持泛型
    template<class T>
    class Task
    {
    public:
        Task(T x = 0, T y = 0, char op = '+')
            :_x(x), _y(y), _op(op), _res(0), _err(0)
        {}

        // 重载运算操作
        void operator()()
        {
            // 简单计算
            switch(_op)
            {
                case '+':
                    _res = _x + _y;
                break;
                case '-':
                    _res = _x - _y;
                break;
                case '*':
                    _res = _x * _y;
                break;
                case '/':
                    if(_y == 0)
                        _err = -1;
                    else
                        _res = _x / _y;    
                break;
                case '%':
                    if(_y == 0)
                        _err = -2;
                    else
                        _res = _x % _y;    
                break;
                default:
                    _err = -3;
                break;
            }
        }

        // 获取计算结果
        std::string getResult()
        {
            // 根据错误标识,返回计算结果
            std::string ret = std::to_string(_x) + " " + _op + " " + std::to_string(_y);
            
            if(_err)
            {
                ret += " error";

                // 判读是 / 错误还是 % 错误
                if(_err == -1)
                    ret += " [-1] \t / 0 引发了错误";
                else if(_err == -2)
                    ret += " [-2] \t % 0 引发了错误";
                else
                    ret += " [-3] \t 不合法的操作符,只能为 [+-*/%]";
            }
            else
            {
                ret += " = " + std::to_string(_res);
            }

            return ret;
        }

    private:
        T _x;
        T _y;
        char _op; // 运算符
        T _res; // 结果
        int _err; // 错误标识
    };
}

轮到 main.cc 进行操作了,逻辑很简单:创建线程池对象,初始化线程池,启动线程池,装载任务,等待运行结果

补充 main.cc

#include "ThreadPool_V1.hpp"
#include <memory>

typedef Yohifo::Task<int> type;
    
int main()
{
    std::unique_ptr<Yohifo::ThreadPool<type>> ptr(new Yohifo::ThreadPool<type>());

    ptr->init();
    ptr->start();

    // 还有后续动作
    while(true)
    {
        // 输入 操作数 操作数 操作符
        int x = 0, y = 0;
        char op = '+';
        std::cout << "输入 x: ";
        std::cin >> x;
        std::cout << "输入 y: ";
        std::cin >> y;
        std::cout << "输入 op: ";
        std::cin >> op;

        // 构建任务对象
        type task(x, y, op);

        // 装载任务
        ptr->pushTask(task);
    }

    return 0;
}

 现在还有最后一个问题:如何获取计算结果?可以在 线程 执行完任务后,直接显示计算结果,也可以通过传入回调函数的方式,获取计算结果,前者非常简单,只需要在 threadRoutine() 中加入这行代码即可

线程回调函数 threadRoutine() — 位于 ThreadPool  类

void *threadRoutine(void *args)
{
	// ...

    // 显示计算结果
    std::cout << task.getResult() << std::endl;
}

除此之外,我们也可以通过 回调函数 的方式获取计算结果

目标:给线程传入一个回调函数,线程执行完任务后,将任务传给回调函数,回调函数结合业务逻辑,灵活处理结果

回调函数 callBack() — 位于 main.cc 源文件

// 回调函数
void callBack(type& task)
{
    // 获取计算结果后打印
    std::string ret = task.getResult();

    std::cout << "计算结果为: " << ret;
}

 为了能让 线程 在执行任务后能回调,需要将这个函数对象作为参数,传递给 ThreadPool  对象

源文件 main.cc

// ...

int main()
{
    std::unique_ptr<Yohifo::ThreadPool<type>> ptr(new Yohifo::ThreadPool<type>(callBack));

	// ...
}

当然,这边传递了一个对象,那边就得接收此对象,为了存储该函数对象,ThreadPool 新增一个类成员:_func,函数对象类型为 void (T&)

修改ThreadPool 头文件

// ...
#include <functional>


namespace Yohifo
{
#define THREAD_NUM 10

    template<class T>
    class ThreadPool
    {
        using func_t = std::function<void(T&)>; // 包装器

    public:
        ThreadPool(func_t func, int num = THREAD_NUM)
            :_threads(num), _num(num), _func(func)
        {
            // 初始化互斥锁和条件变量
            pthread_mutex_init(&_mtx, nullptr);
            pthread_cond_init(&_cond, nullptr);
        }
		
		// ...
		
    private:
    		// ...
   	        func_t _func;
    };
}

修改完成后,创建 ThreadPool 对象时,支持传入一个类型为 void(T&) 的函数对象

获取函数对象后,需要让 线程 在执行完任务后进行回调,但又因为这玩意是一个类内成员,同样需要借助外部传入的 this 指针进行访问,这里直接封装成一个接口,顺便进行调用

 回调函数对象 callBack() — 位于 ThreadPool 类

func_t callBack(T &task)
{
    _func(task);
}

线程回调函数 threadRoutine() — 位于 ThreadPool 类

// 提供给线程的回调函数
static void *threadRoutine(void *args)
{
		// ...

        task(); // 执行任务
        ptr->callBack(task); // 回调函数
    }
}

 做完上述准备工作后,可以进行测试

程序结果正常,不必在意打印问题,因为屏幕也是被多线程并发访问的资源,没加锁保护,导致出现问题 

线程池V2版

线程池V3版

单例模式

什么是单例模式

代码构建类,类实例化出对象,这个实例化出的对象也可以称为 实例,比如常见的 STL 容器,在使用时,都是先根据库中的类,形成一个 实例 以供使用;正常情况下,一个类可以实例化出很多很多个对象,但对于某些场景来说,是不适合创建出多个对象的

比如本文中提到的 线程池,当程序运行后,仅需一个 线程池对象 来进行高效任务计算,因为多个 线程池对象 无疑会大大增加调度成本,因此需要对 线程池类 进行特殊设计,使其只能创建一个 对象,换句话说就是不能让别人再创建对象

正如 一山不容二虎 一样,线程池 对象在一个程序中是不推荐出现多个的

在一个程序中只允许实例化出一个对象,可以通过 单例模式 来实现,单例模式 是非常 经典、常用、常考 的设计模式

什么是设计模式?
设计模式就是计算机大佬们在长时间项目实战中总结出来的解决方案,是帮助菜鸡编写高质量代码的利器,常见的设计模式有 单例模式、建造者模式、工厂模式、代理模式等 

单例模式的特点

单例模式 最大的特点就是 只允许存在一个对象(实例),这就好比现在的 一夫一妻制 一样,要是在古代,单例模式 肯定不被推崇

在很多服务器开发场景中, 经常需要让服务器加载很多的数据 (上百 GB) 到内存中,此时往往要用一个 单例 的类来管理这些数据;在我们今天的场景中,也需要一个 单例线程池 来协同生产者与消费者 

单例模式的简单实现

单例模式 有两种实现方向:饿汉 与 懒汉,它们避免类被再次创建出对象的手段是一样的:构造函数私有化、删除拷贝构造

只要外部无法访问 构造函数,那么也就无法构建对象了,比如下面这个类 Signal

#pragma once
#include <iostream>
namespace Yohifo
{
    class Signal
    {
    private:
        // 构造函数私有化
        Signal()
        {}

        // 删除拷贝构造
        Signal(const Signal&) = delete;
    };
}

当外界试图创建对象时

当然这只实现了一半,还有另一半是 创建一个单例对象,既然外部受权限约束无法创建对象那么类内是肯定可以创建对象的只需要创建一个指向该类对象的 静态指针 或者一个 静态对象,再初始化就好了因为外部无法访问该指针,所以还需要提供一个静态函数 getInstance() 以获取单例对象句柄,至于具体怎么实现,需要分不同方向(饿汉 or 懒汉)

#pragma once
#include <iostream>
namespace Yohifo
{
    class Signal
    {
    private:
        // 构造函数私有化
        Signal()
        {}
        // 删除拷贝构造
        Signal(const Signal&) = delete;
     public:
     	// 获取单例对象的句柄
        static Signal *getInstance()
        {
            return _sigptr;//返回单例实例 _sigptr 的指针。
        }
        void print()
        {
            std::cout << "Hello Signal!" << std::endl;
        }
    private:
        // 指向单例对象的静态指针
        static Signal *_sigptr;
    };
}

注意: 构造函数不能只声明,需要实现,即使什么都不写 

为什么要删除拷贝构造?

= delete 是 C++11 引入的特性,用于显式地禁止某个函数的使用。

如果不删除拷贝构造,那么外部可以借助拷贝构造函数,拷贝构造出一个与 单例对象 一致的 “对象”,此时就出现两个对象,这是不符合 单例模式 特点的

如果 getInstance 不是静态方法会怎样?

1.无需创建对象实例即可访问

在单例模式中,Signal 类应该有且仅有一个实例,且该实例应该通过类提供的一个全局访问点来获取。静态方法 是在没有类的实例时就能调用的,因此,静态方法可以通过类名直接访问,而不需要先创建一个 Signal 对象。

假设你没有将 getInstance() 声明为静态方法,调用它时就必须先创建一个 Signal 对象(即必须先实例化一个对象),这与单例模式的设计理念相违背。

2.静态方法和静态成员配合使用

静态方法和静态成员变量通常一起使用。在单例模式中,getInstance() 方法返回的是一个指向静态成员 _sigptr 的指针,这个成员 _sigptr 是存储唯一实例的静态指针。静态方法可以直接访问静态成员,因此需要将 getInstance() 声明为静态方法。

 为什么非要用静态指针,用普通的指针行吗?

饿汉模式 

张三总是很饿,尽管饭菜还没准备好,他就已经早早的把碗洗好了,等到开饭时,直接开干

饿汉模式 也是如此,在程序加载到内存时,就已经早早的把 单例对象 创建好了(此时程序服务还没有完全启动),也就是在外部直接通过 new 实例化一个对象,具体实现如下

使用静态指针

#pragma once

#include <iostream>

namespace Yohifo
{
    // 饿汉模式
    class Signal
    {
    private:
        // 构造函数私有化
        Signal()
        {}

        // 删除拷贝构造
        Signal(const Signal&) = delete;

    public:
        static Signal *getInstance()
        {
            return _sigptr;
        }

        void print()
        {
            std::cout << "Hello Signal!" << std::endl;
        }

    private:
        // 指向单例对象的静态指针
        static Signal *_sigptr;
    };

    Signal* Signal::_sigptr = new Signal();
}

在程序加载时,该对象会被创建

这里的 单例对象 本质就有点像 全局变量,在程序加载时就已经创建好了

外部可以直接通过 getInstance()  获取 单例对象 的操作句柄,来调用类中的其他函数

#include <iostream>
#include "Signal.hpp"

int main()
{
    Yohifo::Signal::getInstance()->print();

    return 0;
}

 这就实现了一个简单的 饿汉版单例类,除了创建 static Signal* 静态单例对象指针 外,也可以直接定义一个 静态单例对象,生命周期随进程,不过要注意的是:getInstance()  需要返回的也是该静态单例对象的地址,不能返回值,因为拷贝构造被删除了;并且需要在类的外部初始化该静态单例对象

使用静态对象

#pragma once

#include <iostream>

namespace Yohifo
{
    // 饿汉模式
    class Signal
    {
    private:
        // 构造函数私有化
        Signal()
        {}

        // 删除拷贝构造
        Signal(const Signal&) = delete;

    public:
        static Signal *getInstance()
        {
            return &_sig;
        }

        void print()
        {
            std::cout << "Hello Signal!" << std::endl;
        }

    private:
        // 静态单例对象
        static Signal _sig;
    };

    // 初始化
    Signal Signal::_sig;
}

饿汉模式 是一个相对简单的单例实现方向,只需要在类中声明,在类外初始化就行了,但它也会带来一定的弊端:延缓服务启动速度

  • 完全启动服务是需要时间的,创建 单例对象 也是需要时间的,饿汉模式 在服务正式启动前会先创建对象,但凡这个单例类很大,服务启动时间势必会受到影响,大型项目启动,时间就是金钱
  • 并且由于 饿汉模式 每次都会先创建 单例对象,再启动服务,如果后续使用 单例对象 还好说,但如果后续没有使用 单例对象,那么这个对象就是白创建了,在延缓服务启动的同时造成了一定的资源浪费

综上所述,饿汉模式 不是很推荐使用,除非图实现简单,并且服务规模较小;既然 饿汉模式 有缺点,就需要改进,于是就出现了 懒汉模式

懒汉模式

李四也是个很饿的人,他也有一个自己的碗,吃完饭后碗会脏,但他不像张三那样极端,李四比较懒,只有等他吃饭的时候,他才会去洗碗,李四这种做法让他感到无比轻松

懒汉方式最核心的思想是 "延时加载". 从而能够优化服务器的启动速度 

  • 例子1:我们申请内存的时候,首先是在地址空间上申请,而当我们真正要使用的时候,才会发生缺页中断从而建立虚拟地址和物理地址的映射关系!! 
  • 例子2:我们打开文件的时候,文件的属性必然会先被加载起来,但是文件的内容则是我们需要使用的时候才会加载进来!!

在 懒汉模式 中,单例对象 并不会在程序加载时创建,而是在第一次调用时创建,第一次调用创建后,后续无需再创建,直接使用即可

#pragma once

#include <iostream>

namespace Yohifo
{
    // 懒汉模式
    class Signal
    {
    private:
        // 构造函数私有化
        Signal()
        {}

        // 删除拷贝构造
        Signal(const Signal&) = delete;

    public:
        static Signal *getInstance()
        {
            // 第一次调用才创建
            if(_sigptr == nullptr)
            {
                _sigptr = new Signal();
            }

            return _sigptr;
        }

        void print()
        {
            std::cout << "Hello Signal!" << std::endl;
        }

    private:
        // 静态指针
        static Signal *_sigptr;
    };

    // 初始化静态指针
    Signal* Signal::_sigptr = nullptr;
}

 注意: 此时的静态指针需要初始化为 nullptr ,方便第一次判断

饿汉模式 中出现的问题这里全都避免了

  • 创建耗时 -> 只在第一次使用时创建
  • 占用资源 -> 如果不使用,就不会被创建

懒汉模式 的核心在于 延时加载,可以优化服务器的速度及资源占用

这样看来,懒汉模式 确实优秀,实现起来也不麻烦,为什么会说 饿汉模式 更简单呢?

这是因为当前只是单线程场景,程序暂时没啥问题,如果当前是多线程场景,问题就大了,如果一批线程同时调用 getInstance(),同时认定 _sigptr 为空,就会创建多个 单例对象,这是不合理的

也就是说当前实现的 懒汉模式 存在严重的线程安全问题

如何证明懒汉模式会存在线程安全问题呢?

简单改一下代码,每创建一个单例对象,就打印一条语句,将代码放入多线程环境中测试 

获取单例对象句柄 getInstance() — 位于 Signal 类 

static Signal *getInstance()
{
    // 第一次调用才创建
    if(_sigptr == nullptr)
    {
        std::cout << "创建了一个单例对象" << std::endl;
        _sigptr = new Signal();
    }

    return _sigptr;
}

其中 main函数 使用了 lambda表达式 来作为线程的回调函数,重点在于查看现象 

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

int main()
{
    // 创建一批线程
    pthread_t arr[10];
    for(int i = 0; i < 10; i++)
    {
        pthread_create(arr + i, nullptr, [](void*)->void*
            {
                // 获取句柄
                auto ptr = Yohifo::Signal::getInstance();
                ptr->print();
                return nullptr;
            }, nullptr);
    }

    for(int i = 0; i < 10; i++)
        pthread_join(arr[i], nullptr);

    return 0;
}

运行结果如下:

当前代码在多线程环境中,同时创建了多个 单例对象,因此是存在线程安全问题的

饿汉模式没有线程安全问题吗?

没有,因为饿汉模式下,单例对象一开始就被创建了,即便是多线程场景中,也不会创建多个对象,它们也做不到

懒汉模式(线程安全版)

有问题就解决,解决多线程并发访问的利器是 互斥锁,那就创建 互斥锁 保护单例对象的创建

#pragma once

#include <iostream>
#include <mutex>

namespace Yohifo
{
    // 懒汉模式
    class Signal
    {
    private:
        // 构造函数私有化
        Signal()
        {}

        // 删除拷贝构造
        Signal(const Signal&) = delete;

    public:
        static Signal *getInstance()
        {
            // 加锁保护
            pthread_mutex_lock(&_mtx);
            if(_sigptr == nullptr)
            {
                std::cout << "创建了一个单例对象" << std::endl;
                _sigptr = new Signal();
            }
            pthread_mutex_unlock(&_mtx);

            return _sigptr;
        }

        void print()
        {
            std::cout << "Hello Signal!" << std::endl;
        }

    private:
        // 静态指针
        static Signal *_sigptr;
        static pthread_mutex_t _mtx;
    };

    // 初始化静态指针
    Signal* Signal::_sigptr = nullptr;

    // 初始化互斥锁
    pthread_mutex_t Signal::_mtx = PTHREAD_MUTEX_INITIALIZER;
}

互斥锁不是静态的行不? 

答案是不行的。如果锁不是静态的,问题就会变得复杂,因为每个线程都会创建一个独立的锁实例

  • 锁的作用是确保在多线程环境下,只有一个线程可以访问共享资源(比如单例对象的创建)。如果锁不是静态的,那么每个线程都会拥有它自己的锁对象。

所以必须要把锁也定义成静态锁

依旧是借助之前的多线程场景,测试一下改进后的 懒汉模式 代码有没有问题

结果是没有问题,单例对象 也只会创建一个

现在还面临最后一个问题:效率问题

当前代码确实能保证只会创建一个 单例对象,但即使后续不会创建 单例对象,也需要进行 加锁、判断、解锁 这个流程,要知道 加锁 也是有资源消耗的,所以这种写法不妥

DoubleCheck 双检查加锁

在 加锁 前再增加一层判断,如此一来,N 个线程,顶多只会进行 1 次 加锁与解锁,这是非常优雅的解决方案

获取静态对象句柄 getInstance() — 位于 signal 类

static Signal *getInstance()
{
    // 双检查
    if(_sigptr == nullptr)
    {
        // 加锁保护
        pthread_mutex_lock(&_mtx);
        if(_sigptr == nullptr)
        {
            std::cout << "创建了一个单例对象" << std::endl;
            _sigptr = new Signal();
        }
        pthread_mutex_unlock(&_mtx);
    }

    return _sigptr;
}

单纯的 if 判断并不会消耗很多资源,但 加锁 行为会消耗资源,延缓程序运行速度,双检查加锁 可以有效避免这个问题 

所以 懒汉模式 麻烦吗?
相比于 饿汉模式,确实挺麻烦的,不仅要判断后创建 单例对象,还需要考虑线程安全问题

值得一提的是,懒汉模式 还有一种非常简单的写法:调用  getInstance() 时创建一个静态单例对象并返回,因为静态单例对象只会初始化一次,所以是可行的,并且在 C++11 之后,可以保证静态变量初始化时的线程安全问题,也就不需要 双检查加锁 了,实现起来非常简单

#pragma once

#include <iostream>
#include <mutex>

namespace Yohifo
{
    // 懒汉模式
    class Signal
    {
    private:
        // 构造函数私有化
        Signal()
        {}

        // 删除拷贝构造
        Signal(const Signal&) = delete;

    public:
        static Signal *getInstance()
        {
            // 静态单例对象,只会初始化一次,并且生命周期随进程
            static Signal _sig;

            return &_sig;
        }

        void print()
        {
            std::cout << "Hello Signal!" << std::endl;
        }
    };
}

结果也是正常的 

 所以如果当前的生产环境所支持的c++版本为 C++11 及以后,在实现 懒汉模式 时可以选择这种简便的方式,是非常不错的;如果为了兼容性,也可以选择传统写法

注意: 静态变量创建时的线程安全问题,在 C++11 之前是不被保障的

new出来的单例对象不需要销毁吗?

这个单例对象生成周期随进程,进程结束了,资源也就都被销毁了,如果想手动销毁,可以设计一个垃圾回收内部类 GC ,主动去销毁单例对象

线程池_V4最终版

周边问题

总结

更多推荐