並行處理的搭便車指南

在 21 世紀初不時髦的未開發邊陲地帶,存在著一小部分人類知識。

在這部分人類知識中,有一個微不足道的學科,其馮·諾伊曼後代架構是如此驚人地原始,以至於人們仍然認為 RPN 計算機是一個非常棒的想法。

這個學科有——或者說曾經有——一個問題,那就是:大多數研究它的人在嘗試編寫並行軟體時,大部分時間都不快樂。針對這個問題提出了許多解決方案,但其中大多數主要關注於處理稱為鎖定和互斥鎖等的小邏輯片段,這很奇怪,因為總體來說,並不需要並行處理的是小邏輯片段。

因此,問題依然存在;很多人都很壞,而且他們中的大多數都很痛苦,即使是那些擁有 RPN 計算機的人也是如此。

許多人越來越認為,他們在嘗試將並行處理添加到他們的程式語言中犯了一個大錯誤,並且沒有任何程式應該離開其初始執行緒。

注意:惡搞《銀河便車指南》很有趣。如果你還沒讀過這本書,請讀一下。它很棒!

不要驚慌

嗨。今天(或者無論你讀到這篇文章是哪一天,甚至是明天),我將向你介紹並行 Erlang。你可能之前已經閱讀過或處理過並行處理。 一個在電腦前的胖子 你可能也對多核心程式設計的興起感到好奇。無論如何,你很有可能因為這些天關於並行處理的討論而正在閱讀這本書。

不過請注意;本章主要是理論性的。如果你頭痛、厭惡程式語言歷史或者只是想編寫程式,你最好跳到本章的結尾或跳到下一章(其中展示了更多實用知識)。

我已經在本書的引言中解釋過,Erlang 的並行處理是基於訊息傳遞和 Actor 模型,並以人們僅透過信件進行溝通為例。稍後我將再次更詳細地解釋,但首先,我認為定義並發並行之間的區別很重要。

在許多地方,這兩個詞都指同一個概念。它們在 Erlang 的上下文中經常被用作兩個不同的概念。對於許多 Erlang 使用者來說,並發指的是讓許多 Actor 獨立運行的想法,但不一定同時運行。並行是指讓 Actor 完全同時運行。我想說,在電腦科學的各個領域似乎對這些定義沒有任何共識,但我將在本文中以這種方式使用它們。如果其他來源或人們使用相同的術語來表示不同的含義,請不要感到驚訝。

這就是說 Erlang 從一開始就具有並發性,即使在 80 年代所有事情都是在單核心處理器上完成的。每個 Erlang 處理程序都會有自己的時間片段來運行,就像多核心系統之前的桌面應用程式一樣。

當時仍然可以進行並行處理;你所需要做的就是讓第二台電腦運行程式碼並與第一台電腦進行通訊。即使這樣,在這個設定中也只能並行運行兩個 Actor。如今,多核心系統允許在單一電腦上進行並行處理(一些工業晶片具有數十個核心),而 Erlang 充分利用了這種可能性。

別喝太多酷愛飲料
區分並發和並行非常重要,因為許多程式設計師認為 Erlang 在多核心電腦出現的幾年前就已經為其做好了準備。Erlang 直到 2000 年代中期才適應真正的對稱多處理,並且直到 2009 年該語言的 R13B 版本才將大多數實作正確。在那之前,SMP通常必須停用以避免效能損失。若要在沒有 SMP 的多核心電腦上獲得並行處理,你需要啟動許多 VM 實例。

一個有趣的事實是,由於 Erlang 的並發性完全是關於隔離的處理程序,因此在語言層面上並沒有概念上的改變就能為該語言帶來真正的並行處理。所有變更都在 VM 中透明地完成,遠離程式設計師的視線。

並發概念

Joe Armstrong, as in 'Erlang - The Movie

在過去,Erlang 作為一種語言的發展非常迅速,經常從在 Erlang 本身中處理電話交換機的工程師那裡獲得回饋。這些互動證明了基於處理程序的並發和非同步訊息傳遞是建模他們面臨問題的好方法。此外,在 Erlang 出現之前,電話領域就已經存在某種朝向並發的文化。這繼承自愛立信早期創建的語言 PLEX 和使用它開發的交換機 AXE。Erlang 遵循了這種趨勢,並試圖改進現有的工具。

