Linux | 线程互斥 | 互斥锁 | RAII式使用互斥锁 | 死锁

线程切换导致的问题

讲解线程切换的问题前,需要先理解几个概念

临界资源:一个进程下可能有很多的线程,能被多个线程看到的资源并且同时只允许一个线程访问的资源叫做临界资源
临界区:线程访问临界资源的代码叫做临界区
互斥:访问临界资源时,任何时候都只有一个线程可以访问这块资源,其他线程无法访问,我们就称这块资源具有互斥性

假设现在有一个抢票系统,有4个线程想要进行抢票,那么这4个线程就需要看到现在剩余的票数,如果用tickets表示剩余的票数,tickets就是一个能被很多线程看到的共享资源

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

using namespace std;

// 共享资源,tickets
int tickets = 1000;

void* get_tickets(void* arg)
{
    while (1)
    {
        if (tickets > 0)
        {     
            usleep(1000); // 这行代码只是为了演示多线程并发地访问临界资源的问题
            tickets--;
            cout << (char*)arg << ":抢到了票,剩余票数:" << tickets << endl;
        }
        else
        {
            cout << (char*)arg << ":没有抢到票, 因为被抢完了..." << endl;
            break;
        }
    }
}

int main()
{
    pthread_t tid1;
    pthread_t tid2;
    pthread_t tid3;
    pthread_t tid4;
	
	// 线程创建
    pthread_create(&tid1, nullptr, get_tickets, (void*)"thread 1");
    pthread_create(&tid2, nullptr, get_tickets, (void*)"thread 2");
    pthread_create(&tid3, nullptr, get_tickets, (void*)"thread 3");
    pthread_create(&tid4, nullptr, get_tickets, (void*)"thread 4");
	
	// 线程资源回收
    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);
    pthread_join(tid3, nullptr);
    pthread_join(tid4, nullptr);

    return 0;
}

在这里插入图片描述
按理说,票的数量被抢到0后就不能再抢了,但是这段demo运行后,票数被直接抢到了-3,很明显,线程访问共享资源出现了问题,导致问题的原因就是线程缺乏访问控制

从汇编的层面理解tickets–这个操作,tickets的自减大概需要三步操作:

1.将该tickets从内存加载到cpu中,由cpu进行–操作
2.cpu将ticket–,得到运算的结果并保存
3.cpu将得到的结果存回内存,至此tickets–完成

所以tickets–的操作并不是一步到位的,我们称这样不能用一条指令完成的操作不具有原子性,如果一个操作只需要用一条指令完成,那么该操作具有原子性。

线程就是进程下的执行流,操作系统会并发地执行进程下的线程,所以每个线程都有自己的时间片,时间片到了,操作系统会保存线程的上下文数据(线程共享cpu内的寄存器,但是寄存器中的数据是属于当前线程,操作系统将会这些数据保存到线程的控制块中),并将线程从cpu剥离下来,然后恢复另一个线程的上下文数据,并执行该线程,操作系统需要不断的这样切换线程完成多线程的并发执行

除了线程的时间片到了,引起线程被cpu剥离下来的事件还有很多,这里不再赘述,我们只需要知道,线程在执行结束前会被频繁的从cpu剥离,它的上下文数据随之被频繁的保存与恢复。

我们可以假设这样一个场景,线程1正在访问临界资源tickets,并且在执行tickets–的操作,现在的tickets为10000,该线程将tickets从内存加载到cpu上,并且完成–的操作,正准备将tickets写回内存时,它的时间片到了。操作系统将其从cpu上剥离下来,并保存了它的上下文数据,其中包括了运算完成的tickets,9999。

操作系统剥离该线程后恢复线程2的上下文数据,该线程看到的临界资源tickets的值依然为10000,并且该线程疯狂的抢票,将tickets抢到了50,期间没有被操作系统剥离。刚好完成将数值为50的tickets写回内存的操作后,操作系统将其剥离,并恢复线程1的上下文数据,执行线程1时发现它正准备执行将tickets写回内存的操作,于是操作系统继续执行线程1未完成的操作,将数值为9999的tickets写回内存,50的tickets又变成了9999。

