Technický článek

Read lease a write guard pro sešity HotXLS v Delphi

Vlákno na pozadí právě exportovalo přehled o 40 000 řádcích, když vlákno UI nastavilo jednu buňku, a soubor, který dopadl na disk, neodpovídal žádnému sešitu, který kdy existoval. HotXLS řeší tuto třídu chyb v lxWorkbookView.pas, kde IXLSWorkbookViewCore vystavuje read lease s O(1) a write guard s okamžitým selháním: dokud je lease otevřená, každý mutační vstupní bod místo zápisu vyvolá výjimku

Selhání, které přichází bez stack trace

Čtení sešitu nikdy není jediná atomická operace. Průchod přehledem je desetitisíce jednotlivých čtení buněk rozprostřených do sekund a jediný SetValue, který dopadne mezi dvě z nich, stačí ke změně toho, co zbytek průchodu vidí. Klasický engine to ukazuje konkrétně: TXLSCellRef.SetValue může zavolat FSST.Remove a zahodit položku sdíleného řetězce, resetovat FValueType a zneplatnit stav cache vzorců, a to vše, zatímco jiné vlákno je uprostřed dereferencování přesně těchto struktur. Nic nespadne na místě. Dostanete přehled, jehož mezisoučty nesedí, nebo export, který tiše čte index řetězce, který teď ukazuje jinam

HotXLS to neřeší záměrně tím, že by zapisovače nechal čekat. Čtenář může držet sešit několik sekund a ve VCL aplikaci je zapisovač často UI callback nebo obsluha události na hlavním vlákně — zablokovat toto vlákno, dokud export na pozadí neskončí, je horší výsledek než neúspěšná editace. Koordinační jádro proto vyvolá EXLSWorkbookWriteGuardUnavailable v okamžiku, kdy je proti otevřené lease pokus o zápis, dřív, než je dotčeno jediné pole, a volající si rozhodne, zda editaci zařadí do fronty, zopakuje, nebo řekne uživateli. Konflikty selhávají okamžitě, nestojí ve frontě

Koordinační matice HotXLS ukazuje, že read lease koexistují volně, že pokus o zápis proti otevřené lease vyvolá EXLSWorkbookWriteGuardUnavailable, že lease vyžádaná uvnitř zápisové transakce vyvolá EXLSWorkbookReadLeaseUnavailable a že dvě vlákna zapisovatelů se nikdy navzájem nevylučují
Čtenáři koexistují a zapisovači proti nim selhávají okamžitě, ale jádro nikdy nevylučuje jedno vlákno zapisovatele od druhého

Je bezpečné číst sešit ze dvou vláken?

Ano, pokud oba čtenáři drží lease a nikdo nezapisuje. IXLSWorkbookViewCore.AcquireReadLease vezme TCriticalSection, zvětší počítadlo, vyfotí aktuální generaci a vrátí IXLSWorkbookReadLease — konstantní čas, ať sešit drží tisíc buněk, nebo milion. Libovolný počet lease koexistuje, mohou být uvolněny v jakémkoli pořadí a každá drží jádro naživu vlastní referencí rozhraní, takže lease, která přežije objekt, který ji vytvořil, je bezpečná, nikoli visící ukazatel. Obě enginy se účastní: TXLSWorkbook v lxHandle.pas a TXLSXWorkbook v lxHandleX.pas si každý postaví jádro ve svém konstruktoru a vystaví _AcquireReadLease a _AcquireWriteGuard

Stejně důležité je, co lease do čtecí cesty nepřidává. Kritická sekce pokrývá získání lease, uvolnění lease a hranice zápisových transakcí — nic jiného. Obyčejné čtení jedné buňky nikdy nevstoupí do zámku, monitoru ani atomického počítadla, takže držení lease stojí jedno získání a jedno uvolnění na celý sken, ne jedno na buňku. To je tentýž návrhový instinkt, který stojí za prací na paralelním parsování XLSX a alokátoru paměti: platit za koordinaci na hranici, nikdy ve vnitřní smyčce. Symetrické pravidlo platí také — AcquireReadLease vyvolá EXLSWorkbookReadLeaseUnavailable, kdykoli je WriteDepth nenulové, takže lease nelze otevřít zevnitř zápisové transakce, ani na zapisujícím vlákně

