Ruby Ruby 3 Fiber 变化前瞻

dsh0416 · 2020年07月26日 · 最后由 3118918283 回复于 2021年08月09日 · 7045 次阅读
本帖已被管理员设置为精华贴

随着 GitHub #3032 的合并,从 Feature #13618 开始的,关于 Ruby Fiber 调度器的讨论取得了实质性的进展。但相关的变化还没有结束。目前正在被讨论与还没有合并的 Issue 还包括 Feature #16786Feature #16792。这些 Issue 正在围绕 Ruby Fiber 调度器剩余的一些实现进行讨论,这些围绕着 Fiber 技术展开的对并发的实现,将作为 Ruby 3 并发提升的重要来源之一。

Ruby 3 Fiber 调度器会给我们带来什么?如何理解 Ruby 3 Fiber 调度器的引入?如何面对 Ruby 3 Fiber 的新变化?本文就此些问题进行一些讨论。

为什么要有 Fiber?

现代操作系统一个基本的特性就是允许多任务的执行。这个「多任务」可能是多线程或者多进程系统。对于一个 CPU,一个典型的情况是拥有 8 个左右的核心数,所以理论上只能同时执行 8 个任务。但操作系统同时执行的进程数往往有数千个,并不能「真正」同时运行。而操作系统需要在不同进程中快速切换从而实现多任务的同时运行。

现代操作系统使用的调度系统称为抢占式调度系统。简单理解,就是任务运行过程中,如果其它任务急需运行,操作系统会强制停止当前任务来执行其它任务。更传统的操作系统会使用协作式多任务(cooperative multitasking)系统来实现。也就是一个正在执行的任务必须主动宣布自己可以暂停运行,系统才会把执行权交给其它任务。Windows 3.1x、Mac OS 9 就是使用该方法进行的任务调度。

协作式多任务有着显著的优点和缺点。优点是切换的频率减少,执行效率提高了。而缺点是如果有程序发生了死循环或者长时间占用,系统就会陷入卡死,用户体验极差。

然而不同于操作系统,对于单一程序内,协作式多任务有时会带来更大的好处。由于线程是由操作系统实现和管理的,调度必须依赖操作系统,而一次操作系统的切换会带来很大的耗时。相比操作系统无法确定程序会不会发生死循环,自己的程序内部代码完全是自己控制的,如果发生死循环那必然是自己的代码问题。在自己的程序内实现一个简单的协作式多任务系统来提高并发显然是个好办法。

而 Ruby 标准库就实现了一个简单的协作式多任务系统,其中的最小的执行单元称为 Fiber 纤程。提供了 resume yieldtransfer 方法,实现了纤程之间的切换。

Fiber 的实现很简单,早年 Ruby Fiber 是基于 callercallee 来实现的。熟悉 Lisp 语言的,对这两个函数可能是再熟悉不过了。但是对于现在的 Ruby Fiber 实现,主要可以参考 Feature #14739 的实现。由于这个代码是多个机器平台的汇编实现(出于性能上的考虑),我们这里以 amd64 平台为例。

##
##  This file is part of the "Coroutine" project and released under the MIT License.
##
##  Created by Samuel Williams on 10/5/2018.
##  Copyright, 2018, by Samuel Williams. All rights reserved.
##

.text

# For older linkers
.globl _coroutine_transfer
_coroutine_transfer:

.globl coroutine_transfer
coroutine_transfer:
    # Save caller state
    pushq %rbp
    pushq %rbx
    pushq %r12
    pushq %r13
    pushq %r14
    pushq %r15

    # Save caller stack pointer
    movq %rsp, (%rdi)

    # Restore callee stack pointer
    movq (%rsi), %rsp

    # Restore callee stack
    popq %r15
    popq %r14
    popq %r13
    popq %r12
    popq %rbx
    popq %rbp

    # Put the first argument into the return value
    movq %rdi, %rax

    # We pop the return address and jump to it
    ret

简简单单,非常好理解。amd64 的 callee-saved register 是 %rbx (base pointer), %rbp (frame prointer) 以及 %r12 %r13 %r14 %r15。把这 6 个指针塞入栈,然后把栈指针 %rsp 返回。而还原一个上下文则是把这个栈顶指针找出来,然后依次取出这 6 个指针,就还原了上下文。

Fiber 与 I/O

但是要想让 Fiber 来提升 Web 系统的并发问题,还需要解决一个问题,那就是基于 I/O 的调度。我们清楚地知道,如果我们收到一个连接,在 Web 请求传输完之前,我们的 Ruby 程序什么都不能做,只能干等。而当我们处理完返回结果后,我们还是要干等到数据传输完后才能关闭连接。虽然现代的 rack 服务器例如 puma 能够异步解决这一问题。但是一旦涉及到 Redis、数据库和文件读写,我们依然逃不开这个问题。这是包括 Rails 在内的 Ruby 几乎所有 Web 框架性能问题的主要原因。

