Technický článek

Deadlock callbacku file monitoru HotXLS v Delphi

HotXLS hlídá workbook na disku přes TXLSFileMonitor a zvedá OnFileChanged, když jiný proces uloží přes soubor. Nastavení Monitor.Active := False zevnitř toho handleru dřív viselo navždy, protože worker vlákno pořád blokovalo na čekání na callback, který právě rozeslalo. Oprava spočívá v tom, že thread objekt ponese identitu callbacku, který právě běží

Proč zastavení file monitoru uvnitř vlastního handleru visí?

Protože volání deaktivace a callback běží na dvou různých vláknech a každé čeká, až to druhé skončí. TXLSFileMonitor se dodává s SynchronizeEvent nastaveným na True, což znamená, že polling vlákno váš handler nevolá samo. Zavolá TThread.Synchronize, předá metodu hlavnímu vláknu a pak blokuje, dokud hlavní vlákno metodu nespusí a neohlásí dokončení. Přesně to chcete pro handler, který znovu načte grid nebo překreslí form, a přesně to dělá z nasnadě ležícího volání deaktivace past

Dvě tváře deadlocku file monitoru HotXLS: se SynchronizeEvent True hlavní vlákno blokuje ve Worker.WaitFor, zatímco worker blokuje v TThread.Synchronize, a s False běží handler na polling vlákně, takže SetActive False čeká na samotné vlákno a OnFileChanged se nemůže vrátit
Se zapnutým SynchronizeEvent čeká hlavní vlákno ve Worker.WaitFor, zatímco worker čeká v Synchronize; bez něj běží handler na polling vlákně a čeká na vlastní exit — jeden uzavřený cyklus, dva režimy odesílání
procedure TMainForm.HandleFileChanged(Sender: TObject);
begin
  // Jsme na hlavním vlákně, díky TThread.Synchronize
  FBook.LoadFromFile(FMonitor.FileName);
  RefreshGrid;
  // Uživatel chtěl jen jedno znovunačtení, tak přestaň hlídat
  FMonitor.Active := False;   // kdysi se nikdy nevrátilo
end;

Vysledujte ty dva stacky a cyklus je jasný. Hlavní vlákno je uvnitř SetActive(False), které ukončí worker a pak zavolá Worker.WaitFor, aby si bylo jisté, že vlákno opravdu skončilo, než se objekt uvolní. Worker vlákno mezitím parkuje uvnitř TThread.Synchronize a čeká, až se hlavní vlákno vrátí z přesně toho handleru, který volá SetActive. Ani jedna strana se nehne. Aplikace nespadne, nevyhodí výjimku, prostě přestane překreslovat, což je nejhorší diagnostický podpis, jaký chyba může mít

Stejný deadlock má i druhou tvář

Vypnutí synchronizace problém nevyřeší, jen ho přemístí. S SynchronizeEvent nastaveným na False — rozumná volba ve službě nebo konzolovém nástroji bez message loop — volá TXLSFileMonitor handler přímo na polling vlákně. Váš handler teď běží na workeru, takže volání Monitor.Active := False zevnitř dorazí k Worker.WaitFor na samotném worker vlákně. Vlákno nemůže čekat na vlastní exit, takže blokuje, dokud něco zvenku proces nezabije. Obě varianty jsou stejná chyba: SetActive(False) předpokládalo, že se vždy volá odněkud mimo callback. Ten předpoklad platí pro Close tlačítko nebo destruktor formu a selhává u jediného call site, po kterém lidi sáhnou jako první, a proto tak dlouho přežil. Jakákoli komponenta, která vlastní vlákno a z něj rozesílá uživatelský kód, dědí stejné riziko, a řešení je v každém případě stejné — teardown cesta musí umět poznat, jestli běží zevnitř callbacku, který vlákno, jež se chystá připojit, pořád drží

Nechte worker vlákno nést identitu callbacku

HotXLS to řeší zaznamenáním, přímo na thread objektu, které OS vlákno právě vykonává handler. TXLSFileMonitorThread.FireEvent otiskne do FCallbackThreadID hodnotu GetCurrentThreadId těsně před vyvoláním handleru a v finally bloku ji potom vymaže. To jediné pole stačí, aby cesta deaktivace rozlišila "voláno z nesouvisejícího kódu" od "voláno zevnitř vlastního callbacku", a funguje identicky v obou režimech odesílání, protože v synchronizovaném případě GetCurrentThreadId uvnitř FireEvent už hlásí hlavní vlákno

procedure TXLSFileMonitorThread.FireEvent;
var
  Owner: TXLSFileMonitor;
  Handler: TNotifyEvent;
