Teknisk artikel

HotXLS filmonitor-callback-dödläge i Delphi

HotXLS bevakar en arbetsbok på disk med TXLSFileMonitor och lyfter OnFileChanged när en annan process sparar över filen. Att sätta Monitor.Active := False inifrån den hanteraren brukade hänga i all evighet, för worker-tråden låg fortfarande blockerad och väntade på den callback den just hade skickat. Fixen är att låta trådobjektet bära identiteten på den callback det för närvarande kör

Varför hänger det att stoppa en filmonitor inuti sin egen hanterare?

För att avaktiveringsanropet och callbacken kör på två olika trådar, och var och en väntar på att den andra ska bli klar. TXLSFileMonitor levereras med SynchronizeEvent satt till True, vilket betyder att pollingtråden inte anropar din hanterare själv. Den anropar TThread.Synchronize, lämnar metoden till huvudtråden och blockerar sedan tills huvudtråden kört den och signalerat att den är klar. Det är precis vad du vill ha för en hanterare som laddar om ett rutnät eller målar om en form, och det är också det som gör det uppenbara avaktiveringsanropet till en fälla

Två dödlägesansikten hos HotXLS-filmonitorn: med SynchronizeEvent True blockerar huvudtråden i Worker.WaitFor medan worker-tråden blockerar i TThread.Synchronize, och med den False körs hanteraren på pollingtråden så SetActive False väntar på själva tråden, vilket lämnar OnFileChanged oförmögen att returnera
Med SynchronizeEvent på väntar huvudtråden i Worker.WaitFor medan worker-tråden väntar i Synchronize; med den av körs hanteraren på pollingtråden och väntar på sin egen exit — en sluten cykel, två dispatch-lägen
procedure TMainForm.HandleFileChanged(Sender: TObject);
begin
  // Vi är på huvudtråden här, tack vare TThread.Synchronize
  FBook.LoadFromFile(FMonitor.FileName);
  RefreshGrid;
  // Användaren ville bara ha en omladdning, så sluta bevaka
  FMonitor.Active := False;   // brukade aldrig returnera
end;

Spåra de två stackarna så är cykeln uppenbar. Huvudtråden är inuti SetActive(False), som terminerar worker-tråden och sedan anropar Worker.WaitFor för att vara säker på att tråden verkligen har avslutats innan objektet frigörs. Worker-tråden är under tiden parkerad inuti TThread.Synchronize och väntar på att huvudtråden ska returnera från just den hanterare som anropar SetActive. Ingen sida kan röra sig. Applikationen kraschar inte, den höjer inget undantag, den slutar bara måla om, vilket är den värsta diagnostiska signatur en bugg kan ha

Samma dödläge bär ett andra ansikte

Att stänga av synkroniseringen undviker inte problemet, det flyttar bara på det. Med SynchronizeEvent satt till False — det förnuftiga valet i en tjänst eller ett konsolverktyg utan message loop — anropar TXLSFileMonitor hanteraren direkt på pollingtråden. Din hanterare kör nu på worker-tråden, så ett anrop till Monitor.Active := False inifrån den når Worker.WaitFor på själva worker-tråden. En tråd kan inte vänta på sin egen exit, så den blockerar tills något utifrån dödar processen. Båda varianterna är samma bugg: SetActive(False) antog att det alltid anropades från någonstans utanför callbacken. Det antagandet håller för en Close-knapp eller en formdestruktor och fallerar för den enda anropsplats folk sträcker sig efter först, vilket är varför den överlevde så länge. Varje komponent som både äger en tråd och skickar användarkod från den ärver samma risk, och lösningen är densamma i varje fall — teardown-vägen måste kunna avgöra om den körs inifrån en callback som tråden den tänker join:a fortfarande håller i

Låt worker-tråden bära callback-identiteten

HotXLS löser det genom att på själva trådobjektet registrera vilken OS-tråd som för närvarande exekverar hanteraren. TXLSFileMonitorThread.FireEvent stämplar FCallbackThreadID med GetCurrentThreadId omedelbart före det anropar hanteraren och rensar det i ett finally-block efteråt. Det enda fältet räcker för att avaktiveringsvägen ska kunna skilja "anropad från orelaterad kod" från "anropad inifrån min egen callback", och det fungerar identiskt för båda dispatch-lägena, för i det synkroniserade fallet rapporterar GetCurrentThreadId inuti FireEvent redan huvudtråden

procedure TXLSFileMonitorThread.FireEvent;
var
  Owner: TXLSFileMonitor;
  Handler: TNotifyEvent;
