Bu makalemizde, Node.JS'in temel çalışma prensibi ve asenkron çalışma mantığının üzerinden geçeceğiz. Node'un temel çalışma prensibini anlamadan kod yazmaya çalıştığımızda, nasıl facialara sebep olacağımızı göreceğiz. Çoğu teorik olan bu bilgileri, mümkün mertebe seni sıkmadan aktarmaya çalışacağım.

Node hakkında şimdiye kadar neler duydun? Manyak hızlı? Aynı anda yüz binlerce istemciye hizmet verebilir? Hatta Laptop üzerinde bile?? Hmm, olabilir. Haydi, Node'un web sitesine girelim ve hemen üst taraftaki logonun altında yazan tanıtım metnine bir göz atalım.




Buradaki "Event-driven, non-blocking I/O model" sözüne dikkat et. Havalı gözüküyor, değil mi? Birgün dünya çapında bir uygulama yazarsam, ben de böyle havalı bir söz bulacağım inşallah. Ama sırf havalı gözüktüğü için ana sayfaya koymuş olamazlar. Ne anlama geliyor bu?

Bir önceki makalede yaptığımız son örneğimize dönelim. Hatırlıyorsan, Google'a ping atıp, hala orada olup olmadığını kontrol ediyorduk. Ve yine orada demiştim ki, "ping.sys.probe" fonksiyonu asenkron olarak çağırılıyor ve aslında bu, Node'un temel çalışma prensibini yansıtıyor. Bir an bunu unutalım ve sanki senkronmuş gibi kodumuzu yeniden yazalım. İlk olarak kullandığımız modül için aynı "package.json" dosyasını oluşturalım.

{
    "name": "GoogleBusted",
    "version": "1.0.0",
    "dependencies": {
        "ping": "0.1.6"
    }
}
]]>

Ardından aşağıdaki kodlar ile "program.js" dosyamızı oluşturalım.

"use strict";

var http = require("http");
var ping = require("ping");

http.createServer(processRequest).listen(1234, "127.0.0.1");

function processRequest(request, response) {

    var googleAlive = false;

    ping.sys.probe("www.google.com", function (isAlive) {

        googleAlive = isAlive;

    });

    var message = googleAlive ? "Google Ayakta" : "Google Gitti";

    response.writeHead(200, {
        "Content-Type": "text/html"
    });

    response.write(message);

    response.end();
}
]]>

Programı çalıştırmak için öncelikle terminal (Linux veya Mac OS) veya command prompt (Windows) ile projemizin bulunduğu klasöre kadar gidelim ve "npm install" komutunu çalıştıralım. Böylece gerekli paketi indirmiş olduk. Sonrasında da "node program.js" komutu ile istemcileri dinlemeye başlayalım. Son olarak yazdığımız uygulamayı test etmek için bir internet tarayıcı penceresi açalım ve adres çubuğuna http://127.0.0.1:1234/ yazarak Enter'a basalım. Bu noktada aşağıdaki ekran görüntüsü ile karşılaşmış olman gerekiyor.



Geçmiş olsun. Ne oldu peki?

Yazdığımız koda dikkat edersen, bir önceki makaledeki örnekten farklı olarak, fonksiyon seviyesinde bir değişken tanımladık ve "ping.sys.probe" fonksiyonunu çağırarak, Google'a atılan ping'in neticesini bu değişkene atadık. Ancak o zaman da dediğim gibi, bu fonksiyon asenkron olarak çalışıyordu. Dolayısıyla program, buradan cevap gelmesini beklemeden bir alt satıra devam etti ve sonuç olarak değişkenin ilk değerine göre ekrana "Google Gitti" yazdırdı.

Tamam. Tamam da, o fonksiyon neden asenkron? Senkron yapsalardı ya, ne diye işi zora koşuyorlar?

Pekala, şimdi bir gerçekle yüzleşmenin zamanı geldi. Hazır mısın? Node.JS üzerinde yazdığımız programlar, tek bir Process'deki tek bir Thread üzerinde çalışıyor. Bu ne demek? Biz o tek veli nimet Thread'i meşgul ettiğimiz sürece, Node başka bir görevi yerine getiremez.

