Mert Özen Her Satırda Daha İleri
Backend Node.js Temelleri — #01

Node.js Nedir? V8, libuv ve Event-Driven Sunucu Modeli: ASP.NET Core Geliştiricisinin Gözüyle

Mert Özen 14 Kas 2026 14 dk 9 görüntülenme
Node.js Nedir? V8, libuv ve Event-Driven Sunucu Modeli: ASP.NET Core Geliştiricisinin Gözüyle

Node.js, JavaScript'i sunucuda V8 engine'i ve libuv event loop'u ile çalıştıran bir runtime'dır: kodun tek thread'de çalışır, I/O beklemesi thread tutmaz, ama uzun bir senkron hesaplama bütün request'leri bekletir. Gerçek ölçümlerle 500 eşzamanlı beklemenin 227 ms sürmesi, 1,4 saniyelik bir raporun basit bir isteği 1.340 ms bekletmesi, 4 thread'lik libuv pool'u, Kestrel/ThreadPool ile karşılaştırma, Node mu .NET mi tablosu ve Node 27 ile gelen yıllık release takvimi.

Sürüm notu: Bu yazıdaki örnekler Node.js 26.13.1 (LTS, kod adı "Lithium") ile çalıştırılmıştır.

Önce Tahmin Et

Bu seride sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) API'sini Node.js ile kuracağız. İlk yazıda henüz proje yok; önce Node'un nasıl çalıştığını görelim. Aşağıdaki küçük sunucunun üç endpoint'i var: biri kitap listesini hemen döndürüyor, biri veritabanını taklit ederek 100 ms bekliyor, üçüncüsü de 20 milyon satırlık sahte bir fiyat raporu hesaplıyor.

// Bookstore API'sinin küçük bir taklidi: hızlı, I/O bekleyen ve CPU'yu meşgul eden üç endpoint
import { createServer } from "node:http";
import { setTimeout as delay } from "node:timers/promises";

const books = [
    { isbn: "9780441172719", title: "Dune", price: 349.9 },
    { isbn: "9780547928227", title: "The Hobbit", price: 289.5 },
    { isbn: "9780141439600", title: "Middlemarch", price: 310.0 },
];

function json(res, status, body) {
    res.writeHead(status, { "content-type": "application/json" });
    res.end(JSON.stringify(body));
}

const server = createServer(async (req, res) => {
    if (req.url === "/api/books") {
        return json(res, 200, books);
    }
    if (req.url === "/api/books/from-db") {
        // Veritabanı sorgusunu taklit ediyoruz: 100 ms bekleyen I/O, thread'i meşgul etmez
        await delay(100);
        return json(res, 200, books);
    }
    if (req.url === "/api/reports/price-summary") {
        // CPU-bound iş: 20 milyon satırlık sahte bir fiyat raporu
        const started = performance.now();
        let total = 0;
        for (let i = 0; i < 20_000_000; i++) {
            total += Math.sqrt(i) * 1.18;
            total = Number(total.toFixed(2));
        }
        return json(res, 200, { total, ms: Math.round(performance.now() - started) });
    }
    json(res, 404, { error: "not found" });
});

server.listen(3000, () => console.log("listening on http://localhost:3000"));

İkinci bir dosya sunucuya istek atıp süreleri ölçüyor. Önce 500 eşzamanlı "veritabanı" isteği gönderiyor, sonra rapor isteğini başlatıp 50 ms sonra üç kez /api/books çağırıyor:

// server.js'e istek atıp süreleri ölçer
const base = "http://localhost:3000";

async function timed(path) {
    const started = performance.now();
    const res = await fetch(base + path);
    await res.json();
    return (performance.now() - started).toFixed(1);
}

// 1) 500 eşzamanlı "veritabanı" isteği
let started = performance.now();
await Promise.all(Array.from({ length: 500 }, () => timed("/api/books/from-db")));
console.log(`500 concurrent /api/books/from-db: ${Math.round(performance.now() - started)} ms total`);

