Artykuł techniczny

Dzierżawy odczytu i strażniki zapisu HotXLS dla skoroszytów

Wątek tła eksportował raport o 40 000 wierszy, gdy wątek UI ustawił jedną komórkę, a plik, który wylądował na dysku, nie pasował do żadnego skoroszytu, jaki kiedykolwiek istniał. HotXLS obsługuje tę klasę błędów w lxWorkbookView.pas, gdzie IXLSWorkbookViewCore wydaje dzierżawy odczytu O(1) i fail-fast strażniki zapisu: dopóki dzierżawa jest otwarta, każdy punkt wejścia mutacji rzuca, zamiast pisać

Awaria, która przychodzi bez śladu stosu

Czytanie skoroszytu nigdy nie jest jedną operacją atomową. Przejście raportu to dziesiątki tysięcy pojedynczych odczytów komórek rozsmarowanych na sekundy, a pojedynczy SetValue lądujący między dwoma z nich wystarcza, by zmienić, co reszta przejścia widzi. Klasyczny silnik robi to konkretnym: TXLSCellRef.SetValue może wywołać FSST.Remove, by upuścić wpis współdzielonego ciągu, zresetować FValueType i unieważnić stan bufora formuł, wszystko podczas gdy inny wątek jest w połowie dereferencjonowania dokładnie tych struktur. Nic nie wywala się na miejscu. Dostajesz raport, którego sumy częściowe się nie sumują, albo eksport, który po cichu czyta indeks ciągu, który teraz wskazuje gdzie indziej

HotXLS celowo nie rozwiązuje tego przez każenie pisarzom czekać. Czytelnik może trzymać skoroszyt przez kilka sekund, a w aplikacji VCL pisarz to często callback UI albo obsługa zdarzenia na wątku głównym — blokowanie tego wątku do końca eksportu w tle to gorszy wynik niż zawiedzione edytowanie. Rdzeń koordynacji rzuca więc EXLSWorkbookWriteGuardUnavailable w chwili, gdy zapis jest próbowany przeciw otwartej dzierżawie, zanim pojedyncze pole zostanie dotknięte, a wywołujący decyduje, czy zakolejkować edycję, powtórzyć, czy powiedzieć użytkownikowi. Konflikty fail-fast, nie kolejkowane

Matryca koordynacji HotXLS pokazująca, że dzierżawy odczytu współistnieją swobodnie, że zapis próbowany przeciw otwartej dzierżawie rzuca EXLSWorkbookWriteGuardUnavailable, że dzierżawa żądana wewnątrz transakcji zapisu rzuca EXLSWorkbookReadLeaseUnavailable i że dwa wątki pisarzy nigdy nie są wobec siebie wzajemnie wykluczone
Czytelnicy współistnieją, a pisarze zawodzą szybko przeciw nim, ale rdzeń nigdy nie wyklucza jednego wątku pisarza od drugiego

Czy skoroszyt jest bezpieczny do czytania z dwóch wątków?

Tak, pod warunkiem, że obaj czytelnicy trzymają dzierżawę i nikt nie pisze. IXLSWorkbookViewCore.AcquireReadLease bierze TCriticalSection, inkrementuje licznik, robi migawkę bieżącego pokolenia i zwraca IXLSWorkbookReadLease — stały czas niezależnie od tego, czy skoroszyt trzyma tysiąc komórek, czy milion. Dowolna liczba dzierżaw współistnieje, mogą być uwalniane w dowolnej kolejności, a każda przypina rdzeń żywym przez własną referencję interfejsu, więc dzierżawa przeżywająca obiekt, który ją stworzył, jest bezpieczna, a nie wiszącym wskaźnikiem. Oba silniki uczestniczą: TXLSWorkbook w lxHandle.pas i TXLSXWorkbook w lxHandleX.pas każdy buduje rdzeń w swoim konstruktorze i eksponuje _AcquireReadLease i _AcquireWriteGuard

Równie ważne jest to, czego dzierżawa nie dodaje do ścieżki odczytu. Sekcja krytyczna pokrywa nabycie dzierżawy, zwolnienie dzierżawy i granice transakcji zapisu — nic więcej. Zwykły odczyt per komórka nigdy nie wchodzi w blokadę, monitor ani licznik atomowy, więc trzymanie dzierżawy kosztuje jedno nabycie i jedno zwolnienie na całe skanowanie, nie jedno na komórkę. To ten sam instynkt projektowy za pracą nad równoległym parsowaniem XLSX i alokatorem pamięci: płać za koordynację na granicy, nigdy w pętli wewnętrznej. Reguła symetryczna też obowiązuje — AcquireReadLease rzuca EXLSWorkbookReadLeaseUnavailable zawsze, gdy WriteDepth jest niezerowe, więc nie możesz otworzyć dzierżawy z wnętrza transakcji zapisu, nawet na wątku piszącym

