兩種高效能I/O設計模式(Reactor/Proactor)的比較

發表於2014-02-17

綜述

這篇文章探討並比較兩種用於TCP伺服器的高效能設計模式。 除了介紹現有的解決方案,還提出了一種更具伸縮性,只需要維護一份程式碼並且跨平臺的解決方案(含程式碼示例),以及其在不同平臺上的微調。 此文還比較了java、c#、c++對各自現有以及提到的解決方案的實現效能。

系統I/O 可分為阻塞型, 非阻塞同步型以及非阻塞非同步型[12]。 阻塞型I/O意味著控制權只到呼叫操作結束了才會回到呼叫者手裡。 結果呼叫者被阻塞了, 這段時間了做不了任何其它事情。 更鬱悶的是,在等待IO結果的時間裡,呼叫者所線上程此時無法騰出手來去響應其它的請求,這真是太浪費資源了。拿read()操作來說吧, 呼叫此函式的程式碼會一直僵在此處直至它所讀的socket快取中有資料到來。

相比之下,非阻塞同步是會立即返回控制權給呼叫者的。呼叫者不需要等等,它從呼叫的函式獲取兩種結果:要麼此次呼叫成功進行了;要麼系統返回錯誤標識告訴呼叫者當前資源不可用,你再等等或者再試度看吧。比如read()操作, 如果當前socket無資料可讀,則立即返回EWOULBLOCK/EAGAIN,告訴呼叫read()者”資料還沒準備好,你稍後再試”。

在非阻塞非同步呼叫中,稍有不同。呼叫函式在立即返回時,還告訴呼叫者,這次請求已經開始了。系統會使用另外的資源或者執行緒來完成這次呼叫操作,並在完成的時候知會呼叫者(比如通過回撥函式)。拿Windows的ReadFile()或者POSIX的aio_read()來說,呼叫它之後,函式立即返回,作業系統在後臺同時開始讀操作。

在以上三種IO形式中,非阻塞非同步是效能最高、伸縮性最好的。

這篇文章探討不同的I/O利用機制並提供一種跨平臺的設計模式(解決方案)。 希望此文可以給於TCP高效能伺服器開發者一些幫助,選擇最佳的設計方案。下面我們會比較 Java、C#、 C++各自對探討方案的實現以及效能。 我們在文章的後面就不再提及阻塞式的方案了,因為阻塞式I/O實在是缺少可伸縮性,效能也達不到高效能伺服器的要求。

兩種IO多路複用方案:Reactor and Proactor

一般情況下,I/O 複用機制需要事件分享器(event demultiplexor [13])。 事件分享器的作用,即將那些讀寫事件源分發給各讀寫事件的處理者,就像送快遞的在樓下喊: 誰的什麼東西送了, 快來拿吧。開發人員在開始的時候需要在分享器那裡註冊感興趣的事件,並提供相應的處理者(event handlers),或者是回撥函式; 事件分享器在適當的時候會將請求的事件分發給這些handler或者回撥函式。

涉及到事件分享器的兩種模式稱為:Reactor and Proactor [1]。 Reactor模式是基於同步I/O的,而Proactor模式是和非同步I/O相關的。 在Reactor模式中,事件分離者等待某個事件或者可應用或個操作的狀態發生(比如檔案描述符可讀寫,或者是socket可讀寫),事件分離者就把這個事件傳給事先註冊的事件處理函式或者回撥函式,由後者來做實際的讀寫操作。

而在Proactor模式中,事件處理者(或者代由事件分離者發起)直接發起一個非同步讀寫操作(相當於請求),而實際的工作是由作業系統來完成的。發起時,需要提供的引數包括用於存放讀到資料的快取區,讀的資料大小,或者用於存放外發資料的快取區,以及這個請求完後的回撥函式等資訊。事件分離者得知了這個請求,它默默等待這個請求的完成,然後轉發完成事件給相應的事件處理者或者回撥。舉例來說,在Windows上事件處理者投遞了一個非同步IO操作(稱有overlapped的技術),事件分離者等IOCompletion事件完成[1]。 這種非同步模式的典型實現是基於作業系統底層非同步API的,所以我們可稱之為“系統級別”的或者“真正意義上”的非同步,因為具體的讀寫是由作業系統代勞的。

