在Erlang里用进程保存状态

状态是我们如何使程序做不平凡的事情。如果我们正在写一个电子游戏,它的一个状态可能是一个被打败的坏人的数字。在大多数编程语言中,我们将通过将计数分配给变量,然后在每次新的坏人被击败时更新这个值来实现这一点。然而,一旦变量已经被赋值(单次赋值),Erlang不允许我们修改这个变量的值。那么如果我们不能够改变一个变量的值,我们如何在Erlang里跟踪状态?答案是:用进程。

单次赋值

Erlang从单次赋值中获得很多的能力。单次赋值意味着,一旦一个值被赋予一个变量,则没有其他值可以被赋予这同一个变量。在许多Erlang的教程里你可以看到像如下一样的例子:

1
2
3
4
1> X = 1.
1
2> X = 2.
** exception error: no match of right hand side value 2

在第一步,Erlang尝试用值1模式匹配X。因为X没有被绑定,所以它被绑定到值1。从现在开始,当X在模式匹配操作符的左手边的时候,普通的模式匹配就会发生。这就是为什么我们在第二步得到一个错误 - Erlang尝试用值2去和已经有值为1的X匹配,因此引起一个无法匹配的错误。我们依然可以匹配X只要右手边相应的值是1,如下例子:

1
2
3
4
3> X = 1.
1
4> {X, 2} = {1, 2}.
{1,2}

单次赋值是Erlang的一个非常棒的的优点,因为它减少了有副作用的函数调用的可能。副作用使得写高并发的代码非常困难。

状态

在本文剩下的部分我们将使用一个计数器的例子。我的意思是通过一个计数器也就是一段简单的代码,它能够保持计数值,并提供一个API增值和(或)访问计数的值。这是一个关于状态的非常简单的例子;计数器的状态就是计数的值。

面向对象语言做这个例子非常简单,它很自然地存储状态到类实例的变量里。例如,用Ruby来实现一个简单的计数器,我们可能会像下面这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Ruby里一个简单面向对象计数器
class Counter
def initialize
@count = 0
end
def click
@count += 1
end
end
c = Counter.new
c.click # => @count is now 1
c.click # => @count is now 2

@count是Counter类的一个实例变量。当我们通过调用Counter.new来创建Counter类的一个新实例的时候,在构造器(函数initialize)里@count被初始化为0。每次我们调用click方法,@count的值被增加一。@count持有计数器的状态。我们可以很容易地创建多个计数器,它们都有自己私有的状态。

1
2
3
4
5
c1 = Counter.new
c2 = Counter.new
c1.click # => 1
c1.click # => 2
c2.click # => 1

递归和状态

那么我们如何在Erlang里保持状态呢?我们可以用递归来做到,通过从初始函数调用到下一个函数调用和后续的函数调用之间传递状态。如下有一个Erlang的计数器例子,每次递归函数调用的时候打印出递增的计数器值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
%%% loop_counter.erl
% 一个递归循环计数器
% 基于当前进程,并且没有访问计数器的值
-module(loop_counter).
-export([go/0]).
go() ->
go(0).
go(N) ->
io:format("N is ~p~n", [N]),
% 只是为了不让我们的终端忙疯了。
timer:sleep(1000),
go(N + 1).
%%% Erlang shell
1> c(loop_counter).
{ok,loop_counter}
2> loop_counter:go().
N is 0
N is 1
N is 2
N is 3
(hit Ctrl-c)
BREAK: (a)bort (c)ontinue (p)roc info (i)nfo (l)oaded
(v)ersion (k)ill (D)b-tables (d)istribution

在这个例子中,我们使用go/1函数的输入参数N来跟踪我们的状态,我们用N+1作为下一个调用的参数递归地调用相同函数来增加计数器值。事实上,我们能够增加这个值意味着我们正在跟踪计数器的状态。但是这个例子没有太多用处,因为递归调用循环完全占据当前的Erlang进程。

我们可以用 erlang:spawn/1 将我们的计数器循环从主进程(我们的Erlang shell)分开。

1
2
3
4
5
6
7
8
9
10
11
1> c(loop_counter).
{ok,loop_counter}
2> Pid = spawn(fun loop_counter:go/0).
N is 0
<0.39.0>
N is 1
N is 2
N is 3
N is 4
3> q().
ok

这样我们重新获取我们Erlang shell的控制权,但是现在我们无法控制计数器或者以编程方式获取它当前的值。

用消息控制和查询状态

在前面一节,我们创建了一个计数器,通过递归地调用自己来增加自己的值,但是我们无法控制计数器或者从外部代码访问它的状态。我们可以用消息来做到这点。如下的例子,增加一个click消息给我们的计数器。我们可以用click消息增加计数器的值,并且将修改后的值作为消息返回给调用的进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
%%% counter.erl
% 创建一个计数器循环来侦听click消息。
-module(counter).
-export([new/0, click/1]).
% 创建一个计数器,返回pid
new() ->
spawn(fun() -> loop(0) end).
% 递归循环,函数中用receive块接收消息。
loop(N) ->
receive
{click, From} ->
From ! N + 1,
loop(N + 1)
end.
% API函数。增值计数器,计数器的pid是入参给定的。
click(Pid) ->
Pid ! {click, self()},
receive V -> V end.
%%% in Erlang shell
1> c(counter).
{ok,counter}
2> C = counter:new().
<0.39.0>
3> counter:click(C).
1
4> counter:click(C).
2
5> counter:click(C).
3

当我们调用counter:new/0,这个函数创建一个新的递归循环,并用0初始化计数器的值,然后返回被创建进程的pid。循环函数立即进入receive块,在这里它无限期等待从任何其他进程发来的消息。我们设置它只监听一个消息:{click, From},并要求From是调用进程的pid。

