Artykuł techniczny

Zakleszczenie callbacku monitora plików HotXLS w Delphi

HotXLS obserwuje skoroszyt na dysku przez TXLSFileMonitor i podnosi OnFileChanged, gdy inny proces zapisze na plik. Ustawienie Monitor.Active := False z wnętrza tego handlera potrafiło wisieć w nieskończoność, bo wątek roboczy wciąż był zablokowany w oczekiwaniu na callback, który właśnie wysłał. Poprawka polega na tym, żeby obiekt wątku niósł tożsamość callbacku, który aktualnie wykonuje

Dlaczego zatrzymanie monitora plików we własnym handlerze wisi?

Bo wywołanie dezaktywacji i callback biegną na dwóch różnych wątkach i każdy czeka, aż drugi skończy. TXLSFileMonitor ma SynchronizeEvent ustawione na True, co znaczy, że wątek odpytywania nie wywołuje twojego handlera sam. Wywołuje TThread.Synchronize, przekazuje metodę głównemu wątkowi i blokuje się, dopóki główny wątek jej nie uruchomi i nie zasygnalizuje zakończenia. To dokładnie to, czego chcesz dla handlera przeładowującego grid albo odświeżającego formę, i to samo czyni oczywiste wywołanie dezaktywacji pułapką

Dwie twarze zakleszczenia monitora plików HotXLS: przy SynchronizeEvent True główny wątek blokuje się w Worker.WaitFor, podczas gdy worker blokuje się w TThread.Synchronize, a przy False handler biegnie na wątku odpytywania, więc SetActive False czeka na sam wątek, zostawiając OnFileChanged bez możliwości powrotu
Przy włączonym SynchronizeEvent główny wątek czeka w Worker.WaitFor, podczas gdy worker czeka w Synchronize; przy wyłączonym handler biegnie na wątku odpytywania i czeka na własne wyjście — jeden zamknięty cykl, dwa tryby wysyłki
procedure TMainForm.HandleFileChanged(Sender: TObject);
begin
  // Jesteśmy tu na wątku głównym, dzięki TThread.Synchronize
  FBook.LoadFromFile(FMonitor.FileName);
  RefreshGrid;
  // Użytkownik chciał tylko jednego przeładowania, więc przestajemy śledzić plik
  FMonitor.Active := False;   // kiedyś nigdy nie zwracało sterowania
end;

Prześledź oba stacki, a cykl jest oczywisty. Główny wątek jest w SetActive(False), który kończy workera i potem woła Worker.WaitFor, żeby mieć pewność, że wątek naprawdę wyszedł, zanim obiekt zostanie zwolniony. Wątek roboczy tymczasem siedzi w TThread.Synchronize, czekając, aż główny wątek wróci z dokładnie tego handlera, który woła SetActive. Żadna strona nie może się ruszyć. Aplikacja nie crashuje, nie rzuca wyjątku, po prostu przestaje się odświeżać, a to najgorsza sygnatura diagnostyczna, jaką bug może mieć

To samo zakleszczenie ma drugą twarz

Wyłączenie synchronizacji nie omija problemu, tylko go przenosi. Przy SynchronizeEvent ustawionym na False — rozsądny wybór w serwisie albo narzędziu konsolowym bez pętli komunikatów — TXLSFileMonitor wywołuje handler bezpośrednio na wątku odpytywania. Twój handler biegnie teraz na workerze, więc wołanie Monitor.Active := False z jego wnętrza trafia do Worker.WaitFor na samym wątku roboczym. Wątek nie może czekać na własne wyjście, więc blokuje się, dopóki coś z zewnątrz nie ubije procesu. Oba warianty to ten sam bug: SetActive(False) zakładał, że zawsze jest wołany skądinąd niż z callbacku. To założenie trzyma się przy przycisku Close czy destruktorze formy i zawodzi w tym jednym miejscu wywołania, po które ludzie sięgają najpierw — dlatego przeżył tak długo. Każdy komponent, który i jest właścicielem wątku, i wysyła z niego kod użytkownika, dziedziczy to samo zagrożenie, a rozwiązanie jest w każdym przypadku takie samo — ścieżka rozbierania musi umieć stwierdzić, czy biegnie z wnętrza callbacku, który wątek, z którym ma się połączyć, wciąż trzyma

