多线程并发编程核心:互斥与同步的深度解析及生产者消费者模型两种实现

目录

前言

一、互斥

1.概念铺垫

什么是互斥?

为什么要有互斥?

如何实现互斥?

2.互斥锁

接口介绍

原理探究

相关细节

二、同步

1.概念铺垫

为什么需要同步?

什么是同步?

2.条件变量

条件变量概念

条件变量的接口介绍

使用示例

3.POSIX信号量

为什么需要信号量

信号量概念

信号量接口介绍

三、生产消费者模型

1.什么是生产消费者模型

2.分析总结生产消费者模型特性

为什么要有生产者消费者模型

四、代码实现生产者消费者模型

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

基于循环队列的生产者消费者模型

总结



前言

在日常编程中,"并发" 早已不是陌生的词汇,多线程 / 多进程无疑都是提升程序效率的核心手段。但只要涉及 "多线程共享资源",就绕不开两个经典问题:如何保证数据不被多线程同时篡改(互斥)?如何让线程间有序协作(同步)?

本文不从 "为什么需要互斥同步" 出发,先拆解互斥锁、信号量等核心工具的底层逻辑,再一步步实现一个可运行的生产消费者模型(以 C++ 为例),并剖析其中逻辑。无论你是刚接触多线程的新手,还是想巩固并发基础的开发者,你都能从这篇文章中找到实现思路。


一、互斥

1.概念铺垫

首先补充一些在多线程编程中常用到新名词:

临界资源:在多线程执行流中被保护起来的公共资源叫做临界资源。

临界区:访问临界资源的代码叫临界区。

原子性:不会被打断的操作,该操作只有两态------完成状态,未完成状态。

什么是互斥?

互斥的概念:在多线程执行流中,为保护临界资源,同一时刻只能有一个执行流进入临界区访问临界资源。

为什么要有互斥?

我们做个小实验:模拟一个抢票小程序,创建几个线程对一个全局变量count不断进行 -- 操作,我们约定每个线程只有在 count > 0时才能进行 -- 操作,每次操作完打印count的值。

cpp 复制代码
#include <iostream>
#include <vector>
#include <pthread.h>
#include <unistd.h>
int count = 1000;

void *routine(void *args)
{
    char name[32];
    pthread_getname_np(pthread_self(), name, sizeof(name));
    while (true)
    {
        if (count > 0)
        {
            printf("%s 对count减1,count:%d\n", name, count);
            count--;
        }
        else
            break;
    }
    return nullptr;
}

int main()
{
    std::vector<pthread_t> threads;

    for (int i = 1; i <= 5; ++i)
    {
        pthread_t id;
        char name[32];
        snprintf(name, sizeof(name), "thread->%d", i);
        pthread_create(&id, nullptr, routine, nullptr);
        pthread_setname_np(id, name);
        threads.push_back(id);
    }

    sleep(5);
    std::cout << "此时count:" << count << std::endl;

    for (auto &x : threads)
    {
        int ret = pthread_join(x, nullptr);
        if (!ret)
            std::cout << "join sucess" << std::endl;
    }
    
    return 0;
}

我们会发现不管执行几次,最后打印的count值总是为负数。

可是设置的只有在"count >0"才会--,为什么最后count的值还是会 < 0呢?

我们知道上述代码在编译时会先编译成汇编指令,实际上高级语言的一行代码在翻译时都会翻译出多条汇编指令。比如count -- 的操作,这个操作通常不是原子的,它分为三步:读取ticket的值、将值减1、写回新值。而操作系统分配给每个线程的时间片是固定的,那么有没有可能当CPU执行到if 判断与count -- 之间就被切走呢,当再切换回来时count的值可能就不是之前判断的> 0了,但是依旧会执行 -- 操作。

你可能会说哪有这么幸运每次都时间片到,实际上述代码中线程在执行到 if 之后99.99%会切走的,因为我们在 -- 之前执行了打印操作,cout输出会产生I/O阻塞进而导致线程切换 ,当操作系统检测到线程陷入阻塞后可能会**将当前线程挂起,切换到其他线程,**当硬件准备就绪后再将阻塞的线程唤醒继续执行 -- 。这也是为什么每次执行都打印 -4 的原因------5个线程,当count 为1 时每个线程都会进入执行 -- ,之后不满足再退出。

总结原因:count -- 不是原子的;②IO操作会导致线程阻塞进而被切换,当再次执行时ticket的值已然与上次判断结果不同。

归根结底就是线程间访问资源时没有互斥访问。

如果上述代码是个抢票程序的核心内容,count的值代表的是种特别资源,比如飞机票或者电影票,而各个线程代表着用户购票,执行结束后后却多卖出去4张,这岂不是造成许多麻烦?这就是为什么要存在互斥的原因之一。

如何实现互斥?

首先互斥应该实现的效果:

①当一个线程的代码进入临界区执行后,不允许其他线程进入该临界区;

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

------图片来自网络

为了实现上述上述效果,我们需要一把"锁",当线程访问临界区前需要申请锁并加锁,才能访问临界区,访问完再释放锁。

