☰
RK3588服务化部署YOLOv5s:RKNN线程安全与内存生命周期坑点总结
2026/10/28 7:28:46 网站建设 项目流程

前几篇把 RK3588 板子上的环境、YOLOv5s 导出、RKNN 转换和板端推理都过了一遍,这一篇进入真正面向业务的那一层:服务层,以及把摄像头画面真正喂进检测链路。标题里提到的“折腾最久的坑”是实打实调出来的——当时白天测板子,晚上翻源码,最后发现真凶还不是模型本身。所以我先把结论放前面:在 RK3588 上把推理挂成 HTTP 服务,最容易被坑的往往是 RKNN 在服务进程里的线程安全、输出内存生命周期,以及摄像头取流到推理之间的缓冲策略,而不是 YOLOv5s 的精度问题。

这篇适合两类人。一类是刚从算法转到边缘部署,想搞清楚“模型能跑”和“作为一个服务稳定跑”之间差在哪;另一类是已经把 demo 跑通,但一接实际业务就时不时崩一下、正头疼的朋友。前者可以按顺序从架构往下看,后者可以直接跳到第 4 节那个坑。

1. 服务层到底在解决什么问题

先说句实在话:单板部署的 demo 和“可用的服务”之间,差的东西比大多数人想象得多。demo 里常见写法就是把图片读进来,调用一次rknn.forward,画框,保存。而一个服务层至少要解决三件事:对外提供稳定的接口协议、调度板子上有限的 CPU/NPU 资源、处理持续输入(摄像头流)和突发请求(多方调用)之间的速度差。

1.1 “模型能跑”和“服务能扛”的区别

咱们实际一点。YOLOv5s 在 RK3588 上转换后,用 rknn-toolkit2 的 Python API 跑一次 640x640 推理,纯 NPU 时间大概 10~20ms,这在边缘设备上挺可观。但真到服务层,CPU 还得干几件脏活:从摄像头取一帧、解码(如果是 RTSP 或 MJPEG)、缩放到 640、BGR 转 RGB、归一化、再把连续的输入数组递给 NPU。这么一整套下来,环节就多了,每个环节都可能成为瓶颈或者定时炸弹。比如我一开始只在 demo 里测摄像头直连和推理,感觉不错;一挂到服务上,几路摄像头全开、客户端轮询检测结果,很快就把问题全暴露出来了。

所以我在动手前先定了个原则:服务层和推理层严格分离,摄像头取流独立成线程,推理独立成线程,HTTP 接口只负责接收请求和返回结果。这样任何一个环节出问题,至少能定位到具体模块,而不是整个进程一下崩掉。

1.2 整体数据流

我在板子上最终跑通的架构,大致是这样:

摄像头(CSI / USB / RTSP) -> 拉流线程(只做取帧和解码) -> 帧队列(定长,超长丢帧) -> 推理线程(锁住 RKNN 实例,做预处理 + forward + 后处理) -> 结果缓存(只保留最近一次检测结果) -> HTTP 接口(FastAPI 读缓存 / 拉取帧回来)

这个流程里最重要的原则是:不能让取流速度被推理速度反拖。摄像头出帧是硬实时的,25 帧来的时候你处理不过来,队列就会越积越长,延迟越拉越大,最终内存爆炸或者线程卡死。后面第 3 节我会专门讲队列的取舍。

2. 摄像头取流:三种接入方式和我的选择

服务层要对摄像头工作,首先得把画面接进来。RK3588 板子常见的摄像头接入方式无非三种:MIPI CSI 摄像头、USB UVC 摄像头、网络摄像头(RTSP/RTMP)。具体选哪种,看应用场景。我在这个项目里验证过 USB 和 RTSP,MIPI 是另一个子项目里用的。

2.1 CSI、USB、RTSP 三种方式的实际差异

