Teknik Makale

Delphi'de HotXLS Dosya Monitörü Callback Deadlock'u

HotXLS, diskteki bir çalışma kitabını TXLSFileMonitor ile izler ve başka bir süreç dosyanın üzerine kaydettiğinde OnFileChanged olayını tetikler. O işleyicinin içinden Monitor.Active := False ayarlamak eskiden sonsuza dek asılı kalıyordu, çünkü işçi thread, az önce ilettiği callback'i bekleyerek hâlâ bloke durumdaydı. Düzeltme, thread nesnesinin o anda çalıştırdığı callback'in kimliğini taşımasına izin vermek

Bir dosya monitörünü kendi işleyicisinin içinde durdurmak neden asılı kalır?

Çünkü devre dışı bırakma çağrısı ile callback iki farklı thread üzerinde çalışıyor ve her biri diğerinin bitmesini bekliyor. TXLSFileMonitor, SynchronizeEvent değeri True olarak gelir; bu da yoklama thread'inin işleyicini kendisinin çağırmadığı anlamına gelir. TThread.Synchronize çağırır, metodu ana thread'e uzatır ve ana thread onu çalıştırıp tamamlandı sinyali verene dek bloke olur. Bir ızgarayı yeniden yükleyen ya da bir formu yeniden boyayan bir işleyici için tam istediğin şey budur ve apaçık devre dışı bırakma çağrısını bir tuzak yapan da tam bu

HotXLS dosya monitörünün iki deadlock yüzü: SynchronizeEvent True iken ana thread Worker.WaitFor içinde bloke olurken işçi TThread.Synchronize içinde bloke olur; False iken işleyici yoklama thread'inde çalışır, o yüzden SetActive False thread'in kendisinde bekler ve OnFileChanged geri dönemez
SynchronizeEvent açıkken ana thread Worker.WaitFor içinde beklerken işçi Synchronize içinde bekler; kapalıyken işleyici yoklama thread'inde çalışır ve kendi çıkışını bekler — tek bir kapalı döngü, iki dağıtım modu
procedure TMainForm.HandleFileChanged(Sender: TObject);
begin
  // TThread.Synchronize sayesinde burada ana thread üzerindeyiz
  FBook.LoadFromFile(FMonitor.FileName);
  RefreshGrid;
  // Kullanıcı tek bir yeniden yükleme istedi, o yüzden izlemeyi bırak
  FMonitor.Active := False;   // eskiden hiç geri dönmezdi
end;

İki stack'in izini sür ve döngü apaçık. Ana thread SetActive(False) içindedir; bu, işçiyi sonlandırır ve nesne serbest bırakılmadan önce thread'in gerçekten çıktığından emin olmak için Worker.WaitFor çağırır. İşçi thread ise o sırada TThread.Synchronize içinde park etmiştir ve SetActive çağıran o işleyiciden ana thread'in dönmesini bekler. İki taraf da hareket edemez. Uygulama çökmez, istisna fırlatmaz, yalnızca yeniden boyamayı bırakır; bu da bir hatanın sahip olabileceği en kötü teşhis imzasıdır

Aynı deadlock ikinci bir yüz takıyor

Senkronizasyonu kapatmak sorunu çözmez, yalnızca yerini değiştirir. SynchronizeEvent değeri False iken — mesaj döngüsü olmayan bir servis ya da konsol aracında mantıklı seçim — TXLSFileMonitor işleyiciyi doğrudan yoklama thread'inde çağırır. İşleyicin artık işçi üzerinde çalışır; o yüzden içinden yapılan bir Monitor.Active := False çağrısı Worker.WaitFor'a işçi thread'in kendisi üzerinde ulaşır. Bir thread kendi çıkışını bekleyemez, o yüzden dışarıdan biri süreci öldürene dek bloke olur. İki varyant da aynı hata: SetActive(False), her zaman callback'in dışında bir yerden çağrıldığını varsayıyordu. Bu varsayım bir Kapat düğmesi ya da bir form yıkıcısı için geçerlidir ve insanların ilk uzandığı çağrı noktasında çöker; bu yüzden bu kadar uzun yaşadı. Hem bir thread'in sahibi olan hem de ondan kullanıcı kodu dağıtan her bileşen aynı tehlikeyi miras alır ve çözüm her durumda aynıdır — sökme yolu, katılacağı thread'in hâlâ tuttuğu bir callback'in içinden mi çalıştırıldığını söyleyebilmelidir

