Rust 所有权模型与 链路追踪 的思考
最近在给一个 Rust 写的后端服务加链路追踪,选型没什么好纠结的,tracing + opentelemetry 这一套基本就是标配。本来以为照着文档抄抄配置半天就能完事,结果被 span 的各种"不生效"折腾了两天。折腾完回头看,突然发现这事儿跟 Rust 的所有权模型其实是一回事,想明白这一点之后,之前那些别扭的地方就都顺了,记一下。
一切都要从那个 guard 说起
tracing 里 span 的标准用法长这样:
let span = info_span!("handle_request");
let _guard = span.enter();
do_something();
// _guard 在这里被 drop,span 到这里才算退出
重点是那个 _guard。guard 这玩意儿活着,span 就处于"进入"状态,guard 一 drop,span 就退出了。这就是标准的 RAII,说白了就是所有权:guard 被这个函数拥有,作用域结束,值被释放,附带效果是 span 关闭。
我一开始图省事写过这种代码:
span.enter(); // guard 没人接,这一行结束当场就 drop 了
do_something();
编译起来毫无问题,运行也不 panic,就是 span 进去就出来了。Jaeger 里这个 span 的时长永远是 0,日志也全都没挂上 trace id,查的时候点开什么都没有... 一开始还以为是 collector 配置的问题,排查了半天,最后发现是我自己把 guard 丢了。这种 bug 纯纯是静默的,你得自己意识到"哦,这里有个值刚出生就死了"。
跨 await 就更讲究了
同步代码里 guard 模式没什么毛病,但现在是 async 的世界,坑就来了:如果你在一个 async fn 里 enter() 了 span,中间又有个 .await,这个 guard 就会被拿着跨过 await 点。多线程 runtime 下任务随时可能被调度到别的线程,而"当前 span"是 thread-local 的,于是就出现了任务在 A 线程等 IO、日志却打到 B 线程的当前 span 里这种灵异现象。
正确的姿势是别用 enter(),改用 instrument:
#[instrument(skip(req))]
async fn handle_request(req: Request) -> Response {
// 整个 future 的生命周期里都带着这个 span
}
或者手动来:
tokio::spawn(handle_request(req).instrument(info_span!("handle")));
这两种写法本质上都是把 span move 进了 future。不是借用,是移交所有权,span 跟着任务走,任务完成或者被取消,future 被 drop,span 也就顺理成章地关闭了。你看,borrow checker 逼着你做的这个选择,恰好就是链路追踪语义上正确的选择:span 的生命周期就该和这段执行绑在一起,而不是和某根线程绑在一起。
跨进程就没有所有权这回事了
进程内的问题好说,反正有编译器兜底。但链路追踪的重点毕竟在"链路"上,一个请求从网关到服务 A 到服务 B 再到数据库,上下文得一路跟着传。
这里就很微妙了。进程内你可以 move、可以借用、可以 Arc,怎么都行;跨进程呢?什么都不行。你不可能把一个 span 对象传给下游服务,能传的只有一个 HTTP header:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
在 axum 的 middleware 里把这串东西从 header 里解出来,拿到 trace id 和 parent span id,然后自己新建一个 span 挂上去。注意,是"新建",不是"共享"。父子 span 之间不是对象引用,是各自独立存在、靠一串拷贝过来的 id 关联起来的两个东西。
用 Rust 的话说:所有权跨不过进程边界,跨边界的只有拷贝。这个道理放在内存模型里人人都懂,但放到链路追踪里,很多人(包括之前的我)还是会下意识觉得"上下文是一个东西,传过去了"。实际上传过去的从来都是值,不是引用。
想通之后
回头看,Rust 的所有权模型几乎是把链路追踪的正确写法刻在了语言里:
- 一个值同一时间只有一个 owner,对应一个 span 在一条执行路径上只该被进入退出一次,guard 就是用来保证这个的;
- 跨任务传递要 move 或 clone,对应 span 要用
instrument显式地带进 future,而不是靠 thread-local 这种隐式共享; - 跨边界只能拷贝,对应跨服务只能传 header,靠 id 关联而不是靠引用。
当初学 Rust 的时候没少骂 borrow checker,现在链路追踪写多了,反倒觉得这老头骂得对... 上下文传递这种东西,隐式的、共享的写法短期是省事了,长期全是坑。显式地 move 过去、显式地拷贝过去,谁拥有、谁负责、什么时候结束,写得清清楚楚,排查问题的时候才不用猜。
嗯,就记到这。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。