Linux——线程安全

1. Linux线程互斥

1.1 进程线程间的互斥相关背景概念

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

临界资源和临界区

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

2、进程间通信中的第三方资源就叫做临界资源,访问第三方资源的代码就叫做临界区。

3、而多线程的大部分资源都是共享的,线程之间进行通信不需要费那么大的劲去创建第三方资源。

例如,我们只需要在全局区定义一个count变量,让新线程每隔一秒对该变量加一操作,让主线程每隔一秒获取count变量的值进行打印。

cpp 复制代码
#include<cstdio>
#include<pthread.h>
#include<unistd.h>
int count=0;
void* routine(void* arg)
{
    while (1)
    {
        count++;
        sleep(1);
    }
    pthread_exit((void*)0);
}
int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,routine ,nullptr);
    while(1)
    {
        printf("%d\n",count);
        sleep(1);
    }
    pthread_join(tid,nullptr);
    return 0;
}

此时我们相当于实现了主线程和新线程之间的通信,其中全局变量count就叫做临界资源,因为它被多个执行流共享,而主线程中的printf和新线程中count++就叫做临界区,因为这些代码对临界资源进行了访问。

互斥和原子性

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

解决该问题的方案就叫做互斥 ,互斥的作用就是,保证在任何时候有且只有一个执行流进入临界区对临界资源进行访问。

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

例如,下面我们模拟实现一个抢票系统,我们将记录票的剩余张数的变量定义为全局变量,主线程创建四个新线程,让这四个新线程进行抢票,当票被抢完后这四个线程自动退出。

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

int tickets = 1000;
void *TicketGrabbing(void *arg)
{
    const char *name = (char *)arg;
    while (1)
    {
        if (tickets > 0)
        {
            usleep(10000);
            printf("[%s] get a ticket, left: %d\n", name, --tickets);
        }
        else
        {
            break;
        }
    }
    printf("%s quit!\n", name);
    pthread_exit((void *)0);
}
int main()
{
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, NULL, TicketGrabbing, (void *)"thread 1");
    pthread_create(&t2, NULL, TicketGrabbing, (void *)"thread 2");
    pthread_create(&t3, NULL, TicketGrabbing, (void *)"thread 3");
    pthread_create(&t4, NULL, TicketGrabbing, (void *)"thread 4");

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

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

但是这里我们发现一个奇怪的现象,ticket被减到负数了,这并不符合常理,那具体是什么原因呢?

1、if语句判断条件为真以后,代码可以并发地切换到其他线程。

2、usleep用于模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。

3、--ticket操作本身就不是一个原子操作,会导致数据不一致问题。

为什么--ticket不是原子操作?

我们对一个变量进行--,我们实际需要进行以下三个步骤:

load:将共享变量tickets从内存加载到寄存器中。

update:更新寄存器里面的值,执行-1操作。

store:将新值从寄存器写回共享变量tickets的内存地址。

--操作对应的汇编代码如下:

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

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

此时系统再把thread1恢复上来,恢复的本质就是继续执行thread1的代码,并且要将thread1曾经的硬件上下文信息恢复出来,此时寄存器当中的值是恢复出来的1000,然后thread1继续执行--操作的第二步和第三步,最终将999写回内存。

在上述过程中,thread1抢了1张票,thread2抢了100张票,而此时剩余的票数却是999,也就相当于多出了100张票。

所以我们说ticket--这个操作不是原子的,它看起来就一行代码,但是经过编译它其实是3句指令,在调用指令过程中,线程可能被切换,--操作就可能处于完成与未完成的中间态,因此就无法保证原子性。

这里我们来总结一下,实际上为什么会出现上面所说的问题呢?

首先的多线程并发,这个好理解,我们创建了4个线程,就有了并发的条件;还有一点在于我们对线程的切换,那大家思考两个问题:

1、什么时候切换一个线程?

时间片耗尽、阻塞IO、调用sleep等系统调用。