HotXLS platí za koordinaci na hranici skenu: kritická sekce pokrývá jen získání a uvolnění lease a hranice zápisových transakcí, zatímco write guard se získává uvnitř TXLSCellRef.SetValue, takže každé pohodlné API nad ním je strženo jednou
Jedno získání a jedno uvolnění pokryje sken padesáti tisíc buněk a jediná stráž uvnitř TXLSCellRef.SetValue pokryje každou veřejnou zápisovou cestu nad ní
uses
  lxHandle, lxWorkbookView;

procedure TReportThread.Execute;
var
  Lease: IXLSWorkbookReadLease;
  Sheet: TXLSWorksheet;
  Row: Integer;
  Total: Double;
begin
  // Vyvolá EXLSWorkbookReadLeaseUnavailable, když zápis probíhá
  Lease := FWorkbook._AcquireReadLease;
  Sheet := FWorkbook.Sheets[1];
  Total := 0;
  for Row := 1 to 50000 do
    Total := Total + Sheet.Cells[Row, 3].Value;
  FTotal := Total;
  // Lease zde opouští rozsah: její počítadlo referencí klesne na nulu,
  // spustí se ReleaseReadLease a zápis se zase stane možným
end;

Kde write guard skutečně sedí?

V nejnižší měnitelné vrstvě, nikdy v pohodlném API nad ní. _AcquireWriteGuard je volán zevnitř samotného TXLSCellRef.SetValue, což znamená, že každá veřejná cesta, která se do něj sleje — Range.Value, přiřazení textu listu, kopírování po buňkách, vložení — je stržena jednou, místo aby každý obal opakoval kontrolu, kterou budoucí obal zapomene. Pokrytí je záměrně široké: 55 získání stráže v lxHandle.pas a 37 v lxHandleX.pas k dávce, která jádro zavedla

Stržený povrch zahrnuje hodnoty a formátování buněk, TXLSWorkbook.Open, kopírování a vkládání, definované názvy (Add, přejmenování, RefersTo, Visible, IsMacro, Comment, Delete), metadata listu jako Name, Zoom, Visible, StandardHeight, FreezePanes, Protect a Activate, nastavení stránky, konce stránek a Calculate. Umístění je celá pointa: stráž se získá dřív, než je napsáno první pole, nikoli se validuje potom háčkem notifikace, takže odmítnutá mutace nechá model bajt identický. Regresní sada to přesně tvrdí: po každém odmítnutém volání znovu načte název listu, zoom, viditelnost, standardní výšku, okraje, orientaci a počty konců stránek. Zavaděčí cesty dostávají totéž o vrstvu níž, kde brána čtení ZIP koordinuje souběžnou inflaci pro formáty balíčků

procedure TXLSWorksheet.Activate;
var
  WriteGuard: IXLSWorkbookWriteGuard;
begin
  // Získáno dřív, než je dotčeno první pole, nikdy potom
  WriteGuard := FWorkbook._AcquireWriteGuard;
  if not FSelected then
  begin
    FWorkbook.FWorkSheets.Deselect;
    FSelected := True;
  end;
  FWorkbook.FWorkSheets.FActiveSheet := Self;
  // Jen dokončená nejvnější stráž posune generaci
  WriteGuard.Complete;
end;

Proč vnořený zápis posune generaci jen jednou?

Protože zápisovou transakci definuje nejvnější stráž na vlákně, nikoli každá stráž samostatně. Jádro si drží stav zapisovatele na vlákno obsahující id vlákna, hloubku a příznak dokončení. Druhé AcquireWriteGuard na stejném vlákně ten stav najde a zvětší Depth, místo aby vytvořilo novou transakci, a teprve když Depth spadne zpět na nulu — s nejvnější stráží označenou Complete — posune se FGeneration. To je to, co umožní operaci vysoké úrovně, jako je Calculate nebo Open, zavolat pod ní deset stržených primitiv a stále se zaregistrovat jako jedna změna. Vnitřní volání Complete se zaznamenají, ale samy počítadlo nepohnou, a stráže mohou být uvolněny v nesprávném pořadí, aniž by to rozbilo účtování

