【Linux】线程互斥

在这里插入图片描述

👦个人主页:Weraphael
✍🏻作者简介:目前正在学习c++和算法
✈️专栏:Linux
🐋 希望大家多多支持,咱一起进步!😁
如果文章有啥瑕疵,希望大佬指点一二
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍


一、相关背景概念

  • 临界资源:多执行流共享的资源就叫做临界资源。
  • 临界区:执行流访问临界资源的代码。
  • 互斥:本质就是任何时刻,只允许一个执行流访问共享资源(保护共享资源免受并发访问的影响)
  • 原子性:指的是一个操作是不可中断的,只有两种状态:要么执行完毕,要么没有执行。没有正在执行这一说法

以上概念均在往期博客说过:点击跳转

二、多线程的并发访问

2.1 前言

线程使用的数据都是局部变量,变量存储在线程的独立栈空间内。这种情况,变量归属单个线程,其他线程无法获得这种变量(其实也可以获取,只是不这么做而已)。

但有时候,很多变量都需要在线程间共享,这样的变量称为共享资源。那现在就会有一个问题:这个共享资源在被多线程并发访问的时候(并发访问:指的是系统能够同时处理多个任务),有没有可能出现一个线程正在访问,另一个线程正在写呢?

答案是当然有可能!比方说有一个电影院,里面有多个售票窗口。每个窗口(线程)可以同时处理多个观众(任务),这些观众都想购买电影票。如果所有售票窗口都能够同时处理售票请求(并发访问),可能会出现多个观众同时选择同一张座位。

即多线程的可能会导致线程读取到不一致或者不正确的值,或者写入线程的修改被读取线程所干扰,造成数据污染或者逻辑错误。

接下来引入一个日常生活例子:买票。来帮助大家更好理解这一现象。

2.2 例子引入:买票

以下是用多线程来模拟用户买票。有1000张票和4个线程,4个线程同时抢票。

其中规定:

  • 票的编号代表座位号。即一个影院厅做多有1000个位置。不考虑特殊情况,如婴儿家长手抱等。
  • 票数为0表示票已经售完了。
#include <iostream>
#include <pthread.h>
#include <vector>
#include <string>
#include <unistd.h>

using namespace std;

int tickets = 1000;  // 1000张票
#define THREAD_NUM 4 // 4个售票窗口

class threadData
{
public:
    threadData(int number)
    {
        threadname = "thread-" + to_string(number);
    }

public:
    string threadname;
};

void *getTicket(void *args)
{
    threadData *td = static_cast<threadData *>(args);
    const char *name = td->threadname.c_str();
    while (true)
    {
        // 有票就抢
        if (tickets > 0)
        {
            printf("who = %s, get a ticket: %d\n", name, tickets);
            --tickets;
        }
        else
        {
            break;
        }
    }
    printf("%s ... quit\n", name);
    return nullptr;
}

int main()
{
    vector<pthread_t> tids;
    vector<threadData *> thread_datas;

    for (int i = 1; i <= THREAD_NUM; i++)
    {
        pthread_t tid;
        threadData *td = new threadData(i);
        thread_datas.push_back(td);
        pthread_create(&tid, nullptr, getTicket, thread_datas[i - 1]);
        tids.push_back(tid);
    }
    
    for (auto thread : tids)
    {
        pthread_join(thread, nullptr);
    }
    
    for (auto td : thread_datas)
    {
        delete td;
    }
    
    return 0;
}

【程序结果】

在这里插入图片描述

如果,一个卖票程序出现了4种不同情况,这不是搞嘛。显然多线程并发访问是绝对存在问题的!

2.3 解释多线程并发访问的问题

全局变量tickets是所有线程的共享数据,这个共享数据在被多线程并发读写时,并没有我们期望的那样,而这种情况我们称数据在无保护的情况下,造成数据不一致问题。这个数据不一致的原因肯定和多线程并发访问是有关系的。

其中,每个线程无非就是判断线程是否有票,有票就做--操作。然而对一个临界资源(共享资源)进行多线程并发访问,--操作是不安全的(++操作同理)

