İçeriğe geç
academia.sh

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.

Aramak için yazmaya başlayın.

↑↓ Esc gezin · aç · kapat