Javascript Nasıl Çalışır? Event loop ve Asenkron Programlama ile Daha İyi Kod Yazmanın Yolu

Mustafa Morbel
8 min readAug 15, 2023

--

Bu kez, ilk yazımızda ele aldığımız single-threaded ortamında programlama yapmanın kısıtlamalarını gözden geçirerek, Event loop ve async/await kullanarak bu kısıtları nasıl aşacağımızı ele alacağız.

Henüz serinin ilk yazısını okumadıysanız…

Neden Single-threaded sınırlaması var?

Tek iş parçacığı sınırlaması, JavaScript’in özünde yer alan bir kısıtlamadır. JavaScript, tarayıcı ortamında veya Node.js gibi ortamlarda tek bir iş parçacığında çalışır. Bu nedenle, aynı anda yalnızca bir işlem yapabilir.

Bu sınırlama, JavaScript’in çalıştığı ortamlardaki tasarım tercihlerinden kaynaklanmaktadır. Örneğin, tarayıcılar web sayfalarını oluşturmak ve kullanıcı etkileşimini sağlamak için JavaScript kullanırken, Node.js sunucu tarafı işlemleri gerçekleştirmek için JavaScript’i kullanır. Her iki durumda da, tek iş parçacığı kullanımı hedeflenir.

Tek iş parçacığı sınırlamasının temel nedenleri şunlardır:

  1. Basitlik: Tek iş parçacığı modeli, programlamayı daha basit hale getirir. Birden çok iş parçacığıyla çalışmak, senkronizasyon, eşzamanlılık ve yarış durumları gibi karmaşık problemlere yol açabilir.
  2. Veri tutarlılığı: Tek iş parçacığı sınırlaması, birden çok iş parçacığı arasında paylaşılan veriye erişim konusunda sorunları ortadan kaldırır. Eşzamanlı erişim durumunda veri tutarlılığı sorunları ortaya çıkabilir. Tek iş parçacığı modeliyle, veri tutarlılığını sağlamak daha kolaydır, çünkü yalnızca bir iş parçacığı verilere erişebilir.
  3. Güvenlik: Tek iş parçacığı modeli, tarayıcı ortamında güvenliği sağlamak için kullanılır. Birden çok iş parçacığıyla çalışmak, güvenlik açıklarına neden olabilir. Tek iş parçacığı modeli, kötü amaçlı kodların başka iş parçacıklarına zarar vermesini önler.

Bir JavaScript programının yapı taşları

Javascript uygulamanızı tek bir .js dosyasına yazıyor olabilirsiniz, ancak programınız aynı anda yalnızca biri çalışacak birkaç bloktan oluşur ve geri kalanı daha sonra çalıştırılacak. En yaygın blok birimi function dır.

Javascripte yeni başlayan çoğu geliştiricinin yaşadığı sorun, sonra ‘nın kesinlikle ve hemen şu an ‘ın ardından gerçekleşmediğini anlamaktır. (Karmaşık gelmiş olabilir, birlikte anlayacağımızı umuyorum.)
Başka bir deyişle, şu an ’da tamamlanamayan görevlerin, asenkron olarak tamamlanacakları anlamına gelir.

Aşağıdaki örneğe bir göz atalım:

var response = ajax('https://example.com/api');

console.log(response);
// response henüz hazır değil.

Muhtemelen standart Ajax isteklerinin senkron olarak tamamlanmadığını biliyorsunuzdur, yani kod yürütülmesi sırasında ajax(...) fonksiyonunun herhangi bir değeri dönmesi için henüz zaman yoktur.

Asenkron bir işlevin sonucunu beklemek için basit bir yol, bir callback fonksiyonu kullanmaktır:

ajax('https://example.com/api', function(response) {
console.log(response); // `response` şimdi kullanılabilir.
});

Ajax isteğini sadece bir örnek olarak kullandık. Herhangi bir kod bloğunu asenkron olarak çalıştırabilirsiniz.

Bu setTimeout fonksiyonu ile yapılabilir. SetTimeout fonksiyonu daha sonra gerçekleşecek bir olayı (zaman aşımı) ayarlar.

function first() {
console.log('first');
}
function second() {
console.log('second');
}
function third() {
console.log('third');
}
first();
setTimeout(second, 1000);
third();

Bunun çıktısı aşağıdaki gibi olacaktır:

first
third
second

Event loop nedir?