注意:从直观上看,--是由一条语句完成的,但其实并不是!通过翻译成反汇编是三条

在这里插入图片描述

步骤如下:

  1. 先将tickets从内存读入到CPU的寄存器中。
  2. 再在CPU内部对其进行--操作 。
  3. 最后将计算完成的结果写回内存中。

在这里插入图片描述

CPU再执行这三条语句的过程中,线程都有可能被切换走,即一个线程执行--操作时,可能会在中间被暂停,而另一个线程开始执行

  • 线程被切换的时候,需要保存寄存器上下文。

  • 线程被换回的时候,需要恢复寄存器上下文。

  • 寄存器上下文:当操作系统切换执行不同的执行时,它会保存当前执行流的寄存器状态,包括程序计数器PC和其他寄存器的内容。这些信息会被保存在用户级线程控制块中。当线程再次被调度执行时,之前保存的寄存器状态会被恢复,使得线程可以从上次中断的地方继续执行。

  • 注意:虽然线程共享进程地址空间,但它也有自己栈空间、寄存器等(最重要的就是这两个)

举个例子:

  • 线程1正在在执行--tickets的任务,且初始时tickets1000。当tickets在寄存器已经计算一次完毕,即tickets = 9999,准备将计算的结果写回内存的时候,此时发生了线程切换(由线程1切至线程1)。线程1要保存寄存器的上下文,此时寄存器里的值9999
  • 接下来,线程2也要执行--tckets的任务,且线程2运气非常好,它运行过程中没有发生线程切换,因此可以不断循环此--操作(读到寄存器,计算,返回结果)。但是,当tickets自减到11的时候,再次--,读取寄存器,自减到10,准备将结果写回内存的时候,线程2被切走了。同样的,线程2将数据值10保存自己的上下文数据。注意此时内存中tickets的数据是11,而不是10
  • 线程1被切回来了,需要恢复上下文(999重新读回CPU的寄存器里),然后从上次切换的地方继续执行,即将计算结果999写回内存。此时内存中tickets11变成了999
  • 明明票还剩下11张(编号[1,11]的位置),这下可好,999又可以被别人买了。

因此,上述就是典型的数据不一致问题!所以,--操作不是原子的,或者可以说--操作对多线程访问临界资源是不安全的

另外,不只--会出现数据不一致的问题,判断tickets > 0时也同样会出现数据不一致

常识告诉我们:购票需要时间,买票成功后也需要时间,这里通过usleep函数模拟耗费时间。

【关键代码段】

void *getTicket(void *args)
{
    threadData *td = static_cast<threadData *>(args);
    const char *name = td->threadname.c_str();
    while (true)
    {
        // 有票就抢
        if (tickets > 0)
        {
            usleep(1000); // 抢票花的时间
            printf("who = %s, get a ticket: %d\n", name, tickets);
            --tickets;
        }
        else
        {
            break;
        }
    }
    printf("%s ... quit\n", name);
    
    return nullptr;
}

【程序结果】

在这里插入图片描述

解释如下:

  • 判断的过程也是一种运算(逻辑运算)。CPU内部进行的运算分为两类:逻辑运算、算术运算。因此在判断的时候还是要将数值写入到寄存器中。

  • 现假设tickets的值为1,此时线程1执行if判断,此步骤同样需要在CPU内的寄存器执行,tickets == 1 > 0,判断后准备把返回结果正好发生线程切换(由线程1切至线程2)。此时ticket在内存中的值是1

  • 线程2也要执行if判断,把1从内存读到CPU寄存器里判断,发现tickets == 1 > 0,判断后返回结果到内存,随后执行--tickets语句,计算后把结果0返回至内存。

  • 此时线程切换回至线程1,线程1继续执行未完成的--tickets语句,这次是将tickets == 0去自减,计算后把结果-1返回至内存。

这就是为什么买票结果为负数的原因了。

