一个简单的进程
了解 Elixir 中的工作流程。
Elixir 的一个关键特性是将代码打包成小块,这些小块可以独立运行并并发执行。
并发编程众所周知很难,而且创建大量进程会带来性能开销。
但 Elixir 并不存在这些问题,这得益于其运行的 Erlang 虚拟机的架构。Elixir 使用并发的“演员模型”(actor model)。演员是一个独立的进程,与其他任何进程都不共享任何内容。我们可以启动新进程,向它们发送消息,并接收返回的消息。仅此而已。
在过去,我们可能需要使用线程或操作系统进程来实现并发。每次这样做都感觉非常冒险,因为出错的可能性太多。但在 Elixir 中,这种担忧完全消失了。事实上,Elixir 开发者对创建新进程感到非常自如,以至于他们常常在原本应该像 Java 那样创建对象的地方也直接创建进程。
还有一点:当我们谈论 Elixir 中的“进程”时,并不是指原生的操作系统进程。那些太慢且占用资源多。相反,Elixir 使用 Erlang 提供的进程支持。这些进程可以在所有 CPU 上运行(就像原生进程一样),但开销极低。正如我们稍后将看到的,在一台普通的计算机上轻松创建数十万甚至更多的 Elixir 进程是完全可行的,如下图所示:
创建一个简单进程
下面是一个定义了希望作为独立进程运行的函数的模块:
1defmodule SpawnBasic do 2 def greet do 3 IO.puts "Hello" 4 end 5end
是的,就是这样。没有任何特别之处。它只是一个普通的代码。
让我们启动 IEx 并进行实验:
1iex> c("spawn-basic.ex") 2[SpawnBasic]
首先以普通函数方式调用它:
1iex> SpawnBasic.greet 2Hello 3:ok
现在让我们在一个独立进程中运行它:
1iex> spawn(SpawnBasic, :greet, []) 2Hello 3#PID<0.42.0>
spawn 函数启动了一个新进程。它有多种形式,但最简单的两种允许我们运行匿名函数,或在模块中运行命名函数,并传入参数列表。
spawn 函数返回一个进程标识符,通常称为 PID。这个标识符唯一地标识其所创建的进程。该标识符在全球范围内可能是唯一的,但在这里仅在我们的应用程序中唯一。
当我们调用 spawn 时,它会创建一个新进程来运行我们指定的代码。我们不知道它何时会执行,只知道它已具备运行资格。
在这个例子中,我们可以看到函数确实运行了,并输出了 Hello,然后 IEx 才报告 spawn 返回的 PID。但我们不能依赖这一顺序。相反,我们将使用消息来同步进程之间的活动。
在进程之间发送消息
让我们重写示例以使用消息。顶层将发送一条包含字符串的消息给 greet,而 greet 函数则会返回一条包含该消息的问候语。
在 Elixir 中,我们使用 send 函数发送消息。它接受一个 PID 和要发送的消息(一个 Elixir 值,我们也称之为 项)作为参数。我们可以发送任何内容,但大多数 Elixir 开发者倾向于使用原子和元组。
我们使用 receive 等待消息。从某种意义上说,它的作用类似于 case,只不过消息体是参数。在 receive 调用关联的代码块中,我们可以指定任意数量的模式及其对应的动作。与 case 一样,第一个匹配的模式所关联的动作会被执行。
该函数使用 receive 等待消息,然后在代码块中匹配消息。在此例中,唯一的模式是一个两元素元组,第一个元素是原始发送者的 PID,第二个是消息本身。在对应的动作中,我们使用 send sender, ... 将格式化的字符串发回给原始发送者。我们将该字符串包装成一个元组,其中 :ok 作为第一个元素。
在模块外部,我们调用 spawn 创建一个进程,并发送一个元组:
send pid, { self, "World!" }。
函数 self 返回调用者的 PID。这里我们用它将我们的 PID 传给 greet 函数,以便它知道应将响应发送到哪里。
然后我们等待响应。注意我们对 {:ok, message} 进行了模式匹配,从中提取元组的第二个元素,即实际文本内容。
我们可以在 IEx 中运行这段代码:
1iex> c("spawn1.exs") 2Hello, World! 3[Spawn1]
非常棒。文本被发送,greet 成功返回了完整的问候语。
运行 c("spawn1.exs") 命令以执行以下代码:
No configuration found for ID: b33ef84ec2c87052ddeac19c8d516c5beabd09a62981f7e7a8759c14ca5c48de
处理多个消息
让我们尝试发送第二条消息。
我们在 IEx 中运行:
1iex> c("spawn2.exs") 2Hello, World! 3.... 一直卡住 ....
第一条消息被返回了,但第二条消息却不见踪影。更糟的是,IEx 完全卡住,我们必须使用 ^C(“Control-C”组合键)才能退出。
运行 c("spawn2.exs") 命令以执行以下代码:
No configuration found for ID: e1fd07613fe879df7873b97adbc8af13811e4e6fb7af925c4ca1c525a8856636
这是因为我们的 greet 函数只处理一条消息。一旦处理完 receive,它就会退出。因此,我们发送的第二条消息永远不会被处理。顶层的第二个 receive 也会一直挂起,等待永远无法到来的响应。
至少我们要解决卡住的问题。我们可以告诉 receive,如果在指定毫秒数内未收到响应,则触发超时。这使用了一种伪模式,称为 after。
1iex> c("spawn3.exs") 2Hello, World! 3... 短暂暂停 ... 4问候者已离开 5[Spawn3]
运行 c("spawn3.exs") 命令以执行以下代码:
No configuration found for ID: ad2a00b713649b41547453eeb775e4ffa7cd8573b3b42bc0da72a06978c4df03
但如何让我们的 greet 函数处理多个消息呢?我们自然的想法是让它循环,在每次迭代中执行 receive。Elixir 没有循环,但它有递归。
当我们运行这段代码时,两条消息都被成功处理了:
1iex> c("spawn4.exs") 2Hello, World! 3Hello, Kermit! 4[Spawn4]
运行 c("spawn4.exs") 命令以执行以下代码:
No configuration found for ID: ffc3f8ceda7a19ba94f1b57fff19186f803dffff1cb4f95b4dc68833f03ce98f
递归、循环与栈
greet 函数可能让人感到压力。每次接收到消息时,它都会调用自身。在许多语言中,这会在栈上添加一个新的帧。当接收到大量消息后,可能会耗尽内存。
但在 Elixir 中不会发生这种情况,因为它实现了尾调用优化。如果一个函数最后一步是调用自身,那么就不需要真正调用。运行时只需跳回到函数开头即可。如果递归调用带有参数,这些参数将替换原来的参数。但请注意:递归调用必须是最后执行的那一步。例如,下面的代码就不是尾递归:
1def factorial(0), do: 1 2def factorial(n), do: n * factorial(n-1)
虽然递归调用在物理上是函数中的最后一行,但它并不是最后执行的操作。函数还需要将返回值乘以 n。为了使其成为尾递归,我们需要把乘法操作移到递归调用中,这意味着需要引入一个累加器:
1defmodule TailRecursive do 2 def factorial(n), do: _fact(n, 1) 3 defp _fact(0, acc), do: acc 4 defp _fact(n, acc), do: _fact(n-1, acc*n) 5end