// 2) Rapor isteği sürerken /api/books
const report = timed("/api/reports/price-summary").then((ms) => console.log(`price-summary: ${ms} ms`));
await new Promise((r) => setTimeout(r, 50));
const fast = await Promise.all([timed("/api/books"), timed("/api/books"), timed("/api/books")]);
console.log(`/api/books while the report runs: ${fast.join(" ms, ")} ms`);
await report;
console.log(`/api/books after the report: ${await timed("/api/books")} ms`);

İki soru: Node'da JavaScript kodunu çalıştıran tek bir thread var. Her biri 100 ms bekleyen 500 istek toplamda ne kadar sürer, 500 × 100 ms = 50 saniye mi? Rapor hesaplanırken gelen /api/books istekleri, yani hiçbir iş yapmadan sabit bir listeyi döndüren istekler, kaç milisaniyede cevap alır?

node labs/01/server.js
node labs/01/probe.js
500 concurrent /api/books/from-db: 227 ms total
price-summary: 1389.2 ms
/api/books while the report runs: 1339.9 ms, 1339.8 ms, 1339.8 ms
/api/books after the report: 0.4 ms

500 bekleme toplamda 227 ms sürdü; tek thread bunları sırayla beklemedi. Ama raporun çalıştığı 1,4 saniye boyunca en basit istek bile 1.340 ms bekledi, rapor bitince aynı istek 0,4 ms'ye düştü. Bu iki sonuç Node'un çalışma modelinin özeti: beklemeyi çok iyi yönetir, hesaplamayı paylaştırmaz. Yazının geri kalanında bunun nedenini, ASP.NET Core'un Kestrel ve ThreadPool modelinden farkını ve Node'u ne zaman seçmen gerektiğini konuşacağız.

Runtime ve Dil: Node.js Tam Olarak Ne?

JavaScript bir dil; kuralları ECMAScript standardında, TC39 (dili geliştiren Ecma komitesi) tarafından yazılıyor. Dil tek başına dosya okuyamaz, socket açamaz, process'in environment variable'larını bilmez. Bunları yapan, dili çalıştıran ortamdır. Tarayıcıda bu ortam DOM'u (sayfanın JavaScript'ten erişilen object modeli), fetch'i ve tarayıcının event loop'unu verir. Node.js ise aynı dili sunucuda çalıştıran bir runtime: JavaScript engine'i, bir event loop, işletim sistemine erişen API'ler ve bunları bir arada tutan C++ kodu.

.NET tarafından bakarsan ayrım tanıdık: C# bir dil, onu çalıştıran CLR (Common Language Runtime; .NET'in kodu çalıştıran sanal makinesi) ve yanındaki base class library ayrı parçalar. Node'da da dil ile runtime ayrı, ama bir farkla: Node'un sürümü, kullanabileceğin dil özelliklerini de belirler. Çünkü dili Node'un içinde gelen engine çalıştırır. Hangi parçaların hangi sürümde geldiğine bakalım:

// Node.js runtime'ının parçalarını ve sürümlerini yazdırır
import { builtinModules } from "node:module";

const { node, v8, uv, openssl, napi, modules } = process.versions;
console.log({ node, v8, uv, openssl, napi, modules });
console.log("lts:", process.release.lts ?? "(not an LTS release)");
console.log("built-in modules:", builtinModules.filter((m) => !m.startsWith("_")).length);
console.log(builtinModules.filter((m) => ["fs", "http", "crypto", "worker_threads"].includes(m)));
{
  node: '26.13.1',
  v8: '14.6.202.34-node.40',
  uv: '1.52.1',
  openssl: '3.5.9',
  napi: '10',
  modules: '147'
}
lts: Lithium
built-in modules: 59
[ 'crypto', 'fs', 'http', 'worker_threads' ]

modules: '147' native addon'ların derlendiği ABI (Application Binary Interface; derlenmiş kodun runtime ile konuştuğu ikili arayüz) sürümü; Node'u major sürüm atlayarak yükselttiğinde native paketlerin yeniden derlenmesi gerekmesinin nedeni bu sayı. Seri boyunca bu sürümleri kullanacağız.

Node'un Parçaları: V8, libuv ve Core Module'ler