能够出现数据不一致的问题本质还是线程切换过于频繁。而线程切换的场景如下:

  • 时间片:大多数操作系统使用时间片轮转调度算法来分配处理器时间。当一个线程的时间片用完(通常是几毫秒到几十毫秒),操作系统会暂停该线程的执行,并将处理器分配给另一个准备好的线程。

  • 线程阻塞:当线程执行过程中发生阻塞(以上就是线程阻塞现象),操作系统会将该线程标记为不可执行状态,并在条件满足后唤醒它,这时可能会进行线程切换。

  • 系统调用:当线程需要执行系统调用,操作系统可能会暂停当前线程,处理器会从用户态切换到内核态来执行操作系统的代码,这通常需要进行线程切换。

如何解决多线程访问临界资源导致数据不一致问题呢?

对于共享数据(临界资源)的访问,只要确保:任何时候只有一个执行流可以访问临界资源,即保证--的行为是原子的。在技术层面上,可以通过加锁进行保护Linux上提供的这把锁叫互斥锁

三、Linux互斥锁相关接口

3.1 前言

  • Linux系统中,互斥锁相关的接口函数同样位于原生线程库pthread中,函数名通常以pthread_mutex_ 开头。
  • 互斥锁相关的接口函数的参数均有用到pthread_mutex_t类型,这是原生线程库pthread封装的,开发者通常不需要知道其内部结构的细节,只需使用提供的函数接口进行操作即可。
  • 这些函数用于创建、销毁、锁定(加锁)、解锁互斥锁等操作,确保多线程程序中的共享资源可以安全访问,避免数据不一致的发生。

3.2 初始化互斥锁

初始化互斥锁有两种方法:

  • 方法一:静态分配

使用静态分配时,互斥锁必须定义为全局锁,并且可以直接通过初始化变量来完成。它是通过使用 PTHREAD_MUTEX_INITIALIZER宏来静态初始化一个互斥锁变量

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

注意:对于静态分配的互斥锁对象,是不需要显式调用pthread_mutex_destroy函数来销毁它。静态分配的互斥锁会在程序结束时自动释放其资源,因为它们的生命周期与程序的生命周期相同。

  • 方法二:动态分配。需要通过pthread_mutex_init函数来进行初始化
#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t *restrict mutex, 
					   const pthread_mutexattr_t *restrict attr);
// 定义一把互斥锁
pthread_mutex_t lock;
// 初始化互斥锁
pthread_mutex_init(&lock, nullptr)

说明:

  • mutex:是一个指向pthread_mutex_t结构的指针,用来表示要初始化的互斥锁对象。
  • attr:是一个指向pthread_mutexattr_t结构的指针,用来指定互斥锁的属性一般直接设置为nullptr,表示使用默认属性
  • 返回值:成功返回0,失败返回错误码

3.3 销毁互斥锁

互斥锁是一种向系统申请的资源,在使用完毕后需要销毁

#include <pthread.h>

int pthread_mutex_destroy(pthread_mutex_t *mutex);

说明:

  • mutex指向要销毁的互斥锁对象的指针,类型为pthread_mutex_t*
  • 返回值:销毁成功返回0,失败返回非0的错误码

销毁互斥锁需要注意:

  1. 使用PTHREAD_ MUTEX_ INITIALIZER初始化的互斥量不需要销毁。
  2. 不再需要互斥锁时,需要显式地调用pthread_mutex_destroy函数来释放其占用的资源。这是为了避免内存泄漏和资源浪费
  3. 调用pthread_mutex_destroy时,确保没有其他线程正在使用该互斥锁,否则会导致未定义的行为。可以在线程等待后调用即可
  4. 不能重复销毁互斥锁。

【修改买票代码(核心代码)】

