作者
Richard A. O'Keefe <ok(at)cs(dot)otago(dot)ac(dot)nz>
狀態
草稿
類型
標準追蹤
建立於
2010年2月9日
Erlang 版本
OTP_R13B-3

EEP 32:模組本地進程名稱 #

摘要 #

Erlang 中的進程註冊表很方便,但屬於全域共享的可變變數,有兩個主要缺陷:資料競爭的可能性(共享的可變變數)和無法封裝(全域)。此 EEP 復活了舊的(1997 年或更早)模組本地進程值變數提案,提供了一個替換節點本地註冊表用途的方案,具備封裝且沒有競爭。

規範 #

模組(或參數化模組的實例)可能有一個或多個頂級 pid 值變數,如果是這樣,則會有一個與它們相關聯的鎖。該指令的形式為

-pid_name(Atom).

其中 Atom 是一個原子。為了避免混淆仍然需要處理註冊表的程式設計師,此 Atom 不得為「undefined」。

如果模組中至少有一個這樣的指令,編譯器會自動產生一個名為 pid_name/1 的函數。在指令的範圍內

-pid_name(pn_1).
...
-pid_name(pn_k).

pid_name/1 函數更像是

pid_name(pn_1) ->
    with_module_lock(read) -> X = *pn_1 end, X;
...
pid_name(pn_k) ->
    with_module_lock(read) -> X = *pn_k end, X.

只是我們期望有一個 VM 指令 get_pid_safely(Address),並且我們期望編譯器在 Atom 已知時內聯調用 pid_name(Atom)。在像 X86X86_64 這樣的機器上,這可能是一個單一的鎖定加載指令。

-pid_name 的值始終是一個進程 id。

有一個特殊的進程 id 值始終表示一個已死的進程。因此在模組內,

pid_name(X) ! Message

當且僅當 X 是模組中宣告的 pid-name 之一時才是合法的,而無論它所命名的進程是否已死。

如果需要發現 -pid_name 是否在最近但不確定的過去與一個活動進程相關聯,可以通過將 pid_name/1process_info/2 結合使用來找出。

與註冊表一樣,一個進程最多可以有一個 pid_name。為了除錯目的,我認為可以擴展 process_info 以返回一個 {pid_name,{Module,Name}} 元組。

當進程退出時,它會自動取消註冊。也就是說,如果它綁定到一個 -pid_name,則該 -pid_name 現在引用傳統的已死進程。此 EEP 的此草案不包含其他讓進程取消註冊的方式。

註冊進程的重點在於它應該是原子的。所以有兩個新的函數

pid_name_spawn(Name, Fun)
pid_name_spawn_link(Name, Fun)

我們可以理解為

pid_name_spawn(Name, Fun)
  when is_atom(Name), is_function(Fun, 0) ->
    with_module_lock(write) ->
    P = *Name,
    if P is a live process ->
        P
     ; P is a dead process ->
        Q = spawn(Fun),
        *Name := Q,
        Q
    end
    end.

pid_name_spawn_link(Name, Fun)
  when is_atom(Name), is_function(Fun, 0) ->
    with_module_lock(write) ->
    P = *Name,
    if P is a live process ->
        P
     ; P is a dead process ->
        Q = spawn(Fun),
        *Name := Q,
        Q
    end
    end.

這裡,和前面一樣,with_module_lock 是偽代碼,旨在表示在宣告 -pid_name 的模組內僅存在的一種私有鎖上的讀寫鎖。

這兩個函數會像 pid_name/1 一樣在模組內自動宣告。這三個函數不是從 erlang: 模組自動繼承的函數,而是邏輯上位於模組內部的函數,無論它們實際如何實現。模組似乎沒有任何充分的理由匯出任何這些函數,如果嘗試這樣做,編譯器至少應該發出警告。

