Odborný článok

Čítacie prenájmy a stráže zápisu pre zošity HotXLS v Delphi

Vlákno na pozadí práve exportovalo zostavu so 40 000 riadkami, keď vlákno UI nastavilo jednu bunku, a súbor, ktorý skončil na disku, nezodpovedal žiadnemu zošitu, aký kedy existoval. HotXLS rieši túto triedu chýb v lxWorkbookView.pas, kde IXLSWorkbookViewCore vydáva O(1) čítacie prenájmy a fail-fast stráže zápisu: kým je prenájom otvorený, každý vstupný bod mutácie vyvolá výnimku namiesto zápisu

Zlyhanie, ktoré prichádza bez výpisu zásobníka

Čítanie zošitu nikdy nie je jedna atómová operácia. Prechádzanie zostavy predstavuje desiatky tisíc jednotlivých čítaní buniek rozložených do sekúnd a jediný SetValue, ktorý dopadne medzi dva z nich, stačí na zmenu toho, čo zvyšok prechádzania uvidí. Klasický engine to robí hmatateľným: TXLSCellRef.SetValue môže zavolať FSST.Remove a odstrániť položku zdieľaného reťazca, resetovať FValueType a znevalidniť stav cache vzorca, a to všetko v čase, keď iné vlákno je uprostred dereferencovania presne týchto štruktúr. Nič hneď nespadne. Dostanete zostavu, ktorej medzisúčty sa nezhodujú, alebo export, ktorý ticho prečíta reťazcový index ukazujúci teraz niekam inam

HotXLS toto zámerne nerieši čakaním zapisovateľov. Čitateľ môže držať zošit niekoľko sekúnd a vo VCL aplikácii je zapisovateľom často callback do UI alebo obsluha udalosti na hlavnom vlákne — zablokovať toto vlákno, kým export na pozadí neskončí, je horší výsledok než neúspech úpravy. Preto koordinačné jadro vyvolá EXLSWorkbookWriteGuardUnavailable v okamihu, keď sa proti otvorenému prenájmu pokúsi zápis, skôr než sa dotkne jediného poľa, a volajúci rozhodne, či úpravu zaradí do fronty, skúsi znova, alebo o tom informuje používateľa. Konflikty zlyhávajú okamžite, nečakajú vo fronte

Matica koordinácie HotXLS ukazujúca, že čítacie prenájmy môžu voľne koexistovať, že zápis pokúšajúci sa proti otvorenému prenájmu vyvolá EXLSWorkbookWriteGuardUnavailable, že prenájom požadovaný vo vnútri zápisovej transakcie vyvolá EXLSWorkbookReadLeaseUnavailable a že dve zapisovacie vlákna sa navzájom nikdy nevylučujú
Čitatelia koexistujú a zapisovatelia voči nim rýchlo zlyhávajú, no jadro nikdy nevylučuje jedno zapisovacie vlákno od druhého

Je bezpečné čítať zošit z dvoch vlákien?

Áno, pokiaľ obaja čitatelia držia prenájom a nikto nepíše. IXLSWorkbookViewCore.AcquireReadLease vezme TCriticalSection, zvýši počítadlo, urobí snímku aktuálnej generácie a vráti IXLSWorkbookReadLease — konštantný čas nezávisle od toho, či zošit obsahuje tisíc alebo milión buniek. Ľubovoľný počet prenájmov koexistuje, uvoľniť sa dajú v ľubovoľnom poradí a každý z nich udržiava jadro nažive cez vlastnú referenciu rozhrania, takže prenájom, ktorý prežije objekt, ktorý ho vytvoril, je bezpečný a nie visiaci ukazovateľ. Zapájajú sa oba enginy: TXLSWorkbook v lxHandle.pas a TXLSXWorkbook v lxHandleX.pas si každý v konštruktore postaví jadro a sprístupňujú _AcquireReadLease a _AcquireWriteGuard

Rovnako dôležité je, čo prenájom do cesty čítania nepridáva. Kritická sekcia kryje získanie prenájmu, jeho uvoľnenie a hranice zápisových transakcií — nič viac. Obyčajné čítanie jednotlivých buniek nikdy nevstupuje do zámku, monitora ani atómového počítadla, takže držanie prenájmu stojí jedno získanie a jedno uvoľnenie za celé prehľadávanie, nie jedno na bunku. Je to ten istý dizajnový inštinkt ako za prácou na paralelnom parsovaní XLSX a alokátori pamäte: za koordináciu sa platí na hranici, nikdy vo vnútornom cykle. Platí aj symetrické pravidlo — AcquireReadLease vyvolá EXLSWorkbookReadLeaseUnavailable vždy, keď WriteDepth nie je nulové, takže prenájom nemožno otvoriť zvnútra zápisovej transakcie, a to ani na zapisujúcom vlákne