HotXLS płaci za koordynację na granicy skanowania: sekcja krytyczna pokrywa tylko nabycie dzierżawy, zwolnienie i granice transakcji zapisu, podczas gdy strażnik zapisu jest nabywany wewnątrz TXLSCellRef.SetValue, więc każde API wygody nad nim jest bramkowane raz
Jedno nabycie i jedno zwolnienie pokrywają skan pięćdziesięciu tysięcy komórek, a pojedynczy strażnik wewnątrz TXLSCellRef.SetValue pokrywa każdą publiczną ścieżkę zapisu nad nim
uses
  lxHandle, lxWorkbookView;

procedure TReportThread.Execute;
var
  Lease: IXLSWorkbookReadLease;
  Sheet: TXLSWorksheet;
  Row: Integer;
  Total: Double;
begin
  // Rzuca EXLSWorkbookReadLeaseUnavailable, jeśli zapis jest w locie
  Lease := FWorkbook._AcquireReadLease;
  Sheet := FWorkbook.Sheets[1];
  Total := 0;
  for Row := 1 to 50000 do
    Total := Total + Sheet.Cells[Row, 3].Value;
  FTotal := Total;
  // Dzierżawa opuszcza zakres tutaj: jej licznik referencji spada do zera,
  // ReleaseReadLease działa, a pisarze stają się znów możliwi
end;

Gdzie faktycznie siedzi strażnik zapisu?

Na najniższej zmiennej warstwie, nigdy na API wygody nad nią. _AcquireWriteGuard jest wywoływany z wnętrza samego TXLSCellRef.SetValue, co znaczy, że każda publiczna ścieżka, która leje się do niego — Range.Value, przypisanie tekstu arkusza, kopiowanie komórka po komórce, wklejanie — jest bramkowana raz, zamiast każdego opakowania powtarzającego kontrolę, którą przyszłe opakowanie zapomni. Pokrycie jest celowo szerokie: 55 nabyć strażnika w lxHandle.pas i 37 w lxHandleX.pas na moment partii, która wprowadziła rdzeń

Bramkowana powierzchnia obejmuje wartości komórek i formatowanie komórek, TXLSWorkbook.Open, kopiowanie i wklejanie, nazwy zdefiniowane (Add, zmiana nazwy, RefersTo, Visible, IsMacro, Comment, Delete), metadane arkusza takie jak Name, Zoom, Visible, StandardHeight, FreezePanes, Protect i Activate, ustawienia strony, podziały stron i Calculate. Umiejscowienie jest całym punktem: strażnik jest nabywany, zanim pierwsze pole zostanie zapisane, nie walidowany potem przez hak powiadomień, więc odrzucona mutacja zostawia model bajt-identyczny. Zestaw regresji assertuje dokładnie to, ponownie czytając nazwę arkusza, zoom, widoczność, standardową wysokość, marginesy, orientację i liczby podziałów stron po każdej odmówionej rozmowie. Ścieżki ładowania dostają to samo traktowanie o warstwę niżej, gdzie brama odczytu ZIP koordynuje współbieżne rozompresowywanie dla formatów pakietowych

procedure TXLSWorksheet.Activate;
var
  WriteGuard: IXLSWorkbookWriteGuard;
begin
  // Nabyt przed dotknięciem pierwszego pola, nigdy po
  WriteGuard := FWorkbook._AcquireWriteGuard;
  if not FSelected then
  begin
    FWorkbook.FWorkSheets.Deselect;
    FSelected := True;
  end;
  FWorkbook.FWorkSheets.FActiveSheet := Self;
  // Tylko ukończony zewnętrzny strażnik przesuwa pokolenie
  WriteGuard.Complete;
end;

Dlaczego zagnieżdżony zapis przesuwa pokolenie tylko raz?

Bo transakcja zapisu jest definiowana przez najbardziej zewnętrzny strażnik na wątku, nie przez każdy strażnik osobno. Rdzeń trzyma per-wątek stan pisarza trzymający id wątku, głębokość i flagę ukończenia. Drugi AcquireWriteGuard na tym samym wątku znajduje ten stan i inkrementuje Depth, zamiast tworzyć nową transakcję, i dopiero gdy Depth spadnie z powrotem do zera — przy najbardziej zewnętrznym strażniku oznaczonym CompleteFGeneration się przesuwa. To pozwala operacji wysokiego poziomu, takiej jak Calculate albo Open, wywołać dziesięć strzeżonych prymitywów pod spodem i nadal zarejestrować się jako jedna zmiana. Wewnętrzne wywołania Complete są zapisywane, ale same nie ruszają licznika, a strażnicy mogą być uwalniani nie po kolei, nie łamiąc księgowości