動機 #

  • 封裝。

    當模組的客戶端需要與模組管理的一個或多個伺服器通信時,通常會使用進程註冊表,但介面程式碼位於模組內部。公開進程沒有任何優勢,而且風險很大。此過程的一個主要原因是為了在不損失封裝性的情況下獲得可變進程變數的好處。

  • 效率。

    作為共享的可變資料結構,必須在適當的鎖的範圍內存取註冊表。使用這種方法,每個模組都有自己的鎖,爭用應該幾乎為零,並且我認為註冊表最常見的用例可以是一個簡單的加載指令。

  • 安全性。

    安全地註冊一個進程實際上非常困難,並且已註冊名稱的使用與直接進程 id 的使用很不一致。此介面旨在更簡單地安全使用。

原理 #

舊的 Erlang 書籍描述了四個用於處理已註冊進程名稱的函數。還有兩個主要的介面。

Name ! Message when is_atom(Name) ->
  % Also available as erlang:send(Name, Message).
  % A 'badarg' exception results if Pid is an atom that is
  % not the registered name of a live local process or port.
    whereis(Name) ! Message.

register(Name, Pid) when is_atom(Name), is_pid(Pid) ->
  % A 'badarg' exception results if Pid is not a live local
  % process or port, if Name is not an atom or is already in
  % use, if Pid already has a registered name, or if Name is
  % 'undefined'.
    "whereis(Name) := Pid".

unregister(Name) when is_atom(Name) ->
  % A 'badarg' exception results if Name is not an atom
  % currently in use as the registered name of some process
  % or port.  'undefined' is always an error.
    "whereis(Name) := undefined".

whereis(Name) when is_atom(Name) ->
  % A 'badarg' exception results if Name is not a name.
  % in effect, a global mutable hash table with
  % atom keys and pid-or-'undefined' values.

registered() ->
    % yes, I know this is not executable Erlang.
    [Name || is_atom(Name), is_pid(whereis(Name))].

process_info(Pid, registered_name) when is_pid(Pid) ->
    % yes, I know this is not executable Erlang.
    case [Name || is_atom(Name), whereis(Name) =:= Pid]
      of [N] -> {registered_name,N}
       ; []  -> []
    end.

當進程由於任何原因終止時,它會執行以下等效操作

case process_info(self(), registered_name)
  of {_,Name} -> unregister(Name)
   ; []       -> ok
end.

這會產生驚人的後果。

假設我執行

Pid = spawn(Fun),
...
Pid ! Message

並且在創建進程到我向它發送消息之間的這段時間內,進程死掉了。在 Erlang 中,這完全可以接受,消息只會消失。

現在假設我執行

register(Name, spawn(Fun)),
...
Name ! Message

並且在創建進程到我向它發送消息之間的這段時間內,進程死掉了。任何人都會預期結果完全相同:由於 Name 指向一個已死的進程,這相當於向一個已死的進程發送消息,這完全可以接受,消息只會消失。最令人困惑的是,實際情況並非如此,而是會收到一個「badarg」異常。

現在假設我執行

send(Pid, Message) when is_pid(Pid) ->
    Pid ! Message;
send(Name, Message) when is_atom(Name) ->
    case whereis(Name)
      of undefined -> ok
       ; Pid when is_pid(Pid) -> Pid ! Message
    end.
...
    register(Name, spawn(Fun)),
    ...
    send(Name, Message)

這樣做是我們所期望的,但為什麼有必要這樣做?

在 Erlang 的現狀中,如果 Name 曾經引用正確的進程,但該進程已經死掉,則 Name ! Message 會引發錯誤。有人可能會認為這是一個有用的除錯工具,但如果 Name 現在引用了錯誤的進程,則沒有任何幫助。現在,考慮

whereis(Name) ! Message

如果命名進程在調用 whereis/1 之前已死掉,則會引發異常,但請考慮這個時序

live           dies
   whereis runs      message sent

時序上的細微變化可能會不可預測地將行為從晚死時的靜默變為早死時的錯誤,反之亦然。

pid_name(Name) ! Message

始終是靜默的。

當前的進程註冊表也用於埠,埠在許多方面都像進程。

舊的 Erlang 書籍絕對正確地指出,有時您需要一種與您之前未接觸過的進程通信的方式。但是,並非必須通過全域雜湊表來完成。您始終可以向模組請求資訊。

讓我們來看書中的程式 5.5。

