ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Re:Linux系统篇(五十七)线程篇 · 十一:深度长文|彻底搞懂并发四大核心:重入性、死锁四大条件、std::lock原理与STL线程安全

Re:Linux系统篇(五十七)线程篇 · 十一:深度长文|彻底搞懂并发四大核心:重入性、死锁四大条件、std::lock原理与STL线程安全 ◆ 博主名称 小此方-CSDN博客大家好欢迎来到小此方的博客。⭐️Linux系列个人专栏 【主题曲】Linux⭐️此方的GitHub github_此方⭐️Re系列专栏我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)文章目录概要序論一、线程安全问题与重入问题的讨论1.1什么 是线程安全问题什么是重入问题1.2两类问题的各种常见的情况1.2.1常见的线程不安全的情况1.2.2常见不可重入的情况1.2.3常见的线程安全的情况1.2.4常见可重入的情况1.3可重入与线程安全的关系与区别1.3.1可重入与线程安全联系1.3.2可重入与线程安全区别1.3.3你需要注意的是什么Tips为什么 “线程安全不一定是可重入的而可重入函数则一定是线程安全的”二、死锁问题2.1什么是死锁问题2.2死锁问题的四个必要条件三、如何解决或者避免死锁问题3.1trylock打破了请求与保持条件与不可剥夺条件3.1.1什么是trylock3.1.2 为什么说它打破了请求与保持条件3.1.3 为什么说它打破了不可剥夺条件3.2 资源一次性分配与 std::lock 的应用3.2.1 一次性分配资源的原理与痛点3.2.2 C11 std::lock 的标准解决方案3.2.3 运行结果对比分析3.3死锁避免相关的算法有兴趣可以了解一下四、STL、智能指针与线程安全问题4.1STL中的容器是否是线程安全的4.2智能指针是否是线程安全的五、其他常见的各种锁(不做介绍我后面会单独出Linux加餐文章)概要序論Hello大家好我是此方。本文是Linux主线部分的最后一篇当然日后有时间此方还会更新Linux的加餐内容。唉好像也没多少时间能抽出来计算机网络也要更新。废话不多说本文将介绍线程部分的剩余内容主要围绕“线程安全”这个点展开。包括STL中的线程安全线程安全与重入问题常见的锁与死锁问题我们正式开始吧。一、线程安全问题与重入问题的讨论1.1什么 是线程安全问题什么是重入问题线程安全就是多个线程在访问共享资源时能够正确地执行不会相互干扰或破坏彼此的执行结果。一般而言多个线程并发同一段只有局部变量的代码时不会出现不同的结果。但是对全局变量或者静态变量进行操作并且没有锁保护的情况下容易出现该问题。重入同一个函数被不同的执行流调用当前一个流程还没有执行完就有其他的执行流再次进入我们称之为重入。一个函数在重入的情况下运行结果不会出现任何不同或者任何问题则该函数被称为可重入函数否则是不可重入函数。学到现在其实我们已经能理解重入其实可以分为两种情况多线程重入函数信号导致一个执行流重复进入函数1.2两类问题的各种常见的情况1.2.1常见的线程不安全的情况不保护共享变量的函数函数状态随着被调用状态发生变化的函数返回指向静态变量指针的函数调用线程不安全函数的函数1.2.2常见不可重入的情况调用了 malloc/free 函数因为 malloc 函数是用全局链表来管理堆的调用了标准 I/O 库函数标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构可重入函数体内使用了静态的数据结构1.2.3常见的线程安全的情况每个线程对全局变量或者静态变量只有读取的权限而没有写入的权限一般来说这些线程是安全的类或者接口对于线程来说都是原子操作多个线程之间的切换不会导致该接口的执行结果存在二义性1.2.4常见可重入的情况不使用全局变量或静态变量不使用 malloc 或者 new 开辟出的空间不调用不可重入函数不返回静态或全局数据所有数据都有函数的调用者提供使用本地数据或者通过制作全局数据的本地拷贝来保护全局数据不要被上面绕口令式的话语唬住你只要仔细观察其实对应概念说的都是一回事。1.3可重入与线程安全的关系与区别1.3.1可重入与线程安全联系函数是可重入的那就是线程安全的(其实知道这一句话就够了)函数是不可重入的那就不能由多个线程使用有可能引发线程安全问题如果一个函数中有全局变量那么这个函数既不是线程安全也不是可重入的。1.3.2可重入与线程安全区别可重入函数是线程安全函数的一种线程安全不一定是可重入的而可重入函数则一定是线程安全的。如果将对临界资源的访问加上锁则这个函数是线程安全的但如果这个重入函数若锁还未释放则会产生死锁因此是不可重入的。1.3.3你需要注意的是什么如果不考虑信号导致一个执行流重复进入函数 这种重入情况线程安全和重入在安全角度不做区分。但是线程安全侧重说明线程访问公共资源的安全情况表现的是并发线程的特点。可重入描述的是一个函数是否能被重复进入表示的是函数的特点。就是一个硬币的两面看到问题的不同角度这讲的啥呀这是你肯定有地方没听懂。——上文”如果将对临界资源的访问加上锁则这个函数是线程安全的但如果这个重入函数若锁还未释放则会产生死锁因此是不可重入的。“以及”线程安全不一定是可重入的而可重入函数则一定是线程安全的。“我想我没有讲清楚。我必须举一个例子来说明一下Tips为什么 “线程安全不一定是可重入的而可重入函数则一定是线程安全的”我们拿上一期中我们讲的单例模式线程池的获取单例的函数来讲解这个问题。单例模式线程池的完成源码博客传送门Linux加餐二藏在Linux中的设计模式二单例模式与线程池不用文字讲解了我直接手绘更好理解二、死锁问题2.1什么是死锁问题死锁问题在校招面试的时候考到的还蛮多的。死锁是指在一组进程中的各个进程均占有不会释放的资源但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。为了方便表述假设现在线程A线程B必须同时持有锁1和锁2才能进行后续资源的访问。讲个小故事:一个小胖子和一个小男孩各自有5毛钱去小卖部买棒棒糖老板说棒棒糖得一块钱。胖子对小男孩说我有5毛钱我现在要你手里的五毛钱。来获得我的棒棒糖。小男孩对胖子说了同样的话。在老板看来胖子和小男孩各自占有各自的资源5毛钱不释放同时向对方申请五毛钱。于是胖子和小男孩陷入了无穷无尽的争吵中。我们用示意图解释一下就是这样造成的结果就是2.2死锁问题的四个必要条件互斥条件一个资源每次只能被一个执行流使用好理解不做解释。请求与保持条件一个执行流因请求资源而阻塞时对已获得的资源保持不放。不剥夺条件一个执行流已获得的资源在未使用完之前不能强行剥夺。循环等待条件若干执行流之间形成一种头尾相接的循环等待资源的关系。三、如何解决或者避免死锁问题如何解决或者避免死锁问题简单的讲就是破坏死锁的四个必要条件3.1trylock打破了请求与保持条件与不可剥夺条件3.1.1什么是trylocktryLock 是一种非阻塞的锁获取机制。工作机制尝试获取锁成功返回 true若锁已被占用不阻塞等待立即返回 false。3.1.2 为什么说它打破了请求与保持条件核心逻辑拿不到就放手。传统lock()在拿不到新锁时会死等同时强行保持旧锁tryLock()获取新锁失败时立即返回false代码随即主动释放已持有的旧锁打破了“占着不放”的保持状态。3.1.3 为什么说它打破了不可剥夺条件核心逻辑逻辑上的自我剥夺。传统“不可剥夺”指资源不能被外部强行抢走tryLock()则实现了主动剥夺——当线程发现无法获取全部资源时主动“剥夺”并交出自己对已有锁的占有权使资源恢复可分配状态。可能有人会问剥夺难道不是指向别人的动作吗实际上我们这里讲的剥夺也可以指向自己。3.2 资源一次性分配与 std::lock 的应用除了使用tryLock机制外解决死锁问题的另一个有效思路是破坏请求与保持条件以及破坏循环等待条件。如果能做到“要么不占有任何资源要占有就一次性占有全部所需资源”就能彻底杜绝“占着资源 A 去死等资源 B”的困境。3.2.1 一次性分配资源的原理与痛点在多线程开发中假设线程需要同时访问资源1mtx1和资源2mtx2。传统的逐个加锁方式极易诱发死锁// 传统加锁极易产生死锁与资源竞争mtx1.lock();mtx2.lock();// 若此时其他线程持有了 mtx2 并等待 mtx1则直接死锁如果加锁顺序不当或者加锁过程中没有防护机制不仅可能造成线程卡死还会因为错误的并发控制导致数据脏读脏写如累加计数器未达到预期的数值。3.2.2 C11 std::lock 的标准解决方案C11 标准库提供了std::lock函数其核心功能是同时锁定两个或多个互斥量。它在内部实现了一套死锁避免算法通常结合非阻塞尝试与退避算法确保不会产生死锁。以下为使用std::unique_lock结合std::defer_lock实现资源一次性分配的具体代码#includeiostream#includemutex#includethread#includevector#includeunistd.h// 定义两个共享资源整数变量和两个互斥锁intshared_resource10;intshared_resource20;std::mutex mtx1,mtx2;// 同时访问两个共享资源的线程函数voidaccess_shared_resources(){// 1. 使用 std::defer_lock 声明锁管理对象此时暂不真正加锁std::unique_lockstd::mutexlock1(mtx1,std::defer_lock);std::unique_lockstd::mutexlock2(mtx2,std::defer_lock);// 2. 一次性安全加锁同时锁定两个互斥锁破坏死锁条件std::lock(lock1,lock2);// 3. 两个锁均已锁定安全访问共享资源intcnt10000;while(cnt){shared_resource1;shared_resource2;cnt--;}// 4. 函数结束时lock1 和 lock2 的析构函数会自动释放互斥锁}// 模拟多线程并发访问voidsimulate_concurrent_access(){std::vectorstd::threadthreads;// 创建 10 个并发线程for(inti0;i10;i){threads.emplace_back(access_shared_resources);}// 等待所有线程执行完毕for(autothread:threads){thread.join();}// 输出最终数据结果std::coutShared Resource 1: shared_resource1std::endl;std::coutShared Resource 2: shared_resource2std::endl;}intmain(){simulate_concurrent_access();return0;}3.2.3 运行结果对比分析资源是否实现“一次性安全分配”对系统正确性有着直接影响非一次性安全申请非原子加锁由于存在竞争条件或锁等待冲突共享资源的值无法保证正确性例如 10 个线程累加各 10000 次预期应为 100000实际输出却只有94416和94536甚至中途陷入死锁。一次性申请使用 std::lock原子化地同时锁定多个资源共享资源计算结果精准符合预期Shared Resource 1: 100000,Shared Resource 2: 100000。3.3死锁避免相关的算法有兴趣可以了解一下一个是死锁检测算法一个是银行家算法。面试考的比较少有兴趣了解。四、STL、智能指针与线程安全问题4.1STL中的容器是否是线程安全的不是。原因是, STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响.而且对于不同的容器,加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶).因此 STL 默认不是线程安全. 如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全.4.2智能指针是否是线程安全的智能指针的线程安全问题我们以前讲过在C11的时候我直接把当初的博客正文贴过来。对于 unique_ptr, 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题。对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够高效, 原子的操作引用计数。五、其他常见的各种锁(不做介绍我后面会单独出Linux加餐文章)悲观锁在每次取数据时总是担心数据会被其他线程修改所以会在取数据前先加锁读锁写锁行锁等当其他线程想要访问数据时被阻塞挂起。乐观锁每次取数据时候总是乐观的认为数据不会被其他线程修改因此不上锁。表现在更新数据前会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式版本号机制和 CAS 操作。CAS 操作当需要更新数据时判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败失败则重试一般是一个自旋的过程即不断重试。自旋锁读写锁加餐详细介绍。好的本期内容就到这里如果对你有帮助还不要忘记点赞三联支持。我是此方我们下期再见。bye! Linux、C、算法持续连载中欢迎关注WeChat Official Account 【此方的技术栈】。
返回列表