年初的時候將敝社的通知 API 效能提昇了約 90 % ,其中使用到一個技巧,就是將外部的 API calls 放進 Celluloid Futures 中作平行化的 I/O 處理。

一般我們常會看到有人在說 MRI (Matz’s Ruby Implementation) 因為有 Global Interpreter Lock 的緣故,每個 process 最多只能完整地使用一個 CPU 核心的資源,沒辦法作到平行化處理,真的需要平行化處理,需要使用其它 Ruby VM 實作,通常會是最成熟的 JRuby。 或者你想玩最不成熟最實驗性,由我寫的 GobiesVM 也沒人阻止你。

但其實,自 1.9 開始, MRI 就已經有了 OS 原生等級的 thread 支援,所以你在 Ruby 裡建立的 thread 其實與你在 C 裡建立的 thread 所能提供的平行化相去不遠,只是不能吃滿 CPU 而已。

簡單來說,如果你所需要的平行化處理並不會需要大量的 CPU 資源,而是像網路連線之類會有等待的 I/O 時, MRI 本身的平行化能力就以足夠讓你的程式加速數倍了。

來個例子,下面的這段程式是沒有平行化處理的版本,所以會依序等候每一個 HTTP request 完成後才處理下一個,如果剛好有一個 request 花了比較長的時間回應,整支程式就會停在那裡等,整體的執行時間就會是 (等待時間1 + 等待時間2 + 等待時間3)

1
2
3
4
require 'open-uri'
urls = %w[brucehsu.org life.brucehsu.org blog.brucehsu.org]
urls.each { |url| open("http://#{url}") }

接著則是使用 MRI 提供的 thread 來達到平行化處理的版本,在這個版本中,執行時間會是 max([等待時間1, 等待時間2, 等待時間3])

1
2
3
4
5
6
7
8
require 'open-uri'
urls = %w[brucehsu.org life.brucehsu.org blog.brucehsu.org]
threads = []
urls.each do |url|
threads << Thread.new { open("http://#{url}") }
end
threads.each { |t| t.join }

這樣就可以很簡單地透過平行化來加快多個 I/O 事件的執行時間了。

「咦,那標題的 Celluloid Future 是要用在哪裡?」

很多時候,我們所需要作的,並不只是單純的 I/O ,而是透過 I/O 取得資料後的處理。如果資料彼此之間沒有相依性,當然可以很簡單地在 thread block 中就解決掉,但是若現在要作的是像對資料排序,這種會需要處理到資料之間關係的動作,就會遇到同一個物件被多個 threads 存取時的 synchronization 問題。(附帶一提,在 MRI 當中因為有 GIL 的緣故,所以你就算完全沒有處理這個問題基本上也不太會出錯,但是拿到 JRuby 上就會有不可預期的結果。這裡當然是建議大家,既然我們知道這個問題的存在就正視它吧,更何況 Ruby 3.0 的一個重點就是要將 GIL 拿掉呢。)

這裡要介紹的 Celluloid Future ,其實就是一個將 thread 包裝起來的抽象化概念,讓使用者可以不用處理到複雜且容易出錯的 synchronization 。

我們直接來改寫上面的程式碼片段:

1
2
3
4
5
6
7
8
9
10
11
12
require 'open-uri'
require 'celluloid'
require 'celluloid/future'
urls = %w[brucehsu.org life.brucehsu.org blog.brucehsu.org]
futures = []
urls.each do |url|
futures << Celluloid::Future.new { open("http://#{url}") }
end
futures.each { |future| puts future.value }

Celluloid Future 會幫我們將值給儲存起來,當呼叫 future.value 時,才會嘗試存取結果。在這個例子裡,若是 HTTP request 尚末完成,整支程式則會 block 在那裡,等待回應。