【Linux】:线程安全 + 死锁问题

📃个人主页:island1314

🔥个人专栏:Linux—登神长阶

⛺️ 欢迎关注:👍点赞 👂🏽留言 😍收藏  💞 💞 💞


1. 线程安全和重入问题🚀

1.1 基本概念 

  • 线程安全:就是多个线程在访问共享资源时,能够正确地执行,不会相互干扰或破坏彼此的执行结果。一般而言,多个线程并发同一段只有局部变量的代码时,不会出现不同的结果。但是对全局变量或者静态变量进行操作,并且没有锁保护的情况下,容易出现该问题。
  • 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

1.2 线程安全和重入情况 

🍥 学到现在,其实我们已经能理解重入其实可以分为两种情况

  1. 多线程重入函数
  2. 信号导致一个执行流重复进入函数

① 常见的线程不安全的情况

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

② 常见的线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
  • 类或者接口对于线程来说都是原子操作
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性 

③ 常见不可重入的情况

  • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
  • 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
  • 可重入函数体内使用了静态的数据结构 

④ 常见可重入的情况

  • 不使用全局变量或静态变量
  • 不使用用malloc或者new开辟出的空间
  • 不调用不可重入函数
  • 不返回静态或全局数据,所有数据都有函数的调用者提供
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据 

提示 :不要被上面的一系列所弄晕,其实对应概念说的都是一回事

1.3 线程安全和重入的联系区别

📌 可重入与线程安全联系

  1. 函数是可重入的,那就是线程安全
  2. 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
  3. 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。 

📌 可重入与线程安全区别

  1. 可重入函数是线程安全函数的一种
  2. 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  3. 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。 

📌 注意:

  • 如果不考虑 信号导致一个执行流重复进入函数 这种重入情况,线程安全和重入在安全角度不做区分
  • 但是线程安全侧重说明线程访问公共资源的安全情况,表现的是 并发线程 的特点
  • 可重入描述的是一个函数是否能被重复进入,表示的是 函数 的特点

2. 死锁 🖊

2.1 死锁基本概念

  • 死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。

  • 为了方便表述,假设现在线程A,线程B必须同时持有 锁1和 锁2 ,才能进行后续资源的访问

  • 🥑 申请一把锁是原子的,但是申请两把锁就不一定了​​​​​​

  • 🥑 造成的结果如下:

2.2 死锁的四个必要条件

  • 互斥条件:一个资源每次只能被一个执行流使用
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放

  • 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺

  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系 

2.3 避免死锁

  • 破坏死锁的四个必要条件
    • 破坏循环条件等待问题:资源一次性分配,使用超时机制,加锁顺序一致
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
#include <unistd.h>

// 定义两个共享资源(整数变量)和两个互斥锁
int shared_resource1 = 0;
int shared_resource2 = 0;
std::mutex mtx1, mtx2;

// ⼀个函数,同时访问两个共享资源
void access_shared_resources()
{
    std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
    std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
    // 使⽤ std::lock 同时锁定两个互斥锁
    std::lock(lock1, lock2);

    // 现在两个互斥锁都已锁定,可以安全地访问共享资源
    int cnt = 10000;
    while (cnt--)
    {
        ++shared_resource1;
        ++shared_resource2;
    }
    // 当离开 access_shared_resources 的作⽤域时,lock1 和 lock2 的析构函数会被自动调⽤
    // 这会导致它们各⾃的互斥量被⾃动解锁
}

// 模拟多线程同时访问共享资源的场景
void simulate_concurrent_access()
{
    std::vector<std::thread> threads;
    // 创建多个线程来模拟并发访问
    for (int i = 0; i < 10; ++i)
    {
        threads.emplace_back(access_shared_resources);
    }
    // 等待所有线程完成
    for (auto &thread : threads)
    {
        thread.join();
    }
    // 输出共享资源的最终状态
    std::cout << "Shared Resource 1: " << shared_resource1 << std::endl;
    std::cout << "Shared Resource 2: " << shared_resource2 << std::endl;
}

int main()
{
    simulate_concurrent_access();
    return 0;
}

一次申请

不一次申请

  • 避免锁未释放场景

2.4 死锁的预防

🔥 死锁的预防是通过破坏产生死锁的必要条件之一,是系统不会产生死锁。

  • 简单方法是在系统运行之前就采取措施,即在系统设计时确定资源分配算法,消除发生死锁的任何可能性。该方法虽然比较保守、资源利用率低,但因简单明了并且安全可靠,仍被广泛采用。这是一种预先的静态策略。
🥝 破坏互斥条件

🎐 互斥条件:只有对必须互斥使用的资源的争抢才会导致死锁。
如果把只能互斥使用的资源改造为允许共享使用,则系统不会进入死锁状态。比如:SPOOLing 技术。操作系统可以采用 SPOOLing 技术  把独占设备在逻辑上改造成共享设备。比如,用SPOOLing 技术 将打印机改造为共享设备..

  • 该策略的缺点:并不是所有的资源都可以改造成可共享使用的资源。并且为了系统安全,很多地方还必须保护这种互斥性。因此,很多时候都无法破坏互斥条件。

🥝 破坏不可剥夺条件

 💢 不剥夺条件:进程所获得的资源在未使用完之前,不能由其他进程强行夺走,只能主动释放。

