生产者——消费者的 C++ 代码实现
本文将重点介绍生产者——消费者模型的 C++ 代码实现。
题目要求
- 生产者数量限制为 1 个,消费者数量限制为 3 个。
- 生产者生产消息,将其编号并将其插入到消息队列中。
- 消费者从消息队列中获取消息,但必须在消息队列不为空时获取。
- 消息队列可以存放无限多的消息。
- 程序必须包含图形用户界面。
- 程序的执行能够随时暂停和继续。
准备工作
我采用 Qt 5.9.9 来实现程序的 GUI 部分,采用 C++ 11 标准中的多线程库实现此程序中的多线程部分。
代码实现
程序代码整体可分为以下若干部分。
- MainWindow 类
- 实现 GUI 交互界面
- 实现消息队列的存储和读取功能
- 实现程序执行的暂停和继续功能
- 生产者函数
- 生产消息
- 将生产的消息插入到消息队列中
- 消费者函数
- 从消息队列中获取消息
MainWindow 类
类声明
1 | class MainWindow : public QMainWindow |
MainWindow 包含的成员如下
- 公有成员
- MainWindow 构造和析构函数
装载 UI 文件,实现按钮操作,如下图。
- pushTask 函数
插入消息到消息队列中。 - popTask 函数
从消息队列中获得消息并移除,当队列为空时返回 -1 值。 - updateText 函数
将获取消息的文本提示展现在窗口上,如下图。
- MainWindow 构造和析构函数
- 私有成员
- ui
该成员用于装载 ui 文件。 - queueTask
该成员用于存储消息队列。
- ui
UI 组件
UI 布局如下图。
其中,文本为“开始”的按钮勾选了 checkable,这将让其拥有 checkable 的状态,这是我们实现变换效果的关键。
类定义
MainWindow 需要先装载 ui 文件。
1 | ui->setupUi(this); |
之后需要将按钮与相应的函数进行绑定,不过,这段代码很长,我将其分成若干部分来向你介绍。
1 | queueLock.lock(); |
在这一部分代码中,我们实现了按钮的变化效果。
当程序刚启动时,根据 ui 文件设定,按钮上的文本为“开始”;当我们点下该按钮,就会调用 lambda 函数,这一个函数将按值获取参数 check,该参数来自于按钮的 checkable 状态,它在程序开始时是 true,因此 check 值也为 true,这将满足 if 的判断条件并执行下列代码,该按钮文本被更换为“暂停”。
1 | ui->start_button->setText("暂停"); |
另外,在函数开头,我们使用 setCheckable 函数将按钮的 checkable 状态取反。
1 | ui->start_button->setCheckable(!check); |
在下次点击中,因为按钮的 checkable 状态已经被取反,因此下一次点击触发的 lambda 函数将获得值为 false 的参数,这导致 if 的条件不能满足,程序转而执行 else 部分,即下方的代码,导致按钮文本更换为为“继续”。
1 | ui->start_button->setText("继续"); |
不过,我们希望实现的不只是按钮文本变化的效果,还包括程序暂停和继续的功能。因此我们在开头先锁定了队列锁 queueLock。
1 | queueLock.lock(); |
这会导致生产者和消费者的线程均被阻塞,则程序无法一启动就执行,必须等到点击开始后才能执行,随后,当我们点击“开始”按钮时,if 部分将调用 unlock 函数来释放队列锁,生产者和消费者不再被阻塞,程序也就开始执行;当我们再次点击时,else 部分将调用 lock 函数锁定队列锁,生产者和消费者被阻塞,实现了暂停的效果。
而下面这部分代码
1 | ui->start_button->setEnabled(false); |
将暂时关闭该按钮,并于 0.8 秒后使其再次可用。
接着,我们需要绑定清空按钮。
1 | QObject::connect(ui->stop_button, static_cast<void (QPushButton::*)(bool)>(&QPushButton::clicked), this, [this]{ |
清空按钮将会需要读写队列,因此需要获取队列锁 queueLock,为了避免在程序暂停时获取队列锁而导致的未定义行为,queueLock 将是递归锁,而不是普通的互斥锁。
1 | std::recursive_mutex queueLock; |
这允许同一线程可以反复锁定同一把锁,并且不会引发未定义行为。
在锁定队列锁后,我们需要清空文本,这部分的代码很好写,我们只需调用每一个 QTextBrowser 的 clear 方法即可。另外别忘了在清空队列之前,先获取队列锁。
1 | queueLock.lock(); |
仅仅是清空文本还不够,我们还需要清空消息队列,这样一来,当我们暂停程序时,即使消息队列里有消息还没有取出来,也不会影响到重新开始后的消息获取。
1 | { |
我们在一个单独的语句块中创建一个临时变量 empty,并将其与 queueTask 交换,这样就实现清空消息队列的效果了。
最后,我们需要设置总生产数量为 0,使得下一次生产的消息编号能从 1 开始,而不是从上次中断的位置继续。
随后我们释放锁,并暂时禁用该按钮 0.8 秒。
1 | queueLock.unlock(); |
上面这部分代码设定了两个按钮的一些基本功能,我们还没实现队列的存储和读取功能,好在这并没有什么难的,唯一需要注意的是,popTask()需要先检查消息队列是否为空,若为空则返回 -1,若不为空,则获取消息,并将其从队列中移除。
1 | void MainWindow::pushTask(int id) |
最后是文本的展示,我们规定 updateText 函数需要接受调用者的 ID 以及消息的 ID,当调用者 ID 为 0 时代表调用者是生产者,而为 1 到 3 时,则为消费者,插入字符串的时候,将调用者 ID 和消息 ID 拼接到字符串里面即可。
1 | void MainWindow::updateText(int id, int taskID) |
生产者函数
生产者需要源源不断地产生消息并插入到消息队列中,但消息队列可能正在被读取,也可能正在被清空,插入消息后需要将文本输出到展示框,展示框又可能被清空,因此我们在输出和插入消息时是必须获取队列锁的,则实现的代码应当如下。
1 | void producer(MainWindow& w, int id) |
需要注意的是,我们需要在每次消息生产完之后,让其休眠一小会。
消费者函数
每次我们都需要从队列中读取出一个消息并将其从中移除,这意味着这不只是读操作,还是写操作,因此也需要获取队列锁,则实现代码应当如下。
1 | void consumer(MainWindow& w, int id) |
获取消息时,消息队列可能正好是空的(导致返回值变为 -1),因此需要先检查 res 不是 -1 后才能调用 updateText 将其展示出来。
在获取消息后,我们同样需要让消费者线程进行休眠一小会。
主函数部分
我们只需要使用 std::thread 生成 4 个线程,并将其从主进程中分离,这样便可实现生产者——消费者模型。
1 | int main(int argc, char *argv[]) |