Supervisor

Child spec dan strategi restart.

Daftar isi

GenServer pada topik sebelumnya berhenti ketika exception terjadi di dalam callback-nya, dan tidak ada yang menghidupkannya kembali. Supervisor adalah process yang tugasnya satu, yaitu mengawasi process anak, mendeteksi setiap kali anak berhenti, lalu menghidupkannya ulang sesuai aturan yang ditetapkan. Topik ini membahas bentuk child spec, cara Supervisor.start_link/2 menerima daftar anak beserta strateginya, serta empat perilaku restart yang didemonstrasikan langsung, yaitu strategi :one_for_one, anak :permanent, anak :temporary, dan strategi :rest_for_one.

Child spec dan daftar anak#

Supervisor menerima daftar anak berupa child spec, yaitu map yang menjelaskan cara memulai sebuah process. Modul yang menggunakan use GenServer sudah menyediakan child spec bawaan yang cukup untuk kebanyakan kasus. Isinya dapat dilihat dengan memanggil child_spec/1 milik modul tersebut:

elixir Contoh: child spec hasil use GenServer
defmodule Pekerja do
  use GenServer

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

IO.inspect(Pekerja.child_spec([]))
text Keluaran
%{id: Pekerja, start: {Pekerja, :start_link, [[]]}}

Child spec bawaan itu sengaja minimal, yaitu hanya memuat :id sebagai penanda anak dan :start berupa tuple modul, fungsi, dan argumen. Sisanya diisi nilai default oleh supervisor pada saat daftar anak diinisialisasi, yaitu restart: :permanent, shutdown: 5000, dan type: :worker. Nilai :restart menentukan apakah anak yang berhenti dihidupkan ulang, sedangkan :shutdown menentukan berapa lama anak diberi waktu berhenti dengan rapi sebelum dibunuh secara paksa. Ketiga nilai itu dapat diganti dengan mendefinisikan ulang child_spec/1 di modul anak dan menggabungkan hasil super(arg) dengan map nilai penggantinya.

Daftar anak menerima bentuk yang fleksibel. Modul yang ditulis langsung pada daftar, misalnya [Pekerja], diubah menjadi child spec melalui Pekerja.child_spec/1 dengan argumen kosong, sedangkan bentuk tuple {Pekerja, arg} meneruskan arg ke child spec yang sama. Fungsi Supervisor.child_spec/2 juga tersedia untuk mengganti nilai tertentu pada child spec, misalnya mengganti id agar modul yang sama dapat digunakan dua kali dalam satu supervisor tanpa bentrok.

Strategi restart#

Supervisor.start_link/2 menerima daftar anak beserta opsi :strategy, yaitu aturan yang menghubungkan kegagalan satu anak dengan restart anak-anak lainnya. Ada tiga strategi dasar:

StrategiYang dihidupkan ulang
:one_for_oneHanya anak yang mati
:one_for_allAnak yang mati beserta seluruh saudaranya
:rest_for_oneAnak yang mati beserta anak-anak yang dimulai setelahnya

Strategi :one_for_one adalah pilihan default ketika anak-anak tidak saling bergantung. Skrip berikut membuktikan perilakunya. Pekerja mencatat ke tabel :ets publik setiap kali process dimulai, karena state di dalam GenServer ikut hilang saat process mati, sedangkan tabel :ets milik process utama tetap bertahan. Anak dibunuh melalui pid yang dicari dari nama terdaftarnya, dan jumlah start diamati sebelum serta sesudahnya:

elixir supervisor.exs
# supervisor.exs — Supervisor dengan strategi :one_for_one menghidupkan ulang anak yang mati
defmodule Tunggu do
  def hingga(_kondisi, 0), do: :waktu_habis

  def hingga(kondisi, sisa_ms) when sisa_ms > 0 do
    if kondisi.() do
      :ok
    else
      Process.sleep(10)
      hingga(kondisi, sisa_ms - 10)
    end
  end
end

defmodule Pekerja do
  use GenServer

  def start_link(_arg) do
    GenServer.start_link(__MODULE__, nil, name: :pekerja)
  end

  @impl true
  def init(_arg) do
    # catat setiap kali process ini dimulai, termasuk saat hasil restart
    :ets.update_counter(:hitungan_start, :pekerja, {2, 1}, {:pekerja, 0})
    {:ok, nil}
  end
end

:ets.new(:hitungan_start, [:named_table, :public])
baca = fn -> :ets.lookup_element(:hitungan_start, :pekerja, 2) end

{:ok, pengawas} = Supervisor.start_link([Pekerja], strategy: :one_for_one, name: :pengawas)

IO.puts("pekerja dimulai: #{baca.()} kali")

# bunuh anak lewat pid yang dicari dari nama terdaftarnya
Process.exit(Process.whereis(:pekerja), :kill)
Tunggu.hingga(fn -> baca.() == 2 end, 2000)