破坏不剥夺条件

  1. 当某个进程请求新的资源得不到满足时,它必须立即释放保持的所有资源,待以后需要时再重新申请。也就是说,即使某些资源尚未使用完,也需要主动释放,从而破坏了不可剥夺条件。
  2. 当某个进程需要的资源被其他进程所占有的时候,可以由操作系统协助,将想要的资源强行剥夺。这种方式一般需要考虑各进程的优先级(比如:剥夺调度方式,就是将处理机资源强行剥夺给优先级更高的进程使用)

该策略的缺点

  1. 实现起来比较复杂。
  2. 释放已获得的资源可能造成前一阶段工作的失效。因此这种方法一般只适用于易保存和恢复状态的资源,如CPU。
  3. 反复地申请和释放资源会增加系统开销,降低系统吞量。
  4. 若采用方案一,意味着只要暂时得不到某个资源,之前获得的那些资源就都需要放弃,以后再重新申请。如果一直发生这样的情况,就会导致进程饥饿。
🥝 破坏请求并保持条件

请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源又被其他进程占有,此时请求进程被阻塞,但又对自己己有的资源保持不放。

  • 可以采用静态分配方法,即进程在运行前一次申请完它所需要的全部资源,在它的资源未满足前,不让它投入运行。一旦投入运行后,这些资源就一直归它所有,该进程就不会再请求别的任何资源了。

该策略实现起来简单,但也有明显的缺点:有些资源可能只需要用很短的时间,因此如果进程的整个运行期间都一直保持着所有资源,就会造成严重的资源浪费,资源利用率极低。另外,该策略也有可能导致某些进程饥饿

🥝 破坏循环等待条件

循环等待条件:存在一种进程资源的循环等待链,链中的每一个进程已获得的资源同时被下一个进程所请求。
可采用顺序资源分配法。首先给系统中的资源编号,规定每个进程必须按编号递增的顺序请求资源同类资源(即编号相同的资源)一次申请完。

原理分析:一个进程只有已占有小编号的资源时,才有资格申请更大编号的资源。按此规则,已持有大编号资源的进程不可能逆向地回来申请小编号的资源,从而就不会产生循环等待的现象。

该策略的缺点:

  1. 不方便增加新的设备,因为可能需要重新分配所有的编号
  2. 进程实际使用资源的顺序可能和编号递增顺序不一致,会导致资源浪费
  3. 必须按规定次序申请资源,用户编程麻烦。

2.5 死锁的避免

避免死锁同样属于事先预防策略,并不是采取某种限制措施破坏死锁的必要条件,而是在资源动态分配过程中,防止系统进入不完全状态。

🍉 安全序列

进程可以动态的申请资源,但是系统在进行资源分配之前,必须先计算此次分配的安全性。如果计算所得是安全的,则允许分配,但如果是不安全的,则让进程等待。而所谓的安全状态就是,系统可以按照某种进程的推进顺序

这里举了一个银行给BAT三家公司借钱的例子用来引出银行家算法 

这时候如果将 30亿 借给了B公司,那么手里还有 10亿元,这 10亿 已经小于3家公司最小的最多还会借的钱数,没有公司能够达到提出的最大要求,这样银行的钱就会打水漂了!!!

如果是这种情况呢?

这样按照T->B->A的顺序借钱是没有问题的,是安全的。

 按照A->T->B的顺序借钱也是没有问题的。

这样我们就会得到安全序列、不安全序列和死锁的关系了

 注意:

(1)系统在某一时刻的安全状态可能不唯一,但这不影响对系统安全性的判断。
(2)安全状态是非死锁状态,而不安全状态并不一定是死锁状态。即系统处于安全状态一定可以避免死锁,而系统处于不安全状态则仅仅可能进入死锁状态。

原因是如果进入了不安全状态,但是没有进程去请求资源,并且有进程提前归还了一些资源,这样就不会死锁。

🍉 银行家算法

银行家算法是荷兰学者 Dijkstra 为银行系统设计的,以确保银行在发放现金贷款时,不会发生不能满足所有客户需要的情况。后来该算法被用在操作系统中,用于避免死锁。

  • 核心思想:在进程提出资源申请时,先预判此次分配是否会导致系统进入不安全状态。如果会进入不安全状态,就暂时不答应这次请求,让该进程先阻塞等待。
  • 换言之,遍历所有的进程,比对当前的空闲资源数量和该进程仍然需要的资源数,判断是否满足最大需求,满足则将这个进程加入安全序列,更新回收进程释放的资源,不满足则跳过该进程,依次循环检测。

银行家问题的本质:

要设法保证系统动态分配资源后不进入不安全状态,以避免可能产生的死锁。
即:每当进程提出资源请求且系统的资源能够满足该请求时,系统将判断如果满足此次资源请求,系统状态是否安全,如果判断结果为安全,则给该进程分配资源,否则不分配资源,申请资源的进程将阻塞。

当Pi发出资源请求后,系统按下述步骤进行检查:
1. 如果Requesti > Needi,则出错。
2. 如果Requesti>Available,则Pi 阻塞;
3. 系统试探把要求的资源分配给进程Pi,并修改下面数据结构中的数值:
    Availablei=Availablei-Requesti;
    Allocationi=Allocationi+Requesti;
    Needi = Needi- Requesti;
