这是一个公司笔试题。题目描述:
现实生活中和程序世界里有大量的流水线,为了适配流水线不同环节的速率,会在某些流程环节之间设置缓冲队列。请设计实现一个高速生产者和多个慢速消费者的缓冲设施,
1、至少提供入队和出队两个接口
2、注意考虑多线程调用和性能问题
3、注意考虑如何解决溢出或忙等、空闲问题。
一、 思路
题目就是要设计一个缓冲设施,要求里面说的也很清楚了,就是一个队列。
从以下四个方面考虑来完成这道题:
- 实现一个生产消费队列
- 实现高并发
- 确保线程安全
- 解决空闲,忙等问题
-
首先是设计一个生产者消费者队列,分别通过一个while循环生产者往队列加任务,消费者取任务。
-
考虑高并发:高并发需要支持多个生产者多个消费者,单个缓冲队列容量有限,如果生产者持续高于消费者的速率,缓冲队列会溢出。缓冲队列需要支持多线程并发访问,如果使用不合适的同步机制,会导致数据不一致或性能下降。因此采用多缓冲区队列,将队列存储在vector中。
-
考虑线程安全:使用互斥锁,使用哪个队列就获取这个队列的锁。
-
考虑溢出、忙等、空闲等问题:溢出和空闲问题是由于生产者生产速率高于消费者的消费速率,可以用多缓冲区、负载均衡等提高缓冲队列的利用效率。忙等是由于队列为空,消费者取任务就会在while中一直循环来等待,或者是队列满,生产者加任务也会一直循环等待。因此采用条件变量,不满足取任务或加任务的条件的时候就阻塞,直到收到条件变量的通知。
二、实现缓冲区队列接口
这部分就是定义一个队列queue,生产者在while循环中往队列中添加任务,消费者在while循环中从队列中取任务,实现先进先出。
使用多缓冲区队列提升高并发性能,用vector保存queue,方便通过下标索引,每个队列queue对应一个互斥锁。定义队列最大容量max_size和缓冲区数量buffer_count,这是用户传入的参数:
std::vector<std::queue<T>> queues; // 队列
std::vector<std::shared_ptr<std::mutex>> mutexes; // 互斥锁
size_t max_size; // 每个队列的最大任务数量
size_t buffer_count; // 缓冲区数量
这里为什么互斥锁要使用shared_ptr的形式存储在vector,而不是直接直接用vector存储,以及下面条件变量也是用shared_ptr存储的原因,将会在下一节实现构造函数中解释。
基于负载均衡思想,采用加权轮询的方式往队列中加任务取任务,避免溢出或空闲。
加权轮询的方法是在轮询的基础上的改进,就是每次遍历一遍,选择权重最大的队列进行操作,权重表示了每个队列的处理能力,权重越高,接收的任务越多。每个队列定义一个权重:
std::vector<int> weights; // 各缓冲区的加权因子
对于生产者和消费者,使用条件变量避免忙等(以unique_ptr的形式存储在vector中):
std::vector<std::shared_ptr<std::condition_variable>> not_full_conds; // 条件变量,防止队列溢出
std::vector<std::shared_ptr<std::condition_variable>> not_empty_conds; // 条件变量,防止消费者忙等
为了进一步优化加权轮询,可以添加调整队列权重的成员函数adjust_weights。并采用多步调整,即出队一定数量的任务后调用adjust_weights。因此定义:
size_t dequeue_count; // 出队元素计数器,记录出队元素个数
size_t dequeue_threshold; // 调用 adjust_weights 的阈值,默认为1
因此得到多缓冲区队列定义的数据结构和接口:
template<typename T>
class Buffer {
private:
std::vector<std::queue<T>> queues;
std::vector<std::shared_ptr<std::mutex>> mutexes; // 互斥锁
size_t max_size; // 每个队列的最大任务数量
size_t buffer_count; // 缓冲区数量
std::vector<int> weights; // 各缓冲区的加权因子
std::vector<std::shared_ptr<std::condition_variable>> not_full_conds; // 条件变量,防止队列溢出
std::vector<std::shared_ptr<std::condition_variable>> not_empty_conds; // 条件变量,防止消费者忙等
size_t dequeue_count; // 出队元素计数器,记录出队元素个数
size_t dequeue_threshold; // 调用 adjust_weights 的阈值,默认为1
// 动态调整缓冲区权重
void adjust_weights();
public:
Buffer(size_t buffer_count, size_t max_size, const std::vector<int>& weights, size_t dequeue_threshold = 1);
// 入队:加任务
void enqueue(T t);
// 出队:取任务
T dequeue();
};
三、实现构造函数
通过该构造函数初始化各个成员变量并初始化出队元素计数器为0。
传入的参数包括:缓冲区队列数量、各缓冲区队列最大容量、每个缓冲区队列的初始权重和调用动态调整权重函数的阈值。
// 构造
template<typename T>
Buffer<T>::Buffer(size_t buffer_count, size_t max_size, const std::vector<int>& weights, size_t dequeue_threshold) :
buffer_count(buffer_count), max_size(max_size), weights(weights), dequeue_threshold(dequeue_threshold) {
queues.resize(buffer_count);
dequeue_count = 0; // 初始化出队元素计数器为0
for (int i = 0; i < buffer_count; ++i) {
mutexes.emplace_back(std::make_shared<std::mutex>()); // 初始化mutex的数组
not_full_conds.emplace_back(std::make_shared<std::condition_variable>());
not_empty_conds.emplace_back(std::make_shared<std::condition_variable>());
}
}
要解释两个问题:
1、为什么要使用shared_ptr的形式存储?
如果直接存储在vector中,即
std::vector<std::mutex> mutexes; // 互斥锁
这样就没法复制这个Buffer类,同样使用unique_ptr也没法复制,因此采用shared_ptr的形式存储。
2、为什么要使用emplace_back,而不是直接resize?
当buffer_count大于原数组大小,即需要扩充时,resize会调用对象的默认构造函数,构造后进行填充,但是std::shared_ptr 的默认构造函数会创建一个空智能指针nullptr,它不指向任何对象。因此这里采用emplace_back,通过std::make_shared<std::mutex>()构造一个指向有效的mutex对象的智能指针,然后将返回的指针加入到数组尾。
补充:
- emplace_back 是 std::vector 的一个成员函数,它用于在向量的末尾构造一个新元素。与 push_back 不同,emplace_back 会直接在向量的末尾构造新元素,而不是先创建临时对象,然后将其移动或复制到向量中。
- 使用 new 运算符来创建一个新的 std::shared_ptr 对象时,程序需要执行两次内存分配操作:一次用于分配指向的对象,另一次用于分配用于存储引用计数和其他控制信息的控制块。但是使用 std::make_shared 函数来创建新的 std::shared_ptr 对象时,程序只需要执行一次内存分配操作,即同时分配指向的对象和控制块。这样可以减少内存分配的开销,并提高性能。
四、实现入队函数
通过入队函数实现生产者将元素加入到合适的缓冲区队列中。
采用加权轮询的方式选择未满且权重最大的缓冲区添加元素,即通过遍历所有的缓冲区,记录遍历的最大权重和对应的缓冲区索引,当遍历完成后选择最大权重对应的缓冲区加入元素,同时利用互斥锁和条件变量保证线程安全,在取出元素后释放锁,并通知该缓冲区不空的条件变量。
template<typename T>
void Buffer<T>::enqueue(T t) {
size_t i = 0; //缓冲区索引
int max_weight = -1; //用于记录最大权重,初始化为-1
size_t max_index = 0; // 用于记录最大权重对应的缓冲区索引
while (true) {
if (queues[i].size() < max_size && weights[i] > max_weight) { // 寻找未满,且最大权重的缓冲区
max_weight = weights[i];
max_index = i;
}
if (i == buffer_count - 1) { // 说明以及遍历完所有缓冲区,则在最大权重缓冲区中添加元素
std::unique_lock<std::mutex> lock(*mutexes[max_index]);
not_full_conds[max_index]->wait(lock, [this, max_index] { return queues[max_index].size() < max_size; }); // 条件不满足,即队列满 则阻塞
queues[max_index].push(t);
not_empty_conds[max_index]->notify_one(); // 通知一个等待在not_empty_conds[max_index] 条件变量上的线程。
return;
}
i = (i + 1) % buffer_count;
}
}
使用了lambda表达式作为条件变量wait函数的条件,当不满足条件时(该队列满),该线程会阻塞,并在not_empty_conds[max_index] 这个条件变量上等待,直到收到一个通知。
注意:因为是while循环,队列索引是有范围的,因此用 i = (i + 1) % buffer_count; 而不是直接++
五、实现出队函数
通过出队函数,实现消费者选择合适的缓冲区队列取元素。
在出队函数中,也使用了加权轮询的方式,即在所有非空的缓冲区中,选择权重最大的那个,即通过遍历所有的缓冲区,记录遍历的最大权重和对应的缓冲区索引,当遍历完成后选择最大权重对应的缓冲区取出元素。
利用互斥锁和条件变量保证线程安全,在取出元素后释放锁,并通知该缓冲区不满的条件变量。
同时,更新出队元素计数器,并根据阈值判断是否需要调用动态调整权重函数。
template<typename T>
T Buffer<T>::dequeue() {
size_t i = 0;
int max_weight = -1;
size_t max_index = 0;
while (true) {
if (!queues[i].empty() && weights[i] > max_weight) {
max_weight = weights[i];
max_index = i;
}
if (i == buffer_count - 1) {
std::unique_lock<std::mutex> lock(*mutexes[max_index]);
not_empty_conds[max_index]->wait(lock, [this, max_index] { return !queues[max_index].empty(); });
T val = queues[max_index].front(); // 取出任务,并return
queues[max_index].pop();
dequeue_count++;
if (dequeue_count >= dequeue_threshold) { // 达到调整阈值,则进行权重调整
adjust_weights();
dequeue_count = 0; // 重置
}
not_full_conds[max_index]->notify_one(); // 通知生产者加任务
return val;
}
i = (i + 1) % buffer_count;
}
}
六、实现adjust_weights函数
通过动态调整权重函数,实现在自定义调整阈值下根据每个缓冲区的大小来更新每个缓冲区权重的功能。
自定义阈值是为了避免每次取出元素后都要进行调整带来的额外开销,用户可以根据实际情况自定义调整阈值。
该函数中使用一个循环来遍历所有的缓冲区,如果缓冲区的大小小于最大容量的20%,则增加该缓冲区的权重,生产者在未来会更有可能向该缓冲区添加元素;如果缓冲区的大小大于最大容量的80%,则减少该缓冲区的权重,生产者在未来会有更小可能向该缓冲区添加元素。这样可以使得缓冲区的利用率更高,避免溢出或忙等、空闲问题。
template<typename T>
void Buffer<T>::adjust_weights() {
for (int i = 0; i < buffer_count; ++i) {
if (queues[i].size() <= max_size * 0.2) weights[i] += 1;
if (queues[i].size() >= max_size * 0.8) weights[i] -= 1;
}
}
七、总结
- 使用了模板类来实现队列的泛型,提高了代码的复用性和可扩展性,可以支持任意类型的数据或任务。
- 使用了vector来存储多个缓冲区,提高了队列的灵活性和可配置性,可以动态调整缓冲区的数量和容量。
- 使用互斥锁mutex和条件变量condition_variable来保证线程安全,保证并发访问队列的数据一致性和同步性,避免数据竞争或死锁,提高队列的安全性和效率。
- 使用加权轮询来实现负载均衡,根据每个缓冲区的权重和容量来选择入队或出队的缓冲区,动态调整每个缓冲区的权重,使得缓冲区的利用率更高,避免溢出或忙等、空闲问题。