以上的场景虽然夸张了些,但是通过理论我们可以得知多线程共同访问临界资源时,由于访问的操作不具有原子性,很有可能因为多线程的切换导致数据不一致的问题,同时这也解释了为什么票数会被抢到-3。demo中线程的usleep就是为了使当前线程被cpu剥离次数增加,增加多线程访问临界资源出错的概率

当线程1执行完tickets > 0这条语句后,正准备执行tickets–却被切走,假设现在的tickets是1,其他线程依然可以对tickets–,当tickets被减到0时,线程1被切回来执行ticket–的操作,将tickets减到-1。这样的情况一多,tickets就可能被减到了-3。

所以要保证在多线程并发执行的情况下,对临界资源的访问不出现问题,就需要保证访问临界资源的过程是原子的,即一步到位的,在访问时不会被切走的。因此Linux引入了锁的概念,通过给线程加锁,屏蔽其他线程对于临界资源的访问,当当前线程访问临界资源时,其他线程无法访问,即使当前线程被切走

互斥锁

线程库为我们提供了锁的申请与释放相关的接口
在这里插入图片描述

pthread_mutex_init的参数
mutetx:锁的地址,锁的变量类型是pthread_mutex_t
attr:锁的属性,如不需要设置将其置空即可

如果这把锁位于栈区,那么只能使用pthread_mutex_init函数对栈区锁进行初始化,而如果这把锁是位于静态区或者全局数据区的,除了用pthread_mutex_init对全局锁进行初始化,还能使用PTHREAD_MUTEX_INITIALIZER这个宏对锁进行初始化。

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

当使用PTHREAD_MUTEX_INITIALIZER这个宏初始化一个全局或静态锁时,就不用调用pthread_mutex_init和pthread_mutex_destroy对锁进程初始化和销毁。不像全局锁或静态锁,所有新线程都可以看到这样的锁,当使用一个局部的锁时,由于局部锁只属于主线程,只有主线程可以看到这把锁,新线程无法看到这把锁,也就无法使用局部锁,但是可以通过线程的创建接口将锁的地址传入
在这里插入图片描述
将锁的地址作为pthread_create的arg参数,当线程调用start_routine时,arg参数被传入,也就是传入了一把锁的地址。如果arg是一个复杂的结构,有两个及以上的内置类型,arg参数也可以是一个结构体的地址,将这些资源的地址传入函数,使这些资源对于线程可见

对于锁的释放,只要将锁的地址作为pthread_mutex_destroy的参数传入即可。
在这里插入图片描述
申请完互斥锁后,就需要使用这把锁,注意:锁的使用是阻塞式的,如果当前线程正在使用这把锁,其他线程就不能使用这把锁,直到当前线程使用完这把锁,其他线程才有机会使用这把锁。

使用pthread_mutex_lock接口对当前线程加锁:需要将锁的地址传入这个接口,当前线程会因为其他线程正在使用这把锁而阻塞

pthread_mutex_trylock,接口的使用与pthread_mutex_lock一致,不同的是当前线程不会因为其他线程正在使用锁而阻塞,如果其他线程正在使用这把锁,pthread_mutex_trylock上锁失败,线程会向下继续执行,有点像父进程的非阻塞式等待。

pthread_mutex_unlock就是对线程的解锁,没什么好说的,接口都一样。

(原以为线程调用trylock却没有申请到锁时,不会执行接下来的代码直到unlock解锁,从解锁的地方往后执行,但是并不是这样,trylock没申请到锁,依然会执行接下来的代码,也就是说其他线程对临界区是加了锁再访问,而trylock线程却可以不申请锁而访问其他线程需要加锁才能访问的临界区,个人认为trylock这样尝试性的加锁依然会导致临界资源的混乱访问。所以我们需要根据trylock的返回值判断锁的状态,根据锁的状态决定该线程是否要继续执行,这对于trylock的使用非常重要)

// 这是测试trylock的代码
pthread_mutex_t mutex;

