[Twisted] Part 20: Wheels within Wheels: Twisted and Erlang

本文由Dave的Part 20: Wheels within Wheels: Twisted and Erlang翻譯而成,你可以由Part 1開始閱讀這個系列的文章,也可以在這裡找到整個系列的目錄。

Introduction

在這個系列中我們發現一件事情,將同步的「普通Python」程式碼與非同步的Twisted程式碼混用並不是一項簡單的任務,因為在Twisted程式中阻塞一段不確定的時間將使你喪失嘗試使用非同步模型的許多好處。

如果這是你的第一的非同步程式設計的介紹,那麼你得到的知識似乎有些應用的侷限。你可以在Twisted中使用這些新技術,但是不能在一般Python程式碼的更廣闊的世界中使用。當使用Twisted時,你通常受限於特別寫來作為Twisted程式的一部分使用的函式庫,至少如果你想直接的從執行reactor的執行緒中呼叫它們的話。

但是非同步程式設計的技術已經存在很長的時間,而且絲毫不侷限於Twisted。事實上,光Python中就有數量驚人的非同步程式設計框架。稍微搜尋一下可能就有幾十個結果。它們的細節與Twisted不同,但是基本的想法(非同步I/O、以多個資料串流的小區塊來處理資料)是相同的。所以如果你需要或選擇去使用其他的框架,你會因為已經學習了Twisted而有很好的啟蒙。

而在Python之外,有許多其他的語言與系統,基於或使用了非同步程式設計的模型。當你在探索這個主題其他更廣泛的領域時,你Twisted的知識將會技術的幫助你。

在這個章節我們會瀏覽一下Erlang,它是一種程式語言與runtime system,它廣泛的使用非同步程式設計的概念,但以它獨特的方式實現。請注意這不是對Erlang的概論。相反的,這是對嵌入在Erlang中的一些概念的簡短探索,以及它們如何與Twisted中的概念產生連接。基本的主題是你在學習Twisted中得到的知識可以被應用在學習其他技術時。

Callbacks Reimagined

研究一下圖6,callback的圖形表示。在Part 6中介紹的3.0版本用戶端與之後所有的詩歌用戶端,主要的callback都是dataReceived方法。每次我們從其中一台我們已經連接的詩歌伺服器取得一部分詩歌時,這個callback就會被調用。

假設我們的用戶端正從三台不同的伺服器上下載詩歌。從reactor的角度來看(這是我們在這個系列中最強調的觀點),我們會有一個大的迴圈,每次的循環都會有一個或多個callbacks。看看圖40:
40.callbacks%2Bfrom%2Bthe%2Breactor%2Bviewpoint.png-Part 20: Wheels within Wheels: Twisted and Erlang

圖40.從reactor的角度來看callbacks


這張圖顯示了reactor愉快的旋轉著,當詩歌傳進來時呼叫dataReceived。每個dataReceived的調用都是應用於我們PoetryProtocol類別的一個特定實例。而且我們知道有三個實例,因為我們正在下載三首詩歌(而且必定有三個連接)。

讓我們從其中一個Protocol實例的角度來思考一下這張圖片。記住每個Protocol實例指涉及一個連接(因此只有一首詩歌)。那個實例「看到」一個方法呼叫的串流,每個方法呼叫都攜帶著詩歌的下一個部分,像這樣:
dataReceived(self, "When I have fears")
dataReceived(self, " that I may cease to be")
dataReceived(self, "Before my pen has glea")
dataReceived(self, "n'd my teeming brain")
...

雖然嚴格來說這不是實際的Python迴圈,但我們可以將它概念化為一個迴圈:
for data in poetry_stream(): # pseudo-code
    dataReceived(data)

我們可以用圖41想像這個「callkback迴圈」:
41.A%2Bvirtual%2Bcallback%2Bloop.png-Part 20: Wheels within Wheels: Twisted and Erlang

圖41.一個虛擬的callback迴圈


同樣的,這不是一個for迴圈或者while迴圈。在我們的詩歌用戶端中唯一重要的Python迴圈是reactor。但我們可以想像每個Protocol是一個虛擬迴圈,每次這首詩歌的一部份傳送進來時就會執行一次迴圈。根據這種想法,我們在圖42可以重新想像整個用戶端:
42.the%2Breactor%2Bspinning%2Bsome%2Bvirtual%2Bloops.png-Part 20: Wheels within Wheels: Twisted and Erlang

