深入 Ruby 33:进程、超时与取消
系列导航
导读 · 上一篇:32:Ractor 与对象隔离 · 下一篇:34:对象分配与 GC · 完整源码包
截止时间与任务终止是两个状态
一次等待超过期限,调用方可以决定不再等;被调用任务是否停止,还需要单独确认。网络请求超时后服务端可能继续提交,子进程收到信号后也可能执行清理代码。把“抛出了超时异常”直接当成“所有工作已经取消”,会使重试产生重复副作用。
Taskbook 的导入通常是本地计算与文件操作。若将来调用外部转换器,应把输入、结果、截止时间和进程生命周期放到同一个调用边界管理。调用者需要知道返回的是成功结果、业务失败、超时后已回收,还是仍有任务存活。仅返回 nil 会丢失这些区别。
本章实验固定 Ruby 3.4.11,先观察 Timeout 的 ensure,再启动受控 Ruby 子进程,执行就绪握手、等待超时、TERM 和 wait 回收。所有数据均来自临时管道,没有外部服务依赖。
优先在等待操作上设置期限
从仓库根目录运行:
1 | |
一次 TCP 请求通常存在连接建立、写请求、读响应等不同阶段。库提供的 open_timeout、read_timeout 和 write_timeout 分别限制相应等待,未必覆盖完整请求的墙钟时间。重试次数也可能让整体时长超过单次超时值,因此配置必须结合调用链计算。
使用单调时钟构造统一截止点,可以让多个阶段共享预算:
1 | |
每次开始下一步都使用剩余预算,避免三个各两秒的阶段在名义“两秒请求”中累积到六秒。日期时间适合日志,单调时间适合期限;不能把经过系统校时的时间差当成可靠运行时长。
本实验用 IO.select 等待管道数据。返回 nil 表示该次等待到期,没有自动发送任何进程信号。这一行为把“截止时间判断”和“后续取消动作”分开呈现。真实 I/O 超时同样需要调用者决定关闭连接、重试还是报告结果未知。
Timeout 的当前实现与注入边界
Timeout 3.4 文档及实际发行源显示:可用 scheduler 提供 timeout_after 时,会委托调度器;否则提交请求到 timeout 管理线程。不能沿用早期实现印象,写成每次 timeout 都创建一个新的计时线程。
Timeout.timeout 在执行块周围提供异常式期限。实验让块睡眠十秒,期限设为很短的二十毫秒,在 ensure 中记录事件,然后外层捕获 Timeout::Error。验收顺序是 ensure 事件先出现,超时错误后出现。这个测试证明普通受控等待中清理块执行,并未证明任意原生代码都能在二十毫秒时立即被打断。
异步异常可能在业务的中间状态到达。文件已经写了一半、内存计数已增加、远端提交已发送,这些操作不会因为堆栈退出自动撤销。ensure 适合关闭当前拥有的资源,但没有通用回滚能力。需要原子提交的文件,应先写临时文件并在成功后替换;远端操作则需要幂等标识或查询最终结果的协议。
默认超时异常的处理方式与显式提供异常类不同,代码也可能在 ensure 中阻止预期的退出。因此官方明确限制其用于不可信块的能力。超时不是执行任意 Ruby 代码的安全沙箱;有敌对输入时,需要进程、权限及资源限制,而不是把 eval 包在 timeout 里。
子进程必须先握手再计时
实验通过 Process.spawn 启动同一个 Ruby 解释器,参数采用数组形式,输出连接到父进程持有的管道。子进程先安装 TERM 处理器,再写出 ready,并开启标准输出同步。父进程收到完整就绪行后,才把子进程视为进入被测状态。
如果启动后立即睡眠一个固定时长,再发送信号,慢机器上子进程可能尚未安装处理器;测试便混合了启动速度与信号处理行为。就绪协议使“处理器已就位”成为可观察前提。它也适合服务测试:端口可连接、配置已加载等状态都应有明确探针。
主进程关闭自己的写端很重要。管道 EOF 要等全部写端关闭才出现,父进程多保留一个写端,会使读者在子进程退出后仍等不到 EOF。文件描述符的拥有者需要跟踪到每一份继承引用,不能只看创建它的那行代码。
就绪等待本身也有五秒上限。若子进程启动失败或握手内容错误,外层 ensure 会终止并回收仍存活的子进程,关闭管道。超时测试不应成为能够无限挂住测试环境的代码。
TERM、KILL 与回收分别证明什么
收到 TERM 后,本实验子进程的处理器以退出码二十三结束。父进程通过带 WNOHANG 的 wait2 轮询,在有限宽限期内等待;若迟迟没有退出,才升级为 KILL。强制终止兜底存在,但本实验正常断言要求观察到处理器设置的退出码,因此不会把走 KILL 兜底误记为优雅退出成功。
发送信号成功,只表明信号调用没有因目标不存在等原因失败。wait2 返回状态,才给出子进程已经终止并被父进程回收的证据。退出码和因信号终止是两种不同状态,检查 status 时应选择相应字段,不能统一拿非零解释成同一种业务错误。
宽限期轮询使用单调时钟,短 sleep 只降低轮询开销,不承担就绪同步的正确性。等待上限到达后 KILL 也必须 wait;省略回收仍会遗留进程生命周期问题。代码把 pid 置空表示已回收,使 ensure 不会向一个后来被复用的进程编号误发清理信号。
这个脚本只控制一个没有子孙进程的受控 Ruby 进程。真实转换器可能派生其他进程,终止父进程不能推出整个进程树退出。进程组、作业对象或容器级管理应按平台明确设计;不要把实验中的单进程成功扩写为跨平台进程树取消保证。
取消后的业务结果
若子任务尚未提交结果,取消后可以安全报告未完成;若副作用可能已经发生,客户端需要保留“结果未知”。把两种情况都自动重试,会使导出重复、通知重复或远端写入重复。超时错误的类型只能说明等待失败,不能解释服务端事务状态。
资源所有权可按“创建者负责关闭,转移时显式说明”组织。外层创建管道与进程,外层承担失败兜底;内部操作只在自身拥有范围内清理。借用的连接不能随意关闭,否则一个任务的超时可能破坏共享连接上其他请求。
清理也可能失败。关闭错误与原始超时同时发生时,应保留主要故障,并让清理失败可观察。示例对进程已不存在和已回收这两类状态进行窄范围处理,不用 blanket rescue 掩盖权限错误或逻辑错误。
本次输出与中断后的不变量
本次日志中依次出现 timeout_ensure、timeout_error、child_ready、deadline_expired、term_sent、reaped。子进程退出码为二十三,两端管道关闭。加载的 timeout gem 版本是 0.4.3,源码位置位于本次 3.4 安装目录;这些信息使“当前实现”有明确版本锚点。
实验还揭示了取消检查应该放在哪里:就绪之前失败要处理启动资源,就绪之后超时要处理运行中任务,结果取得之后则不能再把同一个 pid 当作活动任务清理。把三段状态用一个布尔 cancelled 混在一起,容易在异常路径重复发送信号或遗漏回收。
对于可协作任务,可以让工作者在批次边界检查取消标志,并在保持业务不变量的地方退出。这样取消延迟受批次时长影响,但退出位置可预测。异步异常提供不同控制能力,却要求被中断代码能够承受更多位置的退出;选择时应比较业务状态完整性和可接受取消延迟。
连接超时后若准备重用连接,也要确认协议是否仍对齐。部分响应残留在连接中时,下一次读取可能得到前一个请求的数据。库的关闭和池化规则属于取消后的资源契约,不能仅捕获超时就把对象重新放回连接池。
练习与验收
将子进程改为忽略 TERM,观察宽限期后的 KILL 路径,断言状态显示信号终止,且不存在未回收子进程。这个变体应使用不同期望,不能继续要求退出码二十三。
再让子进程写入结果后才延迟退出。父进程超时后检查结果是否已经存在,并讨论重试契约。验收需要同时报告时间边界、最终进程状态和副作用状态,三者不能互相替代。
| 信号或结果 | 可证明的事实 | 尚不能证明 |
|---|---|---|
| I/O 等待到期 | 当前等待超过预算 | 远端操作已撤销 |
| TERM 调用成功 | 信号已向目标发送 | 清理已完成 |
| wait2 返回 | 子进程已终止且回收 | 子孙进程全部退出 |
| ensure 事件 | 清理块到达 | 外部副作用已回滚 |