-module(number_analyser).
-export([start/0,server/1]).
-export([add_number/2,analyse/1]).

start() ->
    register(number_analyser,
    spawn(number_analyser, server, [nil])).

%% The interface functions.

add_number(Seq, Dest) ->
    request({add_number,Seq,Dest}).

analyse(Seq) ->
    request({analyse,Seq}).

request(Req) ->
    number_analyser ! {self(), Req},
    receive
    {number_analyser,Reply} ->
            Reply
    end.

%% The server.

server(Analyser_Table) ->
    receive
        {From, {analyse, Seq}} ->
        Result = lookup(Seq, Analyser_Table),
        From ! {number_analyser, Result},
        server(Analyser_Table)
      ; {From, {add_number, Seq, Dest}} ->
        From ! {number_analyser, ack},
        server(insert(Seq, Dest, Analyser_Table))
    end.

我們首先注意到的是,註冊表用於允許該模組的客戶端進程通過此模組中的介面函數與此模組管理的進程通信。沒有理由給進程一個全域可見的名稱,而且有充分的理由不這樣做。我們希望確保與伺服器進程的所有通信都通過介面函數進行,並且只要進程位於全域註冊表中,任何事情都可能發生。因此,全域進程註冊表會適得其反。

同樣,由於對介面函數的回覆消息沒有使用伺服器的身份,而是使用其公開名稱進行標記,因此很容易偽造。這些問題也適用於舊書中的程式 5.6。

但更糟糕的是。永遠不能安全地調用 register/2unregister/1。回想一下,register/2 的前提條件是 Name 未被使用。但是沒有辦法確定這一點。例如,您可以嘗試

spawn_if_necessary(Name, Fun) ->
    case whereis(Name)        % T1
      of undefined ->
     Pid = spawn(Fun),    % T2
     register(Name, Pid)    % T3
       ; Pid when is_pid(Pid) ->
         ok
    end,
    Pid.

不幸的是,在 T1 時間(whereis/1 報告 Name 未被使用)和 T3 時間(當我們嘗試分配它時)之間,某些其他進程可能已註冊。此外,在新進程創建的 T2 時間和我們使用 Pid 的 T3 時間之間,該進程可能已死掉。

由於註冊表是全域的,因此搜索現有程式碼以查看 Name 是否被覆蓋是沒有用的;錯誤可能會在未來的程式碼中引入。

似乎沒有辦法防止進程在 T2 和 T3 之間死掉的可能性。明顯的駭客攻擊,

Pid = spawn(Fun),
erlang:suspend_process(Pid),
register(Name, Pid),
erlang:resume_process(Pid)

行不通,因為 erlang:suspend_process/1 被記錄為與 register/2 有相同的「如果 Pid 不是活動本地進程的 pid 則為 badarg」的混亂。解決這個問題的唯一真正安全的方法是讓新進程在掛起狀態下誕生,但沒有辦法做到這一點。spawn_opt/[2-5] 的選項清單中不允許使用「掛起」選項。

實際上,當然,新進程通常不會死,通常是因為它進入一個迴圈等待消息。即便如此,一個基本元件的這種脆弱性也令人有些擔憂。

讓我們快速檢查一下所有這些的真實性。

sounder.erl

start() ->
    case whereis(sounder) of
        undefined ->
        case file:read_file_info('/dev/audio') of
            {ok, FI} when FI#file_info.access==read_write ->
            register(sounder, spawn(sounder,go,[])),
            ok;
            _Other ->
            register(sounder, spawn(sounder,nosound,[])),
            silent
        end;
        _Pid ->
        ok
    end.

這是一件奇怪的事情:第一次調用 sounder:start/0 時,它將根據是否支援 sound(支援、不支援)返回不同的值(ok、silent)。後續的調用始終返回 ok。這與文件相矛盾。哎呀!除此之外,這是一個簡單的 spawn_if_necessary

man.erl

start() ->
    case whereis(man) of
        undefined ->
        register(man,Pid=spawn(man,init,[])),
        Pid;
        Pid ->
        Pid
    end.

這正是

