目录
2.5 基于 BlockingQueue 的生产者消费者模型
2.5.2 使用 C++ 中的 queue 模拟阻塞队列实现生产者消费者模型
1. 线程互斥
1.1 进程线程间的互斥相关背景概念
临界资源:多线程执行流共享的资源就叫做临界资源。
临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
互斥:任何一个时刻,互斥保证有且只有一个执行流进入临界区访问临界资源,通常对临界资源起保护作用。
原子性:不会被任何调度机制打断的操作。该操作只有两态,要么做就做完,要么就不做。一条汇编语句是原子的。
1.2 互斥锁 mutex
大部分情况,线程使用的数据都是局部数据,变量的地址空间在线程栈空间内,这种情况变量归属单个线程,其他线程无法获得这种变量。
但是有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享完成线程之间的交互。多个线程并发的操作共享变量就会带来数据不一致的问题。如下示例,模拟一个多线程抢票的过程,在没有对临界资源进行保护的情况下发生的情况。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 1000;
void* routine(void* args)
{
const char* id = static_cast<const char*>(args);
while(true)
{
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:: %d\n", id, ticket);
ticket--;
}
else
{
break;
}
}
return nullptr;
}
int main()
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, nullptr, routine, (void*)"thread 1");
pthread_create(&t2, nullptr, routine, (void*)"thread 2");
pthread_create(&t3, nullptr, routine, (void*)"thread 3");
pthread_create(&t4, nullptr, routine, (void*)"thread 4");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
pthread_join(t4, nullptr);
return 0;
}
这里在没有进行临界区保护的时候,出现了将票数抢到负数的情况,这是不被允许的。
这里来理解一下为什么为将票数减到负数。
-- 操作并不是原子操作,而对应了三条汇编指令:(1)将共享变量 ticket 从内存加载到寄存器中。(2)更新寄存器里面的值,执行 -1 操作。(3)将新的值从寄存器写回到共享变量 ticket 的内存地址中。在多条汇编语句顺序执行时,可能因为多线程并发会进行线程切换。
但是 -- 操作不是导致 ticket 减到负数的主要原因。这里用一种情况来说明,当目前 ticket 为 1,线程 1 执行 if 判断进入到 if 代码块中,然后进行线程切换,这时 ticket 的值还是为1,所以线程 2 也进入到 if 判断的代码块中,线程 3 和线程 4 也是这种情况,所以现在 4 个线程都在 if 代码块中,然后线程 1,2,3,4 依次执行 if 代码块中的语句,线程 1 执行之后将 ticket 减到 0 并写回内存中,线程 2 执行 -- 操作的时候从内存中载入 ticket ,此时的 ticket 为0,然后执行 -- 操作,将 0 减到 -1,同理,线程 3 执行 -- 操作从内存中载入的 ticket 为 -1,-- 之后将 -2 写回内存。所以这样就造成了减到负数的情况。上述例子为什么会使多个线程都进入到 if 代码块中,是因为 if 代码块中有一个 usleep(1000),当前一个线程进入 if 代码块之后,执行 usleep 休眠时间长,就会导致该线程的时间片耗尽,然后切换到下一个线程。
要解决上述问题,需要做到三点:(1)代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。上述例子中就是一个线程进入 if 代码块之后,在执行完之前不允许其他线程进入 if 代码块。(2)如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。(3)如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
上述三点总结就是:一个线程进入临界区没执行完之前,不允许其他线程再次进入临界区;没有线程进入临界区,只允许一个线程进入临界区;一个线程不在临界区中,不会影响其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux 上提供的这把锁叫做互斥量。
1.2.1 互斥量接口介绍
1. 初始化互斥量,初始化方法有两种:
方法1:静态分配全局互斥量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
使用宏 PTHREAD_MUTEX_INITIALIZER 初始化全局的锁。不需要进行释放,程序运行结束会自动释放申请的资源。
方法2:动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const
pthread_mutexattr_t *restrict attr);
参数:
mutex:要初始化的互斥量
attr:设置锁的属性,默认情况为 NULL
使用动态分配的方式初始化锁,需要调用 pthread_mutex_destroy 进行销毁。
2. 销毁互斥量
使用 PTHREAD_MUTEX_INITIALIZER 初始化的互斥量不需要销毁;不要销毁一个已经加锁的互斥量;已经销毁的互斥量要确保后面不会有线程再尝试加锁。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
3. 互斥量加锁和解锁(都是原子性操作)
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:
成功返回0,失败返回错误号
调用 pthread_lock 时,可能会遇到以下情况:
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功,执行临界区代码,访问临界资源。
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么 pthread_lock 调用会陷入阻塞(执行流被挂起),等待其他线程解锁互斥量。
加锁的范围尽可能的不要包含太多的非临界区的代码。
加锁之后,一个线程在临界区内部还是会进行线程切换,但是切换之后,该线程是持有锁的,切换到其他线程,其他线程还是拿不到这个锁,就会在 pthread_lock 处进行阻塞,然后再次切换到该线程,等该线程执行完临界区代码释放锁之后,其他线程才能申请到锁。
改进上述代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 1000;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 定义一把全局的锁
void* routine(void* args)
{
const char* id = static_cast<const char*>(args);
while(true)
{
pthread_mutex_lock(&lock); // 加锁
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:: %d\n", id, ticket);
ticket--;
pthread_mutex_unlock(&lock); // 解锁
}
else
{
pthread_mutex_unlock(&lock); // 解锁
break;
}
}
return nullptr;
}
int main()
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, nullptr, routine, (void*)"thread 1");
pthread_create(&t2, nullptr, routine, (void*)"thread 2");
pthread_create(&t3, nullptr, routine, (void*)"thread 3");
pthread_create(&t4, nullptr, routine, (void*)"thread 4");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
pthread_join(t4, nullptr);
return 0;
}
1.3 互斥量实现原理
为了实现互斥锁操作,大多数体系结构都提供了 swap 和 exchange 指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。使用伪代码的形式给出 lock 和 unlock 的实现:
这里解释一下上述的实现原理。
互斥量也是一个变量,当多个线程使用这一个互斥量的时候,该互斥量也需要是共享变量,所以互斥量也需要内存上的一段空间来存储 。按照上图来说,mutex 是一个指针,指向内存中的一块空间,这块空间表示互斥量的标记位,当该空间里面为 1 的时候,表示该锁没有被任何线程申请。而 al 是一个寄存器。
当一个线程进行申请锁的时候,先将该线程 al 寄存器里面的值清0,然后用这个 al 里面的值交换 mutex 指向位置的值,这时候这个线程就持有这个 1,表示申请锁成功,而 mutex 指向位置的值被交换成了 0。下一个线程再次执行交换的时候,也只是 0 和 0 的交换,拿不到这个 1,表示申请锁失败。
换句话说,mutex 中的 1 在整个内存空间中只有一份,当一个线程执行 xchgb(原子性操作,不可被打断) 的时候,会将这个 1 获取到该线程的上下文中。另一个线程申请时,mutex 指向位置没有 1 了,所以只能阻塞挂起。
1.4 互斥量的封装
// Mutex.hpp
#pragma once
#include <iostream>
#include <pthread.h>
// 将互斥量接口封装成面向对象的形式
namespace MutexModule
{
class Mutex
{
public:
Mutex()
{
int n = pthread_mutex_init(&_mutex, nullptr);
(void)n;
}
~Mutex()
{
int n = pthread_mutex_destroy(&_mutex);
(void)n;
}
void Lock()
{
int n = pthread_mutex_lock(&_mutex);
(void)n;
}
void Unlock()
{
int n = pthread_mutex_unlock(&_mutex);
(void)n;
}
private:
pthread_mutex_t _mutex;
};
// 采用RAII风格进行锁管理,当局部临界区代码运行完的时候,局部LockGuard类型的对象自动进行释放,调用析构函数释放锁
class LockGuard
{
public:
LockGuard(Mutex &mutex)
: _mutex(mutex)
{
_mutex.Lock();
}
~LockGuard()
{
_mutex.Unlock();
}
private:
Mutex& _mutex;
};
}
//testMutex.cc
#include <iostream>
#include <unistd.h>
#include "Mutex.hpp"
using namespace MutexModule;
int ticket = 1000;
// 封装一个线程信息对象,描述线程的名字,以及线程持有的锁
class ThreadData
{
public:
ThreadData(const std::string &name, Mutex &lock)
: _name(name),
_lockp(&lock)
{
}
~ThreadData()
{
}
std::string _name;
Mutex *_lockp;
};
void *routine(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
while (true)
{
{
// 临界区
LockGuard guard(*(td->_lockp)); // 这里是LockGuard类型的临时对象,当跳出该代码块的是会调用其析构函数释放锁
// 这个叫做RAII风格的互斥锁
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", td->_name.c_str(), ticket);
ticket--;
}
else
{
break;
}
}
}
return nullptr;
}
int main()
{
// 1. 调用Mutex的构造函数,创建一个局部的互斥量对象lock
Mutex lock;
pthread_t t1, t2, t3, t4;
// 2. 分别给每个线程创建一个ThreadData对象,用于表示每个线程的名字信息
// 每个线程的锁是传的引用,所以四个线程的锁是同一个锁
ThreadData *td1 = new ThreadData("thread 1", lock);
pthread_create(&t1, nullptr, routine, td1);
ThreadData *td2 = new ThreadData("thread 2", lock);
pthread_create(&t2, nullptr, routine, td2);
ThreadData *td3 = new ThreadData("thread 3", lock);
pthread_create(&t3, nullptr, routine, td3);
ThreadData *td4 = new ThreadData("thread 4", lock);
pthread_create(&t4, nullptr, routine, td4);
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
pthread_join(t4, nullptr);
return 0;
// 程序结束时自动调用Mutex的析构函数销毁锁
}
2. 线程同步
从上面的代码可以看到,所以通过互斥解决了数据不一致的问题,但是很长的一段时间都只有一个线程在进行抢票。这种情况叫做其他线程饥饿问题。
形成这种情况的原因是,当当前进入临界区的线程释放锁之后,通过 while 循环重新申请锁,而其他线程还有进行唤醒,从挂起状态转到运行状态,所以表现的现象就是其他线程竞争不过该线程,所以很长一段时间,临界区的代码都是一个线程在执行。
这里有一个简单的解决方案,当临界区代码运行完毕之后,线程调用 usleep(1000)进行一定时间的休眠,给其他线程唤醒的时间,可以一定程度的使其抢票的过程均匀。
之前的做法是纯互斥的做法,也就是临界区只允许一个线程进来,保证了线程安全,但是没有保证所有线程都能按照一定的顺序访问到临界区的代码。
2.1 同步概念与竞态条件
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题叫做同步。
竞争条件:指的是在多线程或多进程环境中,程序的最终执行结果依赖于各个线程或进程的相对执行顺序和时间。由于线程或进程的执行顺序是由操作系统的调度器决定的,具有不确定性,因此当多个线程或进程同时访问和操作共享资源时,可能会导致程序的行为出现不可预测的结果。
2.2 条件变量
条件变量是一种用于线程同步的机制,它允许线程在某个条件不满足时阻塞等待,直到其他线程通知该条件已经满足,从而再去申请锁继续执行。
条件变量通常与互斥锁一起使用,互斥锁用于保护访问共享资源时的线程安全问题,而条件变量则用于在线程之间进行通信和同步。当一个线程需要等到某个共享资源条件满足时,该线程会释放持有的互斥锁并进入阻塞状态,当其他线程改变了共享资源的状态,使其条件满足时,会通过条件变量通知正在等待(阻塞)的线程,等待的线程被唤醒之后重新获取互斥锁,继续执行后续操作。
2.3 条件变量函数
初始化:
1. 使用宏进行全局静态初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
2. 局部条件变量初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t
*restrict attr);
参数:
cond:要初始化的条件变量
attr:条件变量属性,默认为nullptr
销毁
int pthread_cond_destroy(pthread_cond_t *cond)
等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict
mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量
唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);
功能:
唤醒在cond条件变量下等待的所有线程
int pthread_cond_signal(pthread_cond_t *cond);
功能:
唤醒在cond条件变量下等待的一个线程。
demo代码:
#include <iostream>
#include <vector>
#include <unistd.h>
#include <pthread.h>
#include <string>
// 定义一个全局的条件变量和一个全局的锁
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void* routine(void* args)
{
std::string name = static_cast<const char*>(args);
while(true)
{
pthread_mutex_lock(&mutex);
// 阻塞等待唤醒,pthread_cond_wait在进行等待的时候是在临界区等待的,所以当前还持有锁,传入锁的意义是当
// 该线程进行阻塞等待时释放锁,能使别的线程申请该锁
pthread_cond_wait(&cond, &mutex);
std::cout << name << std::endl;
pthread_mutex_unlock(&mutex);
}
// delete name;
return nullptr;
}
#define NUM 5
int main()
{
std::vector<pthread_t> threads;
for (int i = 0; i < NUM; i++)
{
pthread_t tid;
char* name = new char[64];
sprintf(name, "thread-%d", i);
int n = pthread_create(&tid, nullptr, routine, (void*)name);
if (n == 0)
{
threads.push_back(tid);
}
// 这里添加sleep(1),给每个线程一定时间创建后执行到pthread_cond_wait再创建第二个线程
sleep(1);
}
while(true)
{
// 一次唤醒等待中的所有线程
// std::cout << "一次唤醒所有线程" << std::endl;
// pthread_cond_broadcast(&cond);
// 一次唤醒一个线程
std::cout << "一次唤醒一个线程" << std::endl;
pthread_cond_signal(&cond);
sleep(1);
}
for (int i = 0; i < NUM; i++)
{
pthread_join(threads[i], nullptr);
}
return 0;
}
循环创建5个线程,并且都执行到 pthread_cond_wait 进行等待,通过在主线程中对条件变量中的线程进行唤醒, 使用 pthread_cond_broadcast 一起唤醒所有等待线程:
使用 pthread_cond_signal 一次唤醒一个线程:
2.4 生产者消费者模型
生产者消费者模型是一种经典的设计模式,用于解决多线程环境下生产者和消费者之间的同步和数据传递问题。
2.4.1 模型结构
生产者线程:负责生产数据并将其放入缓冲区。生产者线程通常会不断的生产数据,知道满足某个结束条件。
消费者线程:从缓冲区中取出数据并进行处理。消费者线程也会持续运行,不断尝试从缓冲区获取数据进行消费。
缓冲区:作为生产者和消费者之间的中间存储区域,用于暂存生产者生产的数据。缓冲区的大小通常是有限的,当缓冲区为满时,生产者线程需要等待,当缓冲区为空时,消费者线程需要等待。
2.4.2 工作原理
生产者线程不断的生产数据,当缓冲区未满时,生产者将数据放入缓冲区,并通知消费者线程从缓冲区中拿数据。
消费者线程不断尝试从缓冲区中获取数据,当缓冲区不为空时,消费者从缓冲区取出数据进行处理,并通知生产者线程缓冲区有空闲空间可继续生产数据。
当缓冲区为满时,生产者线程进入等待状态,直到消费者线程消费一些数据,腾出空间并发出通知。
当缓冲区为空时,消费者线程进入等待状态,直到生产者线程生成了新的数据并发出通知。
缓冲区需要被多个线程所看到,所以缓冲区一定是临界资源。 所以同一个时刻只有一个线程能访问缓冲区。所以不管是生产者线程还是消费者线程,都需要互斥。
生产者之间:竞争,互斥关系。
消费者之间:竞争,互斥关系。
生产者们和消费者们之间:同步互斥关系。
生产者消费者模型的编写就是维护上述三种关系。
2.4.3 模型优点
1. 将数据生产过程和消费过程进行解耦,并且支持生产和消费的过程并发。
2. 支持生产者和消费者之间忙闲不均。因为有缓冲区的存在,生产者在生产的时候可以没有消费者消费,消费者在消费的时候可以没有生产者生产。
3. 提高效率。
这里对提高效率这个优点做一定的解释。不管是多生产多消费还是单生产单消费,本质上只有一把锁,所以本质上在同一个时刻只有一个线程能够访问临界资源,那么这怎么能叫做提高效率呢。
这里的提高效率指的是,当一个线程从缓冲区中获取任务到线程自己的上下文之后,还需要对任务进行处理,该线程在处理这个任务的同时,其他线程可以并发的继续生产任务或者获取任务,就不用阻塞式的等待这个任务处理完之后再去获取新的任务。
2.5 基于 BlockingQueue 的生产者消费者模型
2.5.1 BlockingQueue
在多线程编程中阻塞队列(BlockingQueue)是一种常用于实现生产者消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列为满时,往队列里存放元素的操作会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的)。
2.5.2 使用 C++ 中的 queue 模拟阻塞队列实现生产者消费者模型
// BlockQueue.hpp
#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.size() <= 0;
}
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 Push(const T &in)
{
// 1. 因为要访问临界资源,所以代码块需要进行加锁
pthread_mutex_lock(&_mutex);
// 2. 队列为满时,生产者线程进行等待,通知消费者取数据,队列不满时,直接入数据
while (IsFull())
{
// 队列为满的时候,生产者线程应该进行等待
_psleep_num++;
std::cout << "生产者线程进行休眠: _psleep_num: " << _psleep_num << std::endl;
// 2.1 pthread_cond_wait调用成功,自动释放锁,并挂起当前线程
// 2.2 当线程被唤醒的时候,默认在临界区,要从pthread_cond_wait中成功返回,需要重新申请锁
// 2.3 如果被唤醒但是申请锁失败,就会在锁上阻塞等待
pthread_cond_wait(&_full_cond, &_mutex);
// 问题1:pthread_cond_wait如果失败,线程没有被放入到条件变量中,立即返回
// 如果上述判断是if不是while就会直接进入到下面的逻辑中,向已经满了的队列中继续push数据
// 问题2:在多生产者的情况下,如果队列满了,生产者们都进行了休眠,然后消费者消费了一个数据
// 使用 pthread_cond_broadcast 唤醒所以生产者,如果上述判断是if不是while,
// 其中一个生产者申请到了锁,填满了刚腾出来的空间,释放锁,其他线程也会申请到锁,向下执行
// 向满的队列中继续push数据
// 所以上述判断需要使用 while 而不是 if
_psleep_num--;
}
// 到这队列一定不为满,入数据并且通知消费者线程进行消费
_q.push(in);
if (_csleep_num > 0) // 条件变量中没有等待的线程时进行唤醒也不影响,这里增加判断增强代码的逻辑性
{
pthread_cond_signal(&_empty_cond);
std::cout << "唤醒一个消费者... " << std::endl;
}
pthread_mutex_unlock(&_mutex);
}
// 消费者调用向队列中取数据
T Pop()
{
// 1. 因为要访问临界资源,所以代码块需要进行加锁
pthread_mutex_lock(&_mutex);
// 2. 队列为空时,消费者线程进行等待,通知生产者线程生产数据,队列不为空时,直接取数据
while (IsEmpty())
{
_csleep_num++;
std::cout << "消费者线程进行休眠: _csleep_num: " << _csleep_num << std::endl;
pthread_cond_wait(&_empty_cond, &_mutex);
_csleep_num--;
}
T data = _q.front();
_q.pop();
if (_psleep_num > 0)
{
std::cout << "唤醒一个生产者..." << std::endl;
pthread_cond_signal(&_full_cond);
}
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; // 生产者休眠个数
};
// Main.cc
#include "BlockQueue.hpp"
#include <unistd.h>
#include "Task.hpp"
void *comsumer(void *args)
{
BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);
while (true)
{
sleep(1);
int x = bq->Pop();
std::cout << "消费一个数据: " << x << std::endl;
}
return nullptr;
}
void *productor(void *args)
{
BlockQueue<int> *bq = static_cast<BlockQueue<int> *>(args);
int count = 0;
while (true)
{
// sleep(1);
std::cout << "生产一个数据: " << count << std::endl;
bq->Push(count);
++count;
}
return nullptr;
}
int main()
{
BlockQueue<int> *bq = new BlockQueue<int>;
pthread_t c, p;
pthread_create(&c, nullptr, comsumer, bq);
pthread_create(&p, nullptr, productor, bq);
pthread_join(c, nullptr);
pthread_join(p, nullptr);
return 0;
}
上述 Main.cc 主函数中,当生产者的生产节奏快于消费者的消费节奏的时候,会出现如下结果。
生产者线程速率快时,先连续生产了 5 个数据(这里由于在生产者线程中将打印日志放在了实际生产的前面,所以先打印出了“生产一个数据:5”,实际到这才生产了五个),然后消费者线程还未启动,阻塞队列已经满了,所有生产者生产第六个数据的时候检测到阻塞队列满了,进入到_full_cond 条件变量中进行休眠。1s 过后,消费者线程开始执行,则消费了一个数据,通知生产者线程阻塞队列中有空位置了,所以唤醒生产者线程生产数据。由于生产者速率远大于消费者速率,所有生产完一个之后,又进行了休眠。此时,后续的整个过程都是按照消费者速率来进行运行的,消费一个数据,唤醒生产者线程然后生产一个数据后进行休眠。
当生产者速率慢于消费者速率,结果如下。
消费者线程先运行,发现阻塞队列中没有数据,然后进行休眠,过了 1s 之后,生产者线程开始运行并生成了一个数据,然后唤醒消费者线程,由于消费者速率远大于生产者速率,消费完一个数据之后阻塞队列中还没有新数据,则进行进入休眠,等待下一次生产者线程生产数据。所以后续过程就是生产生产一个数据,消费者消费一个数据。
这份代码本身也可以兼容多生产者多消费者的情况,因为整个模块中只有一把锁,所以不管是生产者还是消费者线程,同一时刻只有一个线程访问阻塞队列,本身就维护了生产者和生产者以及消费者和消费者之间的互斥关系,而生产者和消费者之间的同步关系由条件变量进行维护。
这里采用模板的形式进行实现,是说明不仅仅只能传入一个整数,还可以传入回调函数指针或者任务对象。
2.6 条件变量的封装
// Mutex.hpp -- 互斥量的封装
#pragma once
#include <iostream>
#include <pthread.h>
// 将互斥量接口封装成面向对象的形式
namespace MutexModule
{
class Mutex
{
public:
Mutex()
{
int n = pthread_mutex_init(&_mutex, nullptr);
(void)n;
}
~Mutex()
{
int n = pthread_mutex_destroy(&_mutex);
(void)n;
}
void Lock()
{
int n = pthread_mutex_lock(&_mutex);
(void)n;
}
void Unlock()
{
int n = pthread_mutex_unlock(&_mutex);
(void)n;
}
pthread_mutex_t* Get()
{
return &_mutex;
}
private:
pthread_mutex_t _mutex;
};
// 采用RAII风格进行锁管理,当局部临界区代码运行完的时候,局部LockGuard类型的对象自动进行释放,调用析构函数释放锁
class LockGuard
{
public:
LockGuard(Mutex &mutex)
: _mutex(mutex)
{
_mutex.Lock();
}
~LockGuard()
{
_mutex.Unlock();
}
private:
Mutex& _mutex;
};
}
// Cond.hpp -- 条件变量的封装
#pragma once
#include <iostream>
#include <pthread.h>
#include "Mutex.hpp"
using namespace MutexModule;
namespace CondModule
{
class Cond
{
public:
Cond()
{
pthread_cond_init(&_cond, nullptr);
}
void Wait(Mutex &mutex)
{
int n = pthread_cond_wait(&_cond, mutex.Get());
(void)n;
}
void Signal()
{
// 唤醒一个在条件变量下等待的线程
int n = pthread_cond_signal(&_cond);
(void)n;
}
void Broadcast()
{
// 唤醒所有在条件变量下等待的线程
int n = pthread_cond_broadcast(&_cond);
(void)n;
}
~Cond()
{
pthread_cond_destroy(&_cond);
}
private:
pthread_cond_t _cond;
};
}
2.7 POSIX信号量
POSIX 信号量和 System V 信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。但是 POSIX 信号量可以用于线程间同步。
POSIX 标准的接口都是比较类似的,这里信号量 sem 的接口和上述条件变量以及互斥量的接口相似。但是使用信号量的接口时需要包含头文件 <semaphore.h>。
初始化信号量:
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
pshared:0表⽰线程间共享,⾮零表⽰进程间共享
value:信号量初始值
销毁信号量:
int sem_destroy(sem_t *sem);
等待信号量:
功能:等待(申请)信号量,等待成功会将信号量的值减1
int sem_wait(sem_t *sem); //P()
发布信号量:
功能:发布信号量,表⽰资源使⽤完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()
2.7.1 基于环形队列的生产者消费者模型
环形队列采用数组进行模拟,用模运算来维持环状特性。环形结构为空为满时都是头指针和尾指针指向同一个位置,所以不好判断是为空状态还是为满状态。
但是信号量本身就是一个计数器,所以可以使用信号量本身的特色来判断环形队列的空满状态。
2.7.2 信号量的封装
#pragma once
#include <iostream>
#include <semaphore.h>
#include <pthread.h>
namespace SemModule
{
// 默认信号量计数器为1
const int defaultvalue = 1;
class Sem
{
public:
Sem(const int sem_value = defaultvalue)
{
sem_init(&_sem, 0, sem_value);
}
// 信号量的PV操作是原子的
// 申请信号量 --
void P()
{
int n = sem_wait(&_sem); // P操作
(void)n;
}
// 归还信号量 ++
void V()
{
int n = sem_post(&_sem); // V操作
(void)n;
}
~Sem()
{
sem_destroy(&_sem);
}
private:
sem_t _sem;
};
}
2.7.3 环形队列的封装
用数组来模拟环形队列,使用模运算来维持环状特性。这里的Mutex是之前封装好的互斥量。
// RingQueue.hpp
#pragma once
#include <iostream>
#include <vector>
#include "Sem.hpp"
#include "Mutex.hpp"
static const int gcap = 5;
using namespace SemModule;
using namespace MutexModule;
template<typename T>
class RingQueue
{
public:
RingQueue(int cap = gcap)
: _cap(cap),
_rq(cap),
_blank_sem(cap),
_p_step(0),
_data_sem(0),
_c_step(0)
{}
void Push(const T& in)
{
// 1. 申请空位置信号量
_blank_sem.P();
{
LockGuard lockguard(_pmutex);
// 2. 生产 -- 在_p_step位置进行生产
_rq[_p_step] = in;
// 3. 更新下标
++_p_step;
// 4. 维持环形特性
_p_step %= _cap;
}
// 5. 生产完数据之后,将数据信号量++
_data_sem.V();
}
void Pop(T* out)
{
// 1. 申请数据信号量
_data_sem.P(); // 如果还没有生产者进行Push操作执行V操作,则_data_sem信号量中没有可用信号量,则会阻塞在这
{
LockGuard lockguard(_cmutex);
// 2. 消费 -- 取出_c_step位置的数据
*out = _rq[_c_step];
// 3. 更新下标
++_c_step;
// 4. 维持环形特性
_c_step %= _cap;
}
// 5. 消费完数据之后,将空位置信号量++
_blank_sem.V();
}
~RingQueue()
{}
private:
std::vector<T> _rq;
int _cap;
// 生产者
Sem _blank_sem;
int _p_step; // 生产者环形队列中的下标
// 消费者
Sem _data_sem;
int _c_step; // 消费者环形队列中的下标
Mutex _pmutex; // 维护多生产者之间的互斥关系
Mutex _cmutex; // 维护消费者之间的互斥关系
};
下面是基于环形队列的生产者消费者模型demo代码:
#include <iostream>
#include <unistd.h>
#include <pthread.h>
#include "RingQueue.hpp"
#include "Sem.hpp"
using namespace SemModule;
class ThreadInfo
{
public:
RingQueue<int>* _rq;
std::string _name;
};
void* consumer(void* args)
{
ThreadInfo *rd = static_cast<ThreadInfo*>(args);
while(true)
{
sleep(1);
// 1. 消费任务 -- 从环形队列中取任务
int x;
rd->_rq->Pop(&x);
// 2. 处理任务
std::cout << rd->_name << "消费一个数据: " << x << std::endl;
}
}
void* productor(void* args)
{
ThreadInfo *rd = static_cast<ThreadInfo *>(args);
int data = 0;
while(true)
{
// 1. 获取任务
std::cout << rd->_name << "生产一个数据: " << data << std::endl;
// 2. 生产任务 -- 将数据放入环形队列中
rd->_rq->Push(data++);
}
}
int main()
{
// 1. new 一个环形队列变量
RingQueue<int>* rq = new RingQueue<int>();
// 2. 创建生产者消费者线程
pthread_t c[2], p[3];
ThreadInfo *ri1 = new ThreadInfo();
ri1->_rq = rq;
ri1->_name = "Thread-1";
pthread_create(c, nullptr, consumer, ri1);
ThreadInfo *ri2 = new ThreadInfo();
ri2->_rq = rq;
ri2->_name = "Thread-2";
pthread_create(c + 1, nullptr, consumer, ri2);
ThreadInfo *ri3 = new ThreadInfo();
ri3->_rq = rq;
ri3->_name = "Thread-3";
pthread_create(p, nullptr, productor, ri3);
ThreadInfo *ri4 = new ThreadInfo();
ri4->_rq = rq;
ri4->_name = "Thread-4";
pthread_create(p + 1, nullptr, productor, ri4);
ThreadInfo *ri5 = new ThreadInfo();
ri5->_rq = rq;
ri5->_name = "Thread-5";
pthread_create(p + 2, nullptr, productor, ri5);
pthread_join(*c, nullptr);
pthread_join(*(c+1), nullptr);
pthread_join(*p, nullptr);
pthread_join(*(p+1), nullptr);
pthread_join(*(p+2), nullptr);
return 0;
}
这里多线程对显示器的访问没有互斥关系,所以会出现消息打印混乱的情况。