Linux系统编程:多线程互斥以及死锁问题

目录

一、临界资源和临界区

二、线程的互斥

1.互斥的概念

2.互斥锁

pthread_mutex_init函数

pthread_mutex_destroy函数

pthread_mutex_lock函数

pthread_mutex_unlock函数

pthread_mutex_trylock函数

3.从内核角度看加锁

4.RAII风格的加锁方式

三、线程安全

可重入与线程安全联系

可重入与线程安全区别

四、死锁

死锁四个必要条件

1. 互斥条件:

2.请求与保持条件:

3.不剥夺条件:

4.循环等待条件

如何避免死锁


一、临界资源和临界区

多个执行流都能够看到并且能够访问的资源,我们称之为临界资源。

多个执行流的代码中,访问了临界资源的代码,我们称之为临界区。

我们举一个简单的例子来具体说明一下临界区和临界资源的概念:

#include <iostream>
#include <cstring>
#include <unistd.h>
#include <pthread.h>

using namespace std;

// 这个全局变量能被多个执行流看到并访问
// 因此它是临界资源
int value = 100;

void *callBack(void *args)
{
    // 这行代码访问了临界资源,因此属于临界区
    cout << "thread: " << pthread_self() << " value: " << value
         << endl;
    
    // 这行代码并没有访问临界资源,不属于临界区
    return (void*)0;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr, callBack, (void *)"thread1");

    pthread_join(tid, nullptr);
    return 0;
}

多线程同时访问临界资源是很有可能会出现问题的,这些问题都是因为线程缺少访问控制导致的,虽然这些问题不一定是百分百会出现,但我们也不能保证它的完全正确。

接下来我们来代码模拟一下抢票系统的运行,每个线程都去抢票,当还有票的时候线程就抢票,票数减一,直到票被抢完为止。

#include <iostream>
#include <cstring>
#include <unistd.h>
#include <pthread.h>

using namespace std;

int tickets = 10000;// 总票数为10000张

// 执行抢票逻辑
void* getTicket(void* args)
{
    const char* name = (char*)args;
    while(true)
    {
        // 先判断是否还有票
        if (tickets > 0)
        {
            cout << name << " 抢票成功, 票编号为: " << tickets << endl;
            tickets--;
        }
        else
        {
            cout << name << " 抢票失败,票被抢完了......" << endl;
            break;
        }
    }

    return nullptr;
}

int main()
{
    // 创建三个线程模拟抢票
    pthread_t tid1, tid2, tid3;
    pthread_create(&tid1, nullptr, getTicket, (void*)"thread1");
    pthread_create(&tid2, nullptr, getTicket, (void*)"thread2");
    pthread_create(&tid3, nullptr, getTicket, (void*)"thread3");

    // 回收线程
    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);
    pthread_join(tid3, nullptr);
    return 0;
}

上面的代码可能会正常运行,各个线程都能成功抢到票不会出现什么问题,但也有可能会出现票数混乱问题。我们的抢票执行的逻辑就是tickets自减操作,这个操作在底层汇编代码中其实是分三步进行的,

  • 首先第一步是将内存中tickets的数据加载到CPU中,
  • 接着第二步是在CPU中对tickets进行运算,
  • 最后第三步是将运算结果写回到内存当中。

但是在多线程的背景下,有可能会出现下图中的这种问题,线程1先被调度进行抢票操作,此时tickets的值是10000,它先将内存中的tickets值加载到CPU中,在CPU中进行运算,运算结果是9999,最后当它正准备要将CPU中的运算结果返回的时候,线程1被切换走了虽然CPU内的寄存器是被所有执行流共享的,但是寄存器里面的数据是属于当前执行流的上下文数据的。所以当线程1被切换走时,寄存器里的运算数据属于线程的上下文数据,会被保存在线程1当中。此时内存中tickets的值依旧是10000,紧接着线程2被调度执行,它的优先级比较高,它能够多次执行运算,当它运算了三次以后将tickets的值减到了9997并且将该值写回了内存当中,然后才被切换走。紧接着线程1继续运行,它将上一次没来得及写入内存的9999数据重新写入了内存,此时tickets的值就发生了混乱,最后的结果就会出错。

我们来运行一下代码,结果如下图

这里我们就要引出线程的互斥概念。

二、线程的互斥

1.互斥的概念

其实要解决上面多线程同时访问临界资源出现的问题,我们只需要保证线程的原子性即可。所谓原子性指的是一件事情要么不做,要么直接做完,不存在中间状态。所以,有原子性的线程2不会被任何调度机制打断,也就来得及写回内存中,不会导致数据的错乱。我们要想实现每一个线程在执行临界区的代码时都不会被打扰,不会临时被切换走,我们就要对临界区代码进行加锁操作。加锁了以后,线程之间就是互斥的。所谓互斥指的是当我们访问某种资源的时候,任何时刻都只有一个执行流在访问。

