Fiber 把暂停位置变成显式控制点

Fiber 保存一段执行过程的局部状态,允许在暂停后从原位置继续。它适合表达分段生产数据、请求等待和协作任务。一个线程内可以有多个 Fiber,但任意时刻仍只有其中一个执行;没有让出控制权的长计算,会阻止同线程其他 Fiber 前进。

这与上一章 Thread 的调度边界不同。Thread 的切换不由业务每一处显式决定;普通 Fiber 的 resume 与 yield 则把控制转移暴露为 API。理解这种最小形式,再观察 scheduler 怎样接管等待,才能分清“暂停当前任务”和“让系统自动并行计算”。

官方 Fiber 文档区分阻塞与非阻塞 Fiber;scheduler 协议描述 Ruby 在受支持操作上调用的钩子。协议定义回调形状,并不自带一个适用于所有业务的事件循环。

从两次恢复建立执行模型

从仓库根目录运行:

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

实验先创建一个普通 Fiber:

1
2
3
4
5
6
7
task = Fiber.new do |value|
next_value = Fiber.yield(value * 2)
next_value + 1
end
raise unless task.resume(3) == 6
raise unless task.resume(8) == 9
raise if task.alive?

第一次 resume(3) 启动代码,参数三进入块参数,执行到 yield(6) 后把六交还恢复者。第二次 resume(8) 的八成为之前 yield 表达式的返回值,代码继续计算九并结束。这不是重新调用整个块;局部变量和当前位置在暂停期间保留。

结束后的 Fiber 不能像普通方法一样反复调用。调用方需要区分“本次返回的是暂停结果”与“执行已经完成”,因此实验额外检查 alive?。把 Fiber 包装成迭代器时,终止条件必须进入协议,不能把某个正常业务值,例如 nil,随意当作结束信号。

局部状态保留也有内存后果。挂起的 Fiber 若引用了大数组或请求对象,这些对象仍可能存活。创建大量几乎不执行的 Fiber,不等于没有资源成本;生命周期结束后还需要释放对 Fiber 本身的引用。并发数量必须结合实际保留的数据测量。

调度器只接管它支持的等待

附件中的 scheduler.rb 固定为 PipeScheduler v1。它是单线程教学实现,只支持实验中的管道就绪与有限时长 sleep,不提供 DNS、任意阻塞器、跨线程唤醒或通用取消。代码与运行脚本一并交付,避免安装一个不断变化的异步框架之后,把框架行为误认为语言保证。

Fiber.set_scheduler(scheduler) 把对象关联到当前线程。Fiber.schedule 调用其 fiber 方法;实现创建 blocking: false 的 Fiber 并立即恢复执行。若读取空管道触发 io_wait,调度器记录描述符、关注事件和可选截止时间,随后 Fiber.yield。该任务暂停,调用链返回,其他任务获得运行机会。

kernel_sleep 记录一个单调时钟截止时间并让出控制。事件循环在根 Fiber 中调用 IO.select,等待可读、可写描述符或最近的定时器。就绪后删除对应等待条目,再恢复原 Fiber,并用事件位掩码作为 io_wait 的返回值。先删除再恢复很重要:恢复后的代码可能立即产生新的等待,不能被旧记录覆盖。

单调时钟只适合计算时间间隔,不用于显示日期。若用墙上时间作为截止值,系统时间校正可能把等待突然延长或提前。教学实现选择最早截止时间作为 select 超时,使多个定时器共享一个等待循环,而不是每个 sleep 再创建一条线程。

这个过程可以写成状态转换:

1
2
3
4
5
运行中 → 登记等待 → 暂停
↓
描述符就绪或截止时间到达
↓
删除登记 → 恢复

交错证据来自管道

实验先安排读者读取一个字节,再安排写者短暂 sleep 后写入 x。预期事件顺序是 reader_wait、writer_start、writer_done、reader_done。另有一项任务在定时等待后抛出受控异常,验证事件循环不会只完成正常任务而遗漏失败结果。

顺序断言结合 io_wait 钩子日志,说明读者确实经过调度器暂停,写者有机会前进。仅出现两个 Fiber 的输出还不够:如果输入在读取前就准备好,read 可能直接完成,不能证明等待钩子被调用。因此实验使用新建空管道,并在创建读者之后才创建写者。

这里 sleep 的作用是被测调度操作,事件依赖本身并不靠“睡足够久就当另一任务完成”。读者先执行到等待钩子,才会返回给安排写者的代码。测试固定的是实际控制转移,而非猜测系统负载。描述符编号则由运行环境决定,不应复制成跨机器不变的期望值。