void* test_trylock1(void* arg)
{
    pthread_mutex_trylock(&mutex);
    cout << "11" << endl;
    sleep(10000);
    pthread_mutex_unlock(&mutex);
}

void* test_trylock2(void* arg)
{
    sleep(1);
    pthread_mutex_trylock(&mutex);
    cout << "22" << endl;
    sleep(10000);
    pthread_mutex_unlock(&mutex);
    cout << "33" << endl;
}


int main()
{
    pthread_mutex_init(&mutex, nullptr);
    pthread_t tid1;
    pthread_t tid2;

    pthread_create(&tid1, nullptr, test_trylock1, (void *)"thread 1");
    pthread_create(&tid2, nullptr, test_trylock2, (void *)"thread 2");

    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);
 
    pthread_mutex_destroy(&mutex);

    return 0;
}

这段demo申请了两个线程,线程1执行test_trylock1函数,线程2执行test_trylock2函数,线程2会先休眠一秒,让线程1申请到mutex锁,线程2调用trylock申请mutex锁,很明显这时的mutex锁已经被线程1申请走了,并且线程1抱着这把锁休眠了10000秒,线程2在很长一段时间无法申请到这把锁。

我原以为cout << “22” << endl这行代码不会执行,线程2会直接跳到解锁的代码后——cout << “33” << endl执行,但是测试结果显示,线程2依然会执行cout << “22” << endl,切休眠10000秒,暂时没有执行cout << “33” << endl
在这里插入图片描述
所以trylock不会使线程进入阻塞,在没有锁的情况下,线程依然可以访问其他线程加了锁的临界区,对共享资源做访问,因此在使用trylock时要特别注意其返回值,通过返回值判断锁的状态,决定是否需要向后执行程序


回到正题,对线程的临界区加锁,使线程对ticket的访问具有原子性,这样程序最后剩余的票数就是正常的

