历史快照
本专题页写于包拆分与 FAST-LIVO2 / CustomMsg / 图像输入落地之前,反映当时的单包结构与
五前端状态。已知过时点:目录布局(apps/launch 现属 asuka_ros 包)、共享的
config_sensor.json(外参已改为各前端 odometry 配置的 sensor 块)、ROS 层 acc_scale
等描述、前端数量(现六种,新增 fastlivo)、CustomMsg 与图像输入链路。2026-09 下旬以来:lightning / superodom 前端与 optimization_miao 后端已移除
(现五前端:fastlio / batchlio / superlio / fastlivo / srlivo;smallpointlio 已于 2026-10-23 移除,odometry/10-11 为其历史快照);
freedom 扩展已整体迁出 asuka(移入 nagisa 包);KeyFrame::stamp 统一为扫描
起始时刻;执行器新增连续失败熔断与 wait_for() 排空检测、扩展与执行器有
ok() 健康查询。
现行事实以主文档 (index / architecture / parameters / odometry 等)与源码为准。
title: "回调槽 CallbackSlot" description: >- 8.3KB 的 CallbackSlot 实现,是模块间事件订阅/发布的中枢原语,支撑 ROS 层、异步执行器、算法插件与扩展模块之间的解耦通信。
回调槽 CallbackSlot
模块定位与职责
utility/callback_slot.hpp(8.3KB,227 行)定义了 asuka::CallbackSlot<Func> —— 一个支持运行期增删、为高频发射调优的多播回调槽。它是整个 asuka 框架中模块间事件通信的中枢原语:与 OdometryEstimation 插件接口构成的"数据面"不同,CallbackSlot 承担的是"事件面"——ROS 层、异步执行器、算法插件与扩展模块通过它彼此解耦,互相不需要持有对方指针。
围绕它有两个直接配套文件:
core/callbacks.hpp:声明全局回调面asuka::Callbacks,聚合 5 个静态槽;core/callbacks.cpp:在 asuka_core 的翻译单元中定义这 5 个槽(原因见下文跨 .so 约束),并通过静态初始化器安装 spdlog 版异常钩子。
头文件结构:异常钩子 + 槽模板
头文件前半部分是进程级异常处理机制,与模板类完全解耦:
CallbackExceptionHandler是普通函数指针void(*)(std::exception_ptr) noexcept。刻意不用std::function,使这个模板头不拖入任何日志框架;asuka_core 通过set_callback_exception_handler()安装 spdlog 版实现;- 钩子存放在函数级静态
std::atomic(detail::callback_exception_sink())中,写入用 release、读取用 acquire; report_callback_exception()由发射线程的catch(...)调用,其自身又被 try/catch 包裹——即使被安装的 logger 本身行为异常再抛异常,也只会被吞掉,绝不会终止发射线程;- 默认钩子为
nullptr,即观察者抛出的异常被静默吞掉。
核心数据结构:写时复制快照
std::shared_ptr<const CallbackList> snapshot; // 不可变回调列表快照
std::atomic<std::size_t> subscriber_count{0}; // 发射快速短路计数
alignas(64) mutable std::mutex mutex; // 仅串行化突变
设计的核心是读写双路径分离:
- 发射路径无锁:
call()只做一次atomic_load读当前已发布快照,无互斥锁、无内存分配、无std::function拷贝,逐个原地解引用调用; - 突变路径持锁:
add/remove在内部 mutex 上串行,拷贝快照、施加变更、再原子发布新快照。正在发射的线程因持有旧快照的shared_ptr,旧快照在其调用期间保持存活,互不干扰; - 缓存行隔离:
alignas(64)让 mutex 独占一条缓存行——突变时写 mutex,发射时热读 snapshot 与 subscriber_count,避免两条路径间的伪共享; - 槽不可拷贝、不可移动(四个特殊成员函数全部 delete),保证静态槽的地址稳定,可作为跨模块身份。
flowchart TB
subgraph W["突变路径 add / remove(持锁 · 拷贝 · 发布)"]
A["add(cb) / remove(id)"] --> B["lock_guard 锁住 alignas(64) 的 mutex"]
B --> C["atomic_load 当前快照"]
C --> D["深拷贝出 fresh 列表<br/>add:追加并返回下标 id<br/>remove:槽位置 nullptr 留洞"]
D --> E["publish:atomic_store_explicit release 新快照"]
E --> F["subscriber_count fetch_add / fetch_sub"]
end
subgraph R["发射路径 call(无锁 · 只读)"]
G["发射线程 call(args...)"] --> H{"subscriber_count acquire 为 0?"}
H -->|是| Z["立即返回(零开销)"]
H -->|否| I["atomic_load 取已发布快照"]
I --> J["遍历快照,跳过空洞<br/>逐回调 try/catch 调用"]
end
E -. "发射者持有旧快照引用<br/>突变不影响进行中的发射" .-> I
J -. "observer 异常经进程级钩子" .-> K["spdlog warn<br/>(callbacks.cpp 静态安装)"]
图中关键节点:E(publish) 是读写两路径的唯一交汇点,release 发布保证新列表在读者 acquire 读到之前完全构造;I 说明旧快照不会立刻析构——发射者还握着 shared_ptr;K 对应默认吞掉或 spdlog 告警两种结局。
发射路径 call() 的两个防线
- 编译期右值防护(static_assert):实参要么是左值引用、要么是 trivially copyable 的按值类型。因为同一实参被转发给每一个观察者——非平凡右值会在第一个观察者处被 move-from,其余观察者看到残缺状态;trivially copyable 类型 move == copy 才安全。非平凡结构体必须传左值。
- 逐回调异常隔离:循环内
try { cb(...) } catch (...) { report_callback_exception(); },一个坏掉的观察者既不中断后续观察者,也不打断发射线程(例如 odometry worker)。
另有快速短路:subscriber_count == 0 时一次 acquire 读即返回;快照为空(从未 add 过)同样直接返回。operator() 是 call 的运算符形式,explicit operator bool 基于订阅计数。
重入语义:每次发射在入口锁定的快照上进行——回调内部 add/remove 订阅、或递归 emit 同一槽,只影响后续发射,绝不影响正在进行的一次。
突变路径与稳定 ID
add返回追加位置的 vector 下标作为稳定 ID;remove(id)对负 ID、越界、已置空的槽位一律静默无操作(no-op),否则置nullptr留洞——ID 永不复用,持有 ID 的一方无需担心失效后误删他人;- 典型生命周期:模块构造时
Callbacks::on_xxx.add(...)记下返回的 id;stop()/at_exit时用该 idremove(id)退订。
全局回调面 Callbacks 与跨 .so 约束
Callbacks 声明了 5 个静态槽,构成全框架的事件总线:
| 槽 | 签名(自 callbacks.hpp) | 事件语义 |
|---|---|---|
on_insert_imu |
void(const ImuData::ConstPtr&) |
执行器把 IMU 事件喂给算法前(worker 线程) |
on_insert_frame |
void(double, const PointCloudT::ConstPtr&) |
执行器把点云帧喂给算法前(worker 线程) |
on_new_frame |
void(const KeyFrame::ConstPtr&) |
前端算法产出新关键帧 |
on_odometry_imu |
void(const KeyFrame::ConstPtr&) |
imu_prediction 的 IMU 前向传播结果 |
on_odometry_opt |
void(const KeyFrame::ConstPtr&) |
后端优化后的位姿结果 |
(各槽的发射者、线程与顺序契约已在 callbacks.hpp 的类注释中成文,详见"全局回调定义"页。)
flowchart TB
subgraph MAIN["主进程 DSO(asuka_core)"]
subgraph SLOTS["Callbacks 静态槽(定义于 callbacks.cpp,保证全进程唯一实例)"]
S1["on_insert_imu"] ~~~ S2["on_insert_frame"] ~~~ S3["on_new_frame"] ~~~ S4["on_odometry_imu"] ~~~ S5["on_odometry_opt"]
end
ROS["AsukaROS(ROS 封装层)"]
ASYNC["AsyncOdometryEstimation worker"]
EXTS["扩展模块<br/>imu_prediction / LOAM / miao / rviz_viewer"]
LOGGER["CallbackExceptionLogger<br/>spdlog::warn(静态初始化安装)"]
end
subgraph SO["dlopen 的算法插件 .so"]
ODO["五种里程计实现"]
end
ROS --> ASYNC --> ODO
ODO -. "跨 .so 注册到同一实例" .-> SLOTS
EXTS -- "订阅;stop 后退订" --> SLOTS
SLOTS -. "observer 异常" .-> LOGGER
图中两条架构级约束(头文件注释明示):
- 跨 .so 共享的静态槽必须定义在 asuka_core 的 .cpp 中(callbacks.cpp 正是这么做的),绝不能是头文件里的
inline/静态成员——否则每个动态库各自持有一份槽实例,插件注册的回调会静默消失。异常钩子同理:必须从主 DSO 安装,插件才能共享同一个 spdlog sink。 - 编译策略一致性:libstdc++ 的
shared_ptr原子自由函数只有在启用-mcx16(cmpxchg16b)时才走无锁路径。所有包含此头的 TU 必须采用同一编译策略,否则无锁读者与持锁写者会在同一进程内共享同一个shared_ptr对象——未定义行为。
异常钩子的安装由 callbacks.cpp 中的静态初始化器 g_callback_exception_logger 完成:rethrow exception_ptr 后按异常类型输出 spdlog::warn("[callbacks] observer exception: ...")。
与停机时序的配合
asuka_ros.cpp 析构函数的注释直接印证了槽的使用方式:先 async->stop() 停 odometry worker,再逐个停扩展模块——所有 stop() 返回后不再有进行中的槽发射,此后模块才能安全地退订并析构,不会与发射者产生竞争。这是 CallbackSlot 无锁发射语义在销毁时序上的镜像要求:发射方先于退订方终止。
边界条件清单
| 场景 | 行为 |
|---|---|
remove 传入负 id / 越界 / 已是空洞 |
静默 no-op |
call 时无订阅者 |
计数短路,立即返回 |
| 快照从未初始化(无任何 add) | 返回 |
| 观察者抛异常 | 逐回调捕获;默认吞掉,或经钩子告警;钩子自身抛异常也被吞 |
call 传入非平凡右值实参 |
编译期 static_assert 报错 |
| 静态槽定义在头文件 / 钩子安装位置错误 | 每个 .so 一份实例,插件注册静默丢失(架构级陷阱) |
各 TU 的 -mcx16 不一致 |
shared_ptr 原子操作 UB(编译级陷阱) |
扩展点
- 新增事件:在
Callbacks中添加新的static CallbackSlot<签名>成员,并在 callbacks.cpp 中给出定义即可接入事件总线; - 替换异常策略:
set_callback_exception_handler()可安装自定义钩子(须保持noexcept),默认行为是静默吞掉; - 泛型复用:
CallbackSlot<Func>对任意可调用签名开放,不局限于Callbacks中的五个槽——扩展模块内部也可自建私有槽实现局部解耦。
Sources:
- src/asuka/include/asuka/utility/callback_slot.hpp#L1-L227
- src/asuka/include/asuka/core/callbacks.hpp#L1-L17
- src/asuka/src/asuka/core/callbacks.cpp#L1-L34
- src/asuka/src/asuka/asuka_ros.cpp#L1-L120
关联源码
src/asuka/include/asuka/utility/callback_slot.hpp