Linux->多线程2

目录

本文说明:

一:线程互斥

1:缺乏互斥的抢票系统

2:抢票系统分析及概念回顾

3:互斥锁

①:相关接口

a:定义锁

b:初始化锁

c:加锁

d:解锁

e:销毁锁

②:优化场景

a:动态分配定义锁

b:静态分配定义锁

4:锁的底层原理

5:C++的锁

6:ARII风格的锁

①:演示C++自带的ARII:

二:可重入VS线程安全

[1. 概念](#1. 概念)

[2. 常见的线程不安全的情况](#2. 常见的线程不安全的情况)

[3. 常见的线程安全的情况](#3. 常见的线程安全的情况)

[4. 常见不可重入的情况](#4. 常见不可重入的情况)

[5. 常见可重入的情况](#5. 常见可重入的情况)

[6. 可重入与线程安全联系](#6. 可重入与线程安全联系)

[7. 可重入与线程安全区别](#7. 可重入与线程安全区别)

三:常见锁概念

[1. 死锁](#1. 死锁)

①:单线程死锁

②:多线程死锁

[2. 死锁四个必要条件](#2. 死锁四个必要条件)

[3. 避免死锁](#3. 避免死锁)

[4. 避免死锁算法](#4. 避免死锁算法)

[5. 其他常见的各种锁](#5. 其他常见的各种锁)

[6. 竞态条件](#6. 竞态条件)

三:线程同步

1:概念

2:条件变量

3:相关接口

①:条件变量

②:初始化条件变量

③:销毁条件变量

④:等待函数

⑤:唤醒函数

5:优化抢票系统


本文说明:

多线程这个章节,博客共5篇,这是第二篇,仍是介绍线程部分的相关概念

一:线程互斥

1:缺乏互斥的抢票系统

例子:

我们创建3个新线程去抢票,全局变量g_tickets代表票数,初始值为1w张,当票数>0的时候,我们线程就去打印一下当前的票数,然后执行--操作

所以,我们预期是最后在mian中打印g_tickets的值为0

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>

using namespace std;

int g_tickets = 10000; // 共享资源,没有保护的

void* route(void* arg)
{
    int& tickets = *(int*)arg;
    while (true)
    {
        if(tickets > 0)
        {
            usleep(1000);
            printf("get tickets: %d\n", tickets);
            tickets--;
        }
        else
        {
            break;
        }
    }
    return nullptr;
}

int main()
{
    pthread_t tids[5];
    
    // 创建5个线程
    for (int i = 0; i < 3; ++i)
    {
        pthread_create(&tids[i], nullptr, route, &g_tickets);
    }
    
    // 等待所有线程结束
    for (int i = 0; i < 3; ++i)
    {
        pthread_join(tids[i], nullptr);
    }
    
    cout << "最终剩余票数: " << g_tickets << endl;
    return 0;
}

运行效果:

**解释:**抢票直接抢到了负数,这意味着代码逻辑不对!

2:抢票系统分析及概念回顾

想要解决这个问题,需要回顾一些概念!

  • 临界资源: 多线程执行流共享的资源叫做临界资源。
  • 临界区: 每个线程内部,访问临界资源的代码,就叫做临界区。
  • 互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
  • 原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。

概念解释:

进程之间如果要进行通信我们需要先创建第三方资源,让不同的进程看到同一份资源,由于这份第三方资源可以由操作系统中的不同模块提供,于是进程间通信的方式有很多种。进程间通信中的第三方资源就叫做临界资源,访问第三方资源的代码就叫做临界区。

而在多线程,我们不需要再创建第三方资源,因为多线程的大部分资源都是共享的!

原子性:指的是不可被分割的操作,该操作不会被任何调度机制打断,该操作只有两态,要么完成,要么未完成。

场景分析

所以出现负数的原因是:

当票数为1时,发生以下时序:

  1. 线程1 进入临界区,判断 tickets=1>0 ✅成功

    • 执行 usleep(1000),被放入等待队列

    • 此时还未执行打印和减操作

  2. 线程2 被调度,判断 tickets=1>0 ✅成功(因为线程1还没减)

    • 执行 usleep(1000),被放入等待队列

    • 此时还未执行打印和减操作

  3. 线程3 被调度,判断 tickets=1>0 ✅成功(因为前两个线程都还没减)

    • 执行 usleep(1000),被放入等待队列

    • 此时还未执行打印和减操作

  4. 线程1usleep 结束,重新被调度:

    • ② 打印:get tickets: 1

    • ③ 执行 tickets--,内存中 tickets=0

  5. 线程2usleep 结束,重新被调度:

    • ② 打印:get tickets: 0(读取到内存最新值0)

    • ③ 执行 tickets--,内存中 tickets=-1

  6. 线程3usleep 结束,重新被调度:

    • ② 打印:get tickets: -1(读取到内存最新值-1)

    • ③ 执行 tickets--,内存中 tickets=-2

最终结果

  • 打印输出:10-1

  • 最终票数:-2

**所以核心问题在于:**多个线程同时通过了if条件检查,但是临界区没有被保护!导致了多个线程同时访问我们的临界资源,才会产生负数的情况!

**保护临界区:**让临界区的代码每一时刻只会被一个线程所访问,这样临界区中的临界资源自然而然的在每一时刻也只会被一个线程所访问!

**注:**一般会说保护临界区,因为访问临界资源,需要的不止一条代码,多条代码就是临界区

而Linux给我们提供的方法,就是给临界区加锁,使得每一时刻都只会一个线程访问临界区!

Q:打印出负数和g_tickets--不是原子性这一特点,没有关系吗?

A:g_tickets--的底层操作如下:

**①:**从内存读取g_tickets到CPU

**②:**在CPU内部进行计算--

**③:**将计算后的g_tickets写回内存

如果g_tickets-- 是原子的,也仍然会出现负数!因为在上述解释中,并没有涉及到CPU试图进行操作③(将计算后的g_tickets写回内存)之前,就有线程去进行操作①(从内存读取g_tickets到CPU),所以尽管g_tickets--是非原子性的,但是在usleep期间,g_tickets--是没有线程去打扰的,其是成功--的!

关键区别在于:

  • 如果只是 tickets-- 不是原子的:可能出现数据损坏(比如两个线程同时减,只减了一次)也就是线程1正要进行操作③时,线程2去进行了操作①,属于白忙活~

  • 但如果临界区没保护 :即使每个操作都是原子的,逻辑上仍然会出现负数

**注:**一个操作只有一条汇编语句,则具有原子性,反之没有!

**解释:**所以全局变量tickets--操作的汇编是三句,不具有原子性

所以既然"g_tickets--是非原子性的,但是**在usleep期间,**g_tickets--是没有线程去打扰的,其是成功--的!",那我们去掉usleep就能体现原子性对打印效果的影响的。但是这属于拓展演示了,因为我们向引入锁,理应避免原子性的影响,专注于保护临界区,但是,见识一下,总是好的~

将代码中的usleep注释后,打印效果如下:

**本质原因就是:**某个线程1被调度,CPU正准备把计算--后的值放回内存,但是这个期间内存中的值已经被多个线程疯狂的去调度--了,而此时线程1打算放回到内存中的值已经无效并且是较大值,所以在线程1把值发回内存后,又有线程去拿到了这个较大值进行了打印,如图中的2706..

所以抢票到负数的例子,本质是通过usleep或者sleep来规避掉g_tickets--的非原子性,专注于体现临界区需要被保护的场景,而当去掉usleep,则会两种问题同时浮现,容易混淆

所以我们用锁之后,即使你去掉usleep也没问题!

3:互斥锁

互斥锁简称锁,本质就是让每个想访问临界区的线程,必须先去申请锁,申请到了锁,你才有资格去访问临界区,访问临界区之后,再释放锁。所以当线程申请到锁却未释放锁之前,其余任何线程都会因为申请不到锁,从而无法访问临界区!这样就保证了临界区的安全性!不会多线程同时访问

申请到锁叫作加锁,释放锁叫作解锁

原理如下图:

**注:**互斥锁也可以叫作互斥量,互斥锁更加形象

所以我们必然要先定义一把锁,那怎么定义呢?用什么类型来定义锁?

①:相关接口

a:定义锁

很简单,你只要包含线程库头文件,便可直接使用pthread_mutex_t 类型去定义一把锁!

cpp 复制代码
#include<pthread.h>//包含Linux的线程库头文件

pthread_mutex_t lock;//定义了一把名为lock的锁

还有一种定义锁的方式,我们将锁定义为一个宏,这个宏实Linux线程库头文件中声明的,所以可以直接使用!

cpp 复制代码
#include<pthread.h>//包含Linux的线程库头文件

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

**解释:**该写法只能写在全局区域,若你要写在函数内部,则需要加上static修饰,本质就是不管在哪里写,都要保证该锁是全局属性的!

二者的差别在于,前者定义方式需要我们手动的调用初始化和销毁接口,后者则不需要!

b:初始化锁

初始化互斥量的函数叫做pthread_mutex_init,该函数的函数原型如下:

cpp 复制代码
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);

参数说明:

  • mutex:需要初始化的互斥锁。
  • attr:初始化互斥锁的属性,一般设置为NULL即可。

返回值说明:

  • 互斥锁初始化成功返回0,失败返回错误码。

调用pthread_mutex_init函数初始化互斥锁叫做动态分配,反之,上文将互斥锁定义为宏的方式叫做静态分配,无非就是需不需要你手动调用接口的区别罢了!

c:加锁

互斥量加锁的函数叫做pthread_mutex_lock,该函数的函数原型如下:

cpp 复制代码
int pthread_mutex_lock(pthread_mutex_t *mutex);

参数说明:

  • mutex:需要加锁的互斥量。

返回值说明:

  • 互斥量加锁成功返回0,失败返回错误码。

调用pthread_mutex_lock时,可能会遇到以下情况:

  1. 互斥量锁存在,该函数会将互斥锁给调用该函数的线程,同时返回0。
  2. 发起函数调用时,其他线程已经占用了互斥锁,或者存在其他线程同时申请互斥锁,但没有竞争到互斥锁,那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待占用互斥锁的线程去解锁。
d:解锁

互斥量解锁的函数叫做pthread_mutex_unlock,该函数的函数原型如下:

cpp 复制代码
int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数说明

  • mutex:需要解锁的互斥量。

返回值说明:

  • 互斥量解锁成功返回0,失败返回错误码。
e:销毁锁

销毁互斥量的函数叫做pthread_mutex_destroy,该函数的函数原型如下:

cpp 复制代码
int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数说明:

  • mutex:需要销毁的互斥量。

返回值说明:

  • 互斥量销毁成功返回0,失败返回错误码。

销毁互斥量需要注意:

  • 使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁。
  • 不要销毁一个已经加锁的互斥量。
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。

②:优化场景

对之前打印出负数的场景进行优化,也就是引用锁来进行保护临界区!

a:动态分配定义锁
cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>

using namespace std;

int g_tickets = 10000; // 共享资源,没有保护的

pthread_mutex_t lock;//定义了一把名为lock的锁

void* route(void* arg)
{
    int& tickets = *(int*)arg;
    while (true)
    {
        pthread_mutex_lock(&lock);//加锁
        if(tickets > 0)
        {
            //usleep(1000);
            printf("get tickets: %d\n", tickets);
            tickets--;
           pthread_mutex_unlock(&lock);//解锁

        }
        else
        {
            pthread_mutex_unlock(&lock);//解锁
            break;
        }
    }
    return nullptr;
}

int main()
{
    pthread_mutex_init(&lock, NULL);//初始化锁

    pthread_t tids[3];
    
    // 创建5个线程
    for (int i = 0; i < 3; ++i)
    {
        pthread_create(&tids[i], nullptr, route, &g_tickets);
    }
    
    // 等待所有线程结束
    for (int i = 0; i < 3; ++i)
    {
        pthread_join(tids[i], nullptr);
    }
    
    cout << "最终剩余票数: " << g_tickets << endl;

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

**注:**申请到锁之后,不管是否修改临界资源都需要释放锁,所以不同分支都调用解锁接口!

运行结果:

**解释:**此代码是没有usleep的,但是有了锁,原子性和临界区保护,通通不在话下!

我们使用 pthread_mutex_t lock;的方式定义锁的时候,我们常常在main函数中调用初始化锁和销毁锁的接口,而不是在线程函数内,并且不管是那种定义锁的方式,最好的写法都是将其写到全局的区域!

b:静态分配定义锁
cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>

using namespace std;

int g_tickets = 10000; // 共享资源,没有保护的

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;//静态分配定义锁

void* route(void* arg)
{
    int& tickets = *(int*)arg;
    while (true)
    {
        pthread_mutex_lock(&lock);//加锁
        if(tickets > 0)
        {
            //usleep(1000);
            printf("get tickets: %d\n", tickets);
            tickets--;
           pthread_mutex_unlock(&lock);//解锁

        }
        else
        {
            pthread_mutex_unlock(&lock);//解锁
            break;
        }
    }
    return nullptr;
}

int main()
{
    pthread_t tids[3];
    
    // 创建5个线程
    for (int i = 0; i < 3; ++i)
    {
        pthread_create(&tids[i], nullptr, route, &g_tickets);
    }
    
    // 等待所有线程结束
    for (int i = 0; i < 3; ++i)
    {
        pthread_join(tids[i], nullptr);
    }
    
    cout << "最终剩余票数: " << g_tickets << endl;

    return 0;
}

运行结果:

**解释:**使用静态分配的方式去定义锁,则我们不需要在任何地方初始化锁和销毁锁。

Q:那什么时候用动态分配定义锁,什么时候用静态分配定义锁?

A: 当你想保护的临界资源是一个共享的资源,比如全局资源,这种情况就可以用静态分配定义锁,省去了初始化和销毁的操作;而当保护的是动态数据结构节点这种类型,则需要动态分配定义锁,这才能使的锁的生命周期和与节点一致!

核心原则

让锁的生存期和可见性与其保护的资源严格保持一致。静态资源用静态锁,动态资源用动态锁。

4:锁的底层原理

Q1:那大家都去申请锁,不会出现两个线程同时申请到锁的情况吗?

**A1:不会,**申请锁和释放锁,这两个动作都是原子性的!所以用锁来保护临界区,其实就是在临界区外面用了一个具有原子性的操作来使得每一时刻,只会有一个线程去访问临界区!所以不会出现多个线程同时申请到锁的情况!要么就是其他线程申请到了锁,要么就是锁还存在,不会存在中间状态!

Q2:那申请锁操作和释放锁操作,是怎么做到具有原子性的?

**A2:**所以为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了锁这个变量的原子性!如下图:

**解释:**比如现在锁变量mutex的值为1,而al是CPU中的一个寄存器!

多线程申请锁,即图中的lock操作:

**①:**多线程会被同时调度,这意味着,多个线程都会拥有自己的上下文数据,也就是都会有属于自己的al寄存器,然后"movb $0, %al"操作,每个线程会把自己的al值置为0

**②:**然后执行"xchgb %al, mutex",也就是交换al寄存器和mutex中的值1。这意味着,最先进行交换的线程,才会把mutex中的值1交换到自己的al寄存器中,此时内存中的mutex中的值为0

**③:**当然,即使最快的线程已经完成了交换,剩余的线程并不知道已经被交换了,仍然会去进行交换,此时就是0交换0,

**④:**最后每个线程都会判断属于自己的al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败(进行0交换0的线程)需要被挂起等待,直到锁被释放后再次竞争申请锁。

所以,xchgb指令是具有原子性的,这是加锁的核心所在!

释放锁,即图中的unlock操作:

将内存中的mutex置回1,然后唤醒等待Mutex的线程。唤醒这些因为申请锁失败而被挂起的线程,让它们继续竞争申请锁,使得下一个申请锁的线程在执行交换指令后能够得到1

所以,movb指令是具有原子性的,这是解锁的核心所在!

Q:正在访问临界区中的线程被切换了怎么办?此时另一个线程岂不是直接不用申请锁,就可以执行临界区代码了?

**A:即便线程被切换了,被阻塞,被挂起,都无所谓!**因为只有我这个线程有锁,我什么时候被调度,我就什么时候继续执行罢了!其余的线程照样无法执行临界区代码!所以一个线程一但加锁 此线程对于临界区的访问这个动作 对于其他线程是原子的,要么我执行完了临界区,要么没有执行完,所以线程必定是安全的!

5:C++的锁

Linux的多线程C++有,而Linux的锁,C++也有

cpp 复制代码
#include <iostream>
#include <thread>//用的C++的线程库
#include <mutex>//用的C++的锁

using namespace std;

int g_tickets = 10000; // 共享资源
std::mutex g_mutex;    // C++标准互斥锁 定义了一把名为g_mutex的锁

void route(int& tickets) {
    while (true) {
        g_mutex.lock(); // 手动加锁
        if (tickets > 0) {
            // usleep(1000);
            printf("get tickets: %d\n", tickets);
            tickets--;
            g_mutex.unlock(); // 手动解锁
        } else {
            g_mutex.unlock(); // 手动解锁
            break;
        }
    }
}

int main() {
    std::thread tids[3];
    
    // 创建3个线程,使用std::ref传递引用
    for (int i = 0; i < 3; ++i) {
        tids[i] = std::thread(route, std::ref(g_tickets));
    }
    
    // 等待所有线程结束
    for (int i = 0; i < 3; ++i) {
        tids[i].join();
    }
    
    cout << "最终剩余票数: " << g_tickets << endl;
    return 0;
}

运行效果:

**解释:**在C++的多线程中,是类的形式,同理C++的锁,也是类的形式,通过调用成员函数的方式进行操作,每个成员函数内部都是封装的Linux的锁的接口,好处在于我们不用显式的初始化,不用进行显式传参了,更加安全!同时C++的锁和C++的线程一样,具有移植性!

对比如下:

cpp 复制代码
g_mutex.lock(); // C++的手动加锁   更安全
pthread_mutex_lock(&g_mutex) //Linux的手动加锁

我们定义出一个对象的时候,其构造函数和析构函数就会自动的调用pthread_mutex_init和pthread_mutex_destroy接口,所以我们不用再显示的调用!

6:ARII风格的锁

ARII指的是:将资源的生命周期与对象的生命周期绑定。

换句话说,ARII风格的锁,是最安全的,相比于C++的mutex类来说,不仅和mutex类一样不需要初始化和销毁,甚至不需要解锁,你只需在合适的时机创建对象即可!省去程序员手动编写这些调用的需要,并确保它们在任何情况下都能正确配对执行。

Q:C++的RAII锁类是怎么实现的?

A:RAII锁类(如lock_guard)是一个管理类,它管理的就是一个mutex类对象的引用,这意味着,RAII锁类不拥有锁,只管理锁的使用,简易实现如下:

cpp 复制代码
template<typename Mutex>
class lock_guard 
{
    
public:
    // 构造函数:接收一个mutex的引用
    explicit lock_guard(Mutex& mutex) : mutex_ref(mutex) 
    {
        mutex_ref.lock();  // 通过引用加锁
    }
    
    // 析构函数
    ~lock_guard() 
    {
        mutex_ref.unlock();// 通过引用解锁
    }

private:
    Mutex& mutex_ref;  // 关键!这是一个引用
};

**解释:**这就是为什么RAII锁类,只需定义出对象即可,不需要手动加锁解锁,不需要手动初始化销毁!因为:

不需要手动加锁: 实例化RAII锁类对象,构造函数就已经调用mutex类的加锁成员函数

不需要手动解锁: RAII锁类对象析构时,析构函数就已经调用mutex类的解锁成员函数

不需要手动初始化: 引用的mutex类对象,该对象的初始化由其自己的构造函数完成

不需要手动销毁: 引用的mutex类对象,该对象的销毁由其自己的析构函数完成

①:演示C++自带的ARII:

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>

using namespace std;

int g_tickets = 10000; // 共享资源
std::mutex g_mutex;    // C++标准互斥锁

void route(int &tickets)
{
    while (true)
    {
        std::lock_guard<std::mutex> guard(g_mutex); // RAII自动管理

        if (tickets > 0)
        {
            printf("get tickets: %d\n", tickets);
            tickets--;
        }
        else
        {
            break; // 这会跳出while循环!
        }
    }
}

int main()
{
    std::thread tids[3];

    // 创建3个线程
    for (int i = 0; i < 3; ++i)
    {
        tids[i] = std::thread(route, std::ref(g_tickets));
    }

    // 等待所有线程结束
    for (int i = 0; i < 3; ++i)
    {
        tids[i].join();
    }

    cout << "最终剩余票数: " << g_tickets << endl;
    return 0;
}

解释:

①: 因为ARII锁类成员变量是对mutex类对象的引用,所以必然要先创建出mutex类对象;

②: lock_guard 的生命周期在访问临界区的代码块内即可

**③:**不管是进入if还是进入else,最后都会导致ARII锁类死亡,从而自动的销毁

二:可重入VS线程安全

讲完了互斥,我们需要总结一下了,再去讲解同步!

1. 概念

  • 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。

  • 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

所以printf cout这种函数因为被重入而导致打印出现粘合时,对其加锁即可!

2. 常见的线程不安全的情况

  • 不保护共享变量的函数

  • 函数状态随着被调用,状态发生变化的函数

  • 返回指向静态变量指针的函数

  • 调用线程不安全函数的函数

3. 常见的线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的

  • 类或者接口对于线程来说都是原子操作

  • 多个线程之间的切换不会导致该接口的执行结果存在二义性

4. 常见不可重入的情况

  • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的

  • 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构

  • 可重入函数体内使用了静态的数据结构

5. 常见可重入的情况

  • 不使用全局变量或静态变量

  • 不使用用malloc或者new开辟出的空间

  • 不调用不可重入函数

  • 不返回静态或全局数据,所有数据都有函数的调用者提供

  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

6. 可重入与线程安全联系

  • 函数是可重入的,那就是线程安全的

  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题

  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的

7. 可重入与线程安全区别

  • 可重入函数是线程安全函数的一种

  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的

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

三:常见锁概念

1. 死锁

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

①:单线程死锁

这种情况不易出现,是因为写代码出现了很明显的错误才会导致,毕竟一个线程一把锁,很难出错

死锁例子:

抢票系统的代码改成单线程,并且因为该单线程申请锁之后,再次申请导致死锁:

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>

using namespace std;

int g_tickets = 10000; // 共享资源
pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER; // Linux互斥锁

// 会制造死锁的函数 - 同一个线程重复申请同一个锁
void deadlock_route(int &tickets) {
    while (true) {
        // 第一次加锁
        pthread_mutex_lock(&g_mutex);
        cout << "第一次加锁成功" << endl;
        
        // 第二次加锁同一个锁 - 这里会死锁!
        pthread_mutex_lock(&g_mutex);
        cout << "第二次加锁成功(这行不会执行)" << endl;
        
        if (tickets > 0) {
            printf("get tickets: %d\n", tickets);
            tickets--;
            
            // 解锁
            pthread_mutex_unlock(&g_mutex);
            pthread_mutex_unlock(&g_mutex);
        } else {
            pthread_mutex_unlock(&g_mutex);
            pthread_mutex_unlock(&g_mutex);
            break;
        }
        
    }
}


int main() {
    cout << "开始单线程抢票(将会死锁)..." << endl;
    cout << "初始票数: " << g_tickets << endl;
    
    // 单线程运行,但会发生死锁
    deadlock_route(g_tickets);
    // deadlock_route2(g_tickets); // 另一种死锁方式
    
    // 这行代码永远不会执行到
    cout << "最终剩余票数: " << g_tickets << endl;
    cout << "程序正常结束(这行不会执行)" << endl;
    
    // 销毁锁(不会执行到这里)
    pthread_mutex_destroy(&g_mutex);
    return 0;
}

运行结果:

**解释:**运行效果会一直卡柱,因为出现了死锁

②:多线程死锁

多线程最易出现死锁

死锁例子:

现在有两个线程,每个线程需要同时持有锁1和锁2,才能访问临界区,但两个线程的申请锁顺序不一样,导致双方都各自申请到了第一把锁,但是想申请的第二把锁都在对方的手上,则会死锁!

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>

using namespace std;

int g_tickets = 10000;
pthread_mutex_t lock1 = PTHREAD_MUTEX_INITIALIZER; // 第一把锁
pthread_mutex_t lock2 = PTHREAD_MUTEX_INITIALIZER; // 第二把锁

// 线程1:先申请lock1,再申请lock2
void* thread1_route(void* arg) {
    int& tickets = *(int*)arg;
    
    while (tickets > 0) {
        cout << "线程1: 尝试获取lock1..." << endl;
        pthread_mutex_lock(&lock1);
        cout << "线程1: 获得lock1,尝试获取lock2..." << endl;
        
        // 模拟一些处理时间,让线程2有机会获取lock2
        usleep(100);
        
        pthread_mutex_lock(&lock2);
        cout << "线程1: 获得lock2,处理票务..." << endl;
        
        if (tickets > 0) {
            printf("线程1: 卖出票 %d\n", tickets);
            tickets--;
        }
        
        pthread_mutex_unlock(&lock2);
        pthread_mutex_unlock(&lock1);
        
        usleep(50000); // 让出CPU时间
    }
    
    cout << "线程1: 结束" << endl;
    return nullptr;
}

// 线程2:先申请lock2,再申请lock1(与线程1顺序相反)
void* thread2_route(void* arg) {
    int& tickets = *(int*)arg;
    
    while (tickets > 0) {
        cout << "线程2: 尝试获取lock2..." << endl;
        pthread_mutex_lock(&lock2);
        cout << "线程2: 获得lock2,尝试获取lock1..." << endl;
        
        // 模拟一些处理时间,让线程1有机会获取lock1
        usleep(100);
        
        pthread_mutex_lock(&lock1);
        cout << "线程2: 获得lock1,处理票务..." << endl;
        
        if (tickets > 0) {
            printf("线程2: 卖出票 %d\n", tickets);
            tickets--;
        }
        
        pthread_mutex_unlock(&lock1);
        pthread_mutex_unlock(&lock2);
        
        usleep(50000); // 让出CPU时间
    }
    
    cout << "线程2: 结束" << endl;
    return nullptr;
}

int main() {
    cout << "开始双线程抢票(将会死锁)..." << endl;
    cout << "初始票数: " << g_tickets << endl;
    
    pthread_t tid1, tid2;
    
    // 创建两个线程
    pthread_create(&tid1, nullptr, thread1_route, &g_tickets);
    pthread_create(&tid2, nullptr, thread2_route, &g_tickets);
    
    // 等待线程结束(实际上会死锁,等不到)
    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);
    
    // 这行代码永远不会执行到
    cout << "最终剩余票数: " << g_tickets << endl;
    
    // 销毁锁
    pthread_mutex_destroy(&lock1);
    pthread_mutex_destroy(&lock2);
    
    return 0;
}

运行结果:

**解释:**出现死锁,因为第二把想申请到的都是对方持有的锁!

2. 死锁四个必要条件

四个必要条件代表这四个条件必须同时被满足,才会导致死锁!

  • 互斥条件:一个资源每次只能被一个执行流使用

解释:这是必然的 因为死锁前提就是申请过锁 所以资源一定是互斥的

  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放

解释:我手中的锁不释放,并且我还想要你的锁

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

解释:想要申请对方的锁的时候,但不能抢占对方的锁,会一直申请,导致死锁

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

解释:线程A想要申请线程B的锁2 线程B想要申请线程A的锁1,导致循环

3. 避免死锁

很简单,只需破坏死锁的四个必要条件中的其一即可!

  • 破坏 互斥条件

解释:不用锁,资源就不会是互斥的了,比如对公共资源只读的时候,就不需要锁

  • 破坏 请求与保持条件

解释:线程A申请锁2失败,把自己的锁1释放掉,再回头重新申请

  • 破坏不剥夺条件

解释:线程A可以剥夺线程B锁,把线程B的锁unlock即可 ,但极不推荐!后果无法预估! 一般优先级高的线程才能解除优先级低的锁,这种类似的手段去剥夺锁才是合理的....

  • 破坏循环等待条件

解释:不同线程的申请锁的顺序一致 这是最常用的避免死锁的做法

4. 避免死锁算法

  • 死锁检测算法:通过检测资源分配图来判断系统是否处于死锁状态

  • 银行家算法:资源分配和避免死锁的算法,通过模拟资源分配来确保系统始终处于安全状态

5. 其他常见的各种锁

我们需要了解一种叫做自旋锁的锁,其和我们之前使用的锁的区别如下;

忙等待(自旋锁) 阻塞等待(互斥锁)
CPU使用 高,浪费资源 低,高效利用
响应速度 快,无切换开销 稍慢,有切换开销
适用场景 等待时间很短 等待时间较长
线程状态 运行态/就绪态 阻塞态

**解释:**我们之前的锁,除开申请到锁的线程,其余线程是阻塞等待的,其不会一直去循环尝试申请锁,而是当占用锁的线程去 释放锁,内核才会自动唤醒等待线程!

而如果自旋锁,没有申请到自旋锁的线程,则不会阻塞等待,这些线程会不断循环检查锁状态,这意味着这些线程在等待期间时刻保持运行状态,所以对CPU的消耗很高,会浪费资源

但是在某些场景下,必须用自旋锁,以后会讲到~

6. 竞态条件

竞态条件: 因为时序问题,而导致程序异常,我们称之为竞态条件,未加锁的抢票就叫做竞态条件,导致了抢票倒负数!

三:线程同步

1:概念

线程互斥固然是好的,但是如果有一个线程竞争锁的能力特别强,比如多线程抢票,一个线程自己就抢了9000张票,这会导致其余的线程的线程饥饿问题!所以光是互斥不够优秀,我们还需让线程之间同步起来!

同步: 在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫做同步。

**所以:**互斥解决线程安全,同步解决线程的执行顺序,两者共同优化资源利用!

同步的本质:

当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后。这样,下一个获取到锁的资源的线程就一定是在资源等待队列首部的线程,如果有十个线程,此时我们就能够让这十个线程按照某种次序进行临界资源的访问。

并且当线程解锁之后,同步机制的唤醒函数可以用来唤醒这个队列的队首线程,此时给线程就会去申请到锁,其余线程照样等待,非常高效!

2:条件变量

我们要想同步,必须先定义一个条件变量,和定义锁类似,条件变量的类型是pthread_cond_t,我们包含了线程库头文件即可直接使用!

条件变量是用来作为同步的相关接口的参数的,当线程在某个条件不满足时就会主动进入条件变量所维护着的"等待队列"去等待,并在条件满足时会从条件变量所维持的队列中唤醒。

所以条件变量更像是一个队列的管理员!并不是一个真正的条件!

3:相关接口

①:条件变量

同样的,条件变量可以动态分配方式定义,也可以静态分配方式定义:

cpp 复制代码
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;//静态分配 无需手动初始化和销毁
pthread_cond_t cond;//动态分配 需要手动初始化和销毁

②:初始化条件变量

初始化条件变量的函数叫做pthread_cond_init,该函数的函数原型如下:

cpp 复制代码
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);

参数说明:

  • cond:需要初始化的条件变量。
  • attr:初始化条件变量的属性,一般设置为NULL即可。

返回值说明:

  • 条件变量初始化成功返回0,失败返回错误码。

③:销毁条件变量

销毁条件变量的函数叫做pthread_cond_destroy,该函数的函数原型如下:

cpp 复制代码
int pthread_cond_destroy(pthread_cond_t *cond);

参数说明:

  • cond:需要销毁的条件变量。

返回值说明:

  • 条件变量销毁成功返回0,失败返回错误码。

④:等待函数

等待条件变量满足的函数叫做pthread_cond_wait,该函数的函数原型如下:

cpp 复制代码
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);

参数说明:

  • cond:需要等待的条件变量。
  • mutex:当前线程所处临界区对应的互斥锁。

返回值说明:

  • 函数调用成功返回0,失败返回错误码。

重点: 当一个在 pthread_cond_wait(&cond, &mutex) 中等待的线程被唤醒时,它不是从代码最开始重新执行,而是从 pthread_cond_wait 调用之后的位置继续执行,并且此时它已经重新获取了互斥锁mutex!

参数详细解释:

某个线程要在哪个条件变量所维持的队列下等待,则第一个参数填写该条件变量!

在调用pthread_cond_wait函数时,还需要将对应的互斥锁传入(第二个参数),此时当线程因为某些条件不满足需要在该条件变量所维持的队列中进行等待时,就会自动释放该互斥锁,再去等待。不然其余线程怎么申请锁?所以第二个参数的意义是释放线程持有的锁!

Q:当我们进入临界区上锁后,如果发现条件不满足,那我们先解锁,然后在该条件变量下进行等待不就行了?有必要在pthread_cond_wait中设置第二个参数?如下:

cpp 复制代码
//错误的设计
pthread_mutex_lock(&mutex);//加锁

while (condition_is_false)//发现条件不满足 则进行在cond下的等待
{
	pthread_mutex_unlock(&mutex);//先解锁
	pthread_cond_wait(&cond);//再等待
	pthread_mutex_lock(&mutex);//被唤醒 从这里这些代码 所以要申请锁
}

//条件满足,则进行相关操作......

pthread_mutex_unlock(&mutex);//解锁

A:这是不可行的! 因为解锁和等待不是原子操作,调用解锁之后,在调用pthread_cond_wait函数之前,这个短短的时间,如果其他线程在这个时间窗口内获取了互斥锁、改变了条件并发送了信号(例如调用pthread_cond_signal),那么此时pthread_cond_wait函数将错过这个信号,因为我还没进入等待队列啊!最终可能会导致线程永远不会被唤醒!

因此解锁和等待必须是一个原子操作,这就是pthread_cond_wait第二个参数的究极意义!

⑤:唤醒函数

唤醒等待的函数有以下两个:

cpp 复制代码
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);

区别:

  • pthread_cond_signal函数用于唤醒等待队列中首个线程。
  • pthread_cond_broadcast函数用于唤醒等待队列中的全部线程。

参数说明:

  • cond:唤醒在cond条件变量下等待的线程。

返回值说明:

  • 函数调用成功返回0,失败返回错误码。

下面举个例子来用一下几个接口:

我们用主线程创建三个新线程,这三个线程会直接在条件变量cond所维持的队列下等待,直接等到是因为我们并没有写什么判断条件,直接使用wait接口,然后用户输入一个字符就会唤醒队列队首的线程,输入几个就会按照顺序唤醒几个

cpp 复制代码
#include <iostream>
#include <cstdio>
#include <pthread.h>

pthread_mutex_t mutex;
pthread_cond_t cond;
void *Routine(void *arg)
{
    pthread_detach(pthread_self());
    std::cout << (char *)arg << " run..." << std::endl;
    while (true)
    {
        pthread_cond_wait(&cond, &mutex); // 阻塞在cond所维持的队列中,直到在mian中被唤醒才会再去申请锁
        std::cout << (char *)arg << "活动..." << std::endl;
    }
}
int main()
{
    pthread_t t1, t2, t3;
    pthread_mutex_init(&mutex, nullptr);
    pthread_cond_init(&cond, nullptr);

    pthread_create(&t1, nullptr, Routine, (void *)"thread 1");
    pthread_create(&t2, nullptr, Routine, (void *)"thread 2");
    pthread_create(&t3, nullptr, Routine, (void *)"thread 3");

    while (true)
    {
        getchar();//只有键盘输入数据
        pthread_cond_signal(&cond);//才会唤醒队列的队首
    }

    pthread_mutex_destroy(&mutex);//销毁锁
    pthread_cond_destroy(&cond);//销毁条件变量
    return 0;
}

运行结果:

**解释:**每次输入回车就会有一个线程被唤醒,并且顺序都是3 2 1,这证明了这些线程在cond所维持的队列下是有序的,实现了线程间的同步!

5:优化抢票系统

我们线程3个线程抢9999张票,现在我们只有互斥,没有同步,所以每个线程抢到的票肯定不可能都为3333张

代码:

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>

using namespace std;

int g_tickets = 9999; // 共享资源,没有保护的

pthread_mutex_t lock;//定义了一把名为lock的锁
__thread int mytickets = 0;

void* route(void* arg)
{
    int& tickets = *(int*)arg;
    while (true)
    {
        pthread_mutex_lock(&lock);//加锁
        if(tickets > 0)
        {
            //usleep(1000);
            printf("get tickets: %d\n", tickets);
            tickets--;
            mytickets++;
           pthread_mutex_unlock(&lock);//解锁

        }
        else
        {
            printf("%d\n",mytickets);
            pthread_mutex_unlock(&lock);//解锁
            break;
        }
    }
    return nullptr;
}

int main()
{
    pthread_mutex_init(&lock, NULL);//初始化锁

    pthread_t tids[3];
    
    // 创建3个线程
    for (int i = 0; i < 3; ++i)
    {
        pthread_create(&tids[i], nullptr, route, &g_tickets);
    }
    
    // 等待所有线程结束
    for (int i = 0; i < 3; ++i)
    {
        pthread_join(tids[i], nullptr);
    }
    
    cout << "最终剩余票数: " << g_tickets << endl;

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

运行结果:

解释: 可以看见互斥下的3个线程,抢到的票数不均,的确存在较大的线程饥饿问题!

下面我们在这个代码上加入同步机制:

每个线程抢到了一张票,就去cond维持的队列下等待,然后主线程中会一直唤醒队列队首的线程去抢票,这样大家就同步了起来,减少进程饥饿对于结果的影响

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>

using namespace std;

int g_tickets = 9999;       // 共享资源,没有保护的
__thread int mytickets = 0; // 每个线程独有的计数器

pthread_mutex_t lock; // 定义了一把名为lock的锁
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

void *route(void *arg)
{
    int &tickets = *(int *)arg;
    while (true)
    {
        pthread_mutex_lock(&lock);
        if (tickets > 0)
        {
            printf("线程%lu抢到第%d张票,剩余%d张\n",
                   pthread_self(), tickets, tickets - 1);
            tickets--;
            mytickets++;
            pthread_cond_wait(&cond, &lock);
            pthread_mutex_unlock(&lock);
        }
        else
        {
            printf("线程%lu退出,共抢到%d张票\n", pthread_self(), mytickets);
            pthread_mutex_unlock(&lock);
            break;
        }
    }
    return nullptr;
}

int main()
{
    pthread_mutex_init(&lock, NULL);

    pthread_t tids[3];

    for (int i = 0; i < 3; ++i)
    {
        pthread_create(&tids[i], nullptr, route, &g_tickets);
    }


    while (true)
    {
        pthread_cond_signal(&cond);
        usleep(1000);
        pthread_mutex_lock(&lock);
        bool done = (g_tickets == 0);
        pthread_mutex_unlock(&lock);
        if (done)
            break;
    }

    // 确保所有等待的线程都被唤醒
    pthread_mutex_lock(&lock);
    pthread_cond_broadcast(&cond);
    pthread_mutex_unlock(&lock);

    // 等待所有线程结束
    for (int i = 0; i < 3; ++i)
    {
        pthread_join(tids[i], nullptr);
    }

    cout << "最终剩余票数: " << g_tickets << endl;
    pthread_mutex_destroy(&lock);
    return 0;
}

**解释:**同步机制后,的确大大的减小了饥饿问题,甚至完美的达到了各自3333张票

有时候也会有轻微出入:

这和系统的调度有关系,在实际应用中,细微的不公平往往是可接受的。

相关推荐
晚枫歌F10 小时前
Dpdk介绍
linux·服务器
李慕婉学姐10 小时前
【开题答辩过程】以《基于JAVA的校园即时配送系统的设计与实现》为例,不知道这个选题怎么做的,不知道这个选题怎么开题答辩的可以进来看看
java·开发语言·数据库
奋进的芋圆12 小时前
Java 延时任务实现方案详解(适用于 Spring Boot 3)
java·spring boot·redis·rabbitmq
sxlishaobin12 小时前
设计模式之桥接模式
java·设计模式·桥接模式
model200512 小时前
alibaba linux3 系统盘网站迁移数据盘
java·服务器·前端
荒诞硬汉12 小时前
JavaBean相关补充
java·开发语言
提笔忘字的帝国12 小时前
【教程】macOS 如何完全卸载 Java 开发环境
java·开发语言·macos
工程师老罗13 小时前
龙芯2k0300 PMON取消Linux自启动
linux·运维·服务器
2501_9418824813 小时前
从灰度发布到流量切分的互联网工程语法控制与多语言实现实践思路随笔分享
java·开发语言
千百元13 小时前
centos如何删除恶心定时任务
linux·运维·centos