Erlang并发编程

本文围绕Erlang开发展开,介绍了基本并发函数,如spawn创建新进程、!发送消息、receive接收消息等。还阐述了客户端 - 服务器相关内容,指出进程轻巧但数量过多会影响性能。此外,讲解了带超时的接收、选择性接收、注册进程等知识,以及尾递归和MFA或Fun分裂。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

Erlang 的并发是基于 进程 process )的。进程是一些独立的小型虚拟机,可以执行 Erlang函数。
Erlang 里,进程隶属于编 程语言,而非操作系统。这就意味着 Erlang 的进程在任何操作系统上都会具有相同的逻辑行为,这样,就能编写可移植的并发代码,让它在任何支持Erlang 的操作系统上运行。
在Erlang里:
(1)创建和销毁进程是非常快速的;
(2)  在进程间发送消息是非常快速的;
(3)  进程在所有操作系统上都具有相同的行为方式;
(4)  可以拥有大量进程;
(5)  进程不共享任何内存,是完全独立的;
(6)  进程唯一的交互方式就是消息传递。
出于这些原因, Erlang 有时会被称为是一种 纯消息传递式语言

一:基本并发函数

下面是几个基本的并发函数:

(1)Pid = spawn(Mod,Func,Args)

创建一个新的并发进程来执行 apply(Mod, Func, Args) 。这个新进程和调用进程并列运行。spawn 返回一个 Pid process identifier 的简称,即进程标识符)。可以用 Pid 来给此进程发送消息。请注意,元数为length(Args) Func 函数必须从 Mod 模块导出。当一个新进程被创建后,会使用最新版 的代码定义模块。
(2)Pid =  spawn(Fun)

创建一个新的并发进程来执行Fun()。这种形式的spawn总是使用被执行fun的当前值,而且这个fun无需从模块里导出。这两种spawn形式的本质区别与动态代码升级有关。

(3)pid ! Message

 向标识符为Pid的进程发送消息Message。消息发送是异步的。发送方并不等待,而是会继续之前的工作。!被称为发送操作符。Pid ! M被定义为M。因此,Pid1 ! Pid2 ! ... ! Msg的意思是把消息Msg发送给Pid1、Pid2等所有进程。

(4)receive ... end

 接收发送给某个进程的消息。它的语法如下:

receive
    Patternl [when Guardl] ->
        Expressions1;
    Pattern2 [when Guard2] ->
        Expressions2;
    ...
end
当某个消息到达进程后,系统会尝试将它与 Pattern1 (以及可选的关卡 Guard1 )匹配,
如果成功就执行 Expressions1 。如果第一个模式不匹配,就会尝试 Pattern2 ,以此类推。
如果没有匹配的模式,消息就会被保存起来供以后处理,进程则会开始等待下一条消息。
到目前为止,我们粗略介绍了 spawn send receive 的工作方式。当 spawn 命令被执行时,
系统会创建一个新的进程。每个进程都带有一个邮箱,这个邮箱是和进程同步创建的。
给某个进程发送消息后,消息会被放入该进程的邮箱。只有当程序执行一条接收语句时才会
读取邮箱。
通过这三个基本函数,我们可以把 area/1 函数转变为一个进程。定义area/1函数的代码如下:
%% geometry.erl
area({rectangle, Width, Height}) -> Width * Height;
area({square, Side}) -> Side * Side.
现在把这个函数改写成一个 进程 。为此,我们从 area 函数的参数里取了两个模式,然后把
它们重置为接收语句里的模式。如下:
%% area_server0.erl
-module(area servero).
-export([loop/0]).

loop() ->
    receive
        {rectangle, width, Ht}->
            io:format("Area of rectangle is ~p~n",[width * Ht]),
            loop();
        {square,Side} ->
            io:format("Area of square is ~p~n",[Side * Side]),
            loop()
    end.
可以在 shell 里创建一个执行 loop/0 的进程。如下:
1> Pid spawn(area_server0,Loop,[]).
<0.36.0>

2> Pid {rectangle,6,10}.
Area of rectangle is 60
{rectangle,6,10}

3> Pid!{square,12}.
Area of square is 144
{square,144}
我们在第 1 行里创建了一个新的并行进程。 spawn(area_server, loop, []) 会创建一个执行area_server:loop() 的并行进程,然后返回 Pid ,也就是打印出来的 <0.36.0>。在第 2 行里向这个进程发送了一个消息。这个消息匹配 loop/0 接收语句里的第一个模式:
loop() ->
    receive
        {rectangle, width, Ht}->
            io:format("Area of rectangle is ~p~n",[width * Ht]),
            loop()
        ...
