Articolo tecnico

Read lease e write guard HotXLS per workbook Delphi

Un thread in background stava esportando un report da 40.000 righe quando il thread UI ha impostato una cella, e il file finito su disco non corrispondeva a nessuna cartella di lavoro mai esistita. HotXLS gestisce quella classe di bug in lxWorkbookView.pas, dove IXLSWorkbookViewCore emette read lease O(1) e write guard fail-fast: mentre un lease è aperto, ogni punto di ingresso di mutazione solleva eccezioni invece di scrivere

Il guasto che arriva senza stack trace

Leggere una cartella di lavoro non è mai una singola operazione atomica. Una passeggiata di report è decine di migliaia di letture di celle individuali sparse su secondi, e un singolo SetValue che atterra tra due di esse basta a cambiare ciò che il resto della passeggiata vede. Il motore classic lo rende concreto: TXLSCellRef.SetValue può chiamare FSST.Remove per eliminare una voce di shared string, azzerare FValueType e invalidare uno stato di cache delle formule, il tutto mentre un altro thread è a metà del dereferenziamento di esattamente quelle strutture. Nulla crasha sul posto. Vi ritrovate un report i cui subtotali non tornano, o un'esportazione che legge silenziosamente un indice di stringa che ora punta altrove

HotXLS deliberatamente non risolve questo facendo aspettare gli scrittori. Un reader può trattenere una cartella di lavoro per parecchi secondi, e in un'applicazione VCL lo scrittore è spesso una callback UI o un event handler sul thread principale — bloccare quel thread finché un'esportazione in background finisce è un esito peggiore del far fallire la modifica. Quindi il core di coordinamento solleva EXLSWorkbookWriteGuardUnavailable nel momento in cui una scrittura viene tentata contro un lease aperto, prima che un singolo campo sia stato toccato, e il chiamante decide se accodare la modifica, riprovare o dire all'utente. Conflitti fail-fast, non in coda

Una matrice di coordinamento HotXLS che mostra che i read lease coesistono liberamente, che una scrittura tentata contro un lease aperto solleva EXLSWorkbookWriteGuardUnavailable, che un lease richiesto dentro una transazione di scrittura solleva EXLSWorkbookReadLeaseUnavailable, e che due thread scrittori non vengono mai esclusi l'uno dall'altro
I reader coesistono e gli scrittori falliscono rapidamente contro di essi, ma il core non esclude mai un thread scrittore da un altro

Una cartella di lavoro è sicura da leggere da due thread?

Sì, a condizione che entrambi i reader trattengano un lease e nessuno scriva. IXLSWorkbookViewCore.AcquireReadLease prende una TCriticalSection, incrementa un contatore, scatta uno snapshot della generazione corrente e restituisce un IXLSWorkbookReadLease — tempo costante indipendentemente dal fatto che la cartella di lavoro tenga mille celle o un milione. Un numero qualsiasi di lease coesiste, possono essere rilasciati in qualsiasi ordine, e ciascuno tiene in vita il core tramite il proprio riferimento a interfaccia, così un lease che sopravvive all'oggetto che l'ha creato è sicuro anziché un puntatore pendente. Entrambi i motori partecipano: TXLSWorkbook in lxHandle.pas e TXLSXWorkbook in lxHandleX.pas costruiscono ciascuno un core nel proprio costruttore ed espongono _AcquireReadLease e _AcquireWriteGuard

Ciò che conta altrettanto è ciò che il lease non aggiunge al percorso di lettura. La sezione critica copre acquisizione del lease, rilascio del lease e confini delle transazioni di scrittura — nient'altro. La comune lettura per cella non entra mai in un lock, un monitor o un contatore atomico, quindi trattenere un lease costa un'acquisizione e un rilascio per l'intera scansione, non uno per cella. È lo stesso istinto di progetto dietro al lavoro su parsing XLSX parallelo e allocatore di memoria: pagare il coordinamento al confine, mai nel loop interno. Vale anche la regola simmetrica — AcquireReadLease solleva EXLSWorkbookReadLeaseUnavailable ogniqualvolta WriteDepth è diverso da zero, quindi non potete aprire un lease dall'interno di una transazione di scrittura, nemmeno sul thread che scrive

