目录
2.6.2pthread_cond_wait调用时的三种特殊情况
引言
在一个进程内部的多个线程中,因为所有的线程共享进程的地址空间,进程额资源大部分都会被线程共享! 那如果多个线程同时访问这部分共享资源呢?那就会出现数据不一致、结果不符合预期的问题。这类问题就是线程安全问题 ,也是并发编程中需要解决的核心问题之一。
一个典型的现象就是,多个现象向显示器打印,会出现打印信息错乱的问题。
那出现这些问题应该怎么解决呢?
解决这些问题就离不开今天所要讲的线程的同步与互斥!
一、线程互斥
1.1进程线程互斥相关概念
共享资源:被多个执行流(进程 / 线程)共同拥有、可以被它们访问和使用的资源。
临界资源:多线程执行流被保护的共享的资源就叫做临界资源
临界区:每个线程内部,访问临界资源的代码,就叫做临界区
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
1.2互斥锁mutex
下面这个售票系统的代码就存在并发问题:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
void *route(void *arg)
{
char *id = (char*)arg;
while ( 1 ) {
if ( ticket > 0 ) {
usleep(1000);//模拟具体抢票画的时间
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
} else {
break;
}
}
}
int main( void )
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void*)"thread 1");
pthread_create(&t2, NULL, route, (void*)"thread 2");
pthread_create(&t3, NULL, route, (void*)"thread 3");
pthread_create(&t4, NULL, route, (void*)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
}
编译运行后会发现明明只有 100 张票,最后却卖出了-1、-2这样的负数票,也就是超卖。这种问题就是数据不一致问题!

还出现了同一张票被卖了很多次的情况:

而且在这张图中,还出现了买出的票的序号混乱的问题。这样的代码会导致严重的业务问题!
为什么会出现上面的问题呢?
在上面的代码中,判断tickets>0是一种逻辑运算。要判断tickets值是否大于0,就必须把这个变量从内存读取到CPU 中,用寄存器保存变量的值,然后进行运算。如果满足条件,CPU中的EIP就会指向下一条指令 ,继续向后执行。执行完ticket--后,再将CPU寄存器里的值再放回内存中。但是上述过程都是以线程1为背景进行分析的,假如有多个线程,例如上面的代码中有四个线程,那么就会出现严重问题:
当线程 1 刚把 ticket 的值从内存读到寄存器,判断 ticket > 0 成立,正准备执行卖票和 ticket-- 时,操作系统突然把线程 1 切换走了。此时线程 1 的上下文(寄存器、EIP、ticket 当前值)都会被保存起来,但内存中的 ticket 还没有被修改。
紧接着,线程 2、线程 3、线程 4 都可能被调度运行,它们同样会把 ticket 从内存读到自己的寄存器,判断 ticket > 0 依然成立,于是所有线程都以为自己可以卖票,都进入了卖票逻辑。这样导致的最终结果就是:同一张票被多个线程卖出,甚至出现 ticket 变成负数的超卖现象。
出现这些问题的根本原因是:判断 ticket、卖票、ticket-- 这三步操作不是原子的 ,在多线程切换时会被打断,导致数据不一致和逻辑错误。
那应该如何解决这些问题呢?
由于tickets这个全局变量是共享资源,多线程进行修改会导致数据不一致问题,所以我们要把这个共享资源转化为临界资源 ,访问临界资源的代码就是临界区 。保护临界资源本质上是保护临界区,而保护临界区就需要引入锁 ,通过互斥锁实现同一时间只有一个线程进入临界区操作,从而保证数据安全。

而在Linux系统中,我们所要用到的锁叫做mutex,而创建这个锁就要使用到pthread_mutex_init函数:

如果我们定义的锁是一个全局的锁、静态的锁,就可以使用最后一条:

但如果我们定义的锁是在栈上开辟的锁,我们就要使用到init和destroy
而每一个线程想要访问临界资源的时候都要申请锁:

加上互斥锁之后的代码如下:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
// 全局静态初始化锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *route(void *arg)
{
char *id = (char*)arg;
while ( 1 ) {
// 加锁
pthread_mutex_lock(&mutex);
if ( ticket > 0 ) {
usleep(1000);//模拟具体抢票画的时间
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
// 解锁
pthread_mutex_unlock(&mutex);
} else {
// 解锁
pthread_mutex_unlock(&mutex);
break;
}
}
}
int main( void )
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void*)"thread 1");
pthread_create(&t2, NULL, route, (void*)"thread 2");
pthread_create(&t3, NULL, route, (void*)"thread 3");
pthread_create(&t4, NULL, route, (void*)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
}
加锁的原则:加上锁之后的临界区,在同一时刻只允许一个线程进行访问。这样必然会带来效率的降低,所以加锁的粒度,必须足够细!加锁的粒度足够细的意思是说在加锁时只把核心的临界区的代码加上锁,而非临界区的代码尽量不要加上锁。所以上面的代码在加锁时只在if-else的逻辑里加锁,而不是在while循环外加锁。
mutex是全局锁,也是共享资源,谁来保护它?
正因为锁也是共享资源,所以互斥锁的解锁和加锁被设计成了原子的!原子的的意思是不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
一些线程遵循先加锁,后解锁,如果另一些线程不遵守这个规则可以吗?
当然不行!访问临界资源,所有线程必须遵守加锁和解锁规则,不能有例外!对临界资源进行保护,加锁的过程,是所有相关线程的共识。
当线程申请锁不成功时在干什么?
当线程调用pthread_mutex_lock申请互斥锁失败时,会主动让出 CPU 并进入阻塞等待状态,被操作系统挂起并放入该锁的等待队列中,暂停执行后续代码,直到持有锁的线程调用pthread_mutex_unlock释放锁资源后,它才会被唤醒并重新尝试竞争锁。
当线程进入临界区时,线程可以切换吗?
当线程进入临界区时,是可以被 CPU 调度切换的,但它持有的互斥锁不会被释放,其他线程会因申请不到锁而阻塞等待。只有当线程将临界区的代码执行完并归还锁之后,其他线程才能申请锁。因此不会导致临界区被并发访问,数据依然安全。
1.3互斥锁的实现原理
为了实现互斥锁操作,大多数体系结构都提供了 swap 或 exchange 指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 下面是lock和unlock的伪代码:
lock:
movb $0, %al
xchgb %al, mutex
if(al寄存器的内容 > 0){
return 0;
} else
挂起等待;
goto lock;
unlock:
movb $1, mutex
唤醒等待Mutex的线程;
return 0;
互斥锁的加锁和解锁的过程:加锁时,线程先将 0 写入%al寄存器,再通过原子指令xchgb将%al与内存中的mutex变量交换,若交换后%al的值大于 0,说明之前mutex为 1(锁空闲),线程加锁成功并进入临界区。若%al为 0,说明锁已被占用,那么线程就会挂起等待。线程被唤醒后会再次尝试加锁。解锁时,线程将mutex写回 1 以释放锁,并唤醒等待该互斥锁的线程,完成一整套的解锁流程。
CPU 调度线程时,是以线程为载体执行加锁逻辑的。CPU 内部的寄存器只有一套,但寄存器里的数据可以有多份,这就区分了 "寄存器" 本身和 "寄存器的内容",后者也就是我们常说的硬件上下文。内存中的变量是线程共享的,只要拿到地址就能访问,而通过 xchg 这类原子指令把内存变量交换到 CPU 内部寄存器中,本质上就是把共享数据临时变成了某个线程的私有数据,以此实现互斥锁的原子性。
以线程 A、B 为例,假设线程 A 先申请锁:线程 A 会先将 0 放入寄存器 al,再通过原子交换指令把内存中的 mutex 与 al 寄存器的值互换。若此时线程 A 被切换出去,会带走自身的硬件上下文。紧接着线程 B 申请锁,此时 mutex 的值已经是 0,交换后 al 和 mutex 的值都为 0,不满足判断条件,线程 B 执行挂起等待,不再占用 CPU。之后线程 A 被切换回来,恢复自己的硬件上下文,把 al 和 eip 的数据写回 CPU,继续执行判断,此时 al 中的值为 1,满足条件并执行 return 0,线程 A 成功竞争到锁。
而锁的本质就是那个值为 1 的令牌,因为exchange交换方式不存在拷贝过程,所以系统里至始至终只有这一个 1,谁通过原子交换拿到了这个 1,谁就拥有了锁的使用权。所以竞争锁,本质就是在竞争执行xchg。
互斥锁的本质是通过将锁变量从 1 变为 0 来实现独占,而独占的核心是我们认为临界资源只有一份,所以可以把互斥锁理解为值为 1 的信号量 ,表示系统中只有一份资源,申请资源的过程就像买票,本质上是对资源的预定机制!
1.4互斥锁的封装
Mutex.hpp
cpp
#pragma once
#include <iostream>
#include <pthread.h>
// 互斥锁类封装
class Mutex
{
public:
// 构造函数:初始化互斥锁
Mutex()
{
pthread_mutex_init(&_lock, nullptr);
}
// 给条件变量 Cond 使用,获取原生锁指针
pthread_mutex_t* ptr()
{
return &_lock;
}
// 加锁方法
void lock()
{
pthread_mutex_lock(&_lock);
}
// 解锁方法
void unlock()
{
pthread_mutex_unlock(&_lock);
}
// 析构函数:销毁互斥锁,释放资源
~Mutex()
{
pthread_mutex_destroy(&_lock);
}
private:
pthread_mutex_t _lock; // 底层原生互斥锁变量
};
//RAII锁守卫,利用对象生命周期自动加锁/解锁
class LockGuard
{
public:
// 构造函数:创建对象时自动加锁
LockGuard(Mutex& mutex)
:_mutex(mutex)
{
_mutex.lock();
}
// 析构函数:对象销毁时自动解锁
~LockGuard()
{
_mutex.unlock();
}
private:
Mutex& _mutex; // 引用外部的锁对象
};
当对锁完成上面的封装后,抢票部分的代码就可以修改为:
cpp
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include "Mutex.hpp"
int ticket = 100;
Mutex mutex;
void *route(void *arg)
{
char *id = (char*)arg;
while (1)
{
{
//临界区
LockGuard lock(mutex);
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
}
else
{
break;
}
}
}
return nullptr;
}
int main(void)
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void*)"thread 1");
pthread_create(&t2, NULL, route, (void*)"thread 2");
pthread_create(&t3, NULL, route, (void*)"thread 3");
pthread_create(&t4, NULL, route, (void*)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
return 0;
}
二、线程同步
当线程拿到锁执行完临界区代码后释放锁,紧接着又再次申请锁。由于当前线程的优先级更高,会一直抢占资源,其他线程就始终没法访问临界资源,就会导致线程的饥饿问题。这种不合理的资源竞争,还会造成整体运行效率偏低。想要解决这些问题,就必须引入线程同步机制!
2.1线程同步的概念
为保障临界资源访问安全,约束各线程按既定次序访问临界资源,这种管控线程执行先后次序的机制,就叫做线程同步。
2.2实现线程同步的方法
为了使用线程的同步机制,我们需要引入一个叫条件变量的东西,下面是条件变量的相关接口。
使用pthread_cond_init和destroy来创建和销毁条件变量:

使用条件变量时,有时候线程需要等待条件变量。等待条件变量所要使用的函数如下:

当需要唤醒正在等待条件变量的线程时,需要使用到的函数:

2.3生产者消费者模型
2.3.1概念
生产者消费者模型是多线程协同的一种模式,提高写作效率,本质是一种通信工作。
生产者消费者模式是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
生产者消费者模型包含 3 种关系、2 种角色和 1 个交易场所:3 种关系分别是生产者与生产者之间、消费者与消费者之间,以及生产者与消费者之间的关系;2 种角色为生产者线程和消费者线程;1 个交易场所是类似 "超市" 的内存空间,通常由特定数据结构来承担。
在生产者消费者模型中,生产者与生产者之间、消费者与消费者之间都属于互斥关系,而生产者与消费者之间则同时存在同步和互斥两种关系。

在生活中,这种生产者消费者的模型很常见,比如超市的场景就是典型的生产者消费者模型
我们把超市货架当成缓冲区,供货商是生产者,顾客是消费者。
1.供货商和供货商之间是互斥关系
同一时间,只能有一个供货商往货架上补货。要是两个供货商同时抢着摆货,就会把商品摆乱、挤在一起,甚至可能掉下来。所以必须排队,一个一个来,这就是互斥。
2.顾客和顾客之间是互斥关系
同一时间,货架上同一个位置的同一件商品,只能被一个顾客拿走。要是两个人同时伸手抢,就会乱套。所以大家也要有序挑选,不能同时操作同一个商品,这也是互斥。
3.供货商和顾客之间是同步和互斥关系
互斥:货架同一位置,不能一边有人补货,一边有人抢货,不然会出安全问题,也会把商品弄乱,所以补货和取货必须错开。
同步:如果货架空了(缓冲区空),顾客(消费者)就没法买东西,只能等供货商补货;如果货架满了(缓冲区满),供货商(生产者)就没法再放货,只能等顾客买走一些。这种 "空了等补、满了等买" 的配合,就是同步。
2.3.2优点
生产者消费者模型的优点主要有三点:
- 解耦
生产者和消费者不直接交互,都只和缓冲区(阻塞队列)打交道,降低了两者的代码耦合度,一方的改动不会直接影响另一方。
- 支持并发
生产者和消费者可以各自独立运行、互不阻塞,生产者只管往队列里放数据,消费者只管从队列里取数据,从而实现高效的多线程协同。
- 支持忙闲不均
缓冲区起到了"削峰填谷"的作用,当生产者生产速度快、消费者处理慢时,数据可以先暂存到队列里;反之,当生产者暂时不生产时,消费者也可以继续处理队列里的存量数据,平衡了两者的处理能力差异。
2.4理解条件变量
条件变量就是线程间的等待 + 唤醒通知工具,搭配互斥锁使用。
线程拿到锁后,若不满足运行条件,就调用等待函数主动阻塞释放锁,让出资源;当其他线程改动数据、条件达成后,发送信号唤醒等待线程,被唤醒的线程重新争抢锁继续执行。
条件变量的数量可以有一个,也可以有多个;可以实现单方向的线程同步,也可以实现多方向的线程同步管理。
条件变量核心作用就是避免线程空循环轮询浪费系统资源,精准把控线程执行顺序,从而实现线程同步。
2.5条件变量相关接口的使用
条件变量相关接口的使用如下:
cpp
#include <iostream>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t gmutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t gcond = PTHREAD_COND_INITIALIZER;
void *Print(void *args)
{
std::string name = static_cast<const char*>(args);
delete[] static_cast<char*>(args); // 释放主线程分配的名字空间
while (true)
{
pthread_mutex_lock(&gmutex);
std::cout << "我是新线程:" << name << std::endl;
// 等待条件变量,等待期间会自动释放锁,被唤醒后重新获取锁
pthread_cond_wait(&gcond, &gmutex);
pthread_mutex_unlock(&gmutex);
}
return nullptr;
}
int main()
{
pthread_t tids[4];
for (int i = 0; i < 4; i++)
{
char *name = new char[64];
snprintf(name, 64, "thread-%d", i + 1);
pthread_create(&tids[i], nullptr, Print, static_cast<void*>(name));
}
while (true)
{
// 方式1:pthread_cond_signal只唤醒一个等待的线程
// pthread_cond_signal(&gcond);
// 方式2:pthread_cond_broadcast唤醒所有等待的线程
pthread_cond_broadcast(&gcond);
sleep(1);
}
// 回收线程资源(实际运行中main不会退出,这里仅为代码完整性)
for (int i = 0; i < 4; i++)
{
pthread_join(tids[i], nullptr);
}
pthread_mutex_destroy(&gmutex);
pthread_cond_destroy(&gcond);
return 0;
}
使用pthread_cond_broadcast唤醒所有线程时,可能会出现执行乱序的情况,原因是锁被释放后,各个线程会重新竞争锁,竞争成功的顺序是不固定的。