???!!

Vallahi öyle.  İnandırıcı gelmedi mi? Haklısın, ilk öğrendiğimde ben de inanmakta zorluk çekmiştim ve kendi gözlerimle görmek istemiştim. Haydi, deneyelim. Aşağıdaki kodlarla yeni bir "program.js" dosyası oluşturalım.

"use strict";

var http = require("http");

http.createServer(processRequest).listen(1234, "127.0.0.1");

function processRequest(request, response) {

    var message = veryLongProcess();

    response.writeHead(200, {
        "Content-Type": "text/html"
    });

    response.write(message);

    response.end();

}

function veryLongProcess() {

    var startTime = new Date().getTime();

    while ((new Date().getTime() - startTime) < 10000) {}

    return "OK";

}
]]>

Programı çalıştırmadan önce kodu biraz inceleyelim. Yazdığımız program, daha önceki örneklerimiz gibi HTTP isteklerini dinliyor. Herhangi bir istemci bağlandığı zaman "veryLongProcess" adında, 10 saniye boyunca devam eden bir fonksiyonu çağırıyor (Fonksiyonun hoş gözükmediğinin farkındayım ama şimdilik işimizi görür). Ardından fonksiyondan dönen "OK" cevabını istemciye geri dönüyor.

Eğer gerçekten de Node tek Process ve tek Thread üzerinde çalışıyorsa, bir istemci bağlandığında, ona cevap dönene kadar başka bir istemciyi kabul etmeyecek ve kuyruğa atacaktır. Deneyelim bakalım.

Terminal (Linux veya Mac OS) veya command prompt (Windows) ile projemizin bulunduğu klasöre kadar gidelim ve "node program.js" komutu ile istemcileri dinlemeye başlayalım. Sonrasında 3 ayrı tarayıcı penceresi açalım ve her biri ile ayrı ayrı http://127.0.0.1:1234/ adresini ziyaret edelim.

Sen de göreceksin ki, tarayıcılardan birine 10 saniye sonra cevap verilecek, bir diğerine 20 saniye sonra cevap verilecek, sona kalana da 30 saniye sonra cevap verilecek. Yani gerçekten de, Node bizim fonksiyonu işletirken dünyayı unutuyor.

Eee? Hangi nerede manyak hız, yüz binlerce istemci, hatta laptop üzerinde bile?? Bu şekilde yüz bin istemci talepte bulunsa, sonda kalan istemcinin cevap alabilmesi için yaklaşık olarak 11,5 gün beklemesi gerekiyor. Tüp kuyruğu mübarek. Ne yani, her şey yalan mıydı?

Hayır, değildi. Ama Node'un çalışma prensibini bilmeden kod yazmaya çalışırsak, Node bizi SSK gibi kuyruğa sokar, böyle madara oluruz işte. O halde şu işin aslını bir öğrenelim bakalım.

Soru geliyor. Bir bilgisayar, "gerçekte" aynı anda kaç tane işi paralel olarak yürütebilir? 100 bin? 100 milyon? 100 milyar? Yanlış.

Doğru cevap, işlemci sayısı (ya da günümüzde Core sayısı) kadar olacak. Yani bilgisayarda 4 Core varsa, sadece 4 işlemi paralel yürütebilir.

Şaka mı bu? Bilgisayarın aynı anda binlerce işlemi paralel çalıştırdığını biliyoruz. Nasıl oluyor bu??

Oyunun adı Context Switch. Bilgisayar, aynı anda birden fazla işi yapabilmek için kendine verilen işleri Process ve Thread'ler üzerinde çalıştırır. Herhangi bir Thread içinde beklemeye geçtiği anda, o anki durumu ile birlikte bunu, daha sonra devam etmek üzere kaydeder ve hemen ardından, daha önceden devam etmek üzere kaydettiği başka bir Thread'i geri yükleyerek işletmeye başlar. Bu şekilde, sırada bekleyen tüm Thread'ler arasında gezinir. Bunu da öyle bir hızda yapar ki, biz hepsini paralel çalıştırıyor sanırız.

