Node.js Gerçekte Nasıl Çalışıyor? Event Loop Mantığı
Tek JavaScript thread'i ile eşzamanlı I/O nasıl birlikte çalışır? Event loop, microtask, libuv worker pool ve CPU yoğun işleri ölçerek inceleyelim.
Node.js için “tek thread kullanır” ifadesi sık tekrarlanır. Daha doğru ifade, bir JavaScript yürütme bağlamındaki callback'lerin aynı anda tek ana thread üzerinde çalıştığıdır. Runtime, işletim sistemi ve bazı işlemleri yürüten worker pool ise bu basit cümleden daha geniş bir yapıya sahiptir.
Bu ayrımı anlamak, Node.js'in neden çok sayıda ağ isteğini yönetebildiğini ama tek bir ağır hesaplamanın tüm endpoint'leri yavaşlatabildiğini açıklar.
I/O beklemek ile CPU'da çalışmak farklıdır
Bir veritabanı sorgusunu beklerken JavaScript'in işlemciyi sürekli meşgul etmesi gerekmez. Uygun asenkron API, işi başlatır ve tamamlanma sonucunu daha sonra bildirir. Bu arada event loop başka hazır callback'leri çalıştırabilir.
Ancak bir diziyi uzun süre sıralamak, büyük JSON verisini parse etmek veya pahalı bir regex yürütmek aktif CPU işidir. Fonksiyonun başına async yazmak bu işi otomatik olarak başka thread'e taşımaz.
async function calculate() {
// Bu döngü hâlâ çağıran JavaScript thread'inde çalışır.
let total = 0;
for (let i = 0; i < 100_000_000; i++) total += i;
return total;
}
Bu örneği canlı request handler'ına koymak, diğer isteklerin callback'lerinin de beklemesine yol açabilir. Küçük girdide hızlı görünen kodun en kötü durum davranışını düşünmek gerekir.
Event loop'un fazları
Event loop; timer, I/O ve diğer callback türlerini belirli aşamalarda işler. Poll aşaması I/O ile, check aşaması setImmediate ile ilişkilidir. Timer süresinin dolması, callback'in o milisaniyede kesin çalışacağı anlamına gelmez; thread'in uygun hale gelmesi de gerekir.
Node.js ve libuv sürümleri bazı zamanlama ayrıntılarını etkileyebilir. Resmi event loop açıklaması, fazları ve sürüm notlarını birlikte sunar.
Uygulama tasarımını “sıfır milisaniyelik timer kesin önce çalışır” gibi hassas bir sıralama varsayımına bağlamayın. İşi bitiren explicit promise veya callback sözleşmesi, tesadüfi zamanlamadan daha güvenilirdir.
Promise ve nextTick ayrı kuyruk davranışları getirir
Aşağıdaki örneği CommonJS dosyası olarak order.cjs adıyla çalıştırabilirsiniz:
console.log("A");
setTimeout(() => console.log("timer"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
console.log("B");
Bu üst seviye CommonJS örneğinde senkron A ve B önce gelir; ardından nextTick ve promise işleri gözlenir. Timer ile immediate sırasını bu bağlamda garanti kabul etmeyin. ESM değerlendirmesi veya farklı callback bağlamları başka ayrıntılar getirir.
Sınırsız şekilde yeni nextTick veya microtask üretmek, event loop'un I/O'ya geri dönmesini geciktirebilir. Yani “asenkron kuyrukta” olması, işin sistem açısından ücretsiz olduğu anlamına gelmez.
libuv worker pool her şeyi çözmez
Bazı dosya sistemi, kriptografi ve isim çözümleme işlemleri libuv worker pool'dan yararlanır. Ağ I/O'sunun önemli kısmı ise işletim sisteminin non-blocking mekanizmaları üzerinden ilerler. Bütün asenkron işlemlerin bire bir arka plan thread'i oluşturduğunu düşünmek yanlıştır.
Worker pool'un kapasitesi de sınırlıdır. Çok sayıda pahalı iş aynı havuzu doldurursa başka işler bekleyebilir. Havuz boyutunu artırmak, kötü algoritmanın veya aşırı eşzamanlılığın yerine geçen genel bir çözüm değildir. Event loop ve worker pool'u engellememek rehberi bu iki darboğazı birlikte ele alır.
Gecikmeyi ölçmek
Tahmin yerine event loop gecikmesini ölçebilirsiniz:
const { monitorEventLoopDelay } = require("node:perf_hooks");
const delay = monitorEventLoopDelay({ resolution: 20 });
delay.enable();
setInterval(() => {
console.log({
p99_ms: Number(delay.percentile(99) / 1e6).toFixed(2),
max_ms: Number(delay.max / 1e6).toFixed(2),
});
delay.reset();
}, 10_000).unref();
Değerler nanosaniyeden milisaniyeye çevriliyor. Bu parça çalışan bir uygulamaya gözlem eklemek içindir; tek başına çalıştırılırsa unref nedeniyle programın açık kalmasını sağlamaz. Ölçüm API'sinin ayrıntıları perf_hooks belgesinde bulunur.
Gecikmenin yükselmesi CPU işini, garbage collection baskısını veya başka yürütme gecikmelerini düşündürebilir. Tek ölçüm kök nedeni kanıtlamaz; profil ve istek süreleriyle birlikte değerlendirilmelidir.
CPU yoğun işi nasıl ayırırız?
Küçük işleri parçalara bölerek event loop'a fırsat vermek bazı durumlarda yeterlidir. Daha ağır hesaplamalar için worker_threads, ayrı process veya kuyrukla yönetilen worker servisi değerlendirilebilir.
Her istekte yeni worker başlatmak da maliyetlidir. Havuz, eşzamanlılık sınırı ve kuyruk kapasitesi belirleyin. İstemci isteği sona erdiğinde işin iptal edilebilir olup olmadığını düşünün. Kuyruğu sınırsız büyütmek yalnızca CPU problemini bellek ve gecikme problemine taşır.
Bir rapor endpoint'i diğer bütün API'yi yavaşlatıyorsa önce hesaplama bölümünü profilleyin. Sonra işi taşıyın ve aynı yük altında p95/p99 gecikmeyi tekrar ölçün. Node.js performansı, “tek thread hızlı mı?” sorusundan çok her işin hangi kaynağı ne kadar süre meşgul ettiğini anlamakla ilgilidir.
İlgili Yazılar
Node.js ve Express ile İlk REST API’ni Geliştirmek
Node.js ve Express ile bir görev yönetimi API’si geliştirelim: endpoint tasarımı, veri doğrulama, HTTP durum kodları ve merkezi hata yönetimi.
Node.js Uygulamasını Production’a Hazırlamak: Logging, Error Handling ve Security
Çalışan bir Node.js API'sini işletilebilir hale getirelim: yapılandırma doğrulama, hata sınırları, güvenli loglama, kaynak limitleri ve kontrollü kapanma.
Backend Uygulamalarında Authentication: JWT Nasıl Çalışır?
JWT'nin kodlama, imza ve doğrulama katmanlarını ayıralım; issuer, audience, algoritma sınırı, refresh token ve kaynak yetkilendirmesini birlikte tasarlayalım.