JavaScript event loop (olay döngüsü), tek iş parçacığında çalışan JavaScript'in asenkron işleri sırayla yürütmesini sağlayan mekanizmadır. Çalışma ortamı bir kuyruktan iş (job) alır, onu çağrı yığını (call stack) tamamen boşalana kadar kesintisiz çalıştırır, sonra sıradakine geçer. Promise işleyicileri ve await devamları microtask kuyruğuna, setTimeout gibi zamanlayıcılar ise task kuyruğuna girer; her task'tan sonra microtask kuyruğu tamamen boşaltılır.

Bu yazıda MDN'in JavaScript yürütme modeli (execution model) sayfasını temel alarak yığını, kuyruğu, "run-to-completion" kuralını ve microtask ile task arasındaki farkı anlatıyoruz. Tüm sıralama örnekleri Node.js 24'te çalıştırıldı ve çıktıları gerçek sonuçtur.

Motor ve çalışma ortamı (host)

JavaScript'in çalışması iki parçanın iş birliğine dayanır: dili uygulayan JavaScript motoru ve dış dünyayla bağlantıyı sağlayan çalışma ortamı (host). Tarayıcıda DOM ve zamanlayıcılar, sunucuda ise Node.js host görevini üstlenir. ECMAScript spesifikasyonu "job" kavramını ve Promise işlerinin sırasını tanımlar; olay döngüsünün tam ayrıntıları ise HTML standardı gibi host belgelerinde yer alır. Node.js de bu modeli büyük ölçüde taklit eder.

Yığın, heap ve kuyruk

Yığın, heap ve kuyruk: Heap, Kuyruk (job queue), Yığın (call stack)

Spesifikasyonda JavaScript'i bağımsız olarak çalıştıran her birime agent denir. MDN, bir agent'ı kabaca bir iş parçacığına benzetir ve her agent'ın üç yapısı olduğunu anlatır:

YapıNe tutar?Çalışma biçimi
HeapOluşturulan nesnelerBüyük, yapılandırılmamış bellek bölgesi
Kuyruk (job queue)Çalışmayı bekleyen işlerGenelde ilk giren ilk çıkar (FIFO)
Yığın (call stack)Yürütme bağlamları (stack frame)Son giren ilk çıkar (LIFO)

Bir fonksiyon çağrıldığında yığına yeni bir çerçeve eklenir, fonksiyon dönünce çıkarılır. Her iş boş bir yığınla başlar ve yığın yeniden boşaldığında tamamlanmış sayılır. Fonksiyonların değişken ortamını nasıl hatırladığını closure yazımızda, belleğin nasıl temizlendiğini bellek yönetimi yazımızda bulabilirsiniz.

Run-to-completion: bir iş bitmeden diğeri başlamaz

Event loop'un en önemli garantisi şudur: kuyruktan alınan her iş, başka bir iş başlamadan önce sonuna kadar çalışır. Bir fonksiyon çalışırken başka bir kod araya girip onun kullandığı verileri değiştiremez. MDN bunu C gibi dillerdeki iş parçacıklarıyla karşılaştırır: orada bir fonksiyon her an durdurulup başka kod çalıştırılabilir.

javascript
const promise = Promise.resolve();
let i = 0;
promise.then(() => { i += 1; console.log(i); });
promise.then(() => { i += 1; console.log(i); });
// 1
// 2

İki işleyici yarış durumu (race condition) yaratacakmış gibi görünse de çıktı her zaman 1 ve 2'dir, çünkü her işleyici bir bütün olarak çalışır.

Bu modelin bedeli de vardır: bir iş uzun sürerse kuyruktaki diğer her şey bekler. Aşağıdaki örnekte zamanlayıcı 0 ms ile kurulmuş olsa da senkron döngü bitene kadar çalışamaz:

javascript
const basla = Date.now();
setTimeout(() => console.log('zamanlayıcı', Date.now() - basla >= 500), 0);
while (Date.now() - basla < 500) {}
console.log('döngü bitti');
// döngü bitti
// zamanlayıcı true

Spesifikasyon da aynı kuralı koyar: bir agent'ta aynı anda yalnızca bir job değerlendirilebilir ve başlayan bir job, başka bir job başlamadan tamamlanmalıdır.

