Web Worker ile Main Thread'i Boşaltmak
Main thread tek şeritli bir yol gibi çalışır. Tüm JavaScript yürütme, DOM güncellemeleri, stil hesaplamaları ve kullanıcı etkileşimleri bu tek şeritte sıraya girer. Bir işlem uzadığında arkadaki her şey beklemek zorundadır; kullanıcı tıklar, tarayıcı duyamaz. Bu, tarayıcı mimarisinin temel bir özelliğidir ve devre dışı bırakılamaz.
Web Worker bu şeridi genişletmez. Yanına bambaşka bir şerit açar. İki şerit paralel akar ama asla doğrudan birleşmez — aralarındaki tek bağlantı mesajlaşma protokolüdür. Bu ayrılık bir kısıt gibi görünse de aslında Worker'ın gücünün kaynağıdır: main thread'i etkilemeden hesaplama yapar, çünkü iki şerit birbirinin durumunu paylaşmaz. Kullanıcı scroll ederken Worker asal sayı hesaplıyor olabilir; ikisi aynı anda, birbirinden habersiz ilerlemeye devam eder.
Sorun şu ki Worker'ı açmak yeterli değildir. Hangi işin Worker'a taşınacağını, veri aktarımının nasıl yönetileceğini ve sonucun main thread'e nasıl döneceğini yanlış planladığınızda kazanç ya silinir ya da beklediğinizin çok altında kalır. Aracın kendisi değil, nasıl kullanıldığı fark yaratır.
Worker'a taşınacak işin profili nasıl belirlenir
Web Worker her CPU yoğun göreve otomatik çözüm değildir. Bir işin Worker'a taşınmaya uygun olup olmadığını anlamak için önce o işin profilinin çıkarılması gerekir.
Chrome DevTools Performance panelinde kayıt alın ve main thread'i bloke eden görevlere bakın. 50 ms'nin üzerinde süren görevler flamegraph üzerinde belirgin biçimde görünür. O görevin çağrı yığınını incelediğinizde hangi fonksiyonun ne kadar süre tükettiği netleşir. Eğer bu görev DOM'a dokunmuyor, stil veya layout tetiklemiyorsa Worker'a taşınmaya adaydır. Eğer her adımda DOM ile konuşuyorsa Worker bu görevi çözemez; mimarinin kendisini gözden geçirmek gerekir.
Tipik uygun işler şunlardır: büyük veri kümelerini filtrelemek ya da sıralamak, kriptografik hesaplamalar, görüntü piksel işlemleri, 1 MB üzerindeki JSON ayrıştırmaları, Markdown veya şablon dönüşümleri, istatistiksel hesaplamalar. Uygunsuz işler ise DOM manipülasyonu, window veya document nesnesine erişim, localStorage operasyonları ve framework'ün reaktif durum sistemiyle senkronize çalışması gereken güncellemelerdir. Bu iki küme birbirine karışmaya başladığı anda Worker'ın değeri düşer.
Bir Worker'ın gerçekten fark yaratıp yaratmadığını ölçmenin kısa yolu şudur: işi Worker'sız çalıştırın, Total Blocking Time değerini not alın; sonra aynı işi Worker üzerinden çalıştırın ve iki değeri karşılaştırın. Fark ihmal edilebilirse bottleneck başka bir yerdedir; Worker eklemek yalnızca karmaşıklık getirir.
postMessage maliyeti ve transferable objects
Worker ile main thread arasındaki tek köprü postMessage metodudur. Bu metot mesajı structured clone algoritmasıyla kopyalayarak iletir. Strings, numbers, plain objects ve diziler için bu kopyalama maliyeti ihmal edilebilir düzeydedir. Ancak büyük ArrayBuffer veya ImageData gibi yapılar kopyalanırken hem bellek hem süre tüketimi belirginleşir. 50 MB'lık bir piksel tamponunu structured clone ile göndermek, düşük güçlü bir mobil cihazda 100 ms'yi aşabilir.
// Ana thread: Worker başlat ve veri gönder
const worker = new Worker('processor.js');
worker.postMessage({ data: largeArray });
// processor.js — Worker içinde
self.onmessage = function(e) {
const result = heavyComputation(e.data.data);
self.postMessage({ result });
};
Transferable objects bu kopyalama sorununu ortadan kaldırır. Bir ArrayBuffer, transfer listesiyle gönderildiğinde kopyalanmaz; sahipliği devredilir. Kaynak taraftaki referans geçersiz hale gelir, verinin kendisi yeni ortama taşınır. Aynı 50 MB'lık tamponu transfer etmek, kopyalamak yerine birkaç mikrosaniye alır.
const buffer = new ArrayBuffer(50 * 1024 * 1024); // 50 MB
worker.postMessage({ buffer }, [buffer]);
// Bu satırdan sonra buffer.byteLength === 0
// Sahiplik Worker'a geçti, ana thread artık okuyamaz
ImageBitmap ve MessagePort da transfer edilebilir nesneler arasındadır. Görüntü işleme pipeline'larında bu iki tip sık kullanılır. Mesajlaşma sıklığı da önemlidir: her postMessage çağrısı bir olay döngüsü turu gerektirir. Binlerce küçük mesaj yerine işleri gruplamak ve tek seferde büyük bir yük göndermek — bundle analizi mantığındaki birleştirme yaklaşımına benzer biçimde — çok daha düşük overhead bırakır.
Worker'dan DOM'a geri dönmek: koordinasyon sınırı
Worker'ın en katı kuralı DOM'a erişememesidir. document, window, getElementById ve benzeri tüm API'ler Worker scope'unda mevcut değildir. Bu sınır kısıtlayıcı değil, koruyucudur: iki thread aynı anda aynı DOM düğümünü değiştirmeye çalışsaydı ne olacağını tahmin etmek çok güç olurdu.
Koordinasyon şöyle işler: Worker hesaplama yapar, sonucu postMessage ile main thread'e gönderir, main thread DOM'u günceller. Basit görünen bu zincir, yanlış kurulduğunda yeni sorunlar doğurur. Sık karşılaşılan bir hata, Worker'dan çok sayıda küçük güncelleme göndermektir. Bir görüntü işleme Worker'ı her satırı bitirince postMessage gönderirse, main thread bu mesaj yağmurunu işlemek için sürekli meşgul olur. Zaten boşaltmak istediğiniz yük geri döner. Doğru yaklaşım tüm hesaplamayı tamamlayıp tek seferde sonucu iletmek ya da belirli aralıklarla — örneğin her 200 ms'de bir — yalnızca ilerleme yüzdesini göndermektir. Bu karar INP üzerinde doğrudan etkilidir: main thread ne kadar az kesintiye uğrarsa etkileşim gecikmesi o kadar düşük kalır.
Canvas ile çalışıyorsanız OffscreenCanvas iyi bir seçenektir. Bir <canvas> öğesinde transferControlToOffscreen() çağrıldığında canvas kontrolü Worker'a devredilir ve Worker bu canvas'a doğrudan çizim yapabilir. Main thread araya girmeden tüm render döngüsü Worker içinde tamamlanır. WebGL tabanlı ağır görselleştirmelerde veya gerçek zamanlı veri grafiklerinde bu desen ölçülebilir fark yaratır; main thread animasyon karesini beklemek yerine tamamen boşta kalır.
SharedArrayBuffer ve Atomics ile senkronizasyon
postMessage çoğu senaryo için yeterlidir; ancak iki thread'in aynı bellek bölgesini eş zamanlı paylaşması gerektiğinde SharedArrayBuffer devreye girer. Bu yapı kopyalama olmadan ortak bir bellek alanı tanımlar. Her iki taraf da aynı tampona okuyup yazabilir; mesaj alışverişi olmadan güncel değere ulaşabilir.
// Ana thread
const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);
worker.postMessage({ sab });
// Worker içinde
self.onmessage = function(e) {
const view = new Int32Array(e.data.sab);
Atomics.store(view, 0, 42);
// Ana thread bu değeri istediği zaman okuyabilir
};
Paylaşılan bellek yarış koşullarına kapı açar. İki thread aynı anda aynı bellek konumuna yazarsa veri bozulur. Atomics nesnesi bu sorunu çözmek için kesintisiz operasyonlar sunar: Atomics.store, Atomics.load, Atomics.add gibi metotlar başka bir thread araya girmeden tamamlanır. Atomics.wait ve Atomics.notify ise ilkel bir sinyal mekanizması kurar; Worker hesaplamayı bitirince main thread'i uyandırabilir.
Pratikte bu mekanizma yalnızca gerçek zamanlı ses işleme, yüksek frekanslı sensör verisi veya çok Worker'lı iş havuzları gibi özel durumlarda değer taşır. Sıradan uygulamalar için postMessage daha anlaşılır, daha az hata üretir ve yeterlidir. SharedArrayBuffer kullanabilmek için ayrıca sunucunun Cross-Origin-Opener-Policy: same-origin ve Cross-Origin-Embedder-Policy: require-corp başlıklarını göndermesi zorunludur; bu başlıklar olmadan SharedArrayBuffer tanımsız döner. Spectre açığı nedeniyle getirilen bu kısıt, paylaşılan belleği production ortamında dikkatli bir sunucu yapılandırmasına bağlar.
Comlink ile Worker arayüzünü sadeleştirmek
Ham postMessage API'siyle çalışmak; mesajları etiketlemek, yanıtları eşleştirmek, hata akışını yönetmek demektir. Tek bir hesaplama için bu kabul edilebilir. Birden fazla metot, farklı argüman tipleri veya dallanan yanıt akışları söz konusu olduğunda boilerplate hızla birikir ve Worker dosyası mesaj anahtarlamacılığına dönüşür.
Comlink bu sorunu zarif biçimde çözer. Google Chrome Labs tarafından geliştirilen bu kütüphane, Worker nesnesini Proxy üzerinden ana thread'de doğrudan çağrılabilir hale getirir. Worker'da bir nesne Comlink.expose() ile dışa açılır, ana thread'de Comlink.wrap() ile sarılır; artık Worker metodları sanki yerel fonksiyon gibi await ile çağrılabilir.
// worker.js
import * as Comlink from 'comlink';
const api = {
async processData(data) {
return heavyComputation(data);
},
async sortLargeArray(arr) {
return arr.slice().sort((a, b) => a - b);
}
};
Comlink.expose(api);
// main.js
import * as Comlink from 'comlink';
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
const api = Comlink.wrap(worker);
const result = await api.processData(largeDataset);
const sorted = await api.sortLargeArray(bigArray);
Comlink'in arka planda yaptığı şey mesaj kimliği eşleştirmesidir: her çağrıya benzersiz bir ID atanır, Worker yanıt verince Promise çözülür. Hata durumunda Worker'dan fırlatılan istisna ana thread'de doğal biçimde yayılır. Kütüphanenin gzip sonrası boyutu yaklaşık 2 KB'tır; eklediği ağırlık ihmal edilebilir düzeydedir.
Dynamic import ile birleştirildiğinde Comlink daha güçlü bir desen sunar: ağır Worker modülü yalnızca ihtiyaç duyulduğunda yüklenir, sayfa açılışını etkilemez. Code splitting mantığıyla örtüşen bu yaklaşım hem hesaplama yükünü hem ağ maliyetini böler; kullanıcı heavy feature'ı tetikleyene kadar Worker hiç indirilmez.
Web Worker'ın sunduğu gerçek avantaj, hesaplamayı arka planda yürütürken main thread'i tamamen boşta bırakmasıdır. Kullanıcı scroll eder, tıklar, yazar; animasyonlar akar. Ağır iş biterken hiçbir şey hissedilmez. JavaScript parse ve execute maliyetini daha önce azalttıysanız ve hâlâ uzun görevler kalıyorsa, Worker bu boşluğu doldurmak için doğru araçtır.
Başlangıç için küçük bir senaryo seçin: büyük bir JSON'ın ayrıştırılması veya binlerce öğeli bir listenin sıralanması. Profil çıkarın, Worker'a taşıyın, Total Blocking Time'ı yeniden ölçün. Fark görünüyorsa aynı yaklaşımı diğer darboğazlara uygulayın. Worker ekosistemi basit bir API'den ibaret görünse de doğru kullanıldığında tarayıcının paralel kapasitesini ilk kez gerçek anlamda devreye sokar.