接入方式优点缺点典型场景
MIPI CSI延迟低、带宽高、不吃 CPU只能短距离、接口固定、摄像头贵车载前视、机械臂视觉
USB UVC即插即用、便宜、兼容性好帧到一定分辨率后 CPU/带宽占用飙升原型验证、短距离部署
RTSP 网络摄像头可远程、多路、PoE 供电有网络带宽限制、解码开销分布式监控、安全巡检

在 RK3588 上,我优先推荐有 GStreamer 支持的方式,因为直接用cv2.VideoCapture默认后端碰到 MJPEG 或者高分辨率时,CPU 占用会非常难看。举个实测例子:同样 1080p 30fps 的 USB 摄像头,用 OpenCV 自带的 FFmpeg 后端拉流,CPU 占用能到 40% 以上;换用 GStreamer 管道加合适策略,CPU 能压到 15% 左右(这只是取流解码部分)。在同时要跑 YOLOv5s 推理、HTTP 服务、可能还有别的业务的板子上,这 25% 的 CPU 差距基本决定整个服务的稳定性。

2.2 拉流配置与延迟控制

如果你的摄像头是 RTSP,我最常用的一条 GStreamer 拉流管道是这样:

rtspsrc location=rtsp://user:pass@192.168.1.100:554/stream1 latency=200 ! \ rtph264depay ! h264parse ! mppvideodec ! queue ! \ videoconvert ! video/x-raw,format=BGR ! \ appsink drop=1 max-buffers=1 sync=false

几个关键点:

  • latency=200是 rtsp 缓冲,网络好的时候可以调到 100,找到延迟和抖动的平衡点。
  • mppvideodec是瑞芯微平台上的硬件解码插件,RTSP H.264/H.265 解码直接走 VPU,不烧 CPU。
  • appsink drop=1 sync=false是精髓——把积压的旧帧丢掉,保证 appsink 永远拿到的都是刚刚到的那一帧,这样系统即便短暂跟不上也不会把延迟越堆越高。

USB/MIPI 摄像头如果走 V4L2,可以直接用cv2.VideoCapture(0, cv2.CAP_V4L2),然后设置:

cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc('M','J','P','G')) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) cap.set(cv2.CAP_PROP_FPS, 30)

注意:不要在板子上直接让 1080p 走 YUYV 裸流。YUYV 在带宽上是 MJPEG 的 5~8 倍,USB 2.0 很容易跑不动,导致摄像头自动降帧甚至报VIDIOC_DQBUF错误。我现在习惯的做法是:能 MJPEG 就 MJPEG,能 720p 就别贪 1080p——反正 YOLOv5s 推理输入是 640x640,720p 足够喂。

2.3 摄像头起不来的排查思路

如果摄像头打开失败或者画面卡住,先别急着写代码,用系统工具看看:

v4l2-ctl --list-devices v4l2-ctl --list-formats-ext -d /dev/video0

其中--list-formats-ext会显示该设备支持的像素格式和分辨率组合。很多“貌似兼容”的摄像头,实际上 1080p 只支持 15fps,或者 MJPEG 下 1080p 帧率减半。搞清楚摄像头硬件能力,再谈应用层配置。

另外我一直保留的一个习惯是:打开设备后先read一帧确认格式,读出来之后用frame.shape和frame.dtype打日志,而不是默认它就是 BGR。这个细节在接不同摄像头时救了我好几次——有些摄像头默认输出 NV12 或者 YUY2,送到后续推理前必须显式转换。

3. 服务层实现:FastAPI、队列和并发控制

摄像头画面进来之后,就该让检测模型以服务的形式跑起来了。服务层的选型我最后定了 FastAPI,原因很简单:异步能力好、自带 OpenAPI 文档、Uvicorn 对多工作线程的处理比 Flask 那套开发服务器稳太多。当然,最初我也是图省事先用 Flask 的app.run(threaded=True),结果很快踩到 RKNN 线程安全的坑——这个坑放到下一节重点讲,这里先说正确的架构。

3.1 为什么不用 Flask 的开发服务器

