『Linux』第九讲:Linux多线程详解(三)_ 线程互斥 | 线程同步

「前言」文章是关于Linux多线程方面的知识,上一篇是 Linux多线程详解(二),今天这篇是 Linux多线程详解(三),内容大致是线程互斥与线程同步,讲解下面开始!

「归属专栏」Linux系统编程

「笔者」枫叶先生(fy)

「座右铭」前行路上修真我

「枫叶先生有点文青病」

「每篇一句」

满堂花醉三千客,

一剑霜寒十四州。

——贯休《献钱尚父》

目录

四、Linux线程互斥

4.1 进程线程间的互斥相关概念

4.2 互斥量mutex

4.3 互斥量接口函数

4.4 互斥量实现原理

五、可重入和线程安全

5.1 概念

5.2 常见的线程不安全的情况

5.3 常见的线程安全的情况

5.4 常见不可重入的情况

5.5 常见可重入的情况

5.6 可重入与线程安全联系

5.7 可重入与线程安全区别

六、死锁

6.1 概念

6.2 死锁四个必要条件

6.3 避免死锁

七、Linux线程同步

7.1 同步概念与竞态条件

7.2 条件变量

7.3 条件变量相关函数


四、Linux线程互斥

4.1 进程线程间的互斥相关概念

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

临界资源&&临界区

如何理解临界资源和临界区??

在前面进程间通信,进程间想要通信,必须得依赖第三方资源,因为进程之间是互相独立的,第三方资源比如是:管道、共享内存等等。进程间通信中的第三方资源就叫做临界资源,访问第三方资源的代码就叫做临界区。第三方资源也叫共享资源

而线程之间想要通信则比较简单,因为线程之间的大部分资源都是共享的。比如,定义一个全局变量 ticket,这个ticket就是一个共享资源,每个线程都可以看得到这份资源。

假设ticket是电影售票系统中的一种电影的票,ticket 一共有1000张,现在有两个线程进行对该电影票进行抢票行为,代码如下:

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

// 票 -- 共享资源
int tickets = 1000;

void* getTicket(void* args)
{
    string username = static_cast<const char*>(args);
    while(1)
    {
        if(tickets > 0)
        {
            cout << username << ": 正在进行抢票 "  << tickets-- << endl;
            sleep(1);//模拟抢票
        }
        else
        {
            break;
        }
    }
}

int main()
{
    pthread_t tid1, tid2;
    pthread_create(&tid1, nullptr, getTicket, (void*)"thread 1");
    pthread_create(&tid2, nullptr, getTicket, (void*)"thread 2");

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

    return 0;
}

编译运行,两个线程都可以进行抢票

在上面的例子中,全局变量 tickets 就是临界资源,每个线程中对临界资源进行访问的代码称为临界区

原子性与互斥

在多线程情况下,如果这多个执行流都自顾自的对临界资源进行操作,那么此时就可能导致数据不一致的问题,会产生线程安全的问题。

比如,多个线程进行抢票,主线程创建5个线程进行抢票,票数为0线程就自动结束了,主线程只需 join 即可

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

// 票 -- 共享资源
int tickets = 1000;

void* getTicket(void* args)
{
    string username = static_cast<const char*>(args);
    while(1)
    {
        if(tickets > 0)
        {
             //模拟抢票花费的时间
            usleep(12345);//微秒
            cout << username << ": 正在进行抢票 "  << tickets-- << endl;
        }
        else
        {
            break;
        }
    }
}

int main()
{
    pthread_t tid1, tid2, tid3, tid4, tid5;
    pthread_create(&tid1, nullptr, getTicket, (void*)"thread 1");
    pthread_create(&tid2, nullptr, getTicket, (void*)"thread 2");
    pthread_create(&tid3, nullptr, getTicket, (void*)"thread 3");
    pthread_create(&tid4, nullptr, getTicket, (void*)"thread 4");
    pthread_create(&tid5, nullptr, getTicket, (void*)"thread 5");

    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);
    pthread_join(tid3, nullptr);
    pthread_join(tid4, nullptr);
    pthread_join(tid5, nullptr);
    return 0;
}