Callback kimliğini işçi thread'in taşımasına izin vermek

HotXLS bunu, işleyiciyi o anda hangi OS thread'in çalıştırdığını thread nesnesinin kendisine kaydederek çözer. TXLSFileMonitorThread.FireEvent, işleyiciyi çağırmadan hemen önce FCallbackThreadID değerine GetCurrentThreadId damgasını basar ve sonrasında bir finally bloğunda temizler. Tek bir alan, devre dışı bırakma yolunun "alakasız koddan çağrıldı" ile "kendi callback'imin içinden çağrıldı" ayrımını yapması için yeterlidir ve iki dağıtım modunda da aynı şekilde çalışır, çünkü senkronize durumda FireEvent içindeki GetCurrentThreadId zaten ana thread'i raporlar

procedure TXLSFileMonitorThread.FireEvent;
var
  Owner: TXLSFileMonitor;
  Handler: TNotifyEvent;
begin
  // Sıraya alınmış bir callback, devre dışı bırakma sırasında WaitFor tarafından dağıtılabilir
  if Terminated then
    Exit;
  Owner := FOwner;
  Handler := Owner.FOnFileChanged;
  if not Assigned(Handler) then
    Exit;
  FCallbackThreadID := GetCurrentThreadId;
  try
    Handler(Owner);
  finally
    // İşleyici Owner'ı yok etmiş olabilir ama bu thread canlı kalır
    FCallbackThreadID := 0;
  end;
end;

SetActive'in devre dışı bırakma dalı sonra bilinçli bir sırayla üç şey yapar. Önce FThread'i temizleyerek thread'i monitörden ayırır; o andan itibaren GetActive False raporlar ve aynı işleyicinin içindeki yeniden girişli bir Active := False, thread'i iki kez sökmeye çalışmak yerine hemen çıkar. Terminate çağırır. Ancak ondan sonra thread nesnesini nasıl geri alacağına karar verir

HotXLS'in dosya monitörü sökmesini nasıl çözdüğü: TXLSFileMonitorThread.FireEvent, işleyiciyi çağırmadan önce FCallbackThreadID'ye GetCurrentThreadId damgasını basar ve onu bir finally bloğunda temizler; o yüzden SetActive, FThread'i ayırabilir, Terminate çağırabilir ve bir callback içinde FreeOnTerminate kendini serbest bırakma ile klasik WaitFor artı Free arasında seçim yapabilir
FireEvent, işleyiciyi çağırmadan önce FCallbackThreadID'ye damga basar ve onu bir finally bloğunda temizler; SetActive sonra kimlik eşleştiğinde FreeOnTerminate, çağıran alakasız olduğunda klasik WaitFor artı Free seçer
Worker := TXLSFileMonitorThread(FThread);
FThread := nil;            // monitör bu noktadan itibaren devre dışı
Worker.Terminate;
FHasBaseline := False;
if Worker.FCallbackThreadID = GetCurrentThreadId then
  // Hem doğrudan hem senkronize işleyiciler, işçi çıkabilmeden önce geri dönmeli;
  // işçi, callback geri sarıldıktan sonra kendini serbest bırakır
  Worker.FreeOnTerminate := True
else
begin
  Worker.WaitFor;
  Worker.Free;
end;

Kimlik eşleştiğinde HotXLS hiç beklemez. FreeOnTerminate := True kurar ve geri döner; işleyicinin bitmesine, Synchronize çağrısının tamamlanmasına, Execute öğesinin Terminated değerini görmesine ve callback stack'i tamamen geri sarıldığında RTL'nin thread nesnesini serbest bırakmasına izin verir. Kimlik eşleşmediğinde — bir Kapat düğmesi, bir yıkıcı, bir arka plan işi — klasik WaitFor artı Free yine de doğru olandır, çünkü Active := False geri döndükten sonra artık hiçbir callback'in ateşlenemeyeceğine dair kesin bir garanti verir. İki çıkış yolu, aralarında seçim yapacak tek bir alan. Söylenmeye değer bir sonuç: kendini serbest bırakma yolunda işçi, işleyiciden daha uzun yaşar. FireEvent dönüş yolundaki hiçbir şey monitör nesnesine dokunamaz, çünkü işleyici onu serbest bırakmış olmaya hakkı vardır. FireEvent bu yüzden Owner ve Handler değerlerini en baştan yerele önbelleğe alır ve finally maddesinde yalnızca kendi alanına dokunur. İşleyici çağrısından sonra başıboş bir FOwner.Something, düzeltilmiş bir deadlock'u use-after-free'e çevirirdi; bu da kesinlikle daha kötü bir sonuçtur

