[譯] Erlang 之禪第一部分

LeviDing發表於2019-02-25

就像其它的任何事物一樣糟糕

Erlang 之禪

我在由 Genetec 組織的 ConnectDev'16 會上受邀演講,這個是我所介紹部分的一個鬆散的文字記錄(或者也可以說是較長的釋義)。

Erlang 之禪

我假定這裡的大多數人都沒有使用過 Erlang,或許可能已經聽說過它,或許就只是知道這個名字。在這種情況下,這個介紹只會涵蓋 Erlang 中的高層次理念,使用這種方式來講述的話即使你從未接觸過這個語言,它也會對你的工作或副專案有所幫助。

就讓它奔潰吧

如果你之前曾經瞭解過 Erlang,那你應該已經聽過“就讓它奔潰”的箴言。當我第一次遇到這句話時就在想這到底是什麼鬼玩意。Erlang 應該對併發和容錯有著很好的支援,但是我卻被告知就讓它奔潰吧,這和我想要這個系統發生的完全相反。這個主張讓人不可思議,但是 Erlang 之禪卻與之息息相關。

爆炸

在一定程度上,在 Erlang 中使用“就讓它奔潰”這句話就和在火箭科學中使用“就讓它爆炸”一樣好笑。在火箭科學中“就讓它爆炸”也許是你最不想發生的事 - 挑戰者號空難就是一個鮮明的提醒。相對的如果你用不同的方式來看待這件事,火箭和它的整個推進機制都是要處理危險且會爆炸的可燃物(這是其中危險之處),但是它通過可控的方式來使用這種能量來驅動空間旅行或者把載荷送上軌道。

這裡的重點在於控制;你可以嘗試把火箭科學看作是一種如何正確駕馭爆炸的方式 - 或者至少是駕馭其中包含的能量 - 用於做我們想要的事情。從同一個角度看就讓它奔潰也是一樣的道理:它所有一切都是關於容錯。這個想法並不是說讓不可控制的錯誤遍佈各處,而是把失敗,異常和奔潰轉化為我們可以使用的工具。

用火來滅火

回燃法和受控制的燃燒是真實世界中用火來滅火的真例項子。在我出生的加拿大薩格內-聖約翰,藍莓田會例行以受控的方式被燒燬,以幫助支援和延續它們之後的生長。為了防止森林火災,使用火來清除森林中已經枯萎了的部分是很常見的行為,這樣森林才能被適當地監管和控制。這裡的主要目的是用這種方式來去除可燃材料,這樣真實的火災就不能進一步蔓延。

在所有這些情況下,火對莊稼或者森林的破壞力被用於確保莊稼的健康或者是防止森林地區發生更大的無法控制的火災。

我認為這就是“就讓它奔潰”所想表達的。如果我們可以通過一種非常好的控制方式來擁抱失敗,奔潰和異常,它們就不會是需要避免的可怕事物,而能成為構建大型可靠系統的強大基石。

程式 / 蜜蜂

所以這個問題就變成想出一個辦法確保奔潰是促進者而不是毀滅者。對於這個,Erlang 使用程式作為它的基本棋子。Erlang 的程式是完全獨立的,程式之間不共享任何東西。任何程式都不能訪問其它程式的記憶體,或者通過修改它所操作的資料來影響它的工作。這樣就很棒了,因為這意味著我們可以保證一個程式死亡只會把問題保留在程式內,從而為你的系統帶來了非常強大的故障隔離。

Erlang 的程式也非常輕量,你就算執行成千上萬個也不是問題。這裡的想法是使用你__需要__執行數量的程式,而不是你__能__執行數量的程式。這裡通常的比較就是說,如果你有一個物件導向的語言,該語言在任何執行時間中只能有 32 個物件,你很快就會發現使用這種語言構建程式會受到過多的限制而且是非常荒謬的。擁有許多小的程式可以確保在拆分事物中有更高的粒度,而且在一個我們想要利用這些失敗力量的世界中,這很棒!

現在想象這樣 Erlang 中程式是如何工作的可能會有些奇怪。當你寫一個 C 程式時,你有一個大的 main() 函式做大量的事情。這個函式是你程式的入口點。在 Erlang 中就沒有這樣的東西。沒有程式是這個程式指定的主程式。每一個程式都執行一個函式,這個函式在對應單個程式中扮演著 main() 函式的角色。

