非常教程

Erlang 20参考手册

stdlib

2. Erlang I / O协议 | 2. The Erlang I/O Protocol

Erlang中的I / O协议支持客户端和服务器之间的双向通信。

  • I/O服务器是一个处理请求并在例如I/O设备上执行请求任务的进程。
  • 客户机是希望从I/O设备读取或写入数据的任何Erlang进程。

自开始以来,通用I / O协议就已经出现在OTP中,但是这些协议多年来一直没有记录,并且也在不断发展。在Robert Virding的基本原理的附录中,描述了原始的I / O协议。本节介绍当前的I / O协议。

原始的I / O协议简单灵活。对存储器效率和执行时间效率的要求多年来引发了协议的扩展,使协议变得更大,并且比原始协议更容易实现。可以肯定地认为,目前的协议太复杂了,但是这部分描述了它现在的样子,而不是它看起来的样子。

原始协议的基本思路仍然存在。I / O服务器和客户端使用一个简单的协议进行通信,并且客户端中不存在任何服务器状态。任何I / O服务器都可以与任何客户端代码一起使用,并且客户端代码不需要知道I / O服务器与之通信的I / O设备。

2.1议定书基础

正如Robert的论文所述,I / O服务器和客户端使用io_request/ io_reply元组进行通信,如下所示:

{io_request, From, ReplyAs, Request}
{io_reply, ReplyAs, Reply}

客户端发送io_request元组发送到I/O服务器,服务器最终发送相应的io_reply元组。

  • Frompid()对于客户端,I/O服务器发送I/O回复的进程。
  • ReplyAs可以是任何数据并返回相应的数据io_reply。所述io模块监控的I / O服务器,并使用显示器参考作为ReplyAs基准。一个更复杂的客户端可以对同一个I / O服务器有许多优秀的I / O请求,并且可以使用不同的引用(或其他)来区分传入的I / O响应。元素ReplyAs被I / O服务器视为不透明。

注意,pid()在元组中未显式显示I/O服务器的io_reply答复可以从任何进程发送,而不一定是实际的I/O服务器。

  • RequestReply在下面进行描述。当I / O服务器接收到一个io_request元组时,它将作用于该元件Request并最终发送一个io_reply元组和对应的部分Reply。2.2输出请求要输出I / O设备上的字符,Request存在以下几种:{put_chars, Encoding, Characters} {put_chars, Encoding, Module, Function, Args}

  • Encodingunicodelatin1,意味着这些字符(如果是二进制文件)编码为UTF-8或ISO Latin-1(纯字节)。如果列表元素Encoding设置为> 255时,表现良好的I / O服务器也会返回错误指示latin1

请注意,这并不能告诉我如何将字符放在I / O设备上或由I / O服务器处理。不同的I / O服务器可以处理它们想要的字符,这只能告诉I / O服务器预期数据的格式。在Module/ Function/ Args情况下,Encoding讲述了指定函数产生的格式。

还请注意,面向字节的数据是使用ISO拉丁语1编码发送的最简单的数据。

  • Characters是要放在I / O设备上的数据。如果Encodinglatin1,这是一个iolist()。如果Encodingunicode,这是一个Erlang标准混合Unicode列表(每个字符列表中的一个整数,二进制中的字符表示为UTF-8)。
  • ModuleFunctionArgs表示被调用来产生数据的函数(如io_lib:format/2)。

Args是该函数的参数列表。该功能是生成指定的数据Encoding。I / O服务器将调用该函数apply(Mod, Func, Args)并将返回的数据放在I / O设备上,就好像它是在{put_chars, Encoding, Characters}请求中一样发送的。如果该函数返回除二进制或列表之外的其他内容,或者抛出异常,则会将错误发送回客户端。

I / O服务器用一个io_reply元组向客户端回复,其中元素Reply是以下之一:

ok
{error, Error}
  • Error向客户描述错误,客户可以根据需要做任何事情。该io模块通常返回它“按原样”。对于后向兼容性,以下Requests为也通过一个I / O服务器处理(它们是不被二郎/ OTP R15B后存在):{PUT_CHARS,人物} {PUT_CHARS,模块,函数,参数数量}这些是作为表现{put_chars, latin1, Characters}{put_chars, latin1, Module, Function, Args},respectively.2.3输入RequestsTo读取从I / O设备的字符,以下Request小号存在:{get_until,编码,提示,模块,功能,ExtraArgs}
  • Encoding表示如何将数据发送回客户端以及将数据发送给由Module/ Function/ 表示的函数ExtraArgs。如果提供的功能将数据作为列表返回,则数据将转换为此编码。如果提供的函数以其他格式返回数据,则不能进行转换,并且由客户端提供的函数以正确的方式返回数据。