4. 系统执行安全性算法,检查此次资源分配后,系统是否处于安全状态。
    若安全,正式将资源分配给进程Pi,以完成本次分配;
    否则,将试探分配作废,恢复原来的资源分配状态,让进程Pi等待。

数据结构

  1. 长度为 m 的一维数组 Available 表示还有多少可用资源
  2. n*m 矩阵 Max 表示各进程对资源的最大需求数
  3. n*m 矩阵 Allocation 表示已经给各进程分配了多少资源
  4. Max-Allocation =Need 矩阵表示各进程最多还需要多少资源
  5. 用长度为 m 的一位数组 Request 表示进程此次申请的各种资源数

银行家算法步骤:

  • ① 检查此次申请是否超过了之前声明的最大需求数
  • ② 检查此时系统剩余的可用资源是否还能满足这次请求
  • ③ 试探着分配,更改各数据结构
  • ④ 用安全性算法检查此次分配是否会导致系统进入不安全状态

安全性算法步骤:

  1. 检查当前的剩余可用资源是否能满足某个进程的最大需求,如果可以,就把该进程加入安全序列,并把该进程持有的资源全部回收。
  2. 不断重复上述过程,看最终是否能让所有进程都加入安全序列。

🧀 银行家算法从避免死锁的角度上说是非常有效的,但是,从某种意义上说,它缺乏实用价值,因为很少有进程能够在运行前就知道其所需资源的最大值,而且进程数也不是固定的,往往在不断地变化(如新用户登录或退出),况且原本可用的资源也可能突然间变成不可用(如磁带机可能坏掉)。因此,在实际中,如果有,也只有极少的系统使用银行家算法来避免死锁。

2.6 死锁检测及解除

❤️‍🔥 死锁预防和避免算法,其实都是在进程分配资源的时候试加限制条件或者检测,但是如果系统为进程分配资源时不采取任何措施,则应该提供死锁检测和解除的手段。

  • 死锁的检测和恢复技术是指定期启动一个软件检测系统的状态,若发现有死锁存在,则采取措施恢复之。
🥑 死锁检测 

为了能对系统是否已发生了死锁进行检测,必须:

  1. 用某种数据结构来保存资源的请求和分配信息。
  2. 提供一种算法,利用上述信息来检测系统是否已进入死锁状态。

🌮 如果系统中剩余的可用资源数足够满足进程的需求,那么这个进程暂时是不会阻塞的,可以顺利地执行下去。如果这个进程执行结束了把资源归还系统,就可能使某些正在等待资源的进程被激活,并顺利地执行下去。相应的,这些被激活的进程执行完了之后又会归还一些资源,这样可能又会激活另外一些阻塞的进程..
如果按上述过程分析,最终能消除所有边,就称这个图是 可完全简化的。此时一定没有发生死锁(相当于能找到一个安全序列)

  • 可以消除所有的边,说明未发生死锁,如果最终不能消除所有边,那么此时就是发生了死锁

死锁的检测可以利用资源分配图来分析,该数据结构包含如下的内容

 检测死锁的算法如下:

🔥 在资源分配图中,找出既不阻塞(请求资源节点的数量足够)又不是孤点的进程pi ,该请求边所申请的数量小于等于下同已有的空闲资源数量。所有的连接该进程的边均满足上述的条件,则这个进程就可以运行直至完成。然后释放自己拥有的资源,消除进程的请求边和分配边,使后释放自己拥有的资源,消除进程的请求边和分配边之成为孤立的节点。如果所有的节点可以被消除与其相连的边,则成为该图是可完全简化的,而且一定不会发生死锁。

  • 对于可以消除所有的边,则称这个图是可以简化的,则一定没有发生死锁。

  • 而如果最终不能消除所有边,那么就一定发生了死锁。

🎈 检查死锁的办法结论由软件检查系统中由进程和资源构成的有向图是否构成一个或多个环路,若是,则存在死锁,否则不存在。

由于死锁是系统中的恶性小概率事件,死锁检测程序的多次执行往往都不会调用一次死锁解除程序,而这却增加了系统开销,因此在设计操作系统时需要权衡检测精度与时间开销。

🥑 死锁解除

一旦检测出死锁的发生,就应该立刻解除死锁,死锁的检测就是通过简化资源分配图。解除死锁的主要方法:

(1)撤消进程法
  「撤消全部死锁进程」:强制杀死该进程,剥夺这些进程的资源。虽然实现简单,但是付出的代价可能会很大,部分进程很可能运行了很长时间,但是被杀之后,功亏一篑。代价太大,该做法很少用。
  「最小代价撤消法」:首先计算死锁进程的撤消代价,然后依次选择撤消代价最小的进程,逐个地撤消死锁进程,回收资源给其他进程,直至死锁不复存在。进程的撤消代价往往与进程的优先级、占用处理机的时间等成正比。
(2)挂起进程法 (剥夺资源)
  「资源剥夺法」: 使用 挂起/激活 机构挂起一些进程,剥夺它们的资源以解除死锁,并将这些资源分配给其他的死锁进程,待条件满足时,再激活进程。目前挂起法比较受到重视。 

🔥 显然,无论哪一种解除死锁的方法,都需要很大的开销。但是死锁的检测与解除办法不对系统的资源分配加任何限制,因此是对付死锁的诸办法中导致资源利用率最高的一种办法,在对安全性要求高的大型系统中常用。