编译运行,多运行几次,我们发现票数居然变成负数了。票数本来就1000张,你还卖出了1001、1002、1003、1004张,这明显不合理,这就是多个线程同时访问一块资源,带来的线程安全的问题

  • 上面的线程就是交叉执行
  • 多个线程交叉执行本质:就是让调度器尽可能的频繁发生线程调度与切换
  • 线程一般发生切换:时间片到了,来了更高优先级的线程,线程等待的时候。
  • 线程是在什么时候检测上面的问题呢?从内核态返回用户态的时候,线程要对调度状态进行检测,如果可以,就直接发生线程切换

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

剩余票数出现负数的原因:

  • 临界区可以被多个线程进行并发(同时)访问,临界资源没有受到保护
  • usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
  • tickets-- 操作不是原子性的

如何对临界区进行保护??

进行互斥,互斥的作用就是,保证在任何时候有且只有一个执行流(线程)进入临界区,对临界资源进行访问

为什么 tickets-- 操作不是原子性的??

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

-- 操作并不是原子操作,而是对应三条汇编指令:

  1. load :将共享变量 ticke t从内存加载到寄存器中
  2. update : 更新寄存器里面的值,执行-1操作
  3. store :将新值,从寄存器写回共享变量 ticket 的内存地址

如果是原子性的,就是要一步完成,没有分出多步。即对一个资源进行的操作,如果只用一条汇编就能完成,就为原子性

。作对应的汇编代码如下:

对应操作如下图,-- 操作分三步

分析为什么票数会变成负数 

假设线程1刚执行完  if(tickets > 0) 的判断,线程1就CPU被切走了,也就是从CPU上剥离下来,切走也要保存该线程的上文数据,因为一个CPU的寄存器只有一套,进行线程切换必须要对该进程的上下文数据进行保存。假设此时票数还剩1张

这时来了一个线程2,线程2执行完  if(tickets > 0)的判断后,也执行完了 -- 操作,即(1)从内存读取 tickets 到CPU的寄存器中,(2)在寄存器中进行逻辑运算,(3)重新把更新后的 tickets 写回内存

这时,CPU又开始执行线程1,重新把线程1的上下文加载到寄存器当中,线程1继续执行原来的代码,即准备执行打印、 -- 操作。线程1此时执行 -- 操作时,(1)从内存读取 tickets 到CPU的寄存器中,此时tickets为0。(2)在寄存器中进行逻辑运算,-1后tickets变成了-1(3)重新把更新后的 tickets 写回内存,此时tickets就变成了负数

这仅仅是其中的一种情况,如果是 -- 操作的三步其中第一步,线程1就被CPU切走了(这种情况模拟不出来,CPU太快了,只能进行口述),假设票数是1000

-- 操作需要三个步骤才能完成,那么就有可能当thread1刚把 tickets 的值读进CPU就被切走了,也就是从CPU上剥离下来,假设此时thread1读取到的值就是1000,而当thread1被切走时,寄存器中的1000叫做thread1的上下文信息,因此需要被保存起来,之后thread1就被挂起了

假设此时thread2被调度了,由于thread1只进行了--操作的第一步,因此thread2此时看到tickets的值还是1000,而系统给thread2的时间片可能较多,导致thread2 一次性执行了500次 -- 才被切走,最终tickets由1000减到了500

此时CPU再把thread1恢复上来,恢复的本质就是继续执行thread1的代码,并且要将thread1曾经的上下文信息恢复出来,此时寄存器当中的值是恢复出来的1000,然后thread1继续执行 --操作的第二步和第三步,最终将999写回内存,这简直极天理难容

此时就可能导致数据不一致的问题,发生了数据安全性的问题,这就是多线程产生线程安全的问题。

因此对一个变量进行--操作并不是原子的,虽然tickets--就是一行代码,但这行代码被编译器编译后本质上是三行汇编,对应 ++操作也不是原子的

如何解决这些问题??互斥量(互斥锁)

4.2 互斥量mutex

  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程独立栈空间内,这种情况的变量归属单个线程
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
  • 多个线程并发的操作共享变量,会带来一些线程安全的问题,比如上面举例的