2、什么时候选择新的线程呢?

内核态返回用户态的时候,进行检查。

1.2 互斥量mutex

大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况变量归属单个线程,其他线程无法获得这种变量。

但有时候,很多变量都需要在线程间共享,这样的变量成为共享变量,可以通过数据的共享,完成线程之间的交互。

多个线程并发的操作共享变量,就会带来一些问题。

要解决上述抢票系统的问题,需要做到三点:

1、代码必须有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。

2、如果多个线程同时要求执行临界区的代码,并且此时临界区没有线程在执行,那么只能允许一个线程进入该临界区。

3、如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

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

1.3 互斥量的接口

1.3.1 初始化互斥量

初始化互斥量的函数叫做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函数初始化互斥量叫做动态分配,除此之外,我们还可以用下面这种方式初始化互斥量,该方式叫做静态分配:

cpp 复制代码
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

1.3.2 销毁互斥量

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

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

参数说明:

mutex:需要销毁的互斥量。

返回值说明:

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

销毁互斥量需要注意:

1、使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁。

2、不要销毁一个已经加锁的互斥量。

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

1.3.3 互斥量加锁

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

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

参数说明:

mutex:需要加锁的互斥量。

返回值说明:

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

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

1、互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。

2、发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

1.3.4 互斥量解锁

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

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

参数说明:

mutex:需要解锁的互斥量。

返回值说明:

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

下面通过一段demo来运用一下上面的接口:

例如,我们在上述的抢票系统中引入互斥量,每一个线程要进入临界区之前都必须先申请锁,只有申请到锁的线程才可以进入临界区对临界资源进行访问,并且当线程出临界区的时候需要释放锁,这样才能让其余要进入临界区的线程继续竞争锁。

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

int tickets = 1000;
pthread_mutex_t mutex;
void* Buyticket(void *arg)
{
    const char *name = (char *)arg;
    while (1)
    {
        pthread_mutex_lock(&mutex);
        if (tickets > 0)
        {
            usleep(100);
            printf("[%s] get a ticket, left: %d\n", name, --tickets);
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
    printf("%s quit!\n", name);
    pthread_exit((void *)0);
}
int main()
{
    pthread_mutex_init(&mutex,nullptr);
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, nullptr, Buyticket, (void *)"thread 1");
    pthread_create(&t2, nullptr, Buyticket, (void *)"thread 2");
    pthread_create(&t3, nullptr, Buyticket, (void *)"thread 3");
    pthread_create(&t4, nullptr, Buyticket, (void *)"thread 4");

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);
    pthread_mutex_destroy(&mutex);
    return 0;
}

注意:

1、在大部分情况下,加锁本身都是有损于性能的事 ,它让多执行流由并行执行变为了串行执行,这几乎是不可避免的。

2、我们应该在合适的位置进行加锁和解锁,这样能尽可能减少加锁带来的性能开销成本。

3、进行临界资源的保护,是所有执行流都应该遵守的标准,这时程序员在编码时需要注意的。

1.4 互斥量实现原理探究

加锁后的原子性体现在哪里?

引入互斥量后,当一个线程申请到锁进入临界区时,在其他线程看来该线程只有两种状态,要么没有申请锁,要么锁已经释放了,因为只有这两种状态对其他线程才是有意义的。

例如,图中线程1进入临界区后,在线程2、3、4看来,线程1要么没有申请锁,要么线程1已经将锁释放了,因为只有这两种状态对线程2、3、4才是有意义的,当线程2、3、4检测到其他状态时也就被阻塞了。

此时对于线程2、3、4而言,它们就认为线程1的整个操作过程是原子的。

临界区内的线程可能进行线程切换吗?

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

其他想进入该临界区进行资源访问的线程,必须等该线程执行完临界区的代码并释放锁之后,才能申请锁,申请到锁之后才能进入临界区。

锁是否需要被保护?

