8-任務和gen_tcp

元氣糯米糰子發表於2014-10-31

這章將學習如何使用Erlang的:gen_tcp模組處理請求. 後續章節我們會擴充套件伺服器使之能處理命令. 還提供了一個極好的機會探索Elixir的Task模組

Echo伺服器

首先通過實現一個echo伺服器,開始學習TCP伺服器. 它只是把接收到的文字返回給客戶端. 我們慢慢的改進伺服器直到它能夠處理多個連線.

一個TCP伺服器, 大致會執行如下步驟:

  • 監聽埠並獲得套接字
  • 等待客戶端連線該埠,並Accept.
  • 讀取客戶端請求並回寫響應

下面來實現這些步驟, 轉到apps/kv_server應用程式, 開啟lib/kv_server.ex,新增下面的函式:

def accept(port) do
  # The options below mean:
  #
  # 1. `:binary` - receives data as binaries (instead of lists)
  # 2. `packet: :line` - receives data line by line
  # 3. `active: false` - block on `:gen_tcp.recv/2` until data is available
  #
  {:ok, socket} = :gen_tcp.listen(port,
                    [:binary, packet: :line, active: false])
  IO.puts "Accepting connections on port #{port}"
  loop_acceptor(socket)
end
defp loop_acceptor(socket) do
  {:ok, client} = :gen_tcp.accept(socket)
  serve(client)
  loop_acceptor(socket)
end
defp serve(client) do
  client
  |> read_line()
  |> write_line(client)
  serve(client)
end
defp read_line(socket) do
  {:ok, data} = :gen_tcp.recv(socket, 0)
  data
end
defp write_line(line, socket) do
  :gen_tcp.send(socket, line)
end

呼叫KVServer.accept(4040)啟動伺服器, 4040為埠. 在accept/1中第一步是監聽埠直到獲得一個可用的套接字, 然後呼叫loop_acceptor/1. loop_acceptor/1僅僅是迴圈地接受客戶端連線. 對於每個接受的連線, 呼叫serve/1.

serve/1是另一個迴圈呼叫, 其從套接字讀取一行資料並把讀取到的行寫回套接字. 注意函式serve/1使用管道操作符 |> 來表達操作流.管道操作符對左邊的表示式求值並把結果作為右側函式的第一個引數傳遞. 上面的例子:

socket |> read_line() |> write_line(socket)

等同於:

write_line(read_line(socket), socket)

當使用 |> 操作符時, 由於操作符優先順序的問題, 給函式呼叫新增必要的括號是非常重要的, 特別是, 這段程式碼:

1..10 |> Enum.filter &(&1 <= 5) |> Enum.map &(&1 * 2)

實際上會轉換為:

1..10 |> Enum.filter(&(&1 <= 5) |> Enum.map(&(&1 * 2)))

這不是我們想要的結果, 因為傳遞給Enum.filter/2的函式作為給Enum.map/2的第一個引數傳遞, 解決辦法是使用括號:

# 譯註: 雖然Elixir的函式呼叫通常情況下可以不使用括號,
# 但是為了避免歧義或不必要的問題,建議所有函式呼叫其他語言中必須的括號風格
1..10 |> Enum.filter(&(&1 <= 5)) |> Enum.map(&(&1 * 2))

read_line/1函式實現使用:gen_tcp.recv/2從套接字接收資料, write_line/2使用:gen_tcp.send/2向套接字寫入資料.

使用命令iex -S mixkv_server應用程式中啟動一個iex會話. 在IEx中執行:

iex> KVServer.accept(4040)

伺服器現在開始執行, 控制檯北阻塞. 我們使用 telnet客戶端訪問我們的伺服器. 它在大多數作業系統中都有, 其命令列引數通常類似:

$ telnet 127.0.0.1 4040
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello
hello
is it me
is it me
you are looking for?
you are looking for?

鍵入hello, 並敲擊回車, 你會的到伺服器的hello響應, 太棒了!

我的telnet客戶端可以通過鍵入ctrl + ], quit, 並敲擊 <Enter>退出, 你的客戶端可能有不同的步驟:

退出telnet客戶端後, 你可能會在IEx(Elixir Shell)會話中看到如下錯誤:

** (MatchError) no match of right hand side value: {:error, :closed}
    (kv_server) lib/kv_server.ex:41: KVServer.read_line/1
    (kv_server) lib/kv_server.ex:33: KVServer.serve/1
    (kv_server) lib/kv_server.ex:27: KVServer.loop_acceptor/1

這是因為我們期望從:gen_tcp.recv/2接收資料,但是客戶端關閉了連線. 伺服器後續的版本修訂需要更好的處理這種情況.

現在有一個更重要的Bug要解決: 如果TCP acceptor崩潰會發生什麼? 因為沒有監視程式, 伺服器異常退出並且不能處理更多後續的請求, 因為它沒有重啟. 這就是為什麼必須把伺服器放在監控樹當中.

Tasks

我們已經學習過了代理(Agents), 通用伺服器(Generic Servers), 以及事件管理器(Event Managers), 它們全部是適合處理多個訊息或管理狀態. 但是, 當我們只需要執行一些任務時,我們使用什麼?

Task模組恰好提供了這個功能. 例如, 其有一個start_link/3函式, 其接受一個模組, 函式和引數, 作為監控樹(Supervision tree)的一部分允許我們執行一個給定的函式.

讓我們試一下. 開啟lib/kv_server.ex, 修改start/2中的監控程式為如下:

def start(_type, _args) do
  import Supervisor.Spec
  children = [
    worker(Task, [KVServer, :accept, [4040]])
  ]
  opts = [strategy: :one_for_one, name: KVServer.Supervisor]
  Supervisor.start_link(children, opts)
