尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Halcon HTuple::ToDArr() 的隐藏大坑

Halcon HTuple::ToDArr() 的隐藏大坑 环境Windows 10 / Qt 5.14.2 (MSVC 2015_64) / Halcon HDevEngine现象算法组件持续运行 2 小时内存肉眼可见地持续上涨根因HTuple::ToDArr()返回的堆上副本从未释放且泄漏点写在循环里复杂度 O(N²)一、背景我们的项目是检测设备的上位机算法层用 Halcon HDevEngine 跑.hdvp过程外层是 Qt 5 编写的算法组件 DLL。最近现场反馈设备连续运行几个小时后进程内存持续上涨涨到一定程度影响系统稳定性。这类问题在工业设备上是致命的——设备是要 7×24 跑的。二、排查思路分段返回二分法算法主流程函数是一条直线流水线。为了快速缩小范围我在函数内部按流程顺序插了 5 个提前0~4位置 0 ──► 前处理 位置 1 ──► Onnx 推理 位置 2 ──► 结果解析 位置 3 ──► ... 位置 4 ──► 完整流程结束不返回测试结果非常干净返回位置运行时长内存表现0 ~ 35~6 小时平稳无上涨4完整流程2 小时出头持续上涨结论泄漏点在第 3 个位置到第 4 个位置之间——即读取算法过程的列表型输出参数这一段。这一段的逻辑用等价的普通代码写出来是下面这个样子原始工程里是封装好的宏调用不影响问题本质// 每帧从 HDevEngine 过程的输出中取 4 个列表型控制参数 HTuple tupleA procCall.GetOutputCtrlParamTuple(ListA); // 长度约几百 HTuple tupleB procCall.GetOutputCtrlParamTuple(ListB); HTuple tupleC procCall.GetOutputCtrlParamTuple(ListC); HTuple tupleD procCall.GetOutputCtrlParamTuple(ListD); ​ // 把每个 tuple 的内容搬到结果列表里顺便拼一份日志字符串 QListfloat listA; for (int i 0; i tupleA.Length(); i) { listA.append(tupleA.ToDArr()[i]); // ← 泄漏点 logStr.append(QString(%1,).arg(listA.last())); } // ListB / ListC / ListD 同样处理 ...肉眼看这段代码没有任何new、没有任何裸指针怎么泄漏的三、根因ToDArr()的语义问题就出在这一行listA.append(tupleA.ToDArr()[i]);这里叠加了两个致命问题。问题 1ToDArr()返回的是堆上的副本必须手动释放翻 Halcon 的头文件HTuple.h注释写得明明白白// Returns the tuple data as an array of double.// The array is a copy of the internal tuple data. Release using DeleteArr()!double* ToDArr() const;也就是说每次调用ToDArr()Halcon 都会在堆上new一块 N×8 字节的数组把数据复制一份出来返回裸指针。这块内存的所有权归调用者官方要求用HTuple::DeleteArr()释放不能用普通delete[]跨 DLL 边界分配/释放必须走同一套运行时。而上面的代码拿到指针取了个值就扔了一次都没释放。问题 2泄漏点写在循环里量级被放大了 N 倍更糟的是ToDArr()写在了for循环内部。这意味着取一个长度为 N 的列表会执行 N 次ToDArr()每次都 new 一个 N×8 字节的数组全部泄漏。用伪代码描述这段逻辑就是tuple ← 长度为 N 的输出参数for i in 0 .. N-1:result[i] ← tuple.ToDArr()[i] # 每次迭代都复制整个数组出来用完即弃单帧单个输出的泄漏量 N × N × 8 字节。代入实际参数算笔账假设每个输出列表 500 个元素、4 个输出、帧率 10 fps500 × 500 × 8 B × 4 × 10 ≈ 76 MB / 秒 ≈ 275 GB / 小时当然实际列表长度会波动、分配器有对齐开销但量级摆在这——运行两个多小时内存明显持续上涨完全对得上。前面 0~3 位置不涨是因为那几段只读单值输出.D()/.I()按值取不分配内存只有这 4 个列表输出走了ToDArr()路径。顺带一提ToIArr() / ToLArr() / ToSArr()是同一个家族全部有这个问题字符串列表输出用的ToSArr()也是同一颗雷。四、修复改用按元素访问HTuple重载了operator[]返回HTupleElement它提供D() / L() / I() / S()等按元素取值方法——不发生任何堆分配。修复前后对比// 修复前循环内每次迭代都调用 ToDArr()返回的数组从不释放for (int i 0; i tuple.Length(); i)list.append(tuple.ToDArr()[i]); // ← 每次迭代泄漏 N*8 字节// 修复后operator[] 返回 HTupleElement按元素取值零堆分配for (int i 0; i tuple.Length(); i)list.append(tuple[i].D()); // ← 不泄漏语义完全等价取 double 值性能还更好——不再有 N 次整块复制。字符串列表的写法同理// 修复前for (int i 0; i strTuple.Length(); i)list.append(strTuple.ToSArr()[i].Text());// 修复后for (int i 0; i strTuple.Length(); i)list.append(strTuple[i].S().Text());修复过程中踩的第二个坑宏参数拼接我们的原始工程里这段逻辑是宏封装的方法名通过宏参数传进去D/L/I。第一版修复时我顺手加了个##// 错误写法在 . 和宏参数之间用了 ## 拼接list.append(tuple[i].##Type()); // Type 是宏参数, 传 D 时想展开成 tuple[i].D()// 正确写法宏参数作为独立 token 原样替换即可list.append(tuple[i].Type()); // Type 传 D 时展开为 tuple[i].D()加了##的版本编译直接报错error: pasting formed .D, an invalid preprocessing token原因##是 token 粘贴运算符它把.和D粘成了一个非法 token.D后面再跟()就不是合法表达式了。这里.是独立 tokenType作为独立 token 会被宏参数原样替换成D根本不需要##。只有两个标识符拼成一个新标识符比如result##Property生成resultXXX才需要##。五、验证写一个最小复现 Demo为了把结论钉死我单独写了个 console 程序后台线程死循环模拟三种写法主线程每 2 秒打印进程内存GetProcessMemoryInfo// main.cpp —— TestHalconMemLeak// 用法: TestHalconMemLeak.exe [mode] [tupleLen] [frameMs]// mode 1 泄漏写法: 循环内每次 ToDArr(), 不释放 (预期: 内存持续上涨)// mode 2 变体: 每帧只调一次 ToDArr(), 不释放 (预期: 上涨, 速度约为 mode1 的 1/N)// mode 3 修复写法: tuple[i].D() 按元素访问 (预期: 内存平稳)#include iostream#include thread#include atomic#include chrono#include vector#include windows.h#include psapi.h#include HalconCpp.husing namespace HalconCpp;static std::atomicbool g_run(true);static std::atomiclong long g_iter(0);static int g_mode 1, g_tupleLen 500, g_frameMs 10;static const int g_outputs 4; // 模拟每帧 4 个列表输出static void printStatus(){PROCESS_MEMORY_COUNTERS_EX pmc;GetProcessMemoryInfo(GetCurrentProcess(),(PROCESS_MEMORY_COUNTERS*)pmc, sizeof(pmc));std::cout [frame g_iter.load() ] WorkingSet pmc.WorkingSetSize / 1048576.0 MB PrivateMem pmc.PrivateUsage / 1048576.0 MB std::endl;}static void workerLoop(){while (g_run.load()){for (int out 0; out g_outputs; out){HTuple tuple; // 模拟 HDevEngine 返回的输出参数for (int i 0; i g_tupleLen; i)tuple.Append(i * 0.5);std::vectordouble list; // 模拟接收结果的列表if (g_mode 1) { // 泄漏写法: 每次迭代泄漏 g_tupleLen*8 字节for (int i 0; i tuple.Length(); i)list.push_back(tuple.ToDArr()[i]);} else if (g_mode 2) { // 变体: 每帧泄漏 g_tupleLen*8 字节double* arr tuple.ToDArr();for (int i 0; i tuple.Length(); i)list.push_back(arr[i]);} else { // 修复写法: 零分配for (int i 0; i tuple.Length(); i)list.push_back(tuple[i].D());}}g_iter;std::this_thread::sleep_for(std::chrono::milliseconds(g_frameMs));}}int main(int argc, char* argv[]){if (argc 1) g_mode atoi(argv[1]);if (argc 2) g_tupleLen atoi(argv[2]);if (argc 3) g_frameMs atoi(argv[3]);std::thread worker(workerLoop);while (g_run.load()) {printStatus();std::this_thread::sleep_for(std::chrono::milliseconds(2000));}worker.join();return 0;}对应的.proHALCON_DIR 换成你本机的 Halcon 头文件/库目录TEMPLATE appTARGET TestHalconMemLeakCONFIG console c11CONFIG - app_bundleCONFIG - qtSOURCES main.cppHALCON_DIR 你的 Halcon 安装目录INCLUDEPATH $$HALCON_DIR/includes/halcon \$$HALCON_DIR/includes/halcon/halconcppQMAKE_LIBDIR $$HALCON_DIR/libs/halconLIBS halcon.lib halconcpp.lib psapi.lib三个模式各跑几分钟任务管理器和程序自己打印的曲线会给出非常直观的对照mode 1泄漏写法内存斜率陡峭上涨几秒一个 MB 地跳mode 2每帧一次 ToDArr 不释放同样上涨但速度约是 mode 1 的 1/N——这能单独验证循环内调用这个放大因素mode 3按元素访问曲线水平一条直线。六、总结这次排查有几条可以复用的经验分段返回二分定位是流水线代码查泄漏的最高效手段。与其盯着几千行代码猜不如按流程顺序插桩用运行时长换定位精度。0~3 不涨、4 涨一枪就把范围压缩到了几行代码。看不懂的封装去翻它的定义。调用点看起来人畜无害的tuple.ToDArr()[i]翻到 HTuple.h 的注释才发现是返回堆副本 调用方负责释放的 API。C 里没有免费的[]。警惕循环里的隐藏分配。单次泄漏 N×8 字节或许还能忍写进循环里就是 N²×8。这种 O(N²) 的泄漏在短时间测试里不明显测试时列表短、跑得少到了现场长时间运行才爆发——测试环境和生产环境的差异恰恰是泄漏问题的温床。HALCON 的ToDArr()/ToIArr()/ToLArr()/ToSArr()全家都要DeleteArr()。如果确实需要整块数组做批量计算正确姿势是double* arr tuple.ToDArr(); // ... 用 arr ... tuple.DeleteArr(arr); // 注意: 是 HTuple::DeleteArr, 不是 delete[]或者干脆用HTuple自带的operator[]HTupleElement::D()按元素访问让编译器和 RAII 替你管内存——这也是本次最终采用的方案。宏参数拼接的##只用于两个 token 拼成一个新 token。tuple[i].Type()里Type会原样替换加##反而会造出.D这种非法 token报pasting formed .D编译错。改完重新编译完整流程跑了通宵内存曲线终于是一条水平线。如果这篇博客帮到了你点个赞再走。有类似的 Halcon / Qt 内存问题欢迎评论区交流。
返回列表