收到消息之后,这个进程打印出矩形的面积。最后, shell 打印出 {rectangle, 6, 10} ,这
是因为 Pid ! Msg 的值被定义为 Msg

二:客户端——服务器介绍

客户端—— 服务器架构是 Erlang 的中心。传统的客户端 —— 服务器架构是指一个分隔客户端与服务
器的网络。大多数情况下客户端会有多个实例,而服务器只有一个。 服务器 这个词经常会让人联
想到专业机器上运行重量级软件的画面。
我们的实现机制则要轻量得多。客户端 —— 服务器架构里的客户端服务器不同的进程,它们之间的通信使用普通的 Erlang 消息传递机制。客户端和服务器可以运行在同一台机器上,也可以运行在不同的机器上。
客户端 服务器 这两个词是指这两种进程所扮演的角色:客户端总是通过向服务器发送一个请求来发起计算。服务器计算后生成回复,然后发送一个 响应 给客户端。
在上一个程序里,我们只需要向某个进程发送请求,然后接收它并打印出来。现在要做的是向发送原请求的进程发送一个响应。问题是,我们不知道该把响应发给谁。要发送一个响应,客户端必须加入一个服务器可以回复的地址。这就像是给某人写信——如果你想得到回复,最好把你的地址写在信中!
因此,发送方必须加入一个回复地址。要做到这一点,可以把:
Pid !{rectangle,6,10)

修改成下面这样:

 Pid (self(), {rectangle, 6, 10})

 self()是客户端进程的标识符。为了响应请求,我们必须把接收请求的代码从:

loop() ->
    receive
        {rectangle, width, Ht}->
            io:format("Area of rectangle is ~p~n",[width * Ht]),
            loop()
        ...

修改成下面这样:

loop() ->
    receive
       {From, {rectangle, Width, Ht}}->
                From  ! Width * Ht,
                loop();
        ...

 

请注意我们是如何把计算结果发回由 From 参数指定的进程的。因为客户端把这个参数设置成它自己的ID ,所以能收到结果。 发送请求的进程通常称为客户端 。接收请求并回复客户端的进程称为 服务器
另外,最佳实践是确认发送给进程的每一个消息都已收到。如果发送给进程的消息不匹配原始接收语句里的任何一个模式,这条消息就会遗留在进程邮箱里,永远无法接收。为了解决这个问题,我们在接收语句的最后加了一个子句,让它能匹配所有发送给此进程的消息。最后,添加一个名rpc remote procedure call的缩写,即远程过程调用)的实用小函数,它封装了向服务器发送请求和等待响应的代码,如下:
%% area_server1.erl
rpc(Pid, Request) ->
    Pid ! {self(), Request},
    receive
        Response ->
            Response
    end.

把这些合并在一起,就能得到:

%% area_server1.erl
-module(area_server1).
-export([loop/0,rpc/2]).

rpc(Pid, Request)->
    Pid {self(), Request},
    receive
        Response ->
         Response
    end.

loop() ->
    receive
        {From, {rectangle, width, Ht}} ->
            From ! width * Ht,
            loop();
        {From, {circle, R}} ->
            From ! 3.14159*R*R,
            loop();
        {From,Other} ->
            From ! {error, Other},
            loop()
    end.
可以在 shell 里试验一下它:
1> Pid spawn(area_serverl,loop,[]).
<0.36.0>

2> area_serverl:rpc(Pid, {rectangle,6,8}).
48

3> area_serverl:rpc(Pid,{circle,6}).
113.097

4> area_serverl:rpc(Pid,socks).
{error,socks}
这段代码有个小问题。在 rpc/2 函数里,我们向服务器发送请求然后等待响应。但我们并不是等待来自服务器的响应,而是在等待任意消息。如果其他某个进程在客户端等待来自服务器的消息时向它发送了一个消息,客户端就会将此消息错误解读为来自服务器的响应。要纠正这个问题,可以把接收语句的形式修改如下:
loop()->
    receive
        {From, ...}->
            From ! {self(), ...}
            loop()
        ...
end
再把 rpc 修改如下:
rpc(Pid,Request) ->
    Pid ! {self(), Request},
    receive
        (Pid,Response} ->
            Response
    end.
调用 rpc 函数时, Pid 会被绑定为某个值,因此 {Pid, Response} 这个模式里的Pid已绑定,而Response 未绑定。这个模式只会匹配包含一个双元素元组(第一个元素是 Pid )的消息。所有别的消息都会进入队列。(receive 提供了 选择性接收 的功能,我会在后面介绍。)修改之后的代码如下:
%% area_server2.erl
-module(area server2).
-export([loop/0,rpc/2]).