int main()
{
    // ======== 定义互斥锁  ======
    pthread_mutex_t lock;
    // ======== 初始化互斥锁 ======
    pthread_mutex_init(&lock, nullptr);

    vector<pthread_t> tids;
    vector<threadData *> thread_datas;

    for (int i = 1; i <= THREAD_NUM; i++)
    {
        pthread_t tid;
        // 使不同线程看到同一把锁
        threadData *td = new threadData(i, &lock);
        thread_datas.push_back(td);
        pthread_create(&tid, nullptr, getTicket, thread_datas[i - 1]);
        tids.push_back(tid);
    }
    for (auto thread : tids)
    {
        pthread_join(thread, nullptr);
    }
    for (auto td : thread_datas)
    {
        delete td;
    }
    // ===== 销毁互斥锁 =====
    pthread_mutex_destroy(&lock);
    return 0;
}

注意:对于多线程来说,应该让线程看到同一把锁。即要将在main函数定义的锁传给线程所执行的函数getTicket

class threadData
{
public:
    threadData(int number, pthread_mutex_t *mutex)
    {
        threadname = "thread-" + to_string(number);
        lock = mutex;
    }

public:
    string threadname;
    pthread_mutex_t *lock;
};

3.4 加锁操作

加锁操作保证了多个线程不会同时进入被保护的临界区,从而避免了数据不一致问题。即如果某个线程成功获取了互斥锁,它将可以独自并安全地访问共享资源或者临界区

#include <pthread.h>

int pthread_mutex_lock(pthread_mutex_t *mutex);

说明:

  • mutex指向要锁定的互斥锁对象的指针,类型为pthread_mutex_t*
  • 返回值:成功返回0,失败返回一个非零的错误码,可以通过errno全局变量获取具体的错误信息

注意:虽然加锁解决了数据一致性问题,但天下没有免费的午餐,当调用pthread_mutex_lock时,如果互斥锁已经被线程A持有,表示当前只有线程A可以访问临界区。若并发访问的线程B也要访问临界区,那么线程B将会被阻塞,直到该互斥锁被解锁为止,即等待锁资源就绪

【总结】

  • 加锁的表现:线程对于临界区代码串行执行。即只有一个线程访问完共享资源,另一个线程才能访问。相当于食堂排队打饭。
  • 加锁的原则:尽量保证临界区代码越少越好。因为线程都是并发访问的,如果阻塞的时间变长,则会降低多线程的并发度,进而降低效率。
  • 加锁的本质:用时间换安全

3.5 解锁操作

#include <pthread.h>

int pthread_mutex_unlock(pthread_mutex_t *mutex);

说明:

  • mutex指向要解锁的互斥锁对象的指针,类型为pthread_mutex_t*
  • 返回值:如果成功返回值0,如果失败返回一个非零的错误码,可以通过errno全局变量获取具体的错误信息

说明:当前线程获取锁资源并完成对临界资源的访问后,就应该进行解锁,将锁资源让出,供其他线程进行加锁。 如果不进行解锁操作,会导致后续线程无法申请到锁资源而永久阻塞,会引发死锁问题

四、改进售票系统

使用以上接口改进售票系统。

4.1 代码改进

【改进前】

在这里插入图片描述

【改进后】

#include <iostream>
#include <pthread.h>
#include <vector>
#include <string>
#include <unistd.h>

using namespace std;

int tickets = 1000;  // 1000张票
#define THREAD_NUM 4 // 4个售票窗口

class threadData
{
public:
    threadData(int number, pthread_mutex_t *mutex)
    {
        threadname = "thread-" + to_string(number);
        lock = mutex;
    }

public:
    string threadname;
    pthread_mutex_t *lock;
};

void *getTicket(void *args)
{
    threadData *td = static_cast<threadData *>(args);
    const char *name = td->threadname.c_str();
    while (true)
    {
        // 加锁
        pthread_mutex_lock(td->lock); // 线程申请锁资源,申请成功执行,失败则阻塞
        // ==================== 临界区 =====================
        if (tickets > 0) // 有票就抢
        {
            usleep(1000); // 抢票花的时间
            printf("who = %s, get a ticket: %d\n", name, tickets);
            --tickets;
            // 解锁
            pthread_mutex_unlock(td->lock);
        }
        else
        {
            // 解锁
            pthread_mutex_unlock(td->lock);
            break;
        }
        // ==================== 临界区 =====================
    }
    printf("%s ... quit\n", name);
    return nullptr;
}

