Ruby 【翻译】Async Ruby(异步 Ruby)

Mark24 · 2023年10月12日 · 最后由 Mark24 回复于 2024年04月02日 · 2363 次阅读
本帖已被管理员设置为精华贴

【翻译】Async Ruby(异步 Ruby)

Ruby 已经有了异步实现!

它现在就可使用,已经做好了投入生产的准备,而且它可能是过去十年甚至更久时间里 Ruby 发生的最令人振奋的事情。

Async Ruby 给这门语言添加了新的并发特性;你可以将其视为“没有任何缺点的线程”。它已经在酝酿了几年,也终 于在 Ruby 3.0 中准备好进入主流。

在这篇文章中,我希望向你展示异步 Ruby 的所有力量、可扩展性和魔力。如果你热爱 Ruby,那这应该会让你非常激动!

Async gem

什么是 Async Ruby?

首先,Async 只是一个 gem,可以通过 gem install async 进行安装。这是一个相当特殊的 gem,因为 Matz( Ruby 的创始人) 请它加入 Ruby 的标准库,但邀请还未被接受。

Async Ruby 是由 Samuel Williams 创建的,他也是一个 Ruby 核心贡献者。Samuel 还实现了 Fiber Scheduler(纤程调度器),这是 Ruby 3.0 的一个重要特性。它是"库无关的",未来可能有其他用途,但目前,纤程调度器的主要目的是使 async gem 与 Ruby 无缝集成

并不是很多 gem 能得到他们自定义的 Ruby 集成,但这个是值得的!

所有这些都告诉你,async 不是"只是外面的另一个 gem"。Ruby 核心团队,包括 Matz 本人,都在支持这个 gem,希望它能成功。

Async 生态

Async 还是一个 gem 生态系统,这些 gem 能很好地一起工作。以下是一些最有用的例子:

  • async-http 是一个功能丰富的 HTTP 客户端
  • falcon 是围绕 Async 核心构建的 HTTP 服务器
  • async-await 是 Async 的语法糖
  • async-redis 是 Redis 客户端
  • ...以及许多其他的

虽然上面列出的每一个 gem 都提供了一些有用的东西,但事实是你只需要核心 async gem 就可以获取它的大部分好处。

异步模型(Asynchronous paradigm)

Asynchronous programming(异步编程),(在任何语言中,包括 Ruby)允许同时运行许多事情。这最常见的是多个网络 I/O 操作(如 HTTP 请求),因为在这方面 async 是最有效的。

多任务操作经常带来混乱:“回调地狱(callback hell)”,“Promise hell(Promise 地狱)”,乃至 "async-await hell(async-await 地狱)" 是其他语言中 async 接口的众所周知的缺点。

但 Ruby 是不同的。由于其超群的设计,Async Ruby 不受任何这些 *-地狱的困扰。它允许编写出令人惊喜的干净、简单且有序的代码。它是一个像 Ruby 一样优雅的 async 实现。

注意:Async 不能绕过 Ruby 的全局解释器锁(GIL)。


译者注:

  • Async gem 以及 Fiber Scheduler 都是工作在当前主线程。他们受到 GIL 约束。
  • 不受 GIL 约束参考 Ractor,Ractor 被设计用来提供 Ruby 的并行执行功能,而不需要考虑线程安全问题。

同步示例(Synchronous example)

让我们从一个简单的例子开始:

require "open-uri"

start = Time.now

URI.open("https://httpbin.org/delay/1.6")
URI.open("https://httpbin.org/delay/1.6")

puts "Duration: #{Time.now - start}"

上述代码正在发起两个 HTTP 请求。单个 HTTP 请求的总持续时间为 2 秒,包括:

  • 大约 0.2 秒的网络延迟在进行请求时
  • 1.6 秒的服务器处理时间
  • 大约 0.2 秒的网络延迟在接收响应时

让我们运行这个示例:

持续时间:4.010390391

如预期,程序需要 2 x 2 秒 = 4 秒才能完成。

这段代码还不错,但它运行速度慢。对于这两个请求,执行过程大概像这样:

  • 触发一个 HTTP(超文本传输协议)请求
  • 等待 2 秒以获取响应

问题在于程序在大部分时间里都处于等待状态;2 秒钟(对于计算机)就像永恒。

