Setiap request PHP berjalan di dalam satu proses, dan satu proses PHP mengeksekusi kode satu per satu — tidak ada dua fungsi berjalan bersamaan di dalamnya. Ketika kita butuh pekerjaan benar-benar berjalan bersamaan (parallel), jawabannya ada di level sistem operasi: proses terpisah. Worker queue yang kita kenal sejak chapter Memahami Background Jobs
hanyalah satu contoh — PHP memiliki API lengkap untuk memulai proses anak, membicarakan hasil lewat exit code, mengirim sinyal, bahkan membelah diri dengan fork. Chapter ini membedah API tersebut satu per satu, dari proc_open() yang paling sering dipakai sampai jebakan proses zombie, lalu ditutup dengan cara mengawasi worker agar bangun sendiri setelah crash.
Mengapa Proses Terpisah #
Konkurensi di dalam satu proses PHP hanya bisa menyusun ulang urutan menunggu — saat menunggu respons API, thread bisa mengerjakan yang lain, tetapi dua perhitungan CPU tetap tidak bisa berjalan bersamaan. Proses terpisah memberi parallelisme sungguhan: sistem operasi menjadwalkan dua proses PHP pada dua core yang berbeda, masing-masing dengan memori sendiri, dan yang satu crash tidak membawa yang lain mati. Inilah alasan semua worker queue, task scheduler, dan runtime async berbasis C-extension pada akhirnya berdiri di atas proses-proses yang dikelola sistem operasi.
Model ini juga menuntut cara berpikir yang berbeda soal pertukaran data. Dua proses tidak berbagi variabel — hasil komunikasi harus lewat jalur eksplisit: berkas, pipe, atau database. Tampak merepotkan, tetapi justru itu sumber ketahanannya: batas antar proses membuat kegagalan tidak menjalar dan state tidak saling mengotori. Tiga alat komunikasi yang akan kita pakai di chapter ini adalah standar input/output (lewat pipe), exit code (status akhir proses), dan sinyal (pesan singkat antar proses).
proc_open dengan Deskriptor Lengkap #
Fungsi proc_open() adalah cara paling terkendali untuk memulai proses anak. Kekuatannya ada di parameter kedua, descriptorspec: array yang memetakan deskriptor berkas standar anak — 0 untuk stdin, 1 untuk stdout, 2 untuk stderr — ke pipe yang kita pegang di sisi induk. Mari kita jalankan proses PHP sungguhan dan menangkap ketiga kanalnya:
<?php
$command = [PHP_BINARY, "-r", 'echo strtoupper($argv[1]), "\n"; fwrite(STDERR, "pesan diagnostik\n");', "proses anak php"];
$descriptorSpec = [
0 => ["pipe", "r"],
1 => ["pipe", "w"],
2 => ["pipe", "w"],
];
$pipes = [];
$process = proc_open($command, $descriptorSpec, $pipes);
if (!is_resource($process)) {
fwrite(STDERR, "Gagal memulai proses anak\n");
exit(1);
}
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
echo "stdout anak: " . trim($stdout) . "\n";
echo "stderr anak: " . trim($stderr) . "\n";
echo "exit code : {$exitCode}\n";Output yang Dihasilkan:
stdout anak: PROSES ANAK PHP
stderr anak: pesan diagnostik
exit code : 0Mari kita bedah baris-per-barisnya:
$commandberupa array —proc_open()menerima bentuk array sehingga argumen diteruskan apa adanya tanpa perantara shell; spasi dan tanda kutip di argumen tidak perlu di-escape.- Konstanta
PHP_BINARYberisi path interpreter yang sedang berjalan, sehingga contoh ini bekerja di komputer mana pun tanpa menebak lokasiphp. $descriptorSpecmemetakan deskriptor 0, 1, 2 milik anak ke pipe; indeks array adalah nomor deskriptor standar Unix yang diwarisi semua proses.$pipesterisi otomatis berisi ujung induk dari ketiga pipe —[0 => stdin, 1 => stdout, 2 => stderr].fclose($pipes[0])menutup stdin anak segera setelah start — anak yang membaca stdin akan melihat end-of-file, bukan menggantung menunggu input yang tak pernah dikirim.stream_get_contents()membaca seluruh output sampai anak selesai menulis, laluproc_close()mengembalikan exit code sekaligus membebaskan resource proses.
Pemisahan stdout dan stderr inilah yang membuat proc_open() layak dipilih: output normal dan pesan diagnostik tidak tercampur, sehingga program dapat memeriksa hasil kerja sambil mencatat warning ke log.
Kode Keluar sebagai Kontrak Hasil #
Setiap proses Unix mengakhiri hidupnya dengan satu angka: exit code 0 sampai 255. Nilai 0 berarti sukses; nilai lain berarti gagal dengan makna yang ditentukan program itu sendiri. Kontrak sederhana ini adalah bahasa universal antar proses — induk tidak perlu mem-parsing teks untuk tahu anak berhasil atau tidak. Mari kita buktikan ketiga skenario sekaligus:
<?php
function jalankanSnippetPhp(string $kodePhp): array
{
$pipes = [];
$process = proc_open(
[PHP_BINARY, "-r", $kodePhp],
[["pipe", "r"], ["pipe", "w"], ["pipe", "w"]],
$pipes
);
fclose($pipes[0]);
$output = trim((string) stream_get_contents($pipes[1]));
fclose($pipes[1]);
fclose($pipes[2]);
return [$output, proc_close($process)];
}
$snippet = [
["berhasil total", 'echo "selesai\n"; exit(0);'],
["gagal tervalidasi", 'echo "data tidak valid\n"; exit(3);'],
["exception tak tertangkap", 'throw new RuntimeException("kaboom");'],
];
foreach ($snippet as [$nama, $kodePhp]) {
[$output, $exitCode] = jalankanSnippetPhp($kodePhp);
$barisPertama = ($output === "") ? "(tanpa output)" : (string) strtok($output, "\n");
echo "[{$nama}] exit code={$exitCode}, output=\"{$barisPertama}\"\n";
}Output yang Dihasilkan:
[berhasil total] exit code=0, output="selesai"
[gagal tervalidasi] exit code=3, output="data tidak valid"
[exception tak tertangkap] exit code=255, output="Fatal error: Uncaught RuntimeException: kaboom in Command line code:1"Tiga skenario menghasilkan tiga exit code yang berbeda maknanya. exit(0) menandakan keberhasilan penuh; exit(3) adalah kode kegagalan yang kita definisikan sendiri (misalnya “input tidak valid”) dan tetap membawa output yang bisa dibaca; exception yang tidak tertangkap membuat interpreter mati dengan exit code 255 — konvensi PHP untuk fatal error, disertai pesan Fatal error: Uncaught ... di output. Worker yang sehat memeriksa exit code ini setiap kali memanggil proses eksternal, dan pipeline CLI seperti && maupun || di shell juga mengambil keputusan dari nilai yang sama.
exec, shell_exec, system, dan proc_open #
Selain proc_open(), PHP menyediakan tiga fungsi eksekusi yang lebih pendek tetapi lebih terbatas. Semuanya memakai shell sehingga command bisa memakai fitur shell seperti | dan * — sekaligus membuka celah injeksi bila input pengguna ikut dirangkai tanpa escapeshellarg(). Perbandingannya:
| Fungsi | Mengembalikan | Exit code | Kontrol stdin/stderr |
|---|---|---|---|
shell_exec() | Seluruh output sebagai satu string | Tidak tersedia | Tidak ada |
exec() | Baris terakhir (array opsional berisi semua baris) | Ya, lewat parameter | Tidak ada |
system() | Baris terakhir, output juga langsung tercetak | Ya, lewat parameter | Tidak ada |
proc_open() | Pipe penuh yang kita kelola sendiri | Ya, dari proc_close() | Ya |
Perilaku ketiga fungsi pendek tersebut paling jelas terlihat saat dijalankan berurutan pada perintah yang sama:
<?php
$perintah = PHP_BINARY . " -r " . escapeshellarg('echo "baris pertama\n"; echo "baris kedua\n";');
$outputShellExec = shell_exec($perintah);
echo "shell_exec => " . str_replace("\n", " | ", trim((string) $outputShellExec)) . "\n";
$barisOutput = [];
$exitCode = 0;
exec($perintah, $barisOutput, $exitCode);
echo "exec => " . count($barisOutput) . " baris, exit code {$exitCode}\n";
$barisTerakhir = system($perintah, $exitCode);
echo "system => baris terakhir: " . trim((string) $barisTerakhir) . ", exit code {$exitCode}\n";Output yang Dihasilkan:
shell_exec => baris pertama | baris kedua
exec => 2 baris, exit code 0
baris pertama
baris kedua
system => baris terakhir: baris kedua, exit code 0Perhatikan keanehan pada baris system(): kedua baris output muncul tercetak langsung sebelum laporan kita sendiri — system() memang menuliskan output ke stdout proses PHP, lalu hanya mengembalikan baris terakhir sebagai string. Sementara itu shell_exec() mengembalikan seluruh output mentah (termasuk baris barunya) tetapi diam-diam membuang exit code, dan exec() mengumpulkan baris ke array serta exit code ke parameter by-reference. Aturan praktisnya: untuk skrip cepat yang hasilnya tidak kritis, exec() sudah memadai; begitu butuh stderr terpisah, stdin, atau interaksi dua arah, naikkan ke proc_open().
Mengirim dan Menangkap Sinyal #
Exit code adalah pesan dari anak yang sudah mati; sinyal adalah pesan untuk proses yang masih hidup. Unix mendefinisikan sinyal-sinyal standar: SIGTERM meminta proses berhenti dengan sopan (sinyal default perintah kill), SIGINT terkirim saat kita menekan Ctrl+C, SIGKILL memaksakan kematian tanpa bisa ditolak. Ekstensi pcntl memungkinkan proses PHP CLI menangkap SIGTERM dan SIGINT lalu bereaksi — fondasi dari graceful shutdown yang kita simulasikan pada chapter Job Failure Handling
:
<?php
$harusBerhenti = false;
pcntl_signal(SIGTERM, function () use (&$harusBerhenti): void {
$harusBerhenti = true;
});
// kirim sinyal ke proses sendiri, lalu layani sinyal yang tertunda
posix_kill(posix_getpid(), SIGTERM);
pcntl_signal_dispatch();
echo $harusBerhenti
? "SIGTERM tertangani: worker akan selesai setelah job saat ini\n"
: "Tidak ada sinyal yang tertangani\n";Output yang Dihasilkan:
SIGTERM tertangani: worker akan selesai setelah job saat iniMekanismenya tiga tahap: pcntl_signal() memasang signal handler — closure yang mengubah sinyal menjadi sekadar bendera $harusBerhenti, tanpa melakukan pekerjaan berat di dalamnya; posix_kill() mengirim sinyal ke proses tertentu (di sini proses sendiri, untuk keperluan demo yang deterministik); pcntl_signal_dispatch() menjalankan handler dari sinyal yang sedang menunggu. Tanpa handler, SIGTERM akan langsung membunuh proses; dengan handler, worker justru mendapat kesempatan menyelesaikan job yang sedang berjalan, memeriksa bendera di antara job, lalu keluar dengan rapi. Di worker produksi, pemanggilan pcntl_signal_dispatch() diletakkan di setiap putaran loop agar sinyal dilayani secepat mungkin.
Fork: Dua Proses dalam Satu Program #
Kalau proc_open() memulai program baru, pcntl_fork() membelah proses yang sedang berjalan menjadi dua. Satu panggilan, dua nilai kembali: di proses induk pcntl_fork() mengembalikan PID anak, sedangkan di proses anak fungsi yang sama mengembalikan 0 — satu berkas kode, dua proses yang mengeksekusi jalur berbeda:
<?php
$pidAnak = pcntl_fork();
if ($pidAnak === -1) {
fwrite(STDERR, "fork gagal, proses anak tidak dibuat\n");
exit(1);
}
if ($pidAnak === 0) {
// kode di dalam blok ini hanya berjalan di proses anak
$hasil = array_sum([10, 20, 30]);
echo "anak : menghitung array_sum([10, 20, 30]) = {$hasil}\n";
exit(7);
}
// kode setelah blok if hanya berjalan di proses induk
$status = 0;
pcntl_wait($status);
echo "induk: anak selesai dengan exit code " . pcntl_wexitstatus($status) . "\n";Output yang Dihasilkan:
anak : menghitung array_sum([10, 20, 30]) = 60
induk: anak selesai dengan exit code 7Alurnya berjalan rapi: nilai kembalian 0 membawa proses anak ke blok perhitungan, yang berakhir exit(7); induk tidak masuk blok tersebut, melainkan menunggu di pcntl_wait() sampai anak mati, lalu membaca exit code anak lewat pcntl_wexitstatus(). Urutan output pun pasti — anak mencetak dulu karena induk baru melanjutkan setelah pcntl_wait() kembali. Pola fork-then-wait seperti ini adalah cara klasik memparalelkan tugas CPU: induk membelah diri N kali, tiap anak mengerjakan potongan data, induk mengumpulkan exit code seluruh anaknya. Karena anak mendapat salinan memori induk, pertukaran data sesungguhnya tetap harus lewat pipe, berkas, atau database — variabel yang diubah anak tidak terlihat oleh induk.
Proses Zombie dan Tugas pcntl_wait #
Ada satu detail penting yang disembunyikan contoh sebelumnya: fungsi pcntl_wait(). Ketika proses anak mati, kernel tidak serta-merta menghapus proses tersebut — anak yang sudah mati tetapi belum “diambil” induknya menjadi zombie: entri proses tanpa memori, hanya menampung exit code menunggu dibaca. Zombie tidak memakan memori besar, tetapi setiap zombie menempati satu PID, dan sistem operasi memiliki jumlah PID terbatas — worker yang melepas ribuan anak tanpa pcntl_wait() lama-lama tidak bisa memulai proses baru. Mari kita amati kejadiannya secara nyata melalui berkas /proc:
<?php
$pidAnak = pcntl_fork();
if ($pidAnak === 0) {
// proses anak langsung berhenti tanpa melakukan apa pun
exit(0);
}
// sengaja JANGAN memanggil pcntl_wait() dulu: tunggu sampai anak
// benar-benar mati (statusnya menjadi zombie) dengan mengamati /proc
$statusAnak = "?";
for ($i = 0; $i < 2000; $i++) {
$stat = @file_get_contents("/proc/{$pidAnak}/stat");
if ($stat === false) {
break;
}
$setelahKurung = trim(substr($stat, (int) strrpos($stat, ")") + 1));
$statusAnak = $setelahKurung[0];
if ($statusAnak === "Z") {
break;
}
usleep(1000);
}
echo "Status anak sebelum di-reap : {$statusAnak} (Z berarti zombie)\n";
$status = 0;
pcntl_wait($status);
$masihTerdaftar = file_exists("/proc/{$pidAnak}");
echo "Status anak setelah di-reap : " . ($masihTerdaftar ? "masih terdaftar" : "sudah di-reap, entri /proc hilang") . "\n";Output yang Dihasilkan:
Status anak sebelum di-reap : Z (Z berarti zombie)
Status anak setelah di-reap : sudah di-reap, entri /proc hilangBerkas /proc/<pid>/stat milik Linux memuat status proses pada karakter setelah nama program; status Z adalah zombie. Output di atas membuktikan dua fase: sebelum pcntl_wait(), anak yang sudah mati masih terdaftar sebagai zombie; sesaat setelah panggilan itu, kernel me-reap (menyingkirkan) entri prosesnya dan /proc/<pid> lenyap. Tindakan mengambil jasad anak ini disebut reaping, dan itulah tugas pcntl_wait() selain memblokir induk. Jadi aturannya sederhana: setiap pcntl_fork() yang berhasil harus berpasangan dengan pcntl_wait() (atau pcntl_waitpid()) — fork tanpa wait adalah resep kebocoran zombie.
Supervisi Worker dengan Supervisor dan systemd #
Worker daemon hidupnya panjang, dan sesuatu yang hidup lama pasti pernah mati: OOM killer, bug fatal, restart server. Menjalankan worker lewat sesi SSH manual (php worker.php &) berarti worker mati diam-diam tanpa ada yang membangunkannya. Solusi industri adalah process supervisor — program khusus yang menjalankan worker sebagai anaknya, memantau exit code, dan me-restart otomatis. Supervisor, yang umum di ekosistem PHP, dikonfigurasi seperti ini:
; /etc/supervisor/conf.d/worker-pesanan.conf
[program:worker-pesanan]
command=php /var/www/app/worker.php pesanan
autostart=true
autorestart=true
startretries=3
user=www-dataEmpat baris tersebut mengubah sikap hidup worker sepenuhnya: autostart menjalankan worker saat server boot, autorestart membangunkannya setiap kali mati, startretries membatasi percobaan bangkit berturut-turut (melindungi kita dari worker yang crash-loop karena poison message), dan user memastikan worker tidak berjalan sebagai root. Alternatifnya yang berbasis init system modern adalah unit systemd — konsepnya sama: Restart=always pada berkas .service. Kombinasikan supervisor dengan graceful shutdown dari chapter sebelumnya: supervisor mengirim SIGTERM saat berhenti, worker menyelesaikan job saat ini, lalu keluar — restart berikutnya mewarisi queue yang utuh.
Ringkasan #
Proses terpisah adalah unit parallelisme sungguhan di PHP: proc_open() memulainya dengan kendali penuh atas stdin, stdout, dan stderr lewat descriptorspec; exit code menjadi kontrak hasil antar proses; sinyal memungkinkan komunikasi dengan proses hidup hingga graceful shutdown; pcntl_fork() membelah proses untuk pekerjaan CPU paralel; dan pcntl_wait() wajib dipanggil agar anak yang mati tidak menjadi zombie. Semua itu akhirnya dirangkai oleh supervisor agar worker bangkit sendiri setelah crash. Dengan proses sebagai fondasi, kita kini siap melihat lapisan paling atas dunia asinkron PHP — event loop, Fiber, dan runtime async modern — pada chapter penutup section ini.