🔥个人主页:爱和冰阔乐
📚专栏传送门:《数据结构与算法》 、C++、 《Linux操作系统》
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然
🏠博主简介
文章目录
- 前言
- 一、单例模式
- 1.1 什么是单例模式
- 1.2 饿汉模式和懒汉模式
- 1.3 饿汉模式的实现
- 1.4 懒汉模式的实现
- 二、线程池改成单例模式
- 2.1 为什么线程池适合做成单例
- 2.2 保护第一次创建
- 2.3 为什么要再次判断
- 三、线程安全与可重入
- 3.1 线程安全讨论什么
- 3.2 什么叫重入
- 3.3 加锁以后为什么还可能卡住
- 四、死锁是怎样产生的
- 4.1 死锁的概念
- 4.2 用一个生活例子理解
- 五、死锁的四个必要条件
- 5.1 互斥条件
- 5.2 请求与保持条件
- 5.3 不可剥夺条件
- 5.4 循环等待条件
- 六、怎样避免死锁
- 6.1 统一加锁顺序
- 6.2 申请失败后主动释放
- 6.3 一次申请需要的资源
- 6.4 死锁检测与避免算法
- 七、STL 与智能指针的线程安全
- 7.1 STL 容器
- 7.2 `unique_ptr`
- 7.3 `shared_ptr`
- 八、其他常见的锁与并发方式
- 8.1 悲观锁
- 8.2 乐观锁
- 8.3 CAS
- 8.4 自旋锁和读写锁
- 总结
前言
上一篇已经把日志和固定线程池写完了。线程池本身只有一份任务队列和一组工作线程,如果在程序中反复创建多个线程池,不但没有必要,还会增加线程数量和管理成本。因此,线程池很适合使用单例模式管理。
不过,单例并不是把构造函数改成私有就结束了。懒汉模式第一次创建对象时,如果多个线程同时进入获取接口,仍然可能创建出多个对象。继续往后看,还会遇到线程安全、函数重入和死锁这些容易混在一起的问题。
这一篇就顺着线程池继续往下写:先完成单例模式,再说明线程安全和可重入的区别,最后整理死锁、STL、智能指针以及几种常见的锁。
一、单例模式
1.1 什么是单例模式
普通类可以根据需要创建多个对象,单例模式则要求一个类在进程中只保留一个实例,并提供统一的访问接口。
例如,程序中的配置对象保存的是整个工程都要使用的配置,同一份配置没有必要创建多次。线程池也是一样:任务统一放入一份任务队列,再由固定的一组线程处理即可。若不同模块各自创建线程池,线程数量会越来越多,任务调度也会变得混乱。
单例模式主要从语法上限制对象的创建。通常需要处理三件事:
- 把构造函数设为私有,禁止用户直接创建对象;
- 禁止拷贝构造和赋值,避免复制出新的对象;
- 在类中提供静态接口,返回唯一实例。
classThreadPool{public:ThreadPool(constThreadPool&)=delete;ThreadPool&operator=(constThreadPool&)=delete;private:ThreadPool()=default;};只隐藏构造函数还不够,我们还需要决定:这个唯一对象应该在什么时候创建。
1.2 饿汉模式和懒汉模式
这两个名字看起来有点抽象,可以先用洗碗来理解。
吃完饭马上把碗洗好,下次吃饭时直接使用,这种方式就是饿汉模式;吃完饭先把碗放着,等下一次要用时再洗,这种方式就是懒汉模式。
放到程序中也是一样:
- 饿汉模式:程序启动时就把对象创建好,后面可以直接获取;
- 懒汉模式:程序启动时先不创建,第一次真正使用时再创建。
两种方式没有谁一定更好。对象很小、初始化也很简单时,提前创建不会带来明显影响;对象较大或者初始化过程比较复杂时,延迟到真正使用时再创建更合适。
1.3 饿汉模式的实现
饿汉模式可以直接把唯一对象定义成类的静态成员。静态成员属于类,不属于某一个普通对象,因此不需要先创建类对象就能访问。
classThreadPool{public:staticThreadPool*GetInstance(){return&_instance;}ThreadPool(constThreadPool&)=delete;ThreadPool&operator=(constThreadPool&)=delete;private:ThreadPool()=default;staticThreadPool _instance;};ThreadPool ThreadPool::_instance;程序进入运行阶段以前,静态对象已经完成创建。调用GetInstance时只需要返回它的地址,不存在第一次调用时多个线程同时创建对象的问题。
饿汉模式的缺点也很明显:无论这个对象后面是否会被使用,它都会提前完成初始化。若对象占用资源较多,或者初始化依赖其他模块,程序启动阶段就要承担这部分成本。
1.4 懒汉模式的实现
懒汉模式先保存一个空指针,第一次调用GetInstance时再创建对象:
classThreadPool{public:staticThreadPool*GetInstance(){if(_instance==nullptr){_instance=newThreadPool;}return_instance;}ThreadPool(constThreadPool&)=delete;ThreadPool&operator=(constThreadPool&)=delete;private:ThreadPool()=default;staticThreadPool*_instance;};ThreadPool*ThreadPool::_instance=nullptr;这段代码在单线程中没有问题,但放到多线程环境中就不安全了。
假设线程 A 判断_instance == nullptr后被切走,线程 B 此时也进入接口,同样发现指针为空。接下来两个线程都会执行new T,最终创建出两个对象。其中一个地址还会被后一次赋值覆盖,造成内存泄漏。
所以,懒汉模式真正需要保护的是第一次创建对象的过程。
二、线程池改成单例模式
2.1 为什么线程池适合做成单例
固定线程池内部已经维护了任务队列、互斥锁、条件变量和若干工作线程。大多数程序只需要一个公共线程池,其他模块取得同一个线程池对象,再向其中推送任务即可。
如果只有main线程提交任务,可以看成单生产者、多消费者;如果多个业务线程都会提交任务,就变成多生产者、多消费者。线程池内部的任务队列虽然已经加锁,但获取线程池对象本身也必须是安全的,否则第一次获取时仍可能创建出多个线程池。
2.2 保护第一次创建
最直接的做法是在获取单例时加锁:
template<classT>classThreadPool{public:staticThreadPool<T>*GetInstance(){LockGuardlock_guard(_lock);if(_instance==nullptr){LOG(LogLevel::DEBUG)<<"首次使用线程池,开始创建";_instance=newThreadPool<T>();_instance->Start();}return_instance;}ThreadPool(constThreadPool&)=delete;ThreadPool&operator=(constThreadPool&)=delete;private:ThreadPool()=default;private:staticThreadPool<T>*_instance;staticMutex _lock;};互斥锁和单例指针都要定义成静态成员。原因很简单:调用GetInstance时对象还没有创建,不能依赖某个对象内部的普通成员锁,只能由类本身保存这把锁。
多个线程同时进入接口时,只有一个线程能先拿到锁并创建线程池。后面的线程拿到锁以后再次检查_instance,发现对象已经存在,直接返回即可。
2.3 为什么要再次判断
第一次判断只能说明线程进入接口时指针为空,不能保证它拿到锁时仍然为空。线程在等待锁的过程中,前面的线程可能已经创建好了对象。因此,真正执行new以前必须在锁内再次检查。
课堂代码中经常在锁外再加一层判断,目的是让对象创建完成后的调用尽量不再竞争锁。理解时要抓住两点:
- 外层判断用于减少已经创建完成后的加锁次数;
- 内层判断用于保证真正执行创建操作的线程只有一个。
单例解决的是“对象只能有一份”,线程池自己的互斥锁解决的是“多线程提交和获取任务时队列不能被破坏”。这是两层不同的保护,不能因为线程池内部已经加锁,就认为获取单例也自动安全。
三、线程安全与可重入
3.1 线程安全讨论什么
线程安全关注的是:多个线程同时执行一段代码时,程序能否得到正确结果,共享资源是否会被破坏。
如果函数只操作当前线程自己的局部变量,通常不会发生共享数据竞争;如果函数会修改全局变量、静态变量或者多个线程共同访问的对象,就需要考虑互斥和同步。
例如,多个线程同时向任务队列中插入任务时,队列就是共享资源。只要所有线程都使用同一把锁保护队列的修改过程,这段操作就可以保持线程安全。
3.2 什么叫重入
一个函数还没有执行完,又被另一条执行路径再次进入,这种情况叫做重入。
常见情况有两种:
- 多个线程同时调用同一个函数;
- 当前线程执行函数时收到信号,信号处理函数又调用了同一个函数。
如果函数被再次进入后,前一次调用和后一次调用不会互相破坏状态,这个函数就是可重入函数。若函数依赖正在被修改的全局数据、静态数据,或者调用了不可重入的接口,再次进入时就可能出问题。
线程安全强调的是多线程并发访问是否正确,可重入强调的是函数执行到一半时能否再次进入。两者关注点不同,不能只看函数里有没有一把锁。
3.3 加锁以后为什么还可能卡住
继续看线程池单例。假设线程正在执行GetInstance,已经拿到互斥锁,但还没有离开临界区。此时程序收到信号,转去执行自定义信号处理函数,而信号处理函数中又调用了GetInstance。
新的调用仍然要申请同一把锁,但这把锁正被当前线程自己持有。第一次调用没有结束,锁不会释放;第二次调用拿不到锁,也无法返回。线程相当于把自己堵住了。
staticThreadPool<T>*GetInstance(){LockGuardlock_guard(_lock);if(_instance==nullptr){_instance=newThreadPool<T>();_instance->Start();}return_instance;}在普通多线程调用中,这把锁可以保证创建过程安全;但在上面的信号重入场景中,同一个线程会重复申请尚未释放的锁,从而形成死锁。
理解这两个概念时,可以记住下面的区别:
- 线程安全描述多线程访问共享资源时是否正确;
- 可重入描述函数被打断后再次进入时是否还能正确执行;
- 加锁可以解决很多线程安全问题,但不能保证函数在所有重入场景中都能正常工作。
四、死锁是怎样产生的
4.1 死锁的概念
死锁是指多个执行流各自持有一部分资源,同时又等待其他执行流释放资源,最后所有执行流都无法继续向后执行。
死锁不只会发生在多个线程之间。前面同一个线程重复申请自己持有的非递归锁,也会把自己卡住。多线程环境中更常见的情况,则是两条线程以不同顺序申请多把锁。
假设线程 A 和线程 B 都必须同时拿到锁 1、锁 2,才能访问后面的资源:
线程 A 先拿到锁 1,线程 B 先拿到锁 2。此时两个线程分别持有一把锁:
接下来,线程 A 申请锁 2,但锁 2 在线程 B 手中;线程 B 申请锁 1,但锁 1 在线程 A 手中。两个线程都会阻塞,同时又不会释放自己已经拿到的锁:
申请一把锁可以是原子的,但“先申请锁 1,再申请锁 2”不是一个整体原子操作。只要中间发生线程切换,就可能形成上面的交叉等待。
4.2 用一个生活例子理解
假设小胖和小瘦各有五毛钱,一根棒棒糖需要一块钱。两个人都想买糖,也都要求对方先把五毛钱交出来,但谁都不愿意先放下自己手里的钱。
小胖可以看成线程 A,小瘦可以看成线程 B,两张五毛钱分别对应锁 1 和锁 2。两个人都持有一部分资源,又等待对方的资源,最后谁都买不到糖。
这个例子虽然简单,但把死锁中“持有自己的资源,同时等待别人的资源”表现得很清楚。
五、死锁的四个必要条件
死锁要形成,通常需要同时满足下面四个条件。
5.1 互斥条件
一个资源同一时刻只能被一个执行流使用。互斥锁本身就是典型的互斥资源:线程 A 持有以后,线程 B 不能同时获得。
5.2 请求与保持条件
执行流已经持有一部分资源,在申请其他资源失败后,仍然保持原来的资源不放。
在线程 A、B 的例子中,A 等待锁 2 时没有释放锁 1;B 等待锁 1 时也没有释放锁 2。双方都在等待,但双方手里又都有对方需要的资源。
5.3 不可剥夺条件
一个执行流已经获得的资源,在主动释放以前,不能被其他执行流强行拿走。
互斥锁不能由另一个线程直接“抢过来”。如果持锁线程没有运行到解锁语句,等待线程只能继续等待。
5.4 循环等待条件
多个执行流之间形成首尾相接的等待关系:A 等 B,B 又等 A;线程更多时,也可能是 A 等 B、B 等 C、C 再等 A。
四个条件必须同时成立才会形成死锁。写代码时只要主动破坏其中一个条件,等待环就无法完整建立。
六、怎样避免死锁
6.1 统一加锁顺序
最常见的办法是规定锁的申请顺序。所有线程都先申请锁 1,再申请锁 2,不允许某些线程反过来先申请锁 2。
voidThreadA(){pthread_mutex_lock(&mutex1);pthread_mutex_lock(&mutex2);// 访问共享资源pthread_mutex_unlock(&mutex2);pthread_mutex_unlock(&mutex1);}voidThreadB(){pthread_mutex_lock(&mutex1);pthread_mutex_lock(&mutex2);// 访问共享资源pthread_mutex_unlock(&mutex2);pthread_mutex_unlock(&mutex1);}两个线程都按照相同顺序申请锁,就不会出现 A 拿锁 1 等锁 2、B 拿锁 2 等锁 1 的循环关系。
6.2 申请失败后主动释放
如果不希望线程在第二把锁上一直阻塞,可以使用pthread_mutex_trylock尝试申请:
intpthread_mutex_trylock(pthread_mutex_t*mutex);申请成功返回0;如果锁已经被其他线程持有,接口直接返回,不会把当前线程一直挂在这里。当前线程可以先释放已经持有的锁,稍后再重新申请。
pthread_mutex_lock(&mutex1);if(pthread_mutex_trylock(&mutex2)!=0){pthread_mutex_unlock(&mutex1);return;}// 同时持有两把锁,访问共享资源pthread_mutex_unlock(&mutex2);pthread_mutex_unlock(&mutex1);这样做破坏了请求与保持条件:拿不到第二把锁时,不再一直占着第一把锁。
6.3 一次申请需要的资源
如果一段代码必须同时使用多份资源,可以在进入临界区前统一申请,申请不完整就不继续执行。其目的仍然是避免线程拿着一部分资源等待另一部分资源。
实际代码中还要尽量缩短持锁时间。访问完共享数据后立即解锁,不要拿着锁执行休眠、耗时计算或者不受控制的操作。锁持有得越久,线程之间形成复杂等待关系的概率越高。
6.4 死锁检测与避免算法
操作系统中还会涉及死锁检测和银行家算法。它们的共同思路是记录资源分配和申请情况,再判断继续分配是否会进入不安全状态。这里先知道用途即可,本文重点仍然是编码阶段通过加锁顺序和释放策略避免死锁。
七、STL 与智能指针的线程安全
7.1 STL 容器
STL 容器默认不会替我们完成业务层面的加锁。多个线程共享同一个容器,并且其中有线程执行插入、删除或修改操作时,调用者需要自己保证同步。
如果标准库给每个容器操作都强制加锁,即使程序只在单线程中使用,也要承担额外成本;而且不同容器适合的加锁粒度不同。哈希表可以考虑保护整张表,也可以细分到桶,标准库无法替所有业务选择同一种方案。
因此,在线程池、生产者消费者模型中,我们都会在容器外面增加互斥锁,再由条件变量或信号量协调线程执行顺序。
7.2unique_ptr
unique_ptr表示独占所有权。正常情况下,一个对象只有一个unique_ptr管理,不会像共享引用计数那样被多个智能指针共同修改。
但独占所有权不等于可以让多个线程同时读写同一个unique_ptr变量。如果需要把资源交给另一个线程,可以通过移动转交所有权,原来的线程不再继续使用它。
7.3shared_ptr
shared_ptr允许多个智能指针共享同一个对象,因此内部还要维护引用计数。标准库会保证引用计数的增加和减少不会因为多线程操作而被破坏。
需要注意,引用计数安全只说明对象的生命周期管理不会轻易出错,不代表shared_ptr指向的对象自动具备线程安全性。多个线程仍然同时修改对象内容时,还是要根据对象内部的数据增加互斥保护。
简单来说:智能指针解决的是资源所有权和释放问题,互斥锁解决的是共享数据并发访问问题,两者不能互相替代。
八、其他常见的锁与并发方式
8.1 悲观锁
悲观锁认为访问数据时很可能发生冲突,因此先加锁,再读取或修改数据。互斥锁、读写锁以及数据库中的行锁,都可以看成这种思路。
其他线程申请不到锁时会等待,优点是逻辑直接,缺点是竞争激烈时会出现阻塞和线程切换。
8.2 乐观锁
乐观锁认为冲突不会频繁发生,读取数据时先不加锁,真正更新以前再检查数据是否被其他执行流修改。
常见做法是增加版本号,或者使用 CAS 比较并交换。如果版本没有变化,说明读取以后没有其他执行流修改数据,可以提交本次更新;如果版本变化,则放弃或重新计算。
8.3 CAS
CAS 操作会比较内存中的当前值和预期值。两者相等时写入新值,不相等则更新失败。
失败后的常见处理是重新读取并再次尝试,因此 CAS 经常和自旋联系在一起。它没有传统互斥锁的阻塞等待过程,但竞争激烈时反复重试同样会消耗 CPU。
8.4 自旋锁和读写锁
自旋锁申请失败后不会立即休眠,而是在 CPU 上不断检查锁是否已经释放。临界区很短时,可以省去线程睡眠和唤醒的开销;如果持锁时间较长,自旋只会白白占用处理器。
读写锁把访问分为读和写。多个读线程可以同时进入,写线程则需要独占资源,因此更适合读操作明显多于写操作的场景。
这些锁的目标并不是替代互斥锁,而是针对不同的访问特点选择不同的同步方式。临界区长度、读写比例和冲突频率不同,合适的方案也不同。
总结
单例模式限制一个类只创建一个实例。饿汉模式提前创建对象,懒汉模式第一次使用时再创建;懒汉模式放到多线程环境中,必须保护第一次创建过程。
线程安全关注多个线程并发访问是否正确,可重入关注函数执行到一半时能否再次进入。加锁可以保护共享数据,但同一执行流重复申请尚未释放的锁,仍然可能把自己卡住。
死锁形成需要互斥、请求与保持、不可剥夺和循环等待四个条件。实际写代码时,统一多把锁的申请顺序,申请失败后及时释放已有资源,并缩短持锁时间,可以减少大部分死锁问题。
STL 容器和智能指针不会替业务代码完成全部线程同步。共享数据是否安全,最终还是要看多个执行流怎样访问,以及我们是否使用了正确的同步方式。
往期博客:
【Linux】手写日志与固定线程池:任务队列、工作线程和安全退出
【Linux】条件变量为什么必须配合互斥锁:从 pthread_cond_wait 到阻塞队列
【Linux】信号量到底在数什么:从PV操作到RingQueue环形队列生产者消费者模型