[Twisted] Part 21: Lazy is as Lazy Doesn’t: Twisted and Haskell

本文由Dave的Part 21: Lazy is as Lazy Doesn’t: Twisted and Haskell翻譯而成,你可以由Part 1開始閱讀這個系列的文章,也可以在這裡找到整個系列的目錄。

Introduction

上個章節中,我們比較了Twisted與Erlang,並將注意力集中在它們共同的一些想法上。最後我們也了解使用Erlang非常簡單,因為非同步I/O與回應式程式設計是Erlang runtime與程序模型的關鍵元件。

今天我們將來到離我們的主題更遠的地方來看看Haskell,另一種與Erlang(當然,還有Python)完全不同的函式型(functional)語言。這次將不會有太多相似之處,但我們仍然會發現一些非同步I/O隱藏在其中。

Functional with a Capital F

雖然Erlang也是一種函式型語言,它主要關注是可靠的並行性(concurrency)模型。另一方面,Haskell是徹徹底底的函式型,毫不掩飾的使用像functorsmonads這些範疇論(category theory)的概念

不要擔心,我們不會深入範疇論的東西(說得好像我們可以一樣)。相反的,我們將專注於一個Haskell更傳統的函式型功能:懶惰。如同許多函式型語言(但不像Erlang),Haskell支援惰性求值(lazy evaluation)在惰性求值語言中,程式的內容並沒有很直接地描述如何計算需要計算的東西。實際執行計算的細節通常留給編譯器與runtime系統。

並且,更重要的是當惰性求值的計算進入runtime時,可能只部分(懶惰的)而不是一次性的計算運算式。通常runtime將只計算讓目前的運算可以有進展所需要的運算式。

這是一個應用haed得簡單的Haskell statement,對於串列[1, 2, 3],這個函式會取回串列中的第一個元素(Haskell與Python共用一些列表語法):
head [1,2,3]

如果你安裝的GHC這個Haskell runtime,你可以自己嘗試看看:
[~] ghci
GHCi, version 6.12.1: http://www.haskell.org/ghc/  : ? for help
Loading package ghc-prim ... linking ... done.
Loading package integer-gmp ... linking ... done.
Loading package base ... linking ... done.
Prelude> head [1,2,3]
1
Prelude>

結果是1,如我們所預期的。