3. STL 智能指针 线程安全 🐋

之前在这篇博客 【C++高阶】:智能指针的全面解析_智能指针详解  里面已经讲过智能指针的内容,感兴趣的可以看看这篇文章

3.1 STL中的容器是否是线程安全的?  

不是,原因是:STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响。 而且对于不同的容器, 加锁方式的不同, 性能可能也不同(例如hash表的锁表和锁桶)。 因此 STL 默认不是线程安全, 如果需要在多线程环境下使用,往往需要调用者自行保证线程安全

3.2 智能指针是否是线程安全的? 

对于 unique_ptr,由于只是在当前代码块范围内生效, 因此不涉及线程安全问题. 对于 shared_ptr, 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题. 但是标准库实现的时候考虑到了这 个问题, 基于原子操作(CAS)的方式保证 shared_ptr 能够高效, 原子的操作引用计数. 

3.3 其他常见的各种锁

  1. 悲观锁:在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。
  2. 乐观锁:每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前, 会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。
  3. CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。 
  4. 以及读写锁和自旋锁 【Linux】:多线程(读写锁 && 自旋锁) 这篇博客里面有详细说明

4. 补充 -- 深度理解互斥 📚

🔥 之前在这篇文章里面 【Linux】:多线程(互斥 && 同步) 我们已经了解了互斥的一些内容,并且手搓实现互斥量 Mutex 的封装,现在对其来进行一个更详细的理解

4.1 加锁粒度

🔥 加锁粒度(Lock Granularity)指的是在多线程或多进程程序中,锁定资源的范围或粒度大小。锁粒度越大,所保护的资源越多;锁粒度越小,保护的资源就越少

🍧 常见的加锁粒度类型:

  1. 粗粒度锁
    • 定义:锁住较大的资源范围,通常是整个数据结构或对象。
    • 优点:实现简单,容易理解和使用。
    • 缺点:会导致较多的线程等待,即使它们并不需要访问整个资源,容易引起性能瓶颈。
    • 例子:锁住整个数据结构(例如一个大数组或链表),即使每次只需要对其中的一部分进行修改。
  2. 细粒度锁
    • 定义:锁住较小的资源范围,通常是数据结构中的单一元素或较小的部分。
    • 优点:减少了线程间的竞争,提高了并发性和性能。
    • 缺点:实现较复杂,可能需要更多的锁管理和协调,容易引发死锁。
    • 例子:对数据结构中的每个元素或节点加锁,或者使用不同的锁保护数据的不同部分。
  3. 无锁(Lock-Free):
    • 定义:在某些情况下,避免使用显式的锁,而是通过原子操作或其他技术来保证线程安全。
    • 优点:避免了锁带来的性能问题,减少了上下文切换和线程竞争。
    • 缺点:实现复杂,调试和维护难度较大。
    • 例子:使用原子操作(如std::atomic)进行数据更新

🍧 选择加锁粒度时的考虑因素:

  • 性能:粗粒度锁可能导致不必要的等待,而细粒度锁可以提高并发性,减少线程间的竞争。
  • 复杂度:细粒度锁的管理复杂度较高,需要考虑死锁和锁的顺序问题。
  • 资源冲突:细粒度锁适用于多个线程需要访问不同部分的情况,而粗粒度锁适用于访问的资源较少,或资源冲突较少的情况。

🍧 加锁粒度越小越好(理解):

  • 减少阻塞范围:较小的锁粒度意味着临界区代码更短,线程持有锁的时间更短。当一个线程持有锁时,其他线程必须等待。因此,缩短锁的持有时间可以减少线程间的等待时间,提高并发性能,降低因线程阻塞导致的上下文切换开销。
  • 避免死锁可能性:细粒度锁有助于减少锁的嵌套,从而降低死锁的风险。当多个锁需要按照特定顺序获取时,如果锁的粒度较大,可能会导致复杂的锁依赖关系,增加死锁的可能性。较小的锁粒度使得锁的管理更为简单,更容易避免死锁。
  • 提高系统可伸缩性:在高并发场景下,细粒度锁允许更多的线程并行执行,因为它们可以在不影响其他线程所需资源的情况下独立工作。大粒度锁可能导致大量线程因争夺同一锁而陷入等待,限制了系统的并发能力。

🧀 举例说明:

💦 假设在一个电商系统中,我们需要对订单进行操作,其中包括更新商品库存、修改订单状态、记录用户购买行为等多个步骤。如果采用单一的粗粒度锁,比如在整个订单服务中只有一个全局锁,那么每次处理订单变更时都会锁定整个服务,即使各个操作之间并无直接的数据冲突。

// 大粒度锁:锁定整个订单服务
mutex orderServiceLock;
 
void processOrder(Order& order) {
    orderServiceLock.lock();
    updateProductInventory(order.productId, order.quantity); // 更新库存
    modifyOrderStatus(order.id, ORDER_STATUS_COMPLETED); // 修改订单状态
    logUserPurchaseBehavior(order.userId, order.productId); // 记录购买行为
    orderServiceLock.unlock();
}

🥎 在上述代码中,任何一个请求处理都需要获得全局锁才能进行操作,这就意味着如果有多个订单同时进来,必须逐个执行,无法并行处理,极大地降低了系统的并发性能

而改为细粒度锁方案,我们可以根据业务逻辑分别对不同的资源加锁