start() -> spawn_if_necessary(fun () -> man:init() end).

tv_table_owner

start() ->
    case whereis(?REGISTERED_NAME) of
        undefined ->
        ServerPid = spawn(?MODULE, init, []),
        case catch register(?REGISTERED_NAME, ServerPid) of
            true ->
            ok;
            {'EXIT', _Reason} ->
            exit(ServerPid, kill),
            timer:sleep(500),
            start()
        end;
        Pid when is_pid(Pid) ->
        ok
    end.

讓我們重新打包一下,看看發生了什麼

spawn_if_necessary(Name, Fun) ->
    case whereis(Name)
      of undefined ->
         Pid = spawn(Fun),
         case catch register(Name, Pid)
           of true ->
              Pid
            ; {'EXIT', _} ->
              exit(Pid, kill),
              timer:sleep(500),
              spawn_if_necessary(Name, Fun)
         end
       ; Pid when is_pid(Pid) ->
     ok
    end.

如果有一個活動的本地進程在 Name 下註冊,則返回其 Pid。當然,在函數返回後,相信在 Name 下仍然註冊著一個活動的本地進程,但這對於 whereis/1 來說也是如此。

如果沒有,則創建一個新進程,無論它是否最終有用。嘗試註冊它。Pid 將是一個未在任何其他名稱下註冊的活動本地進程的 pid,並且 Name 必須是一個除「undefined」之外的原子,否則 whereis/1 會崩潰。因此,唯一可能出錯的事情是其他一些進程偷偷進入並搶走了註冊表槽。在這種情況下,殺死該進程,等待很長時間,然後重試。

從理論上講,在對手的惡意時序恰到好處的情況下,這有可能永遠迴圈。實際上,我確信它運行得很好。

問題是,如果「基本元件」如此脆弱,我寧願不要讓初學者接觸它們。或者就此而言,大多數人:在 Erlang/OTP 來源中有許多使用 register/1 的情況,這些情況沒有得到很好的保護。

解決「註冊競爭」問題的最簡單方法是驗證 spawn_if_necessary/2 是否健全,必要時更正它,並將其放入庫中。但是,這無助於解決註冊表的全域性問題。

沒有類似 registered() 的東西。在模組內部,你可以看到哪些名稱可用;在模組外部,你無權知道。

此 EEP 並不打算廢除舊的註冊表。有許多程式碼和訓練教材仍然在使用或提及它。最重要的是,舊的註冊表可以做到此 EEP 無法做到且不打算做的一件事,那就是提供可以在其他節點中使用的名稱,以 {節點, 名稱} 的形式。本提案的目標是提供一些可以取代註冊表大多數用途且更安全的東西,特別是允許逐步遷移到每個模組的註冊。

向後相容性 #

唯一受新功能影響的模組是那些明顯包含明確 -pid_name 指令的模組。

參考實作 #

無。

範例 #

這是舊書的程式 5.5,已更新至最新版本。

-module(number_analyser).
-export([
    add_number/2,
    analyse/1,
    start/0,
    stop/0
 ]).
-pid_name(server).

start() ->
    pid_name_spawn(server, fun () -> server(nil) end).

stop() ->
    pid_name(server) ! stop.

add_number(Seq, Dest) ->
    request({add_number,Seq,Dest}).

analyse(Seq) ->
    request({analyse,Seq}).

request(Request) ->
    P = pid_name(server),
    P ! {self(), Request},
    receive {P,Reply} -> Reply end.

server(Analyser_Table) ->
    receive
        {From, {analyse, Seq}} ->
        From ! {self(), lookup(Seq, Analyser_Table)},
        server(Analyser_Table)
      ; {From, {add_number, Seq, Dest}} ->
        From ! {self(), ok},
        server(insert(Seq, Dest, Analyser_Table))
    end.
  • 現在可以使用程式設計慣例,其中每個伺服器的 -pid_name 都是 'server'。

  • 模組外部的程式碼不再可能將訊息傳送到伺服器程序。

  • 外部人士不再可能(或至少不再那麼容易)偽造來自伺服器的回應。

版權 #

本文件已置於公有領域。