[Twisted] Part 2: Slow Poetry and the Apocalypse

本文由Dave的Part 2: Slow Poetry and the Apocalypse翻譯而成,你可以由Part 1開始閱讀這個系列的文章,也可以在這裡找到整個系列的目錄。

My Assumptions About You

在展開討論前,我假設你已經有過用Python寫同步程式的經歷,並且至少知道一點有關Python的Socket程式設計的經驗。如果你從沒有寫過Socket程式,或許你可以去看看Socket模組的文件,尤其是後面的範例程式。如果你沒有用過Python的話,那這份介紹往後的部分可能相當難以理解。

My Assumptions About Your Computer

我一般是在Linux上使用Twisted,這個系列的範例也是在Linux完成的。首先聲明的是我並沒有故意讓程式碼依賴於Linux,但我所講述的一些內容可能僅適應於Linux和其它的類Unix系統(比如MAC OSX或FreeBSD)。WIndows是個奇怪詭異的地方,如果你想嘗試在它上面學習這個系列,除了衷心的同情外,我無法為你提供更多的訊息。

我將假設你已經安裝了近期版本的PythonTwisted。我所提供的範例程式是基於Python2.5和Twisted8.2.0(譯註:我已將程式碼改為Python 3.6與Twisted 17.9.0可用的範例,可於我的GitHub下載)。

你可以在單機上運行所有的範例程式,也可以在網路系統上運行它們。但是為了學習非同步程式設計的基礎機制,單機上學習是比較理想的。

Getting the example code

範例可用由我的公開git儲存庫clone或下載ziptar檔案。如果可以使用git或其他可以讀取git儲存庫的版本控制系統,那麼建議使用這個方法,因為我會隨著時間不斷更新範例,你會比較容易保持範例是最新的。下面是clone儲存庫的git command:
git clone git://github.com/jdavisp3/twisted-intro.git

本教程的其餘部分將假定你擁有範例程式的最新副本,並且在其最上層目錄(具有README文件的目錄)中開啟了多個shell。

Slow Poetry

雖然CPU的處理速度遠遠快於網路,但網路的處理速度仍然比人腦快,至少比人類的眼睛快。因此想細微的觀察網路延遲是很難的,尤其是在只有一台機器而且位元組(bytes)全速的在loopback介面傳輸時。我們需要的是一個慢速的伺服器,擁有可供我們調整的延遲,讓我們查看效果。由於伺服器必須提供某些服務,我們的伺服器將提供詩歌。範例程式碼中包含一個poetry子資料夾,裡面放著John DonneW.B. Yeats、與Edgar Allan Poe的各一首詩,當然你也可以自由地用自己的詩歌更換伺服器中的。

blocking-server/slowpoetry.py可執行這個基本的慢速詩歌伺服器,你可用下面的方式來運行它:
python blocking-server/slowpoetry.py poetry/ecstasy.txt

上面這個命令將啟動一個阻塞的伺服器,其提供John Donne的Ecstasy這首詩作為服務。現在我們來看看它的原始碼。正如你所見,這裡面並沒有使用Twisted,只是最基本的Python的socket操作。它每次只發送一定數量的位元組,而每次發送之間延遲一段時間。預設的是每隔0.1秒發送10個bytes,你可以通過-delay和--num-bytes來修改參數,例如每隔5秒發送50 bytes:
python blocking-server/slowpoetry.py --num-bytes 50 --delay 5 poetry/ecstasy.txt

當伺服器啟動時,它會顯示其監聽的埠號。預設情況下,埠號是在你的機器上可用埠號中隨機選擇的。你可能想再次使用相同的埠號,讓你不必修改用戶端的命令,你可以像這樣指定一個埠口:
python blocking-server/slowpoetry.py --port 10000 poetry/ecstasy.txt

如果你裝有netcat工具,可以用如下命令來測試你的伺服器(也可以用telnet):
netcat localhost 10000

如果你的伺服器正常工作,那麼你就可以看到詩歌在你的螢幕上慢慢的列印出來。對!你會注意到每次伺服器會印出它發送了多少bytes。一旦詩歌傳送完畢,伺服器就會關閉這條連接。

預設情況下,伺服器只會監聽本地loopback介面。如果你想讓另外一台機器存取伺服器,你可以用-iface選項指定監聽的介面。

不僅是伺服器在發送詩歌的速度慢,如果你閱讀程式碼可以發現,伺服器在傳送詩歌給一個用戶端時,所有其他的用戶端必須等待它結束才可獲得第一順位。這的確是一個慢速的伺服器,除了作為學習之外沒有任何其它用處。

Or is it?

另一方面,如果Peak Oil那些人的悲觀情緒是對的,而我們的世界正走向全球能源危機與全球性的社會崩潰,那麼也許有一天,低頻寬、低功率的詩歌伺服器可能正是我們需要的。想像一下,經過漫長的一天照顧你自給自足的花園,製作自己的衣服,在你公社的中央組織委員會服務,並且與世界末日後在荒地漫遊的放射性殭屍作戰,你可以開動你的發電機並下載一個來自消失文明的少數高級文化。那時候我們的小伺服器會大有用處。