Linux上提供有这把锁,它叫做互斥量也叫互斥锁。

2.互斥锁

互斥锁,以及下面介绍的条件变量函数接口都在POSIX线程库,即pthread.h头文件中。

接口介绍

使用互斥锁需要先定义一个互斥锁对象,之后互斥操作都是该对象与相关函数一起实现。

定义互斥锁对象:pthread_t XXX;

互斥锁初始化

互斥锁的初始化分为全局初始化与局部初始化两种方式,全局互斥锁 初始化后无需手动销毁 ,局部互斥锁需要手动销毁。

全局初始化示例:

全局互斥锁对象可以用PTHREAD_MUTEX_INITIALIZER宏初始化。

cpp 复制代码
#include<pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

局部互斥锁的初始化需要用到pthread_mutex_init函数:

int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t * attr);

形参一是互斥锁对象的地址;

形参二可以设置相关属性,一般用默认的即可,即传入nullptr;

返回值:成功返回0,失败返回错误码。

局部初始化示例:

cpp 复制代码
#include<pthread.h>
pthread_mutex_t lock;
int ret_pmi = pthread_mutex_init(&lock,nullptr);

互斥锁销毁

全局互斥锁无需手动销毁,在程序结束后会自动回收相关资源,而局部互斥锁需要用到pthread_mutex_destory函数:

int pthread_mutex_destroy(pthread_mutex_t *mutex);

形参为欲销毁互斥锁的地址;

返回值:成功返回0,失败返回错误码。

销毁互斥锁示例:

cpp 复制代码
#include<pthread.h>
pthread_mutex_t lock;

. . . . . . .

int ret_pmd = pthread_destory(&lock);

加锁与解锁

互斥锁诞生目的就是保护临界资源------访问临界区前需要申请锁并加锁,才能访问临界区,访问完后再释放锁供其他线程使用。申请锁与加锁使用的同一个函数pthread_mutex_lock,而解锁使用pthread_mutex_unlock函数。

加锁:int pthread_mutex_lock(pthread_mutex_t *mutex);

解锁:int pthread_mutex_unlock(pthread_mutex_t *mutex);

函数作用:若锁未被使用,则申请锁成功并返回0;**若锁正在被其他线程使用,或者没竞争过其他线程,则在加锁函数处陷入阻塞,**并被链入阻塞队列,当使用的锁被解锁后系统会从互斥锁的等待队列中唤醒一个(或多个)正在等待的线程。

形参为互斥锁的地址;

返回值:成功返回0,失败返回错误码。

加锁解锁使用示例:

cpp 复制代码
#include<pthread.h>
pthread_mutex_t lock;

......
//访问临界资源,加锁
pthread_mutex_lock(&lock);
......
//访问完毕,解锁
pthread_mutex_unlock(&lock);

原理探究

在上文我们分析过 tickets 会减到负数的原因有二:①对临界资源的操作不是原子的;②线程切换导致对临界资源判断失真。 而互斥锁的原理正是从这两点出发,在解决上述两点问题后实现多线程对临界资源的串行访问。

原理探讨:pthread_mutex_lock 函数在被翻译成汇编语言后,其中会有swap或者exchange语句/指令,亦或者类似指令,它们只提供一个功能------交换,以及附带的一条指令天然具有的原子性 它们的工作原理可以这么理解:互斥锁是一个位于寄存器 中的具有全局唯一性 的资源变量,当线程申请互斥锁时,会通过swap或者exchange将有效的互斥锁变量与线程task_struct中的空值交换,交换之后紧接着就会进行判断------互斥锁变量是否有效?有效则继续执行;无效则陷入阻塞。**这样只有得到有效互斥锁变量的线程才会继续往下执行,而其他交换后检测到是空值得线程都将会陷入阻塞。**即使中途被操作系统切换剥离出CPU,具有唯一性的互斥锁也因为属于该线程的运行上下文而被保存在该线程 task_struct 中的,换句话说即使拥有锁的线程阻塞了,其他未拥有锁的线程也进不去临界区。

综上总结互斥锁的原理:①申请锁的过程是原子性的,即swap或者exchange一条指令天然具有的原子性 + 具有全局唯一性的互斥锁解决了"对临界资源的操作不是原子的"问题;②只有带锁的线程才能访问临界区资源,这也就避免了线程切换后其他线程修改临界资源导致判断失真的问题。

相关细节

互斥锁本身就是一种公共资源

互斥锁的存在是为了保护公共资源,为了实现这个目的互斥锁得让其他线程能"看到"自己,也就是说互斥锁本身就是一种公共资源。 互斥锁通过加/解锁过程的原子性保障了自己的安全,之后再借助pthread_mutex_lock函数与pthread_mutex_unlock函数实现得到锁资源的线程,得到锁的线程在执行的过程中不会受到其他线程打扰从而体现出的原子性,实现对临界资源的保护。

二、同步

1.概念铺垫

为什么需要同步?

不知道读者是否注意到上述的抢票代码有个奇怪的现象:不管执行多少次,在一个时间段内总是同一个线程抢到票?

