作者
Richard A. O'Keefe <ok(at)cs(dot)otago(dot)ac(dot)nz>
状态
草案
类型
标准跟踪
创建
2010-02-09
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 名称之一时,才是合法的,而不管它所命名的进程是否已死亡。

如果需要发现 -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 之间死亡的可能性。明显的 hack,

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 时,它将返回不同的值(ok,静默),具体取决于是否支持 sound(是,否)。后面的调用总是返回 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 不能做且不打算做的事情,那就是以 {Node,Name} 的形式提供可以在其他节点中使用的名称。本提案的目的是提供一种可以替代注册表的大部分使用场景,且更安全的方法,特别是允许逐步迁移到基于模块的注册。

向后兼容性 #

唯一受新功能影响的模块是那些明确包含 -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”。

  • 模块外部的代码不再可能向服务器进程发送消息。

  • 外部人员不再可能(好吧,不再那么容易)伪造来自服务器的响应。

版权 #

本文档已置于公共领域。