Erlang 在被認為是好的之前需要滿足一些要求。主要要求是能夠擴展並支援許多交換機上的數千名用戶,然後實現高可靠性——達到永遠不停止程式碼的程度。

可擴展性

我將首先關注擴展。一些屬性被認為是實現可擴展性所必需的。由於用戶將被表示為僅對某些事件做出反應的處理程序(例如:接聽電話、掛斷電話等),理想的系統將支援處理程序進行小計算,並在事件發生時快速切換它們。為了提高效率,處理程序能夠非常快速地啟動、非常快速地銷毀並且能夠非常快速地切換它們是有意義的。讓它們輕量級對於實現此目的至關重要。它也是強制性的,因為你不想擁有諸如處理程序池(你將工作分配給它的固定數量的處理程序)之類的東西。相反,設計可以使用所需的盡可能多的處理程序的程式會更容易。

可擴展性的另一個重要方面是能夠繞過硬體的限制。有兩種方法可以做到這一點:讓硬體更好,或者添加更多硬體。第一個選項在一定程度上很有用,之後它變得非常昂貴(例如:購買超級電腦)。第二個選項通常更便宜,需要你添加更多電腦來完成這項工作。這就是分散式部署可以作為語言一部分的用武之地。

無論如何,回到小的處理程序,由於電話應用程式需要高度的可靠性,因此決定最乾淨的做事方式是禁止處理程序共享記憶體。共享記憶體可能會在某些崩潰後(尤其是在跨不同節點共享的資料上)使事情處於不一致的狀態,並且會有一些複雜性。相反,處理程序應透過傳送複製所有資料的訊息來進行通訊。這可能會比較慢,但更安全。

容錯

這將我們引向 Erlang 的第二種要求:可靠性。Erlang 的第一批編寫者始終牢記失敗是常見的。你可以盡可能地嘗試預防錯誤,但大多數時候仍然會發生一些錯誤。即使沒有發生錯誤,也沒有任何東西可以阻止硬體隨時發生故障。因此,這個想法是找到處理錯誤和問題的好方法,而不是試圖預防所有錯誤和問題。

事實證明,採用具有訊息傳遞的多個處理程序的設計方法是個好主意,因為錯誤處理可以相對容易地接合到它上面。以輕量級處理程序(用於快速重新啟動和關閉)為例。一些研究證明,大規模軟體系統停機的主要原因是間歇性或瞬時錯誤(來源)。然後,有一個原則說,會損壞資料的錯誤應導致系統的有缺陷部分盡快終止,以避免將錯誤和錯誤資料傳播到系統的其餘部分。這裡的另一個概念是,系統終止的方式有很多種,其中兩種是乾淨的關閉和崩潰(以意外錯誤終止)。

這裡最糟糕的情況顯然是崩潰。一個安全的解決方案是確保所有崩潰都與乾淨關閉相同:這可以透過諸如無共享和單一分配(隔離處理程序的記憶體)等做法來完成,避免鎖定(鎖定可能會在崩潰期間沒有解除鎖定,阻止其他處理程序存取資料或使資料處於不一致的狀態)以及其他我不會再介紹的內容,但這些都是 Erlang 設計的一部分。因此,你在 Erlang 中理想的解決方案是盡快終止處理程序,以避免資料損壞和瞬時錯誤。輕量級處理程序是其中的關鍵要素。此外,語言中還包含了錯誤處理機制,允許處理程序監控其他處理程序(在錯誤和處理程序章節中描述),以便了解處理程序何時終止以及決定如何處理。

假設快速重新啟動處理程序足以應對崩潰,你遇到的下一個問題是硬體故障。當有人踢正在執行程式的電腦時,你如何確保程式繼續運行? 一台被仙人掌和雷射保護的伺服器 (HAL) 雖然一個包含雷射檢測和策略性放置的仙人掌的精緻防禦機制可以在一段時間內完成這項工作,但它不會永遠持續下去。提示很簡單,就是在多台電腦上同時運行你的程式,這無論如何都是擴展所需要的。這是沒有訊息傳遞之外的通訊通道的獨立處理程序的另一個優勢。你可以讓它們以相同的方式工作,無論它們是本地的還是在不同的電腦上,這使得透過分散式部署實現的容錯對程式設計師來說幾乎是透明的。