begin
  // Zařazený callback může WaitFor rozeslat během deaktivace
  if Terminated then
    Exit;
  Owner := FOwner;
  Handler := Owner.FOnFileChanged;
  if not Assigned(Handler) then
    Exit;
  FCallbackThreadID := GetCurrentThreadId;
  try
    Handler(Owner);
  finally
    // Handler mohl zničit Owner, ale tohle vlákno zůstává naživu
    FCallbackThreadID := 0;
  end;
end;

Větev deaktivace v SetActive pak dělá tři věci v promyšleném pořadí. Nejdřív odpojí vlákno od monitoru vyčištěním FThread, takže GetActive od toho okamžiku hlásí False a reentrantní Active := False uvnitř téhož handleru rovnou skončí, místo aby se vlákno pokoušelo rozebrat dvakrát. Zavolá Terminate. A teprve pak se rozhodne, jak thread objekt uvolnit

Jak HotXLS řeší teardown file monitoru: TXLSFileMonitorThread.FireEvent otiskne FCallbackThreadID hodnotou GetCurrentThreadId před vyvoláním handleru a v finally bloku ji vymaže, takže SetActive umí odpojit FThread, zavolat Terminate a vybrat mezi samovolným uvolněním FreeOnTerminate uvnitř callbacku a klasickým WaitFor plus Free
FireEvent otiskne FCallbackThreadID před vyvoláním handleru a v finally bloku ji vymaže; SetActive pak volí mezi FreeOnTerminate, když se identita shoduje, a klasickým WaitFor plus Free, když volající nesouvisí
Worker := TXLSFileMonitorThread(FThread);
FThread := nil;            // monitor je od tohoto bodu neaktivní
Worker.Terminate;
FHasBaseline := False;
if Worker.FCallbackThreadID = GetCurrentThreadId then
  // Přímé i synchronizované handlery se musí vrátit, než worker
  // může skončit; uvolní se sám, až se callback rozvine
  Worker.FreeOnTerminate := True
else
begin
  Worker.WaitFor;
  Worker.Free;
end;

Když se identita shoduje, HotXLS nikdy nečeká. Nastaví FreeOnTerminate := True a vrátí se, nechá handler doběhnout, volání Synchronize dokončit, Execute zpozorovat Terminated a RTL uvolnit thread objekt, jakmile se callback stack plně rozvine. Když se identita neshoduje — Close tlačítko, destruktor, úloha na pozadí — je klasické WaitFor plus Free pořád to správné, protože dává tvrdou záruku, že po návratu z Active := False už nemůže vystřelit žádný další callback. Dvě exit cesty, jedno pole, které mezi nimi volí. Jeden důsledek si zaslouží být vysloven: na cestě samovolného uvolnění worker přežije handler. Nic v návratové cestě FireEvent se nesmí znovu dotknout objektu monitoru, protože handler ho mohl oprávněně uvolnit. Proto si FireEvent ukládá Owner a Handler do lokálních proměnných dopředu a v finally klauzuli se dotýká jen vlastního pole. Zatoulané FOwner.Something po volání handleru by proměnilo opravený deadlock v use-after-free, což je přísně horší výsledek

Proč musí FireEvent kontrolovat Terminated, než cokoli udělá?

Protože WaitFor není pasivní čekání. Na hlavním vlákně RTL implementace TThread.WaitFor za běhu odčerpává synchronizační frontu, přesně proto, aby vlákno parkující v Synchronize mohlo dokončit a skončit, místo aby deadlockovalo proti vypínání. To je vlastnost, a znamená to, že callback zařazený mikrosekundy předtím, než jste z Close tlačítka zavolali Active := False, může být pořád rozeslán během teardownu, poté, co monitor už upustil referenci FThread. Stráž na začátku FireEvent dělá z toho rozeslání no-op. Bez ní může aplikace, která zavírá hlavní form, zatímco na hlídaný soubor dopadá uložení, spustit reload handler proti napůl zlikvidovanému stavu, a výsledný access violation nevypadá vůbec jako problém pořadí vypínání. Pokud vaše komponenta rozesílá práci přes Synchronize, pravidlo zobecňuje: každá synchronizovaná metoda potřebuje kontrolu Terminated jako první příkaz, ne proto, že vlákno může být pryč, ale proto, že fronta přežije rozhodnutí zastavit

Stejný tvar chyby v cestě CSV importu

Audit, který vyprodukoval opravu monitoru, odhalil sourozeneckou vadu v textovém importéru, a stojí za to ji číst vedle té první, protože základní chyba je identická: kus konfigurace konzumovaný, jako by to byl scratch stav. TXLSCSVLoader vystavuje vlastnost SkipRows pro bannerové řádky, které v exportovaných reportech sedí nad skutečnou hlavičkou. Parser potřebuje odpočet, dokud ty řádky prochází, a původní implementace odpočítávala přímo na backing poli vlastnosti. Naimportujte jeden soubor a SkipRows tiše bylo nula; použijte stejný loader pro druhý soubor a bannerové řádky přistály v gridu

