返回专辑
·Johan·4 分钟阅读

所有权与 RAII:机器人 C++ 里最先要钉死的习惯

机器人 C++ 所有权:驱动 fd、GPU buffer、回调生存期与 ASan 落地。

所有权与 RAII:机器人 C++ 里最先要钉死的习惯

1. 跑一夜后 open 失败

serial LiDAR 节点长跑几小时,open("/dev/ttyUSB0") 返回 EMFILE——不是驱动 bug,是 exception 中途 return 漏了 close,fd 泄漏到进程上限。机器人栈里 socket、DMA buffer、GPU handle、dlopen 句柄同理:谁分配谁释放必须绑在对象生命周期,不能靠「记得 finally」。RAII 把释放放进析构,作用域结束或 stack unwind 时自动执行,是 field 里跑一夜不挂的最低成本纪律。裸 fd 复制两份 ownership 的 double close,和 leak 一样常见,ASan 不一定抓得到——UniqueFd delete copy 让 double ownership 编译期失败。底盘联调常见「重启节点就好」——往往是 fd 或 DMA 泄漏到阈值,RAII 化后这类间歇故障应显著减少。

2. unique_ptr + custom deleter

UniqueFd 包 open 返回的 fd,析构 close。C 驱动 driver_free 用 unique_ptr<void, decltype(&driver_free)> 绑定。deleter 应 noexcept,move 时随指针转移。禁止裸 new/delete 配对——grep 进 CI fail(测试 fixture 除外)。transfer ownership 用 unique_ptr 返回;共享只读快照才 shared_ptr<const Config>,别与 node 互持成环。构造失败路径也要 RAII:UniqueFd 在 ctor 里 open,若后续步骤 throw,析构仍 close,比手动 goto cleanup 少漏分支。

cpp
struct FdDeleter {
  void operator()(int fd) const noexcept {
    if (fd >= 0) ::close(fd);
  }
};
using UniqueFd = std::unique_ptr<int, FdDeleter>;

3. 借用 vs 拥有

BorrowedCloud(span<const Point>)vs OwnedCloud(unique_ptr)比 @param not owned 注释不易忘。驱动 callback 给的 pointer——文档写清 callback 返回后失效还是调用方 free,别两种语义混在一个 T* 上。DMA 用 custom deleter 的 unique_ptr 比裸 struct 五法则更少 bug;GPU buffer 同理,stream sync 在析构还是显式 sync() 由文档约定,释放路径必须可达。API review 问一句:「caller 还是 callee own?」答不清就拆成 Borrowed 与 Owned 两个类型名。span 借用要求 backing 寿命覆盖整个使用期——异步 worker 里存 span 而不升 unique_ptr,是 use-after-free 的常见来源,review 应禁止把 callback 内 span 存进无同步的成员。

4. 析构顺序与 shutdown

成员声明顺序即析构逆序:先 request_stop worker,join,再 reset pub。若先 destroy publisher 后 stop worker,worker 仍 publish 会 segfault。on_shutdown:停线程 → drain queue → reset pub/sub。jthread + stop_token 比 detach 安全——detach 在 shutdown 是定时炸弹,进程退出时线程仍跑,LSan 报 still reachable 或更糟。Node 类成员顺序把 worker、queue、pub 按依赖排好,别靠「碰巧析构顺序对」——加一个成员就可能变。把 pub 放在 worker 之前声明,析构时 worker 先停,pub 后销毁——这是 ROS 节点类里最常见的正确成员顺序约定之一。

5. ROS 2 与 plugin 生命周期

rclcpp::Node 析构 tear down graph,但 shared_ptr 循环引用仍 leak 或 hang——观测用 weak_ptr。plugin dlopen 配 unique_ptr<void, DlCloser>,卸载在 context 之后,否则 callback 指向已卸载 so。硬件句柄与 ROS entity 谁先谁后要在设计文档写清,别只靠析构碰巧顺序对——换编译器或加成员顺序就变。callback 捕获 shared_ptr<Node> 而 Node 又持 subscription,classic 循环——用 weak_ptr 或 ensure on_shutdown 先 cancel callback。

6. 禁止 detach 与 scope_guard

std::thread detach 在 shutdown 是常见 hang 源。scope_guard 或 jthread 把 cleanup 绑作用域。on_shutdown 顺序写进 runbook:stop jthread → join → reset subscription → reset context。漏 join 测了 leak 但 exit hang,CI 超时红一片却难定位。scope_guard 适合「本函数内临时 open 文件/锁」,与 UniqueFd 互补——短作用域用 guard,长生命周期用 unique_ptr 成员。

7. 失败症状与案例

长跑 open 失败:查 /proc/$PID/fd 趋势。double close:两份 unique_ptr 同一 fd——copy 未 delete。现场 serial 驱动 exception 路径漏 close,重启节点才恢复——包 UniqueFd 后构造失败也不 leak。exit hang:callback 仍持有已销毁 node。EMFILE 前几小时往往有缓慢爬升的 fd 曲线,监控 /proc/self/fd 数量比 crash 后 lsof 更有用。GPU 节点还要盯 cudaMalloc 配对与 context 销毁顺序——泄漏不一定表现为 EMFILE,而是 OOM 或驱动 reset,RAII deleter 同样适用。

8. 验收

LSan/valgrind 长跑:fd 与 heap 稳定。构造中途 throw:已打开资源仍被 deleter 释放。rclcpp::shutdown 后退出码 0。review:on_shutdown 与文档一致;无 detach。新驱动接入 PR 必须列「谁 own fd/DMA」表,别只贴 datasheet。CI 可选跑 1h soak test,fd 数曲线平坦才绿。soak 失败时 dump /proc/self/fd 符号链接列表,比只看总数更快定位哪类资源泄漏——socket、tty、eventfd 各对应不同驱动模块。

9. 与录包、运维对齐

事故袋若无 fd 数或 heap 快照,只能猜 leak 还是 hang。RAII 不是 C++ 炫技——是机器人 field 里资源泄漏与 shutdown 竞态的第一道防线,比事后 lsof 数 fd 便宜得多。运维可定期采样节点 fd 数,突增告警对应 EMFILE 前置信号;与录包时间轴对齐可查哪次 deploy 引入 leak。新驱动接入 checklist:每个 open/mmap/cudaMalloc 是否有对应 RAII 包装、exception 路径是否仍析构、shutdown 顺序是否与 runbook 一致——四项全绿再合 main。

← 全部文章

johan's blog