分散式架構會直接影響程序彼此間的溝通方式。分散式架構最大的障礙之一,就是你不能假設當你發出函式呼叫時存在的節點(遠端電腦),在整個呼叫傳輸過程中都會持續存在,甚至無法保證它能正確執行。有人絆到電纜或拔掉機器插頭都可能導致你的應用程式掛起,或者甚至崩潰,誰也說不準。

事實證明,選擇非同步訊息傳遞也是個好的設計。在「具有非同步訊息的程序」模型下,訊息從一個程序傳送到另一個程序,並儲存在接收程序內的信箱中,直到被取出讀取為止。 重要的是要提到,訊息的傳送甚至不會檢查接收程序是否存在,因為這樣做沒有意義。 如前一段所述,不可能知道一個程序在訊息傳送和接收之間是否會崩潰。 即使收到了,也無法知道它是否會被執行,或者接收程序是否會在執行前就終止。 非同步訊息允許安全的遠端函式呼叫,因為對可能發生的情況沒有任何假設; 程式設計師才是知道狀況的人。 如果你需要收到傳遞確認,你必須發送第二個訊息作為對原始程序的回覆。 這個訊息將具有相同的安全語意,任何你以此原則建構的程式或函式庫也都如此。

實作方式

好的,因此決定使用具有非同步訊息傳遞的輕量級程序來實現 Erlang。 要如何實現這一點呢? 首先,不能信任作業系統來處理程序。 作業系統處理程序的方式有很多種,而且效能差異很大。 大多數(如果不是全部)都太慢或太重,不符合標準 Erlang 應用程式的需求。 透過在 VM 中執行此操作,Erlang 實作者可以保持對最佳化和可靠性的控制。 現在,Erlang 的每個程序約佔用 300 個記憶體字組,並且可以在幾微秒內建立,這在當今主要作業系統上是無法實現的。

Erlang's run queues across cores

為了處理你的程式可能建立的所有這些潛在程序,VM 會針對每個核心啟動一個執行緒,作為排程器。 每個排程器都有一個執行佇列,或是要花費時間處理的 Erlang 程序清單。 當其中一個排程器的執行佇列中有太多任務時,一些任務會被移轉到另一個排程器。 這表示每個 Erlang VM 都會負責完成所有負載平衡,而程式設計師無需擔心。 還有一些其他的最佳化,例如限制過載程序發送訊息的速度,以調節和分配負載。

所有困難的部分都已在那裡,為你管理。 這就是 Erlang 易於並行化的原因。 並行化意味著如果添加第二個核心,你的程式應該會快兩倍,如果有 4 個核心則會快四倍,以此類推,對嗎? 這取決於情況。 這種現象稱為線性擴展,與速度增益與核心或處理器數量有關(請參閱下圖)。 在現實生活中,沒有免費的午餐(好吧,葬禮上有,但還是有人必須在某處付錢)。

並非完全不像線性擴展

難以獲得線性擴展並非因為語言本身,而是因為要解決的問題的本質。 那些擴展性很好的問題通常被稱為可輕易並行化。 如果你在網路上搜尋可輕易並行化的問題,你可能會找到一些例子,例如光線追蹤(一種建立 3D 影像的方法)、密碼學中的暴力搜尋、天氣預報等。

不時地,人們會在 IRC 頻道、論壇或郵件清單中出現,詢問是否可以使用 Erlang 來解決這類問題,或者是否可以用它在 GPU 上進行程式設計。 答案幾乎總是「否」。 原因相對簡單:所有這些問題通常都是關於大量數據運算的數值演算法。 Erlang 並不擅長處理這種問題。

Erlang 的可輕易並行化問題存在於更高的層次。 通常,它們與聊天伺服器、電話交換機、網頁伺服器、訊息佇列、網頁爬蟲或任何其他可以將工作表示為獨立邏輯實體(角色,有人嗎?)的應用程式有關。 這種問題可以用接近線性的擴展效率來解決。

許多問題永遠不會顯示出這種擴展特性。 事實上,你只需要一個集中的操作序列就會失去所有擴展性。 **你的並行程式的速度只會和你最慢的循序部分一樣快**。 任何時候你去購物中心都可以觀察到這種現象。 數百人可以同時購物,很少互相干擾。 然後,一旦到了付款時間,只要收銀員少於準備離開的顧客,就會出現排隊。

可以增加收銀員,直到每位顧客都有一個收銀員,但這樣的話,你就需要為每位顧客設置一個門,因為他們無法同時進入或離開購物中心。

