3、生产者-消费者模式:经典模式详解、相机数据流中的生产者与消费者、队列(Queue)的使用
3.1 经典模式:一个老掉牙但永远好用的套路
生产者-消费者模式,说白了就是「有人负责生产,有人负责消费」。你想想看,生活中到处都是这种场景——厨师做菜,服务员上菜;工厂造零件,工人组装。多线程里也一样。
我刚开始做嵌入式视觉那会儿,总觉得这模式太简单了,不就是两个线程嘛。后来踩了坑才明白,真正难的不是模式本身,而是怎么把数据安全地传递过去。
经典模式的核心就三个角色:
- 生产者:负责生成数据。比如相机驱动线程,一帧一帧地从传感器拿图像。
- 消费者:负责处理数据。比如算法线程,对图像做检测、识别、跟踪。
- 缓冲区:生产者和消费者之间的「中转站」。通常用队列实现。
为什么要加个缓冲区?直接传递不行吗?嗯,这里要注意——生产者和消费者的速度往往不一样。相机可能每秒30帧,但算法处理一帧可能要50毫秒。如果没有缓冲区,生产者就得等着消费者,整个流水线就卡死了。
核心原则:生产者不关心谁消费了数据,消费者也不关心数据从哪来。它们只跟队列打交道。
3.2 相机数据流中的生产者与消费者
在相机流水线里,生产者和消费者的角色非常清晰。我拿一个实际项目举例——我们做过一个工业检测系统,相机采集图像,然后做缺陷检测。
生产者是谁?
通常是相机驱动线程或者DMA(直接内存访问)回调函数。它负责:
- 从相机传感器读取原始图像数据
- 做一些必要的预处理(比如格式转换、裁剪)
- 把图像数据封装成一个帧对象,扔进队列
消费者是谁?
一般是算法处理线程或者显示线程。它负责:
- 从队列里取出图像帧
- 运行检测算法(比如边缘检测、模板匹配)
- 输出结果或者显示图像
我曾经在一个项目里犯过傻——让生产者直接调用消费者的处理函数。结果呢?生产者一帧还没处理完,下一帧就来了,整个系统乱成一锅粥。后来改成队列模式,问题立刻解决了。
我的经验:生产者尽量只做「搬砖」的工作——把数据准备好,扔进队列,然后立刻返回。别在生产者线程里做任何耗时操作,否则会阻塞相机采集。
3.3 队列(Queue)的使用:选对工具,事半功倍
队列是实现生产者-消费者模式的关键。C++标准库提供了 std::queue,但它不是线程安全的。你需要自己加锁,或者直接用现成的线程安全队列。
我个人习惯用 std::queue 配合 std::mutex 和 std::condition_variable。为什么?因为灵活。你可以控制锁的粒度,也可以加一些自定义逻辑,比如队列满时丢弃旧帧。
下面是一个我常用的线程安全队列模板:
#include <queue>
#include <mutex>
#include <condition_variable>
template<typename T>
class ThreadSafeQueue {
private:
std::queue<T> queue_;
mutable std::mutex mutex_;
std::condition_variable cond_;
size_t max_size_;
public:
explicit ThreadSafeQueue(size_t max_size = 10)
: max_size_(max_size) {}
// 生产者调用:往队列里放数据
void push(T value) {
std::lock_guard<std::mutex> lock(mutex_);
if (queue_.size() >= max_size_) {
// 队列满了,丢弃最旧的一帧
queue_.pop();
}
queue_.push(std::move(value));
cond_.notify_one();
}
// 消费者调用:从队列里取数据(阻塞等待)
T pop() {
std::unique_lock<std::mutex> lock(mutex_);
cond_.wait(lock, [this] { return !queue_.empty(); });
T value = std::move(queue_.front());
queue_.pop();
return value;
}
// 非阻塞尝试取数据
bool try_pop(T& value) {
std::lock_guard<std::mutex> lock(mutex_);
if (queue_.empty()) return false;
value = std::move(queue_.front());
queue_.pop();
return true;
}
bool empty() const {
std::lock_guard<std::mutex> lock(mutex_);
return queue_.empty();
}
size_t size() const {
std::lock_guard<std::mutex> lock(mutex_);
return queue_.size();
}
};
避坑指南:我曾经在队列满时直接阻塞生产者,结果相机驱动因为等太久超时报错。后来改成「丢弃旧帧」策略——队列满了就扔掉最老的一帧,保证生产者永远不阻塞。这个策略在实时性要求高的场景下特别好用。
3.4 队列大小的选择:不是越大越好
队列该设多大?这是个好问题。太小了容易丢帧,太大了浪费内存,还会增加延迟。
我一般按这个思路来:
| 场景 | 推荐队列大小 | 说明 |
|---|---|---|
| 实时显示 | 2~4帧 | 延迟敏感,队列越小越好 |
| 离线处理 | 10~30帧 | 可以缓冲,但别太大 |
| 高速采集 | 根据内存定 | 比如100帧,但要算好内存 |
举个例子,1080p的彩色图像,一帧大约6MB。如果队列设100帧,就是600MB内存。你想想看,嵌入式设备哪有那么多内存?所以队列大小一定要根据实际硬件来算。
3.5 多生产者多消费者:更复杂的场景
有时候一个生产者不够用。比如你有多个相机同时采集,或者一个相机采集多个通道的数据。这时候就需要多生产者。
同样,消费者也可能有多个。比如一个线程做检测,一个线程做显示,一个线程做存储。
多生产者多消费者的实现其实不复杂,核心还是那个线程安全队列。但要注意几点:
- 锁竞争:多个生产者同时push,锁的竞争会变激烈。可以考虑用无锁队列,或者分片队列。
- 数据标识:每个数据帧最好带一个时间戳或者ID,方便消费者区分来源。
- 负载均衡:多个消费者从同一个队列取数据,天然就是负载均衡的。谁空闲谁取。
我记得有一次做多相机同步,三个相机同时采集,一个算法线程处理。结果算法线程忙不过来,队列越积越多。后来改成三个队列,每个相机一个队列,三个算法线程分别处理,问题就解决了。
小技巧:如果多个消费者处理速度不一样,可以考虑用优先级队列——重要的帧先处理。比如检测到运动目标的帧优先级更高。
3.6 实战中的常见问题与解决方案
说了这么多理论,来点实际的。我在项目中遇到过几个典型问题:
- 队列溢出导致内存暴涨:生产者太快,消费者太慢。解决方案:设置最大队列大小,满了就丢帧。
- 消费者饿死:多个消费者竞争同一个队列,某个消费者一直抢不到数据。解决方案:用条件变量通知所有消费者,或者给每个消费者分配独立的队列。
- 死锁:生产者持有一个锁,等待消费者释放另一个锁;消费者也在等生产者。解决方案:避免嵌套锁,或者用超时机制。
- 数据拷贝开销大:每次push/pop都拷贝一帧图像,性能很差。解决方案:用智能指针或者移动语义,只传递指针。
我曾经在调试一个死锁问题时,花了整整两天。最后发现是生产者线程里调用了消费者的回调函数,而那个回调函数又试图加锁。嗯,从那以后我定了个规矩——生产者线程里绝对不调用任何消费者相关的函数。
3.7 总结:记住这三条就够了
生产者-消费者模式,说白了就是「解耦」。把数据生产和数据处理分开,让它们各干各的。我做了这么多年嵌入式视觉,这个模式几乎每个项目都用。
最后给你三条建议:
- 队列一定要线程安全:别偷懒用裸的std::queue,否则等着崩溃吧。
- 队列大小要有限制:别让生产者无限制地生产,内存是有限的。
- 生产者要轻量:只做数据搬运,别做任何耗时操作。
下一章我们会讲「流水线中的同步与异步」,到时候你会看到生产者-消费者模式怎么跟其他模式配合使用。嗯,敬请期待。