int main()
{
    // ======== 定义互斥锁  ======
    pthread_mutex_t lock;
    // ======== 初始化互斥锁 ======
    pthread_mutex_init(&lock, nullptr);

    vector<pthread_t> tids;
    vector<threadData *> thread_datas;

    for (int i = 1; i <= THREAD_NUM; i++)
    {
        pthread_t tid;
        threadData *td = new threadData(i, &lock);
        thread_datas.push_back(td);
        pthread_create(&tid, nullptr, getTicket, thread_datas[i - 1]);
        tids.push_back(tid);
    }
    for (auto thread : tids)
    {
        pthread_join(thread, nullptr);
    }
    for (auto td : thread_datas)
    {
        delete td;
    }

    // ===== 销毁互斥锁 =====
    pthread_mutex_destroy(&lock);
    return 0;
}

【程序结果】

在这里插入图片描述

测试了三次,我们发现已经没有数据不一致的问题了。但是为什么都是一个执行流(线程)在买票

在多线程环境下,如果一个线程执行完临界区的代码后立即解锁,并且其他线程正在被阻塞,等待获取这个锁,那么操作系统需要进行调度,以决定哪个线程将会获得这个锁。这个过程可能涉及线程的唤醒和重新调度,而这些操作通常是有成本的,包括上下文切换和调度延迟。因此,每次申请到锁资源的线程都是第一次获取锁资源的线程。

【故事1

  • 就好比方说,有一个无人监管VVIP自习室,只能有一个人在里面学习。所以,为了保证任何时刻只能有一个人进来,大家都要遵守一个规则:门口有一把锁,谁获得这个锁资源,谁就有资格在里面学习。
  • 有一天你起的很早,当你来到自习室门口发现门旁有锁,于是你就把锁带进去了。当下一个同学来的时候,发现门旁没有锁,就只能在外面等待。突然某一个时刻,你饿了,想去食堂干饭。你刚刚出门准备把钥匙放回门旁,发现门外50m有一堆同学正在等待,为了能独享这个自习室,你就赶快拿回钥匙回到自习室了,然后自习从早到晚。 那么自习室外的这批人因为长时间得不到锁资源,导致了饥饿问题。
  • 因此,在多线程环境中,如果某个线程长时间占用了关键资源,其他线程可能因为无法获得资源而长时间等待,甚至导致饥饿问题

这种情况下,为了公平和效率,需要考虑资源分配的策略。学校再次规定:自习室外面的人(等待线程)必须排队,并且出来的人不能立马重新申请锁,必须排到队列的尾部。这种让所有同学(线程)获取锁按照一定的顺序获取资源,我们称为同步

所以,我们可以在每次抢完票以后等待一会(排队),让其他线程有机会申请锁资源

在这里插入图片描述

【程序结果】

在这里插入图片描述

每一个线程在访问临界资源前,都必须得干一件事:申请锁来保证当前临界资源只有一个执行流在访问。但锁本身也是临界资源(共享资源),保护别人的前提是先保护自己,那么谁来保证锁的安全呢

因此,库的设计者也考虑到了这个问题,于是将锁这种临界资源进行了特殊化处理加锁和解锁操作都是原子的,即不会被中断或被其他线程干扰,有且只有一个线程能得到锁资源。

至于是如何做到的,在【互斥锁的原理】中讲解。点击跳转

现在又有一个问题:加了锁之后,在临界区中,已经保证了一个线程具有执行临界资源的能力,那么在执行的过程中,该线程有没有被切换的可能?

答案是有可能被切换!那可能就有就有疑问:切换了不就不能保证--操作是原子的。再次引入上面的故事

【故事2
第二天,又是你第一个到达自习室的门口,你拿到锁就进去自习了,而后面陆陆续续来的同学只能在门口等待。但天不测风云,人有三急,此时此刻你的肚子非常疼,而你又不想失去锁。于是你就突发奇想:我直接把锁放在升上一起去上厕所不就完事了。即便自习室空无一人,但其他同学也无法进入自习室!因为他们没有锁。