換句話說,即使顧客可以並行選擇每個商品,並且無論他們是一個人還是一千人在商店裡,都可以花費相同的時間購物,他們仍然必須等待付款。 因此,他們的購物體驗永遠不會比他們在隊列中等待和付款的時間更短。

這個原則的概括稱為 阿姆達爾定律。 它指出當你向系統添加並行性時,你的系統可以期望獲得多少加速,以及以什麼比例加速

Graphic showing a program's speedup relative to how much of it is parallel on many cores

根據阿姆達爾定律,50% 並行的程式碼永遠不會比之前快兩倍,而 95% 並行的程式碼理論上在添加足夠的處理器後,可以預期快約 20 倍。 從這個圖表中看到有趣的是,與從一個並行度不高的程式中移除盡可能多的循序程式碼相比,移除程式中最後幾個循序部分如何實現相對巨大的理論加速。

別喝太多酷愛飲料
並行性並非所有問題的答案。 在某些情況下,並行化甚至會減慢你的應用程式的速度。 只要你的程式是 100% 循序的,但仍然使用多個程序,就會發生這種情況。

這方面最好的例子之一是環形基準測試。 環形基準測試是一個測試,其中數千個程序會以環狀方式將一塊資料傳遞給另一個程序。 如果你想的話,把它想像成一個 傳話遊戲。 在這個基準測試中,一次只有一個程序執行有用的操作,但 Erlang VM 仍然會花時間將負載分配到各個核心,並讓每個程序都獲得其時間份額。

這與許多常見的硬體最佳化背道而馳,並使 VM 花費時間做無用的事情。 這通常會導致純循序應用程式在多個核心上的執行速度比在單個核心上慢得多。 在這種情況下,停用對稱多處理 ($ erl -smp disable) 可能是一個好主意。

感謝所有魚!

當然,如果本章沒有介紹 Erlang 中並發所需的三個基本概念:產生新程序、傳送訊息和接收訊息,那麼本章就不完整了。 實際上,需要更多機制才能建立真正可靠的應用程式,但目前這些就足夠了。

我一直在繞過這個問題,而且我還沒有解釋程序到底是什麼。 它實際上只是一個函式。 就是這樣。 它執行一個函式,一旦完成就會消失。 從技術上講,程序也有一些隱藏的狀態(例如用於訊息的信箱),但目前函式就足夠了。

為了啟動一個新程序,Erlang 提供了函式 spawn/1,它接受一個單一函式並執行它

1> F = fun() -> 2 + 2 end.
#Fun<erl_eval.20.67289768>
2> spawn(F).
<0.44.0>

spawn/1 的結果 (<0.44.0>) 稱為程序識別符,社群通常簡寫為 PIDPidpid。 程序識別符是一個任意值,代表 VM 生命週期中某個時間點存在(或可能存在)的任何程序。 它用作與程序通訊的位址。

你會注意到我們看不到函式 F 的結果。 我們只會取得它的 pid。 這是因為程序不傳回任何內容。

那麼,我們如何才能看到 F 的結果呢? 好吧,有兩種方法。 最簡單的方法就是輸出我們得到的任何東西

3> spawn(fun() -> io:format("~p~n",[2 + 2]) end).
4
<0.46.0>

這對於實際的程式來說並不實用,但它對於了解 Erlang 如何分派程序很有用。 幸運的是,使用 io:format/2 足以讓我們進行實驗。 我們將快速啟動 10 個程序,並借助函式 timer:sleep/1 暫停每個程序一段時間,該函式接受一個整數值 N,並等待 N 毫秒後再繼續執行程式碼。 延遲後,會輸出程序中存在的值。

4> G = fun(X) -> timer:sleep(10), io:format("~p~n", [X]) end.
#Fun<erl_eval.6.13229925>
5> [spawn(fun() -> G(X) end) || X <- lists:seq(1,10)].
[<0.273.0>,<0.274.0>,<0.275.0>,<0.276.0>,<0.277.0>,
 <0.278.0>,<0.279.0>,<0.280.0>,<0.281.0>,<0.282.0>]
2   
1   
4   
3   
5   
8   
7   
6   
10  
9   

順序毫無意義。 歡迎來到並行性。 因為這些程序同時執行,所以事件的順序不再保證。 這是因為 Erlang VM 使用許多技巧來決定何時執行某個程序,確保每個程序都獲得良好的時間份額。 許多 Erlang 服務都是以程序的方式實作的,包括你正在輸入的 Shell。 你的程序必須與系統本身需要的程序保持平衡,這可能是導致順序混亂的原因。

