☰
香橙派RK3588双路视觉方案:每路独立线程池实现YOLOv5s稳定推理
2026/10/31 6:26:34 网站建设 项目流程

1. 双路视觉为什么不能简单开两个线程

很多人第一次做双路摄像头方案,直觉反应就是“开两个线程,一个线程管一路,各自跑各自的 YOLOv5s 不就行了”。我最早也是这么干的,结果跑起来才发现问题一大堆:两路推理互相抢 CPU 大核、帧率忽高忽低、偶尔还会因为某一帧处理太慢把整个采集队列拖垮。后来把架构改成“每路一个独立线程池”,才真正把双路视觉跑稳。

这篇内容围绕香橙派 RK3588 平台上的双路视觉方案展开,核心讲清楚一件事:为什么双路视觉要用“两路各一个线程池”的结构,以及这个结构具体怎么落地。关键词覆盖香橙派、RK3588、YOLOv5s、线程池、双路视觉。适合已经能在香橙派上跑通单路 YOLOv5s、现在想扩展到双路并发的朋友。如果你还没跑通单路,建议先把单路推理和 NPU 调用理顺,再来看双路,否则问题会混在一起很难定位。

先说结论:双路视觉的本质不是“两个线程”,而是两套独立的处理流水线。每套流水线内部有自己的采集、预处理、推理、后处理、结果输出环节,而这些环节如果共用一个线程池,就会出现任务互相排队、延迟不可控的问题。给每路配一个独立线程池,等于给每路一条专属车道,互不干扰,这才是双路能稳定跑起来的关键。

RK3588 这颗芯片本身是 8 核(4 个 A76 大核 + 4 个 A55 小核),带 6 TOPS 算力的 NPU,理论上同时喂两路 YOLOv5s 是够的。但“够”不等于“稳”,算力够只是前提,任务调度和资源隔离才是决定帧率稳定性的核心。下面我从架构设计讲到代码落地,把这条链路拆开讲透。

2. 单线程池跑双路会撞上哪些墙

2.1 任务排队导致的延迟雪崩

假设你用一个线程池,池里开 4 个 worker 线程,两路摄像头的采集帧都往同一个任务队列里塞。看起来 4 个 worker 能并行处理,但问题在于:队列是无差别的。A 路的一帧和 B 路的一帧在队列里没有优先级区分,谁先被 worker 取到完全看调度。

实际表现就是:A 路某一帧推理慢了 50ms,它占着一个 worker 不放,B 路的帧就在队列里干等。等 B 路积压到十几帧,延迟就从几十毫秒涨到几百毫秒。你去看画面,会发现 B 路明显“滞后”,动作和显示对不上。这不是算力不够,是任务没有隔离。

更麻烦的是,这种延迟是非线性累积的。一开始只慢一点点,队列一积压,延迟就像滚雪球一样涨上去,最后直接卡死。我实测过,单线程池跑双路 1080p,稳定运行大概 3 到 5 分钟后,其中一路的延迟会从 40ms 飙到 400ms 以上。

2.2 大核争抢与 NPU 上下文切换

RK3588 的 NPU 是通过 RKNN 运行时调用的。当你两路推理共用一个线程池时,多个 worker 可能同时去调 NPU,触发频繁的上下文切换。NPU 本身对上下文切换是有开销的,切换太频繁,有效算力利用率会明显下降。

同时 CPU 侧也乱套:预处理(resize、归一化)和后处理(NMS、坐标还原)都吃 CPU,多个 worker 抢大核,缓存命中率下降,单帧处理时间反而比单路时更长。我对比过数据,单线程池双路时,单帧平均处理时间比单路时多了约 35%,这就是资源争抢的代价。

2.3 故障无法隔离

还有一个容易被忽略的点:故障隔离。单线程池里,如果 A 路某一帧数据异常(比如摄像头抖动导致图像损坏),预处理阶段抛异常,可能把整个 worker 线程搞挂,连带影响 B 路。而双线程池结构下,A 路出问题只影响 A 路自己的池子,B 路完全不受影响。做产品级方案,这种隔离性是必须的。

3. 每路一个线程池的架构拆解

3.1 整体数据流设计