Niech wątek roboczy niesie tożsamość callbacku

HotXLS rozwiązuje to, zapisując na samym obiekcie wątku, który wątek OS aktualnie wykonuje handler. TXLSFileMonitorThread.FireEvent stempluje FCallbackThreadID wartością GetCurrentThreadId tuż przed wywołaniem handlera i czyści go potem w bloku finally. To jedno pole wystarcza ścieżce dezaktywacji, żeby odróżnić „wywołane z niezwiązanego kodu" od „wywołane z wnętrza mojego własnego callbacku", i działa identycznie w obu trybach wysyłki, bo w zsynchronizowanym przypadku GetCurrentThreadId wewnątrz FireEvent już raportuje główny wątek

procedure TXLSFileMonitorThread.FireEvent;
var
  Owner: TXLSFileMonitor;
  Handler: TNotifyEvent;
begin
  // Zakolejkowany callback może zostać wywołany przez WaitFor podczas dezaktywacji
  if Terminated then
    Exit;
  Owner := FOwner;
  Handler := Owner.FOnFileChanged;
  if not Assigned(Handler) then
    Exit;
  FCallbackThreadID := GetCurrentThreadId;
  try
    Handler(Owner);
  finally
    // Handler mógł zniszczyć Owner, ale ten wątek dalej żyje
    FCallbackThreadID := 0;
  end;
end;

Gałąź dezaktywacji SetActive robi potem trzy rzeczy w przemyślanej kolejności. Najpierw odczepia wątek od monitora, czyszcząc FThread, więc GetActive raportuje False od tej chwili, a reentrantowe Active := False wewnątrz tego samego handlera kończy się natychmiast, zamiast próbować rozbierać wątek dwa razy. Potem woła Terminate. Dopiero wtedy decyduje, jak odebrać obiekt wątku

Jak HotXLS rozwiązuje rozbieranie monitora plików: TXLSFileMonitorThread.FireEvent stempluje FCallbackThreadID wartością GetCurrentThreadId przed wywołaniem handlera i czyści go w bloku finally, więc SetActive może odczepić FThread, zawołać Terminate i wybrać między samouwolnieniem FreeOnTerminate wewnątrz callbacku a klasycznym WaitFor plus Free
FireEvent stempluje FCallbackThreadID przed wywołaniem handlera i czyści go w bloku finally; SetActive wybiera potem między FreeOnTerminate, gdy tożsamość się zgadza, a klasycznym WaitFor plus Free, gdy wywołujący jest niezwiązany
Worker := TXLSFileMonitorThread(FThread);
FThread := nil;            // od tego momentu monitor jest nieaktywny
Worker.Terminate;
FHasBaseline := False;
if Worker.FCallbackThreadID = GetCurrentThreadId then
  // Zarówno bezpośrednie, jak i synchronizowane handlery muszą się zakończyć, zanim wątek roboczy
  // może zakończyć działanie; zwalnia się sam po zakończeniu callbacka
  Worker.FreeOnTerminate := True
else
begin
  Worker.WaitFor;
  Worker.Free;
end;

Gdy tożsamość się zgadza, HotXLS nigdy nie czeka. Ustawia FreeOnTerminate := True i wraca, pozwalając handlerowi dokończyć, wywołaniu Synchronize się zakończyć, Execute zauważyć Terminated, a RTL zwolnić obiekt wątku, gdy stack callbacku w pełni się rozwinie. Gdy tożsamość się nie zgadza — przycisk Close, destruktor, zadanie w tle — klasyczne WaitFor plus Free wciąż jest właściwe, bo daje twardą gwarancję, że po powrocie z Active := False żaden dalszy callback nie odpali. Dwie ścieżki wyjścia, jedno pole do wyboru między nimi. Jedna konsekwencja zasługuje na dopowiedzenie: na ścieżce samouwolnienia worker żyje dłużej niż handler. Nic w ścieżce powrotu FireEvent nie może już dotknąć obiektu monitora, bo handler miał prawo go zwolnić. Dlatego FireEvent z góry cache'uje Owner i Handler do lokalnych i w klauzuli finally dotyka tylko własnego pola. Zbłąkane FOwner.Something po wywołaniu handlera zamieniłoby naprawione zakleszczenie w use-after-free, a to ściśle gorszy wynik