Threads(线程)

提高多个网络请求速度的常用方法是使用线程。以下是一个示例:

require "open-uri"

@counter = 0

start = Time.now

1.upto(2).map {
  Thread.new do
    URI.open("https://httpbin.org/delay/1.6")

    @counter += 1
  end
}.each(&:join)

puts "Duration: #{Time.now - start}"

代码的输出是:

持续时间:2.055751087

我们将执行时间缩短到 2 秒钟,这表明请求在同时运行。那么,问题解决了吗?

好吧,别过于着急:如果你做过任何真实世界的线程编程,你会知道线程很难。真的,非常难。

如果你打算做任何严肃的线程工作,你最好习惯使用互斥(mutexes),条件变量(condition variables),处理语言级的竞态条件(race conditions)...甚至我们的简单示例在 @counter += 1 这一行就有一个竞态条件错误!

线程是困难的,并且毫无疑问下面的声明在 Ruby 社区一直被不断提及:

I regret adding threads.

                  — Matz

Async 例子

鉴于所有的线程复杂性,Ruby 社区早就应该有一个更好的并发模式。有了 Async Ruby,我们终于有了一种。

async-http

让我们看看使用 Async Ruby 来进行两次 HTTP 请求的同样的例子:

require "async"
require "async/http/internet"

start = Time.now

Async do |task|
  http_client = Async::HTTP::Internet.new

  task.async do
    http_client.get("https://httpbin.org/delay/1.6")
  end

  task.async do
    http_client.get("https://httpbin.org/delay/1.6")
  end
end

puts "Duration: #{Time.now - start}"

示例的输出是:

持续时间:1.996420725

看看总运行时间,我们可以看出请求是同时运行的。

这个例子显示了 Async Ruby 程序的一般结构:

  • 你总是从一个传递任务的 Async 块开始。
  • 这个主任务通常用于用 task.async 生成更多的 Async 任务。
  • 这些任务相互并发运行,也与主任务并发运行。

一旦你习惯了,你会发现这个结构实际上非常整洁。

URI.open

前一个例子中可以被认为是一个缺点的事情是,它使用了 async-http,一个具有异步特性的 HTTP 客户端。我们大多数人有自己喜欢的 Ruby HTTP 客户端,我们不想再花时间去学习另一个 HTTP 库的详细情况。 让我们看收同样的例子,只是这次使用 URI.open:

require "async"
require "open-uri"

start = Time.now

Async do |task|
  task.async do
    URI.open("https://httpbin.org/delay/1.6")
  end

  task.async do
    URI.open("https://httpbin.org/delay/1.6")
  end
end

puts "Duration: #{Time.now - start}"

与前一个例子的唯一区别是,我们用 Ruby 的标准库中的方法 URI.open 替换了 async-http

示例的输出是:

持续时间:2.030451785

这个持续时间显示了两个请求是并行运行的,所以我们认为 URI.open 是异步运行的!

这一切真的很好。我们不仅不需要忍受线程及其复杂性,而且我们可以使用 Ruby 的标准 URI.open 来运行请求,

无论是在 Async 块的外部还是内部。这无疑可以为我们提供一些方便的代码重用。

其他 HTTP clients

虽然 URI.open 是普通的 Ruby,但可能并不是你喜欢的进行 HTTP 请求的方式。而且,你也不经常看到它被用于"serious work(正式的工作)"。

你可能有你自己喜欢的 HTTP gem,你可能会问 "它能在 Async 中工作吗"?为了找出答案,这里有一个使用 HTTParty(一种知名的 HTTP 客户端)的例子。

require "async"
require "open-uri"
require "httparty"

start = Time.now

Async do |task|
  task.async do
    URI.open("https://httpbin.org/delay/1.6")
  end

  task.async do
    HTTParty.get("https://httpbin.org/delay/1.6")
  end
end

puts "Duration: #{Time.now - start}"

在这个例子中,我们在一起运行了 URI.openHTTParty,这完全没问题。

输出是:

持续时间:2.010069566

它运行的时间稍微超过了 2 秒,这表明两个请求是并发运行的(同时进行)。 这里的要点是:你可以在一个 Async 上下文中运行任何 HTTP 客户端,它将会异步运行。Async Ruby 完全支持任何现有的 HTTP gem!