2.互斥锁

Linux操作系统下的互斥锁是pthread_mutex_t类型的,只要我们加上了互斥锁,就可以保证多线程对临界资源的互斥访问。互斥锁可以定义为全局的也可以定义为局部的。全局的互斥锁是所有线程都可以使用的,定义全局互斥锁用 PTHREAD_MUTEX_INITIALIZER 宏来初始化,用这种方式初始化可以不用手动destroy来释放锁。

// 用宏初始化互斥锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

我们再来看一下互斥锁的函数接口,这些也设计的很简单,很实用:

pthread_mutex_init函数

这与上面不同的是,这种方式是动态分配互斥量(mutex),所以需要手动destroy

int pthread_mutex_init(pthread_mutex_t *restrict mutex, const 
pthread_mutexattr_t *restrict attr);
 参数:
 mutex:要初始化的互斥量
 attr:NULL

pthread_mutex_init函数是互斥锁的初始化函数,一般用于定义局部互斥锁时的初始化。参数非常简单,第一个参数mutex传递进定义好的互斥锁,第二个参数attr设置互斥锁的状态,一般我们设置为nullptr代表默认状态即可。

pthread_mutex_destroy函数

pthread_mutex_destroy函数是释放互斥锁的函数,在我们不再需要使用互斥锁的时候,调用该函数将指定的互斥锁释放,参数传递互斥锁即可。

int pthread_mutex_destroy(pthread_mutex_t *mutex);

注意:不要销毁⼀个已经加锁的互斥量

(本质上destroy是释放内核为该锁分配的所有资源)

已经销毁的互斥量,要确保后面不会有线程再尝试加锁

pthread_mutex_lock函数

int pthread_mutex_lock(pthread_mutex_t *mutex);

pthread_mutex_lock函数是在指定的代码区域加锁函数,这种加锁方式是阻塞式加锁,也就是说如果当前申请的锁正在被别的线程使用,别的线程还没有解锁,那调用该函数的线程就会阻塞式地等待。参数传递互斥锁即可。

pthread_mutex_unlock函数

int pthread_mutex_unlock(pthread_mutex_t *mutex);

pthread_mutex_unlock函数是解锁函数,每个线程在使用完锁资源以后都应该要解锁,这样其它线程在申请锁资源的时候才可以正常使用,如果线程永远不解锁,其它线程又在阻塞式地申请锁资源,那就会形成死锁。参数也是传递互斥锁即可。

pthread_mutex_trylock函数

int pthread_mutex_trylock(pthread_mutex_t *mutex);

pthread_mutex_trylock函数也是在指定的代码区域加锁函数,但这种加锁方式是非阻塞式加锁,如果申请的锁资源还没有被其它线程所释放,那么该函数会直接返回EBUSY,调用该函数的线程会继续去执行其它任务。参数传递互斥锁即可。

下面我们写一下代码演示一下这些互斥锁函数接口的具体使用方式:

#include <iostream>
#include <cstring>
#include <unistd.h>
#include <pthread.h>

using namespace std;

int tickets = 10000; // 总票数为10000张

// 定义一个全局的互斥锁
pthread_mutex_t mutex;

// 执行抢票逻辑
void *getTicket(void *args)
{
    const char *name = (char *)args;
    while (true)
    {
        // 在进入循环以后加锁
        // 保证其它申请锁的线程能阻塞在这个地方,不继续往下执行
        pthread_mutex_lock(&mutex);
        // 先判断是否还有票
        if (tickets > 0)
        {
            cout << name << " 抢票成功, 票编号为: " << tickets << endl;
            tickets--;
            // 使用完以后解锁,让其它线程也可以使用
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            // 使用完以后解锁,让其它线程也可以使用
            pthread_mutex_unlock(&mutex);
            cout << name << " 抢票失败,票被抢完了......" << endl;
            break;
        }
    }

    return nullptr;
}

int main()
{
    // 初始化互斥锁
    pthread_mutex_init(&mutex, nullptr);
    // 创建三个线程模拟抢票
    pthread_t tid1, tid2, tid3;
    pthread_create(&tid1, nullptr, getTicket, (void *)"thread1");
    pthread_create(&tid2, nullptr, getTicket, (void *)"thread2");
    pthread_create(&tid3, nullptr, getTicket, (void *)"thread3");

    // 回收线程
    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);
    pthread_join(tid3, nullptr);

    // 释放锁资源
    pthread_mutex_destroy(&mutex);
    return 0;
}

3.从内核角度看加锁