我们试着分析其原因:首先本质是几个线程通过互斥锁互斥式的进行抢票,竞争到互斥锁的线程可以访问临界区修改票数,而没竞争到锁的则阻塞等待,当锁被释放后pthread_lock函数会试着唤醒一个线程,使其获得锁并继续执行。问题就出在这,其他线程想要获得锁得首先从阻塞状态恢复再竞争锁,那么刚刚释放锁得线程会干什么呢,是的在代码中刚释放锁的线程转头又去申请锁去了,这样以来其他还需要唤醒的线程自然竞争不过它,所以锁总是被一个线程占有。

既然原因是需要唤醒的线程竞争不过刚释放锁的线程,那么我们约定释放锁后的线程需要休眠一段时间,如果上述分析正确,那么问题或许会得到解决。

cpp 复制代码
void *routine(void *args)
{
    char name[32];
    pthread_getname_np(pthread_self(), name, sizeof(name));
    while (true)
    {
        pthread_mutex_lock(&mutex);
        if (count > 0)
        {
            std::cout << name << "对count减1,count:" << count << std::endl;
            count--;
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
        usleep(1000);
    }
    return nullptr;
}

实际执行过程也如我们所推测的那样:

但实际上这样的解决办法是不严谨的,因为我们只是通过让释放锁的线程休眠一段时间来增加其他线程得到锁的机会,本质上依旧没有解决每个线程都能均等机会地获得锁的问题。

为了达到尽可能的公平,我们需要使用同步机制从根本上来协调线程的执行顺序。

什么是同步?

同步的概念

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

在多线程(或多进程)环境中,**通过协调多个执行流对共享资源的访问,以确保数据一致性和程序正确性,**共享资源不限于内存变量数据,还包括文件、网络连接、硬件设备等。

当多个执行流并发访问共享资源时,如果没有适当的同步,可能会发生数据竞争,导致程序缺失公平性,并导致行为不确定、数据损坏或崩溃。

我们常使用条件变量 或者信号量来实现线程同步。

2.条件变量

条件变量概念

条件变量是线程同步的一种机制,通常与互斥锁搭配一起使用。它让线程在某个条件不满足时进入阻塞等待状态,直到另一个线程改变了条件并通知等待的线程。

简单来说就是只有当某个线程释放了一个条件变量,使用条件变量的线程才能继续执行,否则就陷入阻塞等待条件变量。

条件变量的接口介绍

条件变量的接口介绍:

定义条件变量

类似互促锁,可以直接通过pthread_cond_t XXX 定义一个条件变量。

初始化条件变量

int pthread_cond_init (pthread_cond_t * restrictcond , constpthread_condattr_t * restrictattr);

类似互促锁,局部条件变量使用pthread_cond_init()初始化,形参一是条件变量对象的地址;**参数二使用默认设置即可,即传入nullptr即可,**返回值,成功返回0,失败返回错误码。

全局条件变量用PTHREAD_COND_INITIALIZER初始化,并且无需销毁。

销毁条件变量

int pthread_cond_destroy(pthread_cond_t * cond);

使用pthread_cond_destory函数销毁回收条件变量,形参一是条件变量对象的地址;返回值,成功返回0,失败返回错误码。

条件变量的使用:等待与唤醒

等待:

int pthread_cond_wait (pthread_cond_t * restrictcond , pthread_mutex_t * restrictmutex);

唤醒:

唤醒一个线程:int pthread_cond_signal(pthread_cond_t*cond);

全部唤醒:int pthread_cond_broadcast(pthread_cond_t * cond);

等待->消耗条件变量:使用pthread_cond_wait()函数在等待时,如果条件变量不满足,该函数会释放互斥锁并陷入阻塞队列,直到被唤醒函数的唤醒,被唤醒后将会重新获取锁。

**唤醒->生产条件变量:**使用唤醒函数后,将会产生一个条件变量并唤醒在阻塞队列中的线程。

**细节注意:条件变量的相关函数调用都是是原子的,**在执行过程中不会被其他线程打断,也不会看到中间状态。

使用示例

将上述抢票代码改成条件变量+互斥锁

代码解释:

①主线程每次放一张票,5个新线程模拟抢票;

②开始没票,所有新线程都会阻塞在条件变量cond_empty处;

③主线程放票后,将所有阻塞的线程全部唤醒;

④如果新线程们还未抢到票,那么主线程将会阻塞在条件变量cond_full处;

⑤当新线程消费完票后,将阻塞的主线程唤醒。

cpp 复制代码
#include <iostream>
#include <vector>
#include <pthread.h>
#include <unistd.h>
int count = 0;
int total = 100;
bool done = false;

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond_empty = PTHREAD_COND_INITIALIZER;
pthread_cond_t cond_full = PTHREAD_COND_INITIALIZER;

void *routine(void *args)
{
    char name[32];
    pthread_getname_np(pthread_self(), name, sizeof(name));
    int ticket = 0;
    while (true)
    {
        pthread_mutex_lock(&mutex);
        while (count <= 0 && !done)
        {
            pthread_cond_wait(&cond_empty, &mutex);
        }

        if (done)
        {
            pthread_mutex_unlock(&mutex);
            break;
        }

        std::cout << name << "对count减1,count:" << count << std::endl;
        count--;
        ticket++;

        pthread_cond_signal(&cond_full);
        pthread_mutex_unlock(&mutex);
    }
    return (void *)ticket;
}

int main()
{
    std::vector<pthread_t> threads;

    for (int i = 1; i <= 5; ++i)
    {
        pthread_t id;
        char name[32];
        snprintf(name, sizeof(name), "thread->%d", i);
        pthread_create(&id, nullptr, routine, nullptr);
        pthread_setname_np(id, name);
        threads.push_back(id);
    }


    while (total > 0)
    {
        pthread_mutex_lock(&mutex);
        if (count <= 0)
        {
            count++;
            total--;
            pthread_cond_broadcast(&cond_empty);
        }
        else
        {
            pthread_cond_wait(&cond_full, &mutex);
        }
        pthread_mutex_unlock(&mutex);
    }

    done = true;
    pthread_cond_broadcast(&cond_empty);

    for (auto &x : threads)
    {
        void *tickets = nullptr;
        int ret = pthread_join(x, &tickets);
        if (!ret)
            std::cout << "join sucess,tickets:" << (long long)tickets << std::endl;
    }

    return 0;
}

执行结果:

为什么每个线程最后抢到的票数不一致?根本原因:线程调度不确定性。被唤醒的线程不会立即执行 ,而是进入就绪队列 ,操作系统调度器决定哪个线程先获得CPU,获得CPU的线程还需竞争锁才能进入临界区。

条件变量保证的是同步正确性 ,而不是分配公平性。所有线程都有机会,但不保证机会均等。

如果想保证绝对的公平性可以考虑使用队列等待:强制满足当其他所有线程都获得一张票后,才能有线程获得第二张票,依次进行下去。

3.POSIX信号量

POSIX信号量和SystemV信号量作用相同,都是用于同步操作,但POSIX可以用于线程间同步,且操作是原子的,信号量本身保证线程安全。

为什么需要信号量

上面我们说到信号量的引入是为了线程同步,可是同步不是已经有了条件变量吗,为什么还要引入信号量?

在实际线程间同步时,对于资源的使用会有两种情况:

一种是如上述的,将 count 变量整体当作一个共享资源来使用,生产与消费资源都要互斥的访问,常通过条件变量 + 互斥锁的方式将资源整体使用;

另一种是将共享资源分块使用,什么意思呢,还是以抢票为例子,如果不再是从count处拿票,而是从vector<int>tickets[5]中抢票,那么该数组中的每一个成员都可以看作是一个独立的共享资源块,那么此时就需要信号量来协同线程间的同步,以做到将资源分块使用。

信号量概念

信号量本质上是一个计数器,它记录了当前可用的资源数量,并通过两个原子操作(P和V,也称为wait和signal)来管理资源的分配与释放。

P操作 :尝试获取一个资源 ,信号量值减1。如果信号量值大于0,则进程继续;如果为0,则进程阻塞,直到信号量值大于0后被唤醒。

V操作释放一个资源,信号量值加1。如果有进程正在等待该资源,则唤醒其中一个。

信号量有两种类型:

二进制信号量:信号量的值只有0和1,相当于互斥锁。

计数信号量:信号量的值可以是任意非负整数,用于表示多个资源的可用数量。

信号量接口介绍

注意:信号量相关的接口都在semaphore.h头文件中。

创建信号量

可以直接通过sem_t XXX定义一个信号量对象。

初始化信号量

参数一创建的信号量对象的地址;

参数二为0表示线程间共享,非零表示进程间共享;

参数三信号量代表公共资源的数量。

返回值,成功返回0,失败返回 -1并设置errno。

销毁信号量

参数为创建信号量对象的地址。

返回值,成功返回0,失败返回 -1并设置errno。

P操作:等待信号量

作用是等待/获取信号量,会将信号量的值减1;

参数为创建信号量对象的地址。

返回值,成功返回0,失败返回 -1并设置errno。

V操作:发布信号量

作用是发布/增加信号量,将信号量值加1。

参数为创建信号量对象的地址。

返回值,成功返回0,失败返回 -1并设置errno。

进一步理解信号量

上面我们说过信号量是资源的计数器,它代表着可用资源的数量。实际上信号量还有另一个隐藏的功能,就是在线程访问临界资源之前,就可以通过信号量接口以原子性方式获知临界资源的状态,如是资源否存在、就绪等,提前做出判断决定是否访问临界区。

三、生产消费者模型

上面我们知道了同步与互斥的概念,并了解了相关接口的用法,可实际编程中应该怎么使用它们呢?我们以生产者消费者模型为例子,介绍同步与互斥在实际编程中的作用,以及什么时候用条件变量,什么时候用信号量。

1.什么是生产消费者模型

有关教材上的描述:"生产者和消费者彼此之间不直接通讯,而是通过一种容器来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给容器,消费者不找生产者要数据,而是直接从容器里取,容器就相当于一个缓冲区,平衡了生产者和消费者的处理能力。"

什么意思呢?假设按任务类型分类我们有两种类型的进程或者线程 ,我们叫它们A类和B类,它们共享一个固定大小的缓冲区A类 进程或线程产生数据 放入缓冲区,B类 进程或线程从缓冲区中取出数据 进行计算,那么这里就是一个生产者和消费者的模式,A类就相当于生产者,B类相当于消费者。

为帮助读者理解,我们举个例子介绍生产者消费者模型,比如超市购物:在生活中工厂生产的商品与消费者购买商品之间是没有直接联系的,工厂生产的商品都是交给超市,而消费者购物时也是直接去超市消费而不是直接去工厂,其中工厂就是生产者,咱们大家就是消费者,而超市就是那个缓冲区。**为什么要这样设计呢?**如果人们消费时直接找工厂,第一工厂要现场生产商品消费者需要等待,时间成本增加;第二单个或一些消费者需要的商品数量较少,如果每来一个消费者工厂才生产一点,那么工厂的生产成本会大幅上升。而超市的作用就是充当两者间矛盾的缓冲区,超市可以一次性向工厂提出大量需求,让工厂只用关注生产不再关心一些其他问题,提高效率降低生产成本,而消费者来了超市就可以消费,节省了消费者的时间成本。

一句话总结:生产者消费者模式通过一个容器来解决生产者和消费者的强耦合问题。

2.分析总结生产消费者模型特性

在生产者消费者模型中一共有三种角色:生产者、消费者、缓冲区;

结合上述同步与互斥的概念,它们之间的关系是?

①生产者之间是竞争的互斥的关系。这很好理解,日常生活中各类工厂也是竞争互斥的,毕竟超市展架就这么大,放了这家工厂的产品另一家的就没地方放了。

**②消费者之间是竞争互斥的关系。**这也很好理解,货架上就一根火腿肠,两个消费者都想买,他们之间就是竞争互斥的关系。

**③生产者与消费者之间是同步的关系。**只有工厂先生产了商品,消费者才能消费,所以他们是同步的关系。

为帮助快速理解,可以采用"321原则"牢记生产者消费者模型特性:3种生产关系,2种任务对象,1种容器缓冲区。

为什么要有生产者消费者模型

上面我们以工厂- 超市 - 顾客的例子,帮助大家理解了什么是生产者消费者模型,并分析了如果没有超市,顾客直接找工厂可能会出现的情况:如果人们消费时直接找工厂,第一工厂要现场生产商品消费者需要等待,时间成本增加;第二单个或一些消费者需要的商品数量较少,如果每来一个消费者工厂才生产一点,那么工厂的生产成本会大幅上升。

所以生产者消费者模型出现的最根本原因就是解决生产者和消费者的强耦合问题。

总结生产者消费者模型的优点或者说好处:

①将生产过程与消费过程解耦;

**②支持忙闲不均。**因为有缓冲区的存在,生产者和消费者就可以以各自不同的速度运行,缓冲区作为中间层,平衡了两者的速度差异,从而解决了忙闲不均的问题。

**③提高效率。**生产者与消费者可以在同时运行互不干扰,提高了整体的运行效率。

上述分析了生产消费者模型的特性以及优点,下面将通过代码实现生产消费者模型以体现同步与互斥的用途与用法。

四、代码实现生产者消费者模型

生产者彼此之间与消费者彼此之间的互斥我们可以用互斥锁解决,可是生产者与消费者的同步问题,我们应该用条件变量解决还是信号量呢?这取决于怎么使用资源,比如看电影,如果是vip影厅那么只能有一个人进去观看,电影院这个资源被视作整体使用 ;如果是普通放映厅,那么其中每个坐位就是一个资源,电影院将被分块使用。

结合条件变量与信号量的概念,整体使用的资源使用条件变量较为合适,因为:资源是整体性的, 同一时间只能被一个线程使用,即资源是互斥的。**被分块使用的资源采用信号量描述更为为合适,因为:资源可以被划分成多个相同单元,**同一时间可以被多个线程使用,但每个线程占用一个单元。

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

在多线程编程中阻塞队列是一种常用于实现生产者和消费者模型的数据结构。其特点是:

①当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;

②当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出;

③将整个阻塞队列视为一个整体,队列本身是具有容量上限的。

既然我们将整个阻塞队列视为一个整体,那么资源就是整体使用的,我们采用条件变量作为同步工具。一开始队列为空,生产者先运行产生数据此时消费者线程应该阻塞在条件变量处,当有数据后再向消费者线程pthread_cond_signal发送信息唤醒消费者;同理若队列为满时,消费者线程应该阻塞在条件变量处,当消费者消费数据,队列中留下有空位后再唤醒阻塞的生产者。综上,条件变量我们应定义两个:一个用于生产者生产数据后通知消费者,另一个用于消费者消费数据后通知生产者。

由于阻塞队列整体视为一个资源,所以任何线程都应该互斥的访问阻塞队列,因此需要一个互斥锁。

将上述逻辑转换成下面的实现代码,若看了注释还是有疑惑的读者欢迎在评论区中提出你的疑惑,笔者看到后会第一时间回答。

cpp 复制代码
#ifndef __BLOCKQUEUE__
#define __BLOCKQUEUE__
#include <queue>
#include <pthread.h>

// 容器默认容量
const int defaultCap = 5;

namespace karsen_BlockQueue
{
    template <class T>
    class BlockQueue
    {
    private:
        //这两个函数用于判断一开始生产者与消费者谁先运行
        bool IsEmpty() const
        {
            return _que.empty();
        }

        bool IsFull() const
        {
            return _que.size() == _cap;
        }

        // 禁用拷贝构造和赋值
        BlockQueue(const BlockQueue &) = delete;
        BlockQueue &operator=(const BlockQueue &) = delete;

    public:
        BlockQueue(int cap = defaultCap)
            : _cap(cap),
              _productor_wait_nums(0),
              _consumer_wait_nums(0),
              _done(false)
        {
            pthread_mutex_init(&_mutex, nullptr);
            pthread_cond_init(&_cond_productor, nullptr);
            pthread_cond_init(&_cond_consumer, nullptr);
        }

        void Enqueue(T &data)
        {
            // IsFull中会访问临界资源queue的属性_que.size(),所以要提前加锁
            pthread_mutex_lock(&_mutex);

            // 防止伪唤醒:当阻塞的线程被唤醒后应该再检查一遍条件是否满足,防止伪唤醒
            while (IsFull() && !_done)
            {
                _productor_wait_nums++;
                pthread_cond_wait(&_cond_productor, &_mutex);
                _productor_wait_nums--;
            }

            if (!_done)
            {
                _que.push(data);
                std::cout << "生产者生产了一个任务...... " << std::endl;
                // 如果阻塞队列中有消费者,唤醒一个
                if (_consumer_wait_nums > 0)
                    pthread_cond_signal(&_cond_consumer);
            }
            pthread_mutex_unlock(&_mutex);
        }

        void Dequeue(T *out)
        {
            pthread_mutex_lock(&_mutex);

            // 防止伪唤醒
            while (IsEmpty() && !_done)
            {
                _consumer_wait_nums++;
                pthread_cond_wait(&_cond_consumer, &_mutex);
                _consumer_wait_nums--;
            }

            // 如果停止且队列为空,返回
            if (_done && Empty())
            {
                pthread_mutex_unlock(&_mutex);
                return;
            }

            *out = _que.front();
            _que.pop();
            std::court << "消费者拿走了一个任务" << std::endl;

            // 如果阻塞队列中有生产者,唤醒一个
            if (_productor_wait_nums > 0)
                pthread_cond_signal(&_cond_productor);

            pthread_mutex_unlock(&_mutex);
        }

        // 停止生产,消费完剩余数据后关闭
        bool Stop()
        {
            pthread_mutex_lock(&_mutex);
            _done = true;
            pthread_cond_broadcast(&_cond_consumer);
            pthread_cond_broadcast(&_cond_productor);
            pthread_mutex_unlock(&_mutex);
            return true;
        }

        //判断停止运行
        bool IsStop() const
        {
            return _done;
        }

        // 获取容量
        int Capacity() const
        {
            return _cap;
        }

        // 判断是否还有任务
        bool Empty() const
        {
            return IsEmpty();
        }

        ~BlockQueue()
        {
            pthread_mutex_destroy(&_mutex);
            pthread_cond_destroy(&_cond_productor);
            pthread_cond_destroy(&_cond_consumer);
        }

    private:
        std::queue<T> _que;
        int _cap;
        bool _done;

        pthread_mutex_t _mutex;
        pthread_cond_t _cond_productor;
        pthread_cond_t _cond_consumer;

        int _productor_wait_nums;
        int _consumer_wait_nums;
    };
}

#endif

测试代码参考:

cpp 复制代码
#include <iostream>
#include "BlockQueue.hpp"
#include <unistd.h>
#include <functional>
#include <vector>
using namespace karsen_BlockQueue;

using task_t = std::function<void()>;
using Routine = void *(*)(void *);

BlockQueue<task_t> bq; // 定义阻塞队列

void task_Up()
{
    std::cout << "这是一个上传任务" << std::endl;
    sleep(2);
}

void task_Downlode()
{
    std::cout << "这是一个下载任务" << std::endl;
    sleep(2);
}

//生产者线程将要执行的函数
void *productorRoutine(void *args)
{
    char *name = static_cast<char *>(args);
    task_t up = task_Up;
    task_t dow = task_Downlode;
    for (int i = 0; i < 3; ++i)
    {
        bq.Enqueue(up);
        bq.Enqueue(dow);
        sleep(1);
    }
    std::cout << name << " 线程生产了6个任务" << std::endl;
    delete name;
    return nullptr;
}

//消费者线程将要执行的函数
void *consumerRoutine(void *args)
{
    char *name = static_cast<char *>(args);
    int cnt = 0;
    while (true)
    {
        //这里让消费者将队列中的任务执行完再break
        if (bq.Empty() && bq.IsStop())
        {
            break;
        }

        task_t task;
        bq.Dequeue(&task);
        task();
        sleep(1);
        cnt++;
    }
    std::cout << name << "执行了" << cnt << "个任务" << std::endl;
    delete name;
    return nullptr;
}

//创建线程函数
void CreateThreads(std::vector<pthread_t> &threads_productor, Routine routine)
{
    for (int i = 1; i < 3; ++i)
    {
        pthread_t id;
        char *name = new char[32];
        snprintf(name, 32, "thread->%d", i);
        pthread_create(&id, nullptr, routine, name);
        threads_productor.push_back(id);
    }
}

//回收线程函数
void JoinThreads(std::vector<pthread_t> &threads_productor)
{
    for (auto &id : threads_productor)
    {
        int n = pthread_join(id, nullptr);
        if (n == 0)
            std::cout << "join success" << std::endl;
    }
}

int main()
{
    //创建两类线程:一种负责生产,另一种负责消费
    std::vector<pthread_t> threads_productor;
    std::vector<pthread_t> threads_consumer;

    //定义函数指针变量,分别是两类线程分别将要执行的函数
    Routine product = productorRoutine;
    Routine consume = consumerRoutine;

    //创建线程,并传入函数
    CreateThreads(threads_productor, product);
    CreateThreads(threads_consumer, consume);

    //回收线程
    JoinThreads(threads_productor);

    // 生产线程停止后,将队列的停止标志位置为true
    bq.Stop();
    std::cout << "生产者全部退出......" << std::endl;
    JoinThreads(threads_consumer);

    return 0;
}

基于循环队列的生产者消费者模型

与阻塞队列相比,循环队列最大的不同就是资源分块使用,循环队列同一时间可以被多个线程使用,其中每个线程占用一个子资源块。 这意味着**生产者与消费者可以同时运行:**由于最小的互斥单位变成一个个的子资源块,所以只要生产者与消费者不同时访问一个子块就能同时运行。

让我们仔细分析循环队列中生产者与消费者同时运行的情况:

①当循环队列为空时,应该生产者先运行;

②当循环队列为满时,应该消费者先运行;

③生产者生产的数据不能生产超过循环队列分块数量,具体为生产者不能把消费者超出一个圈以上;

④消费者不能运行到生产者前面,否则该子块还没有资源。

为什么会总结出以上几点?最根本的原因就是避免生产者与消费者访问同一个子块资源的情况。

一句话总结上述:当环形队列不为空 || 满时,生产者与消费者可以同时运行,但线程彼此之间互斥访问一个子资源块;当循环队列为空 || 满时,生产者与消费者需要同步与互斥。

因此,我们需要两个互斥锁,一个用于生产者之间的互斥,另一个用于消费者之间的互斥;同时我们还需要两个个信号量来同步操作,一个用于标识队列中资源的数目用于消费者,另一个用于标识队列中空位的数目,用于生产者。

值得注意的是,循环队列我们常采用vecotr 模拟,原因是循环队列中生产者访问和消费者会同时访问呢队列,如果用普通的queue则无法实现,至于用vector 原因时vector 支持[ ]随机访问,能直接通过索引定位元素,且时间复杂度为 O (1)十分合适,只需注意索引不要超过 vector 或者锁循环队列的的容量即可。

将上述逻辑转换成下面的实现代码:

cpp 复制代码
#ifndef __RingQueue__
#define __RingQueue__
#include <vector>
#include <atomic>
#include <pthread.h>
#include <semaphore.h>

// 容器默认容量
const int defaultCap = 5;
namespace karsen_RingQueue
{
    template <class T>
    class RingQueue
    {
    public:
        RingQueue(int cap = defaultCap)
            : _cap(cap > 0 ? cap : defaultCap),
              _step_consumer(0),
              _step_productor(0),
              _rque(cap),
              _done(false)
        {
            pthread_mutex_init(&_mutex_consumer, nullptr);
            pthread_mutex_init(&_mutex_productor, nullptr);
            sem_init(&_sem_consumer, 0, 0);
            sem_init(&_sem_productor, 0, _cap);
        }

        bool Push(const T &data)
        {
            // 先查看是否有空位资源
            sem_wait(&_sem_productor);
            // 生产者之间的互斥
            pthread_mutex_lock(&_mutex_productor);

            // 入数据前检查终止标志位
            if (!_done)
            {
                _rque[_step_productor++] = data;
                // 防止越界
                _step_productor %= _cap;

                // 生产了一个资源,为消费者信号量加一
                pthread_mutex_unlock(&_mutex_productor);
                sem_post(&_sem_consumer);

                return true;
            }
            else
            {
                return false;
                pthread_mutex_unlock(&_mutex_productor);
            }
        }

        bool Pop(T *out)
        {
            if (_step_consumer == _step_productor && _done)
            {
                return false;
            }

            // 先查看队列中是否有数据资源
            sem_wait(&_sem_consumer);
            // 消费者之间的互斥
            pthread_mutex_lock(&_mutex_consumer);

            *out = _rque[_step_consumer++];
            // 防止越界
            _step_consumer %= _cap;

            // 消耗了一个资源多出一个空位,为生产者信号量加一

            pthread_mutex_unlock(&_mutex_consumer);
            sem_post(&_sem_productor);

            return true;
        }

        void Stop()
        {
            // 原子性操作有保证
            _done = true;
        }

        ~RingQueue()
        {
            pthread_mutex_destroy(&_mutex_productor);
            pthread_mutex_destroy(&_mutex_consumer);
            sem_destroy(&_sem_productor);
            sem_destroy(&_sem_consumer);
        }

    private:
        // 禁用拷贝构造和赋值
        RingQueue(const RingQueue &) = delete;
        RingQueue &operator=(const RingQueue &) = delete;

    private:
        std::vector<T> _rque;
        int _cap;

        pthread_mutex_t _mutex_productor;
        pthread_mutex_t _mutex_consumer;

        sem_t _sem_productor;
        sem_t _sem_consumer;

        int _step_productor;
        int _step_consumer;

        // C++ std::atomic 原子类型,操作均为原子性的
        std::atomic<bool> _done;
    };
}

#endif

测试代码参考:

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

using namespace karsen_RingQueue;

using task_t = std::function<void()>;
using Routine = void *(*)(void *);
RingQueue<task_t> rq;

void task_Up()
{
    std::cout << "这是一个上传任务" << std::endl;
    sleep(2);
}

void task_Downlode()
{
    std::cout << "这是一个下载任务" << std::endl;
    sleep(2);
}

void *productorRoutine(void *args)
{
    char *name = static_cast<char *>(args);
    task_t up = task_Up;
    task_t dow = task_Downlode;
    for (int i = 0; i < 3; ++i)
    {
        rq.Push(up);
        rq.Push(dow);
        sleep(1);
    }
    std::cout << name << " 线程生产了6个任务" << std::endl;
    delete name;
    return nullptr;
}

void *consumerRoutine(void *args)
{
    char *name = static_cast<char *>(args);
    int cnt = 0;
    while (true)
    {
        task_t task;
        bool ret = rq.Pop(&task);
        if (ret == false)
            break;
        task();
        sleep(1);
        cnt++;
    }
    std::cout << name << "执行了" << cnt << "个任务" << std::endl;
    delete name;
    return nullptr;
}

void CreateThreads(std::vector<pthread_t> &threads_productor, Routine routine)
{
    for (int i = 1; i < 3; ++i)
    {
        pthread_t id;
        char *name = new char[32];
        snprintf(name, 32, "thread->%d", i);
        pthread_create(&id, nullptr, routine, name);
        threads_productor.push_back(id);
    }
}

void JoinThreads(std::vector<pthread_t> &threads_productor)
{
    for (auto &id : threads_productor)
    {
        int n = pthread_join(id, nullptr);
        if (n == 0)
            std::cout << "join success" << std::endl;
    }
}
int main()
{
    std::vector<pthread_t> threads_productor;
    std::vector<pthread_t> threads_consumer;

    Routine product = productorRoutine;
    Routine consume = consumerRoutine;

    CreateThreads(threads_productor, product);
    CreateThreads(threads_consumer, consume);

    JoinThreads(threads_productor);
    // 生产线程停止后,发布stop命令
    rq.Stop();
    std::cout << "生产者全部退出......" << std::endl;
    JoinThreads(threads_consumer);

    return 0;
}

总结

本文介绍了多线程编程中的互斥与同步机制。互斥通过互斥锁实现,保护临界资源避免竞争条件;同步通过条件变量和信号量实现,协调线程执行顺序。

文章详细讲解了互斥锁、条件变量和POSIX信号量的接口用法,并基于生产者消费者模型,分别使用阻塞队列(条件变量+互斥锁)和循环队列(信号量+双互斥锁)实现了两种同步方案。阻塞队列将资源整体使用,循环队列将资源分块使用,体现了不同场景下同步工具的选择策略,为解决多线程并发问题提供了实用方案。

本系列一路总结到此也即将接近尾声,从第一次接触Linux时在黑框框命令行敲上"ls",到本文的互斥同步生产消费者模型详细剖析,一共经过了19篇博客总结,本文是第20篇。

笔者水平有限,若文中有缺失错误之处,还万望读者指出。

读完点赞,手留余香~

相关推荐
ulias2122 小时前
多态理论与实践
java·开发语言·前端·c++·算法
飞Link2 小时前
【MySQL】Linux(CentOS7)下安装MySQL8教程
linux·数据库·mysql
m0_726965982 小时前
RAG源代码笔记JAVA-高级RAG
笔记·ai·agent·rag
llilian_162 小时前
时间基准的行业赋能者——北斗卫星授时同步统一设备应用解析 时统 授时同步设备
服务器·网络·单片机
随祥2 小时前
网络开源工具
linux
码农胖虎-java3 小时前
技术深析:Delayed ACK与Nagle算法的“相爱相杀”
运维·服务器·网络
漂视数字孪生世界3 小时前
项目案例|某水轮机数字孪生平台
运维·信息可视化·自动化·数字孪生·三维可视化
复业思维202401083 小时前
Altium Designer (24.2.2)中更改库以及保持器件参数不变
笔记·学习·硬件工程
巧克力味的桃子3 小时前
进制转换3 学习笔记
笔记·学习