你上厕所带着锁的行为可以看作:线程在持有锁资源的情况下被调度了。显然对于整体程序是没有影响的,因为锁自此自终都还在某一个线程上,持有锁的线程在操作系统调度器的管理下可能会被暂时挂起,即使发生线程切换,因为该线程没有锁,也就没有执行临界资源的权利

4.3 总结一波

  • 在多线程环境下,通过使用互斥锁来控制临界区的访问,保证任何时刻只有一个执行流访问临界资源,而且必须让所有线程看到的是同一把锁
  • 每一个线程访问临界区资源之前都要加锁,本质上是给临界区加锁
  • 加锁和解锁必须配套出现,并且它们的操作都是原子的
  • 线程在持有锁的情况下被切换是没有影响的
  • 当某个线程持有锁资源时,其他线程并不关心持有锁的线程的执行状态,而是关心该锁是否被释放。这种机制确保了临界区内的操作对其他线程来说是原子的(要么执行完毕,要么没有执行)

在这里插入图片描述

【题外话】
只要解决方案的出现,必然会产生新的问题:解决并发度(描述了系统或程序同时处理多少个独立任务或操作的能力)问题,引入多线程,但同时产生了并发访问的问题(数据不一致),随后又引入了锁。

五、互斥锁的实现原理

为了实现互斥锁操作,如今大多数CPU的体系结构(如ARMX86AMD等)都提供了一些特定的硬件操作指令,如swapexchange指令,这种指令可以把寄存器和内存单元的数据直接交换,这些语句在执行时只需要一条CPU指令来完成(在汇编或者底层机器语言)。该语句要么执行,要么不执行,只有两态,因此可以保证指令执行时的原子性

下面来看加锁函数pthread_mutex_lock和解锁函数pthread_mutex_unlock的伪汇编代码:

在这里插入图片描述

其中movb表示转移,al是一个寄存器,xchgb就是支持原子操作的exchange交换语句。另外,大家可以将锁mutex理解成内存中的一个整型变量,1代表当前有锁资源,反之没有。

每个线程申请锁时都要执行上述语句,执行步骤如下:

  1. movb $0,%al:将0加载到寄存器al中。
  2. xchgb %al,mutex:交换al寄存器和内存中mutex的值。
  3. 最后判断al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。

吸取了--操作的经验,我们发现多个线程在执行加锁函数pthread_mutex_lock也有可能会发生线程切换。那么申请锁资源不是也会导致数据不一致的问题吗?答案是不会的,因为我们一开始在上面说过,加锁和解锁的操作一定是原子

大家只要在纸上模拟一遍就可以证明出来了

在这里插入图片描述

因此,加锁操作和解锁之所以是原子的,主要依赖于CPU的指令集是原子的(如xchgb指令),确保了在执行期间不会被中断或者干扰,从而保证了操作的完整性,使得多线程程序能够有效地管理共享资源的访问

六、封装加锁和解锁操作

  • 版本一
#pragma once

#include <pthread.h>

class Mutex
{
public:
    Mutex(pthread_mutex_t *lock) : _lock(lock)
    {
    }
    ~Mutex()
    {
    }

    void Lock() // 加锁
    {
        pthread_mutex_lock(_lock);
    }

    void Unlock() // 解锁
    {
        pthread_mutex_unlock(_lock);
    }

private:
    pthread_mutex_t *_lock;
};

class LockGuard
{
public:
    LockGuard(pthread_mutex_t *lock)
        : _mutex(lock)
    {
        _mutex.Lock();
    }

    ~LockGuard()
    {
        _mutex.Unlock();
    }

private:
    Mutex _mutex;
};
  • 版本二
#pragma once

#include <pthread.h>

class LockGuard
{
public:
    LockGuard(pthread_mutex_t *lock)
        : _mutex(lock)
    {
        pthread_mutex_lock(_mutex);
    }