rpc(Pid,Request)->
    Pid ! {self(),Request},
    receive
        {Pid,Response} ->
        Response
    end.

loop() ->
    receive
        {From, {rectangle, width, Ht}} ->
            From ! {self(), width * Ht},
            loop();
        {From, {circle, R}} ->
            From ! {se1f(), 3.14159*R*R},
            loop();
        {From,other} ->
            From ! {self(),{error,Other}},
            loop()
    end.
它的工作方式和预期的一致:
1> Pid = spawn(area_server2,loop,[])
<0.37.0>

2> area_server2:rpc(Pid,{circle,5}).
78.5397
还有最后一点可改进的地方。我们可以让 spawn rpc 隐藏在模块内 。请注意,还需要把 spawn
的参数(也就是loop/0 )从模块中导出。这是一种好的做法,因为它能让我们在不改变客户端
代码的情况下修改服务器的内部细节。最终的代码如下:
%% area_server_final.erl
-module(area server final).
-export([start/0,area/2,loop/0]).

start() -> spawn(area_server_final,loop,[]).

area(Pid,What) ->
    rpc(Pid,what).

rpc(Pid,Request) ->
    Pid ! {self(),Request},
    receive
        {Pid,Response} ->
            Response
    end.

loop() ->
    receive
        {From,{rectangle,width,Ht}} ->
            From ! {self(),width * Ht},
            loop();
        {From,{circle,R}} ->
            From ! {5e1f(),3.14159*R*R},
            loop();
        {From,Other} ->
            From ! {self(),{error,Other}},
            loop()
    end.
我们调用函数 start/0 area/2 (之前称为 spawn rpc )来运行它。这些新名称更好一些,
因为它们能更准确地描述服务器的行为:
1> Pid area_server_final:start().
<0.36.0>

2> area_server_final:area(Pid,{rectangle,10,8}).
80

3> area_server_final:area(Pid,{circle,4}).
50.2654

三:进程很轻巧

如果创建数百或者数千个 Erlang 进程,就必须付出一定的代价。但在erlang中,创建大量进程是很快的,因此这也是erlang相比其他语言来说的一大优点,创建进程是为了让编程变得更为简单,而不是变得复杂。
为了更直观体验erlang为何会说创建进程是很快的,你可以运行下面代码测试一下:
%% processes.erl
-module(processes).
-export([max/1]).

%% max(N)
%% 创建N个进程然后销毁它们
%% 看看需要花费多长时间

max (N)->
    Max = erlang:system_info(process limit),
    io:format("Maximum allowed processes:~p~n",[Max]),
    statistics(runtime),
    statistics(wall clock),
    L = for(1,N,fun() -> spawn(fun() -> wait() end) end),
    {_, Timel} = statistics(runtime),
    {_, Time2} = statistics(wall_clock),
    lists:foreach(fun(Pid) -> Pid ! die end, L),
    U1 = Time1*1000/N,
    U2 = Time2*1000/N,
    io:format("Process spawn time=~p (~p) microseconds~n",
              [U1,U2]).

wait() ->
    receive
        die -> void
    end.

for(N, N, F) -> [F()];
for(I, N, F) -> [F() | for(I+1, N, F)].

在Shell执行如下:

1> processes:max(20000).

就能看出创建进程的时间,随着进程数量的增加,进程分裂时间也在增加。如果继续增加进程的数量,最终会耗尽物理内存,导致系统开始把物理内存交换到硬盘上,运行速度明显变慢。当然,开多个进程会加大物理内存,但这与虚拟机内存无关,也可以说只要物理内存够大,erlang想建多少都不影响性能的。

四:带超时的接收

有时候一条接收语句会因为消息迟迟不来而一直等下去。发生这种情况的原因有很多,比如程序里可能有一处逻辑错误,或者准备发送消息的进程在消息发出前就崩溃了。要避免这个问题,可以给接收语句增加一个超时设置,设定进程等待接收消息的最长时间。它的语法如下:
receive
    Patternl [when Guardl] ->
        Expressions1;
    Pattern2 [when Guard2]->
        Expressions2;
    ...
after Time ->
    Expressions
end
如果在进入接收表达式的 Time 毫秒后还没有收到匹配的消息,进程就会停止等待消息,转而执行Expressions

1.只带超时的接收

可以编写一个只有超时部分的 receive 。通过这种方法,我们可以定义一个 sleep(T) 函数,它会让当前的进程挂起T 毫秒:
%% lib_misc.erl
sleep(T) ->
    receive
    after T ->
        true
    end.