HotXLS paga il coordinamento al confine di una scansione: la sezione critica copre solo acquisizione e rilascio del lease e i confini delle transazioni di scrittura, mentre la write guard viene acquisita dentro TXLSCellRef.SetValue così ogni API di convenzione sopra di essa viene gated una volta sola
Un'acquisizione e un rilascio coprono una scansione da cinquantamila celle, e una singola guardia dentro TXLSCellRef.SetValue copre ogni percorso di scrittura pubblico sopra di essa
uses
  lxHandle, lxWorkbookView;

procedure TReportThread.Execute;
var
  Lease: IXLSWorkbookReadLease;
  Sheet: TXLSWorksheet;
  Row: Integer;
  Total: Double;
begin
  // Solleva EXLSWorkbookReadLeaseUnavailable se una scrittura è in volo
  Lease := FWorkbook._AcquireReadLease;
  Sheet := FWorkbook.Sheets[1];
  Total := 0;
  for Row := 1 to 50000 do
    Total := Total + Sheet.Cells[Row, 3].Value;
  FTotal := Total;
  // Il lease esce dallo scope qui: il suo reference count scende a zero,
  // ReleaseReadLease gira, e le scritture tornano possibili
end;

Dove siede davvero la write guard?

Al livello mutabile più basso, mai all'API di convenzione sopra di esso. _AcquireWriteGuard viene chiamata dall'interno di TXLSCellRef.SetValue stessa, il che significa che ogni percorso pubblico che vi confluisce — Range.Value, assegnazione di testo del worksheet, copia cella per cella, paste — viene gated una volta sola invece che ogni wrapper ripetere un controllo che un futuro wrapper dimenticherà. La copertura è deliberatamente ampia: 55 acquisizioni di guardia in lxHandle.pas e 37 in lxHandleX.pas al momento del batch che ha introdotto il core

La superficie gated copre valori e formattazione delle celle, TXLSWorkbook.Open, copia e paste, nomi definiti (Add, rinomina, RefersTo, Visible, IsMacro, Comment, Delete), metadati del worksheet come Name, Zoom, Visible, StandardHeight, FreezePanes, Protect e Activate, page setup, interruzioni di pagina e Calculate. Il piazzamento è tutto il punto: la guardia viene acquisita prima che il primo campo venga scritto, non validata dopo da un hook di notifica, così una mutazione rifiutata lascia il modello byte-identico. La suite di regression asserisce precisamente questo, rileggendo nome del foglio, zoom, visibilità, altezza standard, margini, orientamento e conteggi delle interruzioni di pagina dopo ogni chiamata rifiutata. I percorsi di caricamento ricevono lo stesso trattamento un livello sotto, dove il read gate ZIP coordina gli inflate concorrenti per i formati package

procedure TXLSWorksheet.Activate;
var
  WriteGuard: IXLSWorkbookWriteGuard;
begin
  // Acquisito prima che il primo campo venga toccato, mai dopo
  WriteGuard := FWorkbook._AcquireWriteGuard;
  if not FSelected then
  begin
    FWorkbook.FWorkSheets.Deselect;
    FSelected := True;
  end;
  FWorkbook.FWorkSheets.FActiveSheet := Self;
  // Solo una guardia outermost completata avanza la generazione
  WriteGuard.Complete;
end;

Perché una scrittura annidata avanza la generazione una sola volta?

Perché una transazione di scrittura è definita dalla guardia outermost su un thread, non da ogni guardia individualmente. Il core conserva uno stato scrittore per thread che tiene un thread id, una profondità e un flag di completamento. Una seconda AcquireWriteGuard sullo stesso thread trova quello stato e incrementa Depth invece di creare una nuova transazione, e solo quando Depth torna a zero — con la guardia outermost marchiata CompleteFGeneration avanza. Questo è ciò che permette a un'operazione di alto livello come Calculate o Open di chiamare dieci primitive gated sotto di sé e registrarsi comunque come un'unica modifica. Le chiamate Complete interne vengono registrate ma non muovono da sole il contatore, e le guardie possono essere rilasciate fuori ordine senza rompere la contabilità