Sistem güzel, bulan güzel bulmuş. 4 çekirdek ile 4000 işlem paralel işliyormuş gibi gözüküyor. Peki sıkıntı nerede? Az önce bahsettiğim Context Switch işleminde kaydetme ve geri yükleme, düşük sayılarda ihmal edilebilecek kadar kısa zaman alır ancak geçiş yapılacak Thread sayısı arttıkça kendini hissettirmeye başlar. Ayrıca açılan her bir Thread için de bellekte bir miktar yere ihtiyaç duyulur.

Bahsi geçen bu soyut kavramlara, biraz da günümüz sistemlerinden örnek verelim. Şu anda yaygın olarak kullanılan IIS ve Apache sunucuları aynı Process ve Thread mantığında çalışır. Yayın yapılan her bir siteye bağlanan her bir istemci için (elbette pool yönetimi ile birlikte) yeni bir Thread açılır. Düşük ve orta sayıdaki istemcili sistemlerde hissedilecek bir sıkıntı yaşanmaz. Ancak eş zamanlı ziyaretçi sayısı arttıkça, yukarıda bahsedilen Context Switch sebebiyle, sistemin cevap verme süresi uzar ve bellek ihtiyacı artar.

İşte, başta bahsi geçen havalı "Event-driven, non-blocking I/O model" sözü, bu soruna farklı bir açıdan bakarak çözüm arar. Nasıl mı?

Araştırmalar göstermiştir ki, yazdığımız programlarda bir genelleme yapıldığında, program işletilirken geçen sürenin çok çok çok küçük bir kısmı, bizim yazdığımız asıl algoritmaların işletilmesinde geçer. Bunun yanında disk (dosya, veritabanı okuma/yazma vs.) ve network (veritabanı ile haberleşme, HTTP/Web Service çağrımları vs.) işlemlerinde geçen süre, bunlara göre çok çok çok daha fazladır.


Üstelik disk ve network işlemleri, bizim üzerinde bulunduğumuz Thread üzerinde de gerçekleşmezler. Biz sadece başka bir Thread'den cevap gelmesini bekleriz. Bu durumda, yukarıdaki değerler ışığında, üzerinde çalıştığımız Thread, çalışma zamanının çok büyük bir kısmını bekleyerek geçirir ve bu arada Context Switch'e girip çıkar.

Oysa farklı bir yaklaşım ile, biz bir işin gerçekleşmesi için emir versek. Ardından bu işin tamamlanması olayına da bir Event Handler bağlasak, sonra da işi unutsak. Emir verdiğimiz Process tarafından iş tamamlandıktan sonra bir Event fırlatılsa ve bizim Event Hander'ı tetiklese, biz de işin tamamlandığını anlasak ve çalışmaya devam etsek. Bu durumda, aradaki bekleme sırasındaki Context Switch'e ihtiyacımız olmazdı, değil mi? Hatta yukarıdaki değerlere bakarsak, Thread'e bile ihtiyacımız olmazdı. Çünkü bizim Thread, %99,999904982 oranında boşta bekliyor gözüküyor.

Peki, bunu gerçek dünyadaki sonuçlarını görebileceğimiz bir çalışma yok mu? Var elbette. Nginx adındaki bir web sunucusu projesi, "Asynchronous event-driven" yaklaşımı ile hizmet veriyor. Hatta yoğun yük altındaki Apache ile karşılaştırmalı testleri de yapılmış. Bir göz atalım bakalım.


