我們如何實現非阻塞I / O來提高應用程序的性能
為什麼非阻塞IO更具可擴展性?
在幾乎所有現代Web應用程序中,我們都有很多I / O。 我們與數據庫對話並要求記錄或插入/更新它們。 通常,我們從硬盤訪問一些文件,這又是一個I / O操作。
我們正在討論不同的第三方Web服務,例如OAuth集成或其他功能。 如今,許多Web應用程序還以微服務的形式運行,它們必須通過HTTP請求與同一應用程序的其他部分進行對話。
如果您使用Ruby,Python或許多其他語言編寫Web應用程序,則默認情況下所有這些與I / O相關的任務都處於阻塞狀態,這意味著該過程將等待直到收到響應,然後繼續執行程序。
另一方面,Node.js [1]默認情況下正在使用非阻塞I / O。 因此,該過程可以繼續在其他地方工作,並在請求完成時執行回調或promise。
這使操作系統可以充分利用一個CPU內核。 但是,其他編程語言也可以使用非阻塞編程模型嗎?
是的! 在此博客文章中,我們將討論如何使用(幾乎)非阻塞I / O在Ruby中編寫本機事件循環,然後瞭解如何改進此設計。
簡單實現
首先,讓我們看一個可行的本機實現:
<code>def
process_partial_file_input
(data)
end
big_file ="network/path/big_file.xlsx"
file_handler = open(big_file) files_to_look_after = [file_handler] loopdo
puts"(Re)Starting the Event loop"
readable, _writeable = IO.select files_to_look_after, [], [],0
.01
if
readable readable.eachdo
|ready_io|
read_data = ready_io.read_nonblock(4096
) process_partial_file_input read_dataif
read_datarescue
EOFError => e files_to_look_after.reject! {|file|
file == ready_io }end
end
break
if
files_to_look_after.empty?end
/<code>
在討論如何改進此設計之前,讓我們簡短地討論IO.select方法,因為這是事件循環的核心。
IO.select
如評論中所述,此方法是跨平臺的,可在運行程序的任何地方使用。
它採用的第一個參數是程序要讀取的I / O描述符數組(文件描述符,Unix套接字或類似的東西)。
第二個數組仍然是I / O描述符的數組,但這一次它用於可寫連接。
第三個數組是錯誤數組。
最後,最後一個參數是超時。 這是該方法阻塞的最長時間。 因此,在上面的示例中,我們可以說一個刻度至少為10毫秒,這取決於數據處理所花費的時間。
簡單的事件循環的設計討論
當我們看一下這段代碼時,缺點很明顯。 併發引入的複雜性與業務邏輯糾纏在一起,並且分離很困難。
事件循環知道我們的業務邏輯,因為它立即調用了該方法。 我們可以藉助可處理所有讀/寫事件的寄存器來改善此情況。
寄存器可以利用帶有兩個鍵的簡單哈希來進行讀寫,然後在其中保存回調。 在Ruby中,回調可以是任何塊,proc或lambda。 同樣,一個簡單的實現可能看起來像這樣:
<code>class
CallbackRegister
def
initialize
@callbacks = {read:
[],write:
[]}end
def
each
(type, &block)
@callbacks[type].eachdo
|callback|
yield
callbackend
end
def
push
(callback, type)
@callbacks[type] << callbackend
end
big_file ="network/path/big_file.xlsx"
file_handler = open(big_file) files_to_look_after = [file_handler] register = get_callback_register_from_container_manager loopdo
puts"(Re)Starting the Event loop"
readable, _writeable = IO.select files_to_look_after, [], [],0
.01
if
readable readable.eachdo
|ready_io|
read_data = ready_io.read_nonblock(4096
) register.each(:read
)do
|callback|
callback.call(ready_io, read_data)end
rescue
EOFError => e files_to_look_after.reject! {|file|
file == ready_io }end
end
break
if
files_to_look_after.empty?end
/<code>
現在,我們已將業務邏輯與併發邏輯分離。 但這仍然會導致回調地獄。
JavaScript曾經有很多這個問題,但是它通過promise以及最近的async await功能解決了這個問題。 這樣,您可以編寫可同時運行的順序代碼。
儘管如此,我們在此設計中還有其他缺點。 它仍然使用一組固定的描述符來照料,並且我們沒有地方在運行時進行配置。 此外,儘管我們可能不希望這樣做,但每個回調事件都會收到通知,通知每個回調事件。
我們該如何改善? 符合反應堆模式。
反應堆模式
反應器模式是大多數事件循環的基礎。 它將應用程序邏輯與切換實現完全分開,因此使代碼更易於維護和重用。
它由兩個主要部分組成:一個事件多路複用器和一個調度程序,並與另外兩個一起工作-資源和請求處理程序。
反應器使用單線程事件循環,在事件多路複用器中註冊資源,並在事件觸發後分派給回調。
從我們的示例中可以看出,這種方式不需要阻塞I / O,因此進程可以最大限度地利用CPU內核。
實作
Ruby中著名的實現是EventMachine,Celluloid和async。 Python也至少有一個很好的實現,即Twisted。 PHP具有ReactPHP,我可以肯定幾乎所有其他語言也都具有不錯的實現。
缺點
與其他所有內容一樣,反應堆也有一些缺點,您必須意識到這些缺點,才能做出明智的決定,即使用這種模式是否對您的用例有意義。
主要的缺點是,如果其中一個貪婪並且將花費大量時間直到完成,它將阻止所有回調。
本質上,反應堆是一種協作併發。 如上所述,反應器是單線程的,如果從一個回調中充分利用了CPU,則其他所有操作都必須等待。
另一個限制是,由於邏輯流程不是程序運行的方式,因此難以調試反應堆模式。 這也給開發人員帶來了更多的麻煩。
從這裡開始
對於併發I / O,反應堆模式是最好的選擇嗎?
實際上,不,仍然有一些方法可以對此進行改進。 如上所述,傳統的反應器使用多路分解器同步調度事件,並且必須等待回調完成。 我們也可以使用前攝器模式使此異步。
如果您仍然需要更高的性能,那就扔硬件吧! 在某些時候,這是您最好的選擇。 而且,如果您需要執行此操作,那麼微服務體系結構將派上用場,因為您可以獨立擴展應用程序的一小部分。
[1] Node.js只是一個例子,因為這是最常用的平臺,默認使用非阻塞I / O。
(本文翻譯自Gernot Gradwohl的文章《Scalable Concurrency — Meet Non-Blocking I/O》,參考:
https://medium.com/better-programming/scalable-concurrency-meet-non-blocking-i-o-edb6b39c59d7)