HotXLS platí za koordináciu na hranici prehľadávania: kritická sekcia kryje iba získanie a uvoľnenie prenájmu a hranice zápisových transakcií, zatiaľ čo strážcu zápisu získava vnútri TXLSCellRef.SetValue, takže každé pohodlné API nad ním prejde kontrolou raz
Jedno získanie a jedno uvoľnenie pokryjú prehľadávanie päťdesiatich tisíc buniek a jediný strážca vo vnútri TXLSCellRef.SetValue pokryje každú verejnú cestu zápisu nad ním
uses
  lxHandle, lxWorkbookView;

procedure TReportThread.Execute;
var
  Lease: IXLSWorkbookReadLease;
  Sheet: TXLSWorksheet;
  Row: Integer;
  Total: Double;
begin
  // Vyvolá EXLSWorkbookReadLeaseUnavailable, ak práve beží zápis
  Lease := FWorkbook._AcquireReadLease;
  Sheet := FWorkbook.Sheets[1];
  Total := 0;
  for Row := 1 to 50000 do
    Total := Total + Sheet.Cells[Row, 3].Value;
  FTotal := Total;
  // Tu prenájom opúšťa rozsah pôsobnosti: jeho referenčné počítadlo
  // klesne na nulu, spustí sa ReleaseReadLease a zápisy sú opäť možné
end;

Kde sa stráž zápisu vlastne nachádza?

V najnižšej meniteľnej vrstve, nikdy v pohodlnom API nad ňou. _AcquireWriteGuard sa volá z vnútra samotného TXLSCellRef.SetValue, čo znamená, že každá verejná cesta, ktorá sa do nej zlieva — Range.Value, priradenie textu listu, kopírovanie bunku po bunke, prilepenie — prejde kontrolou raz namiesto toho, aby každý obal opakoval kontrolu, ktorú budúci obal zabudne. Pokrytie je zámerne široké: 55 získaní stráže v lxHandle.pas a 37 v lxHandleX.pas k dávke, ktorá toto jadro zaviedla

Strážený povrch zahŕňa hodnoty a formátovanie buniek, TXLSWorkbook.Open, kopírovanie a prilepenie, definované názvy (Add, premenovanie, RefersTo, Visible, IsMacro, Comment, Delete), metadáta listov ako Name, Zoom, Visible, StandardHeight, FreezePanes, Protect a Activate, nastavenie strany, zalomenia strán a Calculate. Umiestnenie je celá pointa: stráž sa získava skôr, než sa zapíše prvé pole, a nevaliduje sa neskôr cez notifikačný hook, takže odmietnutá mutácia ponechá model identický do posledného bajtu. Regresná sada tvrdí presne toto: po každom odmietnutom volaní znovu prečíta názov listu, zoom, viditeľnosť, štandardnú výšku, okraje, orientáciu a počty zalomení strán. Načítacie cesty dostávajú rovnaké zaoblenie o vrstvu nižšie, kde brána čítania ZIP koordinuje súbežnú dekompresiu pre balíkové formáty

procedure TXLSWorksheet.Activate;
var
  WriteGuard: IXLSWorkbookWriteGuard;
begin
  // Získava sa skôr, než sa dotkne prvé pole, nikdy až potom
  WriteGuard := FWorkbook._AcquireWriteGuard;
  if not FSelected then
  begin
    FWorkbook.FWorkSheets.Deselect;
    FSelected := True;
  end;
  FWorkbook.FWorkSheets.FActiveSheet := Self;
  // Iba dokončená najvnejšia stráž posúva generáciu
  WriteGuard.Complete;
end;

Prečo vnorený zápis posúva generáciu len raz?

Lebo zápisová transakcia je definovaná najvnejšou strážou na vlákne, nie každou strážou osobitne. Jadro si drží stav zapisovateľa pre každé vlákno, ktorý obsahuje id vlákna, hĺbku a príznak dokončenia. Druhé AcquireWriteGuard na tom istom vlákne nájde tento stav a zvýši Depth namiesto vytvorenia novej transakcie, a až keď Depth klesne späť na nulu — pričom najvnejšia stráž bola označená ako Complete — posunie sa FGeneration. Vďaka tomu môže operácia vysokej úrovne ako Calculate či Open zavolať pod sebou desať strážených primitív a aj tak sa zaregistrovať ako jedna zmena. Vnútorné volania Complete sa zaznamenajú, ale samy o sebe nepohnú počítadlom, a stráže sa môžu uvoľniť mimo poradia bez narušenia účtovníctva

