Bölüm 1: Donanım, İşletim Sistemi ve Thread Dünyası (Temel Taşlar)
Günümüz web dünyasında, bir uygulamanın hızı ve ölçeklenebilirliği her şeydir. JavaScript veya Node.js ile ilgilenen herkesin duyduğu kalıplaşmış bir cümle vardır: "JavaScript tek iş parçacıklıdır (single-threaded) ve asenkron çalışır." Peki bu tam olarak ne anlama geliyor? Bilgisayarımızın içindeki o yeşil anakart, işlemci (CPU) ve işletim sistemi, yazdığımız o iki satırlık asenkron kodu nasıl algılıyor?
Node.js’in ve Event Loop mimarisinin neden devrimsel bir yaklaşım olduğunu anlamak için, önce tarayıcıların veya editörlerin çok daha derinlerine; işletim sisteminin ve donanımın kalbine inmemiz gerekiyor.
Eşzamanlılık (Concurrency) ve Paralellik (Parallelism) Karışıklığı
Yazılım dünyasında en çok birbirine karıştırılan, zaman zaman eşanlamlıymış gibi kullanılan iki kavramla başlayalım: Eşzamanlılık ve Paralellik.
Eşzamanlılık (Concurrency): Aynı zaman dilimi içinde birden fazla işin ilerlemesiyle ilgilidir. İşlerin aynı anda, aynı salisede çalışması şart değildir. İşletim sistemi, işlemciyi o kadar hızlı bir şekilde işler arasında geçiş yaptırır ki (buna birazdan değineceğiz), biz insani algımızla her iki işin de aynı anda yapıldığını sanırız. Kafede tek başına çalışan bir baristanın, hem kahve siparişi alıp hem de süt köpürtmesi eşzamanlılıktır. Barista aynı anda iki işi yapamaz ama iki işi de ilerletir.
Paralellik (Parallelism): Kelimenin tam anlamıyla aynı anda, aynı fiziksel saniyede birden fazla işin yapılmasıdır. Bunun gerçekleşebilmesi için donanımsal olarak birden fazla işlem birimine (örneğin çok çekirdekli bir CPU’ya) ihtiyaç vardır. Kafede yan yana çalışan iki baristanın olması paralelliktir; biri kahve yaparken diğeri kasaya bakabilir.
JavaScript, doğası gereği tek bir mutfaktaki tek bir barista (single thread) gibidir. Ancak buna rağmen yüzlerce siparişi (isteği) nasıl birbirine karıştırmadan ve sistemi kilitlemeden yönetebildiğini anlamak, bu yazının ana felsefesini oluşturuyor.
İşletim Sistemi Seviyesinde Thread (İş Parçacığı) Nedir?
Bir programı çalıştırdığınızda (örneğin tarayıcınızı veya Spotify'ı açtığınızda), işletim sistemi bu program için bir Process (Süreç) başlatır. Process, programın çalışması için ayrılan izole bellek alanıdır.
Bu Process'in içinde, işlemcinin (CPU) çalıştıracağı kod komutlarını taşıyan en küçük birimlere ise Thread (İş Parçacığı) denir. Thread, process içindeki hafızayı ve kaynakları ortaklaşa kullanan bir işçidir.
CPU Çekirdekleri Thread Sayısını Nasıl Belirler? Multi-Tasking Nasıl Çalışır?
Eski nesil bilgisayarlarda yalnızca tek bir fiziksel CPU çekirdeği (Core) bulunuyordu. Bu donanımsal kısıtlama, işlemcinin aslında aynı anda (aynı salisede) yalnızca tek bir thread yürütebileceği anlamına geliyordu. Peki, durum böyleyse o dönemlerde bile nasıl aynı anda hem arkada müzik çalıp hem de Word belgesinde yazı yazabiliyorduk?
Buradaki sır, işletim sisteminin Scheduler (Zamanlayıcı) mekanizması ve Multi-tasking (Çoklu Görev) illüzyonunda saklıdır. İşletim sistemi, her bir thread’e "Quantum" veya "Time Slice" adı verilen, milisaniyelerle ölçülen çok küçük çalışma süreleri tanır. Örneğin, işlemci A Thread'ini (müzik çalar) 5 milisaniye çalıştırır, onu duraklatıp durumunu kaydeder, ardından hemen B Thread'ini (Word belgesi) 5 milisaniye çalıştırır. Donanım seviyesinde gerçekleşen bu geçiş ve döngü o kadar hızda yapılır ki, insan algısı bu kesintileri fark edemez. Bizler ise bilgisayarda birden fazla programın kusursuz bir eşzamanlılıkla çalıştığını sanırız.
Günümüzde ise donanım mimarisi çok daha ileri bir noktada. Modern işlemci özelliklerinde sıkça gördüğümüz "8 Çekirdek / 16 Thread" gibi ifadeler tam olarak bu evrimin göstergesidir:
Fiziksel Çekirdek (Core): İşlemcinin içinde tamamen bağımsız olarak veri işleyebilen gerçek, fiziksel donanım birimleridir. 8 çekirdekli bir işlemci, fabrikasyon olarak yan yana koyulmuş 8 ayrı mikro işlemci gibi davranır.
Sanal Çekirdek (Hyper-Threading / SMT): Intel'in Hyper-Threading, AMD'nin ise SMT adını verdiği bu teknoloji, tek bir fiziksel çekirdeğin işletim sistemine iki farklı çekirdekmiş gibi görünmesini sağlar. Fiziksel çekirdek içindeki hatlar (pipeline) boş kaldığında, donanım seviyesinde ikinci bir thread'in verileri hazırda bekletilir.
Böylece 8 fiziksel çekirdeğe ve Hyper-Threading desteğine sahip modern bir işlemci, işletim sistemine 16 mantıksal işlemci sunabilir. İşletim sistemi bu mantıksal işlemciler üzerinde 16 farklı hardware thread'i zamanlayabilir; ancak aynı fiziksel çekirdeği paylaşan iki thread, çekirdeğin yürütme kaynaklarını ortak kullanır. Bu nedenle 8 çekirdek/16 thread yapısı, 16 bağımsız fiziksel çekirdekle aynı paralel işlem gücünü sağlamaz. Yine de bu donanımsal gelişme, sunucuların daha fazla işi eşzamanlı yönetebilmesinin önünü açmıştır.
Thread Artışının Gizli Maliyeti: Context Switching (Bağlam Geçişi)
Buraya kadar her şey harika görünüyor: "O zaman sunucumuzda ne kadar çok thread açarsak, uygulamamız o kadar çok işi aynı anda yapar ve hızlanırız!" diye düşünebilirsiniz. İşte geleneksel sunucu mimarilerinin (örneğin Java Servlet mimarilerinin) en büyük tıkanma noktası tam olarak burada başlar.
İşletim sisteminin, çalışan bir thread’i durdurup diğer thread’e geçmesi işlemine Context Switching (Bağlam Geçişi) denir. CPU bir thread’i bırakıp diğerine geçerken şu adımları uygulamak zorundadır:
Mevcut thread’in o anki durumunu (CPU register'larındaki verileri, pointer'ları) RAM'e kaydet.
Sıradaki thread’in daha önce kaldığı yerden durum bilgilerini RAM'den oku ve CPU register'larına yükle.
Kaldığı yerden çalıştırmaya başla.
Thread sayısı yüzlerle, binlerle ifade edilmeye başladığında CPU, uygulama kodunu çalıştırmanın yanında thread'ler arasında geçiş yapmak için de önemli miktarda zaman harcayabilir. Bu Context Switching (Bağlam Geçişi) maliyeti, işlemci önbelleğinin verimliliğini düşürerek uygulamanın toplam performansını olumsuz etkileyebilir. Ayrıca her işletim sistemi thread'i, boyutu platforma ve yapılandırmaya göre değişen kendine ait bir stack alanına ihtiyaç duyar. Ayrılan sanal adres alanının tamamı fiziksel RAM üzerinde kullanılmasa bile binlerce thread oluşturmak toplam bellek tüketimini önemli ölçüde artırabilir.
Bölüm 1'in Sonu.
Bir sonraki bölümde, bu thread'lerin en çok vakit geçirdiği ve CPU'yu çaresizce beklettiği "I/O (Girdi/Çıktı) İşlemleri" dünyasına adım atacağız. Donanım hiyerarşisinin kodumuzun hızını nasıl etkilediğini ve geleneksel sunucuların bu I/O işlemlerini yönetirken nasıl kördüğüme döndüğünü inceleyeceğiz.
Bölüm 2: I/O İşlemleri ve Geleneksel Sunucu Mimarisinin Çıkmazı
Web Dünyasında I/O Nedir ve Neden Önemlidir?
En temel tanımıyla I/O (Input/Output - Girdi/Çıktı), bir yazılımın veya işletim sisteminin kendi dışındaki bir birimle yaptığı her türlü veri alışverişidir. Web sunucuları perspektifinden baktığımızda, işlemcinin (CPU) kendi içinde yaptığı matematiksel hesaplamalar (örneğin bir döngü çalıştırmak veya iki sayıyı toplamak) buzdağının sadece görünen kısmıdır. Günümüz modern web uygulamalarının asıl mesaisi ve ana yükü veri taşımaktır.
Veritabanından (SQL/NoSQL) kullanıcı bilgilerini çekmek bir Disk I/O işlemidir.
Sunucuya yüklenen bir resmi diske kaydetmek veya log dosyası yazmak bir Dosya Sistemi (File System) I/O işlemidir.
Üçüncü parti bir ödeme altyapısına (Stripe/PayPal) veya bir hava durumu API'sine istek atmak ise bir Network (Ağ) I/O işlemidir.
İşte tam da bu yüzden sunucuların performansı, CPU'nun hızından ziyade bu I/O işlemlerini ne kadar verimli yönetebildiği ile ölçülür. Çünkü bilgisayar mimarisinde tüm donanımlar aynı hızda çalışmaz; aralarında muazzam bir uçurum vardır ve bu durum Donanım Hiyerarşisi olarak adlandırılır:
L1 / L2 Cache (Önbellek) < RAM (Ana Bellek) < SSD /HDD (Disk) < Network (Ağ/İnternet)
Modern bir işlemcinin ve bellek hiyerarşisinin yaklaşık erişim sürelerini (Nanosaniye bazında) inceleyelim:
Bellek / Donanım Türü | Tipik Erişim Süresi (Nanosaniye) | Donanımsal Karşılığı |
CPU Register (Yazmaçlar) | 0.3 ns | CPU içindeki en hızlı, doğrudan hücreler. |
L1 Cache (Önbellek) | 0.5 - 1 ns} | Çekirdeğe özel, yüksek hızlı SRAM. |
L2 Cache (Önbellek) | 3 - 7 ns | Çekirdekler arası paylaşımlı/yakın SRAM. |
L3 Cache (Önbellek) | 15 - 25 ns | Tüm işlemci paketinin ortak önbelleği. |
RAM (Ana Bellek) | 60 - 100 ns | Anakart üzerindeki dinamik hafıza (DRAM). |
SSD (NVMe / Flash Disk) | 10,000 - 50,000 ns | Kalıcı depolama birimi (I/O Kanalları üzerinden). |
Network (Ağ / İnternet) | 10,000,000 - 150,000,000ns | TCP/IP protokolü ile dış dünyaya açılan kapı. |
Metriklerin bize söylediği şey çok açık: CPU, L1 önbelleğindeki bir veriye 1 nanosaniyeden kısa sürede erişebilirken; ana belleğe (RAM) çıkması gerektiğinde yaklaşık 100 kat, yerel bir diskten (SSD) veri okuması gerektiğinde ise en az 10.000 kat daha fazla beklemek zorundadır. Veri, ağ (Network) üzerinden bir API veya uzak veritabanından gelecekse, bu gecikme milyonlarca kat artar.
İşte I/O (Girdi/Çıktı) İşlemi dediğimiz kavram, CPU'nun o nanosaniyeler bazındaki ultra hızlı dünyasından çıkıp, mikro saniyeler ve milisaniyeler seviyesindeki "aşırı yavaş" disk ve network dünyasına adım atması demektir.
Cache (Önbellek) Stratejisinin Teknik Önemi
Bu gecikme uçurumundan dolayı, modern yazılım mimarilerinde sürekli disk I/O'su (Veritabanı sorgusu) yapmak yerine, sık talep edilen veriler RAM üzerinde (örneğin Redis veya bellek içi cache mekanizmalarıyla) tutulur. Amaç, CPU'yu binlerce nanosaniyelik disk kuyruklarından kurtarıp, 60\100 ns seviyesindeki RAM hızıyla beslemektir.
Blocking I/O
İşte yazılım mimarilerinin yol ayrımına geldiği nokta tam olarak burasıdır. Madem I/O işlemleri (disk ve network) CPU'ya göre bu kadar yavaş, o halde geleneksel bir web sunucusu (örneğin Java Servlet kullanan Tomcat mimarisi) bu süreci nasıl yönetiyor?
Geleneksel sunucu mimarileri, yapılandırmaları gereği Blocking I/O (Bloklayan Girdi/Çıktı) prensibi ve Thread-per-Request (Her İsteğe Bir Thread) modeliyle çalışır.
Senaryomuz şu olsun: Sunucumuza anlık bir kullanıcı isteği (request) geliyor. Sunucu, elindeki Thread Havuzundan (Thread Pool) boşta olan Thread-A'yı bu isteği karşılaması için görevlendiriyor.
Thread-A işletim sistemi tarafından CPU çekirdeğine atanır (Running durumuna geçer) ve kod satır satır işlenmeye başlar.
Sıra veritabanından kullanıcı verilerini çekmeye (veya bir API'ye istek atmaya) geldiğinde, yani I/O işlemi başladığında Thread-A isteği diske veya ağa gönderir.
İşletim sistemi, Thread-A'nın uzun sürecek bir I/O yanıtı bekleyeceğini anladığı an, işlemci çekirdeğini boş yere meşgul etmesini engellemek için bu thread'i CPU'dan hemen tahliye eder ve "Waiting / Blocked" (Bekleme) moduna alır. Amaç, boşa çıkan CPU çekirdeğini sıradaki diğer aktif thread'lerin kullanımına sunmaktır.
Ancak Thread-A, CPU çekirdeğinden indirilmiş olsa da özgür değildir; sistem hafızasında (RAM) kendi yığın (stack) alanını işgal ederek veritabanından gelecek sinyali beklemeye başlar. Bu süreçte sunucuya gelen yeni kullanıcı isteklerine hizmet veremez, tamamen kilitlidir.
Veritabanından (Disk/Network) yanıt geldiğinde, donanım seviyesinde bir kesme sinyali (Hardware Interrupt) tetiklenir. İşletim sistemi Thread-A'yı tekrar "Ready" (Hazır) durumuna getirerek CPU üzerinde çalıştırılmak üzere kuyruğa sokar.
CPU'ya yeniden alınan Thread-A, kalan kod satırlarını çalıştırır, istemciye yanıtı döner (response) ve ancak o zaman havuzdaki "boştakiler" arasına geri katılır.
Bölüm 3: Sektörel Kriz ve Çözüm (Vaka Analizleri ve Node.js'in Doğuşu)
Bölüm 2'de geleneksel sunucuların kilitlenen thread'ler yüzünden nasıl bir çıkmaza girdiğini (Thread Pool Starvation) teknik olarak gördük. Şimdi hikayeyi 2000'lerin sonuna taşıyalım ve endüstrinin bu donanımsal duvarlara nasıl tosladığını gerçek dünya senaryolarıyla inceleyelim.
Büyük Şirketlerin Çıkmazı: "C10K" Problemi ve Gerçek Vaka Analizleri
İnternetin ilk dönemlerinde web siteleri daha statikti ve anlık trafikler düşüktü. Ancak Facebook, Twitter ve PayPal gibi platformların hayatımıza girmesiyle web dünyası kökten değişti. Artık kullanıcılar sadece bir sayfayı yükleyip bırakmıyordu; sürekli chat yapıyor, bildirim alıyor ve arkada devasa bir anlık veri akışı (real-time data) dönüyordu.
Yazılım dünyası o dönemde C10K (Concurrently Handling 10,000 Connections) adı verilen bir problemle yüzleşti: Bir sunucu, aynı anda aktif olan 10.000 bağlantıyı nasıl çökmeyle yüzleşmeden yönetebilir?
Geleneksel "her isteğe bir thread" modeli bu ölçekte tamamen iflas etti. Gelin, devlerin bu duvarlara nasıl çarptığına yakından bakalım:
1. PayPal Vakası: Java Sunucularının Kaynak İsrafı
PayPal'ın bazı servislerini Java tabanlı yapılardan Node.js'e taşıması, event-driven mimarinin I/O ağırlıklı sistemlerdeki potansiyelini gösteren örneklerden biri oldu. Ancak ortaya çıkan performans ve kaynak kullanımı farkını yalnızca bekleyen Java thread'lerine bağlamak doğru değildir; uygulama mimarisi, kullanılan framework'ler, kod yapısı ve geçiş sırasında yapılan diğer optimizasyonlar da sonuç üzerinde etkili olmuştur.
2. Twitter’ın "Fail Whale" Dönemi
Twitter'ın erken dönemindeki "Fail Whale" hataları, platformun çok hızlı büyümesiyle ortaya çıkan birden fazla ölçeklenebilirlik darboğazının sonucuydu. Yoğun veri akışı ve I/O yükü bu problemlerin önemli bir parçasıydı; ancak yaşanan kesintileri yalnızca thread havuzlarının tükenmesine bağlamak doğru değildir. Uygulama mimarisi, veritabanı erişimi, kuyruk sistemleri ve servisler arasındaki iletişim de bu süreçte birlikte rol oynadı.
Çözüm Arayışı: Non-Blocking I/O ve Event-Driven Mimarinin Yükselişi
Endüstri, thread sayısını artırarak veya daha fazla RAM satın alarak bu sorunun çözülemeyeceğini anladı. Çözüm, donanımı büyütmek (scaling up) değil, I/O yönetim felsefesini değiştirmekti.
Eğer bir thread, I/O işlemi (örneğin veritabanı sorgusu) başlattığında RAM'de kilitlenip beklemek yerine; işletim sistemine "Ben bu sorguyu gönderiyorum, bittiğinde beni haberdar et (event driven)" deyip hemen sıradaki diğer kullanıcı isteklerini göğüslemeye devam edebilseydi ne olurdu?
İşte bu yaklaşım Non-Blocking I/O (Bloklamayan Girdi/Çıktı) mimarisidir. Bu modelde uygulamanın ana yürütme thread'i, disk veya network işleminin sonucunu senkron biçimde bekleyerek bloke edilmez. Bekleme ve tamamlanma süreci işletim sisteminin I/O mekanizmaları veya gerektiğinde yardımcı thread'ler tarafından yönetilir. Böylece ana thread, I/O işlemleri devam ederken diğer bağlantılarla ilgilenebilir.
Tarihçe: Ryan Dahl ve Joyant Sahneye Çıkıyor
Takvimler 2009 yılını gösterdiğinde, Ryan Dahl adında genç bir mühendis bu probleme tamamen farklı bir açıdan yaklaştı. Dahl, tarayıcıların (özellikle Google Chrome'un) web sayfalarını render etmek için kullandığı V8 JavaScript Motoru'nu inceledi.
V8 motoru inanılmaz hızlıydı ve JavaScript, yapısı gereği tarayıcıda zaten asenkron olaylarla (örneğin kullanıcının bir butona tıklaması, arkadan AJAX isteği atılması) çalışıyordu. JavaScript'in doğasında "beklemek" yoktu; her şey olaylar (events) ve geri çağırım fonksiyonları (callbacks) üzerine kuruluydu.
Ryan Dahl şu dahiyane soruyu sordu: "Biz neden bu süper hızlı, bloklamayan, olay güdümlü JavaScript motorunu tarayıcıdan söküp sunucu tarafına taşımıyoruz?"
Node.js'in ilk sürümleri, V8 motorunu Unix sistemlerindeki event tabanlı I/O kütüphaneleriyle bir araya getiriyordu. Projenin Windows desteğine ve platformlar arasında ortak bir I/O katmanına ihtiyaç duymasıyla birlikte libuv geliştirildi. libuv; Linux'ta epoll, macOS ve BSD sistemlerinde kqueue, Windows'ta ise IOCP gibi platforma özgü mekanizmaları ortak bir API altında birleştirdi.
O dönem Joyant firmasının da büyük destek vermesiyle birlikte, sunucu tarafında asenkron ve tamamen bloklamayan (non-blocking, event-driven) kod yazabileceğimiz yeni bir çalışma ortamı doğdu: Node.js.
Node.js'in temel yaklaşımı, her bağlantı için ayrı bir thread tahsis etmek yerine az sayıda thread ve event-driven I/O mekanizmasıyla çok sayıda bağlantıyı yönetmektir. Bu model özellikle I/O ağırlıklı uygulamalarda thread ve context switching maliyetlerini azaltabilir. Ancak CPU ağırlıklı işlemler ana Event Loop'u bloke edebileceği için Node.js her iş yükünde otomatik olarak daha yüksek performans sağlamaz.
Bölüm 3'ün Sonu.
Bir sonraki ve son bölümümüzde, Node.js'in bu iddiayı nasıl gerçeğe dönüştürdüğünü göreceğiz. JavaScript'in o meşhur "Single Thread" yapısını, arkadaki gizli kahraman "Event Loop" mekanizmasını ve asenkron kod yazmanın pratik zorlukları ile performans avantajlarını masaya yatıracağız.
Bölüm 4: JavaScript ve Node.js Dünyasında Asenkronluk
Single Thread vs. Multi Thread: Büyük Mimarilerin Savaşı
Yazılım dünyasında performans ve ölçeklenebilirlik için farklı eşzamanlılık modelleri kullanılır. Java, C# ve C++ gibi diller ile bunların çalışma ortamları, geliştiricilere birden fazla thread oluşturma ve paralel iş yürütme imkânı sunar. Node.js'te ise uygulama kodunun ve çoğu JavaScript callback'inin çalıştığı ana Event Loop varsayılan olarak tek bir thread üzerinde ilerler. Buna karşılık Node.js çalışma ortamının tamamı tek thread'den oluşmaz; libuv Thread Pool, işletim sistemi thread'leri ve isteğe bağlı worker_threads mekanizması arka planda farklı thread'lerden yararlanabilir.
Peki, neden bazı diller karmaşık multi-thread yapısını seçerken, JavaScript tek bir thread ile yetinir? Bir yazılımcı perspektifinden aralarındaki temel farklar nelerdir?
1. Kodlama Kolaylığı ve Yarış Durumları (Race Conditions)
Multi-thread bir dilde kod yazarken, aynı anda paralel çalışan birden fazla thread hafızadaki (RAM) ortak bir değişkene aynı mikrosaniyede erişip onu değiştirmeye çalışabilir. Buna yazılımda Race Condition (Yarış Durumu) denir. Bir thread veriyi okurken diğeri silerse uygulama tutarsızlaşır veya çöker.
Bunu engellemek için yazılımcılar Lock, Semaphore veya Synchronized gibi karmaşık yapılar kullanarak thread'leri hizaya sokmak zorundadır. Bu durum kod yazmayı, test etmeyi ve hataları (debug) bulmayı inanılmaz zorlaştırır.
Ana JavaScript yürütme hattında bir job çalışmaya başladığında başka bir JavaScript job'ı araya girmez; mevcut iş sonuna kadar yürütülür. Bu özellik, klasik paylaşımlı bellek yarışlarının önemli bir bölümünü engeller ve ana thread üzerinde Lock veya Semaphore gibi yapılara duyulan ihtiyacı azaltır. Ancak network istekleri ve diğer asenkron işlemler beklenmeyen bir sırada tamamlanabildiği için uygulama seviyesinde mantıksal race condition'lar yine oluşabilir. Ayrıca Worker, SharedArrayBuffer ve Atomics kullanıldığında paylaşımlı bellek eşzamanlılığı da mümkündür.
2. Kaynak Tüketimi ve Ölçeklenebilirlik
Multi-Thread (Java/Servlet): Geleneksel servlet sunucuları genellikle önceden oluşturulmuş bir Thread Pool kullanır. Yeni bir istek geldiğinde her defasında sıfırdan thread oluşturmak yerine havuzdaki uygun bir thread bu isteğe atanır. Ancak blocking I/O sırasında bu thread isteğin tamamlanmasını beklediği için havuza geri dönemez. Yük arttığında havuzdaki boş thread'ler azalabilir; bellek tüketimi ve Context Switching maliyeti yükselir.
Single Thread (Node.js): Sunucuya gelen 10.000 isteği de tek bir ana thread (Main Thread) karşılar. Yeni istekler için hafızada megabaytlarca yer kaplayan yeni thread'ler oluşturulmaz. Bu sayede Node.js, geleneksel sunucuların gigabaytlarca RAM ile yapamadığı işi, komik denecek kadar küçük bellek harcamalarıyla başarır.
Node.js Nasıl Bloklanmaz? Libuv ve Thread Pool
Burada akıllara hemen şu can alıcı soru gelecektir: "Madem Node.js tek bir thread ile çalışıyor; o tek thread ağır bir Disk I/O işlemine (örneğin 500 MB'lık bir dosya okumaya) denk geldiğinde tüm sunucu kilitlenmez mi? Arkadaki diğer kullanıcılar o dosyanın okunmasını mı bekler?"
Cevap: Hayır, kilitlenmez. Çünkü Node.js, göründüğü kadar "yalnız" değildir.
Node.js çalışma ortamının merkezinde JavaScript kodunu çalıştıran Google V8 Motoru ile event loop, timer ve platformlar arası I/O işlemlerinin önemli bölümünü yöneten C tabanlı libuv bulunur.
Node.js'te asenkron bir işlem başlatıldığında kullanılan mekanizma işlemin türüne göre değişir. Örneğin fs.readFile gibi dosya sistemi işlemleri çoğunlukla libuv'un dahili Thread Pool'u üzerinde yürütülür. TCP bağlantıları ve çoğu veritabanı sorgusu ise bir worker thread'e gönderilmek yerine network socket'leri üzerinden işletim sisteminin non-blocking I/O mekanizmalarıyla takip edilir.
Dosya sistemi gibi bazı işlemler libuv Thread Pool tarafından yürütülürken network bağlantıları non-blocking socket'ler ve işletim sisteminin I/O bildirim mekanizmalarıyla takip edilir. Ana Event Loop thread'i bu işlemlerin sonucunu senkron biçimde beklemediği için, bu sırada diğer hazır callback'leri ve kullanıcı isteklerini çalıştırmaya devam edebilir.
Event Loop (Olay Döngüsü) Mekanizması
Peki, libuv Thread Pool üzerinde çalışan bir dosya işlemi tamamlandığında veya işletim sistemi bir network bağlantısında yeni veri bulunduğunu bildirdiğinde ne olur? Node.js bu tamamlanma bilgisini ilgili JavaScript callback'ine nasıl ulaştırır? İşte bu yürütme akışını yöneten temel mekanizma Event Loop'tur.
Event Loop, sürekli dönen sonsuz bir while döngüsü gibidir. Tek bir görevi vardır: Arka planda biten asenkron işlemlerin geri çağırım fonksiyonlarını (Callback) bir kuyruğa (Callback Queue) toplamak ve ana thread (Call Stack) boşaldığı anda bu fonksiyonları sırayla içeri alıp çalıştırmak.
Süreç adım adım şöyle işler:
Call Stack (Ana hat): JavaScript callback'leri ve senkron kodlar burada çalışır. Kod bir asenkron API çağırdığında ilgili işlem türüne göre runtime veya işletim sistemi seviyesinde başlatılır; JavaScript yürütme hattı ise sonucu senkron biçimde beklemeden mevcut kodu çalıştırmaya devam eder.
Arka Plan (Task Completion): Dışarıdaki I/O işi bittiğinde, onunla ilişkili olan Callback fonksiyonu (yani "veri gelince çalışacak olan kod bloğu") Callback Queue (Kuyruk) içine atılır.
Event Loop Devreye Girer: Event Loop sürekli Call Stack'i gözlemler. Ne zaman ki senkron kodlar tamamen biter ve Call Stack bomboş kalır; Event Loop hemen kuyruğa gider, sıradaki Callback fonksiyonunu alır ve çalışması için Call Stack'e taşır.
İşte normal senkron programlamadan farklı düşünmemiz gereken yer tam olarak burasıdır. Asenkron işlemlerde alt satır çalışırken, üst satırın I/O işleminin bitip bitmediği bilinmez. Program asenkron olarak akmaya devam eder ve yanıtlar geldikçe Event Loop tarafından ana hatta dahil edilerek eritilir.
Event Loop'u Gözümüzde Canlandıralım: Bir Kod Simülasyonu
Teoriyi bir kenara bırakıp, Event Loop'un kodumuzu nasıl işlediğini canlı bir örnek üzerinden inceleyelim. Aşağıdaki JavaScript kodunu yukarıdan aşağıya çalıştırdığımızda konsolda nasıl bir çıktı görmeyi bekleriz?
console.log("1. Program Başladı");
setTimeout(() => {
console.log("2. Zamanlayıcı Tetiklendi (0ms)");
}, 0);
console.log("3. Program Bitti");İlk bakışta, setTimeout fonksiyonunun süresi 0 milisaniye olarak ayarlandığı için çıktı sırasının 1 - 2 - 3 olacağını düşünebilirsiniz. Ancak Event Loop mimarisi yüzünden bu kodun gerçek konsol çıktısı şu şekilde olacaktır:
1. Program Başladı
3. Program Bitti
2. Zamanlayıcı Tetiklendi (0ms)Arka Planda Neler Oldu?
Bu şaşırtıcı sıranın nedeni, Node.js veya tarayıcı motorunun arkada işlettiği şu 4 adımlı süreçtir:
Adım (Call Stack):
console.log("1. Program Başladı")satırı Call Stack'e (ana hatta) girer, ekrana yazdırılır ve stack'ten hemen çıkar.Adım (Timer kaydı): Sıra
setTimeoutçağrısına geldiğinde çalışma ortamı zamanlayıcıyı kaydeder ve JavaScript yürütme hattı sonraki satıra geçer. Verilen0 msdeğeri callback'in anında çalışacağını değil, gecikme eşiği geçildikten sonra çalışmaya uygun hâle gelebileceğini belirtir. Callback ancak mevcut senkron kod tamamlandıktan ve Event Loop uygun aşamaya ulaştıktan sonra çalıştırılabilir.Adım (Call Stack Devam Ediyor): Bu sırada ana hat (Call Stack) hiç durmamıştır. Sıradaki senkron kod olan
console.log("3. Program Bitti")satırını işler, ekrana yazar ve Call Stack tamamen bomboş (idle) kalır.Adım (Event Loop Devreye Girer): Event Loop bakar ki Call Stack bomboş ve çalıştırılacak hiçbir senkron kod kalmadı. Hemen kafasını Callback Queue (Kuyruk) tarafına çevirir. Orada bekleyen
console.log("2...")fonksiyonunu yakalar, Call Stack'e taşır ve kod en son olarak ekrana yazdırılır.
Kritik Kural: Asenkron bir işlemin süresi
0 milisaniyebile olsa, o işlem bir kez ana hattan çıkıp kuyruğa (Queue) girdi mi, Call Stack'teki son senkron kod satırı dahi bitene kadar asla ana hatta geri dönemez. Event Loop, ana hat tamamen boşalmadan kuyruktan içeriye hiçbir şey almaz.
Senkron ve Asenkron Kodun Karşılaştırılması
Yazımızı bitirirken, bu iki yaklaşımın bir geliştiricinin hayatındaki pratik karşılıklarına ve mühendislik maliyetlerine göz atalım:
Senkron Programlama: Okunması, yazılması ve zihinde canlandırılması çok kolaydır. Kod satır satır ilerler, neyin ne zaman çalışacağı tam olarak bellidir. Test etmesi ve hataları (debug) yakalaması basittir. Ancak ağır I/O işlemlerinde sunucuyu bloke ettiği ve kaynakları israf ettiği için yüksek trafikli modern web uygulamalarında performans açısından büyük bir verimsizlik yaratır.
Asenkron Programlama: Sunucu kaynaklarını muazzam verimli kullanır, donanım maliyetlerini düşürür ve anlık yüksek trafikler (C10K) altında sunucunun ayakta kalmasını sağlar. Ancak kodun okunabilirliği (tarihteki Callback Hell dönemi, Promise yapıları ve günümüzdeki async/await karmaşası), test edilmesi ve hata takibi (özellikle bir hata fırlatıldığında o an stack'te hangi fonksiyonların olduğunu izlemek) senkron koda göre çok daha zordur. Performansın bedeli, daha yüksek bir mimari vizyon ve dikkatli bir kod yönetimidir.
Son Söz
JavaScript; tarayıcı pencerelerindeki basit animasyonları ve buton tıklamalarını yönetmek için doğmuş "Single Thread" genetik yapısını, Node.js ve Event Loop devrimi sayesinde günümüzün milyarlık real-time sistemlerini besleyen devasa bir sunucu gücüne dönüştürmeyi başarmıştır.
Donanımı kontrolsüzce daha büyük RAM'lerle boğmak yerine, eldeki tek bir işçiyi asenkron bir döngüyle dehasına ulaştıran bu mimariyi anlamak, modern dünyada ölçeklenebilir ve performanslı web uygulamaları geliştirmenin en temel anahtarıdır.