Haskell串列語法包括由開頭幾個元素定義串列的便利功能。例如串列[2,4 ..]是以2開頭的偶數序列。它到哪裡結束?嗯,它並不會結束。Haskell串列[2,4 ..]與其他類似的串列都代表(概念上)無限串列。如果你嘗試在互動式Haskell提示符求這種串列的值你可以看到無限串列,提示符會嘗試印出你的運算式的結果:
Prelude> [2,4 ..]
[2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48,50,52,54,56,58,60,62,64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98,100,102,104,106,108,110,112,114,116,118,120,122,124,126,128,130,132,134,136,138,140,142,144,146,
...

你必須按下Ctrl+C才能終止這個運算,因為它永遠不會真得停下來。但由於惰性求值,你可以毫無困難的在Haskell中使用這些無限串列:
Prelude> head [2,4 ..]
2
Prelude> head (tail [2,4 ..])
4
Prelude> head (tail (tail [2,4 ..]))
6

這邊我們分別存取這個無限串列的第一、第二、第三個元素,並且沒有在任何地方看到無限串列。這就是惰性求值的本質。Haskell runtime只建構足以讓head完成它的工作所需要的串列,而不是先求整個串列的值(這會導致無限迴圈),然後把串列給予head函式。剩下的串列根本永遠不會被建構,因為要進行運算不需要它們。

當我們引入tail函式時,Haskell被迫進一步建構串列,但是再次只建構Haskell求下一次運算所需的值。並且一但運算完成,(未完成的)串列就可以被丟棄了。

這裡有一些部分使用三個不同的無限串列的Haskell程式碼:
Prelude> let x = [1..]
Prelude> let y = [2,4 ..]
Prelude> let z = [3,6 ..]
Prelude> head (tail (tail (zip3 x y z)))
(3,6,9)

這裡我們將所有串列壓縮在一起,然後抓取尾部的尾部的頭部。再一次,Haskell對此沒有任何問題,並且只建構每個串列所需的量來完成求得我們程式碼的值。我們可以在圖46將 Haskell runtime「消耗」這些無限串列視覺化:
46.Haskell%2Bconsuming%2Bsome%2Binfinite%2Blists.png-Part 21: Lazy is as Lazy Doesn’t: Twisted and Haskell

圖46.Haskell消耗一些無限串列


雖然我們將Haskell runtime畫成一個簡單的迴圈,但它可能是以多執行緒實現的(如果你正在使用Haskell的GHC版本那就很可能是)。但是要注意的關鍵點在於這張圖看起來像是一個reactor迴圈消耗掉一些在網路socket傳進來的資料。

你可以把非同步I/O和reactor模式視為一種非常有限的惰性求值形式。非同步I/O的座右銘是:「只盡可能的處理你擁有的資料」。而惰性求值的座右銘是:「只盡可能的處理你需要的資料」。此外,惰性求值語言幾乎在任何地方都應用這個座右銘,不只在有限的I/O範圍內。

但重點在於,對於惰性求值語言,使用非同步I/O沒有什麼大不了。編譯器和runtime已經被設計為一點一滴的處理資料結構,所以懶惰的處理I/O串流流入的chunks是意料中的事情。因此,就像Erlang的runtime,Haskell的runtime只是合併非同步I/O來作為它socket抽象的一部份。我們可以藉由在Haskell中實現詩歌用戶端來展示這點。

Haskell Poetry

我們第一個Haskell詩歌用戶端在haskell-client-1/get-poetry.hs。和Erlang一樣,我們將直接跳到完成的用戶端,然後如果你想要學習更多內容也有進一步閱讀的資訊。

Haskell也支援輕量級執行緒或程序,儘管它們對Haskell不如對Erlang重要,並且我們的Haskell用戶端為每首我們想要下載的詩歌建立一個程序。其中關鍵函式是runTask,它連接到socket並在一個輕量級的執行緒啟動getPoetry函式。

在這段程式碼中你會注意到很多類型的宣告。不像Python或Erlang,Haskell靜態型態的。我們沒有為每個變數宣告型別,因為Haskell會自動推斷沒有明確宣告的變數(如果沒辦法就會回報錯誤)。許多函式包含了IO類型(技術上是monad),因為Haskell要求我們從純函式中乾淨的分割有副作用的程式碼(即執行I/O的程式碼)。

getPoetry函式包含這一行:
poem <- hGetContents h

這似乎是一次性從這段處理(即TCP socket)中讀入整首詩歌。但像往常一樣,Haskell是懶惰的。並且Haskell runtime包括了一個或多個在select迴圈中執行非同步I/O的實際執行緒,因此保留了I/O串流的惰性求值的可行性。

為了說明非同步I/O真的正在進行,我們的程式中包含了一個「callback」函式叫gotLine,它會印出詩歌中每一行的一些任務資訊。但是它完全不是真正的callback函式,無論我們在程式中有沒有包含它,程式都會使用非同步I/O。沒關係,我們等等會來整理它,但讓我們把第一個Haskell用戶端運作起來。先啟動一些慢速詩歌伺服器:
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

現在編譯Haskell用戶端:
cd haskell-client-1/
ghc --make get-poetry.hs

這將建立一個叫做get-poetry的二進位檔。最後,針對我們的伺服器執行用戶端:
/get-poetry 10001 10002 10003

你應該看到一些像這樣的輸出:
Task 3: got 12 bytes of poetry from localhost:10003
Task 3: got 1 bytes of poetry from localhost:10003
Task 3: got 30 bytes of poetry from localhost:10003
Task 2: got 20 bytes of poetry from localhost:10002
Task 3: got 44 bytes of poetry from localhost:10003
Task 2: got 1 bytes of poetry from localhost:10002
Task 3: got 29 bytes of poetry from localhost:10003
Task 1: got 36 bytes of poetry from localhost:10001
Task 1: got 1 bytes of poetry from localhost:10001
...

輸出與之前的非同步用戶端有點不同,因為我們為每行詩歌印出一行,而不是任意資料的chunk。但是,正如你所看到的,用戶端明確的一起處理從所有的伺服器來得資料,而不是一個接著一個。你也會注意到用戶端當第一首詩歌下載完成時馬上就印出來,不會等待其他繼續按照自己下載節奏的詩歌。

好吧,讓我們從我們的用戶端把剩下那一些殘留的指令給清理掉,並且提出一個只抓取詩歌而不去理會任務編號的版本。你可在haskell-client-2/get-poetry.hs找到它。注意它短很多,並且對於每個伺服器只連接socket,抓取所有資料,並把資料送回。

好,讓我們編譯新的用戶端:
cd haskell-client-2/
ghc --make get-poetry.hs

並且針對同一組詩歌伺服器執行它:
./get-poetry 10001 10002 10003

最後,你應該看到每首詩歌的文字出現在螢幕上。

你將從伺服器輸出注意到每個伺服器正同時傳送資料到用戶端。更重要的是,用戶端會儘快的印出第一首詩歌的每一行,而不會等待這首詩歌剩下的部分,即使當它正在處理其他兩首詩歌。然後快速的印出從開始就累積的第二首詩歌。

這所發生的一切都不需要我們做任何事情。沒有callback,沒有訊息來回傳遞,只是簡單的描述我們希望程式做什麼,與非常少量的關於要做到這些它應該怎麼做的方法。其餘的部分由Haskell編譯器與runtime負責處理。漂亮!

Discussion and Further Reading

從Twisted到Erlang到Haskell,我們可以從前台到後台中看到非同步程式設計背後的概念的平行移動。在Twisted中,在,非同步程式設計是Twisted之所以存在的背後的核心動機概念。Twisted的實現為一個獨立於Python的框架(Python缺乏核心的非同步抽象,如輕量級執行緒),當你在使用Twisted撰寫程式時,要把非同步模型放在最重要的位置。

在Erlang中,非同步性對於程式設計師來說仍然是很明顯的,但是細節現在是程式語言結構與runtime系統的一部分了,進而實現在非同步訊息在同步程序之間交換的抽象。

我們沒有深入的去理解這些情境,我們只是指出許多有非同步模型出現的有趣的地方,並且它可以用很多方式去表達。

如果這篇文章有任何東西激起你對Haskell的興趣,那麼我們會推薦Real World Haskell這本書讓你繼續你的學習。這本書是一個良好的程式語言介紹應該是甚麼樣子的榜樣。並且雖然我沒看過,但是我聽到了很多關於Learn You a Haskell的讚賞。

這篇文章帶我們到Twisted以外的非同步系統的旅程的終點,以及我們這個系列的倒數第二個章節。在Part 22,我們將做總結並且提出學習更多關於Twisted的方法。

Suggested Exercises for the Startlingly Motivated

  1. 互相比較Twisted、Erlang、與Haskell用戶端。
  2. 修改Haskell用戶端來處理連接到詩歌伺服器失敗的狀況,如此它們會下載所有它們可以下載的詩歌,並為不能下載的詩歌輸出適當的錯誤消息。
  3. 撰寫我們用Twisted建立的詩歌伺服器的Haskell版本。

留言