Microtask ve task kuyruğu

Microtask ve task kuyruğu: then, catch, finally işleyicileri, await sonrası kod, queueMicrotask(fn), setTimeout, setInterval

MDN'e göre işler her zaman eşit öncelikle alınmaz. HTML olay döngüsü işleri ikiye ayırır: task ve microtask. Microtask'ların önceliği yüksektir; task kuyruğundan yeni bir iş alınmadan önce microtask kuyruğu tamamen boşaltılır. ECMAScript spesifikasyonundaki bir not da web tarayıcılarının ve Node.js'in Promise işlerini diğer işlerden daha yüksek öncelikle ele aldığını belirtir.

KaynakKuyrukNot
then, catch, finally işleyicileriMicrotaskPromise sonuçlanınca sıraya girer
await sonrası kodMicrotaskDeğer zaten hazır olsa bile
queueMicrotask(fn)MicrotaskPromise işleriyle aynı öncelik
setTimeout, setIntervalTaskSüre 0 olsa bile sonraki turda
G/Ç ve olay geri çağrılarıTaskHost tarafından eklenir

Aşağıdaki örnek, sırayı somut olarak gösterir. Numaralar gerçek çıktı sırasıdır:

javascript
console.log('1 senkron başlangıç');
setTimeout(() => console.log('6 task: setTimeout'), 0);
Promise.resolve()
  .then(() => console.log('3 microtask: then 1'))
  .then(() => console.log('5 microtask: then 2'));
queueMicrotask(() => console.log('4 microtask: queueMicrotask'));
console.log('2 senkron bitiş');
// 1 senkron başlangıç
// 2 senkron bitiş
// 3 microtask: then 1
// 4 microtask: queueMicrotask
// 5 microtask: then 2
// 6 task: setTimeout

Burada dikkat edilecek nokta, "then 2" işleyicisinin "then 1" çalışıp bitene kadar kuyruğa girmemesidir. Bu yüzden queueMicrotask ile eklenen iş araya girer. setTimeout ise tüm microtask'lar bitene kadar bekler.

await ve microtask sırası

Bir async fonksiyon await ile karşılaştığında kontrol çağırana döner; bekleme bitince fonksiyonun devamı yeni bir microtask olarak sıraya girer. MDN'e göre bu, beklenen değer zaten çözülmüş bir Promise ya da Promise olmayan bir değer olsa bile geçerlidir.

javascript
async function a() {
  console.log('a başladı');
  await b();
  console.log('a devam etti');
}
async function b() {
  console.log('b çalıştı');
}
setTimeout(() => console.log('setTimeout'), 0);
a();
Promise.resolve().then(() => console.log('then'));
console.log('senkron son');
// a başladı
// b çalıştı
// senkron son
// a devam etti
// then
// setTimeout

b() senkron olarak çalışır çünkü async fonksiyonun ilk await'e kadar olan kısmı senkron yürütülür. a'nın devamı, sonradan kaydedilen then'den önce kuyruğa girdiği için önce çalışır. Ayrıntılar için async/await rehberimize bakın.

Microtask kuyruğu task'ları geciktirebilir

Microtask kuyruğu, task kuyruğuna geçilmeden önce tamamen boşaltıldığı için kendini sürekli yeniden kuyruğa ekleyen microtask'lar zamanlayıcıları ve G/Ç geri çağrılarını geciktirir. Aşağıda 100.000 adımlık bir microtask zinciri, 0 ms'lik zamanlayıcıdan önce tamamlanır:

javascript
setTimeout(() => console.log('timeout'), 0);
function zincir(n) {
  if (n === 0) return;
  Promise.resolve().then(() => zincir(n - 1));
}
zincir(100000);
Promise.resolve().then(() => console.log('mikrogörevler'));
// mikrogörevler
// timeout

Uzun işleri parçalara bölmek

MDN'in önerisi, işleri kısa tutmak ve mümkünse uzun bir işi birkaç işe bölmektir. Büyük bir listeyi işlerken her parçadan sonra kalan kısmı setTimeout ile yeni bir task olarak planlarsanız, arada bekleyen diğer task'lar da çalışma fırsatı bulur:

javascript
function parcala(liste, i = 0) {
  const son = Math.min(i + 2, liste.length);
  for (; i < son; i++) console.log('işlendi', liste[i]);
  if (i < liste.length) setTimeout(() => parcala(liste, i), 0);
}
parcala(['a', 'b', 'c', 'd', 'e']);
setTimeout(() => console.log('araya giren görev'), 0);
// işlendi a
// işlendi b
// işlendi c
// işlendi d
// araya giren görev
// işlendi e

Node.js tarafında CPU yoğun işler için olay döngüsünü bloklamamaya dair pratik öneriler Node.js performans optimizasyonu yazımızda yer alıyor.

JavaScript tek iş parçacıklı mı?

Bir agent içinde evet: aynı anda yalnızca bir iş yürütülür. Promise yöntemleriyle "eşzamanlı" görünen işler aslında sırayla, kontrolü birbirine devrederek ilerler. MDN'e göre gerçek paralel yürütme, her biri kendi agent'ına sahip olan worker'larla (tarayıcıda Web Worker, Node.js'te worker threads) mümkündür. Bu agent'lar belleği yalnızca SharedArrayBuffer ve Atomics gibi açık mekanizmalarla paylaşır.

Event loop'un bir başka garantisi de "asla bloklamama"dır: G/Ç genellikle olaylar ve callback'lerle yönetildiği için bir ağ isteği beklenirken uygulama diğer işleri yapmaya devam edebilir. MDN, alert() veya senkron XHR gibi eski istisnalardan kaçınılmasını önerir.

Sık yapılan hatalar

Sık yapılan hatalar: setTimeout(fn, 0)'ın hemen çalışacağını sanmak, Senkron ağır döngüler, Sonsuz microtask zinciri, Sıralamayı tahmin etmek yerine ölçmemek
  • setTimeout(fn, 0)'ın hemen çalışacağını sanmak: mevcut senkron kod ve tüm microtask'lar bittikten sonra çalışır.
  • Senkron ağır döngüler: run-to-completion yüzünden diğer tüm işler bekler. İşi bölün veya worker'a taşıyın.
  • Sonsuz microtask zinciri: task kuyruğunu aç bırakır; zamanlayıcılar ve G/Ç geri çağrıları gecikir.
  • Sıralamayı tahmin etmek yerine ölçmemek: karmaşık sıralamalarda küçük bir betikle gerçek çıktıyı kontrol edin.
  • Gereksiz await zincirleri: her await ek bir microtask demektir ve sıralamayı değiştirir.

Sık Sorulan Sorular

Microtask ile task arasındaki fark nedir?

Microtask'lar (Promise işleyicileri, await devamları, queueMicrotask) yüksek önceliklidir ve mevcut iş bittiği anda, bir sonraki task'tan önce hepsi çalıştırılır. Task'lar (zamanlayıcılar, olaylar, G/Ç) birer birer alınır ve her birinin ardından microtask kuyruğu yeniden boşaltılır.

Event loop ECMAScript spesifikasyonunda mı tanımlı?

Spesifikasyon job kavramını, Promise işlerinin kaydedildikleri sırayla çalışması gerektiğini ve bir job'un tamamlanmadan diğerinin başlayamayacağını tanımlar. Task ve microtask ayrımı ise HTML standardı gibi host tanımlarından gelir.

Promise.resolve().then() ile queueMicrotask() aynı mı?

İkisi de microtask kuyruğuna iş ekler ve MDN'e göre aynı öncelikte, kaydedilme sırasıyla çalışır. Yukarıdaki sıralama örneğinde de iki kaynaktan gelen işlerin birbirinin arasına girdiğini görebilirsiniz.

Generator'lar da yığından çıkıp geri döner mi?

Evet. MDN, generator'ın yürütme bağlamının yield'de askıya alınıp yığından çıkarıldığını, next() çağrıldığında yığına geri konup kaldığı yerden devam ettiğini anlatır. Ayrıntılar için generator ve iterator yazımıza bakın.

Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.

İlgili içerikler: JavaScript öğrenme rehberi · Promise nedir · async/await · Closure · Bellek yönetimi · Node.js performans