// 细粒度锁:按资源分别加锁
mutex productInventoryLock;
mutex orderStatusLock;
mutex userPurchaseLogLock;
 
void processOrder(Order& order) {
    productInventoryLock.lock();
    updateProductInventory(order.productId, order.quantity);
    productInventoryLock.unlock();
 
    orderStatusLock.lock();
    modifyOrderStatus(order.id, ORDER_STATUS_COMPLETED);
    orderStatusLock.unlock();
 
    userPurchaseLogLock.lock();
    logUserPurchaseBehavior(order.userId, order.productId);
    userPurchaseLogLock.unlock();
}

🔥 在这种情况下,如果三个操作涉及的是不同的资源(例如不同商品的库存、不同订单的状态和用户的购买历史),那么即使在高并发情况下,不同订单的部分操作也能并行执行,提高了系统的并发能力和整体性能。同时,由于锁的粒度较小,各线程持有锁的时间较短,减少了因争抢锁而导致的阻塞等待时间,进一步降低了死锁的发生概率。

实现细粒度锁的策略

  1. 局部变量加锁:如果可能,尽量将锁的范围限制在局部变量上,而不是整个数据结构。例如,如果全局变量是一个容器(如列表或字典),不要对整个容器加锁,而是针对每次插入、删除或查找操作单独加锁。
  2. 分段锁:对于大型数据结构,可以使用分段锁(如Java中的ConcurrentHashMap)或细粒度锁数组,将数据分成多个部分,每个部分有自己的锁。这样,即使在高并发情况下,不同的线程可以同时操作数据的不同部分,而不必全部等待同一把锁。
  3. 原子操作:对于简单的数值型全局变量,如果支持的话,可以使用原子操作(如AtomicInteger、std::atomic等)代替锁。原子操作在硬件级别保证了操作的完整性,无需显式加锁,提供了极细粒度的同步。
  4. 无锁数据结构和算法:在某些场景下,可以使用专门设计的无锁数据结构和算法,它们通过CAS(Compare-and-Swap)等非阻塞同步原语来实现线程安全,进一步降低锁的使用和开销。

🔥 综上所述,为了防止多线程访问全局变量时互相影响,应使用加锁机制确保访问的原子性和一致性。同时,遵循 “加锁粒度越小越好” 的原则,通过减少阻塞范围、避免死锁以及提高系统可伸缩性,来优化多线程程序的并发性能和稳定性。

4.2 深入理解互斥锁

加锁后,线程在临界区中是否会切换,会有问题吗?
答案是:会切换,但这并不会引起问题

① 为什么加锁后线程被切换不会引起问题?

  • 持有锁的线程被切换:当一个线程获得了锁并进入了临界区,即使由于某种原因(如时间片轮换、I/O操作等)导致该线程被切换到后台,它依然持有锁。这意味着其他线程不能进入临界区,因为它们无法获取已被占用的锁。
  • 保证数据一致性:尽管线程可能被切换,但只要它持有锁,就能保证在它再次获得CPU时间时,能够继续执行临界区内的代码,并完成对其共享资源的操作。因此,即使存在上下文切换,也不会破坏数据的一致性
  • 原子性:锁的机制确保了对临界区的访问具有原子性,即要么完整地执行临界区内的代码,要么根本不执行。这意味着一旦线程获取了锁,即使被切换,它仍然是唯一可以继续执行临界区代码的线程。

② 原子性体现

对于一个没有持有锁的线程2来说,它面临的情况只有两种:

  • 线程1 没有持有锁:这意味着当前没有线程正在访问临界区临界区处于空闲状态。此时,这个线程可以尝试获取锁,并开始执行临界区内的代码。
  • 线程1 释放锁:这意味着线程1已经完成了对临界区的访问,并释放了锁。此时,其他等待的线程可以尝试获取锁,进入临界区执行。

当一个线程成功获取锁后,它就可以独占临界区内的资源,这意味着在这段时间内,其他线程不能进入临界区执行代码或访问资源。这种操作被称为原子性的,因为它要么完全发生(获取锁并执行临界区代码),要么根本不发生(无法获取锁,线程被阻塞)。

  • 这种特性确保了共享资源的一致性和完整性。

③ 加锁是否意味着串行执行?

是的,在使用互斥锁保护的临界区内,线程执行是串行的

🍒 具体来说,当一个线程成功获取到互斥锁并进入临界区后,其他试图获取该锁的线程将被阻塞,直到持有锁的线程执行完毕临界区代码并释放锁。这种机制确保了在同一时刻,只有一个线程能够访问和修改受保护的共享资源(在这里是 tickets 变量)。尽管线程间的调度仍然是不确定的,但在互斥锁的约束下,对临界区的访问是有序的不可重叠的,从而实现了对共享资源的串行化访问

④ 锁也是共享资源?

正确,锁本身确实是一种共享资源,因为所有试图访问受保护资源的线程都需要与之交互

  • 每个线程都必须先尝试获取这把锁,只有成功获取锁的线程才能进入临界区。其他线程在锁被释放之前只能等待。这种共享性是线程间协作的基础,通过统一的锁机制协调对共享资源的访问顺序。

⑤ 谁来保证锁的安全?

🎂 确保锁的安全是非常重要的,因为它直接关系到多线程程序的正确性和数据的一致性。锁的安全性主要包括两个方面:一是锁本身的原子性,二是锁使用的正确性