读取返回的数据也有断言,避免只验证日志顺序。一个错误 scheduler 完全可以打印出正确顺序却返回错误事件位掩码;管道有效负载、钩子记录和资源关闭共同缩小了这种假通过空间。

异常与关闭属于调度器设计

普通方法有明确调用者,异常沿调用栈传播。多个协作任务交错运行后,哪个调用者负责接收任务错误,需要由调度层定义。教学实现捕获 StandardError 存入 errors,运行脚本在关闭后断言错误集合等于一个受控失败。实际库还可能提供父子任务取消或聚合异常,不能仅凭 Fiber API 推出这些策略。

移除 scheduler 时会执行关闭逻辑。实验通过 Fiber.set_scheduler(nil) 驱动剩余等待,直到任务完成,再检查关闭标志及两端管道。读写任务各自用 ensure 关闭其拥有的端点,外层也对尚未关闭的资源作兜底。重复关闭前检查状态,使错误路径不会用“已经关闭”异常遮蔽原始故障。

教学实现没有把任意异常吞掉并输出成功。受控错误被记录后必须由测试检查;意外错误增加一条记录,数量和内容断言就会失败。生产服务应保留类似可观察性,否则某个请求 Fiber 静默终止,事件循环仍存活,监控可能只看到进程健康。

只要外部库进行了未受调度器支持的阻塞调用,同线程的所有任务仍可能一起等待。异步标签不是库兼容性保证。接入数据库驱动、文件 I/O 或本地扩展时,需要单独确认等待是否走钩子,必要时使用专门工作线程,并把线程池容量计入资源预算。

CPU 工作与公平性

在一个 Fiber 内连续进行几千万次整数计算,没有 I/O 或显式让出,其余 Fiber 要等到该计算结束。scheduler 不能凭空得到一个尚未被交还的控制权。把同步方法外面套一层 Fiber,不会产生多核并行,也不会自动变成非阻塞实现。

主动按批次 yield 可以改善同线程响应性,但每批长度影响吞吐与延迟。批次太长,短任务等待;太短,调度成本增加。应测业务尾延迟,不能只测总执行时间。CPU 并行需求则需要不同的执行与隔离方案,下一章的 Ractor 也仍需评估消息开销。

本次轨迹与可迁移的边界

Ruby 3.4.11 本次输出恰好包含一次管道 io_wait,读者先等待,写者启动并写入,随后读者完成。受控异常只有一项,scheduler 的 closed 为 true,两端描述符都已关闭。该结果证明随文实现的指定路径有效,没有覆盖其他库的网络、文件或进程等待。

调度器把等待集合按 Fiber 标识索引,因此每个 Fiber 同时只登记一个等待。若扩展成一次等待多个资源,不能简单复用这个结构而遗漏其他登记;唤醒时需要原子移除同一次等待的所有候选。否则已完成的任务可能被重复恢复,触发已终止 Fiber 或破坏业务顺序。

另一个边界是跨线程唤醒。教学 unblock 直接恢复目标 Fiber,只适用于当前线程内的受控使用;真实事件循环若由其他线程提交事件,需要线程安全队列与唤醒管道,且恢复动作仍回到所属线程执行。这些能力没有被实验需求使用,因此明确排除,没有用空方法伪装完整支持。

事件循环的失败策略也不能从返回数组推导。对于请求服务,通常需要把任务失败返回到对应请求;对于批处理,可以等所有任务结束再聚合错误。相同 scheduler 钩子可以承载不同策略,选择时先规定谁接收结果,再设计错误容器。

等待结束后的恢复值必须按钩子契约返回,不能用一个固定真值代替所有事件类型。

练习与验收

把管道改为两个读者各等待自己的管道,先写第二条,断言第二个任务先结束;不要把创建顺序当完成顺序。随后让一条管道在没有数据时关闭,检查 EOF 的业务结果与资源状态。

再增加一个持续计算的 Fiber,记录另一个定时任务实际恢复时间;实验应展示协作等待会受计算占用影响,而不是要求计时器精确到指定毫秒。保留这两类结果,有助于区分事件循环错误和任务没有让出控制。

需求 观察项 本实验的边界
暂停后继续 resume 参数与 yield 返回值 单条 Fiber 生命周期
I/O 交错 io_wait、管道数据和事件顺序 管道与有限 sleep
故障退出 错误集合、close 和描述符状态 不提供通用任务取消

参考资料

实验附件

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

系列导航

导读 · 上一篇:30:Thread、Mutex、Queue 与 GVL · 下一篇:32:Ractor 与对象隔离 · 完整源码包