查看源代码 进程
在 Elixir 中,所有代码都在进程中运行。进程彼此隔离,并发运行,并通过消息传递进行通信。进程不仅是 Elixir 并发的基础,也是构建分布式和容错程序的手段。
Elixir 的进程不应该与操作系统进程混淆。Elixir 中的进程在内存和 CPU 方面非常轻量级(即使与许多其他编程语言中使用的线程相比也是如此)。因此,同时运行数万甚至数十万个进程并不少见。
在本章中,我们将学习有关生成新进程的基本结构,以及在进程之间发送和接收消息。
生成进程
生成新进程的基本机制是自动导入的 spawn/1
函数
iex> spawn(fn -> 1 + 2 end)
#PID<0.43.0>
spawn/1
接受一个函数,它将在另一个进程中执行该函数。
注意 spawn/1
返回一个 PID(进程标识符)。此时,您生成的进程很可能已经死亡。生成的进程将执行给定的函数,并在函数完成执行后退出
iex> pid = spawn(fn -> 1 + 2 end)
#PID<0.44.0>
iex> Process.alive?(pid)
false
注意:您获得的进程标识符可能与本指南中获得的进程标识符不同。
我们可以通过调用 self/0
来检索当前进程的 PID
iex> self()
#PID<0.41.0>
iex> Process.alive?(self())
true
当我们能够发送和接收消息时,进程变得更加有趣。
发送和接收消息
我们可以使用 send/2
向进程发送消息,并使用 receive/1
接收消息
iex> send(self(), {:hello, "world"})
{:hello, "world"}
iex> receive do
...> {:hello, msg} -> msg
...> {:world, _msg} -> "won't match"
...> end
"world"
当消息发送到进程时,该消息将存储在进程邮箱中。 receive/1
块遍历当前进程邮箱,搜索与任何给定模式匹配的消息。 receive/1
支持守卫和许多子句,例如 case/2
。
发送消息的进程不会阻塞 send/2
,它将消息放入接收者的邮箱并继续执行。特别是,一个进程可以向自己发送消息。
如果邮箱中没有与任何模式匹配的消息,当前进程将一直等待直到有匹配的消息到达。还可以指定超时时间
iex> receive do
...> {:hello, msg} -> msg
...> after
...> 1_000 -> "nothing after 1s"
...> end
"nothing after 1s"
当您已经期望消息在邮箱中时,可以给出 0 的超时时间。
让我们把它们组合在一起,并在进程之间发送消息
iex> parent = self()
#PID<0.41.0>
iex> spawn(fn -> send(parent, {:hello, self()}) end)
#PID<0.48.0>
iex> receive do
...> {:hello, pid} -> "Got hello from #{inspect pid}"
...> end
"Got hello from #PID<0.48.0>"
inspect/1
函数用于将数据结构的内部表示转换为字符串,通常用于打印。请注意,当 receive
块执行时,我们已经生成的发送进程可能已经死亡,因为它的唯一指令是发送一条消息。
在 shell 中,您可能会发现助手 flush/0
非常有用。它刷新并打印邮箱中的所有消息。
iex> send(self(), :hello)
:hello
iex> flush()
:hello
:ok
链接
在大多数情况下,我们在 Elixir 中生成进程时,会将它们生成为链接进程。在我们展示使用 spawn_link/1
的示例之前,让我们看看使用 spawn/1
启动的进程失败时会发生什么
iex> spawn(fn -> raise "oops" end)
#PID<0.58.0>
[error] Process #PID<0.58.00> raised an exception
** (RuntimeError) oops
(stdlib) erl_eval.erl:668: :erl_eval.do_apply/6
它只是记录了一个错误,但父进程仍在运行。这是因为进程是隔离的。如果我们希望一个进程中的故障传播到另一个进程,我们应该将它们链接起来。这可以通过 spawn_link/1
来完成
iex> self()
#PID<0.41.0>
iex> spawn_link(fn -> raise "oops" end)
** (EXIT from #PID<0.41.0>) evaluator process exited with reason: an exception was raised:
** (RuntimeError) oops
(stdlib) erl_eval.erl:668: :erl_eval.do_apply/6
[error] Process #PID<0.289.0> raised an exception
** (RuntimeError) oops
(stdlib) erl_eval.erl:668: :erl_eval.do_apply/6
由于进程是链接的,我们现在看到一条消息,说父进程(即 shell 进程)从另一个进程接收到了 EXIT 信号,导致 shell 终止。IEx 检测到这种情况并启动一个新的 shell 会话。
还可以通过调用 Process.link/1
手动进行链接。我们建议您查看 Process
模块以了解进程提供的其他功能。
进程和链接在构建容错系统中起着重要作用。Elixir 进程是隔离的,默认情况下不共享任何内容。因此,一个进程中的故障永远不会崩溃或破坏另一个进程的状态。但是,链接允许进程在出现故障时建立关系。我们通常将我们的进程链接到监管者,监管者将在进程死亡时检测到并启动一个新的进程来代替它。
虽然其他语言需要我们捕获/处理异常,但在 Elixir 中,我们实际上可以放任进程失败,因为我们期望监管者能够正确地重启我们的系统。“快速失败”(有时称为“让它崩溃”)是编写 Elixir 软件的常见哲学!
spawn/1
和 spawn_link/1
是在 Elixir 中创建进程的基本原语。虽然到目前为止我们只使用过它们,但大多数情况下我们将使用建立在它们之上的抽象。让我们看看最常见的一个,称为任务。
任务
任务建立在生成函数之上,以提供更好的错误报告和自省
iex> Task.start(fn -> raise "oops" end)
{:ok, #PID<0.55.0>}
15:22:33.046 [error] Task #PID<0.55.0> started from #PID<0.53.0> terminating
** (RuntimeError) oops
(stdlib) erl_eval.erl:668: :erl_eval.do_apply/6
(elixir) lib/task/supervised.ex:85: Task.Supervised.do_apply/2
(stdlib) proc_lib.erl:247: :proc_lib.init_p_do_apply/3
Function: #Function<20.99386804/0 in :erl_eval.expr/5>
Args: []
我们使用 Task.start/1
和 Task.start_link/1
来代替 spawn/1
和 spawn_link/1
,它们返回 {:ok, pid}
而不是仅仅返回 PID。这就是使任务能够在监管树中使用的原因。此外, Task
提供了便利函数,例如 Task.async/1
和 Task.await/1
,以及简化分布的功能。
我们将在 "Mix 和 OTP 指南" 中探索任务和围绕进程的其他抽象。
状态
到目前为止,我们还没有在本指南中谈论过状态。如果您正在构建一个需要状态的应用程序,例如,要保留应用程序配置,或者您需要解析一个文件并将其保留在内存中,您会将其存储在哪里呢?
进程是这个问题最常见的答案。我们可以编写无限循环、维护状态并发送和接收消息的进程。例如,让我们编写一个模块,该模块启动新的进程,这些进程在名为 kv.exs
的文件中充当键值存储
defmodule KV do
def start_link do
Task.start_link(fn -> loop(%{}) end)
end
defp loop(map) do
receive do
{:get, key, caller} ->
send(caller, Map.get(map, key))
loop(map)
{:put, key, value} ->
loop(Map.put(map, key, value))
end
end
end
请注意, start_link
函数启动一个新的进程,该进程运行 loop/1
函数,从一个空映射开始。 loop/1
(私有)函数然后等待消息并对每条消息执行适当的操作。我们通过使用 defp
而不是 def
将 loop/1
设置为私有。对于 :get
消息,它将消息发送回调用者并再次调用 loop/1
,以等待新的消息。而 :put
消息实际上使用新版本的映射调用 loop/1
,并将给定的 key
和 value
存储起来。
让我们通过运行 iex kv.exs
来试一试
iex> {:ok, pid} = KV.start_link()
{:ok, #PID<0.62.0>}
iex> send(pid, {:get, :hello, self()})
{:get, :hello, #PID<0.41.0>}
iex> flush()
nil
:ok
首先,进程映射没有键,所以发送 :get
消息然后刷新当前进程收件箱返回 nil
。让我们发送一个 :put
消息并再次尝试
iex> send(pid, {:put, :hello, :world})
{:put, :hello, :world}
iex> send(pid, {:get, :hello, self()})
{:get, :hello, #PID<0.41.0>}
iex> flush()
:world
:ok
注意进程如何保持状态,我们如何通过向进程发送消息来获取和更新此状态。实际上,任何知道上述 pid
的进程都可以向其发送消息并操作其状态。
也可以注册 pid
,为其指定一个名称,并允许所有知道该名称的人向其发送消息
iex> Process.register(pid, :kv)
true
iex> send(:kv, {:get, :hello, self()})
{:get, :hello, #PID<0.41.0>}
iex> flush()
:world
:ok
使用进程来维护状态和名称注册是 Elixir 应用程序中非常常见的模式。但是,大多数情况下,我们不会像上面那样手动实现这些模式,而是使用 Elixir 附带的众多抽象之一。例如,Elixir 提供了 Agent
,它们是围绕状态的简单抽象。我们上面的代码可以直接写成
iex> {:ok, pid} = Agent.start_link(fn -> %{} end)
{:ok, #PID<0.72.0>}
iex> Agent.update(pid, fn map -> Map.put(map, :hello, :world) end)
:ok
iex> Agent.get(pid, fn map -> Map.get(map, :hello) end)
:world
还可以向 Agent.start_link/2
提供 :name
选项,它将自动注册。除了代理之外,Elixir 还提供了一个用于构建通用服务器(称为 GenServer
)、注册表等的 API,这些都是由底层的进程提供支持的。这些内容以及监管树将在 "Mix 和 OTP 指南" 中更详细地探讨,该指南将从头到尾构建一个完整的 Elixir 应用程序。
现在,让我们继续探索 Elixir 中的 I/O 世界。