在板子这种算力有限的环境里,你不需要也不应该用 Flask 自带服务器扛生产流量。它默认是单进程的,虽然能开 threaded,但瓶颈不在 HTTP,而在吃 CPU 的视频处理链。我后来统一用 Uvicorn 起 FastAPI,workers=1,理由是:RK3588 上内存不算宽裕,YOLOv5s 的 RKNN 上下文一次加载大约能占几百 MB;多开 worker 意味着多份模型上下文,内存翻倍不说,RKNN 每个上下文打开的是同一个 NPU,调度竞争反而更剧烈。单进程 + 线程池 + 外部队列,对边缘盒子来说是最平衡的方案。

3.2 帧队列:丢帧策略比你想的重要

队列我用的就是 Python 标准库queue.Queue,容量固定为 4。别设太大,边缘设备不差这几帧,差的是实时性。入队的逻辑很简单:

frame_queue = queue.Queue(maxsize=4) def capture_loop(cap): while not stop_event.is_set(): ret, frame = cap.read() if not ret: time.sleep(0.01) continue if frame_queue.full(): try: frame_queue.get_nowait() # 丢掉最旧的一帧 except queue.Empty: pass frame_queue.put_nowait(frame)

这里的关键是丢哪一帧。我在项目里有段时间用的是if queue.full(): continue(丢新帧),结果摄像头画面看起来“卡一下跳一下”,因为每一帧稍微抖一下就把本应处理的新帧丢了;换成get_nowait()丢最旧那一帧之后,画面和检测结果的跟随感立刻舒服多了。这个取舍在实际业务里不明显,但接大屏或者告警推送时,实时性感知差异很大。

3.3 推理线程和接口

推理线程的职责是:从队列拿帧 -> 预处理 -> 调用 RKNN -> 后处理 -> 把结果放进“最近一次结果”缓存里。接口部分则非常简单——客户端不会一直同步等推理,而是轮询或查询。所以我设计了两类接口:

  • GET /detect/latest:返回最近一次的检测结果(矩形坐标、类别、置信度),附带时间戳。
  • GET /frame/latest:返回最近一帧的 JPEG 编码图像,方便前端画框显示。

有意思的是,实际调优后我发现把推理结果做成“状态”比做成“请求响应”更合理。因为摄像头每秒 25 帧,模型每秒最多跑十几帧,如果每个 HTTP 请求都强绑一次推理,一来一去就是两次上下文切换。用缓存状态的方式,接口永远只读最新值,响应延迟可以压到 1ms 以内(网络除外),而且不会因为一次慢推理拖死所有请求。

预处理部分,我把它写得尽量显式:

def preprocess(frame_bgr): img = cv2.resize(frame_bgr, (640, 640), interpolation=cv2.INTER_LINEAR) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = img.astype(np.float32) / 255.0 img = np.ascontiguousarray(img) return img

最后那行np.ascontiguousarray是我的强迫症,也是排掉很多隐患的关键。OpenCV 的帧和resize后的结果偶尔不保证内存连续,RKNN 底层在做 DMA 拷贝时最讨厌这种非连续数组。

4. 那个折腾最久的坑:RKNN 多线程 + 内存生命周期

终于到这个坑了。标题里说“折腾最久”,真不是标题党。那段时间我在板子上反复测,现象是:单独跑 demo 稳如老狗,一连挂 4 小时不崩;一旦把推理接进 FastAPI 服务,二十分钟内必崩,有时候更快。崩的方式也各不相同:有时候是dmesg里看到进程被 OOM killer 干掉,有时候是rknn.forward报timeout,有时候干脆是 Python 段错误。

4.1 先复盘现象:单 demo 正常 vs 服务进程崩

一开始我怀疑模型转换有问题,但单独推理的结果是对的,精度也对,排除了这个。然后我怀疑是摄像头的问题,换成静态图片轮流推理,照样崩。接着我用压测工具直接打接口,不开摄像头,内存top里肉眼可见地往上蹿,直到有一天dmesg里明确写了Out of memory: Kill process,才把方向锁定在内存和并发上。