JavaScript asenkron koda izin verse de (setTimeout örneğinde gördüğümüz gibi), JavaScript’in kendisi, ES6'ya kadar gerçekten asenkron çalışma kavramını doğrudan içinde barındırmıyordu. JavaScript motoru, herhangi bir anda programınızın yalnızca tek bir bölümünü yürütmekten başka bir şey yapmamıştır.

JavaScript motorunun programınızın parçalarını nasıl yürüteceğini kim söyler? Gerçekte, JS Motoru izole bir şekilde çalışmaz — tipik web tarayıcısı veya Node.js için geliştiriciler için tipik olan bir ortamda çalışır. Aslında, günümüzde JavaScript, robotlardan akıllı ampullere kadar her türlü cihaza gömülüdür. Her bir cihaz, JS Motoru için farklı bir tür barındırma ortamını temsil eder.

Tüm ortamlarda ortak olan nokta, JS kod yürütmesini her seferinde JS Motorunu çağırarak, programınızın birden çok bölümünün zaman içinde yürütülmesini yöneten yerleşik bir mekanizma olan olay döngüsüdür.

Örneğin, JavaScript programınızın sunucudan veri almak için bir Ajax isteği yapması durumunda, “response” kodunu bir fonksiyona (callback) ayarladığınızda, JS Motoru şunu söyler:
“Şimdilik yürütme işlemini askıya alacağım, ancak ağ isteğiyle işin bittiğinde ve verileriniz varsa lütfen bu fonksiyonu geri çağır. (please call this function back) ”

Tarayıcı, ağdan gelen yanıtı dinlemek üzere ayarlanır ve size dönecek bir veri olduğunda “callback” fonksiyonunu event loop’a yerleştirerek zamanlamayı planlar.

Bu şekilde, tarayıcı ağdan gelen yanıtı beklerken diğer işlemleri yapabilir ve asenkron bir şekilde çalışabilir. Yanıt geldiğinde, callback fonksiyonu olay döngüsüne eklenir ve uygun zamanda çalıştırılır. Böylece, asenkron olarak veri alabilir ve diğer işlemlere devam edebilirsiniz. Event loop, asenkron işlemleri koordine etmek ve verimli bir şekilde çalışmalarını sağlamak için kullanılır.

Bu mekanizma sayesinde, JavaScript programınızın tarayıcıda veya başka bir ortamda asenkron olarak çalışmasını sağlayabilirsiniz. Veri almak, ağ istekleri yapmak, dosya okumak gibi işlemlerde zaman alıcı işlemler gerçekleştiğinde, diğer işlemlerin etkilenmeden devam etmesini sağlayabilirsiniz.

Olay döngüsü ve asenkron programlama, JavaScript’i daha etkili, daha hızlı ve daha verimli hale getirir. Bu sayede kullanıcı arayüzünün yanıt vermesi sağlanır ve kullanıcı deneyimi iyileştirilir.

console.log('Hi');
setTimeout(function cb1() {
console.log('cb1');
}, 5000);
console.log('Bye');

Bu kodu çalıştıralım ve ne olduğunu görelim:

  1. Başlangıçta durum açık. Tarayıcı konsolu temiz ve Call Stack boş.
  2. console.log(‘Hi’) Call Stack’e eklenir.
  3. console.log(‘Hi’) çalıştırılır. (tarayıcı konsolunda “Hi” yazdığını görürüz)
  4. console.log(‘Hi’) Call Stack’ten kaldırılır.
  5. setTimeout(function cb1() {…}) Call Stack’e eklenir.
  6. setTimeout(function cb1() {…}) çalıştırılır. Tarayıcı, Web API’lerin bir parçası olarak bir zamanlayıcı oluşturur. Sizin için geri sayımı yönetecek.
  7. setTimeout(function cb1() {…}) kendisi tamamlanır ve Call Stack’ten kaldırılır.
  8. console.log(‘Bye’) Call Stack’e eklenir.
  9. console.log(‘Bye’) çalıştırılır. (tarayıcı konsolunda “Bye” yazdığını görürüz)
  10. console.log(‘Bye’) Call Stack’ten kaldırılır.
  11. En az 5000 ms geçtikten sonra, zamanlayıcı tamamlanır ve cb1 callback fonksiyonu Callback Kuyruğu’na ekler. (Callback Queue)
  12. Event loop, cb1 Callback Queue’dan alır ve Call Stack’e iter.
  13. cb1 çalıştırılır ve console.log('cb1') Call Stack’e eklenir.
  14. console.log('cb1') çalıştırılır.
  15. console.log('cb1') Call Stack’ten kaldırılır.
  16. cb1 Call Stack’ten kaldırılır.

