【Linux】第十一篇:线程安全(互斥锁,死锁,条件变量)


在这里插入图片描述

概念

  1. 临界资源:凡是被多线程共享访问的资源都是临界资源。
  2. 临界区:不是所有代码都会访问临界资源的,凡是能够访问临界资源的代码称为临界区。
  3. 互斥:对临界区进行保护,互斥保证在任何时刻只有一个执行流进入临界区,访问临界资源。
  4. 原子性:原子性操作使执行流排除了任何抢占的可能性,是不会被线程调度机制打断的操作。在当前进程内,一旦某线程的原子性操作开始就会一直运行到结束。所以原子性操作只有两态:要么完整地被执行,要么完全不执行。
  5. 同步:在访问临界资源的过程是安全的前提下(互斥+原子性),让访问资源具有一定的顺序性。

多线程可以共享进程中的大部分资源(临界资源),例如我们定义全局变量,分别使用两个线程分别对其进行修改和读取的操作。

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>

int count=0;
void* thread_run(void* argv)
{
    while(1)
    {
        count++;
        sleep(1);
    }
    return (void*)0;
}
int main()
{
    pthread_t tid;
    pthread_create(&tid,NULL,thread_run,(void*)"thread");

    while(1)
    {
        printf("count= %d\n",count);
        sleep(1);
    }
    pthread_join(tid,NULL);
    return 0;
}

在这里插入图片描述

我们分别让两个线程对临界资源进行读写看似挺和谐,但是危机四伏,多线程自顾自地对临界资源做修改,到最后便会导致该临界资源错乱。

为了能看到造成的数据错乱,我们再模拟一个卖票系统,首先需将票数定义为全局,这样多用户(多线程)才能访问对其操作,同时创建4个子线程来抢票,票抢完线程退出。

int tickets=10;
void* BuyTicket(void* argv)
{
    char* id=(char*) argv;
    while(1)
    {
        if(tickets>0)
        {
            sleep(1);
            printf("[%s] get a ticket , remain: %d\n",id,--tickets);
        }
        else 
        {
            printf("tickets sold out\n");
            break;
        }
    }
    pthread_exit((void*)0);
}
int main()
{
    pthread_t t1,t2,t3,t4;
    pthread_create(&t1,NULL,BuyTicket,(void*)"user1");
    pthread_create(&t2,NULL,BuyTicket,(void*)"user2");
    pthread_create(&t3,NULL,BuyTicket,(void*)"user3");
    pthread_create(&t4,NULL,BuyTicket,(void*)"user4");

    pthread_join(t1,NULL);
    pthread_join(t2,NULL);
    pthread_join(t3,NULL);
    pthread_join(t4,NULL);
  
}

该代码中全局变量 tickets 是临界资源,因为它被多个执行流同时访问,而判断tickets是否大于0、打印剩余票数以及–tickets这些代码就是临界区,因为这些代码对临界资源进行了访问。

在这里插入图片描述

可以看到最后一张票被抢完后,仍有用户线程在抢票,并把票抢成了负数:

因为没有对临界区进行保护,所以if判真后,代码可能并发切换到其他线程。sleep则是模拟漫长业务过程,在此期间,别的线程因为也对tickets做出了访问,所以当时间片轮转回来后,tickets可能已经为0,但是当前进程又会对tickets进行 --操作,于是票被抢成了负数。

🚩注意:临界区即使只有一行代码,也无法保证原子性!

例如:ticket--语句,其汇编会分成3条语句(load加载到寄存器,sub逻辑计算,store寄存器写回),这期间依然可能存在被其他线程抢占的情况。

写段代码试一下:

#include <stdio.h>
int main()
{
    int a=0xFFFFEEEE;
    a--;
    return 0;
}

gcc编译后使用反汇编指令 objdump -S 可执行文件 > test.s 进入test.s可查看汇编代码,一个简单的 a-- 操作执行了3行汇编语句