我当时的怀疑链是:

  1. RKNN 输出数组没释放?
  2. FastAPI 的线程栈不断累积?
  3. OpenCV 的read()循环里有对象引用没断?

我一个个加日志、关线程、压测,最后发现三个因素串在一起,才共同引发了这个坑。

4.2 根因一:RKNN 实例不是线程安全的

这是最根本的一条。RKNN 的 Python API 底层虽然是 C/C++ 实现,但同一个rknn.RKNN()实例同时被多个线程调用forward是有问题的。FastAPI 的async接口虽然入口是协程,但内部同步调用rknn.forward时,Uvicorn 的线程池里可能有多个线程同时进入这个方法。RKNN 内部共享状态就会互相踩踏,轻则返回错误,重则整个进程崩掉。

排查方式很简单,写一段多线程压测脚本:

from concurrent.futures import ThreadPoolExecutor import numpy as np r = rknn.RKNN() # ... load model ... def infer_once(_): img = np.random.rand(1, 3, 640, 640).astype(np.float32) outputs = r.inference(inputs=[img]) with ThreadPoolExecutor(max_workers=4) as ex: list(ex.map(infer_once, range(1000)))

这个脚本几乎必然复现崩溃或报错。确认之后,我的修复方式是给forward加全局锁,或者更简单地,在服务里只保留一个RKNN实例,用threading.Lock包住整个推理调用。

4.3 根因二:输出数组的生命周期比想象中微妙

第二个坑是内存上涨的真凶。rknn.forward()返回的outputs列表,里面的 ndarray 在底层有一部分是引用内部 buffer 的。如果你把这些数组保存到“最近一次结果”缓存里,又在下一次推理时用新的结果替换掉,理论上旧的引用应该被回收;但实际观测到内存只涨不降,后来我做了个实验:在缓存里只保存outputs[0].numpy().copy(),显式复制一份,内存就稳定了。

也就是说,RKNN 返回的数组在它的内部内存管理里是有生命周期的,你不显式copy(),它会一直拿住底层不释放,导致累积。这个坑在单个 demo 里几乎不会暴露(跑完就退,进程反正要被回收),但在一个长期运行的服务里就是慢速内存泄漏。

4.4 根因三:输入数组的内存布局

还有一个和输入有关的细节:如果预处理出来的数组不是 C 连续内存,RKNN 在转发给 NPU 时多了一次缓冲拷贝,这在长时间运行中也会引入不确定的延迟和内存碎片。解决办法就是前面的np.ascontiguousarray()。另外注意像素格式要匹配模型输入,YOLOv5s 导出时如果用 RGB 输入,你送的也必须是 RGB;如果模型输入有自定义means/std,都要一一对应,否则推理结果会漂移。

4.5 修复之后的样子

最终跑稳的服务核心代码逻辑是:

infer_lock = threading.Lock() def detect_one(frame_bgr): img = preprocess(frame_bgr) with infer_lock: outputs = rknn.forward(inputs=[img]) result = postprocess(outputs) return result

推理线程从队列拿帧后调用detect_one,接口只是读取“最近一次结果”缓存,这样锁的粒度只锁在推理段,预处理和后处理不参与锁,并发效率依然不错。

4.6 排查这类坑的一套方法论

给同样在边缘部署的朋友留一套通用排查顺序,能少走弯路:

  1. 先把服务简化到最小:不接摄像头、不用 HTTP,写一个死循环推理,看会不会出问题。
  2. 再逐步加回 HTTP 并发、加回摄像头线程,每加一个模块就稳定跑 20~30 分钟。
  3. 内存问题优先看top里 RES 值的趋势,别只看 CPU。
  4. 进程莫名其妙消失,第一反应去看dmesg | tail -20,OOM killer 杀进程几乎没有其他前置征兆。

这套排查顺序看起来枯燥,但能帮你把“服务崩了”这一个模糊现象,拆成可以定位的单个组件问题。

5. 实际运行中的周边细节与调优记录

坑解决之后,服务能跑了,不代表就完事了。RK3588 毕竟不是 X86 服务器,资源有限,很多小的调优决定“舒服”还是“勉强能跑”。