圖42.reactor轉動一些虛擬迴圈


在這張圖片中,我們有reactor這個大迴圈,以及三個個別詩歌協定實例的虛擬迴圈。大迴圈旋轉起來,這樣做導致虛擬迴圈也旋轉起來,就像一組連鎖的齒輪。

Enter Erlang

Erlang與Python一樣,是一種最初於八十年代創造的通用動態型態程式設計語言。與Python不同的在於Erlang是函式型(functiona)而不是物件導向,並且有讓人聯想到Prolog的語法,那個最初實現Erlang的語言。Erlang是為了建立高度可靠的分散式電信系統所設計的,因此Erlang包含了大量的網路支援。

Erlang最獨特的一點是涉及輕量級程序(lightweight processes)的並行性(concurrency)模型。Erlang的程序既不是作業系統程序也不是作業系統執行緒。相反的,它是Erlang runtime中擁有自己的stack的獨立執行函式。Erlang程序並不是輕量級執行緒,因為Erlang程序不能分享狀態(大部分資料型別都是不可變的,Erlang是一個函式型程式設計語言(functional programming language))。Erlang程序只能透過傳送訊息來與其它Erlang程序互動,而至少在概念上訊息總是被複製的而不是分享。

所以Erlang程是可能看起來會像圖43:
43.An%2BErlang%2Bprogram%2Bwith%2Bthree%2Bprocesses.png-Part 20: Wheels within Wheels: Twisted and Erlang

圖43.有三個程序的Erlang程式


在這張圖中,個別的程序已經變的「真實」了,因為程序是Erlang的頭等概念,就像Python中的物件。而runtime變成「虛擬」了,不是因為它不存在,而是因為它不一定是簡單的迴圈。Erlang的runtime可能是多執行緒的,而且因為它必須實現一個完整的程式語言,它負責的不僅僅只有非同步I/O。此外,語言的runtime不是像Twisted的reactor是一個額外的構造,而是作為Erlang的程序與程式碼中間的媒介。

所以對Erlang程式更適合的想像可能是圖44:
44.An%2BErlang%2Bprogram%2Bwith%2Bseveral%2Bprocesses.png-Part 20: Wheels within Wheels: Twisted and Erlang

圖44.有幾個程序的Erlang程式


當然,Erlang的runtime確實需要使用非同步I/O以及一個或多個select迴圈,因為Erlang允許你建立大量的程序。大型的Erlang程式可以啟動上萬或幾十萬個Erlang程序,因此為每個程序分配一個實體作業系統執行緒根本是天方夜譚。如果Erlang允許多程序執行I/O,並即使I/O阻塞仍允許其它的程序執行,那麼非同步I/O也不得不參與近來。

注意我們Erlang的圖片,每個程序「以自己的力量」執行,而不是被callback旋轉著。情況就是如此。隨著reactor的工作被歸入Erlang runtime的結構中,callback不再扮演中心的腳色。在Twisted中透過callback來解決的,在Erlang中透過從一個Erlang程序傳送非同步訊息到另一個Erlang程序來解決。

An Erlang Poetry Client

讓我們來看看Erlang詩歌用戶端。我們將直接跳到一個可運作的版本,而不是像我們用Twisted做的那樣慢慢的建立。同樣,這並不是完整的Erlang介紹。但如果它激起你的興趣,在這個章節的最後我們建議了一些深度閱讀的資料。

Erlang用戶端位於erlang-client-1/get-poetry。為了執行它,你當然需要安裝Erlang。這是main函式的程式碼,它與我們Python用戶端的main函式有類似的目的:
main([]) ->
    usage();

main(Args) ->
    Addresses = parse_args(Args),
    Main = self(),
    [erlang:spawn_monitor(fun () -> get_poetry(TaskNum, Addr, Main) end)
     || {TaskNum, Addr} <- enumerate(Addresses)],
    collect_poems(length(Addresses), []).

