尝试使用 Ruby 3 调度器
一次失败的提案
在准备 RubyConf China 2020 的时候,我仔细检查了 Fiber 调度器 提出的补丁。当我看调度器的样例代码的时候,我发现其调用的是 Ruby 中的 IO.select
API。IO.select
API 在 Ruby 内部有多种实现,它可能调用 poll
、大尺寸 select
、POSIX 兼容的 select
取决于不同的操作系统。于是我想用一些更快的 syscall 来实现,比如 epoll
kqueue
和 IOCP
。
我做了一个相关的提案但是被拒绝了。主要问题是 Ruby 的 IO.select
API 是无状态的。如果没有含状态的注册,这些新 API 的性能甚至会不如 poll
。在 Koichi Sasada 跑了 banchmark 证明了这一点后,提案被正式拒绝。在和 Samuel Williams 在 Twitter 上讨论后,它建议我从 Scheduler
的实现上来进行注入,因为 Scheduler
本身是有状态的。于是我开始写一个 gem 作为 Ruby 3 调度器接口的概念证明。
实现调度器
本文中的 Ruby 版本是:
1 | ruby 2.8.0dev (2020-08-18T10:10:09Z master 172d44e809) [x86_64-linux] |
基本的 Scheduler 例子来自于 Ruby 的单元测试。这是 Ruby 3 调度器的测试,而不是真正用于生产的,因此是使用 IO.select
进行 I/O 多路复用。因此我们可以基于此,开发一个性能更好的 Ruby 调度器。
我们需要做一些 C 开发来支持其它 syscall,因此第一件事是兼容原始的实现。
Fallback 到 Ruby IO.select
对于 select/poll API, 不需要预先创建文件描述符,也不需要在运行时注册文件描述符。所以唯一要做的就是处理调度器触发时的行为。
1 | VALUE method_scheduler_wait(VALUE self) { |
我们花了 10 行 C 干了原来 1 行 Ruby 就干好了的事。主要是这允许我们用 C 的宏定义来控制,从而使用其它 I/O 多路复用方法,例如 epoll
and kqueue
。我们需要实现 4 个 C 方法:
1 | Scheduler.backend |
1 |
|
Scheduler.backend
是专门给调试用的,剩下 4 个 API 会注入到调度器的 Scheduelr#run
, Scheduelr#wait_readable
, Scheduelr#wait_writable
, Scheduelr#wait_any
中。
使用 epoll
和 kqueue
epoll 的三个核心 API 是 epoll_create
epoll_ctl
epoll_wait
。很好理解,我们只要在调度器初始化的时候初始化 epoll
fd,然后在注册 I/O 事件的时候调用 epoll_ctl
,最后用 epoll_wait
替换掉 IO.select
。
1 |
|
kqueue
是类似的。唯一不同的是,BSD 的注册和等待用的是同一个 API,只是参数不同,所以有点难懂。
1 |
|
使用调度器的 HTTP 服务器例子
在实现好调度器后,我们要测试调度器的性能。因此我写了一个简单的 HTTP 服务器 benchmark。
1 | require 'evt' |
比起原先阻塞的 I/O,使用 Ruby 3 非阻塞 I/O 后可以达到 3.33x 的性能,而使用 epoll
后可以达到 4.21x。服务器的例子很简单,所以当 JIT 启动时,不容易造成 ICache 不命中,因此性能进一步提升到了 4.54x。
测试是基于 Intel(R) Xeon(R) CPU E3-1220L V2 @ 2.30GHz CPU 的,而且程序是单线程的。如果有更好的 CPU,epoll
和 poll
的差距会更大。欢迎尝试,相关 gem 代码已开源。
未来工作
未来工作主要是两部分。一个是提升现有 API 的稳定性,还有就是加入 io_uring
和 IOCP
的支持。io_uring
倒是还好,但我是一点都不懂 Windows 开发。所以欢迎大家来提供意见和贡献。