如果我们能围绕 I/O 设计一个 Fiber 调度器,那么我们就能极大提高 Ruby Web 框架的性能,但是这个问题并不是没有人做过。从早年的 EventMachine 到基于 nio4rasync,包括我自己写的 midori 内单独实现的调度器 murasaki,都是相同的原理。虽然这些框架的细节、性能和功能略有不同。

Ruby 今天 Fiber 自动异步调度仍然没有称为主流的核心原因是社区的分裂。

这几个开源的调度器都有一些小问题,然而大家的解决方法就是「一言不合,再写一个」。这使得像是 ActiveRecord 之类的常用框架都很难跟进这些快速迭代的调度器。根本方法就是大家合力来维护同一个调度器,让这个调度器进入标准库。这就是 GitHub #3032 的核心思路。

Scheduler 主要实现了三个核心的调度形式 scheduler.wait_writable scheduler.wait_readablescheduler.wait_sleep。也就是当 Fiber 需要等待 I/O 完成写入、读取或者需要休眠时,就会主动将工作权让渡出来,交给其它 Fiber。从而实现基于多个 Fiber 的单线程内的并发性能提升。

目前 Fiber 调度器剩余的问题

目前 Scheduler 使用 pollselect 方法实现 I/O 的多路复用,而未来显然会支持 Linux 上的 epoll 、BSD 上的 kqueue、Windows 上的 iocp 来实现更好的多路复用性能,而无需调整 API。因为以目前 Ruby Scheduler 的 API 定义,是可以兼容这些多路复用方法的。而至于会不会去支持 macOS 的 kqueue 可能就要打个问号了,毕竟 macOS 的多路复用实现太 buggy 了。

另一个 Ruby 3 Fiber 亟待解决的问题是目前的 Mutex 锁是基于线程的。而对于同一个 Thread 下多个 Fiber 出现的锁竞争,Mutex 会遇到不小的问题。而目前各个已有的框架都是通过元编程在业务上解决的,比如我 midori-contrib 中对 MySQL 的封装就使用了一系列奇技淫巧来避免问题。不过好在 Feature #16792 正在针对这一问题提出方案,希望在 Ruby 3 之前能够有比较好的解决。

如何迎接 Ruby 3 Fiber 的新变化?

如何你是单纯的 Ruby 高级框架的使用者,那么你几乎什么都不用做。你只需要等着你常用的框架例如 Rails、Sinatra、ActiveRecord、Sequel 更新来支持这一特性,你的 Web 性能就理应会得到质的飞跃。根据我个人的实测,Ruby 的 Web 服务受到 I/O 调度问题而损失的性能高达 80% 到 90%,这意味着随着你使用的库全面支持 Fiber 的自动调度后,性能有望提升 5-10 倍。

如果你是 Ruby 框架的维护者和贡献者,那么你要做的事情就相对比较多。本来我想在这篇文章中进一步讨论 Fiber 调度器的使用,不过由于 API 还有很大的变化的可能,并且你需要使用 ruby-head 版本才能进行体验,我决定把该内容放在之后的文章里讲。核心的就是要尽快让你的 gem 中涉及底层 I/O 调用、锁实现和计时实现兼容新 Fiber。因为对于一个任务的 I/O 阻塞来说,一处阻塞处处阻塞,良好的性能必须要由完全不阻塞的 I/O 实现才能做到,否则都会受到显著的影响。

如果你是 Ruby 的贡献者,并且于 Ruby 不需要额外引入类似 async await 的原语而实现 I/O 无痛的性能提升很感兴趣的话,Ruby 3 Fiber 调度器需要做的事情还很多。比如对 epoll kqueue iocp 的支持;比如对 Ruby 2.x 的 backports。请不要害羞,请尽情贡献你的代码吧。

Ruby 今天 27 岁了,慢慢步入中年。但是我们依然能看到这门步入中年的语言里闪烁着令人激动的新特性的光辉。也许中年危机不单单是中年危机,更是中年转机。而这份转机靠的是我们每一个 Ruby 的使用者、贡献者和宣传者,让更多的程序员开心起来。

huacnlee 将本帖设为了精华贴。 07月27日 09:55

"Fiber 与 I/O" 这一章节下面,你的这句话: "我们我们清楚地知道,如果我们收到一个连接,在 Web 请求传输完之前,我们的 Ruby 程序什么都不能做,"

"我们我们" 这段话重复了......

tcstory 回复

谢谢,已修复 typo。

