几天前,在2020年圣诞节那天,Matz发布了Ruby 3.0。像每年一样,新版本中包含许多有趣的新功能。到目前为止,我阅读的大多数文章都将更多的精力放在引入类型提示和Ractor系统的新方法上,但对我而言,最有趣的添加是Fiber :: SchedulerInterface类的引入。它允许(但尚未实现)更高级的基于事件循环的调度程序,用于Ruby中的非阻塞I / O。从EventMachine和Async之类的事件循环框架到C扩展发布GVL,Ruby中已经存在一些先进的技术,但是这个新接口对我来说更令人兴奋,因为它使意外地做正确的事情变得容易得多。
在本文中,我将介绍调度程序接口在“常规” Ruby中的工作方式,以及如何从MRI C扩展中访问它。我们还将看看当前界面的缺点,因为没有什么是完美的。
在MRI Ruby中,光纤是用于实现轻量级协作并发的原语。它们在某种程度上类似于传统线程,它们占用一个块并与其他光纤同时运行,但它们在线程“内部”存在许多光纤。这意味着每个线程一次最多只能运行一根光纤,但是由于它们使用的内存很少,因此可以毫无问题地创建成千上万的光纤。如果您的光纤需要执行大量计算任务,那么这不会带来任何好处,因为如果一开始的工作量不足,将工作分成许多小部分将无济于事。但是,许多Ruby进程花费大量时间等待I / O,例如等待API和数据库调用的响应,或者等待从网络套接字读取更多HTTP请求。这是纤维发亮的用例。
更多的“传统”系统通过为每个单独的请求分配一个单独的操作系统(OS)线程来管理所有这些等待,然后进行阻止系统调用以读取和写入套接字和文件。这一点可以很好地工作,但是创建OS线程的成本相对较高,并且每当需要运行另一个线程时,它们都需要将上下文切换到OS。这导致很多开销。光纤可以利用现代操作系统提供的用于非阻塞和异步I / O的设施来跳过很多此类开销。当他们意识到自己将无法取得进展时,他们通过召集收益来做到这一点,这让另一根光纤陷入了困境。例如,当调用Kernel#sleep时,或者每当read()或write()系统调用返回EWOULDBLOCK或EAGAIN错误代码时。这个故事中缺少的链接是新的光纤调度程序,它是光纤产生的代码。调度程序负责维护阻塞光纤的库存,并在阻塞光纤的原因消失时恢复这些光纤。例如,如果某条光纤因为调用sleep(10)而被阻塞,那么10秒钟后应重新恢复。如果光纤因要读取的套接字上没有可用数据而阻塞,则应在数据到达后立即恢复。
调度程序可以采用任何机制来实现此目的,但实际上有两个不错的选择:
在几乎所有的Linux系统上,epoll()是监视大量套接字以查看是否有新数据可用的机制。它还可以使用timerFD机制管理睡眠。
在非常现代的Linux系统(内核版本5.4及更高版本)上,可以使用io_uring API。通过此API,不仅可以监视套接字和管理睡眠,还可以将读取和写入调用本身转移到OS,而不是仅转移等待可用性。
在Windows系统上,可以使用与io_uring API几乎相同的方式来使用IO完成端口。
在BSD系统和类似MacOS的派生工具上,kqueue()系统调用用于安装epoll()。我还不知道正在为这些系统开发异步I / O API,但是如果有人可以对此进行纠正,我将非常高兴。
在这些可能的机制之间进行选择,以及管理Ruby对象之间的转换以及OS所需要的是调度程序的任务。由于光纤是线程本地的,并且无法在线程之间移动,因此调度程序也是如此。从理论上讲,可能有一个线程基于epoll()的调度程序,而另一个线程基于io_uring的调度程序,但实际上,调度程序可能由单独的gem提供,并自动为当前OS选择性能最高的接口。
为了使集成对于Ruby开发人员而言是无缝的,在Ruby 3.0及更高版本中,所有相关的标准库方法都已进行了修补,以便在遇到阻塞当前光纤的情况时,可以将其生成给调度程序。这些方法在撰写本文时包括Kernel.sleep,IO#wait_read,IO#wait_writable,IO#read,IO#write和其他相关方法(例如IO#puts,IO#gets),Thread#join,ConditionVariable#等待,Queue#pop,SizedQueue#push。仅当已使用Fiber.set_scheduler为线程实际定义了调度程序时,才产生调度程序的结果。当您意识到这意味着任何gem中的任何最终调用IO#read或IO#write的方法都可以使用调度程序时,无论该gem是否已在考虑调度程序的情况下编写,此方法的真正作用就显而易见。只要它们不需要过多使用C扩展,就可以立即使许多宝石如数据库驱动程序和网络库调度程序。即使他们这样做,也不会全部丢失。稍后我们将介绍如何将C扩展与调度程序集成在一起。
start = Time.now Thread.new do#在此线程中,我们将具有非阻塞光纤Fiber.set_scheduler Scheduler.new%w [2.6 2.7 3.0]。 Fiber.schedule do#在单独的Fiber中运行代码块t = Time.now#Fiber#会调用调度程序将自身添加到等待的光纤列表中,而不是在响应准备就绪时进行阻塞,并将控制权转移给其他Fibre Net :: HTTP.get(' rubyreferences.github.io&#39 ;," / rubychanges /#{version} .html")放%s:以%结尾。 3f' %[version,Time.now-t] end end end.join#在线程代码的末尾,将调度Scheduler以非阻塞的方式调度所有等待的光纤,以总计%的形式完成。 .3f' %(Time.now-start)#打印:#2.6:完成于0.139#2.7:完成于0.141#3.0:完成于0.143#总计:完成于0.146
本示例将新线程的调度程序设置为某些Scheduler类的新实例,然后使用Fiber.schedule发送三个HTTP请求,以获取多个最新Ruby版本的发行说明。从输出中我们可以看到,所花费的总时间仅比最慢的响应长几毫秒,这表明所有三个HTTP请求都是并行执行的。
假设我们在示例中没有使用上面提到的任何方法,该工作如何进行?好的,Net :: HTTP.get在其实现中使用IO#write和IO#read,因此,当请求在传输中并等待响应时,其光纤将退回给调度程序,以让其他光纤工作。由于此处的实际CPU工作量非常低,因此许多光纤可以在同一调度程序上运行,而又不会造成彼此过多的干扰。
使用Fiber调度程序自动具有IO#read和Kernel.sleep之类的功能很好,但是许多有用的Ruby gem使用C扩展来删除GVL,在Ruby垃圾收集器“下方”执行操作,或者只是为了提高执行速度计算上昂贵的操作。这些C扩展通常不会使用Ruby方法来读写文件描述符,因此,如果它们希望使用现有的光纤调度程序来有效地使用非阻塞I / O,则必须自己调用它。幸运的是,这并不难!
MRI C扩展的(非常)快速概述:Ruby对象在C中由VALUE结构表示,该VALUE结构包含有关该对象的所有相关信息。预先定义了一些“标准”值,例如true,false和nil以及其他一些值,可以将它们测试为Qnil,Qtrue等。Ruby方法可以直接在C中调用(如果Ruby方法本身是在C中定义的) )或使用rb_funcall()C函数。
显然,只有在使用Fiber#set_scheduler为当前线程定义了调度程序后,才能使用调度程序。要检查C扩展名是否属于这种情况,我们可以使用rb_scheduler_current()C函数,该函数将返回一个包含当前调度程序或Qnil的VALUE(如果尚未设置)。如果设置了调度程序,则可以使用Ruby存储库主文件夹中scheduler.c中定义的可用函数之一进行调用。最后,作为一个完整的示例,让我们看看Ruby标准库如何实现此模式:
int rb_io_wait_read(int f){值调度程序= rb_scheduler_current(); if(scheduler!= Qnil){返回RTEST(rb_scheduler_io_wait_read(scheduler,rb_io_from_fd(f))); } //如果未定义调度程序,则其余功能}
此代码段还演示了如何使用rb_io_from_fd()从文件描述符中获取Ruby IO对象。
当前的实现方式仍然存在一些缺陷。例如,光纤是线程局部的,不能在线程之间移动。 (出于本段的目的,无论我在何处编写“线程”,您都可以阅读“ Ractor”。)这意味着任何要扩展到单个线程之外的系统都必须运行多个线程,每个线程都有自己的调度程序。如果光纤任务的持续时间之间存在不平衡,则某些线程可能会在其整个工作队列中运行并处于空闲状态,而其他线程仍然有多余的工作。由于并非所有资源都得到适当利用,这将限制系统的吞吐量。诸如Haskell和Go这样的Languags通过“窃取工作”来解决此问题,无需执行任何工作的线程就可以从其他调度程序的运行队列中“窃取”工作。通过使用全局队列(或中间“管道” Ractor)来分配工作项,可以在某种程度上解决此问题,但这仅适用于较粗的项目,例如整个HTTP请求或后台作业。一旦在某个线程上启动了请求或后台作业,它将永远存在。
另一个缺点(仅适用于POSIX系统)是“非阻塞” I / O实际上有时仍会阻塞!虽然可以将任何文件描述符设置为非阻塞模式,但是只有那些表示套接字和管道的文件描述符才会返回EWOULDBLOCK。代表文件系统上实际文件的文件描述符将永远不会返回EWOULDBLOCK,即使该文件位于世界另一端的网络文件系统上并且也会阻塞很大。这更多是一个POSIX问题,并非Ruby独有,但仍需提防。光纤调度程序C接口文件确实提供了异步读写操作,这些操作可以由“真正的”异步I / O操作(例如Linux上的io_uring或Windows上的IO完成端口)支持。但是这些功能尚未记录,因此尚不清楚是否以及如何使用它们。还有更多的系统调用可能尚未被可用的异步操作覆盖(例如,对慢速文件系统上的文件进行unlink()锁定)。
最后,光纤调度机制的主要缺点是,在撰写本文时(2020年12月结束),尚未发布可用于生产的光纤调度器!我知道evt库,但无法安装它,尽管那可能是因为由于涉及该项目的另一个项目而导致我的计算机上的库现在被破坏了。 Reddit上的一些人还推测基于异步框架的调度程序可能正在开发中,但到目前为止似乎尚无消息。最后,在MRI测试套件中有一个基于IO.select的玩具调度程序,但是它似乎已损坏,因为它可以尝试恢复已经完成的纤维。良好的调度程序当前不可用,这也意味着目前我们还不能在程序启动时初始化默认调度程序,因此必须从gem导入默认调度程序,并使用Fiber#set_scheduler对其进行激活。
勘误表:/ u / ioquatix在Reddit注释中对我进行了更正,/ u / ioquatix是宝石异步系列的创建者,也是MRI中FiberScheduler接口的发起者。事实证明,异步已经包括一个功能强大的生产级调度程序,该调度程序可在今天立即使用。
我认为光纤调度程序的接口非常有趣。它在Ruby中启用了高度可扩展的IO绑定系统,同时仍为开发人员提供了一个不错的编程模型,其中没有回调和异步/等待样式的“彩色”函数。您只需以通常简单明了的方式编写光纤代码,调度程序将以高效的方式管理您的所有光纤,在它们阻塞时将它们停放在某个地方,并在它们可以再次恢复时立即进行检索。一种非常类似于Ruby的方法,旨在使程序员满意。
在第一个迭代中,系统的一些属性仍可以改进,例如线程和/或Ractor之间的光纤“窃取工作”,添加默认调度程序以及扩展可用的异步操作。可用的文档还不是很完整。尽管如此,随着这些系统的广泛使用,我们将看到大多数问题都消失了。看看Ruby是如何发展的将会很有趣!