Dlaczego FireEvent musi sprawdzać Terminated, zanim cokolwiek zrobi?

Bo WaitFor to nie bierne czekanie. Na głównym wątku implementacja RTL TThread.WaitFor opróżnia kolejkę synchronizacji, dopóki blokuje, właśnie po to, żeby wątek siedzący w Synchronize mógł dokończyć i wyjść, zamiast zakleszczać się przeciw zamykaniu. To cecha, a znaczy, że callback zakolejkowany mikrosekundy przed tym, jak zawołałeś Active := False z przycisku Close, wciąż może zostać wysłany w trakcie rozbierania, po tym jak monitor już upuścił referencję FThread. Strażnik na górze FireEvent robi z tej wysyłki no-op. Bez niego aplikacja zamykająca główną formę, gdy na obserwowany plik właśnie spada zapis, może uruchomić handler przeładowania przeciw na wpół rozebranemu stanowi, a wynikowy access violation nie wygląda wcale jak problem z kolejnością zamykania. Jeśli twój własny komponent wysyła pracę przez Synchronize, reguła się uogólnia: każda zsynchronizowana metoda potrzebuje sprawdzenia Terminated jako pierwszej instrukcji, nie dlatego, że wątek mógł zniknąć, ale dlatego, że kolejka przeżywa decyzję o zatrzymaniu

Ten sam kształt buga w ścieżce importu CSV

Audyt, który wyprodukował poprawkę monitora, odkrył siostrzany defekt w importerze tekstu, i warto czytać je obok siebie, bo błąd u podstaw jest identyczny: kawałek konfiguracji konsumowany tak, jakby był stanem roboczym. TXLSCSVLoader wystawia właściwość SkipRows dla linii banerowych siedzących nad prawdziwym nagłówkiem w eksportowanych raportach. Parser potrzebuje odliczania, dopóki przerabia te linie, a oryginalna implementacja odliczała na samym polu zapasowym właściwości. Zaimportuj jeden plik i SkipRows po cichu stawało się zerem; użyj tego samego loadera dla drugiego pliku i linie banerowe lądowały w gridzie

Loader := TXLSCSVLoader.Create;
try
  Loader.SkipRows := 2;             // dwie linie banera nad nagłówkiem
  Loader.ParseFile('jan-export.csv');
  ReadGrid(Loader);
  // SkipRows to tu nadal 2: FSkipRemaining jest resetowany przy każdym parsowaniu
  Loader.ParseFile('feb-export.csv');
  ReadGrid(Loader);
finally
  Loader.Free;
end;

HotXLS trzyma teraz FSkipRows jako zadeklarowane ustawienie i resetuje osobny licznik FSkipRemaining na starcie każdego parsowania, w obu ścieżkach — rozdzielanej i o stałej szerokości. Ogólna reguła jest tania w zastosowaniu i sama się zwraca: jeśli pętla go dekrementuje, to nie jest pole zapasowe właściwości. Ta sama partia zaostrzyła też XlsCsvTryParseISODateTime, które wcześniej było skłonne przyjąć parsowalny prefiks jako całą datę. Teraz waliduje całe pole — długość, pozycje separatorów i każdą cyfrę — więc wartość jak referencja zamówienia, która tylko zaczyna się czymś w kształcie daty, zostaje tekstem zamiast stawać się złym TDateTime. Cicha konwersja typów przy imporcie jest dużo droższa w debugowaniu niżej w potoku niż odrzucone pole — wątek, który przewija się też przez to, jak HotXLS chroni skoroszyt przed równoczesnymi czytelnikami i pisarzami

