檢視原始碼 程序

在 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: []

取代 spawn/1spawn_link/1,我們使用 Task.start/1Task.start_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 取代 def,讓 loop/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

也可以提供 :name 選項給 Agent.start_link/2,它會自動註冊。除了代理之外,Elixir 還提供用於建置通用伺服器(稱為 GenServer)、登錄檔等的 API,這些全部都由底層的程序提供支援。這些,連同監督樹,將在 "Mix and OTP 指南" 中更詳細地探討,該指南將從頭到尾建置一個完整的 Elixir 應用程式。

現在,讓我們繼續探索 Elixir 中的 I/O 世界。