# 第8章 編寫健壯的應用程序
| 翻譯: | 王飛 |
|-----|-----|
| 校對: | 連城 |
第7章講解了Erlang的錯誤處理機制。這一章我們來看看怎樣使用這些機制來構建健壯、容錯的系統。
### 防范錯誤數據
回想一下在第??章(程序??.5)中描述的那個用來分析電話號碼的服務程序。它的主循環包含了以下代碼:
~~~
server(AnalTable) ->
receive
{From, {analyse,Seq}} ->
Result = lookup(Seq, AnalTable),
From ! {number_analyser, Result},
server(AnalTable);
{From, {add_number, Seq, Key}} ->
From ! {number_analyser, ack},
server(insert(Seq, Key, AnalTable))
end.
~~~
以上的Seq是一個表示電話號碼的數字序列,如[5,2,4,8,9]。在編寫lookup/2和insert/3這兩個函數時,我們應檢查Seq是否是一個電話撥號按鍵字符[[1]](#)的列表。若不做這個檢查,假設Seq是一個原子項hello,就會導致運行時錯誤。一個簡單些的做法是將lookup/2和insert/3放在一個catch語句的作用域中求值:
~~~
server(AnalTable) ->
receive
{From, {analyse,Seq}} ->
case catch lookup(Seq, AnalTable) of
{'EXIT', _} ->
From ! {number_analyser, error};
Result ->
From ! {number_analyser, Result}
end,
server(AnalTable);
{From, {add_number, Seq, Key}} ->
From ! {number_analyser, ack},
case catch insert(Seq, Key, AnalTable) of
{'EXIT', _} ->
From ! {number_analyser, error},
server(AnalTable); % Table not changed
NewTable ->
server(NewTable)
end
end.
~~~
注意,借助catch我們的號碼分析函數可以只處理正常情況,而讓Erlang的錯誤處理機制去處理badmatch、badarg、function_clause等錯誤。
一般來說,設計服務器時應注意即使面對錯誤的輸入數據,服務器也不會“崩潰”。很多情況下發送給服務器的數據都來自服務器的訪問函數。在上面的例子中,號碼分析服務器獲悉的客戶端進程標識From是從訪問函數獲得的,例如:
~~~
lookup(Seq) ->
number_analyser ! {self(), {analyse,Seq}},
receive
{number_analyser, Result} ->
Result
end.
~~~
服務器不需要檢查From是否是一個進程標識。在這個案例中,我們(借助訪問函數)來防范意外的錯誤情況。然而惡意程序仍然可以繞過訪問函數,向服務器發送惡意數據致使服務器崩潰:
~~~
number_analyser ! {55, [1,2,3]}
~~~
這樣一來號碼分析器將試圖向進程55發送分析結果,繼而崩潰。
### 健壯的服務進程
講解可靠服務進程設計的最好方法就是借助實例。
第??章(程序??.6)給出了一個資源分配器。對于這個分配器,如果一個資源被分配給了進程,而這個進程在釋放資源之前終止(無論是出于意外還是正常終止),那么這個資源就無法被收回。這個問題可以通過以下的方法來解決:
- 令服務程序捕捉EXIT信號(process_flag(trap_exit,true))。
- 在分配器和申請資源的進程之間建立連接。
- 處理由這些進程發出的EXIT信號。
正如圖 8.1 所示。

圖8.1 健壯的分配器進程和客戶進程
分配器的訪問函數不變。通過以下方式啟動分配器:
~~~
start_server(Resources) ->
process_flag(trap_exit, true),
server(Resources, []).
~~~
為了接收EXIT信號,我們將 “服務器” 循環改為:
~~~
server(Free, Allocated) ->
receive
{From,alloc} ->
allocate(Free, Allocated, From);
{From,{free,R}} ->
free(Free, Allocated, From, R);
{'EXIT', From, _ } ->
check(Free, Allocated, From)
end.
~~~
為了跟申請資源(如果還有資源可用)的進程建立連接,還需要修改allocate/3 。
~~~
allocate([R|Free], Allocated, From) ->
link(From),
From ! {resource_alloc,{yes,R}},
server(Free, [{R,From}|Allocated]);
allocate([], Allocated, From) ->
From ! {resource_alloc,no},
server([], Allocated).
~~~
free/4更復雜些:
~~~
free(Free, Allocated, From, R) ->
case lists:member({R, From}, Allocated) of
true ->
From ! {resource_alloc, yes},
Allocated1 = lists:delete({R, From}, Allocated),
case lists:keysearch(From, 2, Allocated1) of
false ->
unlink(From);
_ ->
true
end,
server([R|Free], Allocated1);
false ->
From ! {resource_alloc, error},
server(Free, Allocated)
end.
~~~
首先我們檢查將要被釋放的資源,的確是分配給想要釋放資源的這個進程的。如果是的話,lists:member({R,From},Allocated)返回true。我們像之前那樣建立一個新的鏈表來存放被分配出去的資源。我們不能只是簡單的unlinkFrom,而必須首先檢查Form是否持有其他資源。如果keysearch(From,2,Allocated1)(見附錄??)返回了false,From就沒有持有其他資源,這樣我們就可以unlinkFrom了。
如果一個我們與之建立了link關系的進程終止了,服務程序將會收到一個EXIT信號,然后我們調用Check(Free,Allocated,From)函數。
~~~
check(Free, Allocated, From) ->
case lists:keysearch(From, 2, Allocated) of
false ->
server(Free, Allocated);
{value, {R, From}} ->
check([R|Free],
lists:delete({R, From}, Allocated), From)
end.
~~~
如果lists:keysearch(From,2,Allocated)返回了false,我們就沒有給這個進程分配過資源。如果返回了{value,{R,From}},我們就能知道資源R被分配給了這個進程,然后我們必須在繼續檢查該程序是否還持有其他資源之前,將這個資源添加到未分配資源列表,并且將他從已分配資源列表里刪除。注意這種情況下我們不需要手動的與該進程解除連接,因為當它終止的時候,連接就已經解除了。
釋放一個沒有被分配出去的資源是可能一個嚴重的錯誤。我們應當修改程序??.6中的free/1函數,以便殺死試圖這樣干的程序:[[2]](#)。
~~~
free(Resource) ->
resource_alloc ! {self(),{free,Resource}},
receive
{resource_alloc, error} ->
exit(bad_allocation); % exit added here
{resource_alloc, Reply} ->
Reply
end.
~~~
用這種方法殺死的程序,如果它還持有其他資源,同時還與服務程序保持著連接,那么服務程序因此將收到一個EXIT信號,如上面所述,處理這個信號的結果會是資源被釋放。
以上內容說明了這么幾點:
- 通過設計這樣一種服務程序接口,使得客戶端通過訪問函數(這里是allocate/0和free/1)訪問服務程序,并且防止了危險的“幕后操作”。客戶端和服務程序之間的連接對用戶來說是透明的。特別是客戶端不需要知道服務程序的進程ID,因此也就不能干涉它的運行。
- 一個服務程序如果捕獲EXIT信號,并且和它的客戶端建立連接以便能監視它的話,就可以在客戶端進程死亡的時候采取適當的處理行為。
### 分離計算部分
在一些程序里,我們可能希望將計算部分完全隔離出來,以免影響其它程序。Erlang shell就是這樣一個東西。第??章那個簡單的shell是有缺陷的。在它里面運行的一個表達式可能通過這幾種方式影響到進程:
- 它可以發送進程標示符給其他進程(self/0),然后就可以與這個進程建立連接,給它發送消息。
- 它可以注冊或注銷一個進程
程序8.1用另外一種方法實現了一個shell:
程序8.1
~~~
-module(c_shell).
-export([start/0, eval/2]).
start() ->
process_flag(trap_exit, true),
go().
go() ->
eval(io:parse_exprs('-> ')),
go().
eval({form, Exprs}) ->
Id = spawn_link(c_shell, eval, [self(), Exprs]),
receive
{value, Res, _} ->
io:format("Result: ~w~n", [Res]),
receive
{'EXIT', Id, _ } ->
true
end;
{'EXIT', Id, Reason} ->
io:format("Error: ~w!~n", [Reason])
end;
eval(_) ->
io:format("Syntax Error!~n", []).
eval(Id, Exprs) ->
Id ! eval:exprs(Exprs, []).
~~~
shell進程捕獲EXIT信號。命令在一個與shell進程連接的單獨的進程(spawn_link(c_shell,eval,[self(),Exprs]))中運行。盡管事實上我們把shell進程的進程ID給了c_shell:eval/2,但是因為對于作為實際執行者的eval:exprs/2函數,并沒有給它任何參數,因此也就不會對造成影響。
### 保持進程存活
一些進程可能對系統來說是非常重要的。例如,在一個常規的分時系統里,常常每一個終端連接都由一個負責輸入輸出的進程來服務。如果這個進程終止了,終端也就不可用了。程序8.2通過重啟終止的進程來保持進程存活。
這個注冊為keep_alive的服務程序保有一個由{Id,Mod,Func,Args}模式元組構成的列表,這個列表包含了所有正在運行的進程的標識符、模塊、函數和參數。 它使用BIF spawn_link/3啟動這些進程,因此它也和每一個進程建立連接。然后這個服務程序就開始捕獲EXIT信號,當一個進程終止了,它就會收到一個EXIT信號。在搜索了那個由元組構成的列表之后,它就能重啟這個進程。
不過程序8.2當然也需要改進。如果從進程列表里移除一個進程是不可能的話,那么當我們試圖用一個并不存在的module:function/arity來創建進程,程序就會進入死循環。建立一個沒有這些缺陷的程序,就作為練習留給讀者來完成。
### 討論
當進程收到了一個“原因”不是normal的信號,默認行為是終止自己,并通知與它相連接的進程(見第??節)。通過使用連接和捕捉EXIT信號建立一個分層的系統是不難的。在這個系統最頂層的進程(應用進程)并不捕獲EXIT信號。具有依賴關系的進程相互連接。底層進程(操作系統進程)捕獲EXIT并且和需要監視的應用進程(見圖8.2)建立連接。使用這種操作系統結構的例子是交換機服務器和電話應用程序,將在第??章講述,第??章是它們的文件系統。
一個因為EXIT信號導致異常的應用進程,將會把信號發送給所有跟它處在通一進程集內的進程,因此整個進程集都會被殺死。連接到該進程集內應用程序的操作系統進程也會收到EXIT信號,并且會做一些清理工作,也可能重啟進程集。
程序 8.2
~~~
loop(Processes) ->
receive
{From, {new_proc, Mod, Func, Args}} ->
Id = spawn_link(Mod, Func, Args),
From ! {keep_alive, started},
loop([{Id, Mod, Func, Args}|Processes]);
{'EXIT', Id, _} ->
case lists:keysearch(Id, 1, Processes) of
false ->
loop(Processes);
{value, {Id, Mod, Func, Args}} ->
P = lists:delete({Id,Mod,Func,Args},
Processes),
Id1 = spawn_link(Mod, Func, Args),
loop([{Id1, Mod, Func, Args} | P])
end
end.
new_process(Mod, Func, Args) ->
keep_alive ! {self(), {new_proc, Mod, Func, Args}},
receive
{keep_alive, started} ->
true
end.
~~~

圖8.2 操作系統和應用程序進程
腳注
| [[1]](#) | 即數字0到9和*以及#。 |
|-----|-----|
| [[2]](#) | 這可能是一個好的編程練習,因為它將強制程序的編寫者更正這些錯誤。 |
|-----|-----|