如下例子是从终端创建一个计数器进程并直接发送一个消息给它然后等待接收一个消息返回。

1
2
3
4
5
6
7
8
1> c(counter).
{ok,counter}
2> C = counter:new().
<0.39.0>
3> C ! {click, self()}.
{click,<0.32.0>}
4> receive V -> V end.
1

上述这个例子显得有点笨拙。我们不想每次要增值计数器的时候都必须写消息调用和receive块;而且这样也不安全,因为这样做留下了很多犯错的空间。因此我们引入API函数counter:click/1(参阅上面的代码例子),它的入参是一个计数器的pid,它知道以正确的方式发送和接收一个click消息。

注意:我们在counter:click/1里用self/0来自动获取调用进程的pid。这种用法可以正常工作,因为counter:click/1是被调用进程调用的。如果我们在counter:loop/1函数里调用self/0,它将返回被创建进程的pid,而这个被创建的进程正在执行循环。

使用这个范例,通过创建多个进程来创建和控制多个计数器是很容易的。此外,API隐藏了大部分的底层实现细节,我们可以与计数器实例一起工作,而不必知道它们的值实际上是一个进程标识符。

1
2
3
4
5
6
7
8
9
10
11
12
1> c(counter).
{ok,counter}
2> C1 = counter:new().
<0.39.0>
3> C2 = counter:new().
<0.41.0>
4> counter:click(C1).
1
5> counter:click(C1).
2
6> counter:click(C2).
1

我们也可以修改循环函数来侦听一个set消息来允许我们手工设置计数器的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
% 增加一个消息处理器来设置计数器的值。
loop(N) ->
receive
{click, From} ->
From ! N + 1,
loop(N + 1);
{set, Value, From} ->
From ! ok,
loop(Value)
end.
% API函数。依据给定的pid来设置计数器的值。
set(Pid, Value) ->
Pid ! {set, self(), Value},
receive V -> V end.

用于生产环境的gen_server

我们上述例子的计数器有许多潜在问题。当一个计数器进程收到一个意料之外的消息会发生什么?当系统很忙并且无法以及时的方式响应一个click消息的时候会发生什么?我们如何停止计数器并释放它正在使用的任何系统资源?当我们增加越来越多的消息的时候,代码也很快变得不可管理。

Erlang有一套行为接口用来解决当试图使用Erlang解决问题时普遍遇到的各种问题。这套模块被称为OTP(正式称谓是“开放电信平台”,现在只是简单地叫做“OTP”)。

OTP的 gen_server 行为被设计和实现出来就是为了用来实现一种设计模式,这种设计模式解决我们在本文已经遇到的问题。它以一种健壮和完整的方式实现一个有状态的被创建的进程行为,并且解决很多我们还没有考虑到的问题。

我不打算在这里深入研究OTP行为,因为本文的目的不是教你各种OTP行为或者正确的OTP设计。如果你想学习更多的细节,请看Erlang官方的 gen_server 文档以及《Learn You Some Erlang》的 客户端和服务端 章节。然而下面的例子将给你一个不错的主意,就是gen_server如何解决在Erlang里保持状态的问题。

如下就是一个用gen_server实现一个简单的计数器的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
%%% counter_server.erl
% 简单计数器实现为一个gen_server
-module(counter_server).
-behavior(gen_server).
% API
-export([new/0, click/1]).
% gen_server需要的
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
%%% API 方法
new() ->
gen_server:start(?MODULE, [], []).
click(Pid) ->
gen_server:call(Pid, click).
%%% gen_server 回调
%%% 这些是实现gen_server行为所需要的
%%% 我们只是使用了 init 和 handle_call
init([]) ->
% 第二个值是初始化计数器的状态
{ok, 0}.
handle_call(click, _From, N) ->
% 第二个值是返回给调用者的消息
% 第三个值是新状态
{reply, N + 1, N + 1}.
% 基本上,我们忽略这些函数,不过我们保持同样的计数器状态
handle_cast(_Msg, N) ->
{noreply, N}.
handle_info(_Msg, N) ->
{noreply, N}.
code_change(_OldVsn, N, _Other) ->
{ok, N}.
terminate(_Reason, _N) ->
ok.
%%% Erlang console
1> c(counter_server).
{ok,counter_server}
2> {ok, C} = counter_server:new().
{ok,<0.39.0>}
3> counter_server:click(C).
1
4> counter_server:click(C).
2
5> counter_server:click(C).
3

这个例子可能看起来像是解决一个简单问题用了很多代码,但是我们只是在这个框架里实现功能让我们获得非常大的灵活性。另外也有大量可用模版为你创建代码框架(在Emacs里,你可以从Erlang包里用 M-x tempo-template-erlang-generic-server 创建代码框架)。

作为一个例子,和上述例子一样增加一个set/2功能只是增加一个API函数和一个handle_call回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
%%% API
set(Value, Pid) ->
gen_server:call(Pid, {set, Value}).
...
handle_call({set, Value}, _From, _N) ->
{reply, ok, Value};
% 来自上面的现有回调,供参考
% (这两个函数分支在一起是为了模式匹配)
handle_call(click, _From, N) ->
{reply, N + 1, N + 1}.

结论

这就是你从本文学到的。我们在Erlang里用进程保持状态跟踪,并且我们用消息访问和控制状态。在很多方面,Erlang的进程类似于其他语言中的类的实例。在许多其他方面,它们是非常不同的。在Erlang里解决问题要用不同的思考方式:用进程的概念来思考。一开始这可能很难理解,但是一旦你理解它了,许多问题以这种方式更容易解决。

原文链接: http://dantswain.herokuapp.com/blog/2014/09/27/storing-state-in-erlang-with-processes/