2.超时值为 0 的接收

超时值为 0 会让超时的主体部分立即发生,但在这之前,系统会尝试对邮箱里的消息进行匹配。我们可以用它来定义一个flush_buffer 函数,它会清空进程邮箱里的所有消息:
%% lib_misc.erl
flush_buffer() ->
    receive
        _Any ->
            flush_buffer()
    after 0 ->
        true
    end.
如果没有超时子句, flush_buffer 就会在邮箱为空时永远挂起且不返回。我们还可以使用零超时来实现某种形式的“优先接收”,就像下面这样:
%% lib_misc.erl
priority_receive() ->
    receive
        {alarm, X} ->
            {alarm, X}
    after 0 ->
        receive
            Any ->
                Any
        end
    end.
如果邮箱里 不存在匹配{alarm, X} 的消息, priority_receive 就会接收邮箱里的第一个消息。如果没有任何消息,它就会在最里面的接收语句处挂起,并返回它收到的第一个消息。如果存在匹配{alarm, X} 的消息,这个消息就会被立即返回。请记住,只有当邮箱里的所有条目都进行过模式匹配后,才会检查after 部分。 如果没有after 0 语句,警告( alarm )消息就不会被首先匹配。
注:对大的邮箱使用优先接收是相当低效的,所以如果你打算使用这一技巧,请确保邮箱不要太满。

3.超时值为无穷大的接收

如果接收语句里的超时值是原子 infinity (无穷大),就 永远不会 触发超时。这对那些在接收语句之外计算超时值的程序可能很有用。有时候计算的结果是返回一个实际的超时值,其他的时候则是让接收语句永远等待下去。

4.实现一个定时器

可以用接收超时来实现一个简单的定时器。函数stimer:start(Time, Fun) 会在 Time 毫秒之后执行 Fun (一个不带参数的函数)。它返回一个句柄(是一个PID ),可以在需要时用来关闭定时器:
%% stimer.erl
-module(stimer).
-export([start/2,cancel/1]).

start(Time,Fun) ->
    spawn(fun() -> timer(Time, Fun) end).

cancel(Pid) -> Pid ! cancel.

timer(Time,Fun) ->
    receive
        cancel ->
            void
    after Time ->
            Fun()
    end.
可以像下面这样测试它:
1> Pid = stimer:start(5000,fun() -> io:format("timer event~n") end).
<0.42.0>
timer event

%% 我等待的时间超过了5秒钟,这样定时器就会触发。
%% 现在我将启动一个定时器,然后在到期前关闭它。

2> Pid1 = stimer:start(25000,fun() -> io:format("timer event~n") end).
<0.49.0>

3> stimer:cancel(Pidl).
cancel
超时和定时器是实现许多通信协议的关键。我们等待某个消息时并不想永远等下去,所以会像例子里那样增加一个超时设置。

五.选择性接收

基本函数 receive 用来从进程邮箱里提取消息,但它所做的不仅仅是简单的模式匹配。它还
会把未匹配的消息加入队列供以后处理,并管理超时。下面这个语句:
receive
    Patternl [when Guardl] ->
        Expressions1;
    Pattern2 [when Guard2] ->
        Expressions2;
    ...
after
    Time ->
        ExpressionsTimeout
end
它的工作方式如下。
(1) 进入 receive 语句时会启动一个定时器(但只有当表达式包含 after 部分时才会如此)。
(2) 取出邮箱里的第一个消息,尝试将它与 Pattern1 Pattern2 等模式匹配。如果匹配成功,系统就会从邮箱中移除这个消息,并执行模式后面的表达式
(3) 如果 receive 语句里的所有模式都不匹配邮箱的第一个消息,系统就会从邮箱中移除这个消息并把它放入一个“保存队列”,然后继续尝试邮箱里的第二个消息。这一过程会不断重复,直到发现匹配的消息或者邮箱里的所有消息都被检查过了为止。
(4) 如果邮箱里的所有消息都不匹配,进程就会被挂起并重新调度,直到新的消息进入邮箱才会继续执行。新消息到达后,保存队列里的消息不会重新匹配,只有新消息才会进行匹配。
(5) 一旦某个消息匹配成功,保存队列里的所有消息就会按照到达进程的顺序重新进入邮箱。如果设置了定时器,就会清除它。
(6) 如果定时器在我们等待消息时到期了,系统就会执行表达式 ExpressionsTimeout ,并
把所有保存的消息按照它们到达进程的顺序重新放回邮箱。

六:注册进程

