系列导航

导读 · 上一篇:32:Ractor 与对象隔离 · 下一篇:34:对象分配与 GC · 完整源码包

截止时间与任务终止是两个状态

一次等待超过期限,调用方可以决定不再等;被调用任务是否停止,还需要单独确认。网络请求超时后服务端可能继续提交,子进程收到信号后也可能执行清理代码。把“抛出了超时异常”直接当成“所有工作已经取消”,会使重试产生重复副作用。

Taskbook 的导入通常是本地计算与文件操作。若将来调用外部转换器,应把输入、结果、截止时间和进程生命周期放到同一个调用边界管理。调用者需要知道返回的是成功结果、业务失败、超时后已回收,还是仍有任务存活。仅返回 nil 会丢失这些区别。

本章实验固定 Ruby 3.4.11,先观察 Timeout 的 ensure,再启动受控 Ruby 子进程,执行就绪握手、等待超时、TERM 和 wait 回收。所有数据均来自临时管道,没有外部服务依赖。

优先在等待操作上设置期限

从仓库根目录运行:

1
ruby examples/ruby/labs/33/run.rb

一次 TCP 请求通常存在连接建立、写请求、读响应等不同阶段。库提供的 open_timeout、read_timeout 和 write_timeout 分别限制相应等待,未必覆盖完整请求的墙钟时间。重试次数也可能让整体时长超过单次超时值,因此配置必须结合调用链计算。

使用单调时钟构造统一截止点,可以让多个阶段共享预算:

1
2
3
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
raise 'deadline expired' if remaining <= 0

每次开始下一步都使用剩余预算,避免三个各两秒的阶段在名义“两秒请求”中累积到六秒。日期时间适合日志,单调时间适合期限;不能把经过系统校时的时间差当成可靠运行时长。

本实验用 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 事件 清理块到达 外部副作用已回滚

参考资料

实验附件

下载完整实验。原始运行输出保存在仓库 `writing-plans/ruby/evidence/33/run.log`。使用 Ruby 3.4.11 从仓库根目录执行正文命令;附件与仓库实验内容一致。