高级例子

到目前为止,我们只看到 Async Ruby 用各种 HTTP 客户端进行请求。让我们揭示 Async 在 Ruby 3 中的全部能力。

require "async"
require "open-uri"
require "httparty"
require "redis"
require "net/ssh"
require "sequel"

DB = Sequel.postgres
Sequel.extension(:fiber_concurrency)
start = Time.now

Async do |task|
  task.async do
    URI.open("https://httpbin.org/delay/1.6")
  end

  task.async do
    HTTParty.get("https://httpbin.org/delay/1.6")
  end

  task.async do
    Redis.new.blpop("abc123", 2)
  end

  task.async do
    Net::SSH.start("164.90.237.21").exec!("sleep 1")
  end

  task.async do
    DB.run("SELECT pg_sleep(2)")
  end

  task.async do
    sleep 2
  end

  task.async do
    `sleep 2`
  end
end

puts "Duration: #{Time.now - start}"

我们扩展了包含 URI.open 和 HTTParty 的前一个例子,增加了五个附加操作:

  • Redis 请求
  • 使用 net-ssh gem 进行的 SSH 连接
  • 使用 sequel gem 进行的数据库查询
  • Ruby 的 sleep 方法
  • 运行 sleep 可执行文件的系统命令。

这个例子中的所有操作也需要恰好 2 秒才能运行。

以下是示例输出:

持续时间:2.083171146

我们得到的输出结果和之前一样,这表明所有的操作都是并发运行的。哇,这有很多不同的 gem 可以异步运行!

重点:任何阻塞操作(Ruby 解释器等待的方法)都与 Async 兼容,并将在 Ruby 3.0 和更高版本的 Async 代码块中异步工作。

性能看起来很好:7 x 2 = 14 秒,但示例在 2 秒内完成 – 很容易得到 7 倍的提升。

Fiber Scheduler(纤程调度器)

让我们花一点时间来反思一些重要的事情。这个例子中的所有操作(例如,URI.open,Redis,sleep)都会根据上下文的不同而表现不同:

  • 同步执行: 操作默认同步执行。整个 Ruby 程序(或者更具体的说,当前的线程)会等待一个操作完成后才会进行下一个操作。
  • 异步执行: 当操作包裹在一个 Async 块中时,操作会异步地执行。由此,多个 HTTP 或网络请求可以同时运行。

但是,例如,HTTPartysleep 方法如何能同步和异步同时存在呢?Async 库是否对所有这些 gems 和内部 Ruby 方法进行了猴子补丁? 这种魔术是由于 Fiber Scheduler。这是 Ruby 3.0 的一个特性,使得 async 能够很好地与现有的 Ruby gems 和方法集成 - 不需要任何 hack 或 猴子补丁 (Monkey patch) !

Fiber Scheduler 也可以单独使用 (链接译文)!用这种方式,只需要几个内置的 Ruby 方法就能启用异步编程。

如你所想,Fiber Scheduler 触及的代码范围非常广:它是 Ruby 当前所有的阻塞 API!这绝不仅仅是一个小功能。

扩展例子

让我们提高效率,并展示一个 Async Ruby 擅长的另一方面:扩展 (scaling)。

require "async"
require "async/http/internet"
require "redis"
require "sequel"

DB = Sequel.postgres(max_connections: 1000)
Sequel.extension(:fiber_concurrency)
# Warming up redis clients
redis_clients = 1.upto(1000).map { Redis.new.tap(&:ping) }

start = Time.now

Async do |task|
  http_client = Async::HTTP::Internet.new

  1000.times do |i|
    task.async do
      http_client.get("https://httpbin.org/delay/1.6")
    end

    task.async do
      redis_clients[i].blpop("abc123", 2)
    end

    task.async do
      DB.run("SELECT pg_sleep(2)")
    end

    task.async do
      sleep 2
    end

    task.async do
      `sleep 2`
    end
  end
end

puts "Duration: #{Time.now - start}s"

此例子基于之前的那个例子,只是做了一些改动:

  • Async 区块中的所有内容都会被重复 1000.times (运行 1000 次),这将并发操作的数量增加到了 5000。
  • 出于性能考虑,我们将 URI.openHTTParty 替换为了 async-http HTTP 客户端。async-http 可以与 HTTP2 一起工作,当进行大量请求时,它的速度要快得多。
  • SSH 操作被移除了,因为我找不到一种正确的配置方法可以使其高效地工作。