Směr selhání je stejně explicitní. Je-li stráž uvolněna bez Complete — obvyklý důsledek výjimky rozvíjející referenci rozhraní — generace neposune, protože zápisová transakce nikdy neuplatnila úspěch. Mějte jasno v tom, co to znamená: HotXLS nevrací částečnou editaci zpět. Počítadlo zaznamená, že žádná úspěšná transakce nedokončila, což je přesně signál, který cache potřebuje, ale obnovení modelu do předchozího stavu není něco, co by za vás udělala stráž počítaná referencemi. Pokud může selhání v polovině transakce zanechat sešit ve tvaru, který nemůžete odeslat, schovejte zdrojový soubor a otevřete jej znovu, místo abyste věřili objektu v paměti

Porovnání dvou časových os zápisových transakcí HotXLS: vnořené stráže na jednom vlákně zvětší hloubku a posunou počítadlo generace jen tehdy, když nejvnější stráž dokončí, zatímco výjimka, která rozvine stráže bez Complete, nechá generaci nezměněnou a částečnou editaci na místě
Hloubka sleduje vnoření, ale jen dokončená nejvnější transakce posune generaci a přerušená zanechá počítadlo i částečnou editaci přesně tam, kde byly

Co vám koupí počítadlo generace

Levná detekce zastarání bez skenování. Generation je UInt64, který začíná na 1 a při přetečení přeskočí 0, takže 0 nikdy není hodnota, kterou jádro vystaví, a slouží jako spolehlivý sentinel „nikdy pozorováno". Dvě invarianty z něj dělají použitelný nástroj: generace se nemůže pohnout, dokud existuje jakákoli read lease, a každá úspěšná zápisová transakce jej zvětší přesně jednou. Takže IXLSWorkbookReadLease.Generation je snímek, který zůstává konstantní po celou dobu života lease, a IXLSWorkbookWriteGuard.StartGeneration řekne zapisovateli, jak model vypadal, když se jeho transakce otevřela. Mřížka, náhled tisku nebo odvozený index mohou porovnat jedno celé číslo, místo aby diffly řádky

var
  Lease: IXLSWorkbookReadLease;
begin
  Lease := FWorkbook._AcquireReadLease;
  if Lease.Generation <> FCachedGeneration then
  begin
    FCachedGeneration := Lease.Generation;
    RebuildRowHeightCache;
  end;
  PaintVisibleRows;
  // FCachedGeneration začíná na 0, hodnotě, kterou jádro nikdy nevydá,
  // takže ten úplně první průchod vždy znovu postaví
end;

Co tato koordinace neslibuje

Tři meze stojí za jasné vyjádření, protože předpokládat opak je způsob, jak se mechanizmus zneužívá. Za prvé, write guard není vzájemné vyloučení mezi zapisovateli: jádro vylučuje čtenáře vůči zapisovatelům a dvě různá vlákna mohou současně každé držet write guard a každé nezávisle posouvat generaci — regresní test tvrdí přesně toto chování. Serializovat vlastní vlákna zapisovatelů je stále vaše práce. Za druhé, nic z toho není zámek souboru ani meziprocesová mutex; koordinuje vlákna uvnitř jednoho procesu vůči jedné instanci sešitu a dva procesy otevírající tentýž .xlsx o sobě nic nevědí. Za třetí, záruka dosáhne jen volajících, kteří skutečně lease vezmou — čtení bez lease stále jde odemčenou horkou cestou, která je rychlá a zcela nechráněná. Toto je koordinační jádro, ne transakční databáze

Použito v těchto mezích je malou, poctivou primitivou: devět specializovaných regresních testů pokrývá více čtenářů, oba směry konfliktu, reentranci, uvolnění v nesprávném pořadí, přerušené transakce a závody čtení/zápis a zápis/zápis napříč vlákny, uvnitř sady 1 328 testů procházejících na Win32 a Win64. Spojte jej s cestou ukládání po etapách bezpečnou vůči pádu a export na pozadí se stane něčím, co lze uvažovat od začátku do konce — konzistentní při čtení, atomické při zápisu. Read lease, write guard a počítadlo generace jsou součástí klasického i balíčkového enginu v HotXLS Delphi Component pro Delphi a C++Builder, bez nutnosti jakékoli konfigurace k jejich zapnutí