5.1 内存优化:避免重复申请临时 buffer

我这个项目里,摄像头的分辨率是 1280x720,推理输入 640x640。最初我每次preprocess都重新cv2.resize+cvtColor,虽然 Python 的垃圾回收能接住,但长时间运行会产生零散的内存碎片。后来我改成在初始化阶段预先申请一块 640x640x3 的连续 float32 数组,每次推理只往里填充数据,减少频繁分配。这个优化在压力测试里把内存波动明显压低了,虽然代码稍微丑一点,但边缘设备上值得。

5.2 进程监控与看门狗

服务挂在板子上,SSH 一断就失联是常态,所以我把服务做成 systemd 服务,加上简单健康检查:

ExecStart=/home/pi/env/bin/uvicorn app:app --host 0.0.0.0 --port 8000 --workers 1 Restart=on-failure RestartSec=5

另外加了一行定时脚本,每隔 30 秒请求一次/detect/latest,如果连续 3 次超时,就systemctl restart yolov5s.service。虽然是笨办法,但在真实 24 小时运行场景下,这套组合拳救了很多次场。不要指望程序永不崩溃,要指望崩溃后能自动爬起来继续干活。

5.3 经常被忽略的:电源和散热

最后说一个容易忽略但特别玄学的点:RK3588 满载跑 NPU + 多路摄像头时,发热非常可观。我遇到过几次服务运行一两个小时开始掉帧、报rknn.forward timeout,排查半天发现是散热片贴得不严谨,NPU 高温降频甚至保护性停机。看温度的命令是:

cat /sys/class/thermal/thermal_zone*/temp

超过 70°C 就要留意了,最好给板子加个风扇,或者把 NPU 频率调到合适档位。这个因素在代码层面完全查不出来,往往被归到“随机崩溃”里。

5.4 其他小坑速查

再列几个我在这套链路里踩过的小坑,都是几句话能说清但当时能折腾一阵的:

现象原因解决方法
VIDIOC_REQBUFS failed之前的进程没释放摄像头 buffer先ps -aux干掉残留进程,或重启板子
拉流延迟越来越大appsink 积压管道里加drop=1 max-buffers=1
检测结果偶尔是黑图输入方向/BGR 通道没对齐打印预处理后的均值,确认通道顺序
用久了内存缓涨RKNN 输出未 copy缓存前显式outputs[0].numpy().copy()
多线程偶尔timeout同一个 RKNN 实例并发调用全局锁或单实例串行推理

6. 经验沉淀与检查清单

6.1 这次折腾给我留下的最深印象

第 4 节那个坑让我印象极深,不是因为技术多难,而是因为它几乎集齐了边缘部署所有典型要素:看起来每个环节都正常,组合起来就是不正常。如果你也遇到类似现象,我建议先别急着怀疑算子、怀疑模型量化精度,先在脑海里过一遍“并发 + 内存生命周期 + 硬件外设占用”这三板斧。RK3588 不是只能跑 demo 的玩具,但它的脾气确实需要摸一摸。

6.2 给准备在 RK3588 上接服务的你的检查顺序

我自己的标准操作流程已经固定下来了:

  • 摄像头 buffer 是否干净:确认没有残留进程占用/dev/video0。
  • 单实例 demo 是否稳跑 24h:这是基线,基线不稳别往上叠东西。
  • 多线程压测是否通过:同一段代码被 4 个线程同时调用,什么都会露馅。
  • 输出结果是否显式copy():防止内部 buffer 不释放。
  • systemd 看门狗是否配置好:崩溃自愈比永不崩溃简单得多。
  • 散热和电源是否到位:别让温度抢了优先级。

最后再分享一个小操作:每次调整代码后,我都会在板子上留一个“24小时应力测试”的脚本,跑完再看内存曲线和dmesg里有没关键字。这套流程虽然笨,但非常可靠,尤其适合 RK3588 这种资源有限又讲究稳定的边缘设备。

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

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

立即咨询