双路线程池架构的核心思想是:每路视觉是一条独立的流水线,流水线内部用线程池并行化各阶段任务。整体数据流是这样的:

  • 摄像头采集线程:每路一个独立采集线程,负责从 MIPI 或 USB 摄像头取帧,取到后封装成任务投递到本路线程池的队列。
  • 本路线程池:池内包含预处理、推理、后处理三类任务,worker 从本路队列取任务执行。
  • 结果输出:后处理完成后,把带框的结果送到显示或编码模块。

关键在于,A 路的队列只服务 A 路,B 路的队列只服务 B 路。两个池子物理隔离,互不抢任务。

3.2 线程池参数怎么定

线程池的核心参数是 worker 数量和队列容量。这两个值不能拍脑袋定,要根据 RK3588 的核数和单帧处理耗时来算。

先说 worker 数量。RK3588 有 4 个大核,双路情况下,建议每路池子分配 2 个 worker,这样两路加起来正好占满 4 个大核,小核留给系统和其他任务。如果你给每路开 4 个 worker,两路就是 8 个,会严重超配,反而因为上下文切换导致性能下降。

再说队列容量。队列不能无限大,否则延迟会无上限累积。我的经验值是队列容量设为 3 到 5。为什么是这个数?因为单帧处理时间大约 30 到 50ms,队列里积压 3 帧就是 90 到 150ms 的延迟,这已经是可接受的上限了。超过这个数,说明处理速度跟不上采集速度,应该丢帧而不是继续积压。

参数建议值说明
每路 worker 数2两路共占 4 个大核
队列容量3~5控制最大延迟
队列满策略丢弃最旧帧保证实时性
任务类型预处理/推理/后处理分阶段投递

3.3 队列满时的丢弃策略

队列满了怎么办?这是双路视觉必须回答的问题。有三种策略:阻塞等待、丢弃最新帧、丢弃最旧帧。

阻塞等待绝对不能用,采集线程一阻塞,摄像头缓冲区就会溢出,直接丢帧还不可控。丢弃最新帧会导致画面“卡在旧帧”,延迟越来越大。正确做法是丢弃最旧帧,也就是队列满时,把队头那一帧扔掉,把新帧塞进去。这样保证队列里永远是最新的几帧,延迟可控。

这个策略在代码里实现很简单:投递任务前判断队列是否已满,满了就 pop 掉队头再 push。用 C++ 的std::deque配合互斥锁就能做,后面代码部分会给示例。

4. 线程池的阻塞队列选型与实现细节

4.1 为什么不用无锁队列

一提到高性能队列,很多人第一反应是无锁队列(lock-free queue)。但在双路视觉这个场景,我不建议用无锁队列,原因有两个。

第一,无锁队列的实现复杂度高,容易出隐蔽的 bug,比如 ABA 问题、内存序问题,调试成本极高。第二,双路视觉的任务投递频率其实不高,每路每秒也就 20 到 30 帧,每帧投递 3 个任务,总共每秒不到 100 次入队出队。这个频率下,互斥锁的开销完全可以忽略,用无锁队列属于过度设计。

我用的是std::deque+std::mutex+std::condition_variable的经典组合,稳定可靠,代码也好维护。

4.2 条件变量的正确用法

条件变量是线程池里最容易用错的地方。常见错误是 worker 线程用if判断队列非空,而不是while。这会导致虚假唤醒问题:条件变量被唤醒时,队列可能还是空的,worker 直接去取任务就会取到空,程序崩溃。

正确写法是 worker 等待时用while循环判断:

std::unique_lock<std::mutex> lock(mtx); while (queue.empty() && !stop) { cond.wait(lock); } if (stop && queue.empty()) return; auto task = queue.front(); queue.pop_front();

这个while不是可有可无的,它是防止虚假唤醒的关键。我见过太多人在这里踩坑,程序跑着跑着就 segfault,查半天查不出来。

4.3 停止线程池的优雅方式

线程池停止时,不能直接detach或者强杀线程,否则正在处理的任务会丢,NPU 上下文可能没释放干净。正确做法是设置一个stop标志,然后notify_all唤醒所有 worker,让它们把队列里剩余任务处理完再退出。

