Physical Clock'lar: NTP, Clock Drift, Leap Second ve Wall Clock'a Güvenmemek
Süre ölçmek için wall clock (DateTime.UtcNow) değil monotonic clock (Stopwatch, TimeProvider.GetTimestamp) kullanılır, olayları sıralamak için de fiziksel timestamp'e güvenilmez: NTP saati ileri geri atabilir, quartz saatler kayar. Örnekte NTP'nin 300 ms'lik düzeltmesi 200 ms'lik bir işi -97 ms gösteriyor ve 300 ms geride kalan bir sunucu yüzünden last-write-wins daha yeni fiyat güncellemesini sessizce siliyor.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un katalog servisi iki sunucuda çalışıyor ve kitap fiyatlarını replike ediyor. Çakışan güncellemeleri çözmek için en basit kural seçilmiş: last-write-wins (LWW), yani timestamp'i büyük olan yazma kazanır. Saat 10:00:00.000'da bir editör A sunucusu üzerinden bir kitabın fiyatını 450 yapıyor. 100 milisaniye sonra kampanya ekibi B sunucusu üzerinden aynı kitabı 399'a indiriyor. Gerçek zamanda ikinci yazma daha yeni.
B sunucusunun saati, A'nınkinden 300 milisaniye geride. İki replica da iki yazmayı aldıktan sonra kitabın fiyatı ne olur? Replica'lar birbirleriyle tutarlı mı? Peki doğru mu?
Bir Bilgisayarın İki Saati Var
Bir bilgisayarda "saat" denince iki farklı şey kastedilir ve dağıtık sistemlerdeki zaman hatalarının çoğu bu ikisini karıştırmaktan gelir.
Time-of-day clock (wall clock) takvim zamanını verir: 8 Kasım 2026, 10:00:00 UTC (Coordinated Universal Time). Başka makinelerle karşılaştırılabilir olması gerekir, bu yüzden dışarıdan, NTP (Network Time Protocol) ile düzeltilir. Düzeltme sırasında ileri ya da geri atlayabilir. .NET'te DateTime.UtcNow, DateTimeOffset.UtcNow ve TimeProvider.GetUtcNow() bu saati okur.
Monotonic clock ise yalnız ileri gider. Değerinin mutlak bir anlamı yoktur (genelde makine açıldığından beri geçen süredir) ve iki makine arasında karşılaştırılamaz; tek işi aynı makinede iki an arasındaki süreyi ölçmektir. NTP bu saatin hızını hafifçe ayarlayabilir ama onu geri atmaz. .NET'te Stopwatch, Stopwatch.GetTimestamp(), Environment.TickCount64 ve TimeProvider.GetTimestamp() bu saati okur; altında Windows'ta QueryPerformanceCounter, Linux'ta CLOCK_MONOTONIC gibi işletim sistemi sayaçları vardır.
İki saatin de donanım tarafı genellikle bir quartz kristalidir ve quartz kayar (clock drift). Kristalin titreşim frekansı üretim farkına ve sıcaklığa göre biraz değişir; tipik sapma milyonda onlarca parça (ppm, parts per million) mertebesindedir. 50 ppm küçük görünür ama günde yaklaşık 4,3 saniye eder. Senkronize edilmeyen bir sunucu bir hafta sonra yarım dakika sapmış olabilir.
NTP Saati Nasıl ve Ne Kadar Düzeltir?
NTP, David Mills'in 1980'lerde tasarladığı ve bugün RFC (Request for Comments) 5905 ile tanımlı olan protokoldür. Sunucular katmanlara (stratum) ayrılır: stratum 0 atomik saat ya da GPS (Global Positioning System) alıcısıdır, stratum 1 ona doğrudan bağlı sunucu, stratum 2 stratum 1'den zaman alan sunucu ve böyle devam eder. İstemci bir sunucuya istek atar, gidiş ve dönüş zamanlarından network gecikmesini tahmin eder ve kendi saatinin ne kadar saptığını (offset) hesaplar. Tahmin, gidiş ve dönüş gecikmelerinin eşit olduğunu varsayar; asimetrik bir yol (ör. upload'u dolu bir hat) bu varsayımı bozar ve hata doğrudan offset'e yansır.
Bu yazıyı yazdığım makineden iki farklı NTP sunucusuna sorduğumda aldığım gerçek cevaplar (macOS'taki sntp aracı; ilk sayı saniye cinsinden offset, ikincisi hata payı):
$ sntp time.apple.com
-0.001019 +/- 0.043825 time.apple.com 17.253.14.45
$ sntp pool.ntp.org
-0.012952 +/- 0.037462 pool.ntp.org 162.159.200.1
Aynı anda sorulan iki sunucu, makinemin saati hakkında 12 milisaniye farklı şey söylüyor ve ikisi de yaklaşık ±40 milisaniyelik bir hata payı veriyor. İnternet üzerinden NTP'nin pratikteki doğruluğu bu mertebededir; aynı data center içinde, yakındaki bir sunucuyla milisaniyenin altına inebilir. Kötü durumlar da yaşanır: NTP sunucusuna ulaşılamaz, firewall paketleri keser, yanlış configure edilmiş bir sunucu saatleri yıllarca ileri atar.
Offset küçükse NTP daemon'ı saati atlatmaz, hızını hafifçe değiştirerek farkı yavaşça kapatır (slew). Offset büyükse saati bir hamlede ileri ya da geri atar (step); klasik ntpd'nin varsayılan eşiği 128 milisaniyedir. Bir sanal makine uzun süre duraklatılıp devam ettirildiğinde, bir sunucu ilk açıldığında ya da biri saati elle düzelttiğinde wall clock birden atlar. İşte o anda süre ölçen kodun ne yaptığı önemli hale gelir.
.NET'te Wall Clock ile Süre Ölçmek
NTP'nin saati gerçekten geri atmasını beklemek yerine bunu TimeProvider ile simüle edelim. .NET 8 ile gelen bu abstract class, wall clock'u (GetUtcNow) ve monotonic timestamp'i (GetTimestamp) ayrı ayrı sunar. Kendi provider'ımız wall clock'a bir offset ekleyebiliyor, timestamp'e dokunmuyor:
namespace Bookstore.Clocks;
// NTP'nin ileri geri atabildiği wall clock; monotonic timestamp'e dokunulmuyor
public sealed class SteppingTimeProvider : TimeProvider
{
private TimeSpan _offset;
public void Step(TimeSpan delta) => _offset += delta;
public override DateTimeOffset GetUtcNow() => System.GetUtcNow() + _offset;
public override long GetTimestamp() => System.GetTimestamp();
public override long TimestampFrequency => System.TimestampFrequency;
}
Yaklaşık 200 milisaniye süren bir işi iki yöntemle ölçüyoruz; işin ortasında NTP saati 300 milisaniye geri alıyor:
using System.Diagnostics;
using System.Globalization;
using Bookstore.Clocks;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine($"Stopwatch.IsHighResolution = {Stopwatch.IsHighResolution}");
Console.WriteLine($"Stopwatch.Frequency = {Stopwatch.Frequency:N0} ticks/s");
Console.WriteLine($"TimeProvider.System.TimestampFrequency = {TimeProvider.System.TimestampFrequency:N0}");
// 1) NTP saati 300 ms geri atarken süre ölçmek
SteppingTimeProvider clock = new();
DateTimeOffset wallStart = clock.GetUtcNow();
long monoStart = clock.GetTimestamp();
await Task.Delay(200); // asıl iş yaklaşık 200 ms sürüyor
clock.Step(TimeSpan.FromMilliseconds(-300)); // NTP düzeltmesi tam ortada geliyor
TimeSpan byWallClock = clock.GetUtcNow() - wallStart;
TimeSpan byMonotonic = clock.GetElapsedTime(monoStart);
Console.WriteLine($"wall clock elapsed : {byWallClock.TotalMilliseconds:F0} ms");
Console.WriteLine($"monotonic elapsed : {byMonotonic.TotalMilliseconds:F0} ms");
Stopwatch.IsHighResolution = True
Stopwatch.Frequency = 1,000,000,000 ticks/s
TimeProvider.System.TimestampFrequency = 1,000,000,000
wall clock elapsed : -97 ms
monotonic elapsed : 203 ms
İlk üç satır bu makinenin monotonic sayacının nanosaniye çözünürlükte olduğunu gösteriyor. Sonrası asıl ders: wall clock'a göre iş eksi 97 milisaniye sürdü. Bu sayı bir metrik dashboard'una düşerse grafik bozulur; bir timeout hesabına girerse timeout hiç dolmaz; bir rate limiter'a girerse limit sıfırlanır. Ters yönde, saat ileri atlarsa 200 milisaniyelik bir iş 5 saniye sürmüş görünür ve sağlıklı bir istek timeout'a düşer. Monotonic ölçüm ise gerçeği söylüyor: 203 milisaniye.
Bu hata gerçek sistemleri düşürdü. 1 Ocak 2017'deki leap second sırasında Cloudflare'in DNS (Domain Name System) altyapısındaki bir Go programı, wall clock'tan hesapladığı süre negatif çıkınca rastgele sayı fonksiyonuna negatif değer verdi ve panic ile çöktü; şirketin olay raporu sorunu tam olarak "zaman geri gidemez" varsayımına bağlıyor. Kural basit: süre, timeout, rate limit, lease ve cache yaşı monotonic clock ile ölçülür. Wall clock yalnız insana gösterilecek ya da başka makinelerle konuşulacak takvim zamanı için kullanılır.
Last-Write-Wins ve Kaybolan Güncelleme
Açılıştaki soruya dönelim. LWW register'ı birkaç satır:
namespace Bookstore.Clocks;
public record Versioned(string Value, DateTimeOffset Timestamp, string Node);
// Last-write-wins: büyük timestamp kalır, diğer yazma yok olur
public sealed class LwwRegister
{
public Versioned? Current { get; private set; }
public void Apply(Versioned write)
{
if (Current is null || write.Timestamp > Current.Timestamp)
{
Current = write;
}
}
}
A'nın saati doğru, B'ninki 300 milisaniye geride. Gerçek zamanda ikinci yazma 100 milisaniye sonra geliyor. İki replica iki yazmayı farklı sırayla alıyor:
// 2) İki sunucu arasında last-write-wins, B'nin saati 300 ms geride
DateTimeOffset trueTime = new(2026, 11, 8, 10, 0, 0, TimeSpan.Zero);
TimeSpan skewA = TimeSpan.Zero;
TimeSpan skewB = TimeSpan.FromMilliseconds(-300);
Versioned first = new("price=450", trueTime + skewA, "A");
Versioned second = new("price=399", trueTime.AddMilliseconds(100) + skewB, "B"); // gerçekte 100 ms sonra
LwwRegister replica1 = new();
LwwRegister replica2 = new();
replica1.Apply(first);
replica1.Apply(second);
replica2.Apply(second);
replica2.Apply(first);
Console.WriteLine($"write 1 on A: {first.Value} ts={first.Timestamp:HH:mm:ss.fff}");
Console.WriteLine($"write 2 on B: {second.Value} ts={second.Timestamp:HH:mm:ss.fff} (really 100 ms later)");
Console.WriteLine($"replica 1 keeps: {replica1.Current?.Value} from {replica1.Current?.Node}");
Console.WriteLine($"replica 2 keeps: {replica2.Current?.Value} from {replica2.Current?.Node}");
write 1 on A: price=450 ts=10:00:00.000
write 2 on B: price=399 ts=09:59:59.800 (really 100 ms later)
replica 1 keeps: price=450 from A
replica 2 keeps: price=450 from A
İki replica da 450'de karar kıldı. Sistem tutarlı (iki kopya aynı değeri gösteriyor), ama yanlış: kampanya fiyatı hiçbir hata, hiçbir log satırı olmadan kayboldu. B'nin yazması 09:59:59.800 timestamp'i aldı, çünkü B'nin saatine göre o an oydu. LWW "son yazma"yı değil "en büyük timestamp'li yazma"yı seçer ve bu ikisi ancak saatler kusursuz senkronken aynı şeydir.
Bu yalnız bir ders kitabı senaryosu değil. Cassandra gibi LWW kullanan veritabanlarında her hücrenin timestamp'i yazmayı yapan client'tan ya da coordinator node'dan gelir; saati geri kalan bir uygulama sunucusu, diğerlerinin yeni yazdığı veriyi kalıcı olarak ezemez hale gelir, saati ileri kaçan bir sunucu ise sonraki tüm yazmaları "eski" gösterir. Kleppmann'ın DDIA (Designing Data-Intensive Applications) kitabı bu durumu, NTP ile senkronize edilmiş saatlerle bile replication'da sıralamanın yanlış çıkabileceğinin örneği olarak anlatır. Çözümler sonraki yazıların konusu: event'leri sıralamak için logical clock'lar (Lamport, vector clock), conflict'i fark edip saklamak için version vector'ler, timestamp'i birleştirilebilir veri tipleriyle gereksiz kılmak için CRDT (Conflict-free Replicated Data Type).
Leap Second ve Smear
Dünyanın dönüşü düzensiz ve yavaşlıyor; atomik saatlere dayanan UTC'yi astronomik zamana yakın tutmak için 1972'den bu yana UTC'ye 27 kez bir saniye eklendi. Bu leap second'ların sonuncusu 31 Aralık 2016'da, 23:59:60 olarak eklendi. Bir dakikanın 61 saniye sürmesi, saniyelerin 0 ile 59 arasında olduğunu varsayan her kodu tehdit eder; .NET'te DateTime 23:59:60'ı temsil edemez. 2012'deki leap second'da Linux kernel'indeki bir hata, Java ve MySQL gibi süreçlerin CPU (Central Processing Unit) kullanımını yüzde yüze çıkardı ve birçok büyük sitede kesintiye yol açtı.
Büyük cloud sağlayıcıları sorunu leap smear ile çözüyor: fazladan saniyeyi tek bir anda eklemek yerine, Google'ın uyguladığı biçimde 24 saate yayarak saatleri o gün boyunca biraz yavaş çalıştırıyorlar. Böylece saat hiç atlamıyor, ama smear süresince gerçek UTC'den yarım saniyeye kadar farklı oluyor; smear yapan ve yapmayan sunucuları karıştırırsan bu fark doğrudan saat kaymasına dönüşür. Uzun vadede sorun ortadan kalkıyor: Uluslararası Ağırlıklar ve Ölçüler Konferansı (CGPM) 2022'de, en geç 2035'ten itibaren leap second eklemeye son verilmesine karar verdi.
TimeProvider ile Test Edilebilir Zaman
TimeProvider'ın asıl kazancı test tarafında. Zamana bağlı bir kuralı (sepetteki rezervasyon 15 dakika sonra düşer) test etmek için 15 dakika beklemek ya da DateTime.UtcNow'u statik bir wrapper'a sarmak yerine provider'ı constructor'dan alırsın:
namespace Bookstore.Clocks;
// 15 dakika sonra düşen sepet rezervasyonu; süre monotonic timestamp ile ölçülüyor
public sealed class ReservationHold(TimeProvider time)
{
private readonly long _startedAt = time.GetTimestamp();
public bool IsExpired => time.GetElapsedTime(_startedAt) >= TimeSpan.FromMinutes(15);
}
Testte Microsoft.Extensions.TimeProvider.Testing paketindeki FakeTimeProvider zamanı elle ilerletir:
// 3) Zamana bağlı kodu beklemeden test etmek
Microsoft.Extensions.Time.Testing.FakeTimeProvider fake = new();
ReservationHold hold = new(fake);
fake.Advance(TimeSpan.FromMinutes(14));
Console.WriteLine($"after 14 min expired? {hold.IsExpired}");
fake.Advance(TimeSpan.FromMinutes(1));
Console.WriteLine($"after 15 min expired? {hold.IsExpired}");
after 14 min expired? False
after 15 min expired? True
Production'da TimeProvider.System enjekte edilir. Aynı provider Task.Delay, CancellationTokenSource ve PeriodicTimer'ın overload'larına da verilebildiği için timer kullanan kodu da sanal zamanda test edebilirsin. C# Asenkron serisinin "C# Async ve .NET 8: TimeProvider, ConfigureAwaitOptions, Parallel.ForAsync ve CancelAsync" yazısı bu API'yi (Application Programming Interface) ayrıntılı anlatıyor.
Sık Düşülen Tuzaklar
Süreyi DateTime.Now ile ölçmek. Hem wall clock'tur hem de yerel saattir; NTP atlamasına ek olarak yaz saati geçişinde bir saatlik sıçrama yapar. Süre için Stopwatch.GetTimestamp() ve Stopwatch.GetElapsedTime ya da TimeProvider.GetElapsedTime kullan.
Event'leri timestamp ile sıralamak. İki farklı makinede üretilmiş iki timestamp'i karşılaştırmak, iki saatin farkından daha küçük aralıklar için anlamsızdır. Log birleştirirken ya da event'leri "hangisi önce" diye sıralarken bunu hatırla; sıra gerekiyorsa tek bir sıralayıcıdan (veritabanı sequence'i, broker partition offset'i) ya da logical clock'tan gelmeli.
Saat senkronizasyonunu izlememek. NTP daemon'ı sessizce çalışmayı bırakabilir. Saat offset'ini bir metrik olarak topla ve eşik aşılınca alarm üret; CockroachDB gibi bazı veritabanları offset çok büyürse node'u kendisi kapatır.
Monotonic değerleri makineler arasında taşımak. Stopwatch.GetTimestamp()'in değeri başka bir makinede anlamsızdır; process yeniden başlayınca da sıfırdan başlayabilir. Veritabanına ya da başka bir servise giden her zaman bilgisi wall clock'tur ve yukarıdaki belirsizliği taşır.
Lease ve expiry'yi başka makinenin saatine göre hesaplamak. "Kilit 10:00:30'a kadar benim" bilgisi, kilidi veren sunucunun saatine göredir; senin saatin 2 saniye ilerideyse kilidi erken bırakırsın, gerideyse süre bittikten sonra yazmaya devam edersin. Onuncu yazının konusu tam olarak bu.
Küçük Bir Deneme
★☆☆ skewB'yi -300 milisaniyeden -50 milisaniyeye çek. Kampanya fiyatı bu sefer kalıyor mu? LWW'nin doğru çalışması için saat farkının hangi değerden küçük olması gerekiyor?
★★☆ SteppingTimeProvider ile saati 2 saniye ileri at ve "son 1 saniyede 5'ten fazla istek geldiyse reddet" kuralını wall clock ile uygulayan küçük bir rate limiter yaz. Atlamadan sonra ne oluyor? Aynı kuralı GetTimestamp ile yeniden yaz.
★★★ LWW register'ına iki yazmanın aynı timestamp'i alması durumunu ekle (ör. iki sunucu da 10:00:00.000 üretsin). Replica'ların aynı değere ulaşması için eşitliği nasıl bozarsın? Bu kural "doğru" değeri seçmeyi garanti ediyor mu?
Bu yazıda fiziksel saatlerin event'leri sıralamak için neden yetersiz olduğunu gördük. Bir sonraki yazıda saate hiç bakmadan sıralama kuruyoruz: Lamport'un happens-before ilişkisi, Lamport clock, concurrent event'leri tespit edebilen vector clock ve C# ile yazılmış, xUnit ile test edilmiş küçük implementasyonlar.