如果编码为latin1,则在可能的情况下,将整数列表0..255或包含普通字节的二进制文件发送回客户端。 如果Encoding是unicode,则整个Unicode范围中的整数或以UTF-8编码的二进制文件的列表被发送到客户端。 用户提供的函数总是会看到整数列表,而不是二进制文件,但如果编码是unicode,则列表可以包含大于255的数字。

  • Prompt是一个字符列表(不是混合的,没有二进制文件),或者是作为I / O设备输入提示输出的原子。Prompt经常被I / O服务器忽略; 如果设置为'',它总是被忽略(并导致没有写入I / O设备)。
  • ModuleFunctionExtraArgs表示一个函数和参数来确定何时写入足够的数据。该函数需要两个参数,最后一个状态和一个字符列表。该功能是返回以下之一:

{已完成,结果,RESTCHARS}{更多,继续}

Result可以是任何Erlang术语,但如果它是a list(),I / O服务器可以在将其binary()返回给客户端之前将其转换为适当的格式,前提是I / O服务器设置为二进制模式(请参见下文)。

调用该函数时,将使用I/O服务器在其I/O设备上找到的数据调用该函数,并返回以下内容之一:

-  `{done, Result, RestChars}` when enough data is read. In this case `Result` is sent to the client and `RestChars` is kept in the I/O server as a buffer for later input.

-  `{more, Continuation}`, which indicates that more characters are needed to complete the request.

Continuation在有更多字符可用时,在稍后调用该函数时作为状态发送。当没有更多的字符可用时,该函数必须返回{done, eof, Rest}。初始状态是空列表。在IO设备上达到文件结尾的数据是原子eof

模拟get_line请求可以使用以下功能实现%28无效%29:

-module(demo). -export(until_newline/3, get_line/1). until_newline(_ThisFar,eof,_MyStopCharacter) -> {done,eof,[]}; until_newline(ThisFar,CharList,MyStopCharacter) -> case lists:splitwith(fun(X) -> X =/= MyStopCharacter end, CharList) of {L,[]} -> {more,ThisFar++L}; {L2,MyStopCharacter|Rest} -> {done,ThisFar++L2++MyStopCharacter,Rest} end. get_line(IoServer) -> IoServer ! {io_request, self(), IoServer, {get_until, unicode, '', ?MODULE, until_newline, $\n}}, receive {io_reply, IoServer, Data} -> Data end.

注意,当函数被调用时,Request元组([$\n])中的最后一个元素被添加到参数列表中。该功能将被apply(Module, Function, [ State, Data | ExtraArgs ])I / O服务器调用。

使用以下方法请求一个固定数目的字符Request*

{get_chars, Encoding, Prompt, N}
  • EncodingPrompt作为get_until
  • N从I/O设备读取的字符数。

单行(如前例)请求如下Request

{get_line, Encoding, Prompt}
  • 编码和提示的get_until.Clearly,get_chars和get_line可以实现与get_until请求(事实上他们最初),但效率的要求已经使这些添加必要。I / O服务器回复给客户端一个io_reply元组 ,其中元素Reply是以下之一:Data EOF {error, Error}

  • Data 是以列表或二进制形式读取的字符(取决于I / O服务器模式,请参阅下一节)。
  • eof 在达到输入结束时返回并且没有更多数据可用于客户端进程。
  • Error向客户描述错误,客户可以根据需要做任何事情。该io模块通常按原样返回。

为了向后兼容,以下Requests也要由I / O服务器处理(它们在Erlang / OTP R15B之后不会存在):

{get_until, Prompt, Module, Function, ExtraArgs}
{get_chars, Prompt, N}
{get_line, Prompt}

这些都表现为{get_until, latin1, Prompt, Module, Function, ExtraArgs}{get_chars, latin1, Prompt, N},和{get_line, latin1, Prompt}

2.4 I/O服务器模式

