GenServer
是一个用来实现客户端-服务器模式中服务器的行为模块。
GenServer
是一个普通的 Elixir 进程,同其他 Elixir 进程一样,它可以用来保存状态、异步执行代码等。使用这个模块来实现通用服务器进程(GenServer)的优势在于,它有一套标准的接口函数,并提供用于跟踪和错误报告的功能。同时它也可以用到一个监督树中。
示例
GenServer
行为抽象了常见的客户端-服务器模式。开发人员只需要实现他们感兴趣的回调和功能。
让我们从一个例子开始,然后探索可用的回调。假设我们想用 GenServer 实现一个栈服务,可以压入和弹出元素。我们将通过实现三个回调来定制一个通用 GenServer。
init/1
将我们的初始参数转换为 GenServer 的初始状态。 handle_call/3
在服务器收到同步的 pop
消息时触发,从栈中弹出一个元素并返回给用户。 handle_cast/2
在服务器收到异步的 push
消息时触发,将一个元素压入栈中:
defmodule Stack do
use GenServer
# Callbacks
@impl true
def init(elements) do
initial_state = String.split(elements, ",", trim: true)
{:ok, initial_state}
end
@impl true
def handle_call(:pop, _from, state) do
[to_caller | new_state] = state
{:reply, to_caller, new_state}
end
@impl true
def handle_cast({:push, element}, state) do
new_state = [element | state]
{:noreply, new_state}
end
end
我们将进程的启动、消息传递和消息循环机制留给了 GenServer 行为,我们只用关注栈的具体实现。现在我们可以创建一个进程,并使用 GenServer API 发送消息来与服务交互了:
# Start the server
{:ok, pid} = GenServer.start_link(Stack, "hello,world")
# This is the client
GenServer.call(pid, :pop)
#=> "hello"
GenServer.cast(pid, {:push, "elixir"})
#=> :ok
GenServer.call(pid, :pop)
#=> "elixir"
我们通过调用 start_link/2
来启动我们的 Stack
,它需要服务的实现模块和初始参数。GenServer 行为会调用 init/1
回调来生成 GenServer 的初始状态。从此刻开始,GenServer 接管一切,因此我们可以通过向它发送两种类型的消息与它交互。call 消息期望从服务器获得回复(因此是同步的),而 cast 消息则不需要回复。
GenServer.call/3
产生的消息必须由 GenServer 的 handle_call/3
回调处理。而 cast/2
的消息必须由 handle_cast/2
处理。 GenServer
支持 8 个回调,但只有 init/1
是必需的。
当你使用
use GenServer
时,GenServer
模块会设置@behaviour GenServer
并定义一个child_spec/1
函数,因此你的模块可以做为监督树的子进程。
客户端/服务端 API
尽管在上述示例中我们直接使用了 GenServer.start_link/3
和相关函数来启动服务器并与之通信,但大多数时候我们不直接调用 GenServer
函数。相反,我们将调用封装到新的函数中,做为服务器的公共 API。这些封装被称为客户端 API。
以下是我们栈模块的升级版:
defmodule Stack do
use GenServer
# Client
def start_link(default) when is_binary(default) do
GenServer.start_link(__MODULE__, default)
end
def push(pid, element) do
GenServer.cast(pid, {:push, element})
end
def pop(pid) do
GenServer.call(pid, :pop)
end
# Server (callbacks)
@impl true
def init(elements) do
initial_state = String.split(elements, ",", trim: true)
{:ok, initial_state}
end
@impl true
def handle_call(:pop, _from, state) do
[to_caller | new_state] = state
{:reply, to_caller, new_state}
end
@impl true
def handle_cast({:push, element}, state) do
new_state = [element | state]
{:noreply, new_state}
end
end
实际上,将服务器和客户端的函数放在同一个模块中是很常见的。如果服务器或客户端变得越来越复杂,你可能希望将它们放到不同的模块中。
下图总结了客户端和服务器之间的交互。客户端和服务器都是进程,它们通过消息(实线)进行通信。服务器与实现模块的交互发生在 GenServer 进程调用你的代码时(虚线):
如何监督
一个 GenServer
通常在监督树下启动。当我们调用 use GenServer
时,它会自动定义了一个 child_spec/1
函数,让我们可以直接在监督者下启动 Stack
。通过以下代码我们可以在监督者下启动一个初始为 ["hello", "world"]
的栈:
children = [
{Stack, "hello,world"}
]
Supervisor.start_link(children, strategy: :one_for_all)
use GenServer
还接受一个选项列表来配置子进程描述,控制它在监督者下的运行方式。生成的 child_spec/1
可以使用以下选项自定义:
:id
- 子进程描述的唯一标识,默认是当前模块名。:restart
- 子进程是否应该被重启,默认为:permanent
。:shutdown
- 如何关闭子进程,立即关闭或给一段时间关闭。
例如:
use GenServer, restart: :transient, shutdown: 10_000
详细信息还请参见 Supervisor
模块中的“子进程描述”部分。紧接在 use GenServer
之前的 @doc
注释会添加到生成的 child_spec/1
函数上。
当停止 GenServer 时,例如从回调返回 {:stop, reason, new_state}
元组,退出原因会被监督者用来确定是否需要重启 GenServer。更多信息请参见 Supervisor
模块中的“退出原因和重启”部分。
命名注册
start_link/3
和 start/3
都支持 GenServer
在启动时通过 :name
选项注册一个名称。注册的名称也会在进程终止时自动清理。支持的值包括:
- 一个原子 - 使用
Process.register/2
,GenServer 以此为名称注册到本地(当前节点)。 {:global, term}
- 使用:global
模块的函数将 GenServer 注册到全局,term 是服务名称。{:via, module, term}
- 使用自定义的机制和名称注册服务。:via
选项需要一个导出了register_name/2
、unregister_name/1
、whereis_name/1
和send/2
函数的模块。:global
就是一个这样的模块,它使用这些函数来管理进程名称和PID,使它们对 Elixir 节点网络全局可用。Elixir 还提供了一个本地的、去中心化的、可扩展的注册表叫做Registry
,用于本地存储动态生成的名称。
进程名称和 PID 之间的关系就像域名和 IP 地址一样。
例如,我们可以像下面这样本地启动并注册 Stack
服务:
# Start the server and register it locally with name MyStack
{:ok, _} = GenServer.start_link(Stack, "hello", name: MyStack)
# Now messages can be sent directly to MyStack
GenServer.call(MyStack, :pop)
#=> "hello"
一旦服务启动, GenServer
模块的其他函数(call/3
、 cast/2
等)除了可以通过 PID 来调用,也可以通过 {:global, ...}
或 {:via, ...}
元组调用。总的来说支持以下格式:
- PID
- 原子,如果服务是本地注册的话
{atom, node}
,服务在另一个节点本地注册{:global, term}
,服务是全局注册的话{:via, module, name}
,服务通过其他模块自定义注册
如果想要在本地注册动态名称,请不要使用原子,因为原子不会被垃圾回收,因此动态生成的原子也不会被垃圾回收。对于这种情况,可以使用 Registry
模块做为自己的本地注册服务。
接收“常规”消息
GenServer
的目标是为开发者抽象“接收”循环,自动处理系统消息,支持代码变更、同步调用等。因此,永远不要在 GenServer 回调中调用你自己的“接收”代码,这样做会导致 GenServer 行为异常。
除了 call/3
和 cast/2
提供的同步和异步通信外,通过 send/2
、 Process.send_after/4
等发送的“常规”消息可以在 handle_info/2
回调中处理。
handle_info/2
可用于许多情况,例如处理由 Process.monitor/1
发送的监控 DOWN 消息。 handle_info/2
的另一个用例是借助 Process.send_after/4
执行周期性工作:
defmodule MyApp.Periodically do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, %{})
end
@impl true
def init(state) do
# Schedule work to be performed on start
schedule_work()
{:ok, state}
end
@impl true
def handle_info(:work, state) do
# Do the desired work here
# ...
# Reschedule once more
schedule_work()
{:noreply, state}
end
defp schedule_work do
# We schedule the work to happen in 2 hours (written in milliseconds).
# Alternatively, one might write :timer.hours(2)
Process.send_after(self(), :work, 2 * 60 * 60 * 1000)
end
end
超时
init/1
或任何 handle_*
回调的返回值都可以包含一个单位为毫秒的超时时间;如果没有,则为 :infinity
。超时可以用来检测没有消息的空闲期。
timeout()
的用法如下:
- 如果在超时到达时进程有正在等待处理的消息,超时将被忽略,等待的消息会像往常一样被处理。这意味着即使是 0 毫秒的超时也不能保证被执行(如果你想立即无条件地执行某个动作,请改用
:continue
指令)。 - 如果在超时到达之前有消息到达,超时将被清除,该消息会像往常一样被处理。
- 否则,当超时时间过去后没有消息到达,
handle_info/2
将被调用,并以:timeout
作为第一个参数。
何时(不)使用 GenServer
到目前为止,我们已经了解到 GenServer 可以做为被监督的进程用来处理同步和异步调用。还可以处理系统消息,如周期性消息和监控事件。GenServer 进程也可以被命名。
一个 GenServer,或者更通俗的说,一个进程,是用来建模系统的运行时特性的,绝不能被用来组织代码。
在 Elixir 中,代码通过模块和函数来组织,进程不是必须的。例如,想象你正在实现一个计算器,你决定将所有的计算操作放在一个 GenServer 后面:
def add(a, b) do
GenServer.call(__MODULE__, {:add, a, b})
end
def subtract(a, b) do
GenServer.call(__MODULE__, {:subtract, a, b})
end
def handle_call({:add, a, b}, _from, state) do
{:reply, a + b, state}
end
def handle_call({:subtract, a, b}, _from, state) do
{:reply, a - b, state}
end
这就是一个反模式,不仅是因为它使计算器逻辑变得复杂,而且因为你将计算逻辑放在了一个单一的进程里面,这个进程可能会成为你系统的瓶颈,特别是随着调用数量增加时。直接定义为函数就行了:
def add(a, b) do
a + b
end
def subtract(a, b) do
a - b
end
能不使用进程就不要使用进程。只有在需要建模运行时属性时才会使用进程,如可变状态、并发和失败,而不是为了组织代码。
使用 :sys
模块进行调试
GenServers 作为一种特殊进程,可以使用 :sys
模块进行调试。通过各种钩子,这个模块允许开发者查看进程的状态,并跟踪执行期间发生的系统事件,如收到的消息、发送的回复以及状态变化。
以下是 :sys
模块用于调试的基本函数:
:sys.get_state/2
- 检索进程的状态。在 GenServer 中下,就是回调模块状态,即传递给回调函数的最后一个参数。:sys.get_status/2
- 检索进程的状态。这个状态包括进程字典,如果进程正在运行或被挂起,父 PID,调试器状态,以及行为模块的状态,其中包括回调模块状态(由:sys.get_state/2
返回)。可以通过实现可选的GenServer.format_status/1
回调来改变这个状态的格式。:sys.trace/3
- 将所有系统事件打印到:stdio
。:sys.statistics/3
- 管理(打开/关闭)进程统计信息的收集。:sys.no_debug/2
- 关闭给定进程的所有调试句柄。结束后关闭调试是非常重要的。过多的调试句柄或者本应关闭但没有关闭的,可能会严重影响系统的性能。:sys.suspend/2
- 挂起一个进程,让它只回复系统消息,不回复其他消息。挂起的进程可以通过:sys.resume/2
重新激活。
让我们看看如何使用这些函数来调试我们之前定义的栈服务器。
{:ok, pid} = Stack.start_link([])
:sys.statistics(pid, true) # turn on collecting process statistics
:sys.trace(pid, true) # turn on event printing
Stack.push(pid, 1)
*DBG* <0.122.0> got cast {push,1}
*DBG* <0.122.0> new state [1]
:ok
:sys.get_state(pid)
[1]
Stack.pop(pid)
*DBG* <0.122.0> got call pop from <0.80.0>
*DBG* <0.122.0> sent 1 to <0.80.0>, new state []
1
:sys.statistics(pid, :get)
{:ok,
[
start_time: {{2016, 7, 16}, {12, 29, 41}},
current_time: {{2016, 7, 16}, {12, 29, 50}},
reductions: 117,
messages_in: 2,
messages_out: 0
]}
:sys.no_debug(pid) # turn off all debug handlers
:ok
:sys.get_status(pid)
{:status, #PID<0.122.0>, {:module, :gen_server},
[
[
"$initial_call": {Stack, :init, 1}, # process dictionary
"$ancestors": [#PID<0.80.0>, #PID<0.51.0>]
],
:running, # :running | :suspended
#PID<0.80.0>, # parent
[], # debugger state
[
header: 'Status for generic server <0.122.0>', # module status
data: [
{'Status', :running},
{'Parent', #PID<0.80.0>},
{'Logged events', []}
],
data: [{'State', [1]}]
]
]}