4004f1: c7 45 fc ee ee ff ff  movl   $0xffffeeee,-0x4(%rbp)
4004f8: 83 6d fc 01           subl   $0x1,-0x4(%rbp)
4004fc: b8 00 00 00 00        mov    $0x0,%eax

1. 互斥量(mutex)🔒

以上问题如果发生在现实生活将是灾难性的,所以多线程情况下必须做到以下几点:

  • 临界区代码必须要有互斥行为,一个线程在执行临界区代码时,不允许有其他线程进入该临界区。
  • 如果多个线程要求进入临界区,并且临界区没有线程执行,那么只能允许一个线程进入。
  • 如果线程不在临界区,那么该线程不能阻止其他线程进入临界区

做到以上三点,本质上就是需要一把锁🔒,Linux提供的锁称为互斥量

在这里插入图片描述

互斥量的接口

互斥量使用 pthread_mutex_t 类型的变量表示,使用前需初始化,使用中需加锁与解锁,使用完后需对锁进行释放,其相关函数如下(以下函数需引入头文件 <pthread.h>)。

初始化互斥量

  • 方法一 动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
  • 参数

    • restrict mutex :需要初始化的互斥量
    • restrict attr :初始化互斥量的属性,这里设置为NULL,设为默认属性。
  • 返回值:成功返回0,失败返回错误码

  • 方法二 静态分配

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

使用宏定义来初始化,相当于用 pthread_mutex_init 初始化并且attr参数为NULL。

互斥量加锁与解锁

  • 加锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
  • 解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • 参数

    • mutex :需加锁的互斥量
  • 返回值:成功返回0,失败返回错误码。

    1. 如果互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
    2. 当一个线程B调用加锁函数时,如果这时另一个线程A已经调用加锁函数对互斥量加了锁,则线程B需要挂起等待,直到线程A调用解锁函数释放互斥量,那么线程B会被唤醒,才能获得对该互斥量加锁并继续执行。

🚩如果一个线程既想获得互斥量(锁),又不想阻塞等待,可以调用函数:pthread_mutex_trylock,如果互斥量已被已经被另一个线程加上锁,那么该函数会失败返回 EBUSY,而不会阻塞。

销毁互斥量

int pthread_mutex_destroy(pthread_mutex_t *mutex);
  • 参数

    • mutex:需要销毁的互斥量
  • 返回值:成功返回0,失败返回错误码errno。

🚩注意

  1. 使用 PTHREAD_MUTEX_INITIALIZER初始化的互斥量无需手动销毁。
  2. 销毁一个unlock的互斥量是安全的,而销毁一个lock的互斥量将会出现未定义的结果。
  3. 对于已销毁的互斥量,需确保后面不会再有线程使用此互斥量尝试加锁,除非重新init。

互斥量实验

针对上面的买票程序,我们使用互斥量对临界区进行保护:

  • 进入临界区前申请加锁,只有申请到锁的进程才能访问临界资源。
  • 离开临界区需释放锁,这样才能让其他线程竞争锁,继而进入临界区。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>

pthread_mutex_t mylock;
int tickets=10;
void* Ticket(void* argv)
{
    char* id=(char*) argv;
    while(1)
    {
        sleep(1);//走完单个线程的时间片从而可以切换线程,防止一个线程在其时间片内连续抢票。
        pthread_mutex_lock(&mylock);//加锁
        if(tickets>0)
        {
            sleep(1);
            printf("[%s] get a ticket , remain: %d\n",id,--tickets);
            pthread_mutex_unlock(&mylock);//解锁
        }
        else 
        {
            printf("tickets sold out\n");
            pthread_mutex_unlock(&mylock);//解锁
            break;
        }
    }
    pthread_exit((void*)0);
}