我們現在有一群蜜蜂,但是如果它們之間不能通過任何方式溝通,那可能就很難管理它們加固蜂巢。蜜蜂是通過舞蹈進行溝通,而 Erlang 的程式是通過訊息傳遞。

訊息傳遞

在並行環境中,程式間訊息傳遞是最直觀的通訊形式。這是我們工作過最古老的通訊方式,從我們開始寫信通過郵差騎馬來送到目的地的日子,到這個幻燈片中展示的拿破崙訊號塔。在這種情況下,你只需要帶著一群人進入塔樓,給他們一個訊息,他們就會揮舞旗幟以比騎馬更快的方式把訊息傳遞到遠方,但這很容易讓人疲勞。最終這些都被電報取代了,之後又被電話和收音機取代了,現在我們擁有所有這些很棒的技術來傳遞訊息,並且可以傳的很遠、很快。

所有這些訊息轉遞方式特別是過去的其中一個關鍵在於它們都是非同步的,而且傳輸的訊息都會被複制。沒有人會為了等寄信的信使回來而在門廊上站好幾天,也沒有人(至少我認為)會坐在訊號塔中等訊息的響應發回來。你只是會把訊息傳送出去,然後回去做你的日常工作,最終會有人告訴你有回信了。

這很合理因為如果另一邊沒有回應,你不會什麼事都不做而是傻傻的在門廊前一直等到死去。相反,如果你死了,訊息交流通道另一邊的收信人也不會奇蹟般的馬上收到或改變你的訊息。資料__應該__在被髮送出之前被複制一遍。這兩個原則確保通訊過程中的失敗不會導致一個損壞或者無法恢復的狀態。Erlang 實現了這兩個原則。

為了能閱讀訊息,每個程式都有一個郵箱。任何一個程式都可以寫訊息到一個程式的郵箱,但是隻有擁有這個郵箱的程式能檢視它。這些訊息會預設以它們到達的順序被讀取,但是也有可能通過模式匹配[我們在先前的演講中討論過這個]之類的特性來讓程式臨時只關注一種,從而驅動不同優先順序訊息的執行順序。

連結 & 監視器

你們之中的部分人會注意到我剛才所提到的一些事項;我一再重申隔離和獨立性是偉大的,這樣一個系統的元件就可以在不影響其它元件的情況下死亡和奔潰,同時我也提到了在多個程式或者代理之間進行交流。

每當有兩個程式開始交流,我們在它們之間就建立了一個隱性的依賴。系統中會有一些隱性的狀態將它們繫結在一起。如果程式 A 向程式 B 傳送了一個訊息,但是 B 程式已經死亡而沒有響應 A,那麼 A 程式所能做的要麼就是永遠等著,要麼就是在一段時間後放棄和 B 程式的交流。後者是一個有效的策略,但是它也是一個十分模糊的策略:它並不知道遠端的那個程式是已經死了還只是處理時間比較長,解除繫結之後遠端程式的訊息才傳送到你的郵箱。

相反,Erlang 為我們提供了兩種機制來處理這種情況:監視器和連結

監視器所做的全部就是作為一個觀察者,一個攀緣植物。你決定去留意一個程式,如果該程式出於任何原因死亡了,你就可以在你的信箱中獲取到關於這個的訊息。你就可以對此作出反應並且通過你新發現的資訊來做出決策。其它的程式永遠也不會知道你對它做了什麼。如果你是一個觀察者或者關注對等程式的狀態,那麼監視器可以是非常棒的工具。

連結是雙向的,建立一個連結就會將其所相連的兩個程式命運繫結。當其中一個死亡時,任何與之連結的程式都會收到退出訊號,這個退出訊號會殺死這些程式。

現在這就真變得很有趣了,因為我們使用監控器來快速地檢測到失敗,而且我還能使用連結作為一個架構構造夠把多個程式繫結在一起作為一個共同的失敗單元。無論何時我獨立構建的模組開始有相互之間的依賴,我能夠開始把這些依賴寫入我的程式碼中。這很有用,因為這樣就可以防止我的系統意外奔潰進入到一個不穩定的區域性狀態。連結是一種工具,它可以讓開發人員確保當其中一件事失敗時,最終會完全失敗並留下一個空的白板,而不會影響這個執行中沒有牽涉到的元件。