要解决以上问题,需要做到三点:

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

要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量,也叫互斥锁

4.3 互斥量接口函数

初始化互斥量

互斥量是需要初始化才能使用的,初初始化互斥量有两种方法:静态分配和动态分配

(1)静态分配

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

(2)动态分配

互斥量初始化的函数是 pthread_mutex_init,man 3 pthread_mutex_init 查看:

函数:pthread_mutex_init

头文件: #include <pthread.h>

函数原型:
        int pthread_mutex_init(pthread_mutex_t *restrict mutex,
              const pthread_mutexattr_t *restrict attr);

参数:
    第一个参数mutex:需要初始化的互斥量
    第二个参数attr:初始化互斥量的属性,一般设置为空即可

返回值:
    互斥量初始化成功返回0,失败返回错误码

 销毁互斥量

互斥量使用完了需要进行销毁,互斥量销毁函数是 pthread_mutex_destroy 

函数: pthread_mutex_destroy

头文件:#include <pthread.h>

函数原型:
        int pthread_mutex_destroy(pthread_mutex_t *mutex);

参数:
    mutex:需要销毁的互斥量

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

销毁互斥量需要注意:

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

互斥量加锁

互斥量加锁的函数叫做pthread_mutex_lock,man 3 查看:

函数:pthread_mutex_lock

头文件:#include <pthread.h>

函数原型:
        int pthread_mutex_lock(pthread_mutex_t *mutex);

参数:
    mutex:需要加锁的互斥量

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

 互斥量解锁

互斥量解锁的函数叫做 pthread_mutex_unlock 

函数:pthread_mutex_unlock

头文件:#include <pthread.h>

函数原型:
        int pthread_mutex_unlock(pthread_mutex_t *mutex);

参数:
    mutex:需要解锁的互斥量

返回值:
    互斥量解锁成功返回0,失败返回错误码

注意,使用pthread_mutex_lock,可能会遇到以下情况:

  • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么 pthread_mutex_lock 调用会陷入阻塞(执行流被挂起),等待互斥量解锁

改进上面 4.1 的例子

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

//定义互斥量,全局,每个线程都可以看到
pthread_mutex_t mutex;

// 票 -- 共享资源
int tickets = 1000;

void* getTicket(void* args)
{
    string username = static_cast<const char*>(args);
    while(1)
    {
        pthread_mutex_lock(&mutex);//加锁
        if(tickets > 0)
        {
             //模拟抢票花费的时间
            usleep(12345);//微秒
            cout << username << ": 正在进行抢票 "  << tickets-- << endl;
            pthread_mutex_unlock(&mutex);//解锁
        }
        else{
            pthread_mutex_unlock(&mutex);//解锁
            break;
        }
    }
}

int main()
{
    pthread_mutex_init(&mutex, nullptr);//初始化互斥量
    pthread_t tid1, tid2, tid3, tid4, tid5;
    pthread_create(&tid1, nullptr, getTicket, (void*)"thread 1");
    pthread_create(&tid2, nullptr, getTicket, (void*)"thread 2");
    pthread_create(&tid3, nullptr, getTicket, (void*)"thread 3");
    pthread_create(&tid4, nullptr, getTicket, (void*)"thread 4");
    pthread_create(&tid5, nullptr, getTicket, (void*)"thread 5");

    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);
    pthread_join(tid3, nullptr);
    pthread_join(tid4, nullptr);
    pthread_join(tid5, nullptr);
    pthread_mutex_destroy(&mutex);//使用完了,销毁互斥量
    return 0;
}

临界区:

 编译运行,不会再出现负数

