Process berkondisi

Loop penyimpan state yang ditulis secara manual melalui rekursi berekor.

Daftar isi

Sebuah process BEAM dapat menyimpan nilai dari satu pesan ke pesan berikutnya dengan satu pola yang diulang terus-menerus. Pola itu berbentuk fungsi yang menerima state, membaca pesan, lalu memanggil dirinya sendiri dengan state terbaru. Topik ini membahas penulisan loop semacam itu secara manual melalui rekursi berekor (tail recursion), contoh lengkap sebuah penghitung sederhana, serta hubungan pola ini dengan GenServer yang mengotomatiskannya. Hasil akhirnya adalah kemampuan membaca dan menulis process berkondisi (stateful process) tanpa kerangka apa pun.

State dalam argumen rekursi#

Nilai di Elixir tidak dapat diubah (immutable), sehingga tidak ada variabel yang diperbarui nilainya dari waktu ke waktu. Karena itu, state sebuah process hanya dapat berada di satu tempat, yaitu sebagai argumen dari pemanggilan fungsi yang terus berlangsung. Pola dasarnya adalah loop(nilai) yang memanggil loop(nilai_baru) setelah memproses satu pesan, sehingga nilai terbaru selalu dibawa ke pemanggilan berikutnya.

Pola ini bertumpu pada rekursi berekor, yaitu rekursi yang pemanggilannya merupakan tindakan terakhir dalam fungsi. Ketika syarat itu terpenuhi, BEAM mengganti frame tumpukan pemanggilan saat ini dengan frame pemanggilan berikutnya, sehingga pemanggilan berulang tidak menumbuhkan tumpukan sama sekali. Jutaan iterasi pun selesai dengan memori yang tetap:

elixir Contoh: rekursi berekor pada sepuluh juta langkah
defmodule Langkah do
  def jalan(0), do: :selesai
  def jalan(n), do: jalan(n - 1)
end

IO.puts(Langkah.jalan(10_000_000))
text Keluaran
selesai

Perhatikan bahwa jalan(n - 1) berada pada posisi terakhir klausa yang kedua. Apabila pemanggilan rekursif masih diikuti pekerjaan lain, misalnya penjumlahan setelah kembali, rekursi bukan lagi berekor dan tumpukan bertambah pada setiap langkah. Dalam loop process berkondisi, pemanggilan loop(nilai_baru) selalu ditulis sebagai ekspresi terakhir, sehingga process dapat berjalan selamanya tanpa membengkak.

Skrip utuh: penghitung dengan tiga perintah#

Skrip berikut memuat process penghitung yang menerima tiga jenis pesan. Pesan :tambah menaikkan nilai tanpa memberi balasan, pesan {:ambil, pid} mengirimkan nilai terkini kepada pengirimnya, dan pesan :henti mengakhiri loop. Process utama mengirim :tambah dua kali, mengambil hasilnya, lalu menghentikan process penghitung dengan sengaja:

elixir penghitung.exs
# penghitung.exs — state disimpan dalam argumen rekursi berekor
defmodule Penghitung do
  # loop/1 menerima state terkini dan memanggil dirinya sendiri dengan state baru
  def loop(nilai) do
    receive do
      :tambah ->
        loop(nilai + 1)

      {:ambil, pengirim} ->
        send(pengirim, {:nilai, nilai})
        loop(nilai)

      :henti ->
        # loop tidak dipanggil lagi, sehingga process berhenti dengan sendirinya
        :selesai
    after
      5000 -> :waktu_habis
    end
  end
end

induk = self()

pekerja = spawn(fn -> Penghitung.loop(0) end)

send(pekerja, :tambah)
send(pekerja, :tambah)
send(pekerja, {:ambil, induk})

receive do
  {:nilai, angka} -> IO.puts("nilai penghitung: #{angka}")
after
  1000 -> IO.puts("waktu habis")
end

send(pekerja, :henti)
text Keluaran
nilai penghitung: 2