end

With this change, we are saying that we want to run KVServer.accept(4040) as a worker. We are hardcoding the port for now, but we will discuss ways in which this could be changed later.

現在我們向把KVServer.accept(4040)作為一個worker執行. 現在我們硬編碼了埠號, 但我們將會討論能在以後修改的方法.

現在伺服器作為監控數的一部分, 當執行應用程式的時候它應該自動地啟動. 在終端中鍵入命令mix run --no-halt, 再次使用telnet客戶端驗證一切仍能工作:

$ telnet 127.0.0.1 4040
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
say you
say you
say me
say me

Yes, 仍然可以工作. 如果你殺掉客戶端, 將導致整個伺服器崩潰, 你會看到另一個伺服器程式立即啟動.

同時連線兩個客戶端, 再次測試, 你發現第二個客戶端並沒有echo:

$ telnet 127.0.0.1 4040
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello
hello?
HELLOOOOOO?

這是因為我們在同一個程式中處理接受連線並處理請求. 一個客戶端連線後, 同一時刻就不能在接受其他的客戶端的連線了, 直到之前的請求處理完成.

任務監視器(Task supervisor)

為了使伺服器能處理併發連線, 需要一個程式作為acceptor, 生成(spawns)一個額外的程式來處理請求. 解決辦法是修改下面的程式碼:

defp loop_acceptor(socket) do
  {:ok, client} = :gen_tcp.accept(socket)
  serve(client)
  loop_acceptor(socket)
end

使用 Task.start_link/1, 類似於 Task.start_link/3, 它接受一個匿名函式作為引數, 而非模組,函式,引數:

defp loop_acceptor(socket) do
  {:ok, client} = :gen_tcp.accept(socket)
  Task.start_link(fn -> serve(client) end)
  loop_acceptor(socket)
end

我們已經犯了一次這樣的錯誤. 記得麼?

這個錯誤類似於當我們從registry呼叫KV.Bucket.start_link/0所犯的錯誤. 在任何bucket中的失敗將帶來整個registry當機.

上面的膽碼有同樣的瑕疵: 如果我們連線serve(client)任務到acceptor, 當處理一個請求的時候將導致acceptor崩潰, 結果所有其他的連線,斷開(down)

We fixed the issue for the registry by using a simple one for one supervisor. We are going to use the same tactic here, except that this pattern is so common with tasks that tasks already come with a solution: a simple one for one supervisor with temporary workers that we can just use in our supervision tree!

~~使用一個simple_one_for_one監視程式(supervisor)解決這個問題. 我們將使用相同的策略,except that this pattern is so common with tasks that tasks already come with a solution: 可以在監控樹種使用一個simple_one_for_one監視器和臨時的workers.~~

再次修改start/2, 新增一個監視程式到程式樹:

def start(_type, _args) do
  import Supervisor.Spec

  children = [
    supervisor(Task.Supervisor, [[name: KVServer.TaskSupervisor]]),
    worker(Task, [KVServer, :accept, [4040]])
  ]

  opts = [strategy: :one_for_one, name: KVServer.Supervisor]
  Supervisor.start_link(children, opts)
end

使用名稱KVServer.TaskSupervisor啟動一個Task.Supervisor程式. 記住, 因為acceptor任務依賴此監視程式, 該監視檢查必須首先啟動.

現在只需要修改loop_acceptor/2使用Task.Supervisor來處理每一個請求:

defp loop_acceptor(socket) do
  {:ok, client} = :gen_tcp.accept(socket)
  Task.Supervisor.start_child(KVServer.TaskSupervisor, fn -> serve(client) end)
  loop_acceptor(socket)
end

使用命令mix run --no-halt啟動一個新的伺服器, 然後可以開啟多個併發的telnet客戶端連線. 你還注意到退出一個客戶端後並不會導致acceptor崩潰. 太棒了!

這裡是完整的echo伺服器在單個模組中的實現:

defmodule KVServer do
  use Application

  @doc false
  def start(_type, _args) do
    import Supervisor.Spec

    children = [
      supervisor(Task.Supervisor, [[name: KVServer.TaskSupervisor]]),
      worker(Task, [KVServer, :accept, [4040]])
    ]

    opts = [strategy: :one_for_one, name: KVServer.Supervisor]
    Supervisor.start_link(children, opts)
  end

  @doc """
  Starts accepting connections on the given `port`.
  """
  def accept(port) do
    {:ok, socket} = :gen_tcp.listen(port,
                      [:binary, packet: :line, active: false])
    IO.puts "Accepting connections on port #{port}"
    loop_acceptor(socket)
  end

  defp loop_acceptor(socket) do
    {:ok, client} = :gen_tcp.accept(socket)
    Task.Supervisor.start_child(KVServer.TaskSupervisor, fn -> serve(client) end)
    loop_acceptor(socket)
  end

  defp serve(socket) do
    socket
    |> read_line()
    |> write_line(socket)

    serve(socket)
  end

  defp read_line(socket) do
    {:ok, data} = :gen_tcp.recv(socket, 0)
    data
  end

  defp write_line(line, socket) do
    :gen_tcp.send(socket, line)
  end
end

因為我們已經修改了supervisor規範, 我們需要問: 我們的supervision策略仍然正確麼?

在這種情形下, 答案是yes: 如果acceptor崩潰, 並不會導致現有的連線中斷.從另一方面講, 如果任務監視器(task supervisor)崩潰, 也不會導致acceptor崩潰. 對比registry, 最初每次registry崩潰的時候也會導致supervisor崩潰, 直到使用ETS來對狀態持久化.

相關文章