注意:無論是否啟用對稱多處理,結果都類似。 為了證明這一點,你可以使用 $ erl -smp disable 啟動 Erlang VM 來進行測試。

為了首先了解你的 Erlang VM 是否在啟用或未啟用 SMP 支援的情況下執行,請啟動一個不帶任何選項的新 VM 並查看第一行輸出。 如果你看到文字 [smp:2:2] [rq:2],則表示你在啟用 SMP 的情況下執行,並且你有 2 個執行佇列(rq 或排程器)在兩個核心上執行。 如果你只看到 [rq:1],則表示你在停用 SMP 的情況下執行。

如果你想知道的話,[smp:2:2] 表示有兩個核心可用,並有兩個排程器。[rq:2] 表示有兩個執行佇列處於活動狀態。 在早期的 Erlang 版本中,你可以有多個排程器,但只有一個共享執行佇列。 從 R13B 開始,預設情況下每個排程器都有一個執行佇列,這樣可以實現更好的並行性。

為了證明 Shell 本身是作為常規程序實作的,我將使用 BIF self/0,它會傳回目前程序的 pid

6> self().
<0.41.0>
7> exit(self()).
** exception exit: <0.41.0>
8> self().
<0.285.0>

pid 會變更,因為程序已重新啟動。 稍後將介紹其運作方式的詳細資訊。 現在,還有更多基礎知識需要介紹。 現在最重要的就是弄清楚如何傳送訊息,因為沒有人希望總是輸出程序的結果值,然後手動將它們輸入到其他程序中(至少我知道我不會)。

進行訊息傳遞所需的下一個基本概念是運算子 !,也稱為驚嘆號。 在左側,它接受一個 pid,在右側,它接受任何 Erlang 項。 然後,該項會被傳送到 pid 所代表的程序,該程序可以存取它

9> self() ! hello.
hello

訊息已放入行程的信箱,但尚未被讀取。這裡顯示的第二個 hello 是傳送操作的回傳值。這表示可以透過以下方式將相同的訊息傳送給多個行程:

10> self() ! self() ! double.
double

這等同於 self() ! (self() ! double)。關於行程信箱需要注意的是,訊息會依照接收順序保存。每次讀取訊息時,都會將其從信箱中取出。這有點類似於入門章節中人們寫信的例子。

Message passing explained as a drawing, again

若要查看目前信箱的內容,您可以在 shell 中使用 flush() 命令

11> flush().
Shell got hello
Shell got double
Shell got double
ok

此函數只是一個輸出已接收訊息的快捷方式。這表示我們仍然無法將行程的結果綁定到變數,但至少我們知道如何從一個行程傳送訊息到另一個行程,並檢查是否已接收。

傳送沒有人會讀取的訊息就像寫 emo 詩一樣;沒什麼用處。這就是為什麼我們需要 receive 陳述式。與其在 shell 中玩太久,不如寫一個關於海豚的簡短程式來學習它

-module(dolphins).
-compile(export_all).

dolphin1() ->
    receive
        do_a_flip ->
            io:format("How about no?~n");
        fish ->
            io:format("So long and thanks for all the fish!~n");
        _ ->
            io:format("Heh, we're smarter than you humans.~n")
    end.

如您所見,receive 在語法上類似於 case ... of。事實上,模式的工作方式完全相同,只是它們綁定來自訊息的變數,而不是 caseof 之間的表達式。接收也可以有保護子句

receive
    Pattern1 when Guard1 -> Expr1;
    Pattern2 when Guard2 -> Expr2;
    Pattern3 -> Expr3
end

我們現在可以編譯上面的模組,執行它,並開始與海豚溝通

11> c(dolphins).
{ok,dolphins}
12> Dolphin = spawn(dolphins, dolphin1, []).
<0.40.0>
13> Dolphin ! "oh, hello dolphin!".
Heh, we're smarter than you humans.
"oh, hello dolphin!"
14> Dolphin ! fish.                
fish
15> 