Loader := TXLSCSVLoader.Create;
try
  Loader.SkipRows := 2;             // dva bannerové řádky nad hlavičkou
  Loader.ParseFile('jan-export.csv');
  ReadGrid(Loader);
  // SkipRows je tady pořád 2: FSkipRemaining se resetuje při každém parsu
  Loader.ParseFile('feb-export.csv');
  ReadGrid(Loader);
finally
  Loader.Free;
end;

HotXLS teď drží FSkipRows jako deklarované nastavení a resetuje samostatný čítač FSkipRemaining na začátku každého parsu, v obou cestách, oddělovačové i pevné šířky. To obecné pravidlo je levné a samo se zaplatí: když to smyčka dekrementuje, není to backing pole vlastnosti. Stejná dávka zpřísnila i XlsCsvTryParseISODateTime, která dřív byla ochotná přijmout parsovatelný prefix jako celé datum. Teď validuje celé pole — délku, pozice oddělovačů i každou číslici — takže hodnota, třeba reference objednávky, která jen začíná něčím ve tvaru data, zůstane textem místo toho, aby se stala špatným TDateTime. Tichá typová koerce při importu je dole ve pipeline mnohem dražší na odladění než odmítnuté pole, téma, které probíhá i tím, jak HotXLS chrání workbook před souběžnými čtenáři a zapisovači

Vada CSV loaderu za auditem HotXLS: odpočítávání SkipRows na backing poli vlastnosti ho po jednom parsu tiše vynulovalo, takže druhý soubor propustil bannerové řádky do gridu, zatímco opravený model drží FSkipRows jako deklarované nastavení a resetuje samostatný čítač FSkipRemaining na začátku každého volání ParseFile
Odpočítávání na backing poli vlastnosti mění konfiguraci ve scratch stav: první parse vynuluje SkipRows, zatímco opravený model drží FSkipRows deklarované a resetuje FSkipRemaining na začátku každého parsu

Co tahle oprava dává a nedává

Buďte přesní v záruce. Změna vlastnictví vlákna činí bezpečný přesně jeden dříve fatální vzor: deaktivaci nebo uvolnění TXLSFileMonitor zevnitř vlastního handleru OnFileChanged, v obou režimech odesílání. Není to obecný návrh souběžnosti a nedělá z komponenty něco, co je bezpečné řídit z libovolných vláken

  • Zápisy vlastností pořád nejsou thread-safe. FileName, IntervalMs a SynchronizeEvent nastavujte z jednoho vlákna, obvykle z toho, které monitor vytvořilo
  • IntervalMs platí od příští aktivace a worker spí v krátkých úsecích, takže Terminate se ctí promptně i s dlouhou periodou pollingu
  • Detekce jde po čase posledního zápisu plus velikosti, takže změna zachovávající obojí je neviditelná; soubor, který zmizí uprostřed uložení, nevystřelí, a event dorazí, až je nový obsah na místě
  • S SynchronizeEvent nastaveným na False běží váš handler na worker vlákně a každé pravidlo o dotýkání se VCL ovládacích prvků z vlákna na pozadí platí i pro to, co děláte uvnitř
  • Znovunačtení workbooku z handleru pořád potřebuje vlastní disciplínu, hlavně když jiný kód drží referenci na nahrazovaný book

V praxi si nejvíc péče zaslouží reload handler, ne monitor. Změnová událost obvykle dorazí, když se zapisující proces ještě usazuje, takže načtení, které soubor otevře okamžitě, může narazit na napůl zapsané uložení; jeden retry po krátké prodlevě je odolnější než věřit prvnímu časovému razítku. Pokud hlídaný soubor zapisuje váš vlastní kód, fáze dočasného souboru a atomický rename popsané v tom, jak HotXLS dělá každé uložení crash-safe, dají monitoru taky čistou jednu přechodovou událost k pozorování místo proudu mezivelikostí. A pokud do stejné reload rutiny krmí víc hlídačů, serializační starosti z read gate kolem souběžné ZIP inflace jsou další věc ke čtení. Širší poučení přežije konkrétní komponentu. Každá třída, která vlastní vlákno a volá zpět do uživatelského kódu, potřebuje teardown cestu, která pozná vlastní callback frame, protože handler je to nejpřirozenější místo na světě, kde uživatel řekne stop. Zaznamenání id vlákna callbacku stojí jeden DWORD a mění nevysvětlitelné visení v obyčejnou otázku životnosti objektu. File monitoring, atomická uložení a textový import se všechny dodávají v HotXLS Delphi Component pro Delphi a C++Builder, bez jakékoli extra konfigurace