Theme NexT works best with JavaScript enabled
0%

利用 Celluloid Future 在 Ruby 中平行化處理 I/O

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

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

但其實,自 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 在那裡,等待回應。