void stop() { { std::unique_lock<std::mutex> lock(mtx); stop = true; } cond.notify_all(); for (auto& t : workers) { if (t.joinable()) t.join(); } }

注意stop标志的读写要加锁,或者用std::atomic<bool>。我一般用 atomic,省得每次判断都加锁。

5. 双路线程池的代码落地

5.1 线程池类的完整实现

先给一个精简但完整的线程池实现,可以直接拿去用:

#include <deque> #include <functional> #include <mutex> #include <condition_variable> #include <thread> #include <vector> #include <atomic> class ThreadPool { public: using Task = std::function<void()>; ThreadPool(size_t workerNum, size_t maxQueueSize) : maxQueueSize_(maxQueueSize), stop_(false) { for (size_t i = 0; i < workerNum; ++i) { workers_.emplace_back([this] { workerLoop(); }); } } ~ThreadPool() { stop(); } bool post(Task task) { { std::unique_lock<std::mutex> lock(mtx_); if (stop_) return false; if (queue_.size() >= maxQueueSize_) { queue_.pop_front(); // 丢弃最旧帧 } queue_.emplace_back(std::move(task)); } cond_.notify_one(); return true; } void stop() { { std::unique_lock<std::mutex> lock(mtx_); if (stop_) return; stop_ = true; } cond_.notify_all(); for (auto& t : workers_) { if (t.joinable()) t.join(); } } private: void workerLoop() { while (true) { Task task; { std::unique_lock<std::mutex> lock(mtx_); cond_.wait(lock, [this] { return stop_ || !queue_.empty(); }); if (stop_ && queue_.empty()) return; task = std::move(queue_.front()); queue_.pop_front(); } task(); } } std::deque<Task> queue_; std::vector<std::thread> workers_; std::mutex mtx_; std::condition_variable cond_; size_t maxQueueSize_; std::atomic<bool> stop_; };

这个类有两个关键点:post里队列满时丢弃最旧帧,workerLoop里用wait带谓词的写法避免虚假唤醒。谓词写法比手动while更简洁,效果一样。

5.2 两路流水线的初始化

初始化时创建两个独立的线程池,每路一个:

ThreadPool poolA(2, 4); // A 路:2 个 worker,队列容量 4 ThreadPool poolB(2, 4); // B 路:2 个 worker,队列容量 4

然后每路启动一个采集线程,采集线程里把帧投递到对应的池子:

void captureLoop(int camId, ThreadPool& pool) { cv::VideoCapture cap(camId); while (running) { cv::Mat frame; if (!cap.read(frame)) continue; // 拷贝一份,避免下一帧覆盖 auto framePtr = std::make_shared<cv::Mat>(frame.clone()); pool.post([framePtr, &pool] { preprocessAndInfer(framePtr, pool); }); } }

注意这里frame.clone()是必须的。cap.read返回的frame在下一帧会被覆盖,如果不拷贝,投递到队列里的帧数据会被改掉,推理结果就乱了。这个坑我踩过,画面框位置全错,查了好久才发现是没拷贝。

5.3 推理任务的分阶段投递

预处理、推理、后处理可以拆成三个阶段分别投递,也可以合成一个任务。我建议合成一个任务,因为拆太细会增加队列操作次数,收益不大。一个任务里顺序执行预处理、推理、后处理,逻辑清晰,也够用。

void preprocessAndInfer(std::shared_ptr<cv::Mat> frame, ThreadPool& pool) { // 预处理 cv::Mat input; cv::resize(*frame, input, cv::Size(640, 640)); // 推理(RKNN) auto outputs = rknnInfer(input); // 后处理 auto boxes = postprocess(outputs); // 绘制结果 drawBoxes(*frame, boxes); // 送显示 display(frame); }

如果后处理特别耗时(比如 NMS 很重),可以把后处理单独投递到池子里,让 worker 并行处理。但大多数情况下,YOLOv5s 的后处理没那么重,合成一个任务就够了。

6. 实测数据与调优经验

6.1 单池 vs 双池的帧率对比

我在香橙派 RK3588 上实测了两组数据,输入都是 1080p,模型是 YOLOv5s(RKNN 量化版),NPU 三核全开。

方案A 路帧率B 路帧率平均延迟延迟抖动
单线程池(4 worker)18~25 fps15~22 fps120ms±80ms
双线程池(各 2 worker)24~26 fps24~26 fps45ms±10ms

数据很直观:双线程池方案下,两路帧率都稳定在 24 到 26 fps,延迟从 120ms 降到 45ms,抖动从 ±80ms 降到 ±10ms。这个提升不是一点点,是质的区别。

6.2 worker 数量的调优过程

worker 数量不是拍脑袋定的,我做了对比测试。每路 1 个 worker 时,帧率只有 15 fps 左右,因为单 worker 串行处理,预处理和推理没法重叠。每路 2 个 worker 时,帧率涨到 25 fps,因为一个 worker 在推理时,另一个可以处理下一帧的预处理。每路 3 个 worker 时,帧率反而降到 22 fps,因为 6 个 worker 抢 4 个大核,上下文切换开销上来了。

所以每路 2 个 worker 是最优解,这个结论和 RK3588 的 4 大核结构是吻合的。

6.3 队列容量的影响

队列容量我也测了几组。容量为 1 时,帧率不稳定,因为稍微一波动就丢帧。容量为 3 时,帧率稳定,延迟 45ms 左右。容量为 10 时,帧率看着稳定,但延迟涨到 200ms 以上,因为积压的帧太多。

所以队列容量 3 到 5 是甜点区,既吸收抖动,又不累积延迟。我最终用的是 4。

7. 踩过的坑与排查思路

7.1 画面撕裂与帧错位

最早跑双路时,发现 B 路画面偶尔出现撕裂,框的位置和物体对不上。排查了半天,发现是采集线程里frame没拷贝,投递到队列的是引用,下一帧采集时把数据覆盖了。改成clone()后问题消失。

这个坑的排查思路是:先怀疑数据竞争。双路场景下,任何共享的 buffer 都要警惕。如果画面错位是偶发的,大概率是数据被并发修改了。

7.2 NPU 推理偶发失败

跑一段时间后,NPU 推理偶尔返回错误码。查 RKNN 文档发现,多个线程同时调rknn_run时,如果没做上下文隔离,会冲突。解决办法是每路用独立的 RKNN context,不要共用一个 context。虽然模型是同一个,但 context 要分开创建。

rknn_context ctxA, ctxB; rknn_init(&ctxA, modelData, modelSize, 0, nullptr); rknn_init(&ctxB, modelData, modelSize, 0, nullptr);

这样两路推理完全隔离,互不干扰。

7.3 线程池停止时的卡死

程序退出时偶尔卡死,查出来是stop()里join之前没唤醒 worker,worker 还在wait上挂着。加上cond_.notify_all()后解决。这个坑的教训是:任何让 worker 退出的操作,都必须先唤醒它。

7.4 内存泄漏排查

长时间跑(几小时)后内存缓慢上涨。用valgrind查出来是cv::Mat没释放干净。原因是投递任务时用了裸指针,任务执行完没 delete。改成shared_ptr后,引用计数自动管理,内存稳定。

提示:双路视觉里所有跨线程传递的图像数据,一律用shared_ptr包装,不要用裸指针,这是避免内存问题的最有效手段。

8. 双路方案的扩展方向

这套双路线程池结构跑通后,扩展起来很自然。比如你想加第三路,只需要再创建一个线程池,采集线程投递到新池子即可,前两路完全不受影响。这就是隔离架构的好处。

如果后续要换更强的模型,比如把 YOLOv5s 换成更大的模型,单帧处理时间变长,这时候要重新算 worker 数量和队列容量。原则不变:worker 总数不超过大核数,队列容量控制在延迟可接受范围内。

另外,RK3588 的 NPU 支持多核并行,如果你的 RKNN 版本支持核心绑定,可以把 A 路绑到 NPU 核心 0,B 路绑到核心 1,进一步减少 NPU 侧的争抢。这个我还在测试,初步看帧率能再提升 10% 左右,等数据稳定了再单独写一篇。

最后分享一个我调这套方案时的小习惯:每次改完参数,先跑 10 分钟压力测试,看延迟抖动曲线。帧率平均值好看没用,抖动才是双路视觉的命门。抖动控制在 ±15ms 以内,这套方案才算真正稳了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询