int main()
{
    pthread_mutex_init(&mylock,NULL);//初始化互斥量
    pthread_t t1,t2,t3,t4;
    pthread_create(&t1,NULL,Ticket,(void*)"user1");
    pthread_create(&t2,NULL,Ticket,(void*)"user2");
    pthread_create(&t3,NULL,Ticket,(void*)"user3");
    pthread_create(&t4,NULL,Ticket,(void*)"user4");

    pthread_join(t1,NULL);
    pthread_join(t2,NULL);
    pthread_join(t3,NULL);
    pthread_join(t4,NULL);
    pthread_mutex_destroy(&mylock);//销毁互斥量
    return 0;
}

可以看到票数是合理递减为0的:

在这里插入图片描述

🕵🏻‍♀️这里需要注意

当线程1拿到锁后,其他的线程会阻塞在 pthread_mutex_lock处,等待锁被线程1释放。

即使线程1的时间片结束,切换为其他线程时,他们都处于阻塞状态无法往下执行。

换回到线程1方可继续执行,当线程1释放锁后,其他线程此时被唤醒处于就绪态,但此时由于大概率仍处于线程1的时间片,所以循环回来后线程1仍能拿到锁,这样就会出现一个线程连续抢票的情况。

所以我们在循环后面 sleep一秒钟,让线程走完时间片,这样可以让时间片轮转到其他就绪态的线程拿到锁进入临界区,从而进行一人一次抢一票了。

如果缺少sleep(1):

在这里插入图片描述

3号用户(线程)就把票抢光了。

🚩注意:

  • 处于临界区内的线程一样会被线程切换的,但是其他线程会被阻塞在锁外,影响不到处于临界区内的线程。(拿着锁被切走的,没有释放,其他线程进不来)
  • 加锁是会损失性能的,因为在临界区的执行流只有一条,所以是串行执行。
  • 应当在合适位置加锁,以及加锁的粒度要尽可能小,来减少加锁的性能成本开销。
  • 临界资源的保护是所有执行流应该遵守的原则,程序员编码时需时刻注意。

2. 互斥量原理

显然互斥量是线程都能看到的资源,那么对于互斥量的加锁和解锁操作也势必是原子性的过程。一旦一个线程进入加锁和解锁的过程,其他线程就不能对其进行抢占。

在这里插入图片描述

大多数体系结构为了实现对互斥量的原子性操作,都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条汇编指令,保证了原子性。

即使是多处理器平台,访问内存的总线周期也有先后,一个处理器的交换指令执行时另一个处理器的交换指令只能等待总线周期。

为了了解互斥量自身的原子性操作,我们以x86的xchg指令为例,并写出互斥量加锁与解锁的伪代码以体现其原子性:

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

我们假设初始化的互斥量(mutex)初始值为1,al是cpu的寄存器,申请加锁时会执行以下步骤:

  1. 众线程先后执行 lock函数,首先将al寄存器中的值清0,这里注意al不是唯一的,每个线程会保存自己的一组寄存器信息(上下文)。
  2. 然后线程将al的数据和mutex进行交换,使用的xchg是体系提供的交换指令,具有原子性。
  3. 率先与mutex交换的线程,使al从0变为1,可准入临界区继续执行。而mutex此时为0,之后的其他的线程即使执行 交换指令,他们自己的al交换后也始终为0,进入阻塞队列等待唤醒。
  4. 从临界区执行结束后,线程执行unlock函数,mv被置为1表示互斥量已释放,释放锁的操作也只有一条指令,以保证原子性。之后唤醒阻塞队列中的线程(进入就绪态),此时线程将再次竞争申请锁(goto lock)。从临界区出来的线程的al此时仍为1,但是无关紧要,因为进入lock函数会将al清0。

以下图为例,线程A率先与mutex交换,得到互斥量后,准入临界区,而线程B及其他线程进入阻塞队列等待唤醒。

在这里插入图片描述

🚩注意:

  • 在申请锁时本质上就是哪一个线程先执行了交换指令(总线周期决定了指令的执行必须有先后顺序不能并行),那么该线程就申请锁成功,因为此时该线程的al寄存器中的值就是1了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的。
  • CPU内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器信息(线程切换时将会备份进线程私有栈),但内存中的数据是各个线程共享的,比如mutex。申请锁实际就是,把内存中的mutex通过交换指令,原子性的交换到自己的al寄存器中。