如果你之前從未見過Prolog或類似的語言,那麼Erlang語法會看起來有點奇怪。但也有些人這樣說過Python。main函式由兩個單獨的子句(clauses)定義,以分號分隔。Erlang透過匹配引數來選擇執行哪個子句,因此第一個子句只在我們沒有提供任何命令列引數執行用戶端時執行,而它只會印出幫助說明訊息。第二個子句是所有動作所在。

Erlang函式中各個statement以逗號分隔,而所有函式都以句點結尾。讓我們一次一個的看看第二個子句中的每一行。第一行只是解析命令列引數並將它們綁定到變數(Erlang中所有變數都必須字首大寫)。第二行是使用Erlang的self函式來取得目前執行中的Erlang程序(不是作業系統程序)的程序ID。由於這是main函式,你可以把它想像為Python中__main__模組的相等的東西。第三行是最有趣的:
[erlang:spawn_monitor(fun () -> get_poetry(TaskNum, Addr, Main) end)
     || {TaskNum, Addr} <- enumerate(Addresses)],

這個statement是Erlang的list comprehension,Python中有相似的語法。它產生新的Erlang程序給每個我們需要連接的詩歌伺服器。並且每個程序會執行相同的函式(get_poetry)但有特定於伺服器的不同引數。我們也傳送主程序的PID,以便新程序可以送回詩歌(你通常需要程序的PID來向它傳送訊息)。

main中最後的statement呼叫collect_poems函式,它等待詩歌回來與get_poetry程序結束。我們稍後會看一下其他函式,但首先你可以比較這個Erlang main函式與我們Twisted用戶端中的對等的main函式

現在讓我們來看看Erlang的get_poetry函式。實際上在我們的script中有兩個函式叫做get_poetry。在Erlang中,函式是由名稱與元數(arity)(譯註:一個函式引數的個數)所定義,所以我們的script包含了兩個不同的函式,get_poetry/3與get_poetry/4,它們個別接受三個與四個引數。這裡是get_poetry/3,它是由main產生的:
get_poetry(Tasknum, Addr, Main) ->
    {Host, Port} = Addr,
    {ok, Socket} = gen_tcp:connect(Host, Port,
                                   [binary, {active, false}, {packet, 0}]),
    get_poetry(Tasknum, Socket, Main, []).

這個函式首先建立TCP連接,就向Twisted用戶端的get_poetry一樣。但之後,它不是回傳,而是透過呼叫下面列出的get_poetry/4繼續使用TCP連接:
get_poetry(Tasknum, Socket, Main, Packets) ->
    case gen_tcp:recv(Socket, 0) of
        {ok, Packet} ->
            io:format("Task ~w: got ~w bytes of poetry from ~s\n",
                      [Tasknum, size(Packet), peername(Socket)]),
            get_poetry(Tasknum, Socket, Main, [Packet|Packets]);
        {error, _} ->
            Main ! {poem, list_to_binary(lists:reverse(Packets))}
    end.

這個Erlang函式正在做我們Twisted用戶端中PoetryProcotol的工作,除了它是使用阻塞函式呼叫來做這件事。無論時間有多長,gen_tcp:recv函式會等待直到某些資料到達socket (或者socket關閉)。但是Erlang中的「阻塞」函式只會阻塞執行該函式的程序,而不是整個Erlang的runtime。那個socket並不是真正的阻塞式socket(你不能在純Erlang程式碼中建立真正的阻塞式socket)。對於在Erlang runtime內部某處的每個Erlang socket,都有一個「真正的」TCP socket設定為非阻塞模式,並用於select迴圈的一部分。

但是Erlang程序不需要知道這些。它就只是等待一些資料到達,如果它阻塞了,其他的Erlang程序取代執行。即使程序永遠不會阻塞,Erlang runtime也可以在任何時候從一個程序自由的切換到另一個程序去執行。換句話說,Erlang有一個非合作(non-cooperative)的併行模型。

注意get_poetry/4,在收到一小部分詩歌后,繼續透過遞迴呼叫自己。對於指令式(imperative)語言程式設計師而言,這似乎像是會耗盡記憶體的方法,但是Erlang編譯器可以將「尾端(tail)」呼叫(函式呼叫是函式中最後一個statement)最佳化為迴圈。這又強調出Erlang與Twisted中另一個奇怪的相似處。在Twisted用戶端中,「虛擬」的迴圈是由reactor一次又一次的呼叫相同的函式(dataReceived)所建立。而在Erlang用戶端中,「真正的」程序的執行(get_poetry/4)是藉由使用尾端呼叫最佳化來一次又一次的呼叫自己而形成迴圈。很驚人吧。