void* get_tickets(void* arg)
{
    while (1)
    {
    	// 对临界区的加锁
        pthread_mutex_lock(&mutex);
        if (tickets > 0)
        {
            usleep(1000);
            tickets--;
            cout << (char*)arg << ":抢到了票,剩余票数:" << tickets << endl;
            // 解锁
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            cout << (char*)arg << ":没有抢到票, 因为被抢完了..." << endl;
            // 解锁
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
}

int main()
{
	// 锁的申请
    pthread_mutex_init(&mutex, nullptr);
    pthread_t tid1;
    pthread_t tid2;
    pthread_t tid3;
    pthread_t tid4;

    pthread_create(&tid1, nullptr, get_tickets, (void*)"thread 1");
    pthread_create(&tid2, nullptr, get_tickets, (void*)"thread 2");
    pthread_create(&tid3, nullptr, get_tickets, (void*)"thread 3");
    pthread_create(&tid4, nullptr, get_tickets, (void*)"thread 4");

    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);
    pthread_join(tid3, nullptr);
    pthread_join(tid4, nullptr);
	// 锁的释放
    pthread_mutex_destroy(&mutex);

    return 0;
}

对于加锁的位置也需要特别注意,只能对临界区加锁,并且加锁的粒度越细越好,上面的demo中while循环内的if else判断条件代码块就是对共享资源的访问,是一块临界区,所以可以对这块临界区加锁。并且解锁语句要分别放在if和else块中,不能放在if和else的外面,像这样在这里插入图片描述
当票数为0,else块执行完直接break,略过了解锁语句,这会导致当前线程一直占用着这把锁,其他线程无法使用这把锁,程序就阻塞在了这条上锁语句中,并且由于占有锁的线程已经退出,这个阻塞是永久的

修改demo后运行程序,票数被正常地抢完
在这里插入图片描述
但是深入的思考一下,既然每个线程都要上锁,那么每个线程就需要看到这把锁,所以这把锁也是一种共享资源,用一个共享资源保护另一个共享资源,难道前一个共享资源不会因为多线程的并发执行而出现问题吗?

因为多线程的并发导致共享资源出现数据不一致问题的前提是:多线程对共享资源的访问不是原子的! 只要对共享资源的访问操作是原子的,操作系统切换线程就无法影响到共享资源。而锁的设计原则就是锁的任何操作都是原子的,所以对于锁的访问是绝对安全的,不会出现数据不一致的问题

上面的运行结果还有一个现象,就是一个线程一直在抢票,其他线程没有抢票
在这里插入图片描述
导致这样结果的原因是:在while循环中,当前线程申请了锁,其他线程由于锁被使用,进入阻塞状态。当前线程运行到解锁并重新上锁花费的时间,比唤醒其他阻塞的线程再对其上锁花费的时间少,所以操作系统更偏向于调度当前线程,较少进行线程的切换。

要解决这个问题,就需要增加当前线程从解锁到再次上锁花费的时间,所以我们可以在临界区外添加uslepp,延长线程的执行时间在这里插入图片描述
在这里插入图片描述
这样操作系统就会频繁的切换线程


加锁的原则:如果对一个线程访问共享资源的临界区加了锁,那么其他线程访问该共享资源的临界区也需要加锁,这是一个规范。

加锁的线程会被cpu切走吗?

加锁会使线程的代码成为原子操作吗?很显然并不会,操作系统依然能切换位于临界区的线程

但是加锁与解锁的过程是原子的,也就是说一个线程只有两种状态,要么加锁了,要么没加锁,当线程A申请到了锁,就算被操作系统剥离,其他线程可以访问被线程A加锁的共享资源吗?答案是否定的(前提是其他线程调用pthread_mutex_lock()申请同一把锁),因为其他线程申请锁时会发现这把锁已经被线程A带走了,既然没有锁,共享资源就无法访问,只有拥有锁的线程A才能访问共享资源。

所以对于加了锁的线程A来说,虽然依然会被cpu切走,但是根本不用关心,因为其他线程无法访问线程A正在访问的共享资源。

互斥锁实现原理之一

在这里插入图片描述
这是用编译语言实现上锁的伪代码,大致的解释一下上锁的实现过程。

首先al是一个寄存器,第一行代码将0这个值加载到寄存器中,
第二行代码,将寄存器中的0与内存中的mutex交换,mutex是一个锁的属性,是一个变量,该变量的值一开始就是1
第三行代码,判断al寄存器中的值是否大于0,如果al的值为1,lock成功,返回0,如果al的值为0,lock失败,线程被挂起等待

在这里插入图片描述
其中要特别说明的是,第二行代码的xchgb交换,是用一条指令实现的交换,也就是交换内存中的数据与寄存器中的数据只需要一条指令,这是整个上锁过程的关键

假设现在有A,B,C三个线程,这三个线程都执行了第一行代码,将0加载到寄存器中,那么这三个线程的数据被互相覆盖了吗?答案是没有,保存0的寄存器虽然被多个线程共享,但是寄存器中的数据是属于线程的,当线程被切换,寄存器中的数据作为线程的上下文数据是需要被保存到线程控制块中的。

所以,虽然线程A将0写入了寄存器al,当A被切换时,寄存器al中的数据作为线程A的上下文数据是需要被线程保存的,所以线程A被切换后,寄存器al中的数据就不属于线程A了,线程A已经将自己的数据保存到自己的线程控制块中,也就没有其他线程覆盖线程A的数据的说法了。所以看似有很多线程在访问同一的寄存器,但是因为上下文数据的保存,它们的数据可以做到互不影响

回到互斥锁的实现,从第三行代码可以看出,当寄存器al的值为非0时,函数返回0,表示线程加锁成功,所以al寄存器的值是否为0,是线程申请锁的关键。而al寄存器的值一开始就是0,此时申请锁总是失败的,想让它是一个非0值,唯一的途径就是与内存中的mutex交换,mutex的值为1,交换后al寄存器的值就是1,而mutex的值就是0,也就是说,无论线程有多少个,一把锁只有一个1可以被交换,哪个线程交换到了这个1,哪个线程就可以访问临界资源,因为mutex的值被交换过了,已经是0 了,就算其他线程再交换,得到的值也是0,是0就无法访问临界资源,这样也就做到了临界资源的互斥性。

RAII式的使用互斥锁

对线程库提供的原生锁进行封装,将锁的初始化pthread_mutex_init封装成类的构造函数,将锁的销毁pthread_mutex_destroy封装成类的析构函数。然后就可以创建一个该类的对象,实现创建即初始化。此外,对该类添加lock和unlock函数,分别对应线程的上锁和解锁。

class my_mutex
{
public:
	// my_mutex的构造,对锁进行初始化
    my_mutex()
    {
        pthread_mutex_init(&_mutex, nullptr);
    }
    // my_mutex的析构,释放锁的资源
    ~my_mutex()
    {
        pthread_mutex_destroy(&_mutex);
    }
    // 上锁和解锁
    void lock()
    {
        pthread_mutex_lock(&_mutex);
    }
    void unlock()
    {
        pthread_mutex_unlock(&_mutex);
    }
private:
	// 封装原生的锁
    pthread_mutex_t _mutex;
};

这样封装以后,就不用再使用pthread_mutex_init和pthread_mutex_destroy函数创建和初始化锁,只要定义my_mutex类的对象即可创建锁,当该对象的生命周期结束,锁也就释放了

比如定义了一个my_mutex类对象mutex,当需要对线程上锁时,就要mutex.lock(),解锁就要mutex.unlock(),其中还是可以对其进行优化。再封装一个类,当该类的对象创建时,线程上锁,也就是调用mutex.lock(),当该对象的生命周期结束,线程解锁,也就是调用mutex.unlock()

// 管理上锁与解锁的类
class mutex_guard
{
public:
    mutex_guard(my_mutex* mutex_p)
    :_lock(mutex_p)
    {
        _lock->lock();
    }
    ~mutex_guard()
    {
        _lock->unlock();
    }
private:
    my_mutex* _lock;
};

mutex_guard可以理解为锁的守卫,创建该类的对象时,将需要被守卫的锁的地址传入,mutex_guard的成员只有一个,就是锁的地址,当守卫创建,线程上锁,当守卫生命周期结束,线程解锁。

// 定义一个全局的锁
// 创建即初始化,mutex被创建,锁也被初始化
my_mutex mutex;
int tickets = 10000;

// get_tickets是一个访问临界资源的函数
bool get_tickets()
{
    bool ret = false;
    // 可以特地的创建一块代码块,只是为了控制对象的生命周期,为线程上锁
    {
        mutex_guard guard(&mutex);
        if (tickets > 0)
        {        
            tickets--;
            ret = true;
            usleep(100);
        }
    }
    usleep(123);
    // 还有票时,ret为真,没有票时,ret为假
    return ret;
}

void *start_routine(void *arg)
{   
	// 不断的抢票
    while (1)
    {
        // 如果没有抢到票就退出
        if (!(get_tickets()))
        {
            break;
        }
        // 抢到票了就打印提示信息
        cout << (char *)arg << ":抢到了票,剩余票数:" << tickets << endl;
    }
}

int main()
{
    pthread_t tid1;
    pthread_t tid2;
    pthread_t tid3;
    pthread_t tid4;

    pthread_create(&tid1, nullptr, start_routine, (void *)"thread 1");
    pthread_create(&tid2, nullptr, start_routine, (void *)"thread 2");
    pthread_create(&tid3, nullptr, start_routine, (void *)"thread 3");
    pthread_create(&tid4, nullptr, start_routine, (void *)"thread 4");

    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);
    pthread_join(tid3, nullptr);
    pthread_join(tid4, nullptr);

    return 0;
}

