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
pipevspipeline: hata iletmeyen zincir neden kaynak sızdırır; doğru varsayılan- Backpressure: hızlı üretici + yavaş tüketici;
write()'ınfalse'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
- Büyük dosya üret: bir Writable aç, döngüyle ~200 MB'lık satır yaz
(
write'ın dönüşünü kontrol edipdrainbekleyerek — backpressure'ı ilk elden uygula). İpucu:once(hedef, "drain")(node:events) await'lenebilir. - Kopyalama 1:
readFile+writeFileile kopyala; bitinceprocess.memoryUsage().rssyazdır. Kopyalama 2: aynı işipipeline(createReadStream, createWriteStream)ile yap, yine RSS yazdır. İki sayıyı yan yana koy — bellek kanıtın bu. - Zincire
zlib.createGzip()ekleyip.gzüret;ls -lhile boyut farkına bak. - Hata farkı deneyi: kaynak olarak olmayan bir dosya ver. Önce
pipezinciriyle dene — sürecin nasıl davrandığını ve hedef dosyanın diskte yarım/boş kaldığını gözle. Sonrapipelineile dene — hatanın tekcatch'e düştüğünü gör. İki davranışı ikişer cümleyle kıyasla. - HTTP kanıtı: bölüm 05'teki indirme sunucusunu kur, büyük dosyayı
curl -o /dev/null localhost:3000/raporile çek; bu sırada ikinci terminalden RSS'i izle (watchya 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."