锁的原子性:锁的原子性指的是锁的申请和释放操作必须是不可分割的,即这两个操作要么全部完成,要么都不发生

  • 申请锁(加锁):必须是一个原子操作,确保在申请锁的过程中不会被其他线程打断。
  • 释放锁(解锁):同样必须是一个原子操作,确保在释放锁的过程中不会被其他线程打断。

锁使用的正确性:除了锁本身的操作必须是原子的之外,还需要保证锁在整个使用过程中是安全的,这包括但不限于:

  1. 互斥性:确保在任何时刻只有一个线程持有锁。
  2. 死锁避免:避免多个线程因互相等待对方释放锁而导致的死锁情况。
  3. 资源访问的正确性:确保在持有锁的情况下,线程可以安全地访问共享资源。

4.3 互斥锁实现原理

互斥锁实现原理(本质):以一条汇编的方式,将内存和CPU内寄存区数据进行交互

寄存器的本质

🏀 在计算机系统中,寄存器是一组小容量的高速存储单元,它们位于CPU内部,用于暂存数据和指令。寄存器通常用于快速存储和访问临时数据,如算术运算的中间结果、指针、状态标志等。寄存器的速度远快于主内存,因为它们直接集成在处理器内部,减少了数据传输的时间延迟

寄存器作为当前执行流的上下文

⚽ 在多线程或多执行流的视角下,寄存器可以被视为当前执行流的上下文。这里所说的“上下文”,是指执行流在某一时刻的所有必要状态信息,包括但不限于寄存器的内容、程序计数器(PC)、栈指针(SP)等。

寄存器的空间共享与内容私有

🏸 虽然物理上的寄存器硬件是所有执行流所共享的,但寄存器的内容却是每个执行流私有的。这意味着,虽然多个线程或执行流可能看起来共享同一组寄存器,但当一个执行流正在执行时,它会拥有这些寄存器内容的独占使用权。具体而言:

  1. 寄存器的空间共享:从硬件层面来看,寄存器的物理地址是固定的,所有的执行流都指向这些固定的地址。
  2. 寄存器的内容私有:当一个多线程程序运行时,操作系统或硬件会为每个线程保存一组寄存器状态。当线程被切换时,当前线程的寄存器内容会被保存下来,而新选择的线程的寄存器内容会被加载到寄存器中。这个过程确保了每个线程在执行时看到的寄存器内容是自己独有的。 
⛳ 结合汇编伪代码理解互斥锁

lock和unlock汇编伪代码

lock:
        movb $0,%al
        xchgb al,mutex
        if(al寄存器的内容>0){
                return 0;
        }else
                挂起等待;
        goto lock;
unlock:
        movb $1,mutex
        唤醒等待Mutex的线程;
        return 0;

锁操作详解

  1. 初始化寄存器:movb $0, %al 将 0 移动到寄存器 %al 中。
  2. 交换并获取锁:xchgb %al, mutex 将 %al 中的值与内存中的 mutex 交换。在创建锁之后,mutex 的初始值通常是 1,如果 mutex 原本为 0,则表示锁被占用。
  3. 比较并重试:cmpb $0, %al, 比较 %al 是否大于 0。如果是,则表示锁已成功获取;如果不是,则跳转到 lock 标签处重试。
  4. 解锁操作:movb $1, mutex 将 1 写入 mutex,表示解锁。然后通过其他机制唤醒等待锁的线程。

交换的现象:内存与%al 做交换

  • lock:这部分是获取互斥锁的过程。首先将寄存器 al 设置为0(movb $0, %al),然后使用xchg 指令交换 al 的内容与 mutex 变量的值。如果mutex的初始值大于0,说明已经有其他线程持有该锁,那么当前线程就返回0并挂起等待;否则,它会继续执行并获得锁。
  • unlock:这部分是释放互斥锁的过程。同样地,将寄存器 al 设置为1(movb $1, mutex),然后唤醒等待 Mutex 的线程,并返回0。

🔥 对于 swapexchange 这样的指令,它们通常用于在内存和寄存器之间交换数据,而且在一些体系结构中,这类指令是可以保证原子性的,即在多线程环境下,不会有其他线程能在指令执行过程中中断并改变被交换的数据。例如,在x86架构中,可以用 xchg 指令实现寄存器和内存位置的数据原子交换。

  • 在执行流视角来看,CPU寄存器就是当前执行流状态的重要组成部分,它们反映了当前执行点的关键信息,如算术逻辑运算的中间结果、函数调用的返回地址、栈指针等。每条执行流有自己的寄存器上下文,保证了各自执行的独立性和连续性
  • 在汇编层面,swap 或exchange 指令常用于实现内存与CPU内部寄存器间的数据原子性交换。一旦这条汇编指令仅由单条语句构成,我们通常认为该指令在执行期间是不可分割的,即原子操作,不会受到其他执行流的干扰。

交换的本质:共享<->私有:

  • 从执行流的角度审视CPU内部的寄存器,它们本质上构成了当前执行流的运行环境或上下文。
    • 尽管所有执行流共享CPU内部的寄存器空间资源,但每个独立的执行流(如线程或进程)对其使用的寄存器内容享有专属权
    • 也就是说,寄存器的具体值是各个执行流私有的状态信息。
  • 换言之,在并发或多线程环境中,尽管物理上的寄存器空间是公共资源,但CPU通过巧妙的上下文切换机制,确保每个执行流在运行时都有自己独特且独立的寄存器内容
  • 因此,可以说寄存器的内容反映了执行流在某一特定时间点的执行状态和局部数据,这种状态是与其他执行流隔离的。