1、我们说被多个执行流共享的资源叫做临界资源,访问临界资源的代码叫做临界区。所有的线程在进入临界区之前都必须竞争式的申请锁,因此锁也是被多个执行流共享的资源,也就是说锁本身就是临界资源。

2、既然锁是临界资源,那么锁就必须被保护起来,但锁本身就是用来保护临界资源的,那锁又由谁来保护的呢?

3、锁实际上是自己保护自己的,我们只需要保证申请锁的过程是原子的,那么锁就是安全的。

如何保证申请锁的过程是原子的?

1、上面我们已经说明了--操作不是原子操作,可能会导致数据不一致问题。

2、为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用就是****把寄存器和内存单元的数据相交换。

3、由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。

操作系统的工作原理

1、操作系统一旦启动成功后就是一个死循环。

2、时钟是计算机中的一个硬件,时钟每隔一段时间会向操作系统发起一个时钟中断,操作系统就会根据时钟中断去执行中断向量表。

3、中断向量表本质上就是一个函数表,比如刷磁盘的函数、检测网卡的函数以及刷新数据的函数等等。

4、计算机不断向操作系统发起时钟中断,操作系统就根据时钟中断,不断地去执行对应的代码。

5、CPU有多个,但总线只有一套。CPU和内存都是计算机中的硬件,这两个硬件之间要进行数据交互一定是用线连接起来的,其中我们把CPU和内存连接的线叫做系统总线,把内存和外设连接起来的线叫做IO总线。

6、系统总线只有一套,有的时候CPU访问内存是想从内存中读取指令,有的时候是想从内存读取数据,所以总线是被不同的操作种类共享的。计算机是通过总线周期来区分此时总线当中传输的是哪种资源的。

下面我们来看看lock和unlock的伪代码:

我们可以认为mutex的初始值为1,al是计算机中的一个寄存器,当线程申请锁时,需要执行以下步骤:

1、先将al寄存器中的值清0。该动作可以被多个线程同时执行,因为每个线程都有自己的一组寄存器(上下文信息),执行该动作本质上是将自己的al寄存器清0

2、然后交换al寄存器和mutex中的值。xchgb是体系结构提供的交换指令,该指令可以完成寄存器和内存单元之间数据的交换,本质上这里的"1"只有一份,谁交换成功了,谁就持有锁,交换语句是一条汇编语句,具有原子性,这就说明申请锁的过程是原子性的,哪怕中途这个线程被切换了也不影响,因为这个线程已经把"锁"保存在自己的上下文中带走了。

3、最后判断al寄存器中的值是否大于0。若大于0则申请锁成功,此时就可以进入临界区访问对应的临界资源;否则申请锁失败需要被挂起等待,直到锁被释放后再次竞争申请锁。

当其他线程再来申请的时候,本质上是0换0,这就可以保证申请锁的过程是原子性的,只有当前一个持有锁的线程将锁释放后,其他线程才可以继续申请。

当线程释放锁时,需要执行以下步骤:

1、将内存中的mutex置回1。 使得下一个申请锁的线程在执行交换指令后能够得到1,形象地说就是"将锁的钥匙放回去"。

2、**唤醒等待mutex的线程。**唤醒这些因为申请锁失败而被挂起的线程,让它们继续竞争申请锁。

几点说明:

1、在申请锁时本质上就是哪一个线程先执行了交换指令, 那么该线程就申请锁成功,因为此时该线程的al寄存器中的值就是1了。而交换指令就只是一条汇编指令,一个线程要么执行了交换指令,要么没有执行交换指令,所以申请锁的过程是原子的

2、在线程释放锁时没有将当前线程al寄存器中的值清0,这不会造成影响,因为每次线程在申请锁时都会先将自己al寄存器中的值清0,再执行交换指令。

3、CPU内的寄存器不是被所有的线程共享的,每个线程都有自己的一组寄存器,但内存中的数据是各个线程共享的。申请锁实际就是,把内存中的mutex通过交换指令,原子性地交换到自己的al寄存器中,谁交换成功,锁就是谁的。