begin
  // En köad callback kan skickas av WaitFor under avaktivering
  if Terminated then
    Exit;
  Owner := FOwner;
  Handler := Owner.FOnFileChanged;
  if not Assigned(Handler) then
    Exit;
  FCallbackThreadID := GetCurrentThreadId;
  try
    Handler(Owner);
  finally
    // Hanteraren kan ha förstört Owner, men den här tråden lever vidare
    FCallbackThreadID := 0;
  end;
end;

Avaktiveringsgrenen i SetActive gör sedan tre saker i en medveten ordning. Den kopplar loss tråden från monitorn genom att rensa FThread först, så GetActive rapporterar False från det ögonblicket och ett reentrant Active := False inuti samma hanterare avslutas omedelbart i stället för att försöka riva ned tråden två gånger. Den anropar Terminate. Först därefter avgör den hur trådobjektet ska återtas

Hur HotXLS löser filmonitorns teardown: TXLSFileMonitorThread.FireEvent stämplar FCallbackThreadID med GetCurrentThreadId före det anropar hanteraren och rensar det i ett finally-block, så SetActive kan koppla loss FThread, anropa Terminate och välja mellan FreeOnTerminate-självfrigöring inuti en callback och den klassiska WaitFor plus Free
FireEvent stämplar FCallbackThreadID före det anropar hanteraren och rensar det i ett finally-block; SetActive väljer sedan mellan FreeOnTerminate när identiteten matchar och den klassiska WaitFor plus Free när anroparen är orelaterad
Worker := TXLSFileMonitorThread(FThread);
FThread := nil;            // monitorn är inaktiv från denna punkt
Worker.Terminate;
FHasBaseline := False;
if Worker.FCallbackThreadID = GetCurrentThreadId then
  // Både direkta och synkroniserade hanterare måste returnera innan worker-
  // tråden kan avslutas; den frigör sig själv efter att callbacken rullats av
  Worker.FreeOnTerminate := True
else
begin
  Worker.WaitFor;
  Worker.Free;
end;

När identiteten matchar väntar HotXLS aldrig. Det sätter FreeOnTerminate := True och returnerar, och låter hanteraren bli klar, Synchronize-anropet slutföras, Execute observera Terminated, och RTL frigöra trådobjektet när callbackstacken väl rullat av helt. När identiteten inte matchar — en Close-knapp, en destruktor, ett bakgrundsjobb — är den klassiska WaitFor plus Free fortfarande rätt sak, för den ger dig en hård garanti att ingen ytterligare callback kan avfyras efter att Active := False returnerar. Två exit-vägar, ett fält för att välja mellan dem. En konsekvens förtjänar att sägas rakt ut: på självfrigöringsvägen överlever worker-tråden hanteraren. Ingenting i FireEvents returväg får röra monitorobjektet igen, för hanteraren har rätt att ha frigjort det. Det är därför FireEvent cachar Owner och Handler i lokala variabler på förhand och bara rör sitt eget fält i finally-satsen. Ett förryckt FOwner.Something efter hanteraranropet skulle förvandla ett fixat dödläge till en use-after-free, vilket är ett strikt sämre utfall

Varför måste FireEvent kontrollera Terminated innan det gör något?

För att WaitFor inte är ett passivt väntande. På huvudtråden fortsätter RTL-implementationen av TThread.WaitFor att tömma synkroniseringskön medan den blockerar, just för att en tråd som parkerat i Synchronize ska kunna slutföra och avsluta i stället för att dödläsa mot avstängningen. Det är en funktion, och det betyder att en callback som köades mikrosekunder innan du anropade Active := False från en Close-knapp fortfarande kan skickas under teardownen, efter att monitorn redan släppt sin FThread-referens. Guardet överst i FireEvent gör den dispatchen till en no-op. Utan det kan en applikation som stänger sitt huvudfönster medan en sparning landar på den bevakade filen köra en omladdningshanterare mot halvt avvecklat tillstånd, och den resulterande access violationen ser inte alls ut som ett avstängningsordningsproblem. Om din egen komponent skickar arbete genom Synchronize generaliserar regeln: varje synkroniserad metod behöver en Terminated-kontroll som sin första sats, inte för att tråden kan vara borta utan för att kön överlever beslutet att sluta

Samma buggform i CSV-importvägen

Granskningen som gav monitorfixen blottade ett syskonfel i textimportören, och det är värt att läsa sida vid sida för att det underliggande misstaget är identiskt: en konfigurationsbit som konsumeras som om den vore scratch-tillstånd. TXLSCSVLoader exponerar en SkipRows-egenskap för bannerraderna som sitter ovanför den riktiga rubriken i exporterade rapporter. Parsern behöver en nedräkning medan den arbetar igenom de raderna, och originalimplementationen räknade ned på egenskapens bakomliggande fält själv. Importera en fil så blev SkipRows tyst noll; återanvänd samma loader för en andra fil och bannerraderna hamnade i rutnätet