舉另外個例子來更好地理解Reactor與Proactor兩種模式的區別。這裡我們只關注read操作,因為write操作也是差不多的。下面是Reactor的做法:

  • 某個事件處理者宣稱它對某個socket上的讀事件很感興趣;
  • 事件分離者等著這個事件的發生;
  • 當事件發生了,事件分離器被喚醒,這負責通知先前那個事件處理者;
  • 事件處理者收到訊息,於是去那個socket上讀資料了。 如果需要,它再次宣稱對這個socket上的讀事件感興趣,一直重複上面的步驟;

下面再來看看真正意義的非同步模式Proactor是如何做的:

  • 事件處理者直接投遞發一個寫操作(當然,作業系統必須支援這個非同步操作)。 這個時候,事件處理者根本不關心讀事件,它只管發這麼個請求,它魂牽夢縈的是這個寫操作的完成事件。這個處理者很拽,發個命令就不管具體的事情了,只等著別人(系統)幫他搞定的時候給他回個話。
  • 事件分離者等著這個讀事件的完成(比較下與Reactor的不同);
  • 當事件分離者默默等待完成事情到來的同時,作業系統已經在一邊開始幹活了,它從目標讀取資料,放入使用者提供的快取區中,最後通知事件分離者,這個事情我搞完了;
  • 事件分享者通知之前的事件處理者: 你吩咐的事情搞定了;
  • 事件處理者這時會發現想要讀的資料已經乖乖地放在他提供的快取區中,想怎麼處理都行了。如果有需要,事件處理者還像之前一樣發起另外一個寫操作,和上面的幾個步驟一樣。

現行做法

開源C++開發框架 ACE[13](Douglas Schmidt, et al.開發) 提供了大量平臺獨立的底層併發支援類(執行緒、互斥量等)。 同時在更高一層它也提供了獨立的幾組C++類,用於實現Reactor及Proactor模式。 儘管它們都是平臺獨立的單元,但他們都提供了不同的介面。

ACE Proactor在MS-Windows上無論是效能還在健壯性都更勝一籌,這主要是由於Windows提供了一系列高效的底層非同步API。 [45]。

(這段可能過時了點吧) 不幸的是,並不是所有作業系統都為底層非同步提供健壯的支援。舉例來說, 許多Unix系統就有麻煩。因此, ACE Reactor可能是Unix系統上更合適的解決方案。 正因為系統底層的支援力度不一,為了在各系統上有更好的效能,開發者不得不維護獨立的好幾份程式碼: 為Windows準備的ACE Proactor以及為Unix系列提供的ACE Reactor。

就像我們提到過的,真正的非同步模式需要作業系統級別的支援。由於事件處理者及作業系統互動的差異,為Reactor和Proactor設計一種通用統一的外部介面是非常困難的。這也是設計通行開發框架的難點所在。

更好的解決方案

在文章這一段時,我們將嘗試提供一種融合了Proactor和Reactor兩種模式的解決方案。 為了演示這個方案,我們將Reactor稍做調整,模擬成非同步的Proactor模型(主要是在事件分離器裡完成本該事件處理者做的實際讀寫工作,我們稱這種方法為”模擬非同步“)。 下面的示例可以看看read操作是如何完成的:

  • 事件處理者宣稱對讀事件感興趣,並提供了用於儲存結果的快取區、讀資料長度等引數;
  • 除錯者等待(比如通過select());
  • 當有事件到來(即可讀),除錯者被喚醒, 除錯者去執行非阻塞的讀操作(前面事件處理者已經給了足夠的資訊了)。讀完後,它去通知事件處理者。
  • 事件處理者這時被知會讀操作已完成,它擁有完整的原先想要獲取的資料了。

我們看到,通過為分離者(也就上面的除錯者)新增一些功能,可以讓Reactor模式轉換為Proactor模式。所有這些被執行的操作,其實是和Reactor模型應用時完全一致的。我們只是把工作打散分配給不同的角色去完成而已。這樣並不會有額外的開銷,也不會有效能上的的損失,我們可以再仔細看看下面的兩個過程,他們實際上完成了一樣的事情:

標準的經典的 Reactor模式:

  • 步驟 1) 等待事件 (Reactor 的工作)
  • 步驟 2) 發”已經可讀”事件發給事先註冊的事件處理者或者回撥 ( Reactor 要做的)
  • 步驟 3) 讀資料 (使用者程式碼要做的)
  • 步驟 4) 處理資料 (使用者程式碼要做的)

模擬的Proactor模式:

  • 步驟 1) 等待事件 (Proactor 的工作)
  • 步驟 2) 讀資料(看,這裡變成成了讓 Proactor 做這個事情)
  • 步驟 3) 把資料已經準備好的訊息給使用者處理函式,即事件處理者(Proactor 要做的)
  • 步驟 4) 處理資料 (使用者程式碼要做的)

在沒有底層非同步I/O API支援的作業系統,這種方法可以幫我們隱藏掉socket介面的差異(無論是效能還是其它), 提供一個完全可用的統一“非同步介面”。這樣我們就可以開發真正平臺獨立的通用介面了。

TProactor

我們提出的TProactor方案已經由TerabitP/L [6]公司實現了。 它有兩種實現: C++的和Java的。C++版本使用了ACE平臺獨立的底層元件,最終在所有作業系統上提供了統一的非同步介面。

TProactor中最重要的元件要數Engine和WaitStrategy了。 Engine用於維護非同步操作的生命週期;而WaitStrategy用於管理併發策略。 WaitStrategy和Engine一般是成對出現的, 兩者間提供了良好的匹配介面。

Engines和等待策略被設計成高度可組合的(完整的實現列表請參照附錄1)。TProactor是高度可配置的方案,通過使用非同步核心API和同步Unix API(select(), poll(), /dev/poll (Solaris 5.8+), port_get (Solaris 5.10),RealTime (RT) signals (Linux 2.4+), epoll (Linux 2.6), k-queue (FreeBSD) ),它內部實現了三種引擎(POSIX AIO, SUN AIO and Emulated AIO)並隱藏了六類等待策略。TProactor實現了和標準的 ACE Proactor一樣的介面。這樣一來,為不同平臺提供通用統一的只有一份程式碼的跨平臺解決方案成為可能。

Engines和WaitStrategies可以像樂高積木一樣自由地組合,開發者可以在執行時通過配置引數來選擇合適的內部機制(引擎和等待策略)。可以根據需求設定配置,比如連線數,系統伸縮性,以及執行的作業系統等。如果系統支援相應的非同步底層API,開發人員可以選擇真正的非同步策略,否則使用者也可以選擇使用模擬出來的非同步模式。所有這一切策略上的實現細節都不太需要關注,我們看到的是一個可用的非同步模型。

舉例來說,對於執行在Sun Solaris上的HTTP伺服器,如果需要支援大量的連線數,/dev/poll或者port_get()之類的引擎是比較合適的選擇;如果需要高吞吐量,那使用基本select()的引擎會更好。由於不同選擇策略內在演算法的問題,像這樣的彈性選擇是標準ACE Reactor/Proactor模式所無法提供的(見附錄2)。

在效能方面,我們的測試顯示,模擬非同步模式並未造成任何開銷,沒有變慢,反倒是效能有所提升。根據我們的測試結果,TProactor相較標籤的ACE Reactor在Unix/Linux系統上有大約10-35%效能提升,而在Windows上差不多(測試了吞吐量及響應時間)。

效能比較 (JAVA / C++ / C#).

除了C++,我們也在Java中實現了TProactor。 JDK1.4中, Java僅提供了同步方法, 像C中的select() [78]。 Java TProactor基於Java的非阻塞功能(java.nio包),類似於C++的TProactor使用了select()引擎。

圖1、2顯示了以 bits/sec為單位的傳輸速度以及相應的連線數。這些圖比較了以下三種方式實現的echo伺服器:標準ACE Reactor實現(基於RedHat Linux9.0)、TProactor C++/Java實現(Microsoft Windows平臺及RedHat v9.0), 以及C#實現。測試的時候,三種伺服器使用相同的客戶端瘋狂地連線,不間斷地傳送固定大小的資料包。

這幾組測試是在相同的硬體上做的,在不同硬體上做的相對結果對比也是類似。

io_dp_fig_1
圖 1. Windows XP/P4 2.6GHz HyperThreading/512 MB RAM.
io_dp_fig_2
圖 2. Linux RedHat 2.4.20-smp/P4 2.6GHz HyperThreading/512 MB RAM.

使用者程式碼示例

下面是TProactor Java實現的echo伺服器程式碼框架。總的來說,開發者只需要實現兩個介面:一是OpRead,提供存放讀結果的快取;二是OpWrite,提供儲存待寫資料的快取區。同時,開發者需要通過回撥onReadComplated()和onWriteCompleted()實現協議相關的業務程式碼。這些回撥會在合適的時候被呼叫。

結束語

TProactor為多個平臺提供了一個通用、彈性、可配置的高效能通訊元件,所有那些在附錄2中提到的問題都被很好地隱藏在內部實現中了。

從上面的圖中我們可以看出C++仍舊是編寫高效能伺服器最佳選擇,雖然Java已緊隨其後。然而因為Java本身實現上的問題,其在Windows上表現不佳(這已經應該成為歷史了吧)。

需要注意的是,以上針對Java的測試,都是以裸資料的形式測試的,未涉及到資料的處理(影響效能)。

縱觀AIO在Linux上的快速發展[9], 我們可以預計Linux核心API將會提供大量更加強健的非同步API, 如此一來以後基於此而實現的新的Engine/等待策略將能輕鬆地解決能用性方面的問題,並且這也能讓標準ACE Proactor介面受益。

附錄 I

TProactor中實現的Engines 和 等待策略

引擎型別 等待策略 作業系統
POSIX_AIO (true async)
aio_read()/aio_write()
aio_suspend()
Waiting for RT signal
Callback function
POSIX complained UNIX (not robust)
POSIX (not robust)
SGI IRIX, LINUX (not robust)
SUN_AIO (true async)
aio_read()/aio_write()
aio_wait() SUN (not robust)
Emulated Async
Non-blocking read()/write()
select()
poll()
/dev/poll
Linux RT signals
Kqueue
generic POSIX
Mostly all POSIX implementations
SUN
Linux
FreeBSD

附錄 II

所有同步等待策略可劃分為兩組:

  • edge-triggered (e.g. Linux實時訊號) – signal readiness only when socket became ready (changes state);
  • level-triggered (e.g. select(), poll(), /dev/poll) – readiness at any time.

讓我們看看這兩組的一些普遍的邏輯問題:

  • edge-triggered group: after executing I/O operation, the demultiplexing loop can lose the state of socket readiness. Example: the “read” handler did not read whole chunk of data, so the socket remains still ready for read. But the demultiplexor loop will not receive next notification.
  • level-triggered group: when demultiplexor loop detects readiness, it starts the write/read user defined handler. But before the start, it should remove socket descriptior from theset of monitored descriptors. Otherwise, the same event can be dispatched twice.
  • Obviously, solving these problems adds extra complexities to development. All these problems were resolved internally within TProactor and the developer should not worry about those details, while in the synch approach one needs to apply extra effort to resolve them.

資源

[1] Douglas C. Schmidt, Stephen D. Huston “C++ Network Programming.” 2002, Addison-Wesley ISBN 0-201-60464-7

[2] W. Richard Stevens “UNIX Network Programming” vol. 1 and 2, 1999, Prentice Hill, ISBN 0-13- 490012-X

[3] Douglas C. Schmidt, Michael Stal, Hans Rohnert, Frank Buschmann “Pattern-Oriented Software Architecture: Patterns for Concurrent and Networked Objects, Volume 2” Wiley & Sons, NY 2000

[4] INFO: Socket Overlapped I/O Versus Blocking/Non-blocking Mode. Q181611. Microsoft Knowledge Base Articles.

[5] Microsoft MSDN. I/O Completion Ports.
http://msdn.microsoft.com/library/default.asp?url=/library/en- us/fileio/fs/i_o_completion_ports.asp

[6] TProactor (ACE compatible Proactor).
www.terabit.com.au

[7] JavaDoc java.nio.channels
http://java.sun.com/j2se/1.4.2/docs/api/java/nio/channels/package-summary.html

[8] JavaDoc Java.nio.channels.spi Class SelectorProvider
http://java.sun.com/j2se/1.4.2/docs/api/java/nio/channels/spi/SelectorProvider.html

[9] Linux AIO development
http://lse.sourceforge.net/io/aio.html, and
http://archive.linuxsymposium.org/ols2003/Proceedings/All-Reprints/Reprint-Pulavarty-OLS2003.pdf

更多

Ian Barile “I/O Multiplexing & Scalable Socket Servers”, 2004 February, DDJ

Further reading on event handling
– http://www.cs.wustl.edu/~schmidt/ACE-papers.html

The Adaptive Communication Environment
http://www.cs.wustl.edu/~schmidt/ACE.html

Terabit Solutions
http://terabit.com.au/solutions.php

關於作者

Alex Libman has been programming for 15 years. During the past 5 years his main area of interest is pattern-oriented multiplatform networked programming using C++ and Java. He is big fan and contributor of ACE.

Vlad Gilbourd works as a computer consultant, but wishes to spend more time listening jazz :) As a hobby,he started and runs www.corporatenews.com.au website.

相關文章