2. 可重入VS线程安全

2.1 概念

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

一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则是不可重入函数。
注意: 线程安全讨论的是线程执行代码时是否安全,重入讨论的是函数被重入进入。

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

4. Linux线程同步

4.1 同步概念与竞态条件

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

2、竞态条件: 因为时序问题,而导致程序异常,我们称之为竞态条件。

概念理解:

1、首先需要明确的是,单纯的加锁是会存在某些问题的,如果个别线程的竞争力特别强,每次都能够申请到锁,但申请到锁之后什么也不做,所以在我们看来这个线程就一直在申请锁和释放锁,这就可能导致其他线程长时间竞争不到锁,引起饥饿问题。

2、单纯的加锁是没有错的,它能够保证在同一时间只有一个线程进入临界区,但它没有高效地让每一个线程使用这份临界资源。

3、现在我们增加一个规则,当一个线程释放锁后,这个线程不能立马再次申请锁,该线程必须排到这个锁的资源等待队列的末尾。

4、总结来说,锁是解决对错的问题(线程安全),而同步机制的引入是为了解决效率问题。

4.2 条件变量

概念:条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量是用来描述某种资源是否就绪的一种数据化描述。

条件变量主要包括两个动作:

1、一个线程等待条件变量的条件成立而被挂起。

2、另一个线程使条件成立后唤醒等待的线程。

条件变量通常需要配合互斥锁一起使用。

4.3 条件变量函数

4.3.1 初始化条件变量

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

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

参数说明:

1、cond:需要初始化的条件变量。

2、attr:初始化条件变量的属性,一般设置为NULL(nullptr)即可。

返回值说明:

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

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

cpp 复制代码
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

这里与前面的互斥锁相关接口很相似。

4.3.2 销毁条件变量

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

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

参数说明:

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

返回值说明:

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

销毁条件变量需要注意:

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

4.3.3 等待条件变量满足

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

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

参数说明:

1、cond:需要等待的条件变量。

2、mutex:当前线程所处临界区对应的互斥锁。

返回值说明:

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

4.3.4 唤醒等待

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

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

区别:

1、pthread_cond_signal函数用于唤醒等待队列中首个线程。

2、pthread_cond_broadcast函数用于唤醒等待队列中的全部线程

参数说明:

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

返回值说明:

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

和上面一样,下面通过一段demo来运用一下上面的接口;

下面我们用主线程创建三个新线程,让主线程控制这三个新线程活动。这三个新线程创建后都在条件变量下进行等待,直到主线程检测到键盘有输入时才唤醒一个等待线程,如此进行下去。

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 << "运行" << std::endl;
    while (1)
    {
        pthread_cond_wait(&cond, &mutex); // 阻塞在这里,等待被唤醒
        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 (1)
    {
        getchar();
        pthread_cond_signal(&cond);
    }
    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond);
    return 0;
}

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

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

4.4 生产者消费者模型

4.4.1 概念

生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。

生产者和消费者彼此之间不直接通讯,而通过这个容器来通讯,所以生产者生产完数据之后不用等待消费者处理,直接将生产的数据放到这个容器当中,消费者也不用找生产者要数据,而是直接从这个容器里取数据,这个容器就相当于一个缓冲区,平衡了生产者和消费者的处理能力,这个容器实际上就是用来给生产者和消费者解耦的。

4.4.2 特点

1、三种关系: 生产者和生产者(互斥关系)、消费者和消费者(互斥关系)、生产者和消费者(互斥关系、同步关系)。

2、两种角色: 生产者和消费者。(通常由进程或线程承担)

3、一个交易场所: 通常指的是内存中的一段缓冲区。(可以自己通过某种方式组织起来)

生产者和生产者、消费者和消费者、生产者和消费者,它们之间为什么会存在互斥关系?