我们从运行的过程来看:

  • 序执行变慢了,应为有了互斥量,加锁到解锁的过程,多个线程是串行的(同一时间只能有一个线程执行)
  • 但是我们也发现,这些票只有一个线程在抢(后面需要用线程同步解决
  • 互斥锁只规定互斥访问,没有规定必须让谁先申请,谁获得锁是多个执行流竞争的结果

抢票后去做一些其他的事:比如生成订单

编译运行,不会出现只有一个线程抢票的情况(后面需要用线程同步解决

4.4 互斥量实现原理

如何看待互斥锁?

  • 全局的变量是临界资源,是需要要被保护的,锁是用来保护临界资源的
  • 锁是一个全局资源,所以锁本身也是一个临界资源,锁需要被保护么?既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?
  • 锁实际上是自己保护自己的,我们只需要保证申请锁的过程是原子的,那么锁就是安全的。
  • pthread_mutex_lock、pthread_mutex_unlock:加锁和解锁的过程其实就是原子的:如果申请成功,就继续向后执行,如果申请暂时没有成功,执行流会阻塞,谁持有锁,谁进入临界区。

在临界区内执行的线程会进行线程切换吗?

  • 临界区内的线程完全可以进行线程切换,但即便该线程被切走,其他线程也无法进入临界区进行资源访问
  • 因为此时该线程是拿着锁被切走的,锁没有被释放也就意味着其他线程无法申请到锁,也就无法进入临界区进行资源访问了。
  • 其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。

互斥锁的原子性如何体现?

对于其他线程而言,有意义的锁的形态只有两种:(1)申请锁前,(2)申请锁后。站在其他线程的角度,看待当前线程持有锁的过程,就是原子的 

加锁、解锁过程如何保证原子性?? (互斥锁的原理)

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

加锁和解锁的伪代码如下:

%al 是CPU内的一个寄存器(不同体系结构叫法不一样) ,lock:加锁(申请)的汇编代码,unlock:解锁的汇编代码,都是伪代码,方便理解,假设mutex的初始值是1

申请锁的过程:

  1. 先把0移动到 %al 寄存器里面,即清0
  2. mutex 变量是我们定义的一个互斥锁
  3. xchgb %al, mutex 就是交换 %al 寄存器和 mutex 中的值,该指令可以完成寄存器和内存单元之间数据的交换,mutex是存在于内存中的(该交换是一条汇编语句完成)
  4. 然后判断寄存器内的内容是否大于0,大于0申请锁成功,此时就可以进入临界区访问对应的临界资源。
  5. 寄存器内的内容是不大于0,申请失败,线程被挂起进行等待,直到锁被释放后再次竞争申请锁

释放锁的过程:

  1. 将内存中的mutex的值移动为1
  2. 然后唤醒被挂起的线程,即在等待mutex的线程

申请锁和释放锁的过程不怕被CPU切走,切走回来时恢复上下文数据即可,所以加锁和解锁操作通常是线程安全的

五、可重入和线程安全

5.1 概念

  • 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
  • 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数

5.2 常见的线程不安全的情况

  • 不保护共享变量的函数
  • 函数状态随着被调用,状态发生变化的函数
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

5.3 常见的线程安全的情况

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

5.4 常见不可重入的情况

  • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
  • 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
  • 可重入函数体内使用了静态的数据结构

5.5 常见可重入的情况

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

5.6 可重入与线程安全联系

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

5.7 可重入与线程安全区别

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

六、死锁

6.1 概念

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

  • 在未来,我们可能会使用多把锁,假设线程A持有自己的锁不释放,而且还有对方的锁,线程B也是如此,线程CDE...,此时就容易造成死锁
  • 单执行流也有可能产生死锁,如果某一执行流连续申请了两次锁(写的代码有问题),那么此时该执行流就会被挂起。因为该执行流第一次申请锁的时候是申请成功的,但第二次申请锁时因为该锁已经被申请过了,于是申请失败导致被挂起直到该锁被释放时才会被唤醒,但是这个锁本来就在自己手上,自己现在处于被挂起的状态根本没有机会释放锁,所以该执行流将永远不会被唤醒,此时该执行流也就处于一种死锁的状态

6.2 死锁四个必要条件

  1. 互斥条件:一个资源每次只能被一个执行流使用
  2. 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
  3. 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
  4. 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

注:同时满足了这四个条件才会产生死锁

6.3 避免死锁

  • 破坏死锁的四个必要条件之中的任何一个条件
  • 加锁顺序一致
  • 避免锁未释放的场景
  • 资源一次性分配

除此之外,还有一些避免死锁的算法,比如死锁检测算法和银行家算法。

七、Linux线程同步

7.1 同步概念与竞态条件

  • 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
  • 竞态条件:因为时序问题,而导致程序异常,称之为竞态条件

同步解释如下: 

  • 如果只是单纯的加锁,是会存在一些问题的,假设某个线程的竞争力特别强,每次都能够申请到锁,但是申请到锁之后什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,会引起饥饿问题。
  • 单纯的加锁是没有错的,它能够保证在同一时间只有一个线程进入临界区,但是它不合理,它没有高效的让每一个线程使用这份临界资源。
  • 现在增加一个规则,当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的最后进行排队。
  • 增加这个规则之后,下一个获取到锁的资源的线程就一定是在资源等待队列首部的线程,这就是线程同步

为了支撑线程同步,需要用到条件变量

7.2 条件变量

条件变量的概念

条件变量是一种同步机制,用于在多个线程之间进行通信。它允许一个线程等待另一个线程满足特定的条件,然后再继续执行。条件变量通常与互斥锁一起使用,以确保线程安全。条件变量提供了一种高效的方式来实现线程之间的同步和通信。

一个例子帮助理解条件变量:

  • 假设有一间房间是面试的地方,里面有一位面试官在面试,公司给参见面试的同学发送了面试通知,然后一大堆的同学到这个面试房间的面前等待。当一名同学面试完成了,面试官准备面试下一位同学的时候,面试官发现门口都站满了等待面试的同学。
  • 面试官不知道轮到哪个了,就随便叫了离他最近的一名同学
  • 假如那个叫进去的同学是来得比较晚的,他的并不是前面来的同学,就因为他离面试官近,就先进去了,这符合规则么?符合,但是这不合理
  • 后面,来个一个管理者,对面试的同学进行管理,管理者直接立起一块牌子:要面试就先要排队,只会从排队的里面按顺序进行面试,不排队不能进行面试
  • 立起来的这个牌子就相当于条件变量,只有符合条件,才允许你进行面试。
  • 面试的同学就相当于一个个的线程,这个面试的房间就相当于一个公共的资源,即临界资源,所有进程都想访问这个资源
  • 线程想访问这个临界资源,线程就必须要满足条件变量,否则线程只能去条件变量下等待

转换成以下可以是:

  1. 条件变量的内部自带 "排队" 的队列
  2. 谁调用条件变量的等待函数,谁就去排队
  3. 当一个线程收到 “进入面试房间的信号”,它就会被从队列的头部拿出,允许它访问使用临界资源 “面试房间”

7.3 条件变量相关函数

初始化条件变量

条件变量跟互斥量一样是需要初始化的,初始化的函数是pthread_cond_init,man 3 pthread_cond_init 查看:

函数:pthread_cond_init

头文件:#include <pthread.h>

函数原型:
         int pthread_cond_init(pthread_cond_t *restrict cond,
              const pthread_condattr_t *restrict attr);

参数:
    第一个参数cond:需要初始化的条件变量
    第二个参数attr:初始化条件变量的属性,一般设置为空即可

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

 调用pthread_cond_init函数初始化条件变量叫做动态分配,除此之外,我们还可以用下面这种方式初始化条件变量,该方式叫做静态分配:

 pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

销毁条件变量

销毁条件变量的函数叫做pthread_cond_destroy

函数:pthread_cond_destroy

头文件:#include <pthread.h>

函数原型:
        int pthread_cond_destroy(pthread_cond_t *cond);

参数:
    cond:需要销毁的条件变量

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

注意:使用 PTHREAD_COND_INITIALIZER初始化的条件变量不需要销毁

等待条件变量满足

等待条件变量满足的函数叫做pthread_cond_wait ,man 3 pthread_cond_wait 查看:

函数:pthread_cond_wait

头文件:#include <pthread.h>

函数原型:
        int pthread_cond_wait(pthread_cond_t *restrict cond,
              pthread_mutex_t *restrict mutex);

参数:
    第一个参数cond:需要等待的条件变量。
    第二个参数mutex:当前线程所处临界区对应的互斥锁

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

唤醒等待

唤醒等待的函数有两个 pthread_cond_signal 和 pthread_cond_broadcast ,man 3 查看:

函数:pthread_cond_broadcast 和 pthread_cond_signal

头文件:#include <pthread.h>

函数原型:
        int pthread_cond_broadcast(pthread_cond_t *cond);
        int pthread_cond_signal(pthread_cond_t *cond);

参数:
    cond:唤醒在cond条件变量下等待的线程

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

区别:

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

使用条件变量的流程:

  1. 当一个线程需要等待某个条件时,它会调用条件变量的等待函数,并释放互斥锁。
  2. 当另一个线程满足条件时,它会发送信号通知等待线程,并重新获取互斥锁。
  3. 等待线程接收到信号后,会重新获取互斥锁并检查条件是否满足,如果满足就继续执行,否则继续等待。

例子,还是上面抢票的例子

主线程创建4个新线程,让主线程控制这4个新线程,这4个新线程创建后都在条件变量下进行等待,直到主线程唤醒一个等待线程,线程才会执行

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

// 票 -- 共享资源
int tickets = 1000;

//定义全局互斥量 -- 每个线程都可以看到
pthread_mutex_t mutex;
//定义全局的条件变量
pthread_cond_t cond;

void* getTicket(void* args)
{
    string username = static_cast<const char*>(args);
    while(1)
    {
        pthread_mutex_lock(&mutex);//加锁
        pthread_cond_wait(&cond, &mutex);//线程阻塞在这里,直到被唤醒
        if(tickets > 0)
        {
            //注意这里没有进行sleep
            cout << username << ": 正在进行抢票 "  << tickets-- << endl;
            pthread_mutex_unlock(&mutex);//解锁
        }
        else{
            pthread_mutex_unlock(&mutex);//解锁
            break;
        }
    }
}

int main()
{
    pthread_mutex_init(&mutex, nullptr);//初始化互斥量
    pthread_cond_init(&cond, nullptr);//初始化条件变量

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

    while(1)
    {
        pthread_cond_signal(&cond);//间隔一秒发送信号,唤醒等待的线程(一个)
        cout << "main thread wakeup one thread..." << endl;
        sleep(1);
    }

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

    pthread_mutex_destroy(&mutex);//使用完了,销毁互斥量
    pthread_cond_destroy(&cond);//销毁条件变量
    return 0;
}

编译运行

观察现象会发现唤醒这四个线程时具有明显的顺序性,根本原因是当这若干个线程启动时默认都会在该条件变量下去等待,而我们每次都唤醒的是在当前条件变量下等待的头部线程,当该线程执行完打印操作后会继续排到等待队列的尾部进行wait,所以我们能够看到一个周转的现象

如果我们想每次唤醒都将在该条件变量下等待的所有线程进行唤醒,可以将代码中的pthread_cond_signal函数改为pthread_cond_broadcast函数

编译运行,每一次唤醒都会唤醒在该条件变量下等待的所有线程,也就是每次都将这个线程唤醒

为什么 pthread_cond_wait 的第二个参数需要传入互斥量??

  • 因为在调用 pthread_cond_wait 函数时,需要先将互斥量上锁(加锁在等待条件变量之前),然后将该互斥量传递给函数作为参数,接着在函数内部会将该互斥量解锁并等待条件变量的信号。
  • 如果不传递互斥量,就无法保证在等待条件变量时对共享资源的访问是互斥的,可能会导致数据竞争等问题,从而可能导致程序错误或死锁。
  • 因此,为了保证线程之间的安全操作,需要在调用 pthread_cond_wait 函数时传递互斥量。

线程互斥与同步完结,下一篇进入生产消费者模型

--------------------- END ----------------------

「 作者 」 枫叶先生
「 更新 」 2023.5.3
「 声明 」 余之才疏学浅,故所撰文疏漏难免,
          或有谬误或不准确之处,敬请读者批评指正。

猜你喜欢

转载自blog.csdn.net/m0_64280701/article/details/130458105