在這裡,我們介紹一種新的使用 spawn/3 產生行程的方式。spawn/3 不是接受單一函數,而是將模組、函數及其參數作為自己的參數。一旦函數開始執行,就會發生以下事件

  1. 函數遇到 receive 陳述式。由於行程的信箱是空的,我們的海豚會等待直到收到訊息;
  2. 收到訊息 "oh, hello dolphin!"。函數嘗試比對 do_a_flip 模式。比對失敗,因此嘗試 fish 模式,也失敗。最後,訊息符合萬用子句 (_) 並比對成功。
  3. 行程輸出訊息 "Heh, we're smarter than you humans."

然後應該注意的是,如果我們傳送的第一個訊息有效,第二個訊息沒有引起行程 <0.40.0> 的任何反應。這是因為一旦我們的函數輸出 "Heh, we're smarter than you humans.",它就會終止,行程也會隨之終止。我們需要重新啟動海豚

8> f(Dolphin).    
ok
9> Dolphin = spawn(dolphins, dolphin1, []).
<0.53.0>
10> Dolphin ! fish.
So long and thanks for all the fish!
fish

這次 fish 訊息有效。如果能夠收到海豚的回覆,而不是必須使用 io:format/2,會不會很有用呢?當然會(我為什麼要問?)我在本章前面提到,知道行程是否收到訊息的唯一方法是傳送回覆。我們的海豚行程需要知道要回覆給誰。這就像郵政服務一樣。如果我們希望有人回覆我們的信,我們需要加上我們的地址。在 Erlang 中,這可以透過將行程的 PID 包裝在 tuple 中來完成。最終結果是類似於 {Pid, Message} 的訊息。讓我們建立一個新的海豚函數來接受這樣的訊息

dolphin2() ->
    receive
        {From, do_a_flip} ->
            From ! "How about no?";
        {From, fish} ->
            From ! "So long and thanks for all the fish!";
        _ ->
            io:format("Heh, we're smarter than you humans.~n")
    end.

如您所見,我們現在需要一個變數 From,而不是接受 do_a_flipfish 作為訊息。行程識別碼將會放在這裡。

11> c(dolphins).
{ok,dolphins}
12> Dolphin2 = spawn(dolphins, dolphin2, []).
<0.65.0>
13> Dolphin2 ! {self(), do_a_flip}.          
{<0.32.0>,do_a_flip}
14> flush().
Shell got "How about no?"
ok

它似乎運作良好。我們可以收到我們傳送的訊息的回覆(我們需要在每個訊息中添加地址),但我們仍然需要為每次呼叫啟動一個新的行程。遞迴是解決這個問題的方法。我們只需要讓函數呼叫自己,這樣它就不會結束,而且總是在等待更多訊息。以下是一個 dolphin3/0 函數,將此付諸實踐

dolphin3() ->
    receive
        {From, do_a_flip} ->
            From ! "How about no?",
            dolphin3();
        {From, fish} ->
            From ! "So long and thanks for all the fish!";
        _ ->
            io:format("Heh, we're smarter than you humans.~n"),
            dolphin3()
    end.

在這裡,萬用子句和 do_a_flip 子句都藉由 dolphin3/0 迴圈。請注意,該函數不會使堆疊溢位,因為它是尾遞迴。只要只傳送這些訊息,海豚行程就會無限循環。但是,如果我們傳送 fish 訊息,行程就會停止

15> Dolphin3 = spawn(dolphins, dolphin3, []).
<0.75.0>
16> Dolphin3 ! Dolphin3 ! {self(), do_a_flip}.
{<0.32.0>,do_a_flip}
17> flush().
Shell got "How about no?"
Shell got "How about no?"
ok
18> Dolphin3 ! {self(), unknown_message}.     
Heh, we're smarter than you humans.
{<0.32.0>,unknown_message}
19> Dolphin3 ! Dolphin3 ! {self(), fish}.
{<0.32.0>,fish}
20> flush().
Shell got "So long and thanks for all the fish!"
ok

關於 dolphins.erl 就到此為止。如您所見,它確實符合我們預期的行為,針對每個訊息回覆一次,然後繼續下去,除了 fish 呼叫之外。海豚受夠了我們人類瘋狂的行為,永遠離開了我們。

A man asking a dolphin to do a flip. The dolphin (dressed like the fonz) replies 'how about no?'

就是這樣。這是一切 Erlang 並行性的核心。我們已經了解了行程和基本訊息傳遞。為了建立真正有用且可靠的程式,還有更多概念需要了解。我們將在下一章中看到其中一些概念,在之後的章節中還會看到更多。