Kodun çıktısı şu şekilde:

Hi
Bye
cb1

setTimeout(…) nasıl çalışır?

Önemli bir nokta, setTimeout(…) otomatik olarak callback fonksiyonunu event loop kuyruğuna koymaz. Bir zamanlayıcı kurar. Zamanlayıcı süresi dolduğunda, ortam callback fonksiyonunuzu event loop’a yerleştirir, böylece gelecekteki bir tick (adım) bunu alır ve yürütür. Aşağıdaki örneğe bir göz atın:

setTimeout(function() {
console.log('Hello from setTimeout!');
}, 2000);

Bu kod, 2 saniye (2000 milisaniye) sonra “Hello from setTimeout!” mesajını konsola yazdıracaktır. setTimeout, bir zamanlayıcı ayarlar ve süresi dolduğunda Event loop’a callback fonksiyonunuzu yerleştirir. Bu sayede, bekletme süresi boyunca diğer işlemler gerçekleştirilebilir ve callback planlanan süre sonunda çalıştırılır.

Bu örnekte setTimeout kullandık, ancak diğer Web API’leri de aynı asenkron mantığı kullanarak kullanabilirsiniz. Örneğin, fetch API’si ile veri almak, FileReader API’siyle dosya okumak gibi işlemler de benzer şekilde Event loop’a callback ekleyerek çalışır.

Bir başka örneğe bakalım:

console.log('Hi');
setTimeout(function() {
console.log('callback');
}, 0);
console.log('Bye');

Bekleme süresi 0 ms olarak ayarlanmış olmasına rağmen tarayıcı konsolundaki sonuç aşağıdaki gibi olacaktır:

Hi
Bye
callback

Event loop’un ne yaptığını ve setTimeout’ın nasıl çalıştığını biliyorsunuz: setTimeout’ı ikinci argümanını 0 olarak çağırmak, sadece callback fonksiyonunuzu Call Stack boşalana kadar ertelenmesini sağlar.

ES6'da “Job Queue” nedir?

ES6'da “Job Queue” adı verilen yeni bir kavram tanıtıldı. Bu, Event loop kuyruğunun üzerine yerleştirilen bir katmandır. Promise’lerin asenkron davranışıyla uğraşırken genellikle bununla karşılaşırsınız.

Şimdi kavramı basitçe ele alalım, böylece daha sonra Promise’ler ile asenkron davranışı tartıştığımızda, bu işlemlerin nasıl planladığını ve işlediğini daha iyi anlayabiliriz.

Job Queue, Event loop kuyruğunun her bir tick’inin sonuna eklenmiş bir kuyruktur. Event loop tick’inin bir parçası olarak gerçekleşebilecek belirli asenkron işlemler, Event loop kuyruğuna yeni bir olay(event) eklenmesine neden olmaz, bunun yerine mevcut tick’in job kuyruğunun sonuna bir öğe yani Job ekler.

Bu, daha sonra yürütülecek başka bir işlevselliği ekleyebileceğiniz anlamına gelir ve başka bir şey olmadan hemen sonra yürütüleceğinden emin olabilirsiniz.

Bir Job, aynı kuyruğun sonuna daha fazla Job eklenmesine neden olabilir. Teorik olarak, bir Job “döngüsü” sonsuz bir şekilde dönerek programı bir sonraki Even loop tick’ine geçmek için gerekli kaynaklardan mahrum bırakabilir. Kavramsal olarak, bu, kodunuzda uzun süre çalışan veya sonsuz bir döngüyü ifade etmekle (örneğin, while (true) ..) benzer olur.

Callbacks

Biliyorsunuz ki, callback fonksiyonlar javascript programlarında asenkronluğu ifade etmenin ve yönetmenin en yaygın yoludur. Gerçektende Callback Javascript dilindeki en temel asenkron desendir.

Ancak Callback fonksiyonlar bazı sorunlarla gelir.

Nested Callbacks

Örneğe bir göz atalım:

listen('click', function (e){
setTimeout(function(){
ajax('https://api.example.com/endpoint', function (text){
if (text == "hello") {
doSomething();
}
else if (text == "world") {
doSomethingElse();
}
});
}, 500);
});

