Skip to content

Bölüm 5 / 13

Stream'ler ve backpressure

Büyük veriyi belleği patlatmadan işlemek.

9 dakikalık okuma

Hattın tanıtımındaki "zor kısmı derin" sözünün ikinci ödemesi. 4. derste readFile'ın dosyanın tamamını belleğe aldığını not etmiştik — 2 GB'lık bir log dosyasında bu, sürecin ölümüdür. Stream, Node'un buna cevabıdır: veriyi parça parça, bellek tavanını sabit tutarak akıtmak. Bu ders dört stream türünü, pipe ile pipeline arasındaki hata farkını (üretimde kaynak sızıntısıyla ödenen fark), backpressure mekanizmasını ve HTTP istek/yanıtının zaten birer stream olduğunu işler — bellek kanıtını kendi gözünle sayıyla göreceksin.

01Ne öğreneceksin

  • Stream'in gerekçesi: sabit bellekle sınırsız veri — readFile'a karşı bellek kanıtı
  • Dört tür: Readable / Writable / Duplex / Transform ve gerçek hayattaki örnekleri
  • pipe vs pipeline: hata iletmeyen zincir neden kaynak sızdırır; doğru varsayılan
  • Backpressure: hızlı üretici + yavaş tüketici; write()'ın false'u, drain, highWaterMark
  • HTTP istek/yanıt birer stream: büyük dosyayı akıtmak, gzip Transform'u

02Gerekçe ve dört tür

Problemi sayıyla koy: 2 GB'lık bir CSV'yi readFile ile okursan sürecin belleğinde 2 GB'lık tek bir Buffer oluşur — V8'in ve makinenin tavanına çarparsın; on kullanıcı aynı anda dosya indirirse sunucu ölür. Oysa işin çoğu "oku → işle → yaz" biçimindedir ve hiçbir adım tüm veriyi aynı anda görmek zorunda değildir. Stream bu gözlemin kurumsallaşmasıdır: veri, varsayılan 64 KB'lık parçalar (chunk) hâlinde akar; bellekte aynı anda yalnız birkaç parça yaşar. Bu, 2. hattın bellek dersindeki "çalışma kümesini küçük tut" ilkesinin I/O hâlidir.

Readable    veri ÜRETİR      fs.createReadStream, HTTP isteği (sunucuda), process.stdin
Writable    veri TÜKETİR     fs.createWriteStream, HTTP yanıtı, process.stdout
Duplex      iki yön birden   TCP soketi (hem okur hem yazar)
Transform   akarken DÖNÜŞTÜRÜR  zlib.createGzip, şifreleme, satır ayrıştırıcı

Hepsi EventEmitter'dır (4. ders): Readable data ve end yayınlar, Writable finish, herkes error. Modern kodda olayları elle dinlemek yerine iki üst düzey araç kullanılır — ama hangisini seçtiğin, üretimde ciddi fark yaratır.

03pipe ve pipeline: hata farkı

Akış kurmanın klasik yolu pipe'tır — 0. hattın terminal dersindeki boru fikrinin birebir kod hâli:

import fs from "node:fs";
import zlib from "node:zlib";

fs.createReadStream("basvurular.csv")
  .pipe(zlib.createGzip())
  .pipe(fs.createWriteStream("basvurular.csv.gz"));   // çalışır... güneşli günde

Tuzak: pipe, hataları zincirde iletmez. Okuma stream'i error yayınlarsa (dosya silindi, disk hatası) yazma stream'i bundan haberdar olmaz — dosya tanıtıcısı açık kalır, yarım dosya diskte kalır; üstelik 4. dersin kuralı gereği dinleyicisiz error süreci düşürür. Doğru pipe kullanımı her halkaya ayrı error dinleyicisi + elle temizlik ister — kimse bunu eksiksiz yazmaz. Bu yüzden modern varsayılan pipeline'dır:

import { pipeline } from "node:stream/promises";

try {
  await pipeline(
    fs.createReadStream("basvurular.csv"),
    zlib.createGzip(),
    fs.createWriteStream("basvurular.csv.gz"),
  );
  console.log("tamamlandı");
} catch (hata) {
  console.error("akış başarısız:", hata);   // TEK yakalama noktası
}