如果想给一个进程发送消息,就需要知道它的PID,但是当进程创建时,只有父进程才知道它的PID。系统里没有其他进程知道它的存在。这通常很不方便,因为你必须把PID发送给系统里所有想要和它通信的进程。另一方面,这也很安全。如果不透露某个进程的PID,其他进程就无法以任何方式与其交互。Erlang有一种公布进程标识符的方法,它让系统里的任何进程都能与该进程通信。这样的进程被称为注册进程registered process)。管理注册进程的内置函数有四个:

(1)register(AnAtom,Pid)

AnAtom (一个原子)作为名称来注册进程 Pid 。如果 AnAtom 已被用于注册某个进程,这次注册就会失败。

(2)unregister(AnAtom)

移除与AnAtom关联的所有注册信息。

注: 如果某个注册进程崩溃了,就会自动取消注册。

(3)whereis(AnAtom) -> Pid | undefined

检查 AnAtom 是否已被注册。如果是就返回进程标识符 Pid ,如果没有找到与 AnAtom 关联的进程就返回原子undefined

(4)registered() -> [AnAtom:atom()]

返回一个包含系统里所有注册进程的列表。

 可以用register来改写第一节里的代码示例,并尝试用创建的进程名称进行注册:

1> Pid = spawn(area_servere,loop,[])
<0.51.0>

2> register(area,Pid).
true
一旦名称注册完成,就可以像这样给它发送消息:
3> area {rectangle,4,5}.
Area of rectangle is 20
{rectangle,4,5}
下面我们就用 register 来制作一个模拟时钟的注册进程:
%% clock.erl
-module(clock).
-export([start/2,stop/0]).

start(Time,Fun) ->
    register(clock, spawn(fun() -> tick(Time, Fun) end)).
stop() -> clock !stop.
tick(Time, Fun) ->
    receive
        stop ->
            void
    after Time ->
            Fun(),
            tick(Time, Fun)
    end.
这个时钟会不断滴答作响,直到你停止它:
3> clock:start(5000, fun() -> io:format("TICK ~p~n",[erlang:now()]) end).
true
TICK {1164,553538,392266}
TICK {1164,553543,393084}
TICK {1164,553548,394083}
TICK {1164,553553,395064}

4>clock:stop().
stop

七:关于尾递归的说明

再来看一下我们之前编写的面积计算服务器,它的接收循环如下:
%% area_server_final.erl
loop() ->
    receive
        {From, {rectangle, width, Ht}} ->
            From ! {self(),width * Ht},
            loop();
        {From,{circle,R}} ->
            From ! {se1f(),3.14159*R*R},
            loop();
        {From,Other} ->
            From {self(),{error,Other}},
            loop()
    end.
如果你仔细观察,就会发现每当我们收到消息时就会处理它并立即再次调用 loop() 。这一过程被称为尾递归 tail-recursive 。可以对一个尾递归的函数进行特别编译,把语句序列里的最后一次函数调用替换成跳至被调用函数的开头。这就意味着尾递归的函数无需消耗栈空间也能一直循环下去。 假设编写了以下(不正确的)代码:
loop()->
    receive
        {From,{rectangle,Width,Ht}} ->
            From ! {self(),Width * Ht},
            loop(),
            someotherFunc();
        {From,{circle,R}} ->
            From ! {se1f(),3.14159*R*R},
            Loop();
            ...
    end
end
我们在第 5 行里调用了 loop() ,但是编译器必然推断出“当我调用 loop() 后必须返回这里,因为我得调用第6 行里的 someOtherFunc() ”。于是它把 someOtherFunc 的地址推入栈,然后跳到loop 的开头。这么做的问题在于 loop() 是永不返回的,它会一直循环下去。所以,每次经过第5行,就会有一个返回地址被推入控制栈,最终系统的空间会消耗殆尽。避免这个问题的方法很简单,如果你编写的函数 F是永不返回的(就像loop()一样),就要确保在 调用F之后不再调用其他任何东西,并且别把F用在列表或元组构造器里。

八:MFA 或 Fun 进行分裂

显式的模块函数名参数列表(称为 MFA )来分裂一个函数是确保运行进程能够正确升级为新版模块代码(即使用中被再次编译)的恰当方式。动态代码升级机制不适用于fun 的分裂,只能用于带有显式名称的MFA 上。 如果你不关心动态代码升级,或者确定程序不会在未来进行修改,就可以使用spawn 的spawn(Fun)形式。如果有疑问,就使用 spawn(MFA) 。 就是这样。现在可以编写并发程序了!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

明明如皓

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值