Event loop adalah mekanisme runtime Node yang menjalankan callback pada satu thread dengan berkeliling antrean fase, sambil menyisipkan microtask di antara fase-fase tersebut. Pemahaman atas urutan fase dan posisi microtask menjadi prasyarat membaca kode async, sebab kesalahan asumsi urutan paling sering berakar pada keyakinan bahwa setTimeout(0) dan setImmediate dapat dipertukarkan. Topik ini memetakan lima fase event loop, membandingkan posisi process.nextTick, promise, setTimeout, dan setImmediate, lalu menjelaskan mengapa operasi yang memblokir thread menunda seluruh antrian dan membunuh throughput server.
Satu thread, banyak antrian#
Node menjalankan kode JavaScript pada satu thread utama yang dikelilingi oleh event loop. Operasi I/O berat seperti baca tulis berkas, panggilan jaringan, dan timer tidak dijalankan oleh thread ini, melainkan didelegasikan ke lapisan libuv atau kernel yang mengembalikan hasil lewat antrean. Thread utama hanya mengambil hasil dari antrean dan menjalankan callback yang sesuai. Karena satu thread memproses seluruh callback, operasi yang menghitung sendiri tanpa menyerahkan kendali akan menahan semua callback lain hingga perhitungan itu selesai.
Setiap callback yang sudah dijadwalkan masuk ke salah satu antrean fase. Antrean microtask, yaitu process.nextTick dan promise reaction, diproses sesudah setiap fase dan tidak menunggu satu putaran penuh. Konsekuensinya, microtask selalu dijalankan lebih dahulu daripada setTimeout maupun setImmediate yang dijadwalkan dalam callback yang sama. Asumsi bahwa pekerjaan yang didaftarkan lebih dahulu pasti berjalan lebih dahulu tidak berlaku pada Node.
Fase-fase event loop#
Satu putaran event loop terdiri atas lima fase yang dilalui berurutan. Diagram berikut memperlihatkan urutan fase dan posisi antrean microtask yang dikuras di sela-sela fase.
- timers: callback
setTimeoutdansetIntervalyang sudah jatuh tempo. - pending callbacks: callback sistem tertunda yang bukan timer, misalnya error TCP atau sisa
fsdari iterasi sebelumnya. - poll: mengambil kejadian I/O baru dan menjalankan callback I/O yang selesai, serta memblokir singkat menunggu I/O bila tidak ada kerjaan.
- check: callback
setImmediateyang didaftarkan pada iterasi yang sama. - close callbacks: kejadian
closedari handle yang ditutup, misalnyasocket.on("close", ...).
Setiap perpindahan fase menjadi titik di mana antrean microtask dikuras. Eksekusi microtask tidak dijadwalkan pada fase tertentu, melainkan dijalankan sampai antrean kosong sebelum runtime kembali ke fase berikutnya. Mekanisme inilah yang membuat process.nextTick dipanggil lebih dahulu daripada setImmediate meskipun setImmediate didaftarkan lebih dahulu dalam kode.
Urutan microtask dan macrotask#
Percobaan pada event-loop-urutan.js membandingkan kode sinkron dengan dua jenis microtask, yaitu process.nextTick dan promise reaction. Urutan microtask bersifat deterministik dan tidak bergantung pada konteks pemanggilan.
console.log("1. sync start");
Promise.resolve().then(() => console.log("3. promise.then"));
process.nextTick(() => console.log("2. nextTick"));
console.log("4. sync end");1. sync start
4. sync end
2. nextTick
3. promise.thenKode sinkron berjalan paling awal tanpa masuk antrean apa pun. Setelah seluruh kode sinkron selesai, antrean microtask dikuras lebih dahulu: process.nextTick didahulukan sebelum promise reaction. Urutan ini tidak bergantung pada konteks pemanggilan karena microtask selalu dikuras di sela fase, bukan pada fase tertentu.
Urutan setTimeout dan setImmediate#
Berbeda dengan microtask, urutan setTimeout(0) dan setImmediate bergantung pada konteks. Pada main script, yaitu kode tingkat atas di luar callback I/O, urutan keduanya tidak deterministik. Node mengonversi setTimeout(0) menjadi batas minimum sekitar satu milidetik, sehingga iterasi pertama event loop dapat melewati atau menjalankan fase timers bergantung pada apakah satu milidetik sudah jatuh tempo. Akibatnya, keluaran dapat berbeda antara satu jalankan dengan jalankan berikutnya.
Di dalam callback I/O, urutan setTimeout dan setImmediate menjadi deterministik. Berkas event-loop-io.js memanggil keduanya dari dalam callback fs.readFile. Setelah fase poll menyelesaikan I/O, event loop langsung dilanjutkan ke fase check yang memproses setImmediate, sebelum kembali ke fase timers pada iterasi berikutnya. Akibatnya, setImmediate selalu mendahului setTimeout pada konteks ini.
const fs = require("fs");
fs.readFile(__filename, () => {
setTimeout(() => console.log("setTimeout di callback I/O"), 0);
setImmediate(() => console.log("setImmediate di callback I/O"));
});setImmediate di callback I/O
setTimeout di callback I/OPola setImmediate sebelum setTimeout di dalam callback I/O menjadi cara yang stabil untuk membedakan konteks main script dari konteks I/O. Asumsi urutan yang diturunkan dari satu konteks tidak boleh dipindahkan begitu saja ke konteks lain.
Pemblokiran membunuh throughput#
Operasi yang memblokir thread utama memaksa event loop berhenti berputar selama durasi blok tersebut. Selama berhenti, semua timer, callback I/O, dan permintaan masuk lain ikut tertunda. Berkas event-loop-blocking.js menjadwalkan timer 50 milidetik, lalu menjalankan perhitungan CPU selama 500 milidetik tanpa menyerahkan kendali.
const urutan = [];
setTimeout(() => urutan.push("timer 50ms"), 50);
function busy(ms) {
const end = Date.now() + ms;
while (Date.now() < end) {}
}
busy(500);
urutan.push("pekerjaan blocking selesai");
setImmediate(() => {
console.log("urutan kejadian:", urutan.join(" -> "));
});urutan kejadian: pekerjaan blocking selesai -> timer 50msTimer 50 milidetik tidak dapat dipanggil tepat waktu karena perhitungan busy(500) menahan event loop selama setengah detik. Timer baru dipanggil setelah pekerjaan selesai, sehingga urutan yang tercatat adalah pekerjaan blocking selesai -> timer 50ms. Pada server HTTP, efek yang sama membuat permintaan dari klien lain ikut tertunda, sehingga throughput efektif jatuh ke kecepatan satu operasi yang memblokir. Solusi yang benar adalah memecah perhitungan menjadi potongan yang menyerahkan kendali lewat setImmediate, atau memindahkan pekerjaan ke worker_threads bila tidak dapat dipecah.
Kesalahan umum#
Menganggap setTimeout(..., 0) selalu berjalan sebelum atau sesudah setImmediate pada main script. Pada main script, urutan keduanya tidak deterministik karena bergantung pada apakah batas minimum satu milidetik setTimeout(0) sudah jatuh tempo saat iterasi pertama event loop mencapai fase timers. Baru di dalam callback I/O urutan menjadi deterministik dengan setImmediate mendahului setTimeout.
Menjadwalkan microtask berat di dalam process.nextTick secara berulang. Setiap nextTick yang dipanggil dari dalam nextTick sebelumnya memperpanjang pengurasan microtask tanpa kembali ke fase poll. Akibatnya, callback I/O yang sudah selesai di kernel tetap menunggu di antrean, dan server terlihat tidak responsif walaupun CPU terlihat sibuk. Pola ini lazim disebut starvation fase I/O.
Ringkasan#
- Event loop berputar melalui lima fase: timers, pending callbacks, poll, check, dan close callbacks.
- Antrean microtask (
process.nextTicklalu promise reaction) dikuras di antara fase, bukan pada fase tertentu, sehingga urutannya deterministik. - Pada main script, urutan
setTimeout(0)dansetImmediatetidak deterministik, tetapi di dalam callback I/O urutannya menjadisetImmediatelalusetTimeoutsecara konsisten. - Operasi sinkron yang panjang memblokir seluruh event loop sehingga timer dan callback I/O ikut tertunda.
Referensi#
- The Node.js Event Loop, Timers, and process.nextTick() — Node.js
— Penjelasan resmi urutan fase event loop dan posisi
process.nextTick. - Timers — Node.js
— API
setTimeout,setImmediate, dan perilaku batas minimum waktu timer. - Concurrency model and the event loop — MDN — Model event loop HTML yang menjadi acuan konseptual microtask dan macrotask.