Node.js Event Loop'u Sunucu Tarafında: libuv Phase'leri, process.nextTick, setImmediate ve Event Loop'u Bloklamak
Node.js event loop'u her turda libuv phase'lerinden (pending, poll, check, close, timers) geçer ve her callback'ten sonra önce nextTick, sonra promise queue'sunu boşaltır; bu yüzden I/O callback'i içinde setImmediate, setTimeout(0)'dan önce çalışır. Gerçek ölçümlerle dolu thread pool'un dosya okumayı 371 ms bekletmesi, JSON.parse ve ReDoS ile bloklama, monitorEventLoopDelay ve Bookstore fiyat raporunun inline, parçalı ve worker modlarında /health'i 481, 13 ve 1 ms bekletmesi.
Sürüm notu: Bu yazıdaki örnekler Node.js 26.13.1 (LTS, libuv 1.52.1) ve TypeScript 7.0.2 ile çalıştırılmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) API'sinde bir dosya okunuyor ve okuma bitince callback'in içinde dört ayrı iş sıraya konuyor: 0 ms'lik bir timer, bir setImmediate, bir resolved promise'in then'i ve bir process.nextTick. En sonda da senkron bir console.log var.
import { readFile } from "node:fs";
readFile(import.meta.filename, () => {
setTimeout(() => console.log("setTimeout 0"), 0);
setImmediate(() => console.log("setImmediate"));
Promise.resolve().then(() => console.log("promise.then"));
process.nextTick(() => console.log("process.nextTick"));
console.log("readFile callback");
});
Beş satır hangi sırayla yazılır? Tarayıcı event loop'unu biliyorsan setTimeout 0'ın setImmediate'ten önce geleceğini düşünebilirsin; ikisi de "hemen" demek gibi görünüyor.
node labs/06/guess.ts
readFile callback
process.nextTick
promise.then
setImmediate
setTimeout 0
Senkron kod önce, sonra nextTick, sonra promise, sonra setImmediate ve en son timer. Bu sıra rastgele değil; libuv'un phase'lerinden ve phase'ler arasında boşaltılan iki queue'dan geliyor. Tarayıcının event loop'unu JavaScript İleri serisinin "JavaScript Event Loop Derinlemesine" yazısında anlatmıştık. Bu yazıda sunucu tarafındaki modeli, hangi işin thread pool'a gittiğini, event loop'un nasıl bloklandığını ve nasıl ölçüldüğünü, sonunda da Bookstore'un fiyat raporunu worker thread'e taşımayı görüyoruz.
libuv Phase'leri ve Aradaki Queue'lar

Node'un event loop'u libuv'un döngüsüdür ve her turda sabit bir sırayla phase'lerden geçer. Her phase'in kendi callback queue'su var:
- pending callbacks: bir önceki turdan ertelenmiş bazı I/O callback'leri, örneğin bazı TCP hataları.
- poll: yeni I/O event'lerini toplar ve callback'lerini çalıştırır; socket'ten gelen veri, biten bir dosya okuması. Bekleyecek başka iş yoksa döngü burada durup I/O bekler.
- check:
setImmediate()callback'leri. - close callbacks:
socket.on('close')gibi kapanış callback'leri. - timers: vadesi gelmiş
setTimeoutvesetIntervalcallback'leri.
Sıralamada bir ayrıntı var: libuv 1.45 ile, yani Node 20'den beri, timer'lar her turda poll'dan sonra çalışıyor; daha önce turun başındaydı. Geriye uyumluluk için libuv döngüye girmeden önce timer'ları bir kez çalıştırıyor. Node dokümanındaki diyagram da bunu gösteriyor. idle ve prepare phase'leri de var ama yalnız Node'un iç işleri için.
process.nextTick ve promise callback'leri bu phase'lerin hiçbirinde değil. Node, JavaScript'e her callback çalıştırdığında, callback bittikten sonra önce nextTick queue'sunu, sonra V8'in (Node'un JavaScript engine'i) microtask queue'sunu, yani promise callback'lerini tamamen boşaltır; ancak ondan sonra sıradaki callback'e ya da phase'e geçer. Önce Tahmin Et'teki sıra artık okunabiliyor. readFile'ın callback'i poll phase'inde çalıştı. Bitince nextTick, sonra promise queue'su boşaldı. Döngü poll'dan check'e geçti ve setImmediate çalıştı. Timer en sonda, timers phase'inde çalıştı.
setTimeout(0) mı setImmediate mi?
Bir I/O callback'inin içinden çağrıldıklarında sıra her zaman yukarıdaki gibi: setImmediate önce. Ana module'den çağrıldıklarında ise belirsiz, çünkü sonuç process'in döngüye girdiği andaki zamanlamaya bağlı:
// Ana module'den (bir I/O callback'inin içinden değil): hangisi önce çalışır?
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
for i in $(seq 1 40); do node labs/06/main-race.ts | head -1; done | sort | uniq -c
38 immediate
2 timeout
40 çalıştırmanın 2'sinde timer önce geldi. Koduna bu sıraya güvenen bir varsayım koyma.
nextTick ve Promise: ESM'de Sıra Değişiyor
Promise.resolve().then(() => console.log("promise.then"));
process.nextTick(() => console.log("process.nextTick"));
echo "ESM:"; node labs/06/tick-vs-promise.mjs; echo "CJS:"; node labs/06/tick-vs-promise.cjs
ESM:
promise.then
process.nextTick
CJS:
process.nextTick
promise.then
Aynı iki satır .mjs dosyasında promise'i, .cjs dosyasında nextTick'i önce yazdırıyor. ESM'de (ECMAScript module'lerinde) module'ün kendisi bir promise zincirinin içinde değerlendirildiği için microtask queue, Node nextTick queue'suna dönmeden önce boşalıyor. Pratik ders: iki queue arasındaki sıraya dayanan kod yazma.
Hangi İş Thread Pool'a Gider?
01'de libuv'un 4 thread'lik pool'unu pbkdf2 ile görmüştük. Asıl önemli soru, pool dolduğunda kimin beklediği. Pool'u dört yavaş pbkdf2 ile doldurup aynı anda bir dosya okuyalım, bir DNS çözümlemesi yapalım ve kendi process'imizdeki bir HTTP sunucusuna istek atalım:
// 4 yavaş pbkdf2 pool'u dolduruyor; fs, dns ve network ne yaşıyor?
import { pbkdf2 } from "node:crypto";
import { readFile } from "node:fs/promises";
import { lookup } from "node:dns/promises";
import { createServer } from "node:http";
const server = createServer((req, res) => res.end("ok"));
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as { port: number };
const started = performance.now();
const at = (label: string) => console.log(`${label.padEnd(26)} ${Math.round(performance.now() - started)} ms`);
for (let i = 1; i <= 4; i++) {
pbkdf2("correct horse", "bookstore-salt", 3_000_000, 32, "sha256", () => at(`pbkdf2 #${i}`));
}
await Promise.all([
readFile(import.meta.filename).then(() => at("fs.readFile (thread pool)")),
lookup("localhost").then(() => at("dns.lookup (thread pool)")),
fetch(`http://127.0.0.1:${port}/`).then((r) => r.text()).then(() => at("HTTP request (OS async)")),
]);
server.close();
node labs/06/pool-queue.ts
UV_THREADPOOL_SIZE=8 node labs/06/pool-queue.ts
HTTP request (OS async) 19 ms
pbkdf2 #2 371 ms
fs.readFile (thread pool) 371 ms
dns.lookup (thread pool) 374 ms
pbkdf2 #1 380 ms
pbkdf2 #4 404 ms
pbkdf2 #3 444 ms
---
fs.readFile (thread pool) 4 ms
dns.lookup (thread pool) 6 ms
HTTP request (OS async) 14 ms
pbkdf2 #1 300 ms
pbkdf2 #2 309 ms
pbkdf2 #4 382 ms
pbkdf2 #3 391 ms
Birkaç milisaniyelik bir dosya okuması ve localhost çözümlemesi 371 ms bekledi, çünkü sıraları ancak bir pbkdf2 bitince geldi. HTTP isteği ise 19 ms'de bitti: network I/O pool'u kullanmıyor, işletim sisteminin async mekanizmasıyla yürüyor. Pool'u 8'e çıkarınca dosya ve DNS 4–6 ms'ye indi. Production'daki karşılığı şu: şifre doğrulamada scrypt/pbkdf2 yoğunlaşınca, ilgisiz görünen dosya okumaları ve veritabanı host adının çözümlenmesi yavaşlar. dns.lookup pool'u kullanır; dns.resolve4 gibi fonksiyonlar ise doğrudan network üzerinden sorgu yapar ve pool'a girmez.
Event Loop'u Bloklamak ve Ölçmek
Pool'un dolması yavaşlatır; ana thread'in meşgul olması ise her şeyi durdurur. perf_hooks.monitorEventLoopDelay, belirli aralıklarla bir timer kurup timer'ın ne kadar geç çalıştığını histogram olarak tutar. İki yaygın bloklayıcıyı onunla ölçelim:
// Fark etmeden event loop'u bloklamanın iki yaygın yolu
import { monitorEventLoopDelay } from "node:perf_hooks";
import { pbkdf2Sync } from "node:crypto";
import { setTimeout as delay } from "node:timers/promises";
const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();
async function measure(label: string, work: () => void): Promise<void> {
await delay(50);
histogram.reset();
await delay(50);
const started = performance.now();
work();
const took = performance.now() - started;
await delay(50);
console.log(`${label.padEnd(34)} ${took.toFixed(0).padStart(5)} ms on the main thread, loop delay max ${(histogram.max / 1e6).toFixed(0)} ms`);
}
// 1) Büyük bir JSON payload: 1.000.000 sipariş satırı
const lines = Array.from({ length: 1_000_000 }, (_, i) => ({ orderId: i, isbn: "9780441172719", quantity: 2, unitPrice: 349.9, status: "Paid" }));
const payload = JSON.stringify(lines);
console.log(`payload size: ${(payload.length / 1024 / 1024).toFixed(1)} MB`);
await measure("JSON.parse(payload)", () => JSON.parse(payload));
// 2) Request handler'ında senkron şifre hash'leme
await measure("pbkdf2Sync, 600,000 iterations", () => pbkdf2Sync("correct horse", "bookstore-salt", 600_000, 32, "sha256"));
payload size: 84.8 MB
JSON.parse(payload) 149 ms on the main thread, loop delay max 153 ms
pbkdf2Sync, 600,000 iterations 57 ms on the main thread, loop delay max 68 ms
85 MB'lık bir JSON'u parse etmek 149 ms boyunca başka hiçbir request'in işlenmemesi demek; sipariş export'u gibi büyük gövdeleri stream olarak işlemeyi 09'da göreceğiz. Senkron hash'leme ise tek tek küçük görünür, ama login'de saniyede 50 istek gelince ana thread'in neredeyse tamamını yer. Async pbkdf2 aynı işi pool'da yapar.
Kötü bir RegEx: ReDoS
Üçüncü bloklayıcı en sinsi olanı. Kupon kodlarını doğrulayan şu pattern masum görünüyor, ama iç içe + yüzünden eşleşmeyen bir girdide backtracking sayısı karakter başına katlanıyor. Kullanıcı girdisiyle tetiklenen bu duruma ReDoS (Regular expression Denial of Service; kötü bir RegEx'le sunucuyu meşgul etme saldırısı) denir:
// Savunmasız pattern'de her ek karakter işi kabaca ikiye katlıyor
const vulnerable = /^([A-Z0-9]+-?)+$/;
const safe = /^[A-Z0-9]+(-[A-Z0-9]+)*$/;
// Warm-up: V8 bir RegEx'i birkaç çalıştırmadan sonra makine koduna derler
for (let i = 0; i < 20; i++) {
vulnerable.test("SUMMER-2026!");
safe.test("SUMMER-2026!");
}
for (const length of [20, 22, 24, 26, 28]) {
const input = "BOOKSTORE2026BOOKSTORE2026BOOK".slice(0, length - 1) + "!";
let started = performance.now();
vulnerable.test(input);
const slow = performance.now() - started;
started = performance.now();
safe.test(input);
const fast = performance.now() - started;
console.log(`${String(length).padStart(2)} chars: vulnerable ${slow.toFixed(1).padStart(7)} ms, safe ${fast.toFixed(3)} ms`);
}
console.log(safe.test("SUMMER-2026-BOOKS"), safe.test("SUMMER--2026"));
20 chars: vulnerable 2.3 ms, safe 0.001 ms
22 chars: vulnerable 9.0 ms, safe 0.001 ms
24 chars: vulnerable 35.9 ms, safe 0.002 ms
26 chars: vulnerable 140.6 ms, safe 0.002 ms
28 chars: vulnerable 568.0 ms, safe 0.002 ms
true false
Her iki karakter süreyi dörde katlıyor; aynı oranla 34 karakter yarım dakikayı geçer (bunu ölçmedim, orandan hesapladım). Düzeltilmiş pattern aynı dili kabul ediyor ama tireyi zorunlu bir ayraç yaptığı için belirsizlik kalmıyor. Kullanıcı girdisine uygulanan her RegEx'te iç içe tekrarlara dikkat et ve girdi uzunluğunu RegEx'ten önce sınırla.
Bookstore'da Fiyat Raporu: Inline, Parçalı ve Worker
01'deki fiyat raporunu artık API'ye gerçekten ekliyoruz. Rapor sentetik sipariş satırları üzerinde KDV (katma değer vergisi) dahil ciro ve ortalama birim fiyat hesaplıyor; 20 milyon satır. Aynı hesabı üç yoldan çalıştırabiliyoruz:
// Tek seferde çalışır: dönene kadar event loop bloklanır
export function buildPriceReport(rows: number): PriceReport {
const acc = { revenue: 0, units: 0 };
addRows(acc, 0, rows);
return toReport(rows, acc);
}
// Aynı iş dilimler hâlinde; dilimler arasında event loop'a sıra verilir (check phase)
export async function buildPriceReportChunked(rows: number, chunkSize = 250_000): Promise<PriceReport> {
const acc = { revenue: 0, units: 0 };
for (let from = 0; from < rows; from += chunkSize) {
addRows(acc, from, Math.min(from + chunkSize, rows));
await yieldToLoop();
}
return toReport(rows, acc);
}
yieldToLoop, node:timers/promises'teki setImmediate. Her 250.000 satırda bir await ederek döngünün poll phase'ine, yani bekleyen request'lere sıra veriyor. Üçüncü yol işi ayrı bir thread'e taşımak. worker_threads ile açılan her worker'ın kendi V8 instance'ı ve kendi event loop'u var; ana thread ile mesajlaşarak konuşuyor:
// Kendi V8 isolate'i ve event loop'u olan ayrı bir thread'de çalışır
import { parentPort, workerData } from "node:worker_threads";
import { buildPriceReport } from "#services/priceReport";
const { rows } = workerData as { rows: number };
parentPort!.postMessage(buildPriceReport(rows));
import { Worker } from "node:worker_threads";
import { once } from "node:events";
import type { PriceReport } from "#services/priceReport";
const workerUrl = new URL("../workers/priceReportWorker.ts", import.meta.url);
// Raporu bir worker thread'e verir; ana event loop serbest kalır
export async function buildPriceReportInWorker(rows: number): Promise<PriceReport> {
const worker = new Worker(workerUrl, { workerData: { rows } });
const [report] = (await once(worker, "message")) as [PriceReport];
return report;
}
Worker dosyası da .ts; type stripping worker'larda da çalışıyor ve #services alias'ı aynı package.json'dan çözülüyor. Route, mode query parametresine göre birini seçiyor: GET /api/reports/prices?mode=inline|chunked|worker. API'ye ayrıca event loop gecikmesini döndüren bir diagnostik endpoint ekledim:
import { monitorEventLoopDelay } from "node:perf_hooks";
// Event loop gecikmesini 10 ms'de bir örnekler; değerler nanosaniye cinsinden
const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();
const toMs = (ns: number): number => Math.round(ns / 1e5) / 10;
// Son çağrıdan bu yana gecikmeyi döndürür ve yeni bir ölçüm penceresi başlatır
export function takeEventLoopDelay(): { p50Ms: number; p99Ms: number; maxMs: number } {
const stats = { p50Ms: toMs(histogram.percentile(50)), p99Ms: toMs(histogram.percentile(99)), maxMs: toMs(histogram.max) };
histogram.reset();
return stats;
}
labs/06/probe-report.ts her mod için histogram'ı sıfırlıyor, raporu başlatıyor, rapor sürerken 20 ms'de bir /health çağırıp en yavaş cevabı kaydediyor, sonunda da gecikme histogram'ını okuyor:
node src/server.ts
node labs/06/probe-report.ts
inline report 479 ms | slowest /health 481 ms | loop delay p99 488.6 ms, max 488.6 ms | revenue 19707600000.05
chunked report 472 ms | slowest /health 13 ms | loop delay p99 22.7 ms, max 22.7 ms | revenue 19707600000.05
worker report 503 ms | slowest /health 1 ms | loop delay p99 11 ms, max 11 ms | revenue 19707600000.05
Üç mod da aynı ciroyu yaklaşık aynı sürede hesapladı. Fark, o sürede diğer request'lerin yaşadığında. Inline'da /health 481 ms bekledi ve event loop gecikmesi raporun süresine eşit. Parçalı modda /health en kötü 13 ms bekledi, çünkü her dilim 10–20 ms sürüyor. Worker'da ana thread hiç meşgul olmadı; /health 1 ms'de cevap verdi. Boştaki bir process'te bile gecikmenin 11 ms görünmesi ölçüm çözünürlüğünden geliyor: histogram 10 ms'lik aralıkları ölçüyor. Bir not daha: denemelerimde reset()'ten hemen sonra gelen bir blok bazen histogram'a yansımadı; probe bu yüzden her ölçümden önce 50 ms bekliyor.
Hangisini seçmeli? Parçalı çalışmanın ek altyapısı yok ama işin toplam süresini uzatabilir ve kodu dilimlere ayırmayı gerektirir. Worker, işi gerçekten paralel çalıştırır, ama her worker açılışının ve verinin thread'ler arasında kopyalanmasının bir maliyeti var; sık çalışan işler için her seferinde yeni worker açmak yerine bir worker pool kullanılır. İş saniyeler sürüyorsa ya da tekrar denenmesi gerekiyorsa en doğrusu onu bir queue'ya atıp ayrı bir process'te işlemek; bunu Message Queue'lar ve Background Job'lar serilerinde .NET tarafında anlattık.
Üretimde Dikkat
Event loop gecikmesini metrik olarak topla. p99 gecikme birkaç on milisaniyeyi geçiyorsa bir yerde senkron iş var demektir; latency şikâyeti geldiğinde ilk bakacağın grafik bu olmalı. OpenTelemetry ile bu metriği dışarı aktarmak Observability serisinin konusu.
Senkron API'leri request yolunda kullanma. readFileSync, pbkdf2Sync, zlib.gzipSync ve execSync uygulama açılırken kabul edilebilir, request handler'ında değil.
Büyük JSON'ları sınırla. Request body'sine boyut limiti koy; büyük cevapları tek bir JSON.stringify ile üretmek yerine stream et.
Thread pool'u kullanan işleri izle. Yoğun fs, crypto ya da zlib kullanan bir serviste UV_THREADPOOL_SIZE'ı ölçerek ayarla; pool'u dolduran bir iş, veritabanı bağlantısı kurarken yapılan dns.lookup'ı da bekletir.
Sık Düşülen Tuzaklar
Recursive process.nextTick ile I/O'yu aç bırakmak. nextTick queue'su boşalmadan döngü bir sonraki phase'e geçmez. Kendini 100.000 kez yeniden planlayan bir callback ile 1 ms sonra çalışması gereken bir timer'ı karşılaştırdım:
nextTick done in 12 ms, the 1 ms timer ran after 100,000 calls
setImmediate done in 1237 ms, the 1 ms timer ran after 45 calls
nextTick ile timer, bütün çağrılar bitene kadar hiç çalışamadı; sonsuz bir zincirde hiç çalışmazdı. setImmediate ise her çağrıyı bir sonraki tura bırakıyor, bu yüzden timer 45. çağrıda araya girebildi. Bedeli de görünüyor: her tur bir poll'dan geçtiği için 100.000 çağrı 1,2 saniye sürdü. Kendini tekrar planlayan iş için setImmediate kullan.
"async fonksiyon başka thread'de çalışır" yanılgısı. C#'ta CPU işini Task.Run ile ThreadPool'a gönderirsin; JavaScript'te async kelimesinin böyle bir etkisi yok:
// Async bir fonksiyon da ilk await'ine kadar aynı thread'de çalışır
import { pbkdf2Sync } from "node:crypto";
let ticks = 0;
const interval = setInterval(() => ticks++, 10);
async function hashPasswords(count: number): Promise<number> {
for (let i = 0; i < count; i++) {
pbkdf2Sync(`password-${i}`, "bookstore-salt", 600_000, 32, "sha256");
}
return count;
}
const started = performance.now();
const pending = hashPasswords(10);
console.log(`hashPasswords() returned a promise after ${Math.round(performance.now() - started)} ms, interval ticks so far: ${ticks}`);
await pending;
clearInterval(interval);
hashPasswords() returned a promise after 421 ms, interval ticks so far: 0
Fonksiyon promise'ini ancak 421 ms sonra döndürebildi ve bu sürede 10 ms'lik interval bir kez bile çalışmadı.
setTimeout(fn, 0)'ı "hemen" sanmak. Timers phase'ine kadar bekler ve ana module'de setImmediate ile sırası belirsizdir.
Worker'ı her request'te açmak. Kısa işler için worker açılışı işin kendisinden pahalı olabilir; ölç ve gerekiyorsa pool kullan.
Küçük Bir Deneme
★☆☆ labs/06/guess.ts'teki callback'in içine bir setTimeout'un içinde ikinci bir process.nextTick ekle. Çıktının sırasını çalıştırmadan önce yaz.
★★☆ buildPriceReportChunked'ı chunkSize 50.000 ve 2.000.000 ile çalıştır. Raporun toplam süresi ve en yavaş /health nasıl değişiyor? İkisi arasında nasıl bir denge var?
★★★ buildPriceReportInWorker'ı her çağrıda yeni worker açmak yerine iki worker'lık sabit bir pool kullanacak şekilde yeniden yaz. Probe'u aynı anda beş rapor isteğiyle çalıştırıp iki yaklaşımı karşılaştır.
Event loop'un sırasını gördük; bir sonraki yazıda bu sırayı kullanan asenkron pattern'lere geçiyoruz: callback'ten promise'e geçiş, EventEmitter, AbortSignal ile iptal ve request başına bağlam taşımak için AsyncLocalStorage.