个人主页爱和冰阔乐专栏传送门《数据结构与算法》 、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_ptr7.3 shared_ptr八、其他常见的锁与并发方式8.1 悲观锁8.2 乐观锁8.3 CAS8.4 自旋锁和读写锁总结前言上一篇已经把日志和固定线程池写完了。线程池本身只有一份任务队列和一组工作线程如果在程序中反复创建多个线程池不但没有必要还会增加线程数量和管理成本。因此线程池很适合使用单例模式管理。不过单例并不是把构造函数改成私有就结束了。懒汉模式第一次创建对象时如果多个线程同时进入获取接口仍然可能创建出多个对象。继续往后看还会遇到线程安全、函数重入和死锁这些容易混在一起的问题。这一篇就顺着线程池继续往下写先完成单例模式再说明线程安全和可重入的区别最后整理死锁、STL、智能指针以及几种常见的锁。一、单例模式1.1 什么是单例模式普通类可以根据需要创建多个对象单例模式则要求一个类在进程中只保留一个实例并提供统一的访问接口。例如程序中的配置对象保存的是整个工程都要使用的配置同一份配置没有必要创建多次。线程池也是一样任务统一放入一份任务队列再由固定的一组线程处理即可。若不同模块各自创建线程池线程数量会越来越多任务调度也会变得混乱。单例模式主要从语法上限制对象的创建。通常需要处理三件事把构造函数设为私有禁止用户直接创建对象禁止拷贝构造和赋值避免复制出新的对象在类中提供静态接口返回唯一实例。classThreadPool{public:ThreadPool(constThreadPool)delete;ThreadPooloperator(constThreadPool)delete;private:ThreadPool()default;};只隐藏构造函数还不够我们还需要决定这个唯一对象应该在什么时候创建。1.2 饿汉模式和懒汉模式这两个名字看起来有点抽象可以先用洗碗来理解。吃完饭马上把碗洗好下次吃饭时直接使用这种方式就是饿汉模式吃完饭先把碗放着等下一次要用时再洗这种方式就是懒汉模式。放到程序中也是一样饿汉模式程序启动时就把对象创建好后面可以直接获取懒汉模式程序启动时先不创建第一次真正使用时再创建。两种方式没有谁一定更好。对象很小、初始化也很简单时提前创建不会带来明显影响对象较大或者初始化过程比较复杂时延迟到真正使用时再创建更合适。1.3 饿汉模式的实现饿汉模式可以直接把唯一对象定义成类的静态成员。静态成员属于类不属于某一个普通对象因此不需要先创建类对象就能访问。classThreadPool{public:staticThreadPool*GetInstance(){return_instance;}ThreadPool(constThreadPool)delete;ThreadPooloperator(constThreadPool)delete;private:ThreadPool()default;staticThreadPool _instance;};ThreadPool ThreadPool::_instance;程序进入运行阶段以前静态对象已经完成创建。调用GetInstance时只需要返回它的地址不存在第一次调用时多个线程同时创建对象的问题。饿汉模式的缺点也很明显无论这个对象后面是否会被使用它都会提前完成初始化。若对象占用资源较多或者初始化依赖其他模块程序启动阶段就要承担这部分成本。1.4 懒汉模式的实现懒汉模式先保存一个空指针第一次调用GetInstance时再创建对象classThreadPool{public:staticThreadPool*GetInstance(){if(_instancenullptr){_instancenewThreadPool;}return_instance;}ThreadPool(constThreadPool)delete;ThreadPooloperator(constThreadPool)delete;private:ThreadPool()default;staticThreadPool*_instance;};ThreadPool*ThreadPool::_instancenullptr;这段代码在单线程中没有问题但放到多线程环境中就不安全了。假设线程 A 判断_instance nullptr后被切走线程 B 此时也进入接口同样发现指针为空。接下来两个线程都会执行new T最终创建出两个对象。其中一个地址还会被后一次赋值覆盖造成内存泄漏。所以懒汉模式真正需要保护的是第一次创建对象的过程。二、线程池改成单例模式2.1 为什么线程池适合做成单例固定线程池内部已经维护了任务队列、互斥锁、条件变量和若干工作线程。大多数程序只需要一个公共线程池其他模块取得同一个线程池对象再向其中推送任务即可。如果只有main线程提交任务可以看成单生产者、多消费者如果多个业务线程都会提交任务就变成多生产者、多消费者。线程池内部的任务队列虽然已经加锁但获取线程池对象本身也必须是安全的否则第一次获取时仍可能创建出多个线程池。2.2 保护第一次创建最直接的做法是在获取单例时加锁templateclassTclassThreadPool{public:staticThreadPoolT*GetInstance(){LockGuardlock_guard(_lock);if(_instancenullptr){LOG(LogLevel::DEBUG)首次使用线程池开始创建;_instancenewThreadPoolT();_instance-Start();}return_instance;}ThreadPool(constThreadPool)delete;ThreadPooloperator(constThreadPool)delete;private:ThreadPool()default;private:staticThreadPoolT*_instance;staticMutex _lock;};互斥锁和单例指针都要定义成静态成员。原因很简单调用GetInstance时对象还没有创建不能依赖某个对象内部的普通成员锁只能由类本身保存这把锁。多个线程同时进入接口时只有一个线程能先拿到锁并创建线程池。后面的线程拿到锁以后再次检查_instance发现对象已经存在直接返回即可。2.3 为什么要再次判断第一次判断只能说明线程进入接口时指针为空不能保证它拿到锁时仍然为空。线程在等待锁的过程中前面的线程可能已经创建好了对象。因此真正执行new以前必须在锁内再次检查。课堂代码中经常在锁外再加一层判断目的是让对象创建完成后的调用尽量不再竞争锁。理解时要抓住两点外层判断用于减少已经创建完成后的加锁次数内层判断用于保证真正执行创建操作的线程只有一个。单例解决的是“对象只能有一份”线程池自己的互斥锁解决的是“多线程提交和获取任务时队列不能被破坏”。这是两层不同的保护不能因为线程池内部已经加锁就认为获取单例也自动安全。三、线程安全与可重入3.1 线程安全讨论什么线程安全关注的是多个线程同时执行一段代码时程序能否得到正确结果共享资源是否会被破坏。如果函数只操作当前线程自己的局部变量通常不会发生共享数据竞争如果函数会修改全局变量、静态变量或者多个线程共同访问的对象就需要考虑互斥和同步。例如多个线程同时向任务队列中插入任务时队列就是共享资源。只要所有线程都使用同一把锁保护队列的修改过程这段操作就可以保持线程安全。3.2 什么叫重入一个函数还没有执行完又被另一条执行路径再次进入这种情况叫做重入。常见情况有两种多个线程同时调用同一个函数当前线程执行函数时收到信号信号处理函数又调用了同一个函数。如果函数被再次进入后前一次调用和后一次调用不会互相破坏状态这个函数就是可重入函数。若函数依赖正在被修改的全局数据、静态数据或者调用了不可重入的接口再次进入时就可能出问题。线程安全强调的是多线程并发访问是否正确可重入强调的是函数执行到一半时能否再次进入。两者关注点不同不能只看函数里有没有一把锁。3.3 加锁以后为什么还可能卡住继续看线程池单例。假设线程正在执行GetInstance已经拿到互斥锁但还没有离开临界区。此时程序收到信号转去执行自定义信号处理函数而信号处理函数中又调用了GetInstance。新的调用仍然要申请同一把锁但这把锁正被当前线程自己持有。第一次调用没有结束锁不会释放第二次调用拿不到锁也无法返回。线程相当于把自己堵住了。staticThreadPoolT*GetInstance(){LockGuardlock_guard(_lock);if(_instancenullptr){_instancenewThreadPoolT();_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 时没有释放锁 1B 等待锁 1 时也没有释放锁 2。双方都在等待但双方手里又都有对方需要的资源。5.3 不可剥夺条件一个执行流已经获得的资源在主动释放以前不能被其他执行流强行拿走。互斥锁不能由另一个线程直接“抢过来”。如果持锁线程没有运行到解锁语句等待线程只能继续等待。5.4 循环等待条件多个执行流之间形成首尾相接的等待关系A 等 BB 又等 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_ptrunique_ptr表示独占所有权。正常情况下一个对象只有一个unique_ptr管理不会像共享引用计数那样被多个智能指针共同修改。但独占所有权不等于可以让多个线程同时读写同一个unique_ptr变量。如果需要把资源交给另一个线程可以通过移动转交所有权原来的线程不再继续使用它。7.3shared_ptrshared_ptr允许多个智能指针共享同一个对象因此内部还要维护引用计数。标准库会保证引用计数的增加和减少不会因为多线程操作而被破坏。需要注意引用计数安全只说明对象的生命周期管理不会轻易出错不代表shared_ptr指向的对象自动具备线程安全性。多个线程仍然同时修改对象内容时还是要根据对象内部的数据增加互斥保护。简单来说智能指针解决的是资源所有权和释放问题互斥锁解决的是共享数据并发访问问题两者不能互相替代。八、其他常见的锁与并发方式8.1 悲观锁悲观锁认为访问数据时很可能发生冲突因此先加锁再读取或修改数据。互斥锁、读写锁以及数据库中的行锁都可以看成这种思路。其他线程申请不到锁时会等待优点是逻辑直接缺点是竞争激烈时会出现阻塞和线程切换。8.2 乐观锁乐观锁认为冲突不会频繁发生读取数据时先不加锁真正更新以前再检查数据是否被其他执行流修改。常见做法是增加版本号或者使用 CAS 比较并交换。如果版本没有变化说明读取以后没有其他执行流修改数据可以提交本次更新如果版本变化则放弃或重新计算。8.3 CASCAS 操作会比较内存中的当前值和预期值。两者相等时写入新值不相等则更新失败。失败后的常见处理是重新读取并再次尝试因此 CAS 经常和自旋联系在一起。它没有传统互斥锁的阻塞等待过程但竞争激烈时反复重试同样会消耗 CPU。8.4 自旋锁和读写锁自旋锁申请失败后不会立即休眠而是在 CPU 上不断检查锁是否已经释放。临界区很短时可以省去线程睡眠和唤醒的开销如果持锁时间较长自旋只会白白占用处理器。读写锁把访问分为读和写。多个读线程可以同时进入写线程则需要独占资源因此更适合读操作明显多于写操作的场景。这些锁的目标并不是替代互斥锁而是针对不同的访问特点选择不同的同步方式。临界区长度、读写比例和冲突频率不同合适的方案也不同。总结单例模式限制一个类只创建一个实例。饿汉模式提前创建对象懒汉模式第一次使用时再创建懒汉模式放到多线程环境中必须保护第一次创建过程。线程安全关注多个线程并发访问是否正确可重入关注函数执行到一半时能否再次进入。加锁可以保护共享数据但同一执行流重复申请尚未释放的锁仍然可能把自己卡住。死锁形成需要互斥、请求与保持、不可剥夺和循环等待四个条件。实际写代码时统一多把锁的申请顺序申请失败后及时释放已有资源并缩短持锁时间可以减少大部分死锁问题。STL 容器和智能指针不会替业务代码完成全部线程同步。共享数据是否安全最终还是要看多个执行流怎样访问以及我们是否使用了正确的同步方式。往期博客【Linux】手写日志与固定线程池任务队列、工作线程和安全退出【Linux】条件变量为什么必须配合互斥锁从 pthread_cond_wait 到阻塞队列【Linux】信号量到底在数什么从PV操作到RingQueue环形队列生产者消费者模型
阅读完成 · 觉得有帮助?