Kierunek awarii jest równie jawny. Jeśli strażnik jest uwalniany bez Complete — zwykły skutek wyjątku odwijającego referencję interfejsu — pokolenie się nie przesuwa, bo transakcja zapisu nigdy nie roszczyła sukcesu. Miej jasne oczy co do tego, co to znaczy: HotXLS nie wycofuje częściowej edycji. Licznik zapisuje, że żadna udana transakcja się nie ukończyła, co jest dokładnie sygnałem, którego potrzebuje bufor, ale przywracanie modelu do poprzedniego stanu to nie jest coś, co strażnik liczony referencjami może za ciebie zrobić. Jeśli awaria w środku transakcji może zostawić skoroszyt w kształcie, którego nie możesz wysłać, trzymaj plik źródłowy i otwórz go ponownie, zamiast ufać obiektowi w pamięci

Dwie osie czasu transakcji zapisu HotXLS porównane: zagnieżdżone strażniki na jednym wątku podnoszą głębokość i przesuwają licznik pokoleń tylko, gdy najbardziej zewnętrzny strażnik się ukończy, podczas gdy wyjątek odwijający strażników bez Complete zostawia pokolenie niezmienione, a częściową edycję na miejscu
Depth śledzi zagnieżdżenie, ale tylko ukończona najbardziej zewnętrzna transakcja przesuwa pokolenie, a przerwana zostawia i licznik, i częściową edycję dokładnie tam, gdzie były

Co licznik pokoleń ci kupuje

Tanie wykrywanie nieświeżości bez skanowania. Generation to UInt64, który startuje na 1 i pomija 0 przy zawinięciu, więc 0 nigdy nie jest wartością, którą rdzeń wydaje, i działa jako wiarygodny wartownik „nigdy nie zaobserwowano”. Dwie niezmienniki czynią go użytecznym: pokolenie nie może się ruszyć, dopóki jakakolwiek dzierżawa odczytu istnieje, a każda udana transakcja zapisu inkrementuje je dokładnie raz. Więc IXLSWorkbookReadLease.Generation to migawka, która pozostaje stała przez całe życie dzierżawy, a IXLSWorkbookWriteGuard.StartGeneration mówi pisarzowi, jak model wyglądał, gdy jego transakcja się otworzyła. Siatka, podgląd wydruku albo pochodny indeks mogą porównać jedną liczbę całkowitą zamiast diffować wiersze

var
  Lease: IXLSWorkbookReadLease;
begin
  Lease := FWorkbook._AcquireReadLease;
  if Lease.Generation <> FCachedGeneration then
  begin
    FCachedGeneration := Lease.Generation;
    RebuildRowHeightCache;
  end;
  PaintVisibleRows;
  // FCachedGeneration startuje na 0, wartości, której rdzeń nigdy nie wydaje,
  // więc sam pierwszy przebieg zawsze przebudowuje
end;

Czego ta koordynacja nie obiecuje

Trzy limity są warte podania wprost, bo zakładanie odwrotnego to sposób, w jaki mechanizm bywa nadużywany. Po pierwsze, strażnik zapisu nie jest wzajemnym wykluczeniem między pisarzami: rdzeń wyklucza czytelników przeciw pisarzom, a dwa różne wątki mogą każdy trzymać strażnika zapisu w tym samym czasie, każdy przesuwając pokolenie niezależnie — test regresji assertuje dokładnie to zachowanie. Serializowanie własnych wątków pisarzy to nadal twoja robota. Po drugie, nic tu nie jest blokadą pliku ani międzyprocesowym muteks; koordynuje wątki wewnątrz jednego procesu przeciw jednej instancji skoroszytu, a dwa procesy otwierające ten sam .xlsx nic o sobie nie wiedzą. Po trzecie, gwarancja sięga tylko wywołujących, którzy faktycznie biorą dzierżawę — odczyt bez dzierżawy nadal idzie niezablokowaną gorącą ścieżką, która jest szybka i całkiem niechroniona. To rdzeń koordynacji, nie transakcyjna baza danych

Użyty w tych granicach jest małym, uczciwym prymitywem: dziewięć dedykowanych testów regresji pokrywa wielu czytelników, oba kierunki konfliktu, reentrancję, zwalnianie nie po kolei, przerwane transakcje i wyścigi między-wątkowe odczyt/zapis oraz zapis/zapis, wewnątrz zestawu 1 328 testów przechodzących na Win32 i Win64. Sparuj go ze ścieżką zapisu awaryjnie bezpiecznych etapowanych plików tymczasowych a eksport w tle staje się czymś, o czym możesz rozumować od końca do końca — spójny, gdy czyta, atomowy, gdy pisze. Dzierżawy odczytu, strażnicy zapisu i licznik pokoleń wychodzą jako część silników klasycznego i pakietowego w HotXLS Delphi Component dla Delphi i C++Buildera, bez żadnej konfiguracji potrzebnej do ich włączenia