3. 线程安全与可重入函数

  • 线程安全:多线程并发同一段代码,没有出现数据错乱的情况。例如对全局变量或静态变量的多线程操作,在没有互斥量保护的情况下是线程不安全的。
  • 可重入函数:一个执行流在调用函数时,遇到其他线程同时调用该函数的情况,如果线程在调用此函数期间不出现数据错乱,以及不影响最终的运行结果,那么我们称此函数为可重入函数,否则为不可重入函数,通常访问的都是各自的私有栈资源。

判定线程安全的情形

  • 常见的线程不安全情形
  1. 线程调用了不保护共享变量的函数:如函数中随意修改全局或静态变量;
  2. 返回指向静态变量指针或者动态开辟空间指针的函数;
  3. 函数当中又调用了其他线程不安全的函数。
  • 常见的线程安全
  1. 每个线程只对全局变量或静态变量拥有读权限,没有写权限。而只能对私有栈数据进行处理;
  2. 类和接口对线程而言是原子性操作;
  3. 多线程切换不会导致接口的结果出现二义性。

判断函数是否可重入

  • 常见不可重入情况
  1. 函数中调用了malloc/free,因为malloc函数是用全局链表来管理堆的;
  2. 标准IO库,STL的很多实现都是以不可重入的方式使用全局数据结构;
  3. 函数体内有静态的数据结构。
  • 常见的可重入函数
  1. 不使用全局和静态变量;
  2. 不使用malloc或new开辟的空间;
  3. 函数中调用了其他的不可重入函数;
  4. 不返回静态或全局数据,所有数据只能为私有栈数据;
  5. 针对静态或全局数据,事先将其本地拷贝到私有栈中进行保护,调用时使用拷贝的变量即可。

两者关联

  1. 函数是可重入的(不触动共享资源),那它就是线程安全的函数的一种;

  2. 线程安全并不代表函数是可重入的:

    多线程下,面对不可重入函数我们可以通过加锁来保证线程安全继而可以重复进入一个不可重入的函数。

    而单线程情况下也有可能对一个函数进行重入(信号捕捉)。单线程的重入就无法通过加锁来解决,因为一旦加锁后发生重入(信号处理),就会导致死锁!

    可见重入函数的要求是很高的。

在这里插入图片描述

4. 死锁

🌰例子1:如果一个线程先后两次对同一个互斥量(以下统称为锁)调用lock进行加锁,在第二次调用时,由于锁已经被占用,该线程会挂起等待别的线程释放锁,然而这把锁正是被自己占用着的,但是他又被挂起而没有机会释放锁,因此就永远处于挂起等待状态了,这叫做死锁(Deadlock)。

🌰例子2:线程A获得锁a,线程B获得锁b,这时线程A调用lock试图获得锁b,而线程B也调用lock试图获得锁a。结果是线程A挂起等待线程B释放锁b,而线程B挂起等待线程A释放锁a,于是线程A和B都永久处于挂起状态了。

不难想象,如果涉及到更多线程和更多的锁,死锁的问题会变得更加复杂和隐蔽。

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

  • 不会释放的资源:也称为不可剥夺资源,当这类资源分配给线程后,再不能强行收回,只能在线程使用完后自行释放,常见如打印机,显示器等IO资源,当然也包括了互斥量(锁)。
  • 可释放资源:即可剥夺资源,该资源可以被其他线程或系统剥夺,如计算资源如CPU和主存等。

计算机世界很多事情需要多线程方式去解决,竞争有限资源的情况是不可避免的。如果线程的推进顺序不当,每个线程手握资源的同时又再等待他方占有的资源,从而构成无限期阻塞等待的局面的状态便构成了死锁。

构成死锁的四个必要条件