V8 (Google'ın Chrome için yazdığı JavaScript engine'i) kodu önce bytecode'a çevirip yorumlar, sık çalışan fonksiyonları çalışma sırasında makine koduna derler; yani bir JIT (Just-In-Time; kodu çalışırken derleyen) derleyicidir. Bellek yönetimi ve garbage collector da V8'in işi. Node 26 ile V8 14.6 geliyor; Temporal API'sinin varsayılan olarak açık olması ve Map.prototype.getOrInsert() gibi yenilikler bu engine güncellemesinden geliyor.

libuv, Node için yazılmış ve sonra bağımsız bir C library'si hâline gelmiş bir katman. İki işi var. Birincisi event loop: timer'ları, socket'leri, dosya işlemlerini izleyip hazır olanların callback'lerini sıraya koyar. İkincisi, işletim sisteminin async karşılığı olmayan işler için küçük bir thread pool. libuv, network I/O için her platformun kendi mekanizmasını kullanır: Linux'ta epoll, macOS'ta kqueue, Windows'ta IOCP (I/O Completion Ports; Windows'un async I/O mekanizması). .NET'in socket'lerde kullandığı mekanizmalarla aynı aileden.

Core module'ler ise JavaScript'ten çağırdığın API'ler: node:fs, node:http, node:crypto, node:worker_threads ve toplam 59 tane. node: prefix'i zorunlu değil ama kullanmanı öneririm; npm'den aynı adla gelen bir paketle karışmaz ve okuyana "bu Node'un kendi module'ü" der. Bunların bir kısmı JavaScript, bir kısmı C++ ile yazılmış ve V8'e bağlanmış. npm, fetch'in arkasındaki HTTP client undici ve OpenSSL de Node'la birlikte gelir.

Tek Thread, Binlerce Bağlantı: Kestrel ile Yan Yana

01-thread-model-tr.png

ASP.NET Core'da Kestrel bağlantıları kabul eder ve her request'in kodunu ThreadPool'dan aldığı bir thread'de çalıştırır. Kod await'e geldiğinde, örneğin bir SQL sorgusunu beklerken, thread ThreadPool'a döner; sorgu bitince devam eden kod herhangi bir boş thread'de sürer. Fark, kodun nerede çalıştığında: .NET'te aynı anda birden fazla request'in kodu, çekirdek sayısı kadar thread'de paralel çalışabilir. Biri CPU'yu bir saniye meşgul ederse diğerleri başka thread'lerde ilerler; ThreadPool gerekirse yeni thread ekler.

Node'da senin JavaScript kodun tek bir thread'de çalışır. Bir request geldiğinde handler çalışır, bir I/O işine gelince işi libuv'a verir ve fonksiyon döner. Veritabanı cevabı gelince ilgili callback sıraya girer ve aynı thread onu çalıştırır. Önce Tahmin Et'teki 500 isteğin 227 ms sürmesi bu yüzden: 500 handler'ın her biri birkaç mikro saniye çalışıp bir timer kurdu ve döndü. 100 ms sonra timer'lar doldu, callback'ler sırayla çalışıp cevapları yazdı. Aradaki sürenin büyük kısmı 500 TCP bağlantısının açılması ve cevapların yazılması.

Bu model bir sınırlama gibi görünse de iki somut avantajı var. Birincisi, paylaşılan state'e erişirken lock gerekmez; bir callback çalışırken başka bir JavaScript kodu araya giremez. İkincisi, bekleyen bir request'in maliyeti çok düşük: bir thread stack'i değil, birkaç object ve bir callback. C# Asenkron Programlama serisinde I/O ve ölçek ilişkisini .NET tarafından anlatmıştık; burada aynı problemi başka bir yoldan çözen bir runtime'a bakıyoruz.

libuv Thread Pool: Dört Thread'in Hikâyesi

"Node tek thread'lidir" cümlesi yarım doğru. JavaScript kodu tek thread'de çalışır, ama Node process'inin içinde başka thread'ler de var. Bunlardan biri libuv'un thread pool'u. Network I/O için işletim sisteminin async mekanizması kullanılır, ama dosya sistemi gibi bazı işlerin taşınabilir bir async karşılığı yoktur. libuv bu işleri pool'daki thread'lerde senkron olarak yapar ve bitince event loop'a haber verir. Sekiz async pbkdf2 çağrısı ile bunu görelim:

// 8 async pbkdf2 çağrısı: hepsi libuv thread pool'unda çalışır
import { pbkdf2 } from "node:crypto";

const started = performance.now();
for (let i = 1; i <= 8; i++) {
    pbkdf2("correct horse", "bookstore-salt", 3_000_000, 32, "sha256", () => {
        console.log(`hash ${i} done at ${Math.round(performance.now() - started)} ms`);
    });
}
console.log(`all 8 calls started in ${Math.round(performance.now() - started)} ms`);
node labs/01/threadpool.js
UV_THREADPOOL_SIZE=8 node labs/01/threadpool.js
all 8 calls started in 0 ms
hash 3 done at 310 ms
hash 1 done at 333 ms
hash 4 done at 358 ms
hash 2 done at 436 ms
hash 5 done at 591 ms
hash 7 done at 656 ms
hash 6 done at 661 ms
hash 8 done at 764 ms
---
all 8 calls started in 0 ms
hash 2 done at 503 ms
hash 3 done at 504 ms
hash 1 done at 516 ms
hash 4 done at 524 ms
hash 6 done at 538 ms
hash 7 done at 592 ms
hash 5 done at 602 ms
hash 8 done at 614 ms

Sekiz çağrı da 0 ms'de başladı, yani ana thread bloklanmadı. Ama varsayılan ayarda sonuçlar iki dalga hâlinde geldi: ilk dördü 310–436 ms arasında, sonraki dördü 591–764 ms arasında. Pool'da dört thread var; beşinci iş, ilk dördünden biri bitene kadar sırada bekledi. UV_THREADPOOL_SIZE=8 ile sekizi birlikte çalıştı ve hepsi 503–614 ms arasında bitti.

