錯誤與程序

連結

連結是一種可以在兩個程序之間建立的特定關係。當這種關係建立後,其中一個程序因意外的 throw、error 或 exit(請參閱錯誤與例外)而終止時,另一個連結的程序也會終止。

從盡快失敗以停止錯誤的角度來看,這是一個有用的概念:如果發生錯誤的程序崩潰,但依賴它的程序沒有崩潰,那麼所有這些依賴的程序現在都必須處理依賴項消失的問題。讓它們終止然後重新啟動整個群組通常是一個可以接受的替代方案。連結可以讓我們做到這一點。

為了在兩個程序之間建立連結,Erlang 提供了基本函式 link/1,它接受一個 Pid 作為參數。當呼叫時,此函式會在目前程序和 Pid 所識別的程序之間建立連結。要移除連結,請使用 unlink/1。當其中一個連結的程序崩潰時,會傳送一種特殊的訊息,其中包含有關發生情況的資訊。如果程序因自然原因終止(讀作:完成執行其函式),則不會傳送此類訊息。我將首先在 linkmon.erl 中介紹這個新函式

myproc() ->
    timer:sleep(5000),
    exit(reason).

如果您嘗試以下呼叫(並在每個 spawn 命令之間等待 5 秒),您應該只在兩個程序之間建立連結時,才會看到 shell 因 'reason' 而崩潰。

1> c(linkmon).
{ok,linkmon}
2> spawn(fun linkmon:myproc/0).
<0.52.0>
3> link(spawn(fun linkmon:myproc/0)).
true
** exception error: reason

或者,用圖片表示

A process receiving an exit signal

不過,這個 {'EXIT', B, Reason} 訊息不能像平常那樣用 try ... catch 捕獲。需要使用其他機制來執行此操作。我們稍後會看到它們。

重要的是要注意,連結用於建立應一起終止的較大程序群組

chain(0) ->
    receive
        _ -> ok
    after 2000 ->
        exit("chain dies here")
    end;
chain(N) ->
    Pid = spawn(fun() -> chain(N-1) end),
    link(Pid),
    receive
        _ -> ok
    end.

此函式將接受一個整數 N,啟動 N 個彼此連結的程序。為了能夠將 N-1 參數傳遞給下一個「鏈」程序(呼叫 spawn/1),我將呼叫包裝在匿名函式內,這樣它就不再需要參數。呼叫 spawn(?MODULE, chain, [N-1]) 會執行類似的工作。

在這裡,我將有很多程序連結在一起,隨著它們的每個後繼者終止而終止

4> c(linkmon).               
{ok,linkmon}
5> link(spawn(linkmon, chain, [3])).
true
** exception error: "chain dies here"

而且您可以看到,shell 確實會收到來自其他某些程序的終止訊號。以下是已啟動的程序和連結向下移動的圖示

[shell] == [3] == [2] == [1] == [0]
[shell] == [3] == [2] == [1] == *dead*
[shell] == [3] == [2] == *dead*
[shell] == [3] == *dead*
[shell] == *dead*
*dead, error message shown*
[shell] <-- restarted

執行 linkmon:chain(0) 的程序終止後,錯誤會沿著連結鏈向下傳播,直到 shell 程序本身也因此而終止。崩潰可能發生在任何連結的程序中;由於連結是雙向的,您只需要其中一個終止,其他程序就會隨之終止。

注意:如果您想從 shell 終止另一個程序,您可以使用函式 exit/2,其呼叫方式如下:exit(Pid, Reason)。如果您願意,請嘗試一下。

注意:連結不能堆疊。如果您對相同的兩個程序呼叫 link/1 15 次,它們之間仍然只會存在一個連結,而且只需要單次呼叫 unlink/1 就足以將其拆除。

重要的是要注意,link(spawn(Function))link(spawn(M,F,A)) 會分多個步驟發生。在某些情況下,程序有可能在連結建立之前就終止,然後引發意外行為。因此,已將函式 spawn_link/1-3 新增到語言中。它接受與 spawn/1-3 相同的參數,建立一個程序並將其連結,就像已存在 link/1 一樣,只是所有這些都是作為原子操作完成的(這些操作被組合成一個單一操作,它可能失敗或成功,但沒有其他情況)。這通常被認為更安全,而且您還可以節省一組括號。