Defekt loadera CSV stojący za audytem HotXLS: odliczanie SkipRows w dół na polu zapasowym właściwości po cichu zerowało je po jednym parsowaniu, więc drugi plik przeciekał wierszami banerowymi do grida, a poprawiony model trzyma FSkipRows jako zadeklarowane ustawienie i resetuje osobny licznik FSkipRemaining na starcie każdego wywołania ParseFile
Odliczanie w dół na polu zapasowym właściwości zamienia konfigurację w stan roboczy: pierwsze parsowanie zeruje SkipRows, a poprawiony model trzyma FSkipRows zadeklarowane i resetuje FSkipRemaining na starcie każdego parsowania

Co ta poprawka ci daje, a czego nie

Bądź precyzyjny co do gwarancji. Zmiana dotycząca własności wątku czyni bezpiecznym dokładnie jeden wcześniej śmiertelny wzorzec: dezaktywację albo zwolnienie TXLSFileMonitor z wnętrza jego własnego handlera OnFileChanged, w obu trybach wysyłki. To nie jest ogólny projekt współbieżności i nie czyni komponentu bezpiecznym do sterowania z dowolnych wątków

  • Zapisy właściwości wciąż nie są thread-safe. Ustawiaj FileName, IntervalMs i SynchronizeEvent z jednego wątku, zwykle tego, który utworzył monitor
  • IntervalMs obowiązuje od następnej aktywacji, a worker śpi w krótkich kawałkach, żeby Terminate był honorowany szybko nawet przy długim okresie odpytywania
  • Wykrywanie idzie po czasie ostatniego zapisu plus rozmiarze, więc zmiana zachowująca oba jest niewidzialna; plik, który znika w trakcie zapisu, nie odpala, a zdarzenie przychodzi, gdy nowa treść już jest na miejscu
  • Przy SynchronizeEvent ustawionym na False twój handler biegnie na wątku roboczym, a każda reguła o dotykaniu kontrolek VCL z wątku tła obowiązuje dla wszystkiego, co robisz w środku
  • Przeładowanie skoroszytu z handlera wciąż wymaga własnej dyscypliny, zwłaszcza gdy inny kod trzyma referencję do wymienianego skoroszytu

W praktyce najwięcej uwagi zasługuje handler przeładowania, nie monitor. Zdarzenie zmiany zwykle przychodzi, gdy proces piszący wciąż się osadza, więc ładowanie otwierające plik natychmiast może trafić na na wpół zakończony zapis; jedna ponowna próba po krótkim opóźnieniu jest bardziej odporna niż ufanie pierwszemu stempelowi. Jeśli to twój kod pisze obserwowany plik, przejściowy plik tymczasowy i atomowy rename opisane w tym, jak HotXLS czyni każdy zapis odpornym na awarię, dają też monitorowi czyste zdarzenie pojedynczego przejścia do obserwacji, zamiast strumienia pośrednich rozmiarów. A jeśli kilku obserwatorów zasila tę samą procedurę przeładowania, problemy serializacji w bramce odczytu wokół równoczesnej inflacji ZIP to następna lektura. Szersza lekcja przeżywa konkretny komponent. Każda klasa, która jest właścicielem wątku i woła z powrotem do kodu użytkownika, potrzebuje ścieżki rozbierania potrafiącej rozpoznać własną ramkę callbacku, bo handler to najbardziej naturalne miejsce na świecie, w którym użytkownik powie stop. Zapisanie id wątku callbacku kosztuje jeden DWORD i zamienia niewyjaśnialne zawieszenie w zwykłe pytanie o żywotność obiektu. Monitorowanie plików, atomowe zapisy i import tekstu wszystkie jadą w HotXLS Delphi Component dla Delphi i C++Builder, bez żadnej dodatkowej konfiguracji, żeby cokolwiek z tego włączyć