4.4 结合实例理解互斥锁原理

📕 在下方的图示中,可以看到一个CPU和内存之间的交互过程。当CPU尝试获取锁时,它需要检查内存中的mutex变量的状态。如果状态为0,则可以成功获取锁;反之,如果状态非零,则表示另一个线程已经持有了锁,此时CPU需要等待。

🔥 在这个场景中,有两个线程A和B试图同时访问同一段共享资源(例如一段内存区域或一个变量)。为了防止多个线程同时修改这段共享资源导致的数据不一致或其他问题,我们需要一种机制来保证每次只有一个线程能够访问这段资源。

🛹 互斥锁就是这样的一个机制。它提供了一种方式来控制对共享资源的访问,使得在同一时刻只有一个线程能够拥有锁并访问资源。当一个线程想要访问共享资源时,它必须先尝试获取锁。如果锁已经被其他线程持有,那么这个线程就会被阻塞并进入等待状态,直到锁被释放为止。

  1. 线程A尝试获取锁:
    1. A线程首先尝试获取锁,由于此时锁还没有被任何线程持有,所以A线程能够成功获取锁。
    2. 在获取锁之后,A线程就可以安全地访问共享资源而不用担心与其他线程发生冲突。
  2. 线程B尝试获取锁:
    1. B线程也尝试获取锁,但由于锁已经被A线程持有,所以B线程无法立即获取锁。
    2. 此时,B线程会被阻塞并进入等待状态,直到A线程完成对共享资源的操作并释放锁。
  3. 线程A释放锁:当A线程完成对共享资源的操作后,它会释放锁,这使得其他正在等待锁的线程有机会重新尝试获取锁。
  4. 线程B获取锁:
    1. 在A线程释放锁之后,B线程可以从等待状态恢复过来,并尝试再次获取锁。
    2. 这次,由于没有其他线程持有锁,所以B线程能够成功获取锁并开始访问共享资源。

🧃 通过这种方式,我们可以在多线程环境中实现对共享资源的安全访问,避免了数据竞争和其他并发问题。每个线程都必须遵循相同的规则来获取和释放锁,以确保所有线程都能正确地协调它们对共享资源的访问。

5. 共勉🔥

【*★,°*:.☆( ̄▽ ̄)/$:*.°★* 】那么本篇到此就结束啦,如果有不懂 和 发现问题的小伙伴可以在评论区说出来哦,同时我还会继续更新关于【Linux】的内容,请持续关注我 !!💞

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/942848.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

AI应用-本地模型实现AI生成PPT(简易版)

文章目录 前言技术栈效果展示 一、实现思路二、实现步骤1.本地安装marp-cli2.后端实现3.前端实现 三、代码地址及说明 前言 在许多项目中&#xff0c;生成 PPT 是常见的需求&#xff0c;尤其在教育和报告展示中。传统的生成 PPT 的方法需要手动创建&#xff0c;而使用生成模型…

Linux 服务管理 service systemd systemctl

Linux 服务管理 service systemd systemctl 1 介绍1.1 概述1.2 发展历程1.3 特点1.4 linux 服务管理 发展历程Ubuntu 14.04及更早版本&#xff1a;使用upstart作为默认的init系统旧版本新版本 Ubuntu 15.04到16.04版本&#xff1a;默认使用systemd作为init系统&#xff0c;但仍…

vue 基础学习

一、ref 和reactive 区别 <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><title>Title</title> </head> <body><div id"app"><h1>{{Web.title}}</h1><h1&…

光谱相机在农业中的具体应用案例

作物生长监测与产量预测 美国爱荷华州玉米种植园&#xff1a;农场主使用无人机搭载高光谱相机&#xff0c;定期对玉米田进行拍摄。通过分析光谱数据&#xff0c;获取玉米的叶面积指数、叶绿素含量等生长参数。在玉米生长关键期&#xff0c;依据这些参数及时调整施肥和灌溉方案…

LSTM-SVM时序预测 | Matlab基于LSTM-SVM基于长短期记忆神经网络-支持向量机时间序列预测

LSTM-SVM时序预测 | Matlab基于LSTM-SVM基于长短期记忆神经网络-支持向量机时间序列预测 目录 LSTM-SVM时序预测 | Matlab基于LSTM-SVM基于长短期记忆神经网络-支持向量机时间序列预测预测效果基本介绍程序设计参考资料 预测效果 基本介绍 1.LSTM-SVM时序预测 | Matlab基于LSTM…

05.HTTPS的实现原理-HTTPS的握手流程(TLS1.2)

05.HTTPS的实现原理-HTTPS的握手流程&#xff08;TLS1.2&#xff09; 简介1. TLS握手过程概述2. TLS握手过程细化3. 主密钥&#xff08;对称密钥&#xff09;生成过程4. 密码规范变更 简介 主要讲述了混合加密流程完成后&#xff0c;客户端和服务器如何共同获得相同的对称密钥…

PostgreSQL编译安装教程

