查看源代码 进程

在 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/1spawn_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/1Task.start_link/1 来代替 spawn/1spawn_link/1,它们返回 {:ok, pid} 而不是仅仅返回 PID。这就是使任务能够在监管树中使用的原因。此外, Task 提供了便利函数,例如 Task.async/1Task.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 而不是 defloop/1 设置为私有。对于 :get 消息,它将消息发送回调用者并再次调用 loop/1,以等待新的消息。而 :put 消息实际上使用新版本的映射调用 loop/1,并将给定的 keyvalue 存储起来。

让我们通过运行 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 世界。