FireEvent neden hiçbir şey yapmadan önce Terminated kontrolü yapmak zorunda?

Çünkü WaitFor pasif bir bekleme değildir. Ana thread üzerinde TThread.WaitFor'un RTL uygulaması, bloke olurken senkronizasyon kuyruğunu boşaltmaya devam eder; tam olarak Synchronize içinde park etmiş bir thread'in kapanışa karşı deadlock olmak yerine tamamlanıp çıkabilmesi için. Bu bir özelliktir ve bir Kapat düğmesinden Active := False çağırmandan mikrosaniyeler önce sıraya alınmış bir callback'in, monitör FThread referansını çoktan düşürmüş olsa bile sökme sırasında hâlâ dağıtılabileceği anlamına gelir. FireEvent'in tepesindeki guard o dağıtımı no-op yapar. O olmadan, izlenen dosyaya bir kayıt inerken ana formunu kapatan bir uygulama, yarı yok edilmiş bir duruma karşı bir yeniden yükleme işleyicisi çalıştırabilir ve ortaya çıkan access violation hiçbir şekilde bir kapanış sıralaması sorununa benzemez. Kendi bileşenin Synchronize üzerinden iş dağıtıyorsa kural genelleşir: her senkronize metodun ilk ifadesi bir Terminated kontrolü olmalıdır; thread gitmiş olabileceği için değil, kuyruk durma kararından daha uzun yaşadığı için

CSV içe aktarma yolunda aynı şekildeki hata

Monitör düzeltmesini üreten denetim, metin içe aktarıcıda bir kardeş kusur ortaya çıkardı ve yan yana okumaya değer, çünkü altta yatan hata birebir aynı: karalama durumu gibi tüketilen bir yapılandırma parçası. TXLSCSVLoader, dışa aktarılan raporlarda gerçek başlığın üzerinde duran başlık satırları için bir SkipRows özelliği sunar. Parser'ın o satırların üzerinden geçerken bir geri sayıma ihtiyacı vardır ve orijinal uygulama özellik yedek alanının üzerinde geri sayıyordu. Bir dosyayı içe aktar ve SkipRows sessizce sıfır oldu; aynı yükleyiciyi ikinci bir dosya için yeniden kullan ve başlık satırları ızgaraya düştü

Loader := TXLSCSVLoader.Create;
try
  Loader.SkipRows := 2;             // başlığın üzerinde iki başlık satırı
  Loader.ParseFile('jan-export.csv');
  ReadGrid(Loader);
  // SkipRows burada hâlâ 2: FSkipRemaining her parse'ta sıfırlanır
  Loader.ParseFile('feb-export.csv');
  ReadGrid(Loader);
finally
  Loader.Free;
end;

HotXLS artık FSkipRows değerini bildirilen ayar olarak tutar ve her parse'ın başında ayrı bir FSkipRemaining sayacını sıfırlar; hem sınırlandırılmış hem sabit genişlikli yollarda. Genel kural uygulaması ucuz ve kendini amorti eder: bir döngü onu azaltıyorsa, özellik yedek alanı değildir. Aynı parti XlsCsvTryParseISODateTime öğesini de sıkılaştırdı; o, ayrıştırılabilir bir öneki bütün bir tarih olarak kabul etmeye razıydı. Artık alanın tamamını doğrular — uzunluk, ayraç konumları ve her rakam — o yüzden yalnızca tarih görünümlü bir şeyle başlayan bir sipariş referansı gibi bir değer, yanlış bir TDateTime olmak yerine metin kalır. İçe aktarmada sessiz tür zorlaması, reddedilmiş bir alandan aşağı akışta çok daha pahalıya mal olur; HotXLS'in bir çalışma kitabını eşzamanlı okuyuculara ve yazarlara karşı nasıl koruduğu konusunda da geçen bir tema bu