介于生产者和消费者之间的容器可能会被多个执行流同时访问,因此我们需要将该临界资源用互斥锁保护起来。

其中,所有的生产者和消费者都会竞争式的申请锁,因此生产者和生产者、消费者和消费者、生产者和消费者之间都存在互斥关系。

生产者和消费者之间为什么会存在同步关系?

如果让生产者一直生产,那么当生产者生产的数据将容器塞满后,生产者再生产数据就会生产失败。
反之,让消费者一直消费,那么当容器当中的数据被消费完后,消费者再进行消费就会消费失败。

虽然这样不会造成任何数据不一致的问题,但是这样会引起另一方的饥饿问题,是非常低效的。我们应该让生产者和消费者访问该容器时具有一定的顺序性,比如让生产者先生产,然后再让消费者进行消费。

注意: 互斥关系保证的是数据的正确性,而同步关系是为了让多线程之间协同起来。

4.4.3 优点

1、实现了生产者和消费者之间的解耦。

2、支持忙闲不均。

3、提高了效率。

上面提到的三点,前两点相信大家可以理解,但是第三点会有些让人疑惑。

生产和消费的过程是加锁的呀,那么它们是串行的呀,为啥效率还能高呢?这未免有些矛盾了。

这里大家就要理解生产者消费者模型的本质了,实际上对于生产者来说,有两个流程,获取任务和生产任务,而获取任务可以从另一个模块来获取;对于消费者来说,也有两个流程,消费任务和处理任务,将任务从"交易场所"拿出来只占一小部分时间,大部分时间要用来对拿到的任务进行处理,所以我们说生产者消费者模型效率比较高是因为这里是"效率高"不是体现在出入交易场所,而是体现在生产者获取任务和消费者处理任务是并发的。

4.5 基于阻塞队列实现的生产者消费者模型

4.5.1 什么是阻塞队列

1、在多线程编程中阻塞队列(Blocking Queue)是⼀种常用于实现生产者和消费者模型的数据结构。

2、其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素**;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出**(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)。

4.5.2 基于阻塞队列的生产消费模型代码实现

BlockQueue.hpp

cpp 复制代码
// 阻塞队列
#pragma once
#include <iostream>
#include <string>
#include <queue>
#include <pthread.h>
const int defaultcap = 5;
template <typename T>
class BlockQueue
{
private:
    bool IsFull()
    {
        return _q.size() >= _cap;
    }
    bool Isempty()
    {
        return _q.empty();
    }

public:
    BlockQueue(int cap = defaultcap)
        : _cap(cap), _csleep_num(0), _psleep_num(0)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_full_cond, nullptr);
        pthread_cond_init(&_empty_cond, nullptr);
    }
    void Equeue(const T& in)
    {
        //生产者调用
        pthread_mutex_lock(&_mutex);
        while(IsFull())
        {
            _psleep_num++;
            pthread_cond_wait(&_full_cond,&_mutex);
            _psleep_num--;
        }
        _q.push(in);

        if(_csleep_num>0)
        {
            pthread_cond_signal(&_empty_cond);
            std::cout<<"唤醒消费者"<<std::endl;
        }
        pthread_mutex_unlock(&_mutex);
    }
    T Pop()
    {
        //消费者调用
        pthread_mutex_lock(&_mutex);
        while(Isempty())
        {
            _csleep_num++;
            pthread_cond_wait(&_empty_cond,&_mutex);
            _csleep_num--;
        }
        T data=_q.front();
        _q.pop();
        if(_psleep_num>0)
        {
            pthread_cond_signal(&_full_cond);
            std::cout<<"唤醒生产者"<<std::endl;
        }
        pthread_mutex_unlock(&_mutex);
        return data;
    }
    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_full_cond);
        pthread_cond_destroy(&_empty_cond);
    }

private:
    std::queue<T> _q; // 临界资源
    int _cap;         // 容量大小

    pthread_mutex_t _mutex;
    pthread_cond_t _full_cond;
    pthread_cond_t _empty_cond;

    int _csleep_num; // 消费者休眠个数
    int _psleep_num; // 生产者休眠个数
};

