[Elixir] GenServer.init関数で重い処理


この記事は Elixir その2 Advent Calendar 2020 18日目です。

前日は、「[Elixir] プロセス使用を検討する基準」でした。

はじめに

さて、Elixirのプロジェクトでは、大抵の場合、Supervisionツリー構造を作りプロセスを管理することになると思います。Supervisorはinit関数の中で、子プロセスリストに「指定された順番」で、「同期的」にプロセスが生成されます。各子プロセスのinit関数が処理している間は、Supervisor.initがプロックされている状態になります。ですので、子プロセスのinit関数は、できる限り重い処理を避けたいです。

ほとんどの場合、子プロセスのinitは初期の状態を準備する軽い処理でしょうが、万が一何らかの理由で重い処理が必要な場合に備えておいたに越したことはありません。

アイデアは「Elixir in Action by Saša Juric」で学んだものです。コードは手作りです。

実験1:子プロセスのinit内で重い処理をする

スタート直後、子プロセスが処理している間、IEXがフリーズしてしまいます。

defmodule MyApp.Supervisor do
  use Supervisor

  def start_link() do
    Supervisor.start_link(__MODULE__, nil)
  end

  @impl true
  def init(_args) do
    children = [
      {MyApp.HelloServer, nil},
    ]

    Supervisor.init(children, strategy: :one_for_one)
  end
end

defmodule MyApp.HelloServer do
  use GenServer

  def start_link(_args) do
    GenServer.start_link(__MODULE__, nil)
  end

  @impl true
  def init(todo_list_name) do
    long_task()
    {:ok, nil}
  end

  defp long_task do
    IO.puts("A long task stated")
    Process.sleep(5000)
    IO.puts("A long task finished")
  end
end
iex> MyApp.Supervisor.start_link()
A long task stated
A long task finished
{:ok, #PID<0.249.0>}

実験2:子プロセスのinitから自分プロセスにメッセージを投げ処理を委譲

スタートしたらすぐに終了する。子プロセスの処理が非同期で処理される。

defmodule MyApp.HelloServer do
  use GenServer

  def start_link(_args) do
    GenServer.start_link(__MODULE__, nil)
  end

  @impl true
  def init(todo_list_name) do
    send(self(), :initialize_state)
    {:ok, nil}
  end

  @impl true
  def handle_info(:initialize_state, state) do
    long_task()
    {:noreply, nil}
  end

  defp long_task do
    IO.puts("A long task stated")
    Process.sleep(5000)
    IO.puts("A long task finished")
  end
end
iex(10)> MyApp.Supervisor.start_link()
A long task stated
{:ok, #PID<0.300.0>}
A long task finished

一点注意が必要です。プロセスをローカル名で登録したい場合、外から誰でもそのプロセスにアクセスできてしまうので、初期化のメッセージが最初という保証がなくなります。
その場合はちょっとしたハックでプロセスの登録を遅らせる事により、解決できます。

defmodule MyApp.HelloServer do
  use GenServer

  #...

  @impl true
  def init(todo_list_name) do
    send(self(), :initialize_state)

    # 初期化後にプロセスを登録することにより、初期化が最初のメッセージであることを保証。
    Process.register(self, __MODULE__)

    {:ok, nil}
  end

  # ...
end

どっちがいい?

非同期でよいのであれば、重い処理を非同期にすればSupervisorの起動を早くできる前者がよいと思われます。ただし、次の子プロセスが前の子プロセスの処理に依存する場合は時間がかかろうが同期されることになると思います。どうでしょうか?

さいごに

今後更に新しいことを学んだら随時内容も更新していこうと思います。


明日は「[Elixir] Supervisorのchild_spec」です。引き続き、Elixirを楽しみましょう。