Ractor 是 Ruby 3 新引入的特性。Ractor 顧名思義是 Ruby 和 Actor 的組合詞。Actor 模型是一個基於通訊的、非鎖同步的並發模型。基於 Actor 的並發模型在 Ruby 中有很多應用,比如 concurrent-ruby 中的 Concurrent::Actor。Concurrent Ruby 雖然引入了大量的抽象模型,允許開發高並發的應用,但是它並不能擺脫 Ruby 的 GIL (Global Interpreter Lock),這使得同一時間,只有一個線程是活躍的。所以通常 concurrent-ruby 需要搭配無鎖的 JRuby 解釋器使用。然而,直接解除 GIL 鎖會導致大量默認 GIL 可用的依賴出現問題,在多線程開發中會產生難以預料的線程競爭問題。
去年在 RubyConf China 的時候,我問 matz 說 90 年代多核的小型機以及超級計算機已經變得非常普遍了,為什麼會把 Ruby 的多線程設計成這樣呢?matz 表示,他當時還在用裝著 Windows 95 的 PC,如果他知道以後多核會那麼普遍,他也不會把 Ruby 設計成這樣。
什麼數據可以在 Ractor 間共享?但是,歷史遺留問題依然需要解決。隨著 Fiber Scheduler 在 Ruby 3 引用來提高 I/O 密集場景下單一線程利用率極低的問題;我們需要進一步解決,計算密集場景下,多線程的利用率。
為了解決這一問題,Ruby 3 引入了 Ractor 模型。Ractor 本質來說還是 Thread 線程,但是 Ractor 做了一系列的限制。首先,鎖是不會在 Ractor 之間共享的;也就是說,不可能有兩個線程爭搶同一個鎖。Ractor 和 Ractor 之間可以傳遞消息。Ractor 內部具有全局鎖,確保 Ractor 內的行為和原先 Thread 是一致的。傳遞消息必須是值類型的,這意味著不會有指針跨 Ractor 生存,也會避免數據競爭問題。簡而言之,Ractor 把每個 Thread 當作一個 Actor。
但 Ruby 沒有真正的值類型。但值類型的本質就是用拷貝來替代引用。我們要做的就是確保 Ruby 對象的可拷貝性。我們查看 Ractor 的文檔,我們可以看到這個的嚴格描述:
Ractors don't share everything, unlike threads.
* Most objects are *Unshareable objects*, so you don't need to care about thread-safety problem which is caused by sharing.
* Some objects are *Shareable objects*.
* Immutable objects: frozen objects which don't refer to unshareable-objects.
* `i = 123`: `i` is an immutable object.
* `s = "str".freeze`: `s` is an immutable object.
* `a = [1, [2], 3].freeze`: `a` is not an immutable object because `a` refer unshareable-object `[2]` (which is not frozen).
* Class/Module objects
* Special shareable objects
* Ractor object itself.
* And more...
Ractor 性能提升測試為了測試出 Ractor 的效果,我們需要一個計算密集的場景。最計算密集的場景,當然就是做數學計算本身。比如我們有下面一個程序:
DAT = (0...72072000).to_a
p DAT.map { |a| a**2 }.reduce(:+)這個程序計算 0 到 72072000 的平方和。我們運行一下這個程序,得到運行時間是 8.17s。
如果我們用傳統的多線程來寫,我們可以把程序寫成這樣:
THREADS = 8
LCM = 72072000
t = []
res = []
(0...THREADS).each do |i|
r = Thread.new do
dat = (((LCM/THREADS)*i)...((LCM/THREADS)*(i+1))).to_a
res << dat.map{ |a| a ** 2 }.reduce(:+)
end
t << r
end
t.each { |t| t.join }
p res.reduce(:+)運行後,我們發現,雖然確實創建了 8 個系統線程,但是總運行時間變成了 8.21s。沒有顯著的性能提升。
使用 Ractor 重寫程序,主要需要改變我們子線程內需要訪問外面的 i 變量,我們用消息的方法傳遞進去,改進後的代碼會變成這樣:
THREADS = 8
LCM = 72072000
t = []
(0...THREADS).each do |i|
r = Ractor.new i do |j|
dat = (((LCM/THREADS)*j)...((LCM/THREADS)*(j+1))).to_a
dat.map{ |a| a ** 2 }.reduce(:+)
end
t << r
end
p t.map { |t| t.take }.reduce(:+)其結果如何呢?我們根據不同的線程數量進行了測試。
ThreadsAMD Ryzen 7 2700xIntel i7-6820HQ18.17112.02724.4836.91334.8746.75542.3536.18852.4295.15462.2595.32071.9085.36882.1565.75492.136
103.159
112.577
122.679
132.787
142.615
152.197
162.303Ractor 確實改善了多線程全局解釋鎖的問題。
顯微鏡下的 Ractor我使用了 AMD uProf(對於 Intel CPU,可以使用 Intel VTune)進行 CPU 運算情況的統計。為了降低睿頻對單線程性能的影響,我將 AMD Ryzen 7 2700x 全核心鎖死 4.2GHz。
對於 AMD Ryzen 7 2700x,4 線程比單一線程快了 3 倍多。到 4 線程,比單一線程快了約 4 倍。AMD Ryzen 7 2700x 是一款 8 核心 16 線程的 CPU。同時,每 4 個核心組成一個 CCX,跨 CCX 的內存訪問有額外的代價。這使得 4 線程內性能提升很顯著,超過 4 線程後受限於 CCX 和 SMT,性能提升變得比較有限。其表現是隨著線程數的增加,IPC(每時鐘周期指令數)開始下降。在單線程運算時,每時鐘周期 CPU 可以執行 2.42 個指令;但到了 16 線程運算時,每時鐘周期 CPU 只能執行 1.40 個指令。同時,更多的線程意味著更複雜的作業系統的線程調度,使得多核的利用率越來越低。
同樣,對於 Intel i7-6820HQ,我們得到了類似的結論。這是一款 4 核 8 線程的 CPU,由於第 5 個線程開始需要使用 HT,從而提升變得很有限。
Ractor 如何改善現有 Ruby 程序的性能?Ractor 的引入除了可以改善計算密集場景下的運算效率,對於現有大型 Ruby Web 程序的內存佔用也是有積極意義的。現有 Web 伺服器,比如 puma,由於 I/O 多路復用性能極其低下,通常會使用多線程 + 多進程的形式來提升性能。由於 Web 伺服器可以自由水平擴展,使用多進程的形式來管理,可以完全解開 GIL 鎖的問題。
但是 fork 指令效率低下。微軟在 2019 年 HOTOS 上給出了一篇論文:A fork() in the road,和 spawn 相比,fork 模式會導致啟動速度變得非常慢。為了緩解這一問題,在 Ruby 2.7 引入 GC.compact 後,通常需要執行多次 compact 來降低 fork 啟動的消耗。進一步地,使用 Ractor 來替代多進程管理,可以更容易地傳遞消息,復用可凍結的常量,從而降低內存佔用。
總結Ruby 3 打開了多線程的潘多拉盒子。我們可以更好利用多線程來改善性能。但是看著 CPU Profiler 下不同線程調用會導致 CPU IPC 下降和緩存命中下降,對程序調優也提出了更高的要求。
我們邊走邊看吧。