为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 现在我们把lock和unlock的伪代码改一下

但这个过程具体是如何保证原子性的呢?我们假设有两个线程A和线程B在申请锁资源,演示一下加锁的过程。假设首先是线程A开始申请锁,根据汇编指令,先将数字0写入到al寄存器中,然后再将寄存器中的值与内存中mutex的值交换,此时就表示线程A已经拿到锁了。

仔细读这张图,我们再来想一下,那上面提到的过程也是分了两步才完成的加锁,它到底是怎么保证原子性的呢?

首先,在只考虑单核单CPU的情况下一个CPU在某一时刻只能执行一个线程,那么我们来看上面的两步操作:如果线程A在将数字0写入到al寄存器之后就立马被CPU剥离下来,线程B紧接着被调度,同样执行将数字0写入到al寄存器中,但这丝毫不影响线程A,因为寄存器中的数据属于线程的上下文数据,线程A在被剥离下来的同时也会将这个数据带走,所以线程B对al寄存器的写入并不会覆盖线程A写入的数字0;

如果线程A在执行完第二步以后,线程A被剥离下来,线程B紧接着被调度,它同样执行着将数字0写入到al寄存器中,再将寄存器的值与内存中mutex的值做交换,但此时线程B是拿它自己写入的数字0与线程A写入的数字0做交换,在汇编代码接下来的指令中,条件判断会不通过,因为线程B并没有拿到锁,所以就会挂起等待锁资源。这样就保证了锁申请的原子性。

所以,论述到这里我们就知道了加锁的本质就是:将锁从内存读入寄存器,也就是将锁从共享变成线程私有。

4.RAII风格的加锁方式

RAII 的核心逻辑可以用一句话概括:将资源的生命周期绑定到对象的生命周期

RAII风格是C++程序设计中一种设计的方式,它是Resource Acquisition Is Initialization的简称,翻译过来就是资源获取是初始化。意思就是说我们在初始化的时候就可以顺便获取锁资源。

所以我们就不难理解绑定的含义就是:对象构造时获取资源,对象析构时自动释放资源。

我们来看一段代码来自定义 RAII 锁类:

#include <mutex>
#include <iostream>

// 自定义 RAII 锁管理类
class RAIILock {
private:
    std::mutex& m_mutex; // 引用锁对象,避免拷贝
    bool m_locked;       // 标记是否已加锁,防止重复解锁

public:
    // 构造函数:自动加锁
    explicit RAIILock(std::mutex& mutex) : m_mutex(mutex), m_locked(true) {
        std::cout << "RAIILock: 构造,加锁\n";
        m_mutex.lock();
    }

    // 析构函数:自动解锁(保证执行)
    ~RAIILock() {
        if (m_locked) {
            std::cout << "RAIILock: 析构,解锁\n";
            m_mutex.unlock();
        }
    }

    // 手动解锁(可选)
    void unlock() {
        if (m_locked) {
            m_mutex.unlock();
            m_locked = false;
            std::cout << "RAIILock: 手动解锁\n";
        }
    }

    // 禁止拷贝/移动(避免锁被意外传递)
    RAIILock(const RAIILock&) = delete;
    RAIILock& operator=(const RAIILock&) = delete;
};

// 测试自定义 RAII 锁
void test_custom_lock() {
    std::mutex mtx;
    {
        RAIILock lock(mtx); // 构造加锁
        std::cout << "执行临界区代码\n";
        // lock.unlock(); // 可选:手动提前解锁
    } // 析构自动解锁(如果没手动解锁)
}

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

三、线程安全

线程安全指的是在多个线程并发同一段代码的时候,不会出现错误的结果。我们常见的对全局变量或者是静态变量进行操作,并且在没有锁保护的情况下,会出现错误的结果,这就是线程不安全的问题。

在同一个函数被不同的执行流调用的时候,如果当前的执行流还没有执行完毕,就被切换了有其它的执行流进入,这种现象称之为重入个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

可重入与线程安全联系

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

可重入与线程安全区别

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

四、死锁

死锁指的是一组进程中各个进程均占有不会被释放的资源,但互相之间都在申请着这些被其它进程占用不释放的资源,从而导致处于一种永久的等待状态。如下图

死锁四个必要条件

1. 互斥条件:

资源具有排他性,就像这把锁,同一时间不能被两个人同时占有。

2.请求与保持条件:

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

3.不剥夺条件:

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

4.循环等待条件

一组进程 / 线程之间形成环形的资源依赖链,每个进程都在等待下一个进程占有的资源。

如图线程A(等待)→锁2→线程B→A。如此,便构成了一个循环。

总结一下:

如何避免死锁

本质就是破坏四个必要条件。如上图。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值