Ders 17 / 25
Zamanlanmış Görevler
Takvimden tetiklenen işlerin tanımı ve tehlikeleri: yineleme kuralının hesaplanması, ay sonu ve saat dilimi tuzakları, sabit hız ile sabit gecikme ayrımı ve iki koşumun üst üste binmesini kira kilidiyle önleyen ölçülmüş çözüm.
İçindekiler
Bir önceki ders işleri dışarıdan aldı: havuzun elinde bir liste vardı, onu işçilere dağıttı. Arka plan işlerinin önemli bir bölümü ise dışarıdan gelmez, takvimden gelir. Gecikme bildirimi her sabah altıda, şube stok özeti her on beş dakikada bir, aylık ödünç raporu her ayın birinde üretilir.
Bunu “bir zamanlayıcı kur ve işi çağır” diye özetlemek yanıltıcıdır. Zamanlanmış görevin iki zor sorusu vardır. Birincisi yinelemenin nasıl tanımlandığı: “her ayın 31’i” olmayan bir tarihe düştüğünde ne olur, yerel saat değiştiğinde koşum kaçar mı. İkincisi çakışmadır: bir koşum, bir sonraki tetiklenmeden bitmezse iki koşum aynı anda çalışır ve aynı raporu iki kez üretir. Bu ders ikisini de kurar; ikincisini ölçerek çözer.
Yineleme Tanımı
Yineleyen görev iki parçadan oluşur: ne yapılacağı ve ne zaman yapılacağı. İkincisi bir veri yapısıdır ve tek bir işlevle sınanabilir: “şu andan sonraki koşum hangi an”.
// zamanlama.mjs — yineleyen gorev tanimindan sonraki kosum anlarini uretir (hepsi UTC) const TANIM = { gecikmeBildirimi: { tur: "gunluk", saat: 6, dakika: 0 }, stokOzeti: { tur: "aralik", dakika: 15 }, aylikRapor: { tur: "aylik", gun: 1, saat: 2, dakika: 30 }, ayinSonu: { tur: "aylik", gun: 31, saat: 2, dakika: 30 }, }; const DK = 60_000; function sonraki(tanim, su) { const d = new Date(su); if (tanim.tur === "aralik") { const adim = tanim.dakika * DK; return new Date(Math.floor(su / adim) * adim + adim); } if (tanim.tur === "gunluk") { const bugun = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), tanim.saat, tanim.dakika); return new Date(bugun > su ? bugun : bugun + 24 * 60 * DK); } // aylik: bu ayin gunu gecmisse sonraki ayin ayni gunu const buAy = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), tanim.gun, tanim.saat, tanim.dakika); return new Date(buAy > su ? buAy : Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, tanim.gun, tanim.saat, tanim.dakika)); } const BASLANGIC = Date.parse("2026-01-31T23:47:00Z"); // olcumun sabit baslangic ani for (const [ad, tanim] of Object.entries(TANIM)) { let an = BASLANGIC; const anlar = []; for (let i = 0; i < 3; i++) { an = sonraki(tanim, an).getTime(); anlar.push(new Date(an).toISOString().slice(0, 16).replace("T", " ")); } console.log(ad.padEnd(17), anlar.join(" ")); }
gecikmeBildirimi 2026-02-01 06:00 2026-02-02 06:00 2026-02-03 06:00 stokOzeti 2026-02-01 00:00 2026-02-01 00:15 2026-02-01 00:30 aylikRapor 2026-02-01 02:30 2026-03-01 02:30 2026-04-01 02:30 ayinSonu 2026-03-03 02:30 2026-03-31 02:30 2026-05-01 02:30
Başlangıç anı sabit bir değişmez olarak yazıldığı için çıktı her koşumda aynıdır; zamanlama kuralı böylece sınanabilir bir işleve dönüşür. “Şimdi” değerini işlevin dışından vermek, zamanlamayı sınanabilir kılan tek karardır.
İlk üç satır beklendiği gibi. Dördüncü satır beklendiği gibi değil.
Ay Sonu Tuzağı
ayinSonu tanımı “her ayın 31’i saat 02:30” diyor. Üretilen anlar 3 Mart, 31 Mart ve
1 Mayıs. Şubat’ın 31’i yok; Date.UTC(2026, 1, 31) taşan günü sonraki aya aktarıp
3 Mart’ı üretiyor. Nisan’ın 31’i de yok, o da 1 Mayıs’a kayıyor.
Bu, hesaplamanın hatası değil, tanımın eksikliğidir. Ayın son gününe bağlanan bir görev için iki politika arasında seçim yapmak gerekir: olmayan günü atlamak (o ay koşum yok) ya da ayın son gününe kelepçelemek (28, 29 ya da 30). Hangisinin doğru olduğu işe bağlıdır — aylık rapor kelepçelenmeli, ayın 31’inde biten bir aboneliğin yenilenmesi ise atlanmamalıdır. Yazılmayan politika, çalışma zamanının taşma davranışına teslim edilmiş demektir.
Saat Dilimi
Yukarıdaki hesabın tamamı UTC üzerinde yapıldı. Yerel saate bağlanan bir görev iki ek sorun üretir. Yaz saati uygulamasının ileri alındığı gece, yerel saatle 02:30 hiç yaşanmaz; o gece koşum kaçar. Geri alındığı gece ise 02:30 iki kez yaşanır; koşum iki kez tetiklenir.
Bu yüzden zamanlama kararı iki katmana ayrılır. Görevin tetiklenme anı UTC üzerinde tutulur ve saklanır; yerel saat yalnız kullanıcıya gösterilirken kullanılır. Yerel takvim gerçekten gerekiyorsa (örneğin “şube kapanışından sonra”) saat dilimi kimliği tanımın parçası olur ve dönüşüm her koşumda yeniden yapılır.
Sabit Hız ve Sabit Gecikme
Yinelemenin ikinci ayrıntısı, bir sonraki koşumun neye göre planlandığıdır.
Sabit hız (fixed rate) zamanlamada koşumlar takvime çakılıdır: 02:30, 03:30, 04:30. Koşum ne kadar sürerse sürsün bir sonrakinin anı değişmez. Sabit gecikme (fixed delay) zamanlamada ise bekleme, önceki koşumun bitişinden sayılır: koşum 40 dakika sürdüyse ve gecikme bir saatse, sonraki koşum 04:10’da başlar.
Aradaki fark kaymadır. Kırk dakika süren saatlik bir görev, sabit gecikmeyle günde 24 değil yaklaşık 14 kez koşar ve koşum anları her gün ilerler; bu ilerlemeye kayma (drift) denir. Sabit hızda kayma yoktur, ama karşılığında koşumların üst üste binmesi mümkün hâle gelir: koşum bir saatten uzun sürerse bir sonraki tetikleme, önceki hâlâ çalışırken gelir.
Yani sabit hız ile sabit gecikme arasındaki seçim, kayma ile çakışma arasındaki seçimdir. Kaymayı kabul edemiyorsak çakışmayı ayrıca engellemek zorundayız.
Çakışma
Çakışma yalnız uzun koşumdan doğmaz. İkinci ve daha sık karşılaşılan kaynak, uygulamanın birden çok örnek olarak dağıtılmasıdır. Zamanlayıcı uygulamanın içinde çalışıyorsa, üç örneğe dağıtılmış bir servis her sabah altıda gecikme bildirimini üç kez gönderir. Üçüncü kaynak yeniden dağıtımdır: eski örnek kapanmadan yenisi ayağa kalkarsa tetikleme ikiye çıkar.
Üçünün de çözümü aynıdır: görev, koşmadan önce koşma hakkını almak zorundadır.
Kira Kilidi
Bu hakkı veren yapıya kilit (lock) denir. Arka plan görevlerinde kullanılan biçimi kira kilididir (lease lock): kilit süresizce değil, belirli bir süre için verilir. Süre şart, çünkü kilidi tutan süreç çökerse kilidi bırakacak kimse kalmaz; kira, o durumda kilidin kendiliğinden düşmesini sağlar.
Kilidin doğruluğu tek bir koşula bağlıdır: alma işlemi atomik olmalıdır. İki koşum “kilit var mı” diye sorup ikisi de “yok” cevabını alırsa kilit hiçbir işe yaramaz. Bu yüzden sorgu ile yazma tek bir ifadede birleştirilir.
// gorev.mjs — aylik odunc raporunu ureten yineleyen gorev // node gorev.mjs <etiket> kilitsiz kosum // node gorev.mjs <etiket> --kilit kira kilidiyle kosum import { DatabaseSync } from "node:sqlite"; import { appendFileSync } from "node:fs"; const ETIKET = process.argv[2]; const KILITLI = process.argv.includes("--kilit"); const KIRA_MS = 5000; // kilidin kira suresi const db = new DatabaseSync("zamanlama.db"); db.exec("PRAGMA busy_timeout = 5000"); db.exec(`CREATE TABLE IF NOT EXISTS kilit ( ad TEXT PRIMARY KEY, sahip TEXT NOT NULL, bitis INTEGER NOT NULL)`); // Kilit alma tek bir atomik yazmadir: satir yoksa olusur, varsa yalniz kirasi // dolmussa devralinir. Iki kosum ayni anda denerse yalniz biri satiri degistirir. const kilitAl = (ad, sahip) => { const simdi = Date.now(); return db.prepare( `INSERT INTO kilit (ad, sahip, bitis) VALUES (?, ?, ?) ON CONFLICT(ad) DO UPDATE SET sahip = excluded.sahip, bitis = excluded.bitis WHERE kilit.bitis < ?`).run(ad, sahip, simdi + KIRA_MS, simdi).changes === 1; }; const kilitBirak = (ad, sahip) => db.prepare("DELETE FROM kilit WHERE ad = ? AND sahip = ?").run(ad, sahip); function raporUret() { appendFileSync("iz.log", `basladi ${ETIKET}\n`); const son = Date.now() + 300; // 300 ms suren rapor uretimi while (Date.now() < son); appendFileSync("iz.log", `bitti ${ETIKET}\n`); } if (!KILITLI) raporUret(); else if (kilitAl("aylik-rapor", ETIKET)) { try { raporUret(); } finally { kilitBirak("aylik-rapor", ETIKET); } } else { appendFileSync("iz.log", `atlandi ${ETIKET}\n`); }
Üç ayrıntı kilidi doğru yapan şeydir. Kilit adı görev adıdır, süreç adı değil; farklı
örnekler aynı adı kullanır. Kilidi bırakma sahip sütununu da sorgular, böylece kirası
dolduğu için kilidi devralınmış bir koşum, başkasının kilidini silemez. Ve bırakma
finally içindedir, dolayısıyla hata durumunda da çalışır.
Ölçüm
Kilidin işe yarayıp yaramadığı iddia edilmez, ölçülür. Aşağıdaki sürücü aynı görevi iki
kez aynı anda başlatır ve iz.log dosyasındaki başlama–bitiş imlerinden eş zamanlı
koşum zirvesini hesaplar.
// surucu.mjs — ayni gorevi iki kez ayni anda baslatir ve izi cozumler import { spawn } from "node:child_process"; import { writeFileSync, readFileSync, rmSync } from "node:fs"; const calistir = (argv) => new Promise((c) => spawn("node", argv, { stdio: "ignore" }).on("exit", c)); function cozumle() { let acik = 0, zirve = 0, kosum = 0, atlanan = 0; for (const s of readFileSync("iz.log", "utf8").trim().split("\n")) { if (s.startsWith("basladi")) { acik++; kosum++; zirve = Math.max(zirve, acik); } else if (s.startsWith("bitti")) acik--; else atlanan++; } return { kosum, atlanan, zirve }; } for (const kip of ["kilitsiz", "kilitli"]) { writeFileSync("iz.log", ""); rmSync("zamanlama.db", { force: true }); const ek = kip === "kilitli" ? ["--kilit"] : []; await Promise.all([ calistir(["gorev.mjs", "A", ...ek]), calistir(["gorev.mjs", "B", ...ek]), ]); const { kosum, atlanan, zirve } = cozumle(); console.log(`${kip.padEnd(9)} kosum=${kosum} atlanan=${atlanan} es zamanli zirve=${zirve}`); }
kilitsiz kosum=2 atlanan=0 es zamanli zirve=2 kilitli kosum=1 atlanan=1 es zamanli zirve=1
Kilitsiz kipte iki koşum da rapor üretiyor ve zirve 2 çıkıyor: aylık ödünç raporu iki kez hesaplanmış demektir. Kilitli kipte koşum sayısı 1, atlanan 1 ve zirve 1. İkinci koşum kilidi alamadığı için işi yapmadan çıkıyor.
Atlanan koşumun hata sayılmadığına dikkat edin. Zamanlanmış görevlerde kilidi alamamak beklenen bir sonuçtur; kütüklenir ama uyarı üretmez. Uyarı üreten şey, aynı görevin uzun süre hiç koşamamış olmasıdır.
Kaçırılan Koşum
Kilit çakışmayı önler ama bir soruyu açık bırakır: koşum kilit yüzünden ya da servis kapalı olduğu için hiç yapılamadıysa, sonradan telafi edilecek mi.
İki politika vardır. Telafisiz politikada kaçırılan koşum unutulur; bir sonraki tetiklemede normal koşum yapılır. Şube stok özeti gibi son durumu hesaplayan görevler buna uygundur, çünkü yeni koşum eskisinin sonucunu zaten kapsar. Telafili politikada kaçırılan her aralık için ayrı koşum üretilir. Aylık ödünç raporu buna uygundur, çünkü her ayın raporu ayrı bir çıktıdır ve biri atlanırsa yerine geçen olmaz.
Telafili politikanın tehlikesi, uzun bir kesintiden sonra biriken koşumların hepsinin bir anda tetiklenmesidir. Bu yüzden telafi bir üst sınırla verilir: en fazla son N aralık telafi edilir, gerisi kütüğe düşer.
Özet
- Yineleyen görev tanımı “şu andan sonraki koşum anı” işlevine indirgenir; “şimdi” değerini dışarıdan almak, zamanlamayı sınanabilir kılar.
- Ayın olmayan gününe bağlanan tanımlar taşarak sonraki aya kayar; atlama ya da ayın son gününe kelepçeleme politikası açıkça yazılmalıdır.
- Sabit hız kaymayı önler ama çakışmaya açıktır; sabit gecikme çakışmayı önler ama koşum anlarını kaydırır.
- Çakışma uzun koşumdan, çok örnekli dağıtımdan ve yeniden dağıtımdan doğar; üçünün de çözümü koşmadan önce alınan kira kilididir.
- Kilidin doğruluğu almanın atomikliğine, dayanıklılığı ise kira süresinin dolmasıyla kendiliğinden düşmesine bağlıdır; ölçüm eş zamanlı koşum zirvesini 2’den 1’e indirmiştir.
Sonraki Adım
Bu derste görevler kısaydı: üç yüz milisaniyede biten bir rapor üretimi. Kira süresi de buna göre seçilebildi. Ama arka plan işlerinin bir bölümü dakikalarca sürer — otuz bin ödünç kaydını tarayan aylık rapor, yüklenen kapak görselinin farklı boyutlarda yeniden üretilmesi. Böyle bir iş başladıktan sonra iki soru doğar: işi başlatan taraf ne kadar ilerlendiğini nasıl öğrenir ve işten vazgeçildiğinde iş nerede durur. Sonraki ders uzun süren işlerde ilerleme bildirimini ve iptali kurar.
İlerlemeni kaydetmek ve not almak için Giriş yap
Notlarım
Not almak için giriş yapmalısın.