pipeline üç şeyi üstlenir: herhangi bir halkadaki hata tek noktadan fırlar; hata anında bütün halkalar kapatılıp kaynaklar temizlenir; promise sürümü await'e oturur (6. dersin diline). Kural net: iki stream'i birleştiren her yerde varsayılan pipeline'dır; pipe'ı ancak zinciri canlı tutup dallandırdığın özel durumlarda ve hata yönetimini bilinçle üstlenerek kullanırsın. Mülakat sorusu da tam bu cümledir: "pipe ile pipeline farkı ne?" — cevap hata ve temizliktir, sözdizimi değil.

04Backpressure: yavaş tüketiciye saygı

Sabit bellek vaadi kendiliğinden gerçekleşmez. Senaryo: SSD'den 500 MB/s okuyan bir Readable, 50 MB/s yazabilen bir ağ bağlantısına akıyor. Üretici tüketiciden 10 kat hızlıysa aradaki fark bir yerde birikmek zorundadır — ve o yer, önlem yoksa, süreç belleğidir: vaat çöker. Backpressure (geri basınç) bu birikimi durduran sözleşmedir:

const olur = hedef.write(parca);   // false ⇒ iç tampon doldu (highWaterMark aşıldı)
// false gördüğünde YAZMAYI DURDUR; tampon boşalınca hedef "drain" olayı yayınlar:
hedef.once("drain", devamEt);      // şimdi devam edebilirsin

Mekanizma üç parçadır: her Writable'ın bir iç tamponu ve highWaterMark eşiği vardır (varsayılan 64 KB — bu bir öneri eşiğidir, sert sınır değil: write veriyi yine kabul eder, sadece false ile "yavaşla" der); false dönen write "dur" işaretidir; drain olayı "devam et"tir. İyi haber: pipe ve pipeline bu protokolü senin yerine işletir — false gördüğünde kaynağı duraklatır, drain'de sürdürür. Kötü haber: elle write döngüsü yazan herkes bu sözleşmeyi atlayabilir — dönüş değerine bakmadan milyon satır write eden bir döngü, tüm veriyi Writable'ın tamponuna yığar; "stream kullanıyorum ama bellek yine şişiyor" vakasının bir numaralı sebebi budur. Refleks: elle write yazıyorsan dönüş değeri okunur; okuyamıyorsan zaten pipeline kullanmalıydın.

05HTTP zaten stream'dir

4. derste POST gövdesini "parça parça gelir" diye geçmiştik; artık adını koyabiliriz: sunucuda istek nesnesi bir Readable, yanıt nesnesi bir Writable'dır. Bu, iki güçlü deseni bedavaya verir. (1) Büyük yükleme, belleğe girmeden diske:

const sunucu = http.createServer(async (istek, yanit) => {
  if (istek.method === "POST" && istek.url === "/yukle") {
    await pipeline(istek, fs.createWriteStream("gelen.csv"));   // istek → disk
    yanit.writeHead(201); yanit.end("alındı");
  }
});

Gövde ister 1 KB ister 10 GB olsun süreç belleği sabittir; backpressure sayesinde yavaş disk, hızlı istemciyi TCP düzeyinde bile yavaşlatır. (2) Büyük indirme + akarken dönüşüm: yanıt Writable olduğuna göre dosyayı ona pipeline'larsın; araya bir Transform koyarak sıkıştırma da eklenir:

await pipeline(
  fs.createReadStream("rapor-buyuk.json"),
  zlib.createGzip(),                       // Transform: akarken sıkıştır
  yanit,                                   // Writable: HTTP yanıtı
);   // öncesinde: yanit.writeHead(200, { "content-encoding": "gzip" })