La direzione del guasto è altrettanto esplicita. Se una guardia viene rilasciata senza Complete — la conseguenza ordinaria di un'eccezione che srotola il riferimento a interfaccia — la generazione non avanza, perché la transazione di scrittura non ha mai rivendicato successo. Guardate bene cosa significa: HotXLS non fa il rollback della modifica parziale. Il contatore registra che nessuna transazione riuscita è stata completata, che è esattamente il segnale di cui una cache ha bisogno, ma ripristinare il modello al suo stato precedente non è qualcosa che una guardia a reference counting può fare per voi. Se un fallimento a metà transazione può lasciare la cartella di lavoro in una forma che non potete spedire, conservate il file sorgente e riapritelo, anziché fidarvi dell'oggetto in memoria

Due timeline di transazione di scrittura HotXLS a confronto: guardie annidate su un thread alzano la profondità e avanzano il contatore di generazione solo quando la guardia outermost completa, mentre un'eccezione che srotola le guardie senza Complete lascia la generazione invariata e la modifica parziale al suo posto
La profondità traccia l'annidamento, ma solo una transazione outermost completata avanza la generazione, e una interrotta lascia sia il contatore sia la modifica parziale esattamente dove erano

Cosa vi compra il contatore di generazione

Rilevamento di stantietà economico senza scansioni. Generation è un UInt64 che parte da 1 e salta 0 al wraparound, quindi 0 non è mai un valore che il core emette e funziona come una sentinella affidabile di "mai osservato". Due invarianti lo rendono utilizzabile: la generazione non può muoversi mentre esiste qualsiasi read lease, e ogni transazione di scrittura riuscita la incrementa esattamente una volta. Così IXLSWorkbookReadLease.Generation è uno snapshot che resta costante per tutta la vita del lease, e IXLSWorkbookWriteGuard.StartGeneration dice a uno scrittore com'era il modello quando la sua transazione si è aperta. Una griglia, un'anteprima di stampa o un indice derivato può confrontare un intero invece di fare il diff delle righe

var
  Lease: IXLSWorkbookReadLease;
begin
  Lease := FWorkbook._AcquireReadLease;
  if Lease.Generation <> FCachedGeneration then
  begin
    FCachedGeneration := Lease.Generation;
    RebuildRowHeightCache;
  end;
  PaintVisibleRows;
  // FCachedGeneration parte da 0, un valore che il core non emette mai,
  // quindi il primissimo pass ricostruisce sempre
end;

Cosa questo coordinamento non promette

Tre limiti vale la pena enunciare chiaramente, perché presupporre il contrario è il modo in cui il meccanismo viene usato male. Primo, una write guard non è esclusione mutua tra scrittori: il core esclude i reader contro gli scrittori, e due thread diversi possono ciascuno trattenere una write guard allo stesso tempo, avanzando ciascuno la generazione indipendentemente — un test di regression asserisce esattamente questo comportamento. Serializzare i vostri thread scrittori resta compito vostro. Secondo, qui niente è un file lock o un mutex cross-process; coordina thread dentro un processo contro una singola istanza di cartella di lavoro, e due processi che aprono lo stesso .xlsx non sanno nulla l'uno dell'altro. Terzo, la garanzia raggiunge solo i chiamanti che prendono effettivamente un lease — una lettura senza lease percorre ancora un hot path non bloccato, veloce e del tutto non protetto. Questo è un core di coordinamento, non un database transazionale

Usato entro quei confini è una primitiva piccola e onesta: nove test di regression dedicati coprono reader multipli, entrambe le direzioni di conflitto, rientranza, rilascio fuori ordine, transazioni interrotte e corse cross-thread di lettura/scrittura e scrittura/scrittura, dentro una suite di 1.328 test verdi su Win32 e Win64. Abbinatelo al percorso di salvataggio crash-safe con file temporanei a stadi e un'esportazione in background diventa qualcosa su cui potete ragionare da capo a fine — coerente mentre legge, atomica quando scrive. Read lease, write guard e contatore di generazione arrivano come parte dei motori classic e package nel componente HotXLS per Delphi per Delphi e C++Builder, senza alcuna configurazione necessaria per abilitarli