IO.puts("pekerja dimulai: #{baca.()} kali")
IO.puts("pekerja hidup kembali: #{is_pid(Process.whereis(:pekerja))}")
IO.puts("pengawas tetap hidup: #{Process.alive?(pengawas)}")
text Keluaran
pekerja dimulai: 1 kali
pekerja dimulai: 2 kali
pekerja hidup kembali: true
pengawas tetap hidup: true

Penghitung yang berpindah dari 1 ke 2 menunjukkan satu siklus restart yang lengkap, dan init/1 memang dijalankan kembali oleh process penggantinya. Baris terakhir sama pentingnya, karena pengawas tidak ikut mati ketika anaknya dibunuh. Isolasi ini adalah inti :one_for_one, yaitu kegagalan satu process tidak menular ke pengawasnya, sedangkan pemulihannya otomatis tanpa campur tangan process lain.

Nilai :shutdown pada child spec melengkapi aturan restart dengan aturan berhenti. Anak bertipe worker diberi nilai default 5000 milidetik, yaitu waktu yang diberikan supervisor untuk fungsi terminate/2 milik anak sebelum dibunuh paksa, sedangkan supervisor anak menggunakan :infinity karena penghentian pohon harus tuntas. Nilai :brutal_kill memotong seluruh masa tunggu itu dan langsung membunuh anaknya, dan pilihan ini layak untuk process yang tidak memiliki sumber daya untuk dirapikan. Ketiga nilai tersebut berlaku saat aplikasi berhenti maupun saat supervisor menghentikan anak demi strategi :rest_for_one.

Anak permanen dan anak sementara#

Nilai :restart pada child spec menentukan sikap supervisor terhadap anak yang mati. Anak :permanent, yaitu default, selalu dihidupkan ulang dengan alasan berhenti apa pun. Anak :temporary tidak pernah dihidupkan ulang, dan pilihan ini tepat untuk pekerjaan sekali jalan yang hasilnya tidak bernilai lagi setelah processnya mati. Di antara keduanya terdapat :transient, yaitu dihidupkan ulang hanya apabila berhentinya tidak wajar.

Anak :transient mengandalkan pembedaan alasan berhenti yang dianggap wajar. Supervisor memperlakukan :normal, :shutdown, dan {:shutdown, alasan} sebagai berhenti yang terencana, dan ketiga alasan itulah yang diabaikan oleh anak :transient. Alasan selain ketiganya dianggap kegagalan dan memicu restart, sehingga anak :transient cocok untuk process yang boleh menyelesaikan tugasnya lalu berhenti, tetapi tidak boleh mati karena error.

Skrip berikut menempatkan dua anak dengan sikap berbeda dalam satu supervisor, lalu membunuh keduanya. Anak sementara mengganti nilai :restart melalui child_spec/1 miliknya sendiri:

elixir sementara.exs
# sementara.exs — anak :permanent dihidupkan ulang, anak :temporary tidak
defmodule Tunggu do
  def hingga(_kondisi, 0), do: :waktu_habis

  def hingga(kondisi, sisa_ms) when sisa_ms > 0 do
    if kondisi.() do
      :ok
    else
      Process.sleep(10)
      hingga(kondisi, sisa_ms - 10)
    end
  end
end

defmodule PekerjaTetap do
  use GenServer

  def start_link(_arg) do
    GenServer.start_link(__MODULE__, nil, name: :pekerja_tetap)
  end

  @impl true
  def init(_arg) do
    :ets.update_counter(:hitungan_start, :pekerja_tetap, {2, 1}, {:pekerja_tetap, 0})
    {:ok, nil}
  end
end

defmodule PekerjaSementara do
  use GenServer

  def start_link(_arg) do
    GenServer.start_link(__MODULE__, nil, name: :pekerja_sementara)
  end

  # anak sementara: setelah mati, biarkan tetap mati
  def child_spec(arg) do
    Map.merge(super(arg), %{restart: :temporary})
  end

  @impl true
  def init(_arg) do
    :ets.update_counter(:hitungan_start, :pekerja_sementara, {2, 1}, {:pekerja_sementara, 0})
    {:ok, nil}
  end
end

:ets.new(:hitungan_start, [:named_table, :public])
baca = fn kunci -> :ets.lookup_element(:hitungan_start, kunci, 2) end

{:ok, _} =
  Supervisor.start_link([PekerjaTetap, PekerjaSementara], strategy: :one_for_one, name: :pengawas)

# bunuh kedua anak
Process.exit(Process.whereis(:pekerja_tetap), :kill)
Process.exit(Process.whereis(:pekerja_sementara), :kill)

Tunggu.hingga(fn -> baca.(:pekerja_tetap) == 2 end, 2000)

IO.puts("pekerja_tetap dimulai: #{baca.(:pekerja_tetap)} kali")
IO.puts("pekerja_sementara dimulai: #{baca.(:pekerja_sementara)} kali")
IO.puts("pekerja_sementara masih terdaftar: #{is_pid(Process.whereis(:pekerja_sementara))}")
text Keluaran
pekerja_tetap dimulai: 2 kali
pekerja_sementara dimulai: 1 kali
pekerja_sementara masih terdaftar: false