构成死锁必须要满足下面四个条件

  • 互斥条件:一个资源每次只有一个执行流在使用;
  • 请求与保持:一个线程A因不断请求资源而阻塞,另一个资源紧咬资源不放;
  • 不可剥夺资源:以上所涉及资源一定是不可被剥夺的;
  • 循环等待:若干执行流之间形成了一种头尾相接的循环等待资源的关系。

如何避免死锁

理解构成死锁的因素,尤其是上述的构成死锁的4个必要条件,就可以最大程度的预防和解除死锁。

🗡破坏构成死锁的4个必要条件

  • 减少互斥的情况:减少使用锁也就减少了出现死锁情况。将共享资源备份到线程的私有栈,可将独占性资源改造为线程的栈资源,从而减少了互斥情况,便可不用加锁。
  • 打破占有情况:线程在等待的时候不可占有资源。
  • 打破抢占情况: 资源预先分配策略,线程只有事先申请所有资源才能运行,否则只能等待,这样便不会出现边占有,边抢占的情况。
  • 打破循环条件:资源的申请和加锁必须是有序的,采用有序分配策略,按序申请资源和加锁。

🏦银行家算法是一种分配资源策略,先看清楚资源分配后是否会导致系统锁死。如果会,就不分配,否则就分配。

模拟死锁以及gdb调试

多说无益,我们不妨试一下死锁的情况之一:两个线程按照不同的顺序来申请两个互斥锁。

#include <iostream>
// using namespace std;
#include <pthread.h>
#include <unistd.h>
#include <string>

int a=0;
int b=0;

pthread_mutex_t mtx_a;
pthread_mutex_t mtx_b;

void* thread_run(void* argv)
{
    //获得锁mtx_b
    pthread_mutex_lock(&mtx_b);
    std::cout<<"in child thread , got mutex b ,waiting for mutex a"<<std::endl;
    sleep(5);
    ++b;
    //获得锁mtx_a
    pthread_mutex_lock(&mtx_a);
    b+=a++;
    //释放锁mtx_a
    pthread_mutex_unlock(&mtx_a);
    //释放锁mtx_b
    pthread_mutex_unlock(&mtx_b);
    pthread_exit(NULL);
}

int main()
{
    pthread_t tid;

    pthread_mutex_init(&mtx_a,NULL);
    pthread_mutex_init(&mtx_b,NULL);
    pthread_create(&tid,NULL,thread_run,(void*)"thread1");
    //获得锁mtx_a
    pthread_mutex_lock(&mtx_a);
    std::cout<<"in main thread , got mutex a ,waiting for mutex b"<<std::endl;
    sleep(5);
    ++a;
    //获得锁mtx_b
    pthread_mutex_lock(&mtx_b);
    a+=b++;
    //释放锁mtx_b
    pthread_mutex_unlock(&mtx_b);
    //释放锁mtx_a
    pthread_mutex_unlock(&mtx_a);

    pthread_join(tid,NULL);
    pthread_mutex_destroy(&mtx_a);
    pthread_mutex_destroy(&mtx_b);

    return 0;
}

在上述代码中,主线程试图先占有互斥锁 mtx_a,然后主线程开始处理被该锁保护的全局变量a,但是操作结束后,主线程没有立马释放锁 mtx_a,而是又申请锁 mtx_b,,并在两个互斥锁的保护下操作全局变量a,b,最后才一起释放掉这两个锁。与此同时,子线程则按照相反的顺序来申请互斥锁 mtx_a 和 mtx_b,并在两个锁的保护下操作a和b。

我们用sleep函数来模拟连续两次调用 pthread_mutex_lock 之间的时间差,以确保两个线程先各自占有一把互斥锁(主线程占有 mtx_a,子线程占有 mtx_b),然后等待另一个互斥锁(主线程等待 mtx_a,子线程占有 mtx_b)。

这样两个线程就僵持住,谁也不能继续往下执行,从而形成死锁。如果代码中不加入sleep函数,则主线程可能连续加锁成功,这段代码也许能成功。