HotXLS denetiminin arkasındaki CSV yükleyici kusuru: özellik yedek alanı üzerinde SkipRows'u geri saymak onu bir parse'tan sonra sessizce sıfırlıyordu, o yüzden ikinci bir dosya başlık satırlarını ızgaraya sızdırıyordu; düzeltilmiş model ise FSkipRows'u bildirilen ayar olarak tutar ve her ParseFile çağrısının başında ayrı bir FSkipRemaining sayacını sıfırlar
Özellik yedek alanı üzerinde geri saymak yapılandırmayı karalama durumuna çevirir: ilk parse SkipRows'u sıfırlar, düzeltilmiş model ise FSkipRows'u bildirilmiş tutar ve her parse'ın başında FSkipRemaining'i sıfırlar

Bu düzeltmenin sana verdiği ve vermediği şey

Garanti konusunda kesin ol. Thread sahipliği değişikliği, önceden ölümcül olan tam olarak bir deseni güvenli yapar: TXLSFileMonitor öğesini kendi OnFileChanged işleyicisinin içinden, iki dağıtım modunda da devre dışı bırakmak ya da serbest bırakmak. Genel bir eşzamanlılık tasarımı değildir ve bileşeni keyfi thread'lerden sürmeyi güvenli yapmaz

  • Özellik yazmaları hâlâ thread güvenli değil. FileName, IntervalMs ve SynchronizeEvent değerlerini tek bir thread'den, normalde monitörü yaratan thread'den kur
  • IntervalMs bir sonraki aktivasyondan itibaren geçerli olur ve işçi, uzun bir yoklama döneminde bile Terminate çağrısının hızla onurlandırılması için kısa dilimlerde uyur
  • Algılama son yazma zamanı artı boyut üzerindendir; o yüzden ikisini de koruyan bir değişiklik görünmezdir; kayıt ortasında yok olan bir dosya ateşlemez ve olay yeni içerik yerine oturduğunda gelir
  • SynchronizeEvent değeri False iken işleyicin işçi thread üzerinde çalışır ve arka plan thread'inden VCL denetimlerine dokunmayla ilgili her kural, içinde yaptığın her şey için geçerlidir
  • İşleyiciden bir çalışma kitabını yeniden yüklemek, özellikle de başka kod değiştirilen kitaba bir referans tutuyorsa, yine de kendi disiplinini gerektirir

Pratikte en çok özen hak eden kısım monitör değil, yeniden yükleme işleyicisidir. Bir değişiklik olayı genellikle yazan süreç hâlâ otururken gelir; o yüzden dosyayı hemen açan bir yükleme, kısmen işlenmiş bir kayıtla karşılaşabilir; kısa bir gecikmeden sonra bir kez daha denemek, ilk damgaya güvenmekten daha sağlamdır. İzlenen dosyayı yazan kod seninse, HotXLS'in her kaydı nasıl crash-safe yaptığı konusunda anlatılan aşamalı geçici dosya ve atomik yeniden adlandırma, monitöre bir ara boyut akışı yerine izlenecek temiz bir tek geçişli olay verir. Birden çok izleyici aynı yeniden yükleme rutinini besliyorsa, eşzamanlı ZIP genişletmesi çevresindeki okuma kapısı konusundaki serileştirme endişeleri okunacak bir sonraki şeydir. Daha geniş ders belirli bileşenden sağ çıkar. Bir thread'in sahibi olan ve kullanıcı koduna geri çağıran her sınıfın, kendi callback çerçevesini tanıyabilen bir sökme yolu gerekir, çünkü işleyici, kullanıcının dur demesi için dünyadaki en doğal yerdir. Callback thread kimliğini kaydetmek bir DWORD tutar ve açıklanamaz bir asılmayı sıradan bir nesne ömrü sorusuna çevirir. Dosya izleme, atomik kayıtlar ve metin içe aktarma, Delphi ve C++Builder için HotXLS Delphi Component içinde yer alır ve hiçbirini etkinleştirmek için ekstra yapılandırma gerekmez