01.背景
最近在用Rust实现一个功能,启动后台进程监听RPC请求,同时监控退出信号,如收到取消信号,则关闭RPC服务进程。
之前阅读过一些开源项目源码,常见做法都是在loop里嵌套tokio::select!实现轮询监听服务的同时也关注退出信号,看起来很优雅。
因此简单看了下tokio库官方文档select宏章节就开始尝试着手实现功能,没想到过程一波三折,用select宏写出合理逻辑并不简单。
02. select宏文档介绍
[官方文档](Select | Tokio - 一个异步 Rust 运行时)
tokio::select!宏允许等待多个异步计算,并在单个计算完成时返回。
异步即为async函数或代码块,调用时需要.await的都是异步。
select!宏分支语法定义为
<pattern> = <async expression> => <handler>,
这里的pattern并不像match语法里的分支那样表示判断是否当前模式匹配到给定值,比如match里经典的匹配Result的内容:
match result {
Ok(val) => {
// 处理正常值返回
}
Err(err) => {
// 处理错误信息
}
}
而是对分支语法中第二部分async表达式返回值的模式匹配,实际作用可以理解为解构返回值,并可用于第三部分handler代码里。
另一层匹配的意思是,如果async表达式返回值和pattern模式不匹配,则该分支handler代码不执行。
03. 初步构思
基于以上需求和select宏的使用说明,伪代码如下:
loop {
select! {
req = rpc_server.accept() => {
// 正常处理rpc请求req数据
}
cancelled = check_signal() => {
if cancelled {
break;
}
}
}
}
伪代码的逻辑看似毫无问题,但实际实现和调试过程中却总有奇怪的问题,原因就是出现了短路分支,下边会说明这个问题。
04. 辗转实现之路
前边需求里说需要监控退出信号,好优雅退出服务进程。
本来结合资料用tokio-util的CancellationToken即可实现,只需要select宏分支里加一个等待取消信号的分支:
_ = my_cancellation_token.cancelled() => {
// 处理退出
break;
}
然后在另一处代码里对同一个来源的token做取消操作,即可触发该分支执行
cloned_cancellation_token.cancel();
但写的时候突发奇想,如果把CancellationToken作为可选项作为参数传给主函数,然后添加Ctrl-C键盘事件检测,就可以实现既能通过取消令牌退出,也能通过手动Ctrl-C退出。
a. 仅通过取消令牌控制
此代码通过loop不间断监听新的连接请求,同时在函数外部可以通过对传入的cancellation_token进行取消操作控制退出函数内loop循环。
b. 仅通过Ctrl-C键盘事件控制
此代码将在触发Ctrl-C键盘事件后退出loop循环。
c. 尝试融合两种退出控制方法
首先cancellation_token参数变成了可选项Option<CancellationToken>,因此不能再像示例a里直接在分支中检测cancelled()检测。
第一反应是把对cancellation_token的检测独立成一个函数:
然后修改select宏里检测cancellation_token相关分支:
本着粗略看完的select宏语法和使用说明,想当然以为中间的async表达式只要满足.await()即可,库运行时会在下次轮询会自动优先处理其他分支。
然而直接迈入了一个大坑,调试了许久才发现是对于select宏了解太浅的缘故导致。
编译完程序正常运行,原来如此简单,但连接服务却超时。
加了调试信息,发现loop里一直在无限运行检测cancellation_token,马上意识到作为正常逻辑的accept一直没得到机会运行。
原因虽不甚清楚,但直觉上觉得肯定是检测取消令牌函数返回太快了。
遂在检测函数check_cancellation_token里加了个2秒的sleep,服务倒是正常了。
但这样处理并不能解决实际问题,sleep几秒合适,如果其他分支也有类似问题,依然会有冲突。
d. 二次修正版
经过仔细阅读文档和示例,才意识到分支中间部分的async表达式并非只要满足.await()就行,如果某分支完成太快则会出现短路问题。
就像生活中的电路一样,在电器旁边直接并联导线短接,会让电流直接绕开电器导致电器无法运行,还会产生安全问题烧坏整个电路引起跳闸。
解决办法除了像示例c中临时加一定时间的sleep,还可以是阻塞形式的调用,类似网络IO里的监听端accept()或channel的接收端recv()。
示例a中的cancelled()看似和is_cancelled()一样是个普通的返回bool语义的函数,但看定义就能发现端倪:
is_cancelled()立即返回当前取消令牌是否已取消
cancelled()则只有令牌已经被取消才会返回,否则会阻塞一直等待,相当于在取消之前无限时sleep,因此既不会错过检测取消,也不会影响主分支逻辑。
然后只需要把check_cancellation_token检测函数改成下边即可:
和原来不同的地方是立即返回的
is_cancelled变成了不限时阻塞的cancelled,除非收到外部取消信号。
05. 小结
虽然功能简单,实际实现和调试却耗费了相当多时间排查原因阅读文档解决问题。
经过这次实践,对Rust语法和设计的灵活以及select宏的使用有了更多的了解。