Yukarıdaki grafikte, artan eş zamanlı istemci sayısına (yatay eksen) göre, web sunucu tarafından saniyede cevap verilebilen istek sayısını (dikey eksen) görüyoruz. Her iki sunucu da saniyede 10.000 isteğe cevap verebilirken, yukarıda bahsettiğim Context Switch meselesi yüzünden, Apache eş zamanlı 3500 istemcide ancak 3000 isteğe cevap verebilmeye başlıyor ve bu değer de giderek düşüyor. Oysa nginx tarafında, yukarıda bahsettiğim %99,999904982 boşluk sebebiyle, istemci sayısı cevap süresini pek etkilemiyor. Zaten alet boşta yatıyordu, yüksek istemci sayısında daha da verimli çalışmaya başladı. İstemci sayısını daha da arttırsak, Apache'nin cevap veremeyeceği noktalarda, fark çok daha dramatik bir hale dönüşecektir (İstatistiklere göre Nginx günde 500 milyon isteğe cevap verebiliyormuş).



Bu grafikte de, artan eş zamanlı istemci sayısına (yatay eksen) göre, ihtiyaç duyulan bellek miktarını (dikey eksen) görüyoruz. Klasik yöntemde, yeni gelen istemciler için açılan Thread'ler, bellekte yere ihtiyaç duyuyor. Oysa olay tabanlı asenkron çağrım mantığında çalışan Nginx'in bellek ihtiyacı, artan istemci sayısına rağmen sabit kalıyor. Çünkü Thread sayısı değişmiyor.

Nasıl? Adamlar çalışmış, değil mi :) Artık Node'un neden "laptop üzerinde bile yüz binlerce istemciye manyak hızlı" hizmet verebildiğini anlıyorsundur. Ama bir şartla. Veli nimetimiz, biricik Thread'imizi bloklamamak şartıyla (Event-driven, non-blocking I/O model). Haydi o zaman, şu programı tekrar yazalım.

"program.js" dosyamızın içeriğini aşağıdaki şekilde değiştirelim.

"use strict";

var http = require("http");

http.createServer(processRequest).listen(1234, "127.0.0.1");

function processRequest(request, response) {

    veryLongProcess(function (message) {

        response.writeHead(200, {
            "Content-Type": "text/html"
        });

        response.write(message);

        response.end();
    });

}

function veryLongProcess(callback) {

    setTimeout(function () {

        callback("OK");

    }, 10000);

}
]]>

Dikkat edersen, "veryLongProcess" isimli fonksiyonumuz artık "callback" adında bir fonksiyon alıyor. Toplamda 10 saniye süren işini bitirdikten sonra da, sonucu bu fonksiyona parametre olarak geçerek çağırıyor. Biz de "veryLongProcess" adındaki fonksiyonu asenkron olarak çağırıyoruz ve bırakıyoruz. İşlem bittiği zaman, bizim "veryLongProcess" fonksiyonuna parametre olarak geçtiğimiz fonksiyonumuz çağırılıyor ve ekrana gelen sonuç yazılıyor.

Not : Bu kodu anlayabilmen için JavaScript'de asenkron metot çağrımının nasıl yapıldığını biliyor olman lazım. Normalde bunu Temel JavaScript kategorisinde anlatacağım ama erken geldiysen, internette ufak bir araştırma yapmanı tavsiye ederim.

Bu programı aynı şekilde çalıştırdıktan sonra istersen 10 tane internet tarayıcı penceresi açarak, hepsi ile aynı anda talepte bulunabilirsin. Senin de göreceğin gibi, sanki paralel çalışıyormuşcasına, 10 saniye içerisinde hepsine cevap gelecektir.

Not : Denemeyi Chrome veya Firefox ile yapıyorsan, pencerelerin halen birbirini beklediğini fark edebilirsin. Ancak bu Node tarafı ile değil, Chrome ve Firefox tarafı ile ilgili bir sorun. Bu amcalar, bir URL'e gönderilen talebe cevap gelmeden aynı URL'e ikinci bir istek göndermiyorlar. Bizim denememizde tüm istemciler tek makinede olduğu için, bu duruma düşüyoruz. Konu hakkında ayrıntılı bilgiye buradan ulaşabilirsin.