1、将BlockingQueue当中存储的数据模板化,方便以后需要时进行复用。

2、这里设置BlockingQueue存储数据的上限为5,当阻塞队列中存储了五组数据时生产者就不能进行生产了,此时生产者就应该被阻塞。

3、阻塞队列是会被生产者和消费者同时访问的临界资源,因此我们需要用一把互斥锁将其保护起来。

4、生产者线程要向阻塞队列当中Push数据,前提是阻塞队列里面有空间,若阻塞队列已经满了,那么此时该生产者线程就需要进行等待,直到阻塞队列中有空间时再将其唤醒。

5、消费者线程要从阻塞队列当中Pop数据,前提是阻塞队列里面有数据,若阻塞队列为空,那么此时该消费者线程就需要进行等待,直到阻塞队列中有新的数据时再将其唤醒。

6、因此在这里我们需要用到两个条件变量,一个条件变量用来描述队列为空,另一个条件变量用来描述队列已满。当阻塞队列满了的时候,要进行生产的生产者线程就应该在full条件变量下进行等待;当阻塞队列为空的时候,要进行消费的消费者线程就应该在empty条件变量下进行等待。

7、不论是生产者线程还是消费者线程,它们都是先申请到锁进入临界区后再判断是否满足生产或消费条件的,pthread_cond_wait调用成功,挂起当前线程之前,为了避免死锁,要先自动释放锁;而当它们被唤醒时,还需要重新申请锁,唤醒的过程就是在临界区进行的,如果重新申请失败了,那就会在锁上阻塞等待。

8、当生产者生产完一个数据后,意味着阻塞队列当中至少有一个数据,而此时可能有消费者线程正在empty条件变量下进行等待,因此当生产者生产完数据后需要唤醒在empty条件变量下等待的消费者线程。

9、同样的,当消费者消费完一个数据后,意味着阻塞队列当中至少有一个空间,而此时可能有生产者线程正在full条件变量下进行等待,因此当消费者消费完数据后需要唤醒在full条件变量下等待的生产者线程。

一个细节:

判断是否满足生产消费条件时不能用if,而应该用while:

1、pthread_cond_wait函数是让当前执行流进行等待的函数,是函数就意味着有可能调用失败,调用失败后该执行流就会继续往后执行。

2、其次,在多消费者的情况下,当生产者生产了一个数据后如果使用pthread_cond_broadcast函数唤醒消费者,就会一次性唤醒多个消费者,但待消费的数据只有一个,此时其他消费者就被伪唤醒了。

3、为了避免出现上述情况,我们就要让线程被唤醒后再次进行判断,确认是否真的满足生产消费条件,因此这里必须要用while进行判断。

main.cc

cpp 复制代码
#include "BlockQueue.hpp"
#include "Task.hpp"
#include <iostream>
#include <pthread.h>
#include <unistd.h>
void *consumer(void *arg)
{
    BlockQueue<task_t> *bq = static_cast<BlockQueue<task_t> *>(arg);
    while (1)
    {
        // 1.消费任务
        task_t t = bq->Pop();
        // 2. 处理任务 -- 处理任务的时候,这个任务,已经被拿到线程的上下文中了,不属于队列了
        t();
    }
}
void *productor(void *arg)
{
    BlockQueue<task_t> *bq = static_cast<BlockQueue<task_t> *>(arg);
    while (1)
    {
        sleep(1);
        // 1.获得任务
        std::cout << "生产了一个任务: " << std::endl;

        // 2. 生产任务
        bq->Equeue(Download);
    }
}
int main()
{
    BlockQueue<task_t> *bq = new BlockQueue<task_t>();

    // 构建生产和消费者
    pthread_t c[2], p[3];

    pthread_create(c, nullptr, consumer, bq);
    pthread_create(c+1, nullptr, consumer, bq);
    pthread_create(p, nullptr, productor, bq);
    pthread_create(p+1, nullptr, productor, bq);
    pthread_create(p+2, nullptr, productor, bq);

    pthread_join(c[0], nullptr);
    pthread_join(c[1], nullptr);
    pthread_join(p[0], nullptr);
    pthread_join(p[1], nullptr);
    pthread_join(p[2], nullptr);

    return 0;
}