在這個幻燈片中,我選了一張登山者通過繩子連在一起的照片。現在如果登山者之間只有這個連結,他們恐怕會陷入一個糟糕的境地。任何時間你隊伍裡的一個登山者滑倒,隊裡的其他人也會馬上滑倒死去。這並不是一個做事情的好辦法。

相反,Erlang 可以讓你指定某些程式是特殊的,這些程式會使用 trap_exit 選項作為標記。然後他們可以接受連結傳送過來的退出訊號並且將它們轉化為訊息。這可以讓它們從錯誤中恢復過來並且可能啟動一個新的程式來做之前死掉程式的工作。不像登山者那樣,這種特殊的程式不能阻止一個對等程式奔潰;這是這個對等程式的責任來確保自己不會掛掉,比如說通過 try ... catch 表示式。一個收到退出訊號的程式還是沒有辦法進入另一個程式的記憶體然後儲存這些記憶體,但是它可以避免因為這個而死去。

這成為了實施監督者的關鍵特性。如果你從來沒有聽說過這些,我們很快就會接觸到這些。

搶佔式排程

在進入監督者這部分之前,我們仍需要一點調料才能成功地烘焙出一個系統,這個系統利用奔潰來獲得自身的優勢。其中之一與程式如何排程有關。對於這方面,我想提到的真實世界例子是阿波羅 11 號的登月計劃。

阿波羅 11 號是在 1969 年的登月任務。在這個幻燈片中,我們看到 Buzz Aldrin 和 Neil Armstrong 的登月艙,這張照片我認為是 Michael Collins 拍的,在這次任務中他留在了指揮艙中。

在他們登月的途中,登月艙將由 Apollo PGNCS(主要指揮,導航和控制系統) 所引導。這個指導系統有多個任務在上面執行,它們的執行週期數是被仔細斟酌過的。NASA 也指出所有任務執行只佔用了處理器 85% 的容量,還剩下 15% 的空間。

現在,為了在應對需要終止計劃的情況,宇航員們需要制定一個完善的備份計劃。於是他們還用處理器執行了一個交會雷達以防萬一它能派上用場,這會用掉 CPU 所剩容量中的一大部分。當 Buzz Aldrin 輸入指令時會出現大量關於溢位和容量耗盡的報錯資訊。如果控制系統因此失控,它將無法正常工作,並且害死兩名宇航員

這主要是由於雷達存在已知的硬體錯誤會使它的執行頻率和指揮計算機不匹配,這就導致它竊取了比其本來所應該有的更多的執行週期。當然 NASA 的人也不是白痴,在這種關鍵的任務中他們重用了他們所知道之前用過很少發生錯誤的元件,而不是研發一個新的技術。但是更重要的是,他們設計了優先順序排程。

這意味著即使因為這種雷達或者輸入命令導致處理器過載的情況下,如果它們的執行優先順序與性命攸關的事情相比很低,那麼這些任務將被殺死,從而把 CPU 執行週期給真正迫切需要它的任務。那是在 1969 年;在今天仍然有大量的語言或者框架給你的__只是__合作排程,除此之外別無他有。

Erlang 並不是一種用於構建生命攸關係統的語言 - 它只遵循軟實時時間約束,而不是實時時間約束所以在這些場景中使用它並不是一個好主意。但是 Erlang 為你提供了搶先試排程以及相應的程式優先順序。這就意味著作為一個開發者或者系統設計人員,你並不__需要__去關心確保每個人都仔細統計了他們所有元件的(包括使用的庫)CPU 使用量以免使整個系統變慢。他們並沒有這個能力。而且如果你需要一些重要任務在它必須執行時總能執行,你也能實現這個。

這似乎並不是一個大的或者通用的需求,人們還是能通過協作式的併發任務開發真正成功的專案,但是它確實十分有價值,因為它可以使你免受他人和你自己錯誤的影響。它還為像自動負載平衡,懲罰和獎勵好或者壞的程式或者給予需要做大量工作的程式更高優先順序提供了實現機制。這些東西最終都會使你的系統更好的適應生產環境負載和處理意外事件。