    ~LockGuard()
    {
        pthread_mutex_unlock(_mutex);
    }

private:
    pthread_mutex_t *_mutex;
};

代码非常的简单,主要的用途:用对象的生命周期来管理加锁和解锁操作

比方说:

在这里插入图片描述

像这种获取资源即初始化的风格称为 RAII风格,由C++之父本贾尼·斯特劳斯特卢普提出,非常巧妙的运用了 类和对象 的特性,实现半自动化操作。这里是首次遇到,后面学习`C++```智能指针时还会遇到。

七、线程安全 VS 重入

7.1 概念

  • 线程安全:多线程并发访问同一段代码时,不会出现不同的结果,此时就是线程安全的;但如果在没有加锁保护的情况下访问全局变量或静态变量,导致出现不同的结果,此时线程就是不安全。
  • 重入:同一个函数被多个线程(执行流)调用,当前一个执行流还没有执行完函数时,其他执行流可以进入该函数,这种行为称之为 重入;
    • 在发生重入时,函数运行结果不会出现问题,称该函数为可重入函数,
    • 否则就是不可重入函数。
    • 说明:是否可重入只是函数的一种特征,没有好坏之分。

7.2 常见线程不安全的情况

  • 不保护共享变量。
  • 函数的状态随着被调用,而导致状态发生变化。比如要统计一个函数被调用了多少次,在函数内部定义一个static int cnt = 0,那么我们说函数的状态随着被调用,而导致状态发生变化
  • 返回指向静态变量指针的函数。
  • 调用线程不安全函数的函数。

7.3 常见线程安全的情况

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

7.4 常见不可重入的情况

  • 调用了malloc / free函数,因为这些都是C语言提供的接口,通过全局链表进行管理。
  • 调用了标准I/O库函数,其中很多实现都是以不可重入的方式来使用全局数据结构。
  • 可重入函数体内使用了静态的数据结构。

我们目前写的99.99999999999999%的代码都是不可重入函数!!!

7.5 常见可重入的情况

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

7.6 重入与线程安全的联系

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

7.7 重入与线程安全的区别

  • 可重入函数是线程安全函数的一种。
  • 只要函数不可重入,在多线程调用时可能会出问题;但可重入函数一定是线程安全的。
  • 如果对于临界资源的访问加上锁,则这个函数是线程安全的;但如果这个重入函数中没有解锁而引发死锁,因此是不可被重入的。

总结

  • 线程安全描述的是线程并发的问题,可重入描述的是函数特点的问题。
  • 不可重入函数在多线程并发访问时,可能会出现线程安全问题;而一个函数时可重入的,则不会有线程安全问题。

八、死锁Deadlock

8.1 概念

死锁指的是:同一个进程中的多个线程实例中,这些线程彼此持有对方所需要的资源,导致所有参与的进程或线程都无法继续执行。

举一个简单的例子

假设有两个小朋友,AliceBob,每人都带了五毛钱,他们同时想买一块钱的冰棍,这里冰棍的购买可以被视为一个临界资源,因为它需要两个单独的锁资源才能完成购买过程(例如,一把锁用于支付,另一把锁用于取冰棍)。

现在的情况是这样的:

  • Alice想要买冰棍,但她只有五毛钱。她需要获取第一把锁来支付,然后第二把锁来取冰棍。
  • 同时,Bob也想要买冰棍,同样只有五毛钱。他也需要获取相同的两把锁来完成购买过程。

可能出现的问题是:

  • Alice 先获取了第一把锁来支付,但在尝试获取第二把锁时,可能由于竞争或者执行顺序问题,这第二把锁正在被Bob持有。
  • 同时,Bob也已经获取了第一把锁,但在尝试获取第二把锁时,可能这第二把锁正在被Alice持有。

这种情况下,AliceBob都无法继续完成购买冰棍的过程,因为他们彼此占用了彼此所需的资源,而又不愿意释放已占用的资源。他们互相等待对方释放资源,从而导致了死锁。