Burada üç fonksiyon zinciri bir araya gelmiş durumda ve her biri asenkron bir seride bir adımı temsil ediyor.

Bu tür kod genellikle “callback hell” -callback cehennemi- olarak adlandırılır. Ancak “callback hell”, aslında iç içe geçme (nested) ile neredeyse hiç ilgili değildir. Bu, bundan çok daha derin bir sorun.

İlk olarak, “click” olayını bekliyoruz, ardından zamanlayıcının tetiklenmesini bekliyoruz, ardından Ajax yanıtının gelmesini bekliyoruz ve bu noktada her şey tekrarlanabilir.

İlk bakışta, bu kod, asenkronluğunu doğal olarak ardışık adımlara eşlemiş gibi görünebilir:

listen('click', function (e) {
// ..
});

Daha sonra şu çalışır:

setTimeout(function(){
// ..
}, 500);

ve daha sonra şu çalışır:

ajax('https://api.example.com/endpoint', function (text){
// ..
});

ve son olarak

if (text == "hello") {
doSomething();
}
else if (text == "world") {
doSomethingElse();
}

Dolayısıyla, asenkron kodunuzu ifade etmek için bu tür ardışık bir yol çok daha doğal görünüyor, değil mi? Promise’leri gördükten sonra fikriniz değişebilir.

Promise’ler ve Asenkron Kod Yönetimi

“Callback hell” sorununu çözmek ve daha temiz, daha okunaklı ve daha yönetilebilir bir asenkron kod yazmak için JavaScript’de Promise’leri kullanabiliriz. Promise’ler, gelecekteki bir değeri veya işlem sonucunu temsil eden nesnelerdir. Promise, bir işlem tamamlandığında veya başarılı bir sonuç üretildiğinde çözülür (resolve) veya bir hata oluştuğunda reddedilir (reject).

Promise’ler, asenkron işlemleri daha düzenli bir şekilde zincirlemek, kod karmaşıklığını azaltmak ve hataları daha etkili bir şekilde yönetmek için kullanılır. Aynı zamanda, .then() ve .catch() gibi metodlar sayesinde işlem sonuçlarını ve hataları yakalamak daha kolay hale gelir.

Örnek bir Promise kullanımı:

function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
const data = "Fetched data";
if (data) {
resolve(data); // Success
} else {
reject("Error fetching data"); // Error
}
}, 1000);
});
}

fetchData()
.then(result => {
console.log(result); // Fetched data
})
.catch(error => {
console.error(error); // Error fetching data
});

Async/await

ES2017 (ES8) ile gelen async ve await anahtar kelimeleri, Promise tabanlı asenkron kodun daha okunabilir ve senkron gibi görünen bir şekilde yazılmasını sağlar. async fonksiyonlar, her zaman bir Promise döndürür ve await anahtar kelimesi, bir Promise'in çözülmesini beklemek için kullanılır.

Örnek async/await kullanımı:

function fetchData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
const data = "Fetched data";
if (data) {
resolve(data);
} else {
reject("Error fetching data");
}
}, 1000);
});
}

async function main() {
try {
const result = await fetchData();
console.log(result); // Fetched data
} catch (error) {
console.error(error); // Error fetching data
}
}

main();

Event Loop ve Asenkron Programlama İlişkisi

Event loop, asenkron programlamanın temelini oluşturur. JavaScript’in single-threaded yapısı nedeniyle, Event loop asenkron işlemleri koordine eder ve sırayla işler. Event loop, Call Stack, Callback Queue gibi bileşenlerle birlikte çalışarak asenkron işlemleri planlar ve yönetir.

Event loop, Call Stack boş olduğunda Callback Queue’daki işlemleri Call Stack’e taşır ve işlerin sırayla çalışmasını sağlar. Bu sayede asenkron işlemler sırayla tamamlanır ve senkron kodunuzu engellemeden çalışabilirsiniz.

single-threaded model, programlamayı basit ve veri tutarlılığını kolay hale getirirken, aynı zamanda senkron işlemlerin engellenmesine yol açabilir. Bu nedenle asenkron programlama, JavaScript kodunun daha etkili, daha hızlı ve daha verimli çalışmasını sağlar. Bu, kullanıcı arayüzünün daha hızlı yanıt vermesini ve daha iyi bir kullanıcı deneyimi sunmasını sağlar.

Buraya kadar okuduğunuz için teşekkür ederim :) Önceki yazımı henüz okumadıysanız linkini buraya bırakıyorum.

--

--