如果連接關閉了,get_poetry做的最後一件事情是把詩歌送到主程序。這也結束了get_poetry正在執行的程序,因為它沒有事情要做了。

我們Erlang用戶端剩下的關鍵函式是collect_poems
collect_poems(0, Poems) ->
    [io:format("~s\n", [P]) || P <- -="" _="" code="" collect_poems="" end.="" gt="" oem="" oems="" poem="" poems="" receive="">

就像get_poetry一樣,這個函式由主程序執行,並且以遞迴的方式循環它自己。它也會阻塞。receive statement告訴程序等待匹配其中一個給定模式訊息到達,然後從「信箱」中取出訊息。

collect_poems函式等待兩種訊息:詩歌和「DOWN」通知。後者是當其中一個get_poetry程序因任何原因結束時送給主程序的訊息(這是spawn_monitor的monitor部分)。藉由計算DOWN訊息,我們知道所有的詩歌何時結束。前者是來自其中一個get_poetry程序的訊息,其中包含一首完整的詩歌。

好吧,讓我們來看看Erlang用戶端。首先啟動三個慢速詩歌伺服器:
python blocking-server/slowpoetry.py --port 10001 poetry/fascination.txt
python blocking-server/slowpoetry.py --port 10002 poetry/science.txt
python blocking-server/slowpoetry.py --port 10003 poetry/ecstasy.txt --num-bytes 30

現在我們可以執行Erlang用戶端了,它有與Python用戶端類似的命令列語法。如果你使用Linux或其他類UNIX系統,那麼你應該可以直接執行用戶端(假使你已經安裝了Erlang並在使得它在你的PATH中)。在Windows上,你可能需要執行escript程式,以Erlang用戶端的路徑作為第一個引數(以及用於Erlang用戶端剩下的引數)。
./erlang-client-1/get-poetry 10001 10002 10003

之後你應該看到像這樣的輸出:
Task 3: got 30 bytes of poetry from 127:0:0:1:10003
Task 2: got 10 bytes of poetry from 127:0:0:1:10002
Task 1: got 10 bytes of poetry from 127:0:0:1:10001
...

這就像我們之前其中一個Python用戶端一樣,我們印出我們得到的每一小部分詩歌。當所有詩歌下載完成,用戶端應該印出每首完整的詩歌。注意用戶端會在所有伺服器之間切換,這取決於哪個伺服器有一些詩歌要傳送。

圖45顯示了Erlang用戶端的程序結構:
45.Erlang%2Bpoetry%2Bclient.png-Part 20: Wheels within Wheels: Twisted and Erlang

圖45.Erlang詩歌用戶端


這張圖顯示了三個get_poetry程序(每個伺服器一個)和一個主程序。你還可以看到訊息從get_poetry程序流向主程序。

那麼如果其中一台伺服器離線了會發生什麼事情?讓我們試試看:
./erlang-client-1/get-poetry 10001 10005

上面的命令包含了一個啟動的埠口(假設你讓所有之前的詩歌伺服器都還在執行)與一個未啟動的埠口(假設你沒有在10005埠執行任何伺服器)。而我們得到一些像這樣的輸出:
Task 1: got 10 bytes of poetry from 127:0:0:1:10001

=ERROR REPORT==== 25-Sep-2010::21:02:10 ===
Error in process <0 .33.0=""> with exit value: {{badmatch,{error,econnrefused}},[{erl_eval,expr,3}]}

Task 1: got 10 bytes of poetry from 127:0:0:1:10001
Task 1: got 10 bytes of poetry from 127:0:0:1:10001
...

最終,用戶端從啟動的伺服器完成下載詩歌,印出詩歌並退出。那main函式如何知道兩個程序都完成了?那個錯誤訊息就是線索。當get_poetry嘗試連接到伺服器並得到連接拒絕的錯誤,而不是期望的數值({ok,Socket})時,就會發生錯誤。產生的例外叫做badmatch,因為Erlang「賦值」statement實際上是模式匹配的操作。

Erlang程序中一個未處理的例外會導致程序「崩潰」,這表示程序停止執行並且所有資源都被垃圾回收了。但是,監視所有get_poetry程序的主程序,會在程序因為任何原因停止執行時收到DOWN訊息。因此,我們的用戶端這時應該退出,而不是永遠執行下去。

Discussion

讓我們來看看Twisted與Erlang的用戶端之間相似之處:

  1. 兩個用戶端都是同時連接(或嘗試連接)所有詩歌伺服器。
  2. 兩個用戶端會會立即從伺服器接收數據,無論哪個伺服器提供數據。
  3. 兩個用戶端都是一點一點的處理詩歌,因此都必須保存到目前為止收到的部分詩歌。
  4. 兩個用戶端都建立了一個「物件」(Python物件或Erlang程序)來處理一個特定伺服器的所有工作。
  5. 當兩個用戶端都必須仔細的確認所有的詩歌何時完成,無論個別的下載是成功還是失敗。

最後,兩個用戶端的main函式非同步的接收詩歌與「任務完成」的通知。在Twisted用戶端中,這個訊息是通過Deferred實例傳遞,而Erlang用戶端是透過接收程序間的訊息來傳遞。

注意兩個用戶端在整體策略與程式碼結構上的相似程度。機制有些不同,一邊是物件、deferreds、與calllback,另一邊是程序與訊息。但是高層的mental models非常相似,而且一但你熟悉了兩者,就很容易把其中一種程式碼轉換為另外一種。

甚至reactor模式也在Erlang用戶端中以小型化的形式重現。我們詩歌用戶端中的每一個Erlang程序最終轉變為一個遞迴迴圈用來:

  1. 等待某些事情發生(一小部分詩歌傳進來,一首詩歌傳送完畢,另一個程序結束。
  2. 以及採取一些適當的行動。

你可以將Erlang程式視為許多小reactor的大型集合,每個小reactor都會旋轉並且向另一個小reactor傳送訊息(小reactor會以另一個事件來處理這些訊息)。

如果你深入研究Erlang,你會發現callback出現了。Erlang的gen_server程序是個一般的reactor迴圈,你可以藉由提供一組固定的callback函式來「實例化」它,這是一種在Erlang系統的其他地方也重複出現的模式。

因此,如果學習了Twisted之後,你決定試試看Erlang,我想你會發現自己處於熟悉的心理領域。

Further Reading

在這個章節中,我們關注Twisted與Erlang的相似性,但是當然還是有很多不同。Erlang其中一個特別獨特的功能是它處理錯誤的方式。一個大型的Erlang程式會被結構化為樹狀程序,在較高的分支上有「主管」,在葉子上有「工人」。如果工人程序崩潰,主管程序會注意到並且採取某些行動(通常會重新啟動失敗的工人)。

如果你有興趣學習更多Erlang的知識,那麼你很幸運。有幾本Erlang的書籍最近已經出版了,或者很快就要出版了:

  • Programming Erlang-由其中一位Erlang的發明者所寫的。是對這個語言得精采介紹。
  • Erlang Programming-這本補充了Armstrong的書,並在幾個關鍵的地方進行更詳細的介紹。
  • Erlang and OTP in Action-這本還沒發表,但我急切地等待著。前兩本書都沒有真正的處理OTP,也就是用於建構大型應用程式得Erlang框架。跟各位坦白:其中兩位作者是我的朋友。

這些就是關於Erlang的內容。在下個章節,我們將討論Haskell,另一種有著與Python及Erlang截然不同的感覺得函式型語言。

Suggested Exercises for the Highly Motivated

  1. 瀏覽Erlang與Python的用戶端,分辨出它們的相似處與相異處。它們分別如何處理錯誤的(例如連接詩歌伺服器失敗)?
  2. 簡化Erling用戶端,使其不在印出傳入詩歌的每一個小部分(因此你也不在需要繼續追蹤任務編號)。
  3. 修改Erlang用戶端來測量下載每首詩歌所需要的時間。
  4. 修改Erlang用戶端來印出詩歌,使其順序與我們在命令列給予的順序相同。
  5. 修改Erlang用戶端在當我們無法連接到詩歌伺服器時,印出更多易讀的錯誤訊息。
  6. 寫出我們用Twisted建立的詩歌伺服器的Erlang版本。

留言