8.2 模拟死锁

如下我创建了两个线程,两把锁。线程1先申请A锁,线程2先申请B锁,申请完后,线程1又开始申请B锁,而线程2又开始申请A锁,此时就出现了线程1拿着A锁,线程2拿着B锁,他俩还互相想要对方的锁,但是他们要的锁已经被对方所拿走,此时就出现,线程1在申请B锁的时候申请不到,线程1抱着A锁挂起等待,线程2也不可能申请到A锁,线程2抱着B锁挂起等待。

#include <iostream>
#include <cstring>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/syscall.h>
 
using namespace std;
 
pthread_mutex_t mutexA = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutexB = PTHREAD_MUTEX_INITIALIZER;
 
void *startRoutine1(void *args)
{
    while (true)
    {
        pthread_mutex_lock(&mutexA);
        sleep(1);
        pthread_mutex_lock(&mutexB);
        cout << "我是线程1, 我的tid: " << pthread_self() << endl;
        pthread_mutex_lock(&mutexA);
        pthread_mutex_lock(&mutexB);
    }
}
void *startRoutine2(void *args)
{
    while (true)
    {
        pthread_mutex_lock(&mutexB);
        sleep(1);
        pthread_mutex_lock(&mutexA);
        cout << "我是线程2, 我的tid: " << pthread_self() << endl;
        pthread_mutex_lock(&mutexB);
        pthread_mutex_lock(&mutexA);
    }
}
int main()
{
    pthread_t t1, t2;
    pthread_create(&t1, nullptr, startRoutine1, nullptr);
    pthread_create(&t2, nullptr, startRoutine2, nullptr);
 
    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    return 0;
}

【程序结果】

在这里插入图片描述

经典问题:只有一把锁会造成死锁吗?

答案是:会!在只有一把锁的情况下,如果一个线程(比如线程A)获取了锁并且在使用临界资源后没有释放锁,而其他线程也需要这把锁来继续执行,那么这些线程会被阻塞,无法继续执行。同时,线程A由于没有释放锁,那么它会一直处于等待状态,直到获取到锁为止。这不就是死锁的表现吗?

在这里插入图片描述

【程序结果】

在这里插入图片描述

8.2 形成死锁的四个必要条件

  1. 互斥:一个资源每次只能被一个执行流使用(使用锁)。【前提】
  2. 请求与保持:一个执行流因请求资源而阻塞时,对已获得的资源保持不释放。【原则】
  3. 不剥夺条件:不能强行剥夺其他线程的资源。【原则】
  4. 循环等待条件:若干执行流之间形成一种首尾相接的循环等待资源的关系。【重要条件】

只有四个条件都同时满足了,才会引发死锁问题!

8.3 如何避免死锁

核心思路:破坏四个必要条件的其中一个或多个

  • 方法一:不加锁。本质是不保证互斥。(破坏条件1

  • 方法二:尝试主动释放锁资源给对方。本质就是一个牺牲自己,成就对方。(破坏条件2

可以借助pthread_mutex_trylock函数实现这种方案

#include <pthread.h>

int pthread_mutex_trylock(pthread_mutex_t *mutex);

该函数主要用于避免线程阻塞等待锁资源的情况。

其功能是:用于尝试获取一个互斥锁,如果该锁当前没有被其他线程占用,则立即获取并返回成功。如果锁已经被其他线程占用,则立即返回失败,而不是阻塞等待。

  • 方法三:不剥夺对方的资源,那就释放对方的资源。(破坏条件3

调用pthread_mutex_unlock接口即可。

  • 方法四:按照顺序申请锁。(破坏条件4

环路问题的根本在于:双方都有对方需要的资源。所以可以按顺序申请锁资源呢?然后再按照顺序释放锁。

总结

  • 破坏死锁的四个必要条件的其中之一即可
  • 加锁顺序一致
  • 避免锁未释放的场景
  • 资源一次性分配
  • 死锁检测算法
  • 银行家算法

九、相关代码

Gitee代码仓库:点击跳转

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值