下载安装 1.在家目录创建一个文件夹放下载安装包 mkdir softwarecd software 2.下载文件压缩包 wget https://ftp.postgresql.org/pub/source/v16.0/postgresql-16.0.tar.gz 3.解压 tar -xzvf postgresql-16.0.tar.gz 4.编译 在software/postgresql-16.0下 cd software…

009-spring-bean的实例化流程

1 spring容器初始化时&#xff0c;将xml配置的bean 信息封装在 beandefinition对象 2 所有的beandefinition存储在 beandefinitionMap的map集合中 3 spring对map进行遍历&#xff0c;使用反射创建bean实例对象 4 创建好的bean存在名为singletonObjects的map集合中 5 调用ge…

定位方式:css

使用相对路径 div ul #div下的所有ul&#xff0c;空格表示相对路径&#xff08;这个实际中用的多一些&#xff09; 绝对路径-一般不用绝对路径 html>head>div&#xff0c;“>”表示根路径 使用class名称定位 使用.表示 使用id定位 使用#表示 使用属性定位 [属性名…

基于深度学习(HyperLPR3框架)的中文车牌识别系统-搭建开发环境

本篇内容为搭建开发环境。包括&#xff1a;python开发环境&#xff0c;Qt/C开发环境&#xff0c;以及用到的各个库的安装和配置。 一、Python开发环境搭建与配置 1、下载并安装Anaconda 我没有用最新的版本&#xff0c;安装的是 Anaconda3-2021.05-Windows-x86_64.exe&#…

kamailio.cfg 图形化

kamailio.cfg 文件随着业务增加&#xff0c;会越来越大&#xff0c;越来越复杂&#xff0c;很难看出整体结构来。 有没有什么工具看出整体轮廓呢?有。主要依靠下面的python文件&#xff1a; python脚本 调用命令如下&#xff1a; python3 route_graph.py kamailio.cfg 执…

【Linux】进程控制,手搓简洁版shell

⭐️个人主页&#xff1a;小羊 ⭐️所属专栏&#xff1a;Linux 很荣幸您能阅读我的文章&#xff0c;诚请评论指点&#xff0c;欢迎欢迎 ~ 目录 1、进程创建2、进程终止3、进程等待4、进程程序替换5、手写简洁版shell 1、进程创建 fork函数&#xff1a;从已经存在的进程中创…

rabbitmq相关使用

使用rabbitmq实现异步解耦 使用步骤&#xff1a; 1、pom依赖 <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-amqp</artifactId> </dependency> 2、yml配置文件 spring:rabbitmq:host: 12…

单机和微服务的区别,微服务有什么问题?数据一致性问题怎么解决?幂等问题怎么解决?

单机和微服务的区别&#xff0c;微服务有什么问题&#xff1f;数据一致性问题怎么解决&#xff1f;幂等问题怎么解决&#xff1f; 单机架构和微服务架构在设计理念、部署和扩展性上有显著区别。 单机架构 vs 微服务架构 单机架构 定义&#xff1a;所有组件&#xff08;前端…

VUE3+django接口自动化部署平台部署说明文档(使用说明,需要私信)

网址连接&#xff1a;http://118.25.110.213:5200/#/login 账号/密码&#xff1a;renxiaoyong 1、VUE3部署本地。 1.1本地安装部署node.js 1.2安装vue脚手架 npm install -g vue/cli # 或者 yarn global add vue/cli1.3创建本地项目 vue create my-vue-project1.4安装依赖和插…

overleaf中出现TeX capacity exceeded PDF object stream buffer=5000000的原因和解决方案

在插入pdf 配图后&#xff0c;编译出错提示信息如图&#xff0c;很可能的一个原因是pdf文件大小太大了&#xff0c;最好压缩一下&#xff0c;压缩到1MB以内。

Golang的并发编程问题解决

Golang的并发编程问题解决 第一部分&#xff1a;Golang并发编程基础 并发与并行 在计算机领域&#xff0c;经常会听到并发和并行这两个词语。简单来说&#xff0c;并发是指在单处理器上通过时间片轮转实现多任务同时执行&#xff0c;而并行则是指多个任务在多个处理器上同时执行…

【玩转MacBook】Maven安装

下载Maven 官网&#xff1a; https://maven.apache.org/download.cgi 下载 Zip 类型的压缩包&#xff1a; 配置环境变量 以管理员身份编辑配置文件。注意&#xff0c;由于 MacBook 上使用了 zsh 命令行&#xff0c;所以需要编辑~/.zshrc文件而不是~/.bash_profile文件&am…

MySQL数据库(锁)

1、MySQL有哪些锁&#xff1f; 全局锁&#xff1a;flush tables with read lock 执行以下语句之后&#xff0c;使用全局锁&#xff0c;整个数据库就处于只读状态了&#xff0c;这时其他线程执行对数据的增删改或者对表结构的更改操作操作&#xff0c;都会被阻塞。 全局锁的应…

HarmonyOS NEXT 实战之元服务:静态案例效果(二)

背景&#xff1a; 前几篇学习了元服务&#xff0c;后面几期就让我们开发简单的元服务吧&#xff0c;里面丰富的内容大家自己加&#xff0c;本期案例 仅供参考 先上本期效果图 &#xff0c;里面图片自行替换 效果图代码案例如下&#xff1a; Index里面实现 import { authent…