以上对于锁的两层封装使用了RAII思想:创建即初始化,利用变量的生命周期,简化了我们对锁的管理

(我们可以在程序的任意位置添加代码块,用代码块控制对象的生命周期,使线程上锁的粒度更细)
在这里插入图片描述

可重入与线程安全

可重入:当一个线程还未执行完一个函数,其他线程就能进入该函数,我们称该函数具有重入特性。一个函数在被重入的情况下,不会出现任何问题,我们称该函数为可重入函数。反之,如果一个函数被重入,会出现问题,我们称该函数为不可重入函数
线程安全:多线程并发的执行代码时,不会出现数据不一致的情况,线程安全问题常见于线程对全局或静态变量的访问中,如果线程没有加锁,就会导致线程安全问题

当一个程序的函数是可重入的,那么这个程序就是线程安全的,而如果这个程序是线程安全的,它的函数不一定是可重入的,还有可能是不可重入函数,只是对其添加了访问控制

死锁

死锁是指在一组进程中,各个线程都占用着不会释放的资源,并且线程之间互相申请被占用的不会释放的资源,导致所有线程处于永远阻塞的状态

一个死锁的经典场景:现在有两个线程,线程1和线程2,并且有两把锁,锁A和锁B,线程1先申请A锁,再申请B锁,线程2先申请B锁,再申请A锁。由于系统调度的时序问题,可能会发生线程1申请了A锁,线程2也申请到了B锁,当线程1要申请B锁时,由于B锁被线程2使用,线程1陷入阻塞,而线程2要申请A锁,但A锁被线程1使用,于是线程2也陷入阻塞。两个线程带着两把锁陷入了阻塞,但是两个线程都不会被唤醒,因为唤醒它们需要的锁,被占有它们的线程带入了阻塞状态,两个线程陷入阻塞,互相等待对方将锁释放,但这是不可能的,所以两个线程就陷入了永久阻塞,也叫死锁