Balasan berupa angka 2 pada keluaran murni merupakan hasil mekanisme pesan, tanpa pengaturan khusus. Process utama mengirim ketiga pesan secara berurutan ke process yang sama, dan BEAM menjamin pesan dari satu pengirim ke satu penerima tiba dalam urutan aslinya. Karena itu, penghitung memproses :tambah pertama, :tambah kedua, baru :ambil, sehingga balasan yang diterima pasti menggambarkan state setelah dua penambahan. Tidak ada penguncian dan tidak ada memori bersama, hanya antrean pesan yang menjamin urutan pengirimannya.

Struktur klausa receive juga menunjukkan konvensi yang sebaiknya diikuti. Pesan tanpa data ditulis sebagai atom, sedangkan pesan yang membawa data ditulis sebagai tuple yang elemen pertamanya atom, misalnya {:ambil, pengirim}. Pesan {:ambil, pengirim} menyertakan pid pengirim, sehingga penghitung tidak perlu mengetahuinya sebelumnya. Setiap klausa yang melanjutkan loop mengakhiri tubuhnya dengan pemanggilan loop(nilai) atau loop(nilai + 1), dan nilai hasil pemrosesan pesan terbawa ke siklus berikutnya.

Menghentikan process dengan sengaja#

Klausa :henti menunjukkan cara satu-satunya bagi loop ini untuk berakhir, yaitu tidak memanggil loop/1 lagi. Ketika receive menyelesaikan tubuh klausa :henti, fungsi loop/1 selesai tanpa pemanggilan rekursif, process kehabisan pekerjaan, dan process itu berhenti dengan alasan normal. Tidak ada perintah khusus yang diperlukan, karena berakhirnya fungsi awal memang berarti berakhirnya process.

Perilaku tersebut membuat loop yang ditulis dengan hati-hati tidak terganggu oleh pesan yang tak dikenal, tetapi pesan yang menumpuk tanpa pernah dibaca tetap memakan memori. Klausa after 5000 pada penghitung menambahkan pengaman kedua. Apabila tidak ada pesan dalam lima detik, receive menghasilkan :waktu_habis, fungsi loop/1 berakhir, dan process berhenti dengan sendirinya tanpa dihentikan dari luar.

Dari loop manual ke GenServer#

Loop manual di atas menjalankan tiga tugas sekaligus, yaitu menyimpan state di argumen rekursi, memilih pesan yang ditangani, dan membalas pid pengirim. Ketiga tugas itu persis yang diotomatiskan oleh GenServer, yaitu behaviour OTP yang menyediakan loop process berkondisi bawaan beserta kontrak callback-nya. Pada GenServer, pemilihan pesan ditulis dalam handle_call/3 dan handle_cast/2, state diteruskan otomatis ke callback berikutnya, dan balasan ke pengirim disusun oleh kerangkanya, sehingga penulis modul hanya menyediakan logika pesannya.

Pemahaman loop manual tetap diperlukan ketika GenServer dipelajari, karena seluruh perilaku callback hanya penamaan yang lebih rapi untuk pola pada topik ini. Fungsi Penghitung.loop/1 pada skrip sebelumnya memiliki peran yang sama dengan handle_call/3 pada GenServer, hanya tanpa pembungkus yang menambahkan penanganan waktu tunggu, pencatatan error, dan integrasi dengan pohon supervision. Setelah pola dasarnya tertanam, modul Inti OTP akan terasa seperti penataan ulang, bukan konsep baru.

Ringkasan#

  • State sebuah process hanya dapat disimpan sebagai argumen pemanggilan rekursi berekor, yang oleh BEAM dieksekusi tanpa menumbuhkan tumpukan.
  • Loop loop(nilai) membaca pesan dengan receive, memprosesnya per klausa, lalu memanggil dirinya dengan state terbaru, dan pesan balasan dikirim ke pid pengirim yang dibawa dalam pesannya.
  • Loop berakhir ketika ada klausa yang tidak lagi memanggil loop/1, sedangkan pesan yang tidak cocok tetap menunggu di mailbox dan tidak menghentikan process.
  • Pola loop manual ini adalah bentuk dasar dari GenServer yang mengotomatiskan penyimpanan state, pemilihan pesan, dan balasannya.

Referensi#