Mark24
【翻译】Async Ruby(异步Ruby)
- 原文作者:Bruno Sutic
- 原文链接: 《Async Ruby》
- 原文时间:2021年10月30日
- 原文讨论:Hacker News 讨论
- 译者:Mark24
- 译者 Email:mark.zhangyoung@gmail.com
- 译文链接:https://mark24code.github.io/ruby/2023/10/12/Async-Ruby.html
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.open
和 HTTParty
,这完全没问题。
输出是:
持续时间: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 或网络请求可以同时运行。
但是,例如,HTTParty
或 sleep
方法如何能同步和异步同时存在呢?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.open
和HTTParty
替换为了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!