Task.hpp

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


// 我们定义了一个任务类型,返回值void,参数为空
using task_t = std::function<void()>;

void Download()
{
    std::cout << "我是一个下载任务..." << std::endl;
}

1、阻塞队列要让生产者线程向队列中Push数据,让消费者线程从队列中Pop数据,因此这个阻塞队列必须要让这两个线程同时看到,所以我们在创建生产者线程和消费者线程时,需要将该阻塞队列作为线程执行例程的参数进行传入。

2、这里我专门写了Task.hpp,我们可以在这个文件中写具体任务,然后再将任务传给生产者。

4.6 为什么pthread_cond_wait需要互斥量

  1. 这里我们首先要知道,条件等待是线程同步的一种手段,如果只有一个线程,那么条件不满足的话这个线程会一直等待,所以必须需要另一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。

  2. 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据。

  3. 当线程进入临界区的时候,需要先加锁,在上面的阻塞队列的模型中,生产者和消费者要想进入阻塞队列都必须先申请到锁才可以进行数据的相关操作,所以举个例子,比如一个生产者线程进入了阻塞队列,结果发现阻塞队列已经满了,这个时候这个生产者线程就必须在对应的条件变量下进行等待,在这里我们一定要清楚一个点,此时这个生产者线程是持有锁的,并且生产者和消费者用的是同一把锁,如果它带着锁去阻塞等待(挂起),那么这就意味着这个锁永远都不会被释放了,因为想要满足条件,其他线程必须进入阻塞队列去进行某些操作,但是想要进入阻塞队列(临界资源)就必须申请到那唯一的一把锁。

  4. 所以我们在调用pthread_cond_wait函数的时候,必须将互斥锁传进来,这时在调用函数的时候,会将锁自动释放,这样被挂起的线程就可以"放心"地休眠了,不会影响其他线程申请锁。

  5. 当等待的线程被唤醒时,该线程会接着执行临界区内的代码,此时便要求该线程必须立马获得对应的互斥锁,因此当某一个线程被唤醒时,实际会自动获得对应的互斥锁

总结

1. 线程等待和被唤醒都是在临界区中进行的,pthread_cond_wait函数调用,挂起当前线程之前,就要先将其持有的锁进行释放。

2. 当线程被唤醒时,要从pthread_cond_wait函数成功返回,需要当前线程,重新申请锁。

3. 如果等待的线程被唤醒后,重新申请锁失败了,那么该线程就会在锁上继续阻塞等待,直到它重新持有锁。

相关推荐
飞雁科技2 小时前
CRM客户管理系统定制开发:如何精准满足企业需求并提升效率?
大数据·运维·人工智能·devops·驻场开发
wanhengidc2 小时前
云手机畅玩 梦幻西游
运维·服务器·arm开发·智能手机·自动化
wdfk_prog2 小时前
[Linux]学习笔记系列 -- [kernel][time]timer
linux·笔记·学习
fy zs2 小时前
linux下动静态库
linux
佐杰2 小时前
Jenkins安装部署
运维·servlet·jenkins
熊猫_豆豆2 小时前
回调函数的作用与举例(Python版)
服务器·python·编程语法
VincentHe3 小时前
当 ServerCat 遇上 Shell 环境变量:一次服务器监控性能优化记录与探索
服务器·shell·监控
深耕AI3 小时前
如何在云服务器上找回并配置宝塔面板:完整指南
运维·服务器
zly35003 小时前
360极速浏览器 安装猫抓插件的方法
运维