Loader := TXLSCSVLoader.Create;
try
  Loader.SkipRows := 2;             // två bannerrader ovanför rubriken
  Loader.ParseFile('jan-export.csv');
  ReadGrid(Loader);
  // SkipRows är fortfarande 2 här: FSkipRemaining återställs per parsning
  Loader.ParseFile('feb-export.csv');
  ReadGrid(Loader);
finally
  Loader.Free;
end;

HotXLS behåller nu FSkipRows som den deklarerade inställningen och återställer en separat FSkipRemaining-räknare vid starten av varje parsning, både i den avgränsade och i den fasta breddvägen. Den generella regeln är billig att tillämpa och betalar sig själv: om en loop dekrementerar den, är den inte ett egenskapsbakomliggande fält. Samma batch skärpte också XlsCsvTryParseISODateTime, som tidigare var villig att acceptera ett parsningsbart prefix som ett helt datum. Det validerar nu hela fältet — längd, avgränsningspositioner och varje siffra — så att ett värde som en orderreferens som bara börjar med något datumliknande förblir text i stället för att bli ett felaktigt TDateTime. Tyst typkoercion vid import är långt dyrare att felsöka nedströms än ett avvisat fält, ett tema som också löper genom hur HotXLS skyddar en arbetsbok mot samtidiga läsare och skribenter

CSV-loaderfelet bakom HotXLS-granskningen: att räkna ned SkipRows på egenskapens bakomliggande fält nollställde det tyst efter en parsning, så en andra fil läckte bannerrader in i rutnätet, medan den fixade modellen behåller FSkipRows som deklarerad inställning och återställer en separat FSkipRemaining-räknare vid starten av varje ParseFile-anrop
Att räkna ned på egenskapens bakomliggande fält förvandlar konfiguration till scratch-tillstånd: den första parsningen nollställer SkipRows, medan den fixade modellen behåller FSkipRows deklarerad och återställer FSkipRemaining vid starten av varje parsning

Vad den här fixen ger dig och inte ger dig

Var precis om garantin. Trådägarskapsändringen gör exakt ett tidigare fatalt mönster säkert: att avaktivera eller frigöra en TXLSFileMonitor inifrån sin egen OnFileChanged-hanterare, i endera dispatch-läget. Det är inte en generell konkurrensdesign, och det gör inte komponenten säker att driva från godtyckliga trådar

  • Egenskapskrivningar är fortfarande inte trådsäkra. Sätt FileName, IntervalMs och SynchronizeEvent från en tråd, normalt den som skapade monitorn
  • IntervalMs gäller från nästa aktivering, och worker-tråden sover i korta skivor så att Terminate hedras snabbt även med en lång pollperiod
  • Detektering sker via senaste skrivtid plus storlek, så en ändring som bevarar båda är osynlig; en fil som försvinner mitt i en sparning avfyrar inget, och händelsen kommer när det nya innehållet är på plats
  • Med SynchronizeEvent satt till False körs din hanterare på worker-tråden, och varje regel om att röra VCL-kontroller från en bakgrundstråd gäller för allt du gör inuti den
  • Att ladda om en arbetsbok från hanteraren kräver fortfarande sin egen diciplin, särskilt om annan kod håller en referens till boken som ersätts

I praktiken är omladdningshanteraren den del som förtjänar mest omsorg, inte monitorn. En ändringshändelse anländer oftast medan den skrivande processen fortfarande håller på att landa, så en laddning som öppnar filen omedelbart kan möta en delvis genomförd sparning; att försöka en gång till efter en kort fördröjning är robustare än att lita på den första stämpeln. Är det din egen kod som skriver den bevakade filen ger den stegade tempfilen och atomiska renamingen som beskrivs i hur HotXLS gör varje sparning kraschsäker också monitorn en ren enkel-övergångshändelse att observera i stället för en ström av mellanstorlekar. Och om flera bevakare matar samma omladdningsrutin är serialiseringsfrågorna i lässpärren kring samtidig ZIP-inflation nästa sak att läsa. Den bredare läxan överlever den specifika komponenten. Varje klass som äger en tråd och anropar tillbaka in i användarkod behöver en teardown-väg som kan känna igen sin egen callback-ram, för hanteraren är världens mest naturliga plats för en användare att säga stopp. Att registrera callback-tråd-id:t kostar en DWORD och förvandlar en oförklarlig hängning till en vanlig objektlivstidsfråga. Filbevakning, atomiska sparningar och textimport levereras alla i HotXLS Delphi Component för Delphi och C++Builder, utan extra konfiguration för att aktivera något av det