在这里插入图片描述

死锁发生,进程便卡死了。

  • pstack 进程id 查看当前进程的运行堆栈

我们正常使用gdb调试时也会发生死锁:

在这里插入图片描述

  1. 先让mymutex处于运行状态,使用 gdb attach 进程id来调试一个正在运行的进程,开始gdb调试

在这里插入图片描述

  1. bt查看主线程堆栈,使用 thread apply all bt 查看所有堆栈

在这里插入图片描述

  1. t 线程编号 (t for thread)跳转至某个线程堆栈,然后可用 bt 查看当前线程的调用栈,f 线程编号 跳转至线程当前运行的位置

在这里插入图片描述

可见两个线程分别等待加锁。

也可以使用 info thread 查看线程栈当前状态

在这里插入图片描述

  1. 通过 thread apply all bt 可以得到互斥锁的地址,然后强转成pthread_mutex_t 用 print 打印

在这里插入图片描述

__owner表示锁的拥有者。

可知子线程(Thread2)等待锁a,其拥有者主线程,主线程(Thread1)等待锁b,其拥有者为子线程。

5. 线程同步

如果说互斥量是用于线程对共享数据进行互斥访问的话,那么条件变量则是用于线程之间同步共享数据的变量。

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

为什么需要同步?

  • 设想有两个线程对一个共享变量分别执行写和读的操作,如果写线程的竞态优先级更高,那么它将一直进入临界区对共享变量进行修改,而读线程竞争优先级较低,便长期得不到锁资源产生饥饿,于是就一直读不到数据。这里就需要同步机制,让读写操作“像拉链一般”协同有序执行。

条件变量提供了一种线程间的通知机制:

  • 线程A需要等某个条件成立才能继续往下执行,现在这个条件不成立,线程A就阻塞等待,而线程B在执行过程中使这个条件成立了,就唤醒线程A继续执行。

在 pthread 库中通过条件变量来使线程阻塞等待某个条件,或者唤醒等待这个条件的线程。条件变量的类型使用 pthread_cond_t 表示。

条件变量初始化与销毁 —— pthread_cond_init ,pthread_cond_init

和互斥量的初始化和销毁类似

int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

int pthread_cond_destroy(pthread_cond_t *cond);
  • 参数

    • restrict cond :需要初始化/销毁的条件变量
    • restrict attr :初始化条件变量的属性,NULL表示默认属性。
  • 返回值:成功返回0,失败返回错误码。

使用宏初始化:pthread_cond_t cond=PTHREAD_COND_INITIALIZER;
相当于用pthread_cond_init函数初始化并且attr参数为NULL。

使用宏初始化的条件变量无需销毁。

pthread_cond_t 销毁一个正在被等待的条件变量将返回EBUSY。

条件变量的等待与唤醒

等待函数

//无条件等待
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
//计时等待
int pthread_cond_timedwait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex,const struct timespec *restrict abstime);
  • 参数
    • restrict cond :再此条件变量下,会提供等待队列,将等待的的线程放入等待队列。
    • restrict mutex : 将等待队列的线程持有的互斥锁释放,保证wait不会产生死锁。
    • restrict abstime :与time()系统调用相同意义的绝对时间形式出现,0表示格林尼治时间1970年1月1日0时0分0秒。

计时等待函数如果在给定时刻前条件没有满足,则返回ETIMEOUT,结束等待。

唤醒函数

//唤醒所有的在cond等待进程
int pthread_cond_broadcast(pthread_cond_t *cond);
//唤醒在cond等待队列中的第一个进程
int pthread_cond_signal(pthread_cond_t *cond);
  • 参数 cond:需唤醒的条件变量,signal将cond的等待队列里的一个线程唤醒,并使其持有互斥锁mutex。可能存在虚假唤醒,将在后文讨论。

  • 返回值

    • 唤醒与等待函数返回值一致。成功返回0,失败返回错误码。