从I / O服务器读取数据时,要求效率不仅会增加get_line和get_chars请求,还会增加I / O服务器选项的概念。 没有必须实现的选项,但Erlang标准库中的所有I / O服务器都遵守二进制选项,这使得io_reply元组中的元素Data可以是二进制而不是列表。 如果数据是以二进制形式发送的,则Unicode数据以标准的Erlang Unicode格式发送,即UTF-8(请注意,无论I / O服务器模式如何,get_until请求的功能仍会获取列表数据)。

请注意,该get_until请求允许将数据指定为始终为列表的函数。而且,来自这种函数的返回值数据可以是任何类型的(当io:fread/2,3请求被发送到I / O服务器时的确如此)。客户必须为收到的数据做好准备,以便以各种形式回复这些请求。但是,I / O服务器将尽可能将结果转换为二进制文件(也就是说,提供的函数get_until返回列表时)。这在部分的例子中完成An Annotated and Working Example I/O Server

二进制模式下的I / O服务器会影响发送到客户端的数据,因此它必须能够处理二进制数据。为了方便起见,可以使用以下I / O请求设置和检索I / O服务器的模式:

{setopts, Opts}
  • Opts是由proplists模块(和I / O服务器)识别格式的选项列表。例如,交互式shell(in group.erl)的I / O服务器理解以下选项:{binary,boolean()} (或二进制/列表){回波,布尔()} {expand_fun,有趣()} {编码,统一字符编码/ LATIN1}(或Unicode / latin1的)选项binaryencoding是常见的用于OTP所有I / O服务器,而echoexpand有效仅适用于此I / O服务器。选项unicode通知如何将字符放置在物理I / O设备上,也就是说,如果终端本身具有Unicode识别功能。它不会影响在I / O协议中如何发送字符,其中每个请求都包含所提供或返回的数据的编码信息.I / O服务器将发送以下内容之一作为Reply:ok {error,Error} enotsup如果I / O服务器不支持该选项,则会出现错误(最好是)(例如,如果echosetopts对纯文件的请求中发送选项)。要检索选项,请执行以下操作请求被使用:getoptsThis请求要求I / O服务器支持的所有选项的完整列表以及它们的当前值.I / O服务器回复:OptList {error,Error}
  • OptList是一个元组列表{Option, Value},其中Option总是一个原子。

2.5 多个I / O请求

Request元素可以在本身含有许多Request使用以下格式S:

{requests, Requests}
  • Requests是有效的列表。io_request协议的元组。必须按照它们出现在列表中的顺序执行它们。执行将继续进行,直到其中一个请求导致错误或列表被使用为止。最后一个请求的结果被发送回客户端。对于请求列表,I/O服务器可以根据列表中的请求在答复中发送下列任何有效结果:ok {ok, Data} {ok, Options} {error, Error}2.6可选I/O请求以下I/O请求是可选实现的,客户端将为错误返回做好准备:{get_geometry, Geometry}
  • Geometry是原子rows或原子columns

I / O服务器将发送Reply作为:

{ok, N}
{error, Error}
  • N如果适用于I/O服务器处理的I/O设备,则为I/O设备所具有的字符行或列数。{error, enotsup}是个很好的答案。

2.7未实现的请求类型

如果I / O服务器遇到无法识别的请求(即io_request元组具有预期的格式,但Request未知),则I / O服务器将发送包含错误元组的有效答复:

{error, request}

这使得扩展带有可选请求的协议成为可能,并且客户端可以向后兼容。

2.8一个说明和工作示例I/O服务器

I / O服务器是任何能够处理I / O协议的进程。没有通用的I / O服务器行为,但很可能。框架很简单,一个处理传入请求的进程,通常是I / O请求和其他I / O设备特定请求(定位,关闭等)。

示例I / O服务器将字符存储在ETS表中,组成一个相当粗糙的RAM文件。

该模块以通常的指令、启动I/O服务器的函数和处理请求的主循环开始:

-module(ets_io_server).

-export([start_link/0, init/0, loop/1, until_newline/3, until_enough/3]).

-define(CHARS_PER_REC, 10).

-record(state, {
	  table,
	  position, % absolute
	  mode % binary | list
	 }).

start_link() ->
    spawn_link(?MODULE,init,[]).