Kedua anak dibunuh dengan cara yang sama, tetapi keluarannya terbelah. Anak permanen mendapat process pengganti sehingga penghitungnya mencapai 2, sedangkan anak sementara tetap berhenti dengan penghitung 1 dan namanya tidak terdaftar lagi. Supervisor tetap mencatat anak sementara yang sudah berhenti sebagai bagian dari daftarnya, tetapi tidak memulai process baru untuknya.

Urutan restart dengan :rest_for_one#

Strategi :rest_for_one menghidupkan ulang anak yang mati beserta seluruh anak yang dimulai setelahnya, dan strategi ini tepat apabila urutan start mencerminkan kebergantungan antar-anak. Skrip berikut memasang tiga anak berurutan, lalu mematikan anak tengah. Anak pertama dibuat tidak bergantung pada dua lainnya, sedangkan anak ketiga dianggap membutuhkan anak tengah agar statenya selalu dihitung dari awal yang sama:

elixir rest_for_one.exs
# rest_for_one.exs — strategi :rest_for_one menghidupkan ulang anak yang mati beserta anak setelahnya
defmodule Tunggu do
  def hingga(_kondisi, 0), do: :waktu_habis

  def hingga(kondisi, sisa_ms) when sisa_ms > 0 do
    if kondisi.() do
      :ok
    else
      Process.sleep(10)
      hingga(kondisi, sisa_ms - 10)
    end
  end
end

defmodule AnakA do
  use GenServer

  def start_link(_arg) do
    GenServer.start_link(__MODULE__, nil, name: :anak_a)
  end

  @impl true
  def init(_arg) do
    :ets.update_counter(:hitungan_start, :anak_a, {2, 1}, {:anak_a, 0})
    {:ok, nil}
  end
end

defmodule AnakB do
  use GenServer

  def start_link(_arg) do
    GenServer.start_link(__MODULE__, nil, name: :anak_b)
  end

  @impl true
  def init(_arg) do
    :ets.update_counter(:hitungan_start, :anak_b, {2, 1}, {:anak_b, 0})
    {:ok, nil}
  end
end

defmodule AnakC do
  use GenServer

  def start_link(_arg) do
    GenServer.start_link(__MODULE__, nil, name: :anak_c)
  end

  @impl true
  def init(_arg) do
    :ets.update_counter(:hitungan_start, :anak_c, {2, 1}, {:anak_c, 0})
    {:ok, nil}
  end
end

:ets.new(:hitungan_start, [:named_table, :public])
baca = fn kunci -> :ets.lookup_element(:hitungan_start, kunci, 2) end

{:ok, _} =
  Supervisor.start_link([AnakA, AnakB, AnakC], strategy: :rest_for_one, name: :pengawas_urutan)

# matikan anak tengah, lalu amati anak mana yang dihidupkan ulang
Process.exit(Process.whereis(:anak_b), :kill)
Tunggu.hingga(fn -> baca.(:anak_b) == 2 and baca.(:anak_c) == 2 end, 2000)

IO.puts("anak_a dimulai: #{baca.(:anak_a)} kali")
IO.puts("anak_b dimulai: #{baca.(:anak_b)} kali")
IO.puts("anak_c dimulai: #{baca.(:anak_c)} kali")
text Keluaran
anak_a dimulai: 1 kali
anak_b dimulai: 2 kali
anak_c dimulai: 2 kali

Keluaran menunjukkan pembagian yang presisi sesuai urutan daftar anak. Anak B yang dibunuh mencapai penghitung 2, dan anak C yang berada setelahnya pula, karena C dihentikan supervisor lalu dimulai ulang bersama B agar urutan startnya kembali utuh. Anak A yang berada sebelum B tidak disentuh sama sekali. Aturan sederhananya, letakkan process yang menyimpan sumber kebenaran di awal daftar, dan letakkan process pengikut yang membutuhkannya setelahnya.

Pemilihan strategi ini menuntut penilaian yang jujur atas kebergantungan antar-anak. Apabila tidak ada anak yang statenya bergantung pada anak lain, :one_for_one lebih tepat karena tidak menghukum anak yang sehat. Apabila seluruh anak saling bergantung pada satu sumber yang sama, :one_for_all lebih jujur daripada menyusun urutan yang rawan salah baca. Urutan daftar anak pada start/2 dengan demikian menjadi bagian dari desain keandalan, bukan sekadar tata letak.

Ringkasan#

  • Supervisor mengawasi process anak melalui child spec yang berisi :id dan :start, dengan nilai restart, shutdown, dan type diisi default oleh supervisor.
  • Strategi :one_for_one hanya menghidupkan ulang anak yang mati, :one_for_all menghidupkan semuanya, sedangkan :rest_for_one menghidupkan anak yang mati beserta anak-anak setelahnya.
  • Anak :permanent selalu dihidupkan ulang, :temporary tidak pernah, dan :transient hanya apabila berhentinya tidak wajar.
  • Restart yang terus-menerus melebihi max_restarts dalam max_seconds membuat supervisor menghentikan dirinya, sehingga kegagalan merambat naik ke pohon di atasnya.

Referensi#