而使用pthread_cond_signal唤醒线程时,因为每次只会唤醒一个等待的线程,不会出现多个线程同时抢锁的情况,所以线程竞争锁的顺序是有序的。

2.6基于BlockingQueue的生产者消费者模型
在多线程编程中阻塞队列 (Blocking Queue) 是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出 。

代码实现如下:
BlockingQueue.hpp
cpp
#ifndef BLOCK_QUEUE_H
#define BLOCK_QUEUE_H
#include <iostream>
#include <queue>
#include <pthread.h>
using namespace std;
template <class T>
class BlockQueue
{
public:
BlockQueue(int cap = 5)
:_cap(cap)
{
pthread_mutex_init(&_mtx, nullptr);
pthread_cond_init(&_consumer_cond, nullptr);
pthread_cond_init(&_productor_cond, nullptr);
}
// 生产者:放数据
void push(const T& in)
{
// 加锁
pthread_mutex_lock(&_mtx);
// 队列满,生产者等待
while(is_full())
{
pthread_cond_wait(&_productor_cond, &_mtx);
}
// 放数据
_bq.push(in);
// 唤醒消费者
pthread_cond_signal(&_consumer_cond);
// 解锁
pthread_mutex_unlock(&_mtx);
}
// 消费者:拿数据
void pop(T* out)
{
// 加锁
pthread_mutex_lock(&_mtx);
// 队列空,消费者等待
while(is_empty())
{
pthread_cond_wait(&_consumer_cond, &_mtx);
}
// 取数据
*out = _bq.front();
_bq.pop();
// 唤醒生产者
pthread_cond_signal(&_productor_cond);
// 解锁
pthread_mutex_unlock(&_mtx);
}
~BlockQueue()
{
pthread_mutex_destroy(&_mtx);
pthread_cond_destroy(&_consumer_cond);
pthread_cond_destroy(&_productor_cond);
}
private:
bool is_empty()
{
return _bq.empty();
}
bool is_full()
{
return _bq.size() == _cap;
}
private:
queue<T> _bq; // 底层队列
int _cap; // 队列最大容量
pthread_mutex_t _mtx; // 互斥锁
pthread_cond_t _consumer_cond; // 消费者条件变量
pthread_cond_t _productor_cond; // 生产者条件变量
};
#endif
cpp
#include "BlockQueue.h"
#include <unist.h>
void* consumer(void* arg)
{
BlockQueue<int>* bq = (BlockQueue<int>*)arg;
while(true)
{
int x;
bq->pop(&x);
cout << "消费数据:" << x << endl;
sleep(1);
}
}
void* productor(void* arg)
{
BlockQueue<int>* bq = (BlockQueue<int>*)arg;
int x = 0;
while(true)
{
bq->push(x);
cout << "生产数据:" << x << endl;
x++;
}
}
int main()
{
BlockQueue<int> bq;
pthread_t c, p;
pthread_create(&c, nullptr, consumer, &bq);
pthread_create(&p, nullptr, productor, &bq);
pthread_join(c, nullptr);
pthread_join(p, nullptr);
return 0;
}
2.6.1两个核心问题
我们平时写代码时,都会遵守 "临界区要尽量短" 的原则,避免长时间持有锁。但是在阻塞队列里,线程却会拿着锁、在临界区内直接阻塞等待,这和我们的直觉完全相反。要搞懂它,必须先解决两个核心问题:
1.为什么判断条件和等待操作,必须放在临界区里完成?
2.为什么 pthread_cond_wait 必须和互斥锁绑定使用?
1.为什么判断条件和等待操作,必须放在临界区里完成?
因为我们要判断临界资源是否就绪(比如队列是否为空、是否已满),而这个判断动作本身,就是在访问临界资源。所以线程必须先加锁、进入临界区,才能安全地检查条件。当条件不满足时,线程就只能在临界区内等待,直到条件满足后再继续执行。
2.为什么pthread_cond_wait必须和互斥锁绑定使用?
这里有两个最关键的行为,必须依靠锁来完成:
(1)等待时自动释放锁
线程是在临界区内等待的,如果不释放锁,其他线程永远无法进入临界区修改条件,会造成死锁。
所以pthread_cond_wait必须拿到锁,然后在阻塞等待时自动释放锁,让其他线程可以正常工作。
(2)唤醒时自动重新获取锁
当线程被唤醒时,它会直接在临界区内醒来,此时pthread_cond_wait会自动帮线程竞争并再次获取锁,保证线程醒来后可以安全地继续访问临界资源。
2.6.2pthread_cond_wait调用时的三种特殊情况
在实际使用 pthread_cond_wait 过程中,除了正常的等待与唤醒流程,还会遇到三种问题,分别是过量唤醒、函数调用失败和伪唤醒。
- 过量唤醒
当使用pthread_cond_broadcast唤醒所有等待线程时,会一次性唤醒当前所有阻塞的线程。但往往此时系统资源、运行条件仅能支撑部分线程执行,其余被唤醒的线程依旧不满足运行要求,只能再次进入等待状态。
- 函数调用失败
pthread_cond_wait 并非一定能正常执行。但如果传入的条件变量、互斥锁未完成初始化,或是锁的运行状态异常,都会导致函数调用失败。函数执行失败后,线程不会进入阻塞状态,后续业务逻辑也会出现错乱。
- 伪唤醒
伪唤醒是条件变量使用中最需要重视的问题------线程没有收到任何主动唤醒信号,却被操作系统自动唤醒。
这是系统线程调度机制引发的正常现象,不属于代码漏洞。也正是因为伪唤醒的存在,所以在判断等待条件必须使用 while 循环,禁止使用 if 语句。线程被意外唤醒后,会重新校验条件,若依旧不满足,则再次调用 pthread_cond_wait 阻塞等待,以此保证程序逻辑不出错。
所以,为了解决pthread_cond_wait在调用时可能会出现的这三个问题,我们将原本用于判断的if语句改为while循环:
cpp
while(is_empty())
{
pthread_cond_wait(&_consumer_cond, &_mtx);
}
结合前面提到的三种异常情况,我们就能理解 while 循环的核心作用:
通过 while 循环进行条件判断,线程在被系统莫名唤醒后,会重新检查条件,不满足则继续等待;被广播唤醒但资源不足的线程,也会自动回到等待状态;即便pthread_cond_wait函数异常返回,循环也会自动重试,全程保证线程安全等待。
所以,只要使用pthread_cond_wait,就必须使用 用while,绝对不能用if!
2.6.3生产者消费者唤醒策略优化
在上面的代码的基础上,我们可以在BlockQueue这个类中添加私有成员变量高水位和低水位:
cpp
int _low_water; // 低水位:低于该值 → 唤醒生产者
int _high_water; // 高水位:高于该值 → 唤醒消费者
高水位和低水位的含义是:当库存的量低于一定的值的时候,唤醒生产者进行产品生产;当库存量高于一定值时唤醒消费者进行消费。所以在push()和pop()函数中我们可以加入两条判断逻辑:
cpp
// 库存低于低水位 → 唤醒一个生产者补货
if (_bq.size() <= _low_water)
{
pthread_cond_signal(&_productor_cond);
}
// 库存高于高水位 → 唤醒一个消费者消费
if (_bq.size() >= _high_water)
{
pthread_cond_signal(&_consumer_cond);
}
我们也可以在BlockQueue这个类中添加计数器,用于统计正在休眠的生产者和消费者的数量:
cpp
int _sleep_producer; // 休眠的生产者数量
int _sleep_consumer; // 休眠的消费者数量
while循环和判断的逻辑变为:
cpp
while(_q.empty())
{
_sleep_consumer++; // 计数:休眠的消费者+1
pthread_cond_wait(&_consumer_cond, &_mtx);
_sleep_consumer--; // 唤醒后,计数-1
}
//消费完成,若有生产者在休眠,则唤醒
if(_sleep_producer > 0)
{
pthread_cond_signal(&_productor_cond);
}
我们也可以把上面的两种唤醒策略结合起来,组成更复杂的唤醒策略:
cpp
// 高水位:库存积压,广播唤醒所有消费者
if (_bq.size() >= _high_water && _sleep_consumer > 0)
{
pthread_cond_signal(&_cons_cond);
}
// 普通区间:仅存在休眠线程时,单次唤醒
else if (_sleep_consumer > 0)
{
pthread_cond_signal(&_cons_cond);
}
2.6.4多生产者多消费者
上述代码是只有一个生产者和一个消费者的情况,倘若要引入多生产者和多消费者呢?
只需要在主函数中把单个线程变量,改成线程数组,并多次调用 pthread_create,创建多个消费者、多个生产者,最后回收线程即可:
cpp
int main()
{
BlockQueue<int>* bq = new BlockQueue<int>(10); // 队列容量10
pthread_t c[3], p[2];
// 创建3个消费者线程
pthread_create(&c[0], nullptr, ConsumerRoutine, bq);
pthread_create(&c[1], nullptr, ConsumerRoutine, bq);
pthread_create(&c[2], nullptr, ConsumerRoutine, bq);
// 创建2个生产者线程
pthread_create(&p[0], nullptr, ProductorRoutine, bq);
pthread_create(&p[1], nullptr, ProductorRoutine, bq);
// 等待所有线程结束
pthread_join(c[0], nullptr);
pthread_join(c[1], nullptr);
pthread_join(c[2], nullptr);
pthread_join(p[0], nullptr);
pthread_join(p[1], nullptr);
delete bq;
return 0;
}
由于上面的BlockingQueue.hpp中的代码本身就支持多生产者消费者,所以仅需要修改主函数就可以了!
2.7条件变量的封装
Cond.hpp
cpp
// Cond.hpp
class Cond
{
public:
Cond()
{
pthread_cond_init(&_cond, nullptr);
}
void Wait(Mutex &mutex)
{
// 这里就可以调用mutex.Ptr()拿到锁地址
int n = pthread_cond_wait(&_cond, mutex.Ptr());
(void)n;
}
void Signal()
{
pthread_cond_signal(&_cond);
}
void Broadcast()
{
pthread_cond_broadcast(&_cond);
}
~Cond()
{
pthread_cond_destroy(&_cond);
}
private:
pthread_cond_t _cond;
};