這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
許式偉:我們開始,先介紹一下ECUG,從07年開始,最早在珠三角珠海廣州深圳,在珠三角興起,最早是Erlang的社區。大概到10年的時候更名為即時效雲端運算的群組,最早的時候也不局限於Erlang,而是會有各種語言如Haskell、Scala等..,其實根本就沒有限制,只要是中途穿插後端開發營運的實踐都可以,後來我們就正式改名為實效雲端運算的群組。,範圍擴也蠻大到全國,基本上北京、長三角都有舉辦過。所以應該說到今天堅持了也差不多有8年,總共有9屆,07年的時候辦了2屆。這個是ECUG 的曆史。南京是第一次辦這個大會,我大學是在南京念的。今年想的挺久的,我們希望是能夠把這個火花在所有的城市都能夠點燃,所以今年就選擇了南京這樣一個對我來說比較有特殊意義的地方。
我開始我的話題了。其實,這個話題我其實在杭州的ECUG上時候講過,但是當時講的比較婉約,實際上我當時已經意識到Erlang的編程風格的問題,但是解刨得並不徹底,,所以我今天又回頭談一下這個話題,用相對比較詳細的方法去對比說Go與Erlang並行存取模型兩者到底有什麼不同?因為基本上我知道ECUG 發展曆史的人都有困惑,為什麼我會從Erlang轉到切到GoO。
這個是話題的來由還是想從頭談談GO和Erlang的並發,我在09年開始決定放棄用Erlang自己在C++裡面重新造一個Erlang的編程模型,CERL這個網路程式庫最初的出發點是這樣的,所以CERL的C代表的是C/C++、ERL代表的是Erlang。最早的思路是簡單把Erlang搬到C++,因為Erlang的程式員確實比較難招,但是後來發現其實Erlang的並行存取模型並沒有我想象得那麼舒暢,就搞了一個CERL2.0,它是對Erlang模型的反思和改進。最後發現這個反思最終得到的結果和Go的並行存取模型完全一致。所以CERL1.0和2.0的對比,其實你可以認為是 Erlang 和 Go 的對比。其實很多人問我這個話題,為什麼CERL沒有開源?原因是我覺得過了那個時間點了,開源沒有太大的意義,所以我覺得不太想誤人子弟,因為我自己最早是C++的粉絲,但是我接觸Go以後有一個非常強烈的願望我希望C++這樣的東西最好還是能夠早點退出曆史舞台,關於這個話題我曾經有一個演講,是講Go與七牛的曆史,我反思了我的C++奮鬥史,那個演講我會後給大家作為一個補充的材料放上去。
既然沒有開源那應該怎麼理解CERL呢?其實這個世界有類似的東西,這個Pth是我最近才知道的,中途還看到過另一個開源庫,大概在08年開源的,可惜我一時找不到項目網址了。當時我看了一下跟CERL差不多,但是沒有CERL庫寫的完整,但是Pth這個庫出現曆史是非常早的,而且是GNU下的一個開源項目,它是99年就已經起動了,到06年左右就不再更新了。它的出現時間非常早,並行存取模型和CERL是幾乎一樣,而且完成度非常高,畢竟發展了7年,所以要理解CERL其實也是研究一下這個庫基本上就差不多了。但是我其實有一個反思,為什麼這個Pth這麼好的東西為什麼沒有流行起來?第一個是生不逢時,出現的太早所以沒有引起注意,因為其實大概談多核時代這樣的概念在我的印象當中是07年左右開始有這樣的概念,Erlang也是那個時候才逐步被人意識到價值的。第二個就是不是標準庫,因為這樣一個庫侵入性是非常強的不光你意識它好還有別人也意識到,否則有一個問題別人寫庫你是不能用的,這個就是侵入性和傳染性,所以會導致其實沒有辦法真的把這個庫用起來,這種有侵入性和傳入性的庫最初興起的時候需要有一些激發的條件,它沒有這樣的條件。這和 C/C++ 中 GC 比較難流行是類似的道理,因為 GC 也有侵入性和傳染性。 第三個是從實現講,還是有瑕疵的,最大的瑕疵就是輕量級進程並不是真的輕量。輕量級進程的核心不只是要效能好, 更重要的是資源佔用要小,但多數情況下的這種資源佔用小這個其實是比較難實現的, Go 在這一點做的比較好,有棧的自動成長,最小的棧最初的時候可以只有 4K , 這樣每個輕量級進程從資源佔用來說真的很輕量。 但是要達到這一點這個絕大多數的庫都很難做到。像CERL我們只能做到說你自己指定說這個輕量級進程棧要多大,但是對程式員來說指定棧大小是非常困難的事情,有很大的心智負擔。要理解CERL,研究這個Pth是比較好的學習材料,當然第二個我認為就是直接學習Go的Runtime了。從輕量級進程來講,它的底層跟CERL是一樣的。
輕量級進程模型我非常早就提了,從我最初提倡Erlang的時候就已經提出了這個概念,什麼是輕量級進程模型呢?很簡單就是兩個,一個是鼓勵用同步IO寫程式邏輯,第二點是用儘可能多的並發進程來提升IO並發能力。這和非同步IO並行存取模型不一樣的,哪怕你是單線程也可以做高並發。
所有輕量級進程並行存取模型的核心思想都是一樣的,第一讓每個輕量級進程的資源佔用更小,這樣就可以建立百萬千萬層級的並發,能夠建立的進程個數唯一限制就是你的記憶體。每個進程資源佔用的越小能夠產生的並發能力就越高。做服務端大家都知道,記憶體資源是非常寶貴的資源,但某種意義來說也是非常廉價的。第二就是更輕的切換成本,這是為什麼把進程做到使用者態,這個和函數的調用基本是在同一個數量級的,切換成本非常非常低。但是如果是作業系統進程則至少要從使用者態到核心態再到使用者態的切換。
講一下輕量級進程模型的實現原理,這個是蠻多人還是比較關注的。我之前比較少談這個,但是我今天我們詳細的談一談輕量級進程到底是怎麼回事。先談談進程,所謂的進程到底是什麼樣的東西?其實進程本質上無非就是一個棧加上寄存器器的狀態。進程的切換怎麼做呢?就是儲存當前進程的寄存器,然後把寄存器修改為另外一個新進程的寄存器狀態,這樣相當於同時也切換了棧,因為棧的位置其實也寄存器維持的(ESP/EBP)。這個就是進程的概念,哪怕作業系統的核心幫你做的本質上也是這樣。所以這些事情是在使用者態一樣可以做到,而不是不能做到。本質上來講和函數的那個調用你可以認為也是差不多,因為函數的調用也是儲存寄存器,只是相對少一些,至少不會切換棧。所以本質上講其實是這個切換的成本是和函數調用是基本上差不多的,我自己測過,大概就是函數調用的10倍左右,基本還是在同樣的數量級範疇。那麼在這樣一個輕量級進程的概念引入以後,實際上整個輕量級進程的程式物理上是怎麼樣的?底層其實還是線程池加非同步IO,你可以把這個線程池中的每個線程想象成虛擬CPU(VCPU)。邏輯的輕量級進程(routine)的個數通常是遠大於物理的線程數的,每個物理的線程同一個時刻肯定只有一個routine在跑,更多的routine是在等待當中的。但是這個等待中的routine有兩種,一種是等IO的,就是說我把CPU交給他也幹不了活,還有一種是IO操作已經完成,或者是自己本身並沒有等任何前置條件,總之是可以參與調度的。如果某一個物理的線程(VCPU)它的routine主動的或者是因為IO觸發了一個調度,把線程(VCPU)讓出來,這個時候就可以讓一個新routine跑在上面,也就是從等待當中並且可以滿足調度的routine參與調度,按照某種優先順序演算法選擇一個routine。所以輕量級進程調度原理是這樣的,它是使用者態的線程,然後有一個非強佔式的調度機制,調度時機主要由IO操作觸發。發生IO操作的時候,IO操作的函數是這樣實現的:首先發起一個非同步IO請求,發起後把這個routine狀態設定為等待IO完成,然後再出讓CPU,這個時候也就觸發調度器的調度,這個時候調度器就會看看有沒有人等著調度,有它就可以切換過去。然後再IO事件完成的時候,IO完成後通常會有一個回呼函數作為IO完成的事件通知,這個會被調度器接管,具體做什麼呢?很簡單就是把這個IO操作所屬的routine設為Ready,可以參與調度了。因為剛剛它的狀態是在等IO,就算調度到它也沒有辦法做事情。而 Ready 的話就是讓這個routine可以參與調度。還有一種情況就是routine主動出讓CPU,這種情況下routine的狀態在切換的時候仍然是Ready的,任何的時間都可以切到它。以上幾個基本上是非強佔式的調度裡面最基礎的幾個調度器觸發的條件:IO操作、IO完成事件、主動出讓CPU。但是其實在使用者態的線程也可以實現強佔式的調度,做法也是非常簡單的,調度器起來一個定時器,這個定時器定時出發一個定時任務,這個定時任務檢查每個正在執行當中的routine的狀態,發現占CPU時間比較長就可以讓它主動地讓出CPU,這就就可以實現強佔式的調度。所以哪怕在使用者態,它可以完全實現作業系統進程調度所有做的事情。這就是輕量級進程的實現原理。
下面一個問題是Erlang和Go到底有什麼不同?這兩個不都是輕量級進程的並行存取模型?應該說它們的基礎哲學確實差不多,但是細節上有非常大的差異,而不是一點點的差異。主要的差異是在於幾點:第一個對鎖的態度不一樣,第二個對非同步IO的態度不一樣,第三個不算最主要的細節,但是是次重要的細節,兩者的訊息機制不太一樣。
首先談談對鎖的態度,Erlang 對鎖非常反感,它認為變數不可變可以很大程度避免鎖,Erlang 認為鎖有很大的心智負擔所以不應該存在鎖。 Go 的觀念是鎖確實有很大的心智負擔,但是鎖基本上避無可避。我們先宏觀看看鎖為什麼是避無可避的,首先伺服器首先是一個共用資源,是很多使用者在用的,不是為某一個人用的, 所以伺服器本身就是共用資源, 一旦有並發就是這些並發請求就在搶這個共用資源。我們清楚, 一旦有人共用狀態並且相互強佔去改變它的話,這個時候必然是有鎖的,這點是不以技術的實現細節為轉移的, 當然這個分析是從宏觀角度講,後面我還會講技術細節,來談鎖為什麼不可以避免。
Erlang為什麼沒有鎖呢?實際上Erlang的伺服器是單進程(Process)的,是邏輯上就無並發的東西。一個Process就是一個執行體,所以Erlang的伺服器和Go的伺服器不一樣,Go的伺服器必然是多進程(goroutine)一起構成一個伺服器的,每個請求一個獨立的進程(goroutine)。但是Erlang不一樣,一個Erlang伺服器是一個單進程的東西,既然是一個單進程的首先所有的並發請求都進入了進程郵箱(後面會談這個進程郵箱),然後這個伺服器從進程郵箱裡面取郵件(請求的內容)然後處理,所以Erlang的單個伺服器並沒有並發的請求,這個是他不需要鎖的根本原因,其實並不是因為它沒有變數,變數不可變這些。因為大家都知道單線程的伺服器一定是沒有鎖的。那麼可能會有人問,那Erlang怎麼做高並發呢?其實是兩點:第一是每個Erlang物理的進程會有很多的伺服器,每個伺服器相互是無幹擾的,它們可以並發。第二是單伺服器想要高並發怎麼辦?Erlang對這個問題的回答就是請非同步IO。
但是非同步 IO 給 Erlang 帶來了什麼麻煩呢?首先是伺服器狀態變複雜了,這個複雜是非常非常要命的,這導致我最後認為 Erlang一旦引入了非同步 IO 之後,其實比正統的非同步 IO 編程模型還要糟糕。我們看幾點。首先為什麼會有中間狀態的引入?因為有非同步 IO,所以剛剛的某一個請求其實還沒有完成,但是它必須把時間讓給另外一個請求,所以這個時候伺服器就要維持剛剛沒有完成的那個請求的中間狀態。一旦有中間狀態的話,這個伺服器的狀態本身就不乾淨,單次請求的中間狀態要伺服器來維持狀態,這個是非常不合理的事情。第二,這個伺服器的中間狀態將導致比較複雜的狀態機器,這裡面的狀態很複雜,因為伺服器不只是要維持一個請求的狀態,而是所有的未完成的請求的狀態都要它來維持。第三,這些中間狀態會導致有鎖的訴求,為什麼會有鎖的訴求我下面會講。所以Erlang雖然試圖避開鎖,但是一旦有非同步 IO 其實本質上仍然沒有辦法避開鎖。
為什麼Erlang沒有避開鎖呢?剛剛我們已經講了,本質上講是因為有進程郵箱的存在,而且Erlang的伺服器是單進程(執行體),所以常規上沒有並發所以不需要鎖,但是一旦引入了非同步IO以後就會有偽的並發。既然是單的進程,不可能真的有並發,但如果我們把Erlang的進程(Process)也是認為一個VCPU,因為有請求沒有完成,所以同時就有很多並發請求在同一個VCPU上跑。這中間可能出現某個請求需要暫時佔用某種資源是不能釋放的,會出現一些相互互斥的行為。一旦有這樣的行為就必然有鎖,這個鎖雖然不是作業系統實現而是自己實現,具體可能會體現為類似BusyFlag這樣的東西,這其實就是鎖。所有鎖的特徵,比如說忘記把這個釋放了,整個伺服器就被掛住了,它的行為和所有的鎖的行為是完全一樣的。有人會說我根本沒有作業系統鎖,的確單線程的程式必然不會有作業系統的鎖,但是不能懷疑其實我們代碼裡面是有鎖的。
所以,在對鎖的態度這個問題上,Erlang竭力避免鎖,但是實質上只是把鎖的問題拋給使用者。而Go則選擇接受了鎖無法迴避的事實。
我們再看對非同步IO的態度。Go認為,無論如何都不應該有非同步IO的代碼。而Erlang從輕量級進程並行存取模型來說不是很純粹,它沒有排斥非同步IO,是一個混雜體,是非同步IO編程加上輕量級進程模型的混雜,這個混雜的結果是讓Erlang的編程,一旦用了非同步IO的話,其實是比單純的非同步IO編程的心智負擔還要大。
最後一個細節是我剛剛講過的次重要的概念,它是 Erlang的進程郵箱,所有發給Erlang進程的訊息都會發到這個進程郵箱,Erlang提供郵箱收發訊息的元語。Go則提供了channel這樣的通訊設施,這個channel可以輕易建立很多個,然後用它進行進程通訊。相比之下,Go的訊息機制抽象更輕盈。訊息佇列和進程是完全獨立的設施。
那麼,我們再看看我們應該如何去理解Go的並行存取模型?Go的並行存取模型很新嗎?其實不是的。我在很多的場合都講過,Go的並行存取模型其實根本不是一個創新性的東西,為什麼呢?因為Go的並行存取模型是從有網路以來我們就是這麼寫程式的,從第一天寫網路程式的時候我們寫的就是Go推崇的並行存取模型。那麼問題在哪裡呢?為什麼大家最後放棄了最古老的並行存取模型?原因是因為OS的進程和線程太重,導致了大家人們去想方設法提高IO並發的時候用了一些歪招,也就是今天大家廣泛接受的非同步IO編程範式。這個非同步IO變成範式帶來的問題是程式員的編程心智負擔大大加重。所以Go的創舉有兩點:第一點就是價值迴歸,其實最古老的並發編程模型就是最好的並行存取模型。它的問題是執行體的成本,所以Go的最重要的事情就是讓執行體的成本無限降低,大家知道Go的最新版本棧最小可以到4K,小到讓很多人覺得不可思議。所以這一點Go其實是從實現層面解決的,而不是從編程範式解決的。Go第二個創舉是讓執行體變成了語言內建的標準設施,剛剛我說那個Pth庫流行不起來是因為這種並行存取模型是有傳染性和互斥性的,這個系統當中不應該有兩個這樣的設施,而如果大家用的設施不一樣,它是會排斥的,這個傳染性必須要求執行體必須成為標準化的東西。而且這已經是什麼年代了?多核時代已經喊了快十年了,但是我們大家可以看到,幾乎沒有多少語言把執行體這個作為語言內建標準來做,我覺得這是Go很大的創舉。
讓我們回顧一下,Go的並行存取模型其實就是這一頁提到的東西。它是最古老的並行存取模型。現代的作業系統,以及大家學的作業系統原理,和Go裡面的概念完全一致。首先這個並行存取模型涉及的是執行體這樣一個概念,也就是Go的goroutine,然後一次是原子操作、互斥體、同步、訊息,最後就是同步IO。這些就是Go的並行存取模型所有包含的內容。
那麼最後一個問題,Erlang中是不是可以實施Go的並行存取模型?在Go裡面實施Erlang的並行存取模型是比較容易的,但是反過來想Erlang裡面可不可以實現Go的並行存取模型呢?原則上是不能。因為在Erlang當中進程不能實現共用狀態,這個是他反對鎖的最重要的基點。進程不能共用狀態,所以不用鎖,但其實我認為這個是最大的問題,為什麼呢?因為Erlang收到請求以後沒有辦法建立一個子的執行體,然後讓它處理某一個具體的請求不用再管它。但是Erlang裡面進程沒有共用狀態,你要改伺服器狀態必須用非同步IO的方式,把事情做了再把訊息扔給伺服器對他說你自己改狀態。通過訊息改伺服器狀態,這個成本是比較大的,而且帶來了很多問題。所以我認為Erlang的用訊息改這個狀態是不好的做法,繞了一大圈沒有本質改變任何的東西。當然,如果我在Erlang裡面非要做到Go的並行存取模型也可以,這需要對Erlang做一個閹割,如果我們讓Erlang的伺服器都無狀態的話,是可以實施Go的並行存取模型。什麼樣的伺服器是無狀態的?大家可能很容易想到PHP伺服器。它把狀態交給所有的外部的儲存服務,由儲存服務來維持狀態。如果說Erlang的伺服器是無狀態的是可以實施Go的並行存取模型,因為所有的狀態都通過修改外部的儲存。但是這樣的話Erlang程式員肯定是很傷心,看起來Erlang語言並沒有帶來什麼實質性的好處。所以我的結論是:是時候放棄Erlang了。
這就是我的演講內容,謝謝大家!