就像之前一样,每个独立操作都需要 2 秒才能执行。其输出为:

持续时间:13.672289712

这表明累积运行时间为 10,000 秒的 5,000 个操作仅在 13.6 秒内就完成了!

这个持续时间比前面的例子(2 秒)要长,这是因为创建这么多网络连接的开销。

我们几乎没有进行性能调优(例如,调整垃圾收集,内存分配等),但我们仍然实现了 730 倍的“加速”,在我看来,这是一个相当令人印象深刻的结果!

扩容限制 (Scaling limits)

最好的部分是:我们只是初步探索了使用 Async Ruby 所能做到的事情。

虽然线程(Threads)的最大数量是 2048(至少在我的机器上是这样),但是 Async tasks 的上限数量是百万级别的!

你真的可以同时运行百万个异步操作吗?是的,你可以 - 已经有些用户做到了。

Async 真的为 Ruby 打开了新局面:想象一下一个 HTTP 服务器处理成千上万的客户,或者同一时间处理成百上千的 websocket 连接 ... 这都是可能的!

结论

Async Ruby 经过了漫长而神秘的开发期,但现在它稳定且已经准备好投入生产。已经有一些公司在生产环境下运行它并从中受益。要开始使用它,你可以去 Async 的仓库看看。

唯一的注意点是,它不能和 Ruby on Rails 一起工作,因为 ActiveRecord 不支持 Async gem。但如果不涉及到 ActiveRecord,你仍然可以在 Rails 中使用它。

Async 的最大优势在于扩展网络 I/O 操作,比如进行或接收 HTTP 请求。对于 CPU 密集型的工作负载,线程是更好的选择,但至少我们不再需要把他们用于所有事情。

Async Ruby 非常强大,可扩展性极高。它是一个游戏规则改变者,我希望这篇文章能证明这一点。Async 改变了 Ruby 的可能性,并且当我们所有人开始更多地“异步”思考时,它将对 Ruby 社区产生重大影响。

最好的一点是,它不会使任何现有的代码变得过时。就像 Ruby 本身一样,Async 设计得很美,使用起来也很愉快。

希望你在使用 Async Ruby 时编程愉快!

Happy hacking with Async Ruby!

Rei 将本帖设为了精华贴。 10月12日 15:01

为什么会翻译为"宝石"?

大写的方法名,好奇怪。。 另外 ruby 有 contextvar 么,类似于 python 的 contextvar, 协程级别隔离的变量

femto 回复

Thread.current,Fiber.current 往上面绑变量? 😀

Mark24 回复

Thread.current 有,Fiber.current 没有

Mark24 回复

那怎么设置 Fiber current 变量呢?

femto 回复

特性在试验中:

** Sets the storage hash for the fiber. This feature is experimental and may change in the future. **

不过依然可以给出例子

# Fiber#storage 例子
# https://devdocs.io/ruby~3.2/fiber#method-i-storage
puts "start..."

def work
  puts "work,running...."
  puts "work,set :tmp_value"

  # 可以携带数据,一直保持在这个 Fiber 中
  Fiber.current.storage = {
    payload: "some data"
  }
  puts "work,do some thing...."

  puts "work: tmp_value" ,Fiber.current.storage

  Fiber.yield 12
  puts "work,come back"
  puts "tmp_value:",Fiber.current.storage
end

puts "start...(before fiber)"
fiber = Fiber.new {
  work
}

fiber.resume
puts "back to main"
puts fiber.resume

puts "the end"

运行结果

start...
start...(before fiber)
work,running....
work,set :tmp_value
work,do some thing....
work: tmp_value
{:payload=>"some data"}  # 携带数据
back to main   # 切换会主干执行
work,come back  # 切换回 fiber
tmp_value:
{:payload=>"some data"} # 数据还在

the end

对于公共范围的变量,类似于开始例子里的@counter,要怎么保证并发安全呢?

mrxia 回复

CRuby 内部只要是 Ruby 代码,有 GIL 锁,自带线程安全。

运行的是 Ruby 代码,多线程,只会轮替的使用一个核心。

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