pthread_mutex_t mutex_a;
pthread_mutex_t mutex_b;

void* test_trylock1(void* arg)
{
    pthread_mutex_lock(&mutex_a);
    sleep(1);
    cout << "线程1申请到了A锁" << endl;
    pthread_mutex_lock(&mutex_b);

    cout << "线程1正在运行" << endl;

    pthread_mutex_unlock(&mutex_a);
    pthread_mutex_unlock(&mutex_b);
}

void* test_trylock2(void* arg)
{
    pthread_mutex_lock(&mutex_b);
    cout << "线程2申请到了B锁" << endl;
    sleep(1);
    pthread_mutex_lock(&mutex_a);
    cout << "线程2正在运行" << endl;

    pthread_mutex_unlock(&mutex_b);
    pthread_mutex_unlock(&mutex_a);
}

int main()
{
    pthread_mutex_init(&mutex_a, nullptr);
    pthread_mutex_init(&mutex_b, nullptr);

    pthread_t tid1;
    pthread_t tid2;

    pthread_create(&tid1, nullptr, test_trylock1, (void *)"thread 1");
    pthread_create(&tid2, nullptr, test_trylock2, (void *)"thread 2");

    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);

    pthread_mutex_destroy(&mutex);

    return 0;
}

这段demo为了演示死锁现象,在线程1申请A锁和线程2申请B锁后使两个线程休眠一秒,当它们被唤醒时,A锁和B锁肯定都被彼此使用,此时两个线程陷入死锁
在这里插入图片描述

死锁的四个必要条件

互斥条件:临界资源在同一时间下只能被一个线程访问
请求与保持条件:一个线程申请锁资源时,不释放自己占有的锁资源,如果因为申请锁资源失败而陷入阻塞,就容易造成死锁
不剥夺条件:一个线程不能剥夺其他线程占有的资源
循环等待条件:若干执行流之间的申请关系构成一个循环

以上四个条件缺少一个都无法构成死锁

如何避免死锁

破坏四个必要条件中的一个:只要四个必要条件少一个都无法构成死锁
加锁顺序一致:破坏循环等待条件,减少线程占用彼此资源的概率
避免锁未释放的场景:破坏请求与保持条件,减少线程占有锁资源同时又去申请其他锁资源的概率
资源一次性分配:使线程占用彼此资源的概率减少,破坏请求与保持条件
总结下来就是,规范自己的编码习惯,使每个线程加锁顺序一致,及时释放锁

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值