網路意識

我想討論獲得優雅容錯性的最後一個調料是網路意識。在我們開發的任何需要長時間執行的系統中,讓多臺計算機快速的執行這個系統是一個先決條件。你不會想坐在由鈦門鎖在裡面的金色機器旁邊,卻不能忍受任何方式引起的中斷影響到你的使用者。

所以最終你需要兩臺計算機,這樣一臺機器可以在另一臺破環時繼續提供服務,如果你想要在損壞計算機還是你係統一部分的時候部署,那麼你或許就需要第三臺。

這個幻燈片中的飛機是 F-82 雙生野馬,這是一架在第二次世界大戰期間設計的飛機,用於護送大多數其它戰機無法覆蓋範圍內的轟炸機。它有兩個駕駛艙,這樣隨著時間推移,當一個駕駛員累的時候另一個可以互相替換;在一些情況下他們也能相互配合,其中一個人飛行的時候,另一個可以操作雷達作為攔截者的角色。現代飛機仍然在做類似的一些事情;他們有數不清的備用方案,經常有機組人員在飛行期間的途中睡覺,以確保總有人能時刻警惕準備好駕駛飛機。

當這個說法用於程式語言或者開發環境,它們中大多數的設計都完全忽略了分散式,儘管人們都知道如果你寫的是伺服器棧,那麼你需要的就不止一臺伺服器。然而,如果你要使用檔案,這些變成語言就會有標準庫幫你完成這些事情。大多數語言更進一步就是給你一個套接字型檔或者 HTTP 客戶端。

Erlang 意識到了分散式這個事實並且為你提供了一個實現,這個實現是有文件記錄而且透明的。這可以讓人們為故障轉移設或者是接管奔潰的應用配置所想要的邏輯從而提供更高的容錯性,甚至可以讓其它語言假裝它們是 Erlang 的節點來構建多邊形系統。

就讓它奔潰吧。

所有這些就是 Erlang 之禪食譜的基本調料。這整個語言的目的在於獲得崩潰和失敗,並使它們如此易於管理,從而有可能將它們當作工具。就讓它崩潰開始有道理起來了,這裡看到的原則大部分都是可以在非 Erlang 系統中作為靈感重用的。

如何將它們組合在一起是下一個挑戰。

監管樹

監管樹描述的是如何實施你 Erlang 程式的架構。它們源自於一個簡單的觀念,有一個監管者,它唯一的工作就是啟動程式,關注程式的執行,然後在它們執行失敗時重啟它們。順便提下,監管者是 ‘ OTP ’ 的核心元件之一,它是被廣泛使用的開發框架,名字叫做 ‘ Erlang/OTP ’。

這樣做的目的是建立一個層級結構,在這個結構中所有重要必須穩定執行的東西越接近樹的根部,而所有易變或正在轉移的部分則會積累在葉子部分。事實上,這就是現實生活中大多數樹木的樣子:樹葉不是固定的,樹上會有很多樹葉,在秋天它們都會飄落下來,而這個樹仍然活著。

這意味著當你構建 Erlang 程式時,任何你覺得脆弱的允許執行失敗的程式應該處於這個層級的更深處,而穩定而且可靠性要求很高的應該移到層級上面。

監管者們

監管者是通過使用連結和捕獲退出來實現這個功能的。它們的工作從一次啟動它們的子程式開始,從上往下,從左到右。只有當一個子程式完全開始之後它才會返回上個層級開始建立下一個子程式。每一個子程式都會被自動連結。

每當一個子程式死亡時,有以下三個策略可供選擇。第一個策略在這個幻燈片中就是 ‘一對一’,通過替換死去的子程式來實現。這是用於監管者的所有子程式相互之間都獨立時的策略。

第二個策略是‘一即是全部’。這個策略用於子程式之間存在相互依賴關係。當它們中的任何一個死去時,監管者就會在把它們全部重新啟動之前把其它所有子程式都殺掉。當失去一個特殊的子程式會使其它程式陷入一個不確定的狀態時,你就可以使用這個策略。讓我們想象三個程式進行一個對話,該對話以投票結束的。如果在投票過程中其中一個程式死亡,那麼可能我們並沒有編寫任何程式碼來處理這個問題。用一個新的替換死去的程式會在表格上帶來一個新的同伴,而其中的所有程式完全不知道接下來該做什麼。