pthread_cond_broadcast 函数以广播的方式唤醒所有等待目标条件的线程,这会带来“惊群效应”,最终只有一个线程能够获得互斥量,其余线程又重新进入阻塞队列,这样容易引起操作系统的管理负担。

何为条件变量

设计 pthread_cond_wait 的初衷

线程为什么要等待?是因为它要访问是共享对象,需与其他线程满足同步+互斥的关系,否则共享对象长时间被一个线程占有会造成其他线程的饥饿问题,有了等待条件让线程等待,才能让多线程有序协同的访问共享资源。

线程如果要等待某个条件发生,他该作何处理?它可以不断地获得互斥锁和释放互斥锁,每次都会检查共享元素,以查找某个值是否满足条件。这样线程一直活跃处于运行态,却只是做一些“询问”工作,而不能产生效能白白浪费cpu的时钟周期。

举个例子,去市场采购时如果货物售罄,我们不会一遍又一遍地询问老板有没有货,而是给老板留下手机号,让他在有货时通知我们就行。

鉴于此,等待某个条件发生时唤醒线程,那在没有收到通知的期间线程就可以去“睡觉”了,cpu也不会因此浪费性能。

pthread_cond_wait的内部操作

线程等待中的条件变量本身作为临界资源是需要被互斥锁保护的,所以首先在我们调用pthread_cond_wait之前,线程需要先申请互斥锁,然后再调用pthread_cond_wait。

  • pthread_cond_wait所做的第一件事就是释放互斥锁。否则如果线程A拿着锁去睡觉,那别的进程就无法拿到互斥锁了,也无法再唤醒A去释放锁,构成死锁问题。这也是为何 pthread_cond_wait 需要我们传入互斥锁mutex。

  • 释放锁之后,线程会进入等待队列,期间不会消耗CPU的周期。

    🚩注意:在不同的书中,“释放锁+把线程放到等待队列”的顺序可能不同,但是没有关系,因为这两个步骤是原子性的,当中无法再穿插别的线程。
    试想一下,如果不是原子性的,当中就可能插入了其他线程操作:

    • 如果线程A先释放互斥锁,此时线程B向共享的队列中添加数据,随后使用 pthread_cond_signal ,线程A后面才被放到等待队列中,那么线程A就错过了signal信号,无法被唤醒,在单生产者单消费者模型中便会卡死。
    • 如果先把线程A放在等待队列,此时线程B调用了pthread_cond_signal,线程A收到signal信号被唤醒,需立即获取互斥锁,两次获取mutex会产生死锁。
  • 当 pthread_cond_signal 或者pthread_cond_broadcast被调用,说明条件满足,等待队列中的线程被唤醒,每个线程都会去竞争锁,竞争到的线程加锁,然后执行后续临界区代码。

条件变量的虚假唤醒(使用while的原因)

在这里插入图片描述

在wait端我们必须把判断布尔条件和wait()放到 while 循环中而不能使用if语句,因为可能会引起虚假唤醒

  • 虚假唤醒

举个🌰,现在有3个线程A,B,C和一个共享队列queue,A线程往queue里存放数据,B,C线程从queue里提取数据。🚩注意:A线程称为生产者,B、C线程称为消费者。

1)B线程从queue里提取了最后一个元素,queue为空。

2)C线程也想从queue提取元素,但为空,条件不符合,于是C线程释放锁并进入阻塞(cond.wait()),调入等待队列。

3)A线程往queue加入了一个元素,并调用cond.notify()唤醒等待队列。

4)处于等待状态的C线程接收到A线程的唤醒信号,便准备解除阻塞状态,执行接下来的任务(获取queue中的元素)。

5) 然而可能出现这样的情况:当C线程准备获得互斥锁锁,去获取queue中的元素时,此时B线程刚好返回也想申请锁再去请求获取队列中的元素,B线程若竞争优先级更高,便获得该互斥锁,检查到queue非空,就获取到了A线程刚刚入队的元素,然后释放锁。

