04 RouDi 守护进程与服务发现
RouDi:
iceoryx_posh/source/roudi/;运行时:iceoryx_posh/source/runtime/;CaPro:iceoryx_posh/source/capro/
源码根:ros2_humble/src/eclipse-iceoryx/iceoryx(版本 2.0.6)
1. RouDi 的职责与定位
RouDi(Routing and Discovery)是 iceoryx 的中心守护进程,一台机器(或一个 domain)上只运行一个。它负责:
| 职责 | 实现 |
|---|---|
| 创建/销毁共享内存段 | RouDiMemoryManager + MemoryProvider |
| 进程注册与生命周期监控 | ProcessManager(心跳超时清理) |
| 端口(Publisher/Subscriber/Client/Server)的分配与销毁 | PortManager + PortPool |
| 服务发现路由(CaPro 协议状态机的”邮差”) | PortManager::doDiscovery() |
| 内省(introspection)数据发布 | 内置 topic |
关键设计:RouDi 不在数据路径上。数据 chunk 从发布者到订阅者的传递完全发生在应用进程之间的共享内存里(见 05 篇);RouDi 只在”控制面”工作——建立/拆除连接、分配资源。RouDi 崩溃不会中断已建立连接上的数据传输(但无法再建新连接、新进程无法注册)。
2. 启动流程
2.1 入口:iox-roudi 可执行文件
roudi_main.cpp 是 iox-roudi 的 main:解析命令行 → 解析 TOML 配置 → 构造 IceOryxRouDiApp 并 run():
1 | int main(int argc, char* argv[]) noexcept |
2.2 RouDiApp / IceOryxRouDiApp
基类 RouDiApp(application/roudi_app.cpp)处理信号(SIGINT/SIGTERM 通过信号量唤醒主线程退出)、日志级别、monitoring 模式、配置校验(段/mempool 不能为空)。派生类 IceOryxRouDiApp::run() 创建两个核心对象后阻塞等待退出信号:
1 | uint8_t IceOryxRouDiApp::run() noexcept |
IceOryxRouDiComponents 的构造顺序值得注意:先清理遗留的 RouDi IPC channel,再创建并”宣布”共享内存,最后才构造 PortManager:
1 | IceOryxRouDiComponents::IceOryxRouDiComponents(const RouDiConfig_t& roudiConfig) noexcept |
2.3 内存体系:MemoryProvider / MemoryBlock / RouDiMemoryManager
RouDi 的内存创建采用两级抽象(roudi/memory/ 目录):
MemoryBlock:一段有 size/alignment 需求的逻辑内存块,如PortPoolMemoryBlock(端口池)、MemPoolCollectionMemoryBlock(introspection mempool)、MemPoolSegmentManagerMemoryBlock(SegmentManager,管理所有用户数据段)。MemoryProvider:把若干 MemoryBlock 汇总,计算总大小并实际创建内存。POSIX 实现是PosixShmMemoryProvider,基于shm_open+mmap:
1 | cxx::expected<void*, MemoryProviderError> PosixShmMemoryProvider::createMemory(const uint64_t size, |
DefaultRouDiMemory 组装出管理段(名字为 iceoryx_mgmt,见 iceoryx_posh_types.hpp 中 SHM_NAME),它容纳 introspection mempool 与 SegmentManager:
1 | DefaultRouDiMemory::DefaultRouDiMemory(const RouDiConfig_t& roudiConfig) noexcept |
RouDiMemoryManager::createAndAnnounceMemory() 逐个调用 provider 的 create(),全部成功后 announceMemoryAvailable()(roudi/memory/roudi_memory_manager.cpp 第 65~89 行)。用户数据段(payload segment)由 SegmentManager/MePooSegment 按 TOML 配置逐段创建,每段有 reader/writer POSIX 用户组权限。
所以磁盘上可见的共享内存文件通常是:/dev/shm/iceoryx_mgmt + 每个用户组一个数据段(如 /dev/shm/<group>)。
2.4 RouDi 对象:两个线程
RouDi 构造函数启动两个线程:
1 | // run the threads |
| 线程 | 周期 | 职责 |
|---|---|---|
Mon+Discover |
DISCOVERY_INTERVAL = 100 ms |
ProcessManager::run() → 心跳监控 + PortManager::doDiscovery() |
IPC-msg-process |
阻塞收消息 | 处理应用发来的 IPC 请求(注册、建端口等) |
IPC 消息处理线程创建名为 roudi 的 IPC channel 并打印著名的 “RouDi is ready for clients”:
1 | void RouDi::processRuntimeMessages() noexcept |
3. 进程注册:PoshRuntime 与 RouDi 的 IPC channel
3.1 IPC channel 的实体
应用与 RouDi 之间的控制通道是按平台选择的(iceoryx_hoofs/platform/*/platform_settings.hpp):Linux/QNX/macOS 上是 Unix Domain Socket(using IoxIpcChannelType = iox::posix::UnixDomainSocket;,Linux 见 iceoryx_hoofs/platform/linux/include/iceoryx_hoofs/platform/platform_settings.hpp 第 39 行),Windows 上是 NamedPipe。历史版本用的 POSIX message queue 在 2.0 中仍保留实现(iceoryx_hoofs/internal/posix_wrapper/message_queue.hpp),但默认平台配置不再使用。
消息本身是 ASCII 文本、以分隔符拼接的字段序列(IpcMessage),收发封装在 IpcInterfaceBase(source/runtime/ipc_interface_base.cpp)中。通道拓扑是每进程一条:RouDi 有名为 roudi 的接收通道;每个应用按自己的 runtime 名字创建一条接收应答的通道。
3.2 应用侧:PoshRuntime::initRuntime()
应用第一行代码通常是 iox::runtime::PoshRuntime::initRuntime("app_name"),它构造单例 PoshRuntimeImpl:
1 | PoshRuntimeImpl::PoshRuntimeImpl(cxx::optional<const RuntimeName_t*> name, const RuntimeLocation location) noexcept |
两步:① IpcRuntimeInterface 完成注册握手;② SharedMemoryUser 用握手拿到的信息 mmap 共享内存。
3.3 注册握手(REG / REG_ACK)
IpcRuntimeInterface 构造函数是一个状态机:WAIT_FOR_ROUDI → SEND_REGISTER_REQUEST → WAIT_FOR_REGISTER_ACK → FINISHED,默认最多等待 RouDi PROCESS_WAITING_FOR_ROUDI_TIMEOUT = 60 s。注册请求携带进程名、pid、uid、时间戳与版本信息:
1 | // send IpcMessageType::REG to RouDi |
RouDi 侧 ProcessManager::addProcess() 做版本兼容性检查、把进程加入 m_processList(上限 MAX_PROCESS_NUMBER),并回 REG_ACK,内容是管理段大小 + SegmentManager 在管理段内的偏移 + 时间戳 + segment id:
1 | // send REG_ACK and BaseAddrString |
应用拿到 ACK 后,SharedMemoryUser 以 OPEN_EXISTING 方式 mmap iceoryx_mgmt,用相对指针机制 registerPtr(segmentId, baseAddress, size) 注册地址映射,再顺着 SegmentManager 打开自己有权限的所有数据段(source/runtime/shared_memory_user.cpp 第 29~85 行)。每个进程 mmap 的虚拟地址不同,这正是所有共享内存内部结构必须使用相对指针(RelativePointer)的原因。
3.4 心跳 keep-alive 与进程监控
- 应用侧:
PoshRuntimeImpl的定时器周期性调用sendKeepAliveAndHandleShutdownPreparation()→ 发KEEPALIVE消息(间隔PROCESS_KEEP_ALIVE_INTERVAL= 3 × 100 ms = 300 ms)。 - RouDi 侧:
KEEPALIVE分支调用m_prcMgr->updateLivelinessOfProcess(runtimeName)刷新时间戳;Mon+Discover线程里的monitorProcesses()检查超时:
1 | void ProcessManager::monitorProcesses() noexcept |
超时阈值 PROCESS_KEEP_ALIVE_TIMEOUT = 5 × 300 ms = 1.5 s(iceoryx_posh_types.hpp 第 266~268 行)。是否监控由 RouDi 启动时的 monitoring 模式 决定(MonitoringMode::ON/OFF,命令行 -m 切换;ON 时所有进程被监控)。
优雅退出走另一条路:应用析构 PoshRuntimeImpl 时发 TERMINATION,RouDi 清理端口后回 TERMINATION_ACK。
4. PortManager 与端口分配
4.1 PortPool:为什么端口数据也在共享内存
所有端口的数据结构本体(PublisherPortData、SubscriberPortData、ConditionVariableData、NodeData……)都存放在管理段中的 PortPool(固定容量的 FixedPositionContainer 集合,roudi/port_pool.cpp)。原因:
- 端口是应用与 RouDi 的共享状态机。应用侧用
PublisherPortUser/SubscriberPortUser视图操作它(写m_offeringRequested、收发 chunk),RouDi 侧用PublisherPortRouDi/SubscriberPortRouDi视图操作同一块数据(处理 CaPro 状态迁移、把订阅者队列挂到发布者)。两个进程要同时看到同一对象,只能放在共享内存。 - 零拷贝路由需要。订阅时 RouDi 把订阅者的
ChunkQueueData的指针(相对指针形式)直接塞进发布者的ChunkDistributor队列列表——发布者随后 push chunk 时完全不需要 RouDi 参与。 - 崩溃恢复。应用死掉后 RouDi 仍能访问其端口数据并回收 chunk(
releaseAllChunks())。
端口创建后,RouDi 把端口对象在管理段内的相对指针偏移通过 IPC 回给应用(CREATE_PUBLISHER_ACK 携带 offset + segmentId),应用侧换算回本进程的绝对地址:
1 | if (stringToIpcMessageType(IpcMessage.c_str()) == IpcMessageType::CREATE_PUBLISHER_ACK) |
4.2 创建 Publisher 端口的完整链路
1 | 应用: iox::popo::Publisher<T> 构造 |
注意 addPublisherForProcess 里传入的 payloadDataSegmentMemoryManager 是按发起进程的 POSIX 用户挑选的数据段 memory manager——这决定了该 publisher 之后 loan() 从哪个 mempool 拿 chunk。
4.3 doDiscovery:发现主循环
Mon+Discover 线程每 100 ms 调 PortManager::doDiscovery(),轮询所有端口收集状态变化并互相路由:
1 | void PortManager::doDiscovery() noexcept |
5. CaPro 协议(Canonical Protocol)
5.1 ServiceDescription:service/instance/event 三元组
capro::ServiceDescription(iceoryx_posh/capro/service_description.hpp 第 80、156~160 行)由三个字符串组成:m_serviceString / m_instanceString / m_eventString。ROS 2 的 rmw_iceoryx、cyclonedds 的 iceoryx 网关都会把 topic 名映射到这个三元组。两个端口能匹配的首要条件是三元组完全相等(PortManager::isCompatiblePubSub 第 483 行)。
5.2 CaproMessage 类型
1 | enum class CaproMessageType : uint8_t |
pub/sub 用到的核心是 OFFER/STOP_OFFER(发布者宣告)、SUB/UNSUB(订阅者请求)、ACK/NACK(应答);client/server 用 CONNECT/DISCONNECT。CaproMessage 里最关键的字段是 m_chunkQueueData——订阅者接收队列在共享内存里的指针,它随 SUB 消息一路传给发布者。
5.3 订阅如何路由到发布者
双方端口各自是一个小状态机,RouDi 只是搬运消息:
- 应用调
Subscriber构造(subscribeOnCreate默认 true)→ 设置m_subscribeRequested。 - 下一轮
doDiscovery()中,SubscriberPortSingleProducer::tryGetCaProMessage()产出 SUB 消息并携带自己的队列指针:
1 | if (currentSubscribeRequest && (SubscribeState::NOT_SUBSCRIBED == currentSubscriptionState)) |
PortManager::sendToAllMatchingPublisherPorts()(port_manager.cpp第 500 行)遍历所有 publisher,对三元组匹配且 QoS 兼容者调用dispatchCaProMessageAndGetPossibleResponse(SUB)。- 发布者侧把订阅者队列挂进自己的
ChunkSender(即ChunkDistributor的队列容器),成功则回 ACK:
1 | if (capro::CaproMessageType::SUB == caProMessage.m_type) |
- ACK 被 RouDi 转回订阅者,订阅者状态迁移到
SUBSCRIBED。若没有匹配发布者则 RouDi 直接给订阅者发 NACK,订阅者进入WAIT_FOR_OFFER,等以后收到 OFFER 再自动补发 SUB(subscriber_port_single_producer.cpp第 73~82 行)。
反方向:发布者 OFFER 时 sendToAllMatchingSubscriberPorts()(第 539 行)把 OFFER 派发给所有等待的订阅者,触发它们回 SUB,随后同样走上面 3-5 步(一轮 discovery 内完成握手)。
QoS 兼容性检查(阻塞策略、history 支持):
1 | bool PortManager::isCompatiblePubSub(const PublisherPortRouDiType& publisher, |
另外 OFFER/STOP_OFFER 还会转发给所有 InterfacePort(sendToAllMatchingInterfacePorts)——这是 gateway(如 iceoryx-dds、rmw 网关)监听全网服务变化的机制。
5.4 服务注册表(ServiceRegistry)
RouDi 在 PortManager 内维护一份 ServiceRegistry(roudi/service_registry.cpp),OFFER 时 addPublisher、STOP_OFFER 时 removePublisher(server 同理)。每次变化都会把整个注册表作为一个 chunk 发布到内置 topic 上:
1 | void PortManager::publishServiceRegistry() const noexcept |
对应的内置服务三元组是 {SERVICE_DISCOVERY_SERVICE_NAME, SERVICE_DISCOVERY_INSTANCE_NAME, SERVICE_DISCOVERY_EVENT_NAME}(port_manager.cpp 第 71~74 行)。
6. 服务发现 API(2.0 的 ServiceDiscovery / findService)
2.0 把 1.0 里”应用发 FIND 消息、RouDi 应答”的模式改成了纯 pub/sub:应用侧 iox::runtime::ServiceDiscovery 内部就是一个订阅上述内置 topic 的 subscriber,findService() 先 take() 最新注册表快照再本地过滤:
1 | void ServiceDiscovery::update() |
三个参数都是 optional——nullopt 表示通配。还可以把 ServiceDiscovery 挂到 WaitSet/Listener 上监听 SERVICE_REGISTRY_CHANGED 事件(enableEvent,同文件第 82 行),实现事件驱动的发现。
ServiceRegistry::find() 本身是对最多 MAX_NUMBER_OF_SERVICES 条目的线性匹配(service_registry.cpp 第 141~165 行),每个条目带 publisherCount/serverCount 引用计数(multi-set 语义,同一 service 多个 publisher 只占一个条目)。
7. 内省(Introspection)服务
RouDi 自己作为一个”进程”注册(m_processIntrospection.addProcess(getpid(), IPC_CHANNEL_ROUDI_NAME),roudi.cpp 第 65 行),并用内部 publisher 端口周期发布以下内置 topic(服务描述常量定义在 iceoryx_posh_roudi_types.hpp / introspection_types.hpp):
| 内置服务 | 内容 |
|---|---|
IntrospectionMempoolService |
各 mempool 的 chunk 使用量/最小剩余 |
IntrospectionProcessService |
已注册进程列表(pid、名字、node) |
IntrospectionPortService / IntrospectionPortThroughputService / IntrospectionSubscriberPortChangingDataService |
端口清单、吞吐、订阅状态 |
这些端口在 PortManager 构造时通过 acquireInternalPublisherPortData() 创建(port_manager.cpp 第 80~96 行),chunk 从专用的 introspection mempool(管理段内,配置见 default_roudi_memory.cpp 的 introspectionMemPoolConfig())分配。官方 iceoryx_introspection(iox-introspection-client)就是订阅这些 topic 的 ncurses 客户端。内置服务名以 Introspection/服务发现常量为 service 名,普通应用禁止创建同名 publisher(acquirePublisherPortDataWithoutDiscovery 中 isInternal() 检查,返回 INTERNAL_SERVICE_DESCRIPTION_IS_FORBIDDEN)。
8. RouDi 配置
8.1 TOML 配置文件
iox-roudi -c roudi_config.toml;不提供时先找默认路径(defaultConfigFilePath,即 /etc/iceoryx/roudi_config.toml),再退回编译期默认配置(TomlRouDiConfigFileProvider::parse(),roudi_config_toml_file_provider.cpp 第 59~68 行)。格式(版本必须为 1):
1 | [general] |
解析核心(段数上限 MAX_SHM_SEGMENTS、每段 mempool 上限 MAX_NUMBER_OF_MEMPOOLS):
1 | for (auto mempool : *mempools) |
注意 mempool 的 size 是 payload 大小,实际 chunk 会加上 ChunkHeader 并按 MemPool::CHUNK_MEMORY_ALIGNMENT 对齐;mempool 必须按 chunk 大小递增排序(MemoryManager::addMemPool 强制检查)。
8.2 主要命令行参数
| 参数 | 作用 |
|---|---|
-c <file> |
TOML 配置路径 |
-m on/off |
monitoring 模式(cmd_line_args.hpp 第 31 行默认 MonitoringMode::ON) |
-l <level> |
日志级别 |
-u <id> |
unique RouDi id(写入 UniquePortId,用于多 RouDi 隔离) |
-k <sec> |
关闭时等待应用退出的 kill delay(默认 PROCESS_DEFAULT_KILL_DELAY = 45 s) |
-x <level> |
版本兼容性检查级别 |
9. 崩溃行为
9.1 应用崩溃
- monitoring ON:心跳超时 1.5 s 后,RouDi 主动
deletePortsOfProcess()——对每个端口执行有序关闭(先注入 STOP_OFFER/UNSUB 走完 CaPro 流程,通知对端),然后releaseAllChunks()回收该进程持有的所有 chunk(port_manager.cpp第 796~848 行)。之后同名进程可重新注册。 - monitoring OFF:RouDi 不会主动发现崩溃。同名进程重新注册时走”已存在则视为崩溃重注册”路径:
1 | findProcess(name) |
已知限制:如果应用恰好死在持有 ChunkDistributor 锁的瞬间,RouDi 清理时会遇到死锁风险并触发 FATAL(chunk_distributor.inl 的 cleanup() 注释明确说明,见 05 篇 9.3 节)。
9.2 RouDi 崩溃 / 关闭
- 已建立的 pub/sub 连接数据传输不受影响(数据路径不经过 RouDi),但:没有发现、没有新端口、进程无法注册/注销,keep-alive 发送失败会打印警告。共享内存文件仍在
/dev/shm中,重启 RouDi 时以PURGE_AND_CREATE模式重建管理段(default_roudi_memory.cpp第 30 行),旧应用必须重启(其映射的旧段已失效,且版本/会话不匹配)。 - 正常关闭(SIGINT/SIGTERM):
RouDi::shutdown()先停 discovery 线程,然后向所有注册进程发 SIGTERM 并等待其发回 TERMINATION,超过 kill delay(默认 45 s)仍存活则 SIGKILL(roudi.cpp第 102~133 行)。requestShutdownOfAllProcesses()同时调用m_portManager.unblockRouDiShutdown(),把所有 publisher 强制 STOP_OFFER,避免因BLOCK_PRODUCER策略卡死关闭流程。
10. 时序总览
下一篇
- 05-零拷贝数据路径详解.md:
loan()/publish()/take()端到端调用链、ChunkDistributor、无锁队列与引用计数回收。
正在加载留言…