Admiral Ackbar

這是一個陷阱!

現在回到連結和程序終止的問題。跨程序的錯誤傳播是透過類似於訊息傳遞的程序完成的,但使用一種稱為訊號的特殊訊息類型。終止訊號是會自動作用於程序的「秘密」訊息,會在其作用時終止程序。

我已經多次提到,為了可靠起見,應用程式需要能夠快速終止和重新啟動程序。目前,連結可以很好地完成終止部分。缺少的是重新啟動。

為了重新啟動程序,我們需要一種方法來首先知道它已終止。這可以透過在連結之上新增一層(蛋糕上的美味糖霜)並使用稱為系統程序的概念來完成。系統程序基本上是正常的程序,只是它們可以將終止訊號轉換為一般訊息。這可以透過在執行中的程序中呼叫 process_flag(trap_exit, true) 來完成。沒有什麼比範例更能說明問題,所以我們將使用範例。我將使用開頭的系統程序來重做鏈範例

1> process_flag(trap_exit, true).
true
2> spawn_link(fun() -> linkmon:chain(3) end).
<0.49.0>
3> receive X -> X end.
{'EXIT',<0.49.0>,"chain dies here"}

啊!現在事情變得有趣了。回到我們的圖示,現在發生的情況更像是這樣

[shell] == [3] == [2] == [1] == [0]
[shell] == [3] == [2] == [1] == *dead*
[shell] == [3] == [2] == *dead*
[shell] == [3] == *dead*
[shell] <-- {'EXIT,Pid,"chain dies here"} -- *dead*
[shell] <-- still alive!

這就是允許快速重新啟動程序的機制。透過使用系統程序編寫程式,可以輕鬆建立一個程序,其唯一作用是檢查是否有東西終止,然後在失敗時重新啟動它。我們將在下一章中更深入地探討此內容,屆時我們將真正應用這些技術。

現在,我想回到例外章節中看到的例外函式,並展示它們在捕獲終止的程序周圍的行為。讓我們首先設定基礎,以便在沒有系統程序的情況下進行實驗。我將依序展示相鄰程序中未捕獲的 throw、error 和 exit 的結果

例外來源:spawn_link(fun() -> ok end)
未捕獲的結果:- 無 -
捕獲的結果{'EXIT', <0.61.0>, normal}
程序正常終止,沒有問題。請注意,這看起來有點像 catch exit(normal) 的結果,只是在元組中新增了 PID 以知道哪個程序失敗。
例外來源:spawn_link(fun() -> exit(reason) end)
未捕獲的結果** exception exit: reason
捕獲的結果{'EXIT', <0.55.0>, reason}
程序已因自訂原因終止。在這種情況下,如果沒有捕獲終止,則程序會崩潰。否則,您會收到以上訊息。
例外來源:spawn_link(fun() -> exit(normal) end)
未捕獲的結果:- 無 -
捕獲的結果{'EXIT', <0.58.0>, normal}
這成功模擬了程序正常終止。在某些情況下,您可能希望終止程序作為程式正常流程的一部分,而沒有發生任何異常情況。這就是執行此操作的方式。
例外來源:spawn_link(fun() -> 1/0 end)
未捕獲的結果Error in process <0.44.0> with exit value: {badarith, [{erlang, '/', [1,0]}]}
捕獲的結果{'EXIT', <0.52.0>, {badarith, [{erlang, '/', [1,0]}]}}
錯誤({badarith, Reason})永遠不會被 try ... catch 區塊捕獲,並冒泡到 'EXIT' 中。此時,它的行為與 exit(reason) 完全相同,但帶有堆疊追蹤,提供有關發生情況的更多詳細資訊。
例外來源:spawn_link(fun() -> erlang:error(reason) end)
未捕獲的結果Error in process <0.47.0> with exit value: {reason, [{erlang, apply, 2}]}
捕獲的結果{'EXIT', <0.74.0>, {reason, [{erlang, apply, 2}]}}
1/0 幾乎相同。這很正常,erlang:error/1 的目的是允許您執行此操作。
例外來源:spawn_link(fun() -> throw(rocks) end)
未捕獲的結果Error in process <0.51.0> with exit value: {{nocatch, rocks}, [{erlang, apply, 2}]}
捕獲的結果{'EXIT', <0.79.0>, {{nocatch, rocks}, [{erlang, apply, 2}]}}
由於 throw 永遠不會被 try ... catch 捕獲,因此它會冒泡到錯誤中,而錯誤又會冒泡到 EXIT 中。如果沒有捕獲終止,則程序會失敗。否則,它可以正常處理它。

這就是關於常見例外的全部內容。事情很正常:一切都很好。發生異常情況:程序終止,會傳送不同的訊號。

然後是 exit/2。這是一個 Erlang 程序,相當於一把槍。它允許程序從遠處安全地終止另一個程序。以下是一些可能的呼叫

例外來源:exit(self(), normal)
未捕獲的結果** exception exit: normal
捕獲的結果{'EXIT', <0.31.0>, normal}
當不捕獲終止時,exit(self(), normal) 的作用與 exit(normal) 相同。否則,您會收到與收聽來自國外終止程序的連結時相同的格式的訊息。
例外來源:exit(spawn_link(fun() -> timer:sleep(50000) end), normal)
未捕獲的結果:- 無 -
捕獲的結果:- 無 -
這基本上是對 exit(Pid, normal) 的呼叫。此命令沒有任何作用,因為無法以 normal 作為參數遠端終止程序。
例外來源:exit(spawn_link(fun() -> timer:sleep(50000) end), reason)
未捕獲的結果** exception exit: reason
捕獲的結果{'EXIT', <0.52.0>, reason}
這是國外程序因 reason 本身而終止。看起來與國外程序在自身上呼叫 exit(reason) 的情況相同。
例外來源:exit(spawn_link(fun() -> timer:sleep(50000) end), kill)
未捕獲的結果** exception exit: killed
捕獲的結果{'EXIT', <0.58.0>, killed}
令人驚訝的是,訊息會從終止的程序變更為產生器。產生器現在收到 killed 而不是 kill。這是因為 kill 是一種特殊的終止訊號。稍後將詳細介紹此內容。
例外來源:exit(self(), kill)
未捕獲的結果** exception exit: killed
捕獲的結果** exception exit: killed
哎呀,看看那個。似乎這個實際上不可能捕獲。讓我們檢查一下。
例外來源:spawn_link(fun() -> exit(kill) end)
未捕獲的結果** exception exit: killed
捕獲的結果{'EXIT', <0.67.0>, kill}
現在變得令人困惑了。當另一個程序使用 exit(kill) 自行終止,而且我們不捕獲終止時,我們自己的程序會因原因 killed 而終止。但是,當我們捕獲終止時,情況並非如此。

雖然您可以捕獲大多數終止原因,但在某些情況下,您可能希望殘酷地謀殺程序:也許其中一個程序正在捕獲終止,但也卡在無限迴圈中,永遠不會讀取任何訊息。kill 原因充當無法捕獲的特殊訊號。這確保您使用它終止的任何程序都將真正終止。通常,kill 有點像是最後的手段,當其他一切都失敗時才使用。

A mouse trap with a beige laptop on top

由於 kill 原因永遠無法捕獲,因此當其他程序收到訊息時,需要將其變更為 killed。如果沒有以這種方式變更,則連結到它的其他每個程序都會因相同的 kill 原因而終止,並且反過來會終止其相鄰程序,依此類推。會產生死亡級聯。

這也解釋了為什麼從另一個連結的程序接收到 exit(kill) 時,看起來會像 killed(訊號被修改,使其不會級聯),但在本地捕獲時仍然看起來像 kill

如果你覺得這些都很令人困惑,別擔心。許多程式設計師都有同感。退出訊號有點像一種奇怪的生物。幸運的是,特殊情況並不多於上述的那些。一旦你理解了這些,你就可以毫無問題地理解 Erlang 的大多數並行錯誤管理。

監控器

所以,是的。也許謀殺程序不是你想要的。也許你不希望自己一旦消失就讓世界跟著你一起崩潰。也許你更像一個跟蹤狂。在這種情況下,監控器可能才是你想要的。

更嚴肅地說,監控器是一種特殊的連結類型,有兩個差異

Ugly Homer Simpson parody

當一個程序想要知道第二個程序發生了什麼事,但它們彼此都不是至關重要的時,監控器就是你想要的。

如上所述,另一個原因是堆疊引用。現在,從快速瀏覽來看,這可能看起來沒用,但它對於編寫需要知道其他程序發生了什麼事的程式庫來說非常有用。

你看,連結更像是一種組織結構。當你設計應用程式的架構時,你會決定哪個程序將執行哪些工作,以及哪些將依賴於哪些。有些程序會監督其他程序,有些程序如果沒有雙生程序就無法生存等等。這種結構通常是固定的,事先知道的。連結對此很有用,並且不應該在它之外使用。

但是,如果你有 2 或 3 個不同的程式庫,你呼叫它們,而且它們都需要知道一個程序是否存活,那會發生什麼事?如果你要使用連結來做到這一點,當你需要取消連結一個程序時,你會很快遇到問題。現在,連結不可堆疊,所以一旦你取消連結一個,你就會取消連結所有連結,並搞砸其他程式庫所建立的所有假設。這非常糟糕。因此,你需要可堆疊的連結,而監控器是你的解決方案。它們可以單獨刪除。此外,在程式庫中單向是很方便的,因為其他程序不應該需要知道這些程式庫的存在。

那麼監控器是什麼樣子的呢?很簡單,讓我們設定一個。該函式是 erlang:monitor/2,其中第一個參數是原子 process,第二個參數是 pid

1> erlang:monitor(process, spawn(fun() -> timer:sleep(500) end)).
#Ref<0.0.0.77>
2> flush().
Shell got {'DOWN',#Ref<0.0.0.77>,process,<0.63.0>,normal}
ok

每次你監控的程序關閉時,你都會收到這樣的訊息。訊息是 {'DOWN', MonitorReference, process, Pid, Reason}。參考用於讓你取消監控該程序。請記住,監控器是可堆疊的,因此有可能關閉多個監控器。參考可讓你以獨特的方式追蹤每個監控器。另外請注意,與連結一樣,有一個原子函式可以在監控它的同時產生一個程序,spawn_monitor/1-3

3> {Pid, Ref} = spawn_monitor(fun() -> receive _ -> exit(boom) end end).
{<0.73.0>,#Ref<0.0.0.100>}
4> erlang:demonitor(Ref).
true
5> Pid ! die.
die
6> flush().
ok

在這種情況下,我們在另一個程序崩潰之前就取消了對它的監控,因此我們沒有任何關於它死亡的追蹤。函式 demonitor/2 也存在,並提供了一些額外的資訊。第二個參數可以是選項列表。只有兩個存在,infoflush

7> f().
ok
8> {Pid, Ref} = spawn_monitor(fun() -> receive _ -> exit(boom) end end). 
{<0.35.0>,#Ref<0.0.0.35>}
9> Pid ! die.
die
10> erlang:demonitor(Ref, [flush, info]).
false
11> flush().
ok

當你嘗試移除監控器時,info 選項會告訴你該監控器是否存在。這就是為什麼運算式 10 返回 false。使用 flush 作為選項將會從信箱中移除 DOWN 訊息(如果該訊息存在),導致 flush() 在目前程序的信箱中找不到任何內容。

命名程序

在理解連結和監控器之後,還有另一個問題仍然需要解決。讓我們使用 linkmon.erl 模組的以下函式

start_critic() ->
    spawn(?MODULE, critic, []).

judge(Pid, Band, Album) ->
    Pid ! {self(), {Band, Album}},
    receive
        {Pid, Criticism} -> Criticism
    after 2000 ->
        timeout
    end.

critic() ->
    receive
        {From, {"Rage Against the Turing Machine", "Unit Testify"}} ->
            From ! {self(), "They are great!"};
        {From, {"System of a Downtime", "Memoize"}} ->
            From ! {self(), "They're not Johnny Crash but they're good."};
        {From, {"Johnny Crash", "The Token Ring of Fire"}} ->
            From ! {self(), "Simply incredible."};
        {From, {_Band, _Album}} ->
            From ! {self(), "They are terrible!"}
    end,
    critic().

現在我們就假裝我們正在逛商店,購買音樂。有一些專輯聽起來很有趣,但我們從來都不太確定。你決定打電話給你的朋友,評論家。

1> c(linkmon).                         
{ok,linkmon}
2> Critic = linkmon:start_critic().
<0.47.0>
3> linkmon:judge(Critic, "Genesis", "The Lambda Lies Down on Broadway").
"They are terrible!"

由於太陽風暴(我試著在這裡找一些真實的東西),連線斷開了

4> exit(Critic, solar_storm).
true
5> linkmon:judge(Critic, "Genesis", "A trick of the Tail Recursion").
timeout

很煩人。我們再也無法獲得關於專輯的評論了。為了讓評論家保持活躍,我們將編寫一個基本的「監督者」程序,其唯一作用是在評論家關閉時重新啟動它

start_critic2() ->
    spawn(?MODULE, restarter, []).

restarter() ->
    process_flag(trap_exit, true),
    Pid = spawn_link(?MODULE, critic, []),
    receive
        {'EXIT', Pid, normal} -> % not a crash
            ok;
        {'EXIT', Pid, shutdown} -> % manual termination, not a crash
            ok;
        {'EXIT', Pid, _} ->
            restarter()
    end.

在這裡,重新啟動者將會是它自己的程序。它將反過來啟動評論家的程序,而且如果評論家因異常原因而死亡,restarter/0 將會循環並建立一個新的評論家。請注意,我為 {'EXIT', Pid, shutdown} 新增了一個子句,作為在我們需要時手動關閉評論家的一種方式。

我們的方法的問題在於,沒有辦法找到評論家的 Pid,因此我們無法呼叫他來獲得他的意見。Erlang 解決這個問題的其中一種方法是為程序命名。

為程序命名的行為可讓你用原子來取代不可預測的 pid。然後,此原子可以在傳送訊息時完全像 Pid 一樣使用。要為程序命名,可以使用函式 erlang:register/2。如果程序死亡,它將會自動失去其名稱,或者你也可以使用 unregister/1 來手動執行此操作。你可以使用 registered/0 取得所有已註冊程序的列表,或使用 shell 命令 regs() 取得更詳細的列表。在這裡,我們可以將 restarter/0 函式重寫如下

restarter() ->
    process_flag(trap_exit, true),
    Pid = spawn_link(?MODULE, critic, []),
    register(critic, Pid),
    receive
        {'EXIT', Pid, normal} -> % not a crash
            ok;
        {'EXIT', Pid, shutdown} -> % manual termination, not a crash
            ok;
        {'EXIT', Pid, _} ->
            restarter()
    end. 

所以如你所見,無論 Pid 是什麼,register/2 都會永遠給我們的評論家命名為 'critic'。我們需要做的是從抽象函式中移除傳入 Pid 的需要。讓我們試試這個

judge2(Band, Album) ->
    critic ! {self(), {Band, Album}},
    Pid = whereis(critic),
    receive
        {Pid, Criticism} -> Criticism
    after 2000 ->
        timeout
    end.

在這裡,行 Pid = whereis(critic) 用於尋找評論家的程序識別碼,以便在 receive 運算式中針對它進行模式匹配。我們想要與此 pid 匹配,因為它可以確保我們將會匹配正確的訊息(我們說話時,信箱中可能會有 500 條訊息!)不過,這可能會是問題的根源。上面的程式碼假設評論家的 pid 在函式的前兩行之間將保持不變。但是,完全有可能會發生以下情況

  1. critic ! Message
                        2. critic receives
                        3. critic replies
                        4. critic dies
  5. whereis fails
                        6. critic is restarted
  7. code crashes

或者,這也是一種可能性

  1. critic ! Message
                           2. critic receives
                           3. critic replies
                           4. critic dies
                           5. critic is restarted
  6. whereis picks up
     wrong pid
  7. message never matches

如果我們沒有正確地執行操作,在不同程序中出錯的可能性可能會導致另一個程序出錯。在這種情況下,可以從多個程序中看到 critic 原子的值。這被稱為共享狀態。這裡的問題在於,幾乎可以同時被不同的程序存取修改 critic 的值,導致資訊不一致和軟體錯誤。這種情況的通用術語是競爭條件。競爭條件特別危險,因為它們取決於事件的時序。在幾乎所有並行和並行語言中,此時序取決於不可預測的因素,例如處理器有多忙、程序在哪裡執行,以及你的程式正在處理哪些資料。

別喝太多酷愛飲料
你可能聽說過,Erlang 通常不會出現競爭條件或死鎖,並且可以使並行程式碼安全。這在許多情況下是正確的,但永遠不要假設你的程式碼真的那麼安全。命名程序只是並行程式碼可能出錯的許多方式的一個例子。

其他範例包括存取電腦上的檔案(以修改它們)、從許多不同的程序更新相同的資料庫記錄等等。

幸運的是,如果我們不假設命名程序保持不變,則可以相對容易地修復上面的程式碼。相反地,我們將使用參考(使用 make_ref() 建立)作為識別訊息的唯一值。我們需要將 critic/0 函式重寫為 critic2/0,並將 judge/3 重寫為 judge2/2

judge2(Band, Album) ->
    Ref = make_ref(),
    critic ! {self(), Ref, {Band, Album}},
    receive
        {Ref, Criticism} -> Criticism
    after 2000 ->
        timeout
    end.

critic2() ->
    receive
        {From, Ref, {"Rage Against the Turing Machine", "Unit Testify"}} ->
            From ! {Ref, "They are great!"};
        {From, Ref, {"System of a Downtime", "Memoize"}} ->
            From ! {Ref, "They're not Johnny Crash but they're good."};
        {From, Ref, {"Johnny Crash", "The Token Ring of Fire"}} ->
            From ! {Ref, "Simply incredible."};
        {From, Ref, {_Band, _Album}} ->
            From ! {Ref, "They are terrible!"}
    end,
    critic2().

然後透過使其產生 critic2/0 而不是 critic/0 來變更 restarter/0 以符合需求。現在,其他函式應該可以正常運作。使用者不會看到差異。好吧,他們會看到,因為我們重新命名了函式並變更了參數的數量,但他們不會知道變更了哪些實作細節,以及為什麼它很重要。他們將看到的是他們的程式碼變得更簡單,並且他們不再需要在函式呼叫中傳送 pid

6> c(linkmon).
{ok,linkmon}
7> linkmon:start_critic2().
<0.55.0>
8> linkmon:judge2("The Doors", "Light my Firewall").
"They are terrible!"
9> exit(whereis(critic), kill).
true
10> linkmon:judge2("Rage Against the Turing Machine", "Unit Testify").     
"They are great!"

現在,即使我們殺死了評論家,一個新的評論家也會立即回來解決我們的問題。這就是命名程序的好處。如果你嘗試呼叫 linkmon:judge/2 而沒有已註冊的程序,則函式內的 ! 運算子會拋出 bad argument 錯誤,以確保依賴於已命名程序的程序無法在沒有它們的情況下執行。

注意:如果你還記得先前的文章,原子可以用於有限(儘管數量很多)的數字。你不應該建立動態原子。這表示命名程序應該保留給對 VM 實例而言很重要的唯一服務,以及在你的應用程式執行期間應該一直存在的程序。

如果你需要命名程序,但它們是暫時的,或者它們中沒有任何一個對於 VM 而言是唯一的,這可能表示它們需要以群組的形式表示。如果它們崩潰,將它們連結並一起重新啟動可能是一個明智的選擇,而不是嘗試使用動態名稱。

在下一章中,我們將運用最近在 Erlang 並行程式設計方面學到的知識來練習編寫實際的應用程式。