好久没关注 Fiber 了... mysql2 这样的大热门的 Gem 有生产可用的 fiber 实现了么?没有的话...很难推广...

抓个虫 而 Ruby 标准库就实现了一个简单的协作式多任务系统,其中的最小的执行单元称为 Fiber 纤程。提供了 resume yield 和 transfer 方法,实现了纤程之间的切换。

//挺好的,关注下
public function a(){}
killyfreedom 回复

fiber 是线程的替代品,mysql2 是一个客户端,用不上,要用也是 active record 的线程池。用的最多的还是 server 编程

piecehealth 回复

用了 fiber, 会希望所有的 io 操作都能基于 fiber 调度,mysql 这块重头戏,这块整体底层的 io 调度如果不支持 fiber 调度,那 fiber 带来的整体的改进,大概和也就和 eventmachine 差不多...

mysql 的 c 库里面,调用 io 操作的时候会有 global vm lock 的锁的 (之前看的时候还是这样)

piecehealth 回复

有兴趣可以看下 https://ruby-china.org/topics/29041 我 16 年的帖子

12 楼 已删除
piecehealth 回复

thread 和 fiber 不存在取代、替代的说法,根据阅读源码可知,fiber 关联在 thread 下,另外你得看看 youtube 关于 3x3 guild 的设计,它的意图是线程 native 并发但是数据共享只读(为了安全)。

fiber 的存在是为了实现一种调度式编码风格,但并不能真实实现并行,跟并行扯不上关系。所以 丁同学也说了,它的前瞻是实现调度 scheduler。

其次,这个 scheduler 还要手工 consume & transfer。

fiber1.transfer

我个人认为,这种设计还是不够优雅的,比起 javascript 的 Promise,我个人认为 Promise 的 then then then 比这个手动写个 transfer 简单。

someJob()
  .then(() => () {
  }).then...

更不如 es 6 的 async await 好看...我就不写例子了

jakit 回复

Promise / async / await 根本就是 Fiber Scheduler 语义的等价写法。Fiber Scheduler 要做的就是自动 consume 和 transfer。只有在脱离 Fiber Scheduler 裸写 Fiber 的 Ruby 2.x 里才需要手动 consume 和 transfer。

Promise 在 JavaScript 里只是为了解决的 callback hell 的替代,如果要用 Promise 只需要加个类就行。这步甚至和调度没有任何关系:

class Promise
  def initialize(&callback)
    @callback = callback
  end

  def then(&resolve)
    @callback.call(resolve)
  end
end

而如果要有全局的 async await 关键字支持,在有 fiber scheduler 的情况下,transfer 也已经自动完成了,只要把 fiber chain 起来就行了:

##
# Meta-programming Kernel for Syntactic Sugars
module Kernel
  # Make fiber as async chain
  # @param [Fiber] fiber root of async chain
  def async_fiber(fiber)
    chain = proc do |result|
      next unless result.is_a? Promise
      result.then do |val|
        chain.call(fiber.resume(val))
      end
    end
    chain.call(fiber.resume)
  end

  # Define an async method
  # @param [Symbol] method method name
  # @yield async method
  # @example
  #   async :hello do 
  #     puts 'Hello'
  #   end
  def async(method)
    define_singleton_method method do |*args|
      async_fiber(Fiber.new {yield(*args)})
    end
  end

  # Block the I/O to wait for async method response
  # @param [Promise] promise promise method
  # @example
  #   result = await SQL.query('SELECT * FROM hello')
  def await(promise)
    result = Fiber.yield promise
    if result.is_a? PromiseException
      raise result.payload
    end
    result
  end
end

这点上根本不需要 Ruby MRI 解释器做额外的支持。

piecehealth 回复

java 有个 redis 的客户端,就是单线程 + 异步 io。

thread 和 fiber 有不同的适用场景。fiber 没办法抢占式调度,最后还得借助系统线程。

yfractal 回复

mysql2 是一个 mysql c client 的 wrapper,io 操作在 c 那边,用不上 fiber。用 fiber 的是https://github.com/socketry/db-mariadb

piecehealth 回复

mysql2 的 C 实现提供阻塞和非阻塞两种模式,后者可以在 Ruby 上进一步接入 Fiber。但通常 Ruby 上用的是前者。而我们可以用 Fiber 和非阻塞模式封装出一个 I/O 性能更好的,但是使用方法和前者一样的 API。要看 wrapper 的实现社区具体想怎么跟进了。

dsh0416 为 Ruby 3 Fiber 调度器设计事件库 Evt 提及了此话题。 12月22日 16:29

怎么越看越像 react-concurrent?

需要 登录 后方可回复, 如果你还没有账号请 注册新账号