6. hattın önbellek/header bilgisi burada birleşir: content-encoding tarayıcıya "açarak oku" der. Aynı desen 19. hatta mesaj akışlarında, 21. hatta LLM yanıtlarının token token akıtılmasında (streaming) yeniden karşına çıkacak — fikir hep aynıdır: tamamını bekletme, akıt. Sınır çizgisi de bilinçli olsun: küçük ve sınırlı veri (birkaç KB'lık JSON gövdesi) için stream törenine gerek yoktur — express.json() gibi araçlar gövdeyi toplar, limitler ve parse eder; stream, boyutu öngörülemeyen verinin aracıdır.

06Mini alıştırma — Belleği sayıyla kanıtla

Yaklaşık 20 dakika · çekirdek modüller, paket yok

  1. Büyük dosya üret: bir Writable aç, döngüyle ~200 MB'lık satır yaz (write'ın dönüşünü kontrol edip drain bekleyerek — backpressure'ı ilk elden uygula). İpucu: once(hedef, "drain") (node:events) await'lenebilir.
  2. Kopyalama 1: readFile + writeFile ile kopyala; bitince process.memoryUsage().rss yazdır. Kopyalama 2: aynı işi pipeline(createReadStream, createWriteStream) ile yap, yine RSS yazdır. İki sayıyı yan yana koy — bellek kanıtın bu.
  3. Zincire zlib.createGzip() ekleyip .gz üret; ls -lh ile boyut farkına bak.
  4. Hata farkı deneyi: kaynak olarak olmayan bir dosya ver. Önce pipe zinciriyle dene — sürecin nasıl davrandığını ve hedef dosyanın diskte yarım/boş kaldığını gözle. Sonra pipeline ile dene — hatanın tek catch'e düştüğünü gör. İki davranışı ikişer cümleyle kıyasla.
  5. HTTP kanıtı: bölüm 05'teki indirme sunucusunu kur, büyük dosyayı curl -o /dev/null localhost:3000/rapor ile çek; bu sırada ikinci terminalden RSS'i izle (watch ya da aralıklı log). Sabit kaldığını gör.

07Kendini sına

Stream'in çözdüğü problemi sayısal bir senaryoyla anlat; dört türü birer gerçek örnekle eşleştir ve stream'in gerekmediği durumu söyle.

Problem bellek tavanıdır: 2 GB'lık dosyayı readFile ile okumak, sürecin belleğinde 2 GB'lık tek Buffer üretir — birkaç eşzamanlı istekte süreç ölür. Oysa "oku → işle → yaz" işlerinin hiçbir adımı tüm veriyi aynı anda görmek zorunda değildir; stream veriyi varsayılan 64 KB'lık parçalarla akıtır, bellekte aynı anda yalnız birkaç parça yaşar — sınırsız veri, sabit bellek. Dört tür: Readable üretir (createReadStream, sunucudaki HTTP isteği, stdin); Writable tüketir (createWriteStream, HTTP yanıtı, stdout); Duplex iki yönlüdür (TCP soketi); Transform akarken dönüştürür (zlib.createGzip, şifreleme, satır ayrıştırıcı). Hepsi EventEmitter olduğundan error hijyeni (4. ders) burada da geçerlidir. Gerekmediği yer: boyutu küçük ve sınırlı veri — birkaç KB'lık JSON gövdesini stream töreniyle işlemek gereksiz karmaşıklıktır; gövde toplayıcılar (express.json) yeter. Stream, boyutu öngörülemeyen verinin aracıdır.

"pipe ile pipeline arasındaki fark sözdizimi değildir" — savun: hata anında her biri ne yapar, sızıntı nerede doğar, doğru varsayılan nedir?

pipe yalnız veri akışını bağlar; hataları zincirde iletmez. Kaynak stream error yayınladığında hedef bundan habersizdir: dosya tanıtıcısı açık kalır, diskte yarım dosya durur — kaynak sızıntısı; üstelik o halkada error dinleyicisi yoksa, dinleyicisiz error kuralı gereği süreç çöker. pipe'ı doğru kullanmak her halkaya ayrı error dinleyicisi ve hata anında elle temizlik (destroy çağrıları) ister — pratikte eksiksiz yazılmaz. pipeline bu üç işi üstlenir: herhangi bir halkanın hatası tek noktadan (callback ya da promise reddi) fırlar; hata anında zincirdeki bütün stream'ler kapatılır, kaynaklar temizlenir; stream/promises sürümü try/catch + await ile okunur. Doğru varsayılan: iki stream'i birleştiren her yerde pipeline; pipe ancak zinciri canlı tutup dallandırmak gibi özel durumlarda, hata yönetimi bilinçle üstlenilerek kullanılır. Fark üretimde ödenir: güneşli günde ikisi aynı çalışır — ayrım disk dolduğunda, dosya silindiğinde, istemci bağlantıyı kopardığında ortaya çıkar.

Backpressure protokolünü üç parçasıyla anlat: highWaterMark neyi sınırlar (ve neyi sınırlamaz), kim uygular, elle write döngüsü nerede bozar?

Sorun hız uyuşmazlığıdır: 500 MB/s okuyan kaynak, 50 MB/s yazan hedefe akarsa fark bir yerde birikir — önlem yoksa süreç belleğinde ve stream'in sabit bellek vaadi çöker. Protokolün üç parçası: (1) her Writable'ın iç tamponu ve highWaterMark eşiği vardır (varsayılan 64 KB) — bu sert bir sınır değil öneri eşiğidir: write veriyi eşik aşılsa da kabul eder, yalnız dönüş değeriyle sinyal verir; (2) write()'ın false dönmesi "tampon doldu, yazmayı durdur" işaretidir; (3) tampon boşalınca Writable drain olayı yayınlar — "devam et". pipe ve pipeline bu protokolü otomatik işletir: false'ta kaynağı duraklatır, drain'de sürdürür; HTTP'de bu, backpressure'ın TCP akış kontrolüne kadar inmesi demektir — yavaş istemci, hızlı sunucuyu doğal olarak yavaşlatır. Bozulduğu yer elle write döngüsüdür: dönüş değerine bakmadan milyon kez write çağıran kod, veriyi hedefin tamponuna sınırsız yığar — "stream kullanıyorum ama bellek şişiyor" vakasının bir numaralı sebebi. Refleks: elle write varsa dönüş değeri okunur ve drain beklenir; bu zahmete girmeyeceksen pipeline kullan.

"HTTP istek ve yanıtı birer stream'dir" cümlesinin sunucudaki iki pratik sonucunu kodsuz anlat; Transform'un bu tabloya katkısını ve content-encoding bağını ekle.

Sunucuda istek nesnesi Readable (gövde parça parça gelir), yanıt nesnesi Writable'dır (parça parça gönderilir). Birinci sonuç — büyük yükleme: istek doğrudan bir dosya Writable'ına pipeline'lanır; 10 GB'lık gövde bile süreç belleğine girmeden diske akar, backpressure sayesinde yavaş disk hızlı istemciyi TCP düzeyinde frenler. Gövdeyi önce belleğe toplayan yaklaşım (readFile refleksinin HTTP hâli) aynı yükte süreci öldürürdü. İkinci sonuç — büyük indirme: dosya Readable'ı yanıta pipeline'lanır; istemci sayısı artsa da istek başına bellek sabittir. Transform bu boru hattının ara halkasıdır: veri akarken dönüştürülür — en yaygını zlib.createGzip ile sıkıştırmadır; kaynak → gzip → yanıt zinciri kurulur ve yanıta content-encoding: gzip header'ı yazılır ki istemci gövdeyi açarak okusun (6. hattın header bilgisi). Aynı "tamamını bekletme, akıt" fikri ileride mesaj akışlarında (19. hat) ve LLM yanıtının token token akıtılmasında (21. hat) aynen tekrar eder — stream burada öğrenilen genel bir düşünme biçimidir, fs ayrıntısı değil.

08Sınav köşesi

Yazılıda nasıl sorulur

(1) Stream kullanmanın temel gerekçesi nedir; readFile ile createReadStream farkını bellek açısından açıklayın. (2) Dört stream türünü birer örnekle yazın. (3) pipe'ın hata davranışındaki eksiklik nedir; pipeline bunu nasıl çözer? (4) Backpressure nedir; write()'ın false dönüşü ve drain olayı nasıl kullanılır? (5) highWaterMark neyi belirler? (6) HTTP sunucusunda istek ve yanıt nesneleri hangi stream türleridir? (7) Büyük bir dosyayı HTTP yanıtına gzip'leyerek akıtan zinciri yazın; hangi header gerekir? (8) "Stream kullanıyorum ama bellek şişiyor" şikâyetinin olası sebebi nedir?

Sözlüde nasıl anlatılır — 2 dakikalık taslak

Düzen: (1) Gerekçe — "readFile tüm dosyayı belleğe alır; stream 64 KB'lık parçalarla akıtır — sınırsız veri, sabit bellek. Bunu RSS ölçerek kanıtlarım." (2) Türler — "Readable üretir, Writable tüketir, Duplex soket gibi iki yönlü, Transform akarken dönüştürür — gzip klasiği." (3) Ayrım — "pipe veri bağlar ama hata iletmez: kaynak hata verince hedef açık kalır, sızıntı; pipeline tek catch, tam temizlik — varsayılanım pipeline'dır." (4) Backpressure — "Hızlı üretici yavaş tüketiciye yazarsa fark bellekte birikir; write false der, drain devam ettirir; pipe/pipeline bunu otomatik işletir — elle write döngüsünde dönüş değerini okumayan kod vaadi bozar." (5) Kıdem vuruşu — "HTTP req/res zaten stream: 10 GB'lık yüklemeyi belleğe sokmadan diske, büyük yanıtı gzip Transform'uyla istemciye akıtırım; küçük JSON içinse stream törenine girmem — araç, boyutu öngörülemeyen verinindir."