6) 等到C线程获得互斥锁,判断发现queue仍为空,B线程“偷走了”这个元素,所以对于C线程而言,这次唤醒就是虚假的,它需要再次等待queue非空。

  • 使用while的原因

pthread_cond_broadcast会唤醒所有等待此条件的线程,而在多核处理器下,pthread_cond_signal可能会激活多于一个线程(阻塞在条件变量上的线程)。结果就是,当一个线程调用pthread_cond_signal()后,多个调用pthread_cond_wait()或pthread_cond_timedwait()的线程返回。这种效应就称为“虚假唤醒”。

然后各个消费者线程的pthread_cond_wait获取mutex后返回,当然,只可能有一个线程获取到了mutex,而其他的线程没有竞争到锁,需要重新等待,于是只能依赖while,重新回到调用pthread_cond_wait()进入等待队列,等待下次条件成立。

如果使用if判断,那么被虚假唤醒的线程将回不到等待队列而处于就绪态,在下一次竞争锁的时候,这些线程将会与生产者线程竞争锁,那就极有可能造成“queue为空,而仍有线程取数据”的情况发生。即程序没有办法保证signal线程将wait线程唤醒的时机是正确的,所以pthread_cond_wait的返回是可能会失败的(wait返回应该给予阻塞线程互斥锁,但是互斥锁在signal传回期间被别人抢去了),需要多重判断,让被虚假唤醒的线程回到等待队列,等待条件变量成立。

signal唤醒线程的时机

我们没有办法保证调用pthread_cond_signal和解锁(unlock)的原子性。

在这里插入图片描述

这样就给了其他线程可乘之隙!!

我们不妨来讨论一下:

  1. 先unlock,再signal

    如果此时有个运行态的线程在signal和wait之间获取mutex,那么它就跳过了pthread_cond_wait,便会虚假唤醒在等待队列中的线程,无法做到同步。

    场景:

    1. 消费者线程A阻塞在Pop函数,解锁后等待Push操作向队列中添加item。
    2. 生产者线程B调用Push函数,向队列中添加item。在Push函数unlock之后,还未signal之前,线程B的CPU时间片耗尽,发生了上下文切换。
    3. 生产者线程C获取到mutex,调用Push函数,向队列中添加item,解锁并且调用signal函数。
    4. 此时线程A获取到mutex,wait函数返回,处理了刚才Push进来的两个item,之后继续阻塞在条件变量上。
    5. 此时如果线程B得到CPU时间片,那么继续从2处运行,调用signal,唤醒A线程
    6. 线程A被唤醒,但是因为之前的item已经被它Pop出来,所以此时队列仍然为空,所以线程A再次进入阻塞状态,此为虚假唤醒。

虚假唤醒大部分情况下并不影响同步,但是会有些许性能损耗。

  1. 先signal,再unlock

    唤醒后的线程在等待为该互斥锁加锁,一旦锁被释放,wait线程就会立即加锁,而极少发生上述,锁被抢占额度情况。但是如果wait等不到mutex,就会反复等待这个mutex的到来,从而不停在内核态和用户态切换,性能损耗。

    但是在Linux系统的线程中,有两个队列,分别是 cond_wait 队列和 mutex_lock 队列,pthread_cond_signal只是让线程从cond_wait队列移到mutex_lock队列,而不用返回到用户空间,不会有性能的损耗。

    所以在Linux中推荐这种模式。

条件变量的使用规范

等待条件变量的代码:

pthread_mutex_lock(&mutex);
while (条件为假)//反复等待,防止虚假唤醒
	pthread_cond_wait(&cond, &mutex);
修改条件
pthread_mutex_unlock(&mutex);

唤醒等待线程的代码:

pthread_mutex_lock(&mutex);
访问共享内存
期间可能触发条件变量为真
pthread_cond_signal(&cond);//先唤醒
pthread_mutex_unlock(&mutex);

实操

见后续博客生产者消费者模型。


青山不改 绿水长流

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值