init() ->
    Table = ets:new(noname,[ordered_set]),
    ?MODULE:loop(#state{table = Table, position = 0, mode=list}).

loop(State) ->
    receive
	{io_request, From, ReplyAs, Request} ->
	    case request(Request,State) of
		{Tag, Reply, NewState} when Tag =:= ok; Tag =:= error ->
		    reply(From, ReplyAs, Reply),
		    ?MODULE:loop(NewState);
		{stop, Reply, _NewState} ->
		    reply(From, ReplyAs, Reply),
		    exit(Reply)
	    end;
	%% Private message
	{From, rewind} ->
	    From ! {self(), ok},
	    ?MODULE:loop(State#state{position = 0});
	_Unknown ->
	    ?MODULE:loop(State)
    end.

主循环从客户端接收消息(可以使用io模块发送请求)。对于每个请求,该函数request/2被调用并且最终使用函数发送回复reply/3

“私人”消息{From, rewind}导致伪文件中的当前位置被重置为0(“文件”的开头)。这是不属于I / O协议一部分的I / O设备特定消息的典型示例。将这些私人消息嵌入到io_request元组中通常是一个坏主意,因为这可能会使读者感到困惑。

首先,我们检查回复函数:

reply(From, ReplyAs, Reply) ->
    From ! {io_reply, ReplyAs, Reply}.

它发送io_reply返回到客户端,提供元素ReplyAs如前面所述,在请求中接收到的以及请求的结果。

我们需要处理一些请求。首先是书写字符的请求:

request({put_chars, Encoding, Chars}, State) ->
    put_chars(unicode:characters_to_list(Chars,Encoding),State);
request({put_chars, Encoding, Module, Function, Args}, State) ->
    try
	request({put_chars, Encoding, apply(Module, Function, Args)}, State)
    catch
	_:_ ->
	    {error, {error,Function}, State}
    end;

Encoding说明请求中的字符是如何表示的。我们希望将字符作为列表存储在ETS表中,因此我们使用函数将它们转换为列表unicode:characters_to_list/2。转换功能方便地接受编码类型unicodelatin1,所以我们可以使用Encoding直接。

ModuleFunctionArguments提供时,我们运用它,做相同的结果,就好像将数据直接提供。

我们处理检索数据的请求:

request({get_until, Encoding, _Prompt, M, F, As}, State) ->
    get_until(Encoding, M, F, As, State);
request({get_chars, Encoding, _Prompt, N}, State) ->
    %% To simplify the code, get_chars is implemented using get_until
    get_until(Encoding, ?MODULE, until_enough, [N], State);
request({get_line, Encoding, _Prompt}, State) ->
    %% To simplify the code, get_line is implemented using get_until
    get_until(Encoding, ?MODULE, until_newline, [$\n], State);

在这里,我们已经多少有点欺骗,只实施get_until和使用内部帮手来实施get_charsget_line。在生产代码中,这可能是低效的,但这取决于不同请求的频率。在我们开始之前执行的功能put_chars/2get_until/5,我们检查剩下的几个要求:

request({get_geometry,_}, State) ->
    {error, {error,enotsup}, State};
request({setopts, Opts}, State) ->
    setopts(Opts, State);
request(getopts, State) ->
    getopts(State);
request({requests, Reqs}, State) ->
     multi_request(Reqs, {ok, ok, State});

请求get_geometry对此I / O服务器没有任何意义,所以答复是{error, enotsup}。我们处理的唯一选项是binary/ list,这是在单独的函数中完成的。

多请求标记(requests)在一个单独的循环函数中处理,一个接一个地应用列表中的请求,并返回最后的结果。

我们需要处理向后兼容性和file模块(它使用旧的请求,直到与R13之前的节点不再需要向后兼容)。请注意如果file:write/2不添加以下内容,则I / O服务器无法正常工作:

request({put_chars,Chars}, State) ->
    request({put_chars,latin1,Chars}, State);
request({put_chars,M,F,As}, State) ->
    request({put_chars,latin1,M,F,As}, State);
request({get_chars,Prompt,N}, State) ->
    request({get_chars,latin1,Prompt,N}, State);
request({get_line,Prompt}, State) ->
    request({get_line,latin1,Prompt}, State);
request({get_until, Prompt,M,F,As}, State) ->
    request({get_until,latin1,Prompt,M,F,As}, State);

{error, request}如果请求未被识别,则必须返回:

request(_Other, State) ->
    {error, {error, request}, State}.

接下来,我们处理不同的请求,首先是相当通用的多请求类型:

multi_request([R|Rs], {ok, _Res, State}) ->
    multi_request(Rs, request(R, State));
multi_request([_|_], Error) ->
    Error;
multi_request([], Result) ->
    Result.

我们循环访问请求中的一个,当我们遇到错误或列表用尽时停止。最后一个返回值被发送回客户端(首先返回到主循环,然后通过函数发回io_reply)。

请求getoptssetopts也很容易处理。我们只更改或读取州记录:

setopts(Opts0,State) ->
    Opts = proplists:unfold(
	     proplists:substitute_negations(
	       [{list,binary}], 
	       Opts0)),
    case check_valid_opts(Opts) of
	true ->
	        case proplists:get_value(binary, Opts) of
		    true ->
			{ok,ok,State#state{mode=binary}};
		    false ->
			{ok,ok,State#state{mode=binary}};
		    _ ->
			{ok,ok,State}
		end;
	false ->
	    {error,{error,enotsup},State}
    end.
check_valid_opts([]) ->
    true;
check_valid_opts([{binary,Bool}|T]) when is_boolean(Bool) ->
    check_valid_opts(T);
check_valid_opts(_) ->
    false.

getopts(#state{mode=M} = S) ->
    {ok,[{binary, case M of
		      binary ->
			  true;
		      _ ->
			  false
		  end}],S}.

按照惯例,所有的I / O服务器同时处理{setopts, [binary]}{setopts, [list]}{setopts,[{binary, boolean()}]},因此特技proplists:substitute_negations/2proplists:unfold/1。如果发送给我们的选项无效,我们会将其发送{error, enotsup}回客户端。

请求getopts的列表{Option, Value}元组。它具有双重功能,既提供当前值,也提供此I/O服务器的可用选项。我们只有一个选择,因此返回。

到目前为止,这个I / O服务器是相当通用的(除了rewind在主循环中处理请求和创建ETS表)之外。大多数I / O服务器包含与此类似的代码。

为了使这个例子可以运行,我们开始实施对ETS表的数据的读写。第一功能put_chars/3

put_chars(Chars, #state{table = T, position = P} = State) ->
    R = P div ?CHARS_PER_REC,
    C = P rem ?CHARS_PER_REC,
    [ apply_update(T,U) || U <- split_data(Chars, R, C) ],
    {ok, ok, State#state{position = (P + length(Chars))}}.

我们已将数据作为(Unicode)列表,因此仅将列表拆分为预定义大小的运行,并将表中的每个运行放在当前位置(并转发)。功能split_data/3apply_update/2实现如下。

现在我们要从表中读取数据。函数get_until/5读取数据并应用该函数,直到它表示完成为止。结果被发送回客户端:

get_until(Encoding, Mod, Func, As, 
	  #state{position = P, mode = M, table = T} = State) ->
    case get_loop(Mod,Func,As,T,P,[]) of
	{done,Data,_,NewP} when is_binary(Data); is_list(Data) ->
	    if
		M =:= binary -> 
		    {ok, 
		     unicode:characters_to_binary(Data, unicode, Encoding),
		     State#state{position = NewP}};
		true ->
		    case check(Encoding, 
		               unicode:characters_to_list(Data, unicode))
                    of
			{error, _} = E ->
			    {error, E, State};
			List ->
			    {ok, List,
			     State#state{position = NewP}}
		    end
	    end;
	{done,Data,_,NewP} ->
	    {ok, Data, State#state{position = NewP}};
	Error ->
	    {error, Error, State}
    end.

get_loop(M,F,A,T,P,C) ->
    {NewP,L} = get(P,T),
    case catch apply(M,F,[C,L|A]) of
	{done, List, Rest} ->
	    {done, List, [], NewP - length(Rest)};
	{more, NewC} ->
	    get_loop(M,F,A,T,NewP,NewC);
	_ ->
	    {error,F}
    end.

这里我们还处理可以通过请求设置的模式(binarylistsetopts。默认情况下,所有OTP I / O服务器都将数据作为列表发送回客户端,但binary如果I / O服务器以适当的方式处理它,则切换模式可以提高效率。由于get_until提供的函数被定义为将列表作为参数,但是get_chars并且get_line可以针对二进制模式进行优化,所以实现难以高效。但是,这个例子并没有优化任何东西。

根据设置的选项,返回的数据是正确的类型很重要。因此,如果可能,我们会在返回之前以正确的编码将列表转换为二进制文件。在get_until请求元组中提供的函数可以作为其最终结果返回任何内容,所以只有返回列表的函数才能将它们转换为二进制文件。如果请求包含编码标记unicode,则列表可以包含所有的Unicode代码点,并且二进制文件将处于UTF-8格式。如果编码标签是latin1,客户端只能获取范围内的字符0..255check/2如果编码被指定为,函数会处理不会返回列表中的任意Unicode代码点latin1。如果函数没有返回列表,则不能执行检查,结果是所提供函数的结果不变。

为了操作表,我们实现了以下实用函数:

check(unicode, List) ->
    List;
check(latin1, List) ->
    try 
	[ throw(not_unicode) || X <- List,
				X > 255 ],
	List
    catch
	throw:_ ->
	    {error,{cannot_convert, unicode, latin1}}
    end.

如果客户端请求,如果Unicode代码点大于255,则函数检查会提供一个错误元组latin1

这两个函数是辅助函数until_newline/3和函数until_enough/3一起使用get_until/5来实现get_charsget_line(低效):

until_newline([],eof,_MyStopCharacter) ->
    {done,eof,[]};
until_newline(ThisFar,eof,_MyStopCharacter) ->
    {done,ThisFar,[]};
until_newline(ThisFar,CharList,MyStopCharacter) ->
    case
        lists:splitwith(fun(X) -> X =/= MyStopCharacter end,  CharList)
    of
	{L,[]} ->
            {more,ThisFar++L};
	{L2,[MyStopCharacter|Rest]} ->
	    {done,ThisFar++L2++[MyStopCharacter],Rest}
    end.

until_enough([],eof,_N) ->
    {done,eof,[]};
until_enough(ThisFar,eof,_N) ->
    {done,ThisFar,[]};
until_enough(ThisFar,CharList,N) 
  when length(ThisFar) + length(CharList) >= N ->
    {Res,Rest} = my_split(N,ThisFar ++ CharList, []),
    {done,Res,Rest};
until_enough(ThisFar,CharList,_N) ->
    {more,ThisFar++CharList}.

可以看出,上面的函数只是在get_until请求中提供的函数的类型。

要完成I/O服务器,我们只需要以适当的方式读取和写入表:

get(P,Tab) ->
    R = P div ?CHARS_PER_REC,
    C = P rem ?CHARS_PER_REC,
    case ets:lookup(Tab,R) of
	[] ->
	    {P,eof};
	[{R,List}] ->
	    case my_split(C,List,[]) of
		{_,[]} ->
		    {P+length(List),eof};
		{_,Data} ->
		    {P+length(Data),Data}
	    end
    end.

my_split(0,Left,Acc) ->
    {lists:reverse(Acc),Left};
my_split(_,[],Acc) ->
    {lists:reverse(Acc),[]};
my_split(N,[H|T],Acc) ->
    my_split(N-1,T,[H|Acc]).

split_data([],_,_) ->
    [];
split_data(Chars, Row, Col) ->
    {This,Left} = my_split(?CHARS_PER_REC - Col, Chars, []),
    [ {Row, Col, This} | split_data(Left, Row + 1, 0) ].

apply_update(Table, {Row, Col, List}) ->     
    case ets:lookup(Table,Row) of
	[] ->
	    ets:insert(Table,{Row, lists:duplicate(Col,0) ++ List});
	[{Row, OldData}] ->
	    {Part1,_} = my_split(Col,OldData,[]),
	    {_,Part2} = my_split(Col+length(List),OldData,[]),
	    ets:insert(Table,{Row, Part1 ++ List ++ Part2})
    end.

表格是以大块的形式读取或写入的?CHARS_PER_REC,必要时覆盖。实施显然没有效率,它只是在工作。

这个例子结束了。它是完全可运行的,您可以通过使用io模块甚至file模块来读取或写入I / O服务器。就像在Erlang中实现一个完全成熟的I / O服务器一样简单。

Erlang 20

Erlang 是一种通用的面向并发的编程语言,可应付大规模开发活动的程序设计语言和运行环境。

主页 https://www.erlang.org/
源码 https://github.com/erlang/otp
版本 20
发布版本 20.1