从单线程到多线程的视频处理,这个题我琢磨了很久。起因是一个再普通不过的安防监控项目:一路1080P摄像头,OpenCV读帧,简单预处理后做人脸检测,再把结果叠加到画面上实时显示。单线程跑下来,CPU占用一直在90%上下跳动,帧率只有可怜的12到15帧,画面一卡一卡的,检测框还总追着人跑。我把检测算法换了又换,调参调到怀疑人生,最后才意识到问题根本不在算法精度,而在整个处理链路的结构设计上——所有工作都在一个线程里排队等着,读帧的等待时间、算法的计算时间、显示的刷新时间全都串在一起,互相拖后腿。这算是OpenCV多线程编程的入门第一课。
把视频处理从单线程改成多线程,很多人第一反应是"加线程数不就完事了",实际动手才发现坑比想象中多:OpenCV的Mat到底能不能安全跨线程传、队列里该放什么、多个线程同时读同一个VideoCapture会不会崩、为什么线程多了帧率没涨延迟却翻倍。这篇文章我想把从单线程到多线程这条路上的架构设计、代码落地、实测数据和踩坑过程全部捋一遍,给正在做实时视频处理的朋友一个可以直接参考的路线图。
1. 单线程视频处理卡在哪:从读帧到前端显示的完整链路拆解
1.1 视频处理链路的基本盘:看似简单的一条流水线
一个典型的实时视频处理程序,表面上看就是在循环里做四件事:读取一帧、预处理、跑算法、渲染显示。写成OpenCV代码也就十几行:
cpp复制while (true) {
Mat frame;
if (!capture.read(frame)) break; // 1. 读帧
Mat processed = preprocess(frame); // 2. 预处理
Mat result = detector.detect(processed); // 3. 核心算法
imshow("result", result); // 4. 显示
if (waitKey(1) >= 0) break;
}
这个循环看起来清爽,但每一行背后的耗时完全不同。读帧这一步是IO密集型操作,摄像头的帧率是独立的,比如USB摄像头标称30帧,但USB传输、驱动缓冲、解码这些环节会产生不稳定延迟,read()经常在这里卡住几十毫秒。imshow配合waitKey又会和操作系统的窗口事件队列纠缠,在高DPI或GPU加速环境下延迟更不可控。预处理和目标检测则是纯粹的CPU计算密集型任务,模型越大,这里越是一堵墙。
我做过一个简单的耗时统计:一段720P视频,每帧读帧平均16毫秒,预处理(转灰度、缩放、高斯模糊)7毫秒,检测算法平均28毫秒,显示叠加要6毫秒。单轮循环总耗时57毫秒,理论最高帧率17.5帧每秒,而视频源本来有25帧每秒。也就是说,算法计算时间占了整个链路的一半,读帧的抖动又占了三成,两者一叠加,实时性基本报废。这个瓶颈不是靠优化某一环节能解决的,因为每一环都在等上一环的结果,整个链路是严格串行的。
1.2 单线程的致命瓶颈:串行等待与CPU核心闲置
单线程视频处理最大的浪费在于CPU资源完全没利用起来。现在随便一台机器都是四核八线程起步,但单线程程序跑起来,只有可怜的一个核心在忙。我做性能分析时观察更扎心:检测算法在计算时,视频帧还在驱动缓冲区里等着,摄像头那边的新帧已经准备好甚至被丢掉了;等算法算完,才去取下一帧,循环往复,一个核心忙到冒烟,其他核心全部在看戏。
延迟叠加的数学很直白。假设帧间隔T(30帧就是33ms),单帧处理总耗时P,只要P大于T,每帧都会欠着处理时间,积压越来越多。实际表现就是延迟从几十毫秒慢慢涨到几百毫秒,画面动作和实际动作明显脱节。这就是我们说的"帧堆积"——不是算法算得太慢的瞬间问题,而是整个流水线结构上就注定了会这样。
还有一个很多人没意识到的问题:VideoCapture的read()本身不是线程安全的。在单线程下没问题,但如果你想在程序另一个线程里去"偷看"最新帧,或者清理缓冲,极可能读到不完整的数据。这是OpenCV底层很多历史包袱决定的,后面踩坑章节具体说。单线程环境里你没法优雅地解决读帧抖动,只能把read()放在一切工作的开头硬等,这个等待时间就白白浪费了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 多线程架构选型:生产者-消费者模型与三线程流水线设计
2.1 为什么必须把"读帧"和"处理"拆开
既然瓶颈在于串行等待,最直接的思路就是把链路切成独立环节,让它们各跑各的线程。视频处理里最经典的做法是把"生产帧"和"消费帧"彻底分开,也就是生产者-消费者模型。
读帧环节是天然的缓慢生产者:摄像头的帧是外部设备按固定节奏吐出来的,不是你想要就有。你在线程A里循环read(),读到的帧丢进一个队列,立刻回头读下一帧。处理环节是消费者:线程B从队列里取帧,做预处理和算法,做完把结果交给显示线程C。这样设计的好处,用大白话说就是——读帧的同学只负责拿货,货到了就往传送带上放,不用管后面有没有人接;处理算法的同学也不用干等着拿货,传送带上有料就开工,干完了继续取下一件。
拆开之后再算一笔账:读帧平均16毫秒但会抖动到40毫秒,算法需要28毫秒,显示需要6毫秒。三个线程并行跑,理论上每帧总耗时就是最慢环节的耗时而不是三者相加。如果读帧和算法都在30毫秒上下,总帧率就能从之前的17帧跳到30帧左右,几乎翻倍。这个提升不是来自算法优化,而是架构优化,成本只是加两个线程和一块缓冲区。
2.2 三线程流水线:读帧线程、处理线程、显示线程的职责边界
具体到视频处理,我建议直接上三线程:捕获线程、处理线程、输出线程,而不是笼统的"两个线程搞定"。三个线程的职责必须非常清晰,越界是后期一切混乱的根源。
捕获线程只负责一件事:从VideoCapture里read()帧,然后把帧放进输入队列,继续下一轮。它绝不能碰算法,也不要碰UI。这个线程唯一的退出条件是队列满了(如果你设置了有界队列)或者程序收到终止信号。处理线程是核心,它从输入队列取帧,做预处理,跑模型,把结果Mat放进输出队列。显示线程最轻松,从输出队列取结果,imshow、写视频文件、或者推流,做完立刻回到队列取下一份。
为什么显示还要单独一个线程?因为imshow和窗口系统绑定,操作系统的消息循环、窗口刷新的节奏和你程序的算法节奏完全不在一个拍子上。如果你在算法线程里顺手调用imshow,轻则卡顿,重则窗口直接假死。写入文件也一样,视频编码器的缓冲和磁盘IO都可能阻塞,不应该让算法线程去等一次磁盘写完成。保持"读帧不问处理、处理不问渲染"的边界,每个线程只干一件事,出问题时也最容易定位。
2.3 队列缓冲区的容量与丢帧策略:调参的第一步
队列在这套架构里是核心缓冲器件,它的容量直接影响整个系统的行为特征,这里要专门说道说道。
无界队列最省事,push永远不会失败,但副作用是"生产过快、消费过慢"时队列无限增长,内存越吃越多,延迟越积越大。实时视频处理场景我强烈建议用有界队列,容量通常用2到4帧就够。为什么这么小?因为队列本质上是允许消费端暂时慢半拍的缓冲,不是用来囤积的仓库。4帧的缓冲意味着消费端可以落后最多4帧,换成时间是133毫秒(25帧源),这是比较合理的延迟补偿。
队列满了怎么办,这是设计决策的分水岭。我见过很多方案:"丢掉最旧的一帧"和"阻塞等待",后者只在特殊场景下用。实时交互系统对延迟极其敏感,宁可跳帧也不能让自己处理到"过期"的数据——你检测一个0.2秒以前的人脸动作,画面反馈已经严重滞后了。丢旧帧策略用环形缓冲很顺手:队列满了就覆盖最旧的数据,消费者永远拿到的是"最新可用"的帧,系统表现就是偶尔跳帧,但延迟始终被压住。离线处理视频文件则反过来,通常希望每一帧都被处理到,那队列就不应该丢帧,反而要足够大甚至无界,或者让生产端等待。
3. 三种多线程实现方案的对比与代码落地
3.1 C++ std::thread + 共享队列:最推荐的生产环境方案
OpenCV的C++接口配合std::thread是我在生产项目里最常用的组合,性能和可控性平衡得最好。核心实现非常简短,一个带锁和条件变量的线程安全队列,加上三个线程的循环体即可。
线程安全队列的标准写法是互斥锁加锁和条件变量配合:
cpp复制template <typename T>
class SafeQueue {
private:
std::queue<T> queue_;
std::mutex mutex_;
std::condition_variable cv_;
size_t capacity_;
public:
explicit SafeQueue(size_t capacity) : capacity_(capacity) {}
bool tryPush(const T& value) {
std::lock_guard<std::mutex> lock(mutex_);
if (queue_.size() >= capacity_) return false;
queue_.push(value);
cv_.notify_one();
return true;
}
bool pop(T& value) {
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this] { return !queue_.empty(); });
value = std::move(queue_.front());
queue_.pop();
return true;
}
void clear() {
std::lock_guard<std::mutex> lock(mutex_);
while (!queue_.empty()) queue_.pop();
}
};
捕获线程往里丢帧,处理线程消费队列,就这么简单。关键点是tryPush在队列满时直接丢弃新帧——这正是前面说的实时丢帧策略。处理线程用阻塞式pop,队列为空就睡过去等待唤醒,不浪费CPU。
cpp复制SafeQueue<Mat> inputQueue(4);
// 捕获线程
void captureLoop(VideoCapture& cap) {
Mat frame;
while (running) {
if (!cap.read(frame)) break;
if (!inputQueue.tryPush(frame)) {
// 队列满,该帧直接丢弃
}
}
}
// 处理线程
void processLoop() {
Mat frame;
while (running) {
if (!inputQueue.pop(frame)) continue;
Mat result = doAlgorithm(frame);
outputQueue.tryPush(result);
}
}
这里有个性能细节:尽量直接投递Mat对象而不是指针或shared_ptr,因为Mat对象本身就是引用计数管理内存的,拷贝构造只增加引用计数而不复制图像数据,栈上传值基本没有开销。真需要显式复制的场景后面单独说。
3.2 Python threading + queue:GIL让你加线程但没加速度
很多朋友用Python写OpenCV,因为算法调试方便,numpy和OpenCV的binding很顺手。但Python里做多线程视频处理有一个绕不开的坎:GIL全局解释器锁。简单说,Python字节码在同一时刻只能跑在一个CPU核心上,你开10个线程,本质上还是一个一个轮流执行的,纯CPU计算场景下开线程并不能让多个核心同时算。
那Python是不是完全不能搞多线程视频处理了?不是,但要用对地方。GIL的调度粒度比很多人想象的小,读帧写队列时频繁发生IO等待,元组传参、队列操作都会释放GIL,让其他线程有机会执行。所以用"捕获线程只管read和入队、处理线程只管算法"这样的IO和CPU分离结构,在Python里依然是有效的,只是提升幅度比C++小得多,通常能把卡顿感降下来,但CPU利用率上不去。
Python的Threading方案我试过的最简写法如下。注意用queue.Queue,它本身是线程安全的,不需要自己加锁:
python复制import threading
import queue
import cv2
class CaptureThread(threading.Thread):
def __init__(self, src, cap_queue):
super().__init__()
self.src = src
self.cap_queue = cap_queue
self.running = True
def run(self):
cap = cv2.VideoCapture(self.src)
while self.running:
ret, frame = cap.read()
if not ret:
break
if not self.cap_queue.full():
self.cap_queue.put(frame)
class ProcessThread(threading.Thread):
def __init__(self, cap_queue, out_queue):
super().__init__()
self.cap_queue = cap_queue
self.out_queue = out_queue
self.running = True
def run(self):
while self.running:
frame = self.cap_queue.get()
# 处理逻辑:灰度化、缩放、检测、画框
result = frame
self.out_queue.put(result)
GIL背景下这套架构的真实定位是"解决卡顿和职责分离",不是"榨干多核"。如果你的Python OpenCV项目对算力要求极高,终极方案是multiprocessing多进程,或者把重计算下沉到C++扩展——我在第3.3节展开。
3.3 multiprocessing多进程:绕开GIL但代价不低
Python里想要真正并行利用多核,multiprocessing多进程是绕开GIL的手段之一。每个进程有自己独立的Python解释器和内存空间,没有锁限制,多核能真正同时干活。配合OpenCV使用,进程间传递图像一般有两种姿势。
姿势一:图像编码后走multiprocessing.Queue传递。把Mat先imencode成二进制字节流,塞进队列,接收端再imdecode还原。这种方式跨进程传输的是字节数组,pickle序列化也友好。代价是编解码往返的CPU开销,720P帧encode一次大约要4到6毫秒,两进程就要双份,这个开销在某些帧率要求下不能接受。
姿势二:共享内存方案,比如用multiprocessing.RawArray分配一段内存,把图像数据按行拷贝进去,辅以Lock或Event同步。性能好但没有对象封装,写起来又回到C风格内存管理,极易踩越界。我自己只在推流转发这种纯转储场景用过,图像处理场景还是解码传字节更省心。
多方权衡后,我的建议是:轻量级需求用C++ std::thread模型,算法重且离不开Python生态就上多进程;如果算法本身能在C++侧完成,就别让Python在里面当瓶颈。
4. 线程安全与共享队列:OpenCV Mat在并发下的真实表现
4.1 浅拷贝、深拷贝与引用计数:clone()为什么不能乱用
Mat是OpenCV里最核心的数据结构,它的内存管理是基于引用计数的。Mat对象头里有一个指针指向图像数据区,同时维护一个引用计数器。用Mat b = a做拷贝构造,只复制了对象头、增加引用计数,图像数据是共享的,修改b会直接影响a。这样设计非常好用,但也埋下了多线程问题的种子。
当你把一个Mat放进队列传递时,拷贝的是对象头,图像数据还是同一块,消费者和线程安全依赖于引用计数在两个线程间的原子操作。OpenCV的引用计数操作本身有内部保护,但请注意:这个保护只保证"计数"正确,不保证"图像数据"在并发读写时安全。也就是说,两个线程同时读同一个Mat没问题,一个线程写、一个线程读就会出花屏甚至崩溃——这就是很多人说"多线程里Mat不能乱传"的根本原因。
需要真正独立的图像副本时,用clone()或copyTo()做深拷贝,复制整块图像数据。但这东西的CPU开销不小,一帧1080P的clone要好几毫秒。我的经验是:只在必须修改帧数据的边界处使用深拷贝。比如处理线程拿到帧后要做原地绘制(画矩形、写字),而捕获线程或显示线程可能还要用原图,这种情况必须clone。如果算法本身就是读帧、输出新帧,各环节不会同时写同一块数据,那就别clone,否则多线程省下的性能全浪费在拷贝上了。
4.2 队列里到底该放Mat、帧路径还是帧ID:场景决定成本
线程间传递数据用什么"载体",比你想象的影响更大。三种做法各有适用场景:
第一种,直接传Mat对象。这是视频处理最简单自然的方案,开销最小,但要求全链路不修改原始帧内容,或者改之前先clone。适合预处理只是在帧上加一个不影响像素内容的算子引用(画框在复制品上做时就得注意)。
第二种,传帧路径或者帧ID。这在处理一段视频文件时很有效:生产者不读帧,只把视频帧的时间戳或路径丢进队列,消费者自行打开文件,用cap.set(CAP_PROP_POS_FRAMES, id)跳到指定帧去读。队列里传输的是整数,代价极低,但第二次读取需要重新定位和解码,随机访问视频帧的效率并不稳定,尤其是压缩率高的H.264视频,seek不是按帧精确的。
第三种,传共享内存句柄或GPU显存对象。传输的是轻量级的句柄和元数据,实际图像数据留在共享内存或显存里。GPU加速场景(CUDA、OpenCL)下比较推荐,显存里的GpuMat对象在多个CUDA流之间复用,代价小且不用来回在CPU和GPU之间搬运。前提是消费者的CUDA上下文能正确访问生产者传入的显存对象,这个在复杂OpenCV版本组合下偶尔会有惊悚的画面。
4.3 有锁队列和无锁队列:什么时候值得上无锁版本
前面SafeQueue用的mutex和condition_variable是有锁方案,绝大多数场景完全够用。有人追求极致性能去搞无锁队列,用原子操作和内存序来管理环形缓冲区。我的态度是:先用有锁队列跑通并测出性能,离硬实时要求还有差距再考虑无锁。
有锁队列在低竞争下开销很小,加一次锁的成本大概20到50纳秒,和视频帧几毫秒的处理时长比几乎可以忽略。真正的开销来自两件事:一是消费者等待条件变量时的上下文切换,二是锁竞争激烈时多个线程排队等锁。前者本来就是我们想要的"队列空就睡觉"的节流,后者只有队列容量过小、生产消费节奏剧烈波动时才严重。
无锁队列适合的视频处理场景是:帧率极高(几百帧)、单帧计算极短(小于几百微秒)、锁等待时间可能超过单帧处理时间。常见的实现有boost::lockfree::spsc_queue,一个生产一个消费的设计循环数组队列。我之前在高速线阵相机项目里用过一次,每秒帧率上千,单帧预处理不到1毫秒,有锁队列的等待开销确实明显。普通摄像头视频处理完全不必折腾无锁,把心思放在队列容量和丢帧策略上,收益大得多。
5. 实测数据对比与性能调优:帧率、延迟和CPU占用一起来看
5.1 三种场景实测:瓶颈到底在解码、算法还是显示
为了把"什么状态下值得改成多线程、改成多线程能提升多少"说清楚,我在一个双核四线程的普通办公机(核显,无独显)上做了三组对照实验,统一用同一段1080P视频源,算法统一用一个中等复杂度的HOG行人检测(比人脸检测更吃CPU),结果整理如下:
| 场景 | 单线程帧率 | 三线程帧率 | 提升 | 单线程CPU占用 | 三线程CPU占用 | 瓶颈类型 |
|---|---|---|---|---|---|---|
| 仅解码显示 | 30 FPS | 30 FPS | 0% | 35% | 36% | 帧源已满速,无提升空间 |
| 解码+轻量处理(高斯模糊) | 25 FPS | 30 FPS | 20% | 62% | 83% | 处理基本无压力,瓶颈在读帧等待 |
| 解码+行人检测 | 11 FPS | 23 FPS | 109% | 78% | 89% | 算法耗时长,单线程严重拖累 |
第二列到第三列是关键。解码+行人检测的场景,单线程只能跑11帧,CPU占用却已经78%,看着像"CPU很忙",其实忙的是单个核心硬扛所有工作;改成三线程后帧率翻倍多,CPU占用只涨到89%,说明多核开始真正分担工作了。
注意第一行那种情况:源端本身就30帧,单线程已经能满速处理,线程拆分后帧率不会提升,反而因为线程切换和队列开销可能略降。这类场景就别折腾多线程了,把时间花在别处更有价值。做多线程改造前先测出你单线程循环里各环节的耗时占比,链路中没有"等待型瓶颈"的时候,多线程没有收益。
5.2 帧率上去了,延迟为什么反而变大
很多人改完多线程后发现帧率高了不少,但画面动作的跟手程度变差了,甚至误以为代码改坏了。这其实是队列模型带来的一个经典trade-off:帧率和延迟不是同涨同跌的关系。
单线程世界里,一帧从头到尾只经过一次"等待+处理",延迟基本等于单帧处理时间加一点点排队缓冲。多线程架构里,帧生产出来后要先进队列等消费者来取,消费者算完再进第二个队列等显示线程取。队列的每一层都是一个排队环节。队列缓冲4帧,延迟理论增加为"4帧的帧间隔时间",25帧源就是大约160毫秒的额外延迟,相当于从摄像头采集到画面显示明显慢了一拍。
实时交互场景(比如远程控制、AR交互)一般要求端到端延迟在100毫秒以内,这时候宁可牺牲一点帧率,把队列容量压到2,用稍微丢帧换取更即时的响应。离线分析、视频转码这类不在乎延迟只求吞吐量的任务,队列可以加深到8甚至16,让算法吃饱。调队列容量要在帧率和延迟之间反复试,参考你要交付的场景,而不是盯着某一个指标。
5.3 调优参数清单:队列深度、超时与非核心绑定的取舍
我列一个实测下来最有效的调优顺序,从零成本到高成本排:
- 队列深度。每个队列独立设置,输入队列和输出队列不需要一样大。捕获到处理的队列建议3到5帧,处理到显示的队列通常2帧就够,因为显示线程消费很快,队列深了只是堆积旧数据。
- 长时间阻塞退出。处理线程的pop条件变量等待没有超时,一旦程序要退出,得靠running标志加cv_.notify_all()唤醒。别裸写while(true)靠break跑,退出逻辑会很难写。
- 线程优先级和绑核。Linux上可以用pthread_setaffinity_np把捕获线程、处理线程、显示线程分别绑到不同物理核上。实测在超线程机器上,把处理线程绑到一个物理核心,显示线程绑到另一个超线程核心,帧率还有10%左右的提升。捕获线程不需要单独绑核,它更多在等待IO。
- 显存后处理。如果你有CUDA或OpenCL,把预处理和检测搬到GPU上,CPU线程只做读取和推送,这本质上是把算法瓶颈整体移除了。注意开启GPU后队列深度要适当加大,因为GPU的异步计算会让生产者远远跑在消费者前面。
- 开启多帧并行。严格说这是"消费者内部再并行":处理线程里把取到的帧丢进一个线程池,multi-threaded detection同时处理多个尺度的检测块。好处是吞吐量进一步上升,代价是帧之间的顺序性变弱,结果帧对不上输入帧的风险增加。需要为每个处理任务打帧ID,显示线程按ID丢弃错序结果。
这些调优不是玄学,每一步都有清晰的性价比。按照队列深度→退出机制→绑核→GPU→多帧并行的顺序一步步来,每一步都有明确的验证方法:跑一个固定时长的视频源,统计平均帧率和P90延迟。
6. 踩坑实录:从卡顿、花屏到崩溃的问题排查链路
6.1 诡异花屏与Mat引用计数的陷阱
第一次跑三线程版本,画面时不时出现条纹状花屏,尤其高负载时严重。当时第一反应是摄像头坏了,换个源也一样,才意识到是多线程问题。排查过程从崩溃思路转向数据一致性思路后,很快定位到问题:处理线程在拿到Mat后调用了frame.convertTo()或者rectangle()这类会改变图像内容的原地操作,而捕获线程正准备把同一块内存再次用于read(),数据竞争导致花屏。
修复方案是区分"只有消费者看"和"多个消费者看"两种情况。处理线程输出的Mat往输出队列丢的时候,显示线程拿到后要做画框叠加,而这份结果可能还有其他模块要读,于是显示线程做了一次深浅拷贝决策:画框之前的处理在拷贝上进行,原始结果帧直接进队列,把拷贝释放。实际修起来也很简单,就是明确一个原则:从队列取出的Mat默认以只读方式使用,任何需要写图像的模块先clone一份再动手。
6.2 延迟无限增长的元凶:队列积压与慢生产者
另一个高频问题:程序跑半小时后画面延迟肉眼可见地越来越大,最后基本变成慢放。刚看到这个现象时以为是内存泄漏,用valgrind扫了半天没发现明显泄漏,后来打印队列占用率才发现,处理线程的耗时在运行过程中逐渐变慢,从最初的25毫秒慢慢涨到70毫秒,输入队列从空慢慢涨到满,然后持续丢帧。
根因是算法对象内部维护的临时缓冲区随着视频内容复杂度动态扩容,这是某第三方检测库的老毛病。查出来之后解决方案是定期裁剪内部缓存,更治本的方法是把处理线程里面的检测对象设计成"固定容量":预先分配好最大需要的缓冲区,在循环内只重填不复扩张。这个教训让我养成了时常在代码里打点看队列长度的习惯,队列积压是推迟发生的性能问题最灵敏的报警器。
6.3 一次隐蔽的内存泄漏:为什么队列清了,内存还涨
前面说的延迟增长排查过程中,还顺手遇到一个更隐蔽的问题:内存占用稳步增长,每小时多几百MB,但valgrind和ASan都查不到明显越界。最后发现是显示线程里imshow窗口的渲染缓冲区没有及时释放:屏幕上叠加了一直累积的画框信息,那些绘制中间结果全被缓存在窗口系统内部了。
处理方案有两个,缺一不可:一是在显示线程每轮循环末尾显式调用result.release()或者让Mat离开作用域,确保引用计数真正归零;二是给渲染缓冲区设置主动刷新机制,不要依赖imshow内部的脏区域管理。这条经验的价值在于:多线程视频处理里,内存泄漏很少发生在你主动new的地方,更多是某个线程持有了Mat引用没释放,或者某个库的隐式缓存没清。排查时记得把对象生命周期梳理一遍,重点看Mat在哪里被拷贝、在哪里出了作用域、引用计数还剩多少。
讲完这么多,最后送一个我一直在用的小习惯:给每个线程起个顺口的名字,在关键路径上打印带线程名的日志([capture] frame captured, [process] inference done),多线程问题定位的难易程度立刻降一个级别。视频处理这个东西,帧率是纸面指标,稳定的低延迟才是真正让用户觉得"流畅不卡"的东西。从单线程到多线程,别急着铺开乒乓并发,先把队列、Mat生命周期和线程边界设计清楚,再谈优化,你会少踩至少一半的坑。
