一个 Broker 进程里同时存在网络请求、后台维护、存储 I/O、元数据持久化和退出清理。任务能被执行只是起点:谁拥有这些任务?过载时由谁拒绝新工作?请求超时后,磁盘写入还在不在继续?进程退出前,怎样确认已经接收的工作真正结束?
rocketmq-runtime 把这些问题收敛到一套共享运行时基础设施:由应用入口拥有 Tokio runtime,组件通过受约束的上下文提交工作,再把任务生命周期、资源预留和关闭结果连接起来。
下面沿着架构动画的七个阶段,拆解这套设计及其边界。
图:项目 Runtime 架构动画,按 boot → spawn → schedule → block → budget → persist → shutdown 展示一轮工作流。原始 SVG 使用 32 秒 CSS 循环动画;查看原图与说明。动画表达依赖和生命周期关系,不代表实测耗时。
本文以 2026 年 10 月 9 日核对的 main 快照 902ed461891db1ffd14daf6635e33821590ea971 为依据,链接固定到该提交。内容是源码与契约解读,未在本文中重新执行测试或性能测量。
先分清两棵树与一组共享执行通道
理解全图,可以先抓住三个结构。
任务所有权树回答“谁接收、跟踪并关闭工作”。RootServiceContext 派生 ChildServiceContext,每次 component(...) 都创建子 TaskGroup。子组件仍可继续派生子组件,同一个组既能拥有任务,也能拥有子组。
资源预算树回答“这份工作占用了多少额度”。组件从共享进程预算派生更窄的预算,显式预留数量、持有字节数以及可选的速率额度。它与任务树分离:任务归属不会自动给它的每一次内存分配记账。
共享 blocking lanes接收短时阻塞工作。它们有独立的任务登记表和全局准入额度,组件作用域约束能否提交。图中的虚线很重要:这些通道不应被理解成每个组件新建的一套线程池。
TaskKind 只是任务分类标签。OperationContext 为既有组件内的任务补充取消与截止时间,也不会再增加一层任务组。把这几种关系混成一棵树,后面就容易误读取消传播和资源释放。架构契约
1 boot 由入口拥有 runtime,向组件传递能力
启动路径是 RuntimeConfig → RuntimeOwner::plan → build → RootServiceContext。
plan(config) 做确定性的配置验证,不启动 Tokio,也不探测系统资源。build() 才解析内存预算并构造 Tokio 多线程 runtime。把验证与运行时构造分开,能让非法线程数等配置问题在启动副作用之前暴露。
RuntimeOwner 持有 runtime、共享资源和根上下文。根上下文没有公开构造函数,也不可克隆;库组件接收 ChildServiceContext,如果只需要提交任务,可以继续收窄成 TaskSpawner。RuntimeHandle 是内部实现类型,不是业务组件的公开集成入口。
这也划清了两层职责:Tokio 负责执行异步 future、I/O 与时间驱动;rocketmq-runtime 负责 RocketMQ 工作的所有权、准入、预算及生命周期。组件无需自行发现当前 runtime,也不应悄悄建立独立 runtime。
以下是 README 中的有限生命周期示例,使用已核对的公开 API,注册服务后立即走协作式关闭路径:
use rocketmq_runtime::{RuntimeConfig, RuntimeOwner};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let owner = RuntimeOwner::plan(RuntimeConfig::broker_default())?.build()?;
let broker = owner.root_context().component("broker");
let cancellation = broker.task_group().cancellation_token();
broker.spawn_service("heartbeat", async move {
cancellation.cancelled().await;
// 在这里完成有顺序要求的异步清理。
})?;
let report = owner.shutdown_runtime_blocking()?;
assert!(report.is_healthy(), "{}", report.to_json());
Ok(())
}
实际入口会先通过 owner.block_on(...) 运行启动和服务逻辑,再在 Tokio 异步上下文之外消费 owner,关闭 runtime。当前 Broker 入口正是这样组织,并把 ServiceLifecycle 冻结的退出截止时间传给 owner。Owner 实现 · Broker 入口
2 spawn 把任务接收与关闭放在同一条边界上
如果提交任务和关闭任务组互不协调,会出现一个典型竞态:关闭方已经判断“没有任务”,提交方却刚把新的 future 交给执行器。
TaskGroup 用 spawn gate 串行化登记与关闭状态转换。任务元数据和 tracker token 在 gate 内登记;交给 Tokio 和安装任务 handle 在 gate 外完成。即使 abort 发生在 handle 安装之前,后续也会遵守这个请求。
取消关系则遵循所有权方向:父组取消向下传播;取消一个子组不会取消父组或兄弟组。克隆 context 共享原组,只有创建子组件才获得新的子组身份。丢弃 context handle 也不能代替显式关闭,活跃任务可能继续维持组的生命期。
选择提交 API 时,需要先回答“取消时允许丢弃 future 吗”:
spawn_service跟踪服务,但要求服务自行观察取消信号并按顺序清理spawn_cancellable_service在 owner 取消时丢弃服务 future,适合取消安全的工作spawn_operation同时受组件 owner 与 operation 的取消、deadline 约束spawn_draining_operation允许已接收工作在 owner 取消后继续,但仍受 operation 取消、deadline 和组关闭的约束
请求级工作通常应使用 OperationContext,无需为每次请求建一个组件组。cancel() 仅发送取消信号;shutdown(...) 才关闭准入并等待结果。组内任务 panic 还可能让开放中的组进入 Poisoned,拒绝后续登记。因此,关键失败监控应放在被监控组之外,避免让已经中毒的组承担自己的恢复工作。任务与取消契约
3 schedule 为周期工作明确积压策略
周期任务的问题通常出现在“本轮还没结束,下一个 tick 已经来了”。继续并发、跳过,还是补跑,需要由业务语义决定。
ScheduledTaskGroup 是统一调度入口。context.scheduled_tasks(...) 创建子任务组,调度 driver 与每次 run 都归这个组跟踪。
它区分三种时间模型:
fixed_delay:本次完成后,再等待一个周期fixed_rate_no_overlap:按固定节拍触发,但不允许执行重叠fixed_rate:按固定节拍触发,在显式并发上限内重叠执行
配置决定时间模式,执行 policy 必须与它一致。固定频率下,当运行槽位全部占满,可以 Skip 丢弃该 tick、CoalesceLatest 保留一个待执行请求,或用 BoundedCatchUp(n) 有界补跑。这里的“有界”决定了维护任务变慢时会不会把压力继续堆积下去。
例如只关心最新状态的刷新,可以考虑合并;每轮完成后才有必要开始下一轮的维护,更适合 fixed delay。这些是选择依据,具体策略仍要由组件的业务约束决定。
max_run_time 超时会丢弃本次 run 的 future,不能据此断言已触发的外部副作用回滚。调度指标提供完成、跳过、重叠和 drift 等信息,用来观察实际行为。调度契约
4 block 调用者超时后,容量仍归真实工作所有
组件可取得 storage_io()、metadata_io()、cpu_crypto() 三条 managed lane。它们分别承接短时存储 I/O、元数据 I/O、有限 CPU 或加密工作,并共享同一 owner 的全局准入容量。
每条 lane 还有自己的并发上限和排队边界。空闲容量允许借用;有等待者时,其保留份额受到保护,同 lane 的新请求不能插队。底层最终执行仍使用 owner 的 Tokio blocking pool,托管容量之外另留 headroom。
需要区分两种超时:queue_timeout 约束等待执行容量的时间,task_timeout 约束准入后调用者等待结果的时间。二者都不能强制停止已开始的 blocking closure。
例如一个磁盘操作仍然卡住,调用者已经返回超时。如果此时立刻归还执行额度,后续请求就会不断获准进入,真实并发量最终突破限制。因此,permit 和任务记录由实际 closure 保持,只有它退出才释放;放弃等待的运行中任务可被记录为 TimedOutStillRunning。
长期阻塞循环不适合这套通道,BlockingKind::LongRunning 会被拒绝。这样的服务需要独立 OS 线程或领域服务 owner,以及明确的 stop/join 协议。BlockingExecutor 实现
5 budget 预算必须覆盖对象真正存活的时间
RuntimeResources 提供共享进程预算,子预算通过祖先链一起检查。数量和字节额度采用原子预留;如果后续祖先检查失败,之前的预留会回滚。临界容量附近的并发请求可能遇到保守拒绝,但不能以突破上限来换取准入。
ResourcePermit 用 RAII 持有预留,drop 时释放。BudgetClass::Control 可以使用配置好的控制预留,数据工作无法占用这部分容量,避免数据拥塞把必要的控制工作完全挤出。
容易遗漏的是出队后的记账:普通 recv() 或 try_pop() 会在出队时释放 permit;recv_budgeted() 或 try_pop_budgeted() 返回的 BudgetedItem 会继续持有额度。假如 payload 离开队列后仍在处理中,后者才能覆盖这段存活期。
队列的拒绝、等待、合并和丢弃策略也必须明确。过载策略不是单纯的性能参数:CoalesceLatest 会改变保留哪些工作,DropStale 会丢弃过时项,只有业务允许时才能使用。
owner 可以从环境配置、进程 cgroup 或主机物理内存探测限额,再通过 memory policy 派生可记账预算。但这些额度只覆盖显式接入预算 API 的资源,不会自动限制整个进程的 RSS。未记账的分配、调用者仍持有的副本和其他运行时开销都需要单独考虑,生产配置应结合实际测量保留余量。资源预算与队列
6 persist 接收快照与确认持久化是两次不同的承诺
MetadataIoActor 接收不可变快照,协调 generation,并通过共享 MetadataIo lane 执行真正的文件写入。actor 自身有 pending operation 和 pending bytes 上限,已排队及执行中的快照还会占用 owner 的进程预算。
submit 或 submit_next 返回 Accepted,表示拿到了 receipt;持久化完成需要继续等待 receipt,或使用 durable 提交接口并检查结果。若同一资源已有待处理工作指向不同 target,会得到 TargetConflict,不能把它当作正常接收。
同一逻辑资源的排队 generation 可以合并,较新的 durable generation 可以满足较早的等待者。这适用于“最新完整快照包含此前状态”的场景,不能直接套用到必须逐条保留的追加日志。
默认 actor 一次写一个资源,慢写会拖延其他资源。with_max_concurrent_writes(n) 允许独立资源在上限内并行,但单个资源的 generation 仍按序写入,并发也受共享 metadata lane 限制。
本地持久化路径先写临时文件并同步,再替换目标文件,并在支持的平台同步父目录。源码将这些文件系统动作与 actor 状态管理分开。即使 observer 等待超时,真实 closure 仍持有快照和预算,直到工作结束。文件系统实现
因此,超时之后不能简单声称“没有写入”,也不能盲目重试成另一个事实。未知提交结果仍有核对义务;target registry 的协调范围限于进程内部,不提供跨进程锁或通用崩溃恢复保证。关闭时应停止 actor 准入、排空已接收工作,并检查 MetadataIoShutdownReport 中未完成的 generation。元数据持久化契约
7 shutdown 用同一个截止时间收敛退出
退出路径需要同时处理停止接收、通知取消、等待清理与最终 I/O。ServiceLifecycle 区分 Starting、Ready、Draining、Stopped 和 Failed,readiness 与 liveness 也分别表达“能否接收业务”和“是否仍在有效推进”。
首次 shutdown request 会冻结一个绝对 ShutdownDeadline,后续重复信号不能延长它。组件及 owner 共享这个截止时间,避免每层重新获得一段完整 timeout,让总退出耗时不断累加。
任务组先封闭准入、广播取消,再并发关闭子组并等待本组任务,到期后 abort 未结束的 tracked tasks。组报告会缓存;owner 还会合并 blocking 工作的观察结果。shutdown_tasks_until 保留 Tokio runtime,shutdown_runtime_blocking_until 则消费 owner,在剩余预算内继续释放 runtime,后者必须从 Tokio 上下文之外调用。
这里最重要的是完成证据的层次:
- 任务完成发布发生在注册 future 销毁之后,但不能证明业务操作成功
OperationContext的等待策略可区分Completed、AbortConfirmed、Unconfirmed;已发出 abort 请求仍可能尚未确认销毁- blocking closure 可能在等待超时后继续执行,报告中的
blocking_still_running不能被忽略 - 元数据 receipt 的 durable 结果才表达对应持久化契约,任务退出不能替代它
相对时间的 operation 等待 API,必要时还会多等待最多一秒来确认 abort;需要组合统一预算时,应优先使用绝对 deadline 形式。即使接口不主动增加等待额度,runtime 饥饿或阻塞析构仍会延迟实际调度,不能把它宣传为硬实时承诺。
ShutdownReport::is_healthy() 会检查本组与子报告的 leaked、failed、panicked、timed_out 和 blocking_still_running。单独出现 aborted 不必然是不健康,但立即关闭报告也不能证明异步清理和最终 I/O 已完成。Drop 和 background shutdown 是兜底路径,有清理要求的服务应显式组织关闭顺序。完成顺序 · 关闭报告
接入时值得逐项检查的边界
当前仓库文档列出 Broker、NameServer、Proxy、Controller 入口均采用 owner 与生命周期 deadline;ClientRuntime 要求注入应用拥有的子作用域。仍需检查具体消费者:Store 存在显式兼容适配边界,不能据此推断所有调用点都走完全相同的路径。
RuntimeContext::try_from_current 面向迁移与测试:它借用现有 Tokio runtime,关闭登记的 RocketMQ 工作,但不拥有或关闭宿主 runtime,其宽松测试预算也不能当成生产内存发现结果。
做实际接入或 review 时,可以顺着动画检查:组件是否在偷偷创建 runtime;任务取消是否安全;定时工作慢于周期时会怎样;blocking 超时后额度是否保留;payload 出队后是否仍记账;metadata accepted 是否被误写成 durable;最终清理是否共享同一个 deadline。
诊断接口也有范围边界。V2 用 local、subtree、process_shared 标明观察范围,缺少调用者拥有的输入时,相应 section 缺席。详情扫描预算只约束详情,聚合扫描仍需遍历活跃项;整个快照不是跨组全局原子快照。对外暴露时应使用脱敏视图并由调用方提供鉴权。集成与诊断说明
总结
沿着七阶段动画看下来,rocketmq-runtime 的核心是把工作生命周期中的责任明确下来:入口拥有执行环境,组件拥有任务,permit 跟随资源的真实存活期,持久化 receipt 给出独立完成证据,最终通过统一 deadline 和 shutdown report 收敛退出。
这套设计的价值需要在具体负载与故障场景里验证。阅读源码时尤其要追问:当前看到的“完成”,到底是等待结束、future 销毁、blocking closure 退出,还是数据达到持久化契约?把这几个层次分清,才能正确使用图中的每一个箭头。
项目:mxsm/rocketmq-rust;延伸阅读:Runtime README、架构不变量。