Son bir ek bilgi. Bazı durumlarda, çalıştırdığımız algoritma, gerçekten de çok kompleks işlemler yapar (Pi sayısının dibini mi bulmak istiyoruz, ne yapıyorsak artık) ve bu işlemler gerçekten de üzerinde çalıştığımız Thread üzerinde yürür. Bu durumda, yukarıda anlatılanlar bir işe yaramaz ve tüm sistemi kilitleme tehlikesi ile karşı karşıya kalırız. Böyle durumlar için, sadece bu yoğun işlem yapan kısmı, farkı bir Process'e taşıyabilir ve işlem bittiğinde de bize geri haber verilmesini sağlayabiliriz (Aynı veritabanına emir verip beklemeye geçmek gibi). Ancak bu konuyu, ileride ayrı bir makalede anlatacağım inşallah. Şimdilik sadece kulak dolgunluğu olması açısından söylüyorum.

Umarım teorik bilgilerle fazla sıkmamışımdır ancak ileride komik durumlara düşmemek için bunları öğrenmemiz gerekiyordu. Makalemizi tamamlarken, öğrendiklerimizi özetleyelim.
  • Node ile yazdığımız programlar, tek bir Process ve tek bir Thread üzerinde çalışırlar.
  • Klasik mantıkta, işlemleri paralel yürütmek için Process ve Thread'ler kullanılır.
  • Bilgisayarın işlemcisi, Process ve Thread'leri paralel çalıştırmak için Context Switch denilen yöntemi uygular.
  • Context Switch yönteminde açılan Thread sayısı arttıkça, sistemin cevap verme süresi uzar ve bellek ihtiyacı artar.
  • Yazdığımız programların üzerinde çalıştığı Thread, çok çok çok büyük bir zamanını bekleyerek geçirir.
  • "Asynchronous event-driven" yaklaşımı, bu bekleme sürelerini daha verimli kullanmak için soruna farklı bir açıdan yaklaşır.
  • Bu yaklaşımda, artan istemci sayısı, sistemin cevap verme süresini uzatmaz ve bellek ihtiyacını arttırmaz.
  • Bu yaklaşımı kullanabilmek için, yazdığımız tüm programı "Event-driven, non-blocking I/O model" yaklaşımına uygun olarak, üzerinde çalıştığımız Thread'i bloklamayacak şekilde yazmamız gerekir.
  • Eğer ki Thread'i bloklayacak kompleks bir algoritma işimiz varsa, bu işi farklı bir Process'e atabilir ve orada sonuçlanmasını bekleyebiliriz.


Yorum Gönder

  1. Levent hocam, javascript ile asenkron fonksiyonlar konusunda da makale bekliyoruz.

    YanıtlaSil
  2. Böylesi harika bir yazı nasıl olur da hala ön plana çıkmamış...

    YanıtlaSil
  3. Efsane ...bu işileri anlatan türk bulmak helal olsun hocam sana.. Çok önemli konuları gayet zevkli ve anlaşılır anlatmışsın Heycanla takipçisi oldum sitenin.. Yeni yazılarını haycanla bekliyorum okumaya devam edeceğim

    YanıtlaSil
  4. Efsane ...bu işileri anlatan türk bulmak helal olsun hocam sana.. Çok önemli konuları gayet zevkli ve anlaşılır anlatmışsın Heycanla takipçisi oldum sitenin.. Yeni yazılarını haycanla bekliyorum okumaya devam edeceğim

    YanıtlaSil
  5. Değerli bir kaynak çok teşekkür ediyorum

    YanıtlaSil
  6. Değerli bir kaynak çok teşekkür ediyorum

    YanıtlaSil
  7. Ustad selamlar, asenkron kod cagirimda callback in sonuc firlatmasi icin gorev atanip bekleniyor ve thread e girip cikilmiyor ise callback kodu hangi thread te calisiyor. Yeni bir thread acilmiyor mu?

    YanıtlaSil
    Yanıtlar
    1. hayır hocam :) orada açıkladı hocam zaten

      Sil
  8. cidden harika bir yazı olmuş öğrendiğim şeyleri sayenizde çok güzel pekiştirdim ve eksiklerini doldurdum. elinize sağlık

    YanıtlaSil

 
Top