Smer zlyhania je rovnako výslovný. Ak sa stráž uvoľní bez Complete — obvyklý dôsledok výnimky rozvíjajúcej referenciu rozhrania — generácia nepostúpi, lebo zápisová transakcia si nikdy nenárokovala úspech. Majte jasno v tom, čo to znamená: HotXLS čiastočnú úpravu neroluje späť. Počítadlo zaznamená, že sa nedokončila žiadna úspešná transakcia, čo je presne signál, ktorý cache potrebuje, ale obnoviť model do predchádzajúceho stavu je niečo, čo za vás stráž s referenčným počítadlom nedokáže. Ak zlyhanie v polovici transakcie môže nechať zošit v stave, ktorý nemôžete odoslať, ponechajte si zdrojový súbor a otvorte ho znova, namiesto dôvery v objekt v pamäti

Porovnanie dvoch časových osí zápisových transakcií HotXLS: vnorené stráže na jednom vlákne zvyšujú hĺbku a posúvajú počítadlo generácií iba keď najvnejšia stráž dokončí, zatiaľ čo výnimka, ktorá rozvinie stráže bez Complete, ponechá generáciu nezmenenú a čiastočnú úpravu na mieste
Hĺbka sleduje vnorenie, ale iba dokončená najvnejšia transakcia posúva generáciu a prerušená nechá počítadlo aj čiastočnú úpravu presne tam, kde boli

Čo vám prinesie počítadlo generácií

Lacnú detekciu zastarania bez skenovania. Generation je UInt64, ktorý začína na 1 a pri pretečení preskočí 0, takže 0 nie je nikdy hodnota, ktorú jadro vydá, a funguje ako spoľahlivý sentinel „nikdy pozorované“. Dva invarianty z neho robia použiteľný nástroj: generácia sa nemôže pohnúť, kým existuje akýkoľvek čítací prenájom, a každá úspešná zápisová transakcia ho zvýši presne raz. IXLSWorkbookReadLease.Generation je preto snímka, ktorá ostáva konštantná po celý život prenájmu, a IXLSWorkbookWriteGuard.StartGeneration povie zapisovateľovi, ako vyzeral model, keď sa jeho transakcia otvorila. Grid, náhľad tlače alebo odvodený index môžu porovnať jedno celé číslo namiesto diffovania riadkov

var
  Lease: IXLSWorkbookReadLease;
begin
  Lease := FWorkbook._AcquireReadLease;
  if Lease.Generation <> FCachedGeneration then
  begin
    FCachedGeneration := Lease.Generation;
    RebuildRowHeightCache;
  end;
  PaintVisibleRows;
  // FCachedGeneration začína na 0, na hodnote, ktorú jadro nikdy nevydá,
  // takže úplne prvý priebeh cache vždy znova prestaví
end;

Čo táto koordinácia nesľubuje

Tri obmedzenia stoja za otvorené povedanie, lebo predpoklad opaku je spôsob, akým sa mechanizmus zneužíva. Po prvé, stráž zápisu nie je vzájomné vylúčenie medzi zapisovateľmi: jadro vylučuje čitateľov voči zapisovateľom a dve rôzne vlákna môžu držať stráž zápisu naraz, pričom každé posúva generáciu nezávisle — regresný test tvrdí presne toto správanie. Serializácia vašich vlastných zapisovacích vlákien je stále vaša práca. Po druhé, nič z toho nie je zámok súboru ani medziprocesový mutex; koordinuje vlákna v jednom procese voči jednej inštancii zošitu a dva procesy otvárajúce ten istý .xlsx o sebe nevedia nič. Po tretie, garancia dosahuje iba volajúcich, ktorí si prenájom naozaj vezmú — čítanie bez prenájmu stále kráča po odistenej horúcej ceste, ktorá je rýchla a úplne nechránená. Toto je koordinačné jadro, nie transakčná databáza

Použité v týchto medziach je to malý, úprimný primitív: deväť špecializovaných regresných testov pokrýva viacerých čitateľov, oba smery konfliktu, reentranciu, uvoľňovanie mimo poradia, prerušené transakcie a preteky čítanie/zápis a zápis/zápis naprieč vláknami, a to vo vnútri sady 1 328 testov prechádzajúcich na Win32 a Win64. Skombinujte ho s crash-safe ukladaním cez staged dočasné súbory a export na pozadí sa stane niečím, o čom môžete uvažovať od začiatku do konca — konzistentný počas čítania, atómový pri zápise. Čítacie prenájmy, stráže zápisu a počítadlo generácií dodáva klasický aj balíkový engine ako súčasť HotXLS Delphi Component pre Delphi a C++Builder, bez akejkoľvek konfigurácie potrebné na ich zapnutie