如果我們沒有真正定義當一個程式在投票過程中造成嚴重出故障時要怎麼做,那麼這種不一致的狀態可能是有危險的。相比於這個,殺死所有的程式可能會更安全,然後從已知穩定的狀態重新開始。通過這樣做我們就可以限制錯誤的範圍:在錯誤發生時早點及時奔潰會比慢慢且長時間毀壞資料要更好。

當程式之間根據它們的啟動順序有依賴關係時通常可以用最後這種策略。它的命名叫做‘一個所剩下的’,當一個子程式死亡時,在之後它後面啟動的程式會被殺死。然後程式就會像之前預期的那樣重新啟動。

每個監管者還額外有可配置的控制和忍耐級別。一些監管者可能中斷之前每天只能忍受一個故障,而其它的或許可以每秒承受 150 個故障。

Heisenbugs

在我提到監管者之後大家通常都會提及的評論就是“但是如果我的配置檔案就是錯的,重啟並不能解決任何問題!”。

這完全正確。重啟有效的原因在於生產環境系統中所遇到的錯誤性質。為了討論這個問題,我必須提及 Jim Gray 在 1985 年提出的 ‘ Bohrbug ’ 和 ‘ Heisenbug ’ 這兩個術語(我建議你儘可能多讀下 Jim Gray 的論文,它們都寫的很棒!)。

基本上來看,一個 bohrbug 是一個穩定的,可觀察的而且可復現的錯誤。它們傾向於可以被開發者容易地推測出問題的原因。相反 Heisenbug 具有不可靠的行為,它不會在確定的條件中出現,而且如果只是採取簡單的行為嘗試去觀測這些問題時它們可能會被隱藏起來。比如說在系統中使用每個操作都會被循序執行的偵錯程式時,併發錯誤就無法查詢出來。

Heisenbugs 是這些在一千次,百萬次,十億次或者萬億次錯誤中才會出現一次的令人討厭的錯誤。當你看到有人列印了一頁又一頁的程式碼以及在它們中填上一大堆標記時,你就知道他已經處理這種型別的錯誤有一段時間了。

定義了這些術語之後,讓我們來看看它們的出現頻率應該是多少。

在生產環節中查詢錯誤就是這麼簡單

在這裡,我把 bohrbugs 列為可重複的錯誤型別,把 heisenbugs 列為暫時的錯誤型別。

如果你在你係統的核心功能中有 bohrbugs,那麼當這個系統到達生產環境之前它們應該能很容易被找出來。通過可重複性,以及這類錯誤通常在程式執行的關鍵路徑上,你應該遲早會遇到它們,而且在到達下一個階段之前修復它們。

那些發生在次要的,更少使用的功能上的錯誤,更像是提醒和錯過的事。每個人都承認的是修復軟體中的全部錯誤是一件艱苦的戰爭,為此得到的收益是遞減的;隨著你繼續編寫程式碼,除去其中的小缺陷可能要花越來越多的時間。通常情況下,這些次要功能往往會收到較少的關注,不僅因為較少的客戶會使用它們,還因為它們對滿意度的影響並沒有那麼重要。或者也許它們只是要晚些時候被安排修復而且把時間表拖後最終會降低開發人員處理這個的重要度。

在任何情況下,它們在一定程度上都挺容易找到的,我們只是沒有時間或者資源來做這件事。

Heisenbugs 幾乎不可能在開發過程中發現它們。像形式證明,模型檢查,窮舉測試或者基於屬性的測試這些很棒的技術可能會增加發現其中一部分或者全部問題(取決於所用方法)的可能性,但是坦白講,除非手頭上的任務是非常關鍵的,否則我們中很少有人使用這些技術。在數十億次中出現一次的問題就需要大量的測試和驗證才能發現,而且如果你已經看到過這個錯誤,那麼很可能沒那麼好運氣再次產生這個錯誤。

更多內容請見本文第二部分:Erlang 之禪:第二部分


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章