The Blocking Client

在範例中有一個可以從多個伺服器中,一個接一個的下載詩歌的阻塞模式用戶端。讓我們的用戶端執行三個任務,正如Part 1的圖1描述的那樣。首先我們啟動三個伺服器,提供三首不同的詩歌。在三個終端視窗中執行這些命令:
python blocking-server/slowpoetry.py --port 10000 poetry/ecstasy.txt --num-bytes 30
python blocking-server/slowpoetry.py --port 10001 poetry/fascination.txt
python blocking-server/slowpoetry.py --port 10002 poetry/science.txt

如果在你的系統中上面那些埠號有正在使用中,可以選擇其它沒有被使用的埠。注意,由於第一個伺服器發送的詩歌長度是其它的三倍,這裡我讓第一個伺服器使用每次發送30個位元組而不是預設的10個位元組,如此它們會在幾乎相同的時間內完成工作。

現在我們使用在blocking-client/get-poetry.py的阻塞模式的用戶端來獲取詩歌,運行如下所示的命令:
python blocking-client/get-poetry.py 10000 10001 10002

如果你在伺服器使用了不同的埠,也可以在這裡更改埠號。由於這個用戶端採用的是阻塞模式,它將依次由每個埠口下載一首詩歌,直到收到完整的詩歌時才會開始下載另外一首。這個用戶端會像下面這樣輸出資訊而不是將詩歌列印出來:
Task 1: get poetry from: 127.0.0.1:10000
Task 1: got 3003 bytes of poetry from 127.0.0.1:10000 in 0:00:10.126361
Task 2: get poetry from: 127.0.0.1:10001
Task 2: got 623 bytes of poetry from 127.0.0.1:10001 in 0:00:06.321777
Task 3: get poetry from: 127.0.0.1:10002
Task 3: got 653 bytes of poetry from 127.0.0.1:10002 in 0:00:06.617523
Got 3 poems in 0:00:23.065661

這是圖1最典型的文字版了,每個任務下載一首詩歌。你執行後可能顯示的時間會與上面有所差別,並且也會隨著你改變伺服器的發送時間參數而改變。嘗試著更改一下參數來觀察一下效果。

The Asynchronous Client

現在,我們來看看不用Twisted撰寫的非同步模式的用戶端。首先,我們先運行它試試。像之前一樣啟動三個伺服器。如果前面開啟的還沒有關閉,那就繼續用它們好了。現在我們可以執行位於async-client/get-poetry.py的非同步用戶端程式,像這樣:
python async-client/get-poetry.py 10000 10001 10002

你或許會得到類似於下面的輸出:
Task 1: got 30 bytes of poetry from 127.0.0.1:10000
Task 2: got 10 bytes of poetry from 127.0.0.1:10001
Task 3: got 10 bytes of poetry from 127.0.0.1:10002
Task 1: got 30 bytes of poetry from 127.0.0.1:10000
Task 2: got 10 bytes of poetry from 127.0.0.1:10001
...
Task 1: 3003 bytes of poetry
Task 2: 623 bytes of poetry
Task 3: 653 bytes of poetry
Got 3 poems in 0:00:10.133169

這次的輸出會比較長,這是由於在非同步模式的用戶端中,每次接收到一段伺服器發送來的資料時,都會輸出一行提示資訊,而伺服器是一點一滴地發送這些位元組。值得注意的是,這些任務相互交錯執行,正如Part 1的圖3所示。

嘗試著修改伺服器的延遲設定(例如將一個伺服器的延遲設定的長一點),來觀察一下非同步模式的用戶端是如何自動「調整」變慢的伺服器的速度,同時仍然跟上速度較快的伺服器。這正是非同步模式在起作用。

值得注意的是,根據上面伺服器的設置,非同步模式的用戶端大約在10秒內完成工作,而同步模式的用戶端大約23秒才能取得所有的詩歌。現在回憶一下Part 1的圖3圖4,藉由減少阻塞的時間,我們的非同步模式的用戶端可以在更短的時間裡下載所有的詩歌。我們的非同步用戶端確實有些阻塞發生,那是由於伺服器太慢了。由於非同步模式的用戶端可以在不同的伺服器來回切換,它比同步模式的客戶產生的阻塞就少得多。

技術上來說,我們的非同步用戶端正在執行阻塞操作:它使用這些print statements寫入標準輸出的file descriptor(檔案描述符)。這對我們的範例來說不是問題,在本地機器的終端shell上總是可以接受更多輸出的print statements而不會造成阻塞,相對於我們的慢速伺服器來說它執行的很快。但如果我們希望我們的程式成為程序pipeline的一部份,並且仍然保持非同步,我們需要使用非同步I/O進行標準的輸出跟輸入。Twisted支援這種做法,但為了簡單起見,既使在Twisted程式中我們還是使用print statements。

A Closer Look

現在看一下非同步模式用戶端的原始碼。注意其與同步模式用戶端的差別:

  1. 非同步模式用戶端一次性與全部伺服器完成連接,而不是一次只連接一個。
  2. 用來進行通信的socket物件透過呼叫setblocking(0)來設定為非阻塞模式。
  3. select模組中的select方法是用來等待(阻塞)直到任何sockets準備好給我們一些資料。
  4. 當從伺服器中讀取資料時,會儘量多地從socket讀取資料直到它阻塞為止,然後讀下一個socket接收的資料(如果有資料接收的話)。這意味著我們需要追蹤從不同伺服器傳送過來詩歌的接收情況。

非同步模式中用戶端的核心就是最高層的迴圈,即get_poetry函數。這個函數可以被拆分成幾個步驟:

  1. 使用select函數等待(阻塞)所有開啟的socket,直到至少有一個socket有資料被讀取。
  2. 對每個有資料需要讀取的socket,從中讀取資料。但僅僅只是讀取有效資料,不能為了等待還沒來到的資料而發生阻塞
  3. 重複前兩步,直到所有的socket被關閉。

可以看出,同步模式用戶端也有個迴圈(在main函數內),但是這個迴圈的每次迭代都是完成一首詩的下載工作。而在非同步模式用戶端的每次迭代過程中,我們可以完成所有詩歌的下載,或者只是其中的一部分;而且我們不知道我們在處理哪個迭代,或者我們可以從這個迭代中得到多少資料。這些都依賴於伺服器的發送速度與網路環境。我們只需要select函數告訴我們那個socket有資料需要接收,然後在保證不阻塞程式的前提下從socket儘量讀取資料。

如果同步模式用戶端總是連結固定數量的伺服器(例如三個),它根本不需要額外的迴圈,只需要依序呼叫三次阻塞用的get_poetry函式即可。但是為了得到非同步的好處,非同步用戶端不能沒有外部迴圈,我們需要一次性地等待所有的socket,並在每個迭代中盡可能的處理資料。

這個利用迴圈來等待事件發生然後處理發生的事件,這種狀況非常普遍,已經普遍到被實作成為一個設計模式(design pattern):reactor(反應堆)模式。其圖形化表示如圖5所示:
5.the%2Breacotr%2Bloop.png-Part 2: Slow Poetry and the Apocalypse

圖5.reactor迴圈


這個迴圈就是個reactor,因為它等待事件的發生然後對其做出相應的反應。正因為如此,它也被稱作事件迴圈。由於回應式系統(reactive systems)經常等待I/O,這種迴圈也有時被稱作select loop,這是由於select調用被用來等待I/O操作。因此,在一個select迴圈中,一個事件是指一個socket能被讀取或寫入。值得注意的是,select並不是等待I/O的唯一方法,它僅僅是一個比較古老的方法(因此才被用的如此廣泛)。現在有一些新API能在不同的作業系統上完成select的工作,而且提供更好的性能。不考慮性能上的因素,它們都完成同樣的工作:接收一系列的socket(真正的file descriptor) 並阻塞程式,直到其中一個準備好進行I/O。

注意,使用select或類似的功能來簡單的檢查一組file descriptor是否已經準備I/O而不造成阻塞這是可行的,這樣的功能允許回應式系統在回圈內執行非I/O的作業,但通常在回應式系統中所有的工作都是I/O,因此對所有file descriptor進阻塞可以節省CPU資源。

嚴格意義上來說,我們的非同步模式用戶端中的迴圈並不是reactor,因為這個迴圈邏輯並不是專門用於實現詩歌伺服器的「商業邏輯」(譯註:指接收伺服器傳送來的詩歌)。它們被混合在一起。reactor模式真正的執行是將迴圈作為一個單獨的抽象去實現,並具有如下的功能:

  1. 接受一組與你I/O執行相關的file descriptor。
  2. 不停地向你彙報哪些file descriptor準備好I/O了。

而且一個設計優秀的reactor模式實現還需要做到:

  1. 處理所有不同系統會出現的奇怪corner case(邊界案例)。
  2. 提供很多優雅的抽象來幫助你以最少的心思去使用reactor。
  3. 提供你馬上可以使用的公開協定的實現。

好了,我們上面所說的其實就是Twisted—堅固、跨平臺reactor模式的實現並含有很多附加功能。在Part 3中,我們將開始寫一些簡單的Twisted程式,實現Twisted版的下載詩歌服務。

Suggested Exercises

  1. 改變伺服器的數量與設定,用阻塞與非同步用戶端來做一些計時實驗。
  2. 非同步用戶端可以提供一個回傳詩歌文本的get_poetry函式嗎?為什麼不能?
  3. 如果你在非同步用戶端想要一個類似get_poetry同步版本的get_poetry函式,它會怎樣運作?它可能會有怎樣的引數跟回傳值?

留言