Node dokümanına göre pool'u kullanan API'ler şunlar: file watcher'lar ve Sync ile biten fonksiyonlar dışındaki bütün fs API'leri, crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes() gibi async crypto API'leri, dns.lookup() ve senkron olmayan bütün zlib API'leri. Dokümandaki uyarı da önemli: pool sabit boyutlu olduğu için bunlardan biri uzun sürerse, birbiriyle ilgisiz görünen diğer işler de yavaşlar. Boyut process başlarken okunur; kodun içinde process.env.UV_THREADPOOL_SIZE'ı değiştirmenin işe yarayacağı garanti değil, bu yüzden değeri environment variable olarak ver. Windows'ta PowerShell ile $env:UV_THREADPOOL_SIZE=8 yazıp aynı komutu çalıştırırsın (bu satırı Windows'ta denemedim).

CPU-Bound İş Neden Her Şeyi Durdurur?

Önce Tahmin Et'e dönelim. Fiyat raporu, for döngüsünde 20 milyon kez hesap yapan senkron bir kod. Bu kod çalışırken JavaScript thread'i meşgul. /api/books istekleri işletim sisteminin socket buffer'ına ulaştı, libuv bunları fark etti, ama callback'leri çalıştıracak thread rapor bitene kadar boşa çıkmadı. İstekler 1.340 ms bekledi, sonra mikro saniyeler içinde cevaplandı.

ASP.NET Core'da aynı rapor bir ThreadPool thread'ini 1,4 saniye meşgul ederdi, ama diğer request'ler başka thread'lerde çalışmaya devam ederdi. Node'da ise tek bir yavaş handler bütün process'i yavaşlatır. Bu yüzden Node'da kural basit: event loop'u bloklama. CPU yoğun iş varsa ya parçalara bölünüp aralarda event loop'a sıra verilir, ya worker_threads ile ayrı bir thread'e taşınır, ya da bir queue'ya atılıp ayrı bir process'te işlenir. Bunların hepsini 06'da, aynı fiyat raporunu Bookstore API'sine ekleyip ölçerek göreceğiz.

Burada bir yanılgıyı da düzeltelim: fonksiyonu async yapmak onu başka bir thread'e taşımaz. C#'ta CPU işini Task.Run ile ThreadPool'a gönderebilirsin; JavaScript'te async anahtar kelimesinin böyle bir etkisi yok. async bir fonksiyon, ilk await'e kadar çağıranla aynı thread'de, aynı anda çalışır.

Node mu .NET mi? Karar Tablosu

Bu serinin amacı .NET'i bırakmak değil; Node'un iyi olduğu işleri tanıyıp doğru yerde kullanmak. Bookstore özelinde düşündüğümde tablo şöyle çıkıyor:

SenaryoNode.jsASP.NET Core
Angular istemcisi için BFF (backend for frontend; tek bir istemciye özel yazılan ince backend)Güçlü: aynı dil, JSON'u olduğu gibi taşır, çoğu iş başka API'leri beklemekMümkün, ama ekip iki dil arasında gidip gelir
Angular SSR (sunucu tarafında render)Doğal yeri: Angular SSR Node üzerinde çalışırUygun değil
Build script'i, CLI, kod üreticiGüçlü: npm ekosistemi, frontend araçlarıyla aynı runtimeMümkün, dotnet tool olarak
Ağırlıklı I/O yapan ince API, proxy, webhook alıcıGüçlü: az bellekle çok bağlantıGüçlü
Fiyat raporu, PDF, görsel işleme gibi CPU yoğun işZayıf: worker thread ya da ayrı servis gerekirGüçlü: işi thread'lere paylaştırır
Sipariş, stok, kurumsal onay akışı gibi büyük domain modeliYapılır, ama tip sistemi ve araçlar daha az yardımcı olurGüçlü: Entity Framework Core, güçlü tipler, olgun araçlar

Bu seride Bookstore'un ana API'sini Node'da yeniden yazıyoruz, çünkü bir runtime'ı öğrenmenin en iyi yolu tanıdık bir problemi onunla çözmek. Gerçek hayatta Bookstore'un sipariş ve ödeme çekirdeği .NET'te kalır, Node ise BFF, SSR ve tooling tarafında iş görür.

Release Modeli: Current, LTS ve Node 27 ile Gelen Yıllık Takvim

Node'un sürüm modeli bu yıl değişti, o yüzden biraz ayrıntı verelim. Şimdiye kadarki düzende her yıl iki major sürüm çıkıyordu: Nisan'da çift numaralı, Ekim'de tek numaralı. Her major sürüm altı ay Current statüsünde kalıyordu. Tek numaralılar altı ay sonunda destekten çıkıyor, çift numaralılar LTS'e (Long Term Support; uzun süreli destek) geçiyordu. Node 26 bu düzenle çıkan son sürüm: 26.0.0 5 Mayıs 2026'da yayınlandı, 28 Ekim 2026'da 26.13.0 ile LTS oldu ve "Lithium" kod adını aldı. Bu yazıyı denediğim 26.13.1 ise 10 Kasım'da çıkan bir güvenlik sürümü. Release takvimine göre Node 26 Ekim 2027'de bakım moduna geçecek, Nisan 2029'da destekten çıkacak.

Node 27 ile yeni düzen başladı: yılda tek major sürüm. Her sürüm önce altı ay Alpha kanalında (Ekim–Mart) test edilir, sonra Nisan'da Current olarak çıkar, Ekim'de LTS'e geçer ve 30 ay destek alır. Alpha'da breaking change'lere izin var; bu kanal production için değil, library yazarlarının CI'da erken deneme yapması için. Tek/çift ayrımı kalkıyor, her sürüm LTS olacak. Sürüm numarası da yıla bağlanıyor: 27.0.0 2027'de, 28.0.0 2028'de. Bugün nodejs.org'daki tabloda 27 Alpha, 26 (Lithium), 24 (Krypton) ve 22 (Jod) LTS, 20 ve öncesi EOL (End of Life; desteği bitmiş sürüm) durumda. Current statüsünde bir sürüm yok, çünkü 26 LTS'e geçti, 27 ise henüz Alpha'da.

Production kuralı değişmedi ve nodejs.org da bunu açıkça yazıyor: production'da yalnız Active LTS ya da Maintenance LTS sürüm kullan. Bu seri Node 26 LTS üzerine kurulu. Alpha'yı ise library yazıyorsan CI'da ek bir satır olarak çalıştırmak mantıklı.

Deno ve Bun'a tek paragraf: ikisi de Node'a alternatif JavaScript runtime'ları. Deno, Node'un ilk yazarı Ryan Dahl'ın başlattığı, TypeScript'i doğrudan çalıştıran ve varsayılan olarak izin sistemiyle gelen bir runtime; npm paketleriyle uyumluluğu da var. Bun, V8 yerine Safari'nin engine'i JavaScriptCore'u kullanan, package manager, test runner ve bundler'ı tek binary'de toplayan bir araç. İkisi de Node API'lerinin büyük kısmını taklit ediyor. Bu seride Node'da kalıyoruz, çünkü LTS politikası, hosting desteği ve ekosistemi en olgun olan o.

Üretimde Dikkat

Event loop'un durumunu ölç. Node'da gecikmenin en sık nedeni yavaş veritabanı değil, event loop'u meşgul eden bir kod parçası. perf_hooks.monitorEventLoopDelay ile event loop gecikmesini metrik olarak toplamayı 06'da kuracağız; o metrik bir dashboard'da yoksa yukarıdaki 1.340 ms'lik beklemeyi production'da fark etmen zor olur.

Thread pool'u bilerek ayarla. Uygulama yoğun dosya işlemi, bcrypt/pbkdf2 ile şifre doğrulama ya da zlib sıkıştırması yapıyorsa dört thread yetmeyebilir. UV_THREADPOOL_SIZE'ı artırmak bu durumda kuyruğu kısaltır, ama CPU çekirdeği sayısını aşan değerler işleri hızlandırmaz, yalnız sıranın yerini değiştirir. Senin JavaScript kodundaki CPU işini ise hiç etkilemez; o hâlâ ana thread'de.

Sürümü LTS'te tut ve güvenlik sürümlerini takip et. 26.13.1, nodejs.org'da güvenlik sürümü olarak işaretli.

Bir process tek çekirdek kullanır. Çok çekirdekli bir makinede tek Node process'i çekirdeklerin çoğunu boşta bırakır. Ölçeklemeyi ya birden fazla process ile (cluster module, birden fazla container replikası) ya da worker thread'lerle yaparsın; bunu 14'te production hazırlığıyla birlikte konuşacağız.

Sık Düşülen Tuzaklar

"Node tek thread'li, o yüzden sadece bir çekirdek kullanır" ile "Node tek thread'li, o yüzden binlerce bağlantıyı kaldıramaz" ikisi de yanlış. Senin kodun tek thread'de çalışır; I/O beklemesi thread tutmaz, libuv pool'u ve V8'in garbage collector thread'leri ayrıca çalışır.

async fonksiyonu paralel sanmak. async kodu başka thread'e taşımaz; CPU işini Promise içine sarmak da event loop'u kurtarmaz. 06'da bunun ölçümünü göreceğiz.

CPU sorununu UV_THREADPOOL_SIZE ile çözmeye çalışmak. Pool yalnız libuv'a verilen işleri çalıştırır. Kendi yazdığın döngü pool'a gitmez.

Tek numaralı ya da Alpha sürümü production'a koymak. Yeni özellik için Current'a geçmek cazip olabilir, ama destek süresi kısa ve breaking change riski yüksek.

Küçük Bir Deneme

★☆☆ labs/01/threadpool.js'i UV_THREADPOOL_SIZE=2 ile çalıştırmadan önce sonuçların kaç dalga hâlinde geleceğini tahmin et, sonra çalıştırıp karşılaştır.

★★☆ server.js'e /api/books/hash adında, async crypto.pbkdf2 ile 3.000.000 iterasyonluk bir hash hesaplayan bir endpoint ekle. Probe'u bu endpoint ile tekrar çalıştır: hash hesaplanırken /api/books bekliyor mu? pbkdf2Sync kullanınca ne değişiyor?

★★★ Aynı üç endpoint'i bir ASP.NET Core minimal API'si olarak yaz ve aynı probe'u ona çalıştır. Rapor sürerken /api/books süreleri ne oluyor? Rapor isteğini aynı anda 20 kez gönderdiğinde iki runtime nasıl davranıyor?

Node'un nasıl çalıştığını gördük; şimdi sıra makinede düzgün bir kurulum yapmakta. Bir sonraki yazıda Node'u version manager ile kurup Bookstore API projesini başlatacağız: .nvmrc, package.json, node --watch, --env-file ve ilk GET /health endpoint'i.