Article technique

Baux de lecture et gardes d'écriture HotXLS en Delphi

Un thread en arrière-plan exportait un rapport de 40 000 lignes quand le thread de l’interface utilisateur a fixé une cellule, et le fichier qui a atterri sur disque ne correspondait à aucun classeur ayant jamais existé. HotXLS traite cette classe de bogue dans lxWorkbookView.pas, où IXLSWorkbookViewCore émet des baux de lecture en O(1) et des gardes en écriture à échec rapide : tant qu’un bail est ouvert, chaque point d’entrée de mutation lève au lieu d’écrire

La défaillance qui arrive sans trace de pile

Lire un classeur n’est jamais une seule opération atomique. Un parcours de rapport est des dizaines de milliers de lectures de cellules individuelles étalées sur des secondes, et un seul SetValue atterrissant entre deux d’entre elles suffit pour changer ce que le reste du parcours voit. Le moteur classique rend cela concret : TXLSCellRef.SetValue peut appeler FSST.Remove pour abandonner une entrée de chaîne partagée, réinitialiser FValueType, et invalider un état de cache de formule, tout cela pendant qu’un autre thread est à mi-chemin du déréférencement d’exactement ces structures. Rien ne plante sur place. Vous obtenez un rapport dont les sous-totaux ne tombent pas juste, ou un export qui lit silencieusement un index de chaîne qui pointe maintenant ailleurs

HotXLS ne résout délibérément pas cela en faisant attendre les écrivains. Un lecteur peut retenir un classeur pendant plusieurs secondes, et dans une application VCL l’écrivain est souvent un rappel d’interface ou un gestionnaire d’événement sur le thread principal — bloquer ce thread jusqu’à ce qu’un export en arrière-plan se termine est une issue pire que de faire échouer l’édition. Ainsi le noyau de coordination lève EXLSWorkbookWriteGuardUnavailable dès qu’une écriture est tentée contre un bail ouvert, avant qu’un seul champ ait été touché, et l’appelant décide s’il met l’édition en file, réessaie, ou prévient l’utilisateur. Des conflits à échec rapide, pas des conflits en file

Une matrice de coordination HotXLS montrant que les baux de lecture coexistent librement, qu’une écriture tentée contre un bail ouvert lève EXLSWorkbookWriteGuardUnavailable, qu’un bail demandé à l’intérieur d’une transaction en écriture lève EXLSWorkbookReadLeaseUnavailable, et que deux threads écrivains ne sont jamais exclus l’un de l’autre
Les lecteurs coexistent et les écrivains échouent vite contre eux, mais le noyau n’exclut jamais un thread écrivain d’un autre

Un classeur est-il sûr à lire depuis deux threads ?

Oui, à condition que les deux lecteurs détiennent un bail et que personne n’écrive. IXLSWorkbookViewCore.AcquireReadLease prend une TCriticalSection, incrémente un compteur, photographie la génération courante, et renvoie un IXLSWorkbookReadLease — temps constant que le classeur porte mille cellules ou un million. Un nombre quelconque de baux coexistent, ils peuvent être libérés dans n’importe quel ordre, et chacun maintient le noyau en vie par sa propre référence d’interface, si bien qu’un bail survivant à l’objet qui l’a créé est sûr plutôt qu’un pointeur pendant. Les deux moteurs participent : TXLSWorkbook dans lxHandle.pas et TXLSXWorkbook dans lxHandleX.pas construisent chacun un noyau dans leur constructeur et exposent _AcquireReadLease et _AcquireWriteGuard

Ce qui compte tout autant est ce que le bail n’ajoute pas au chemin de lecture. La section critique couvre l’acquisition de bail, la libération de bail, et les frontières de transaction en écriture — rien d’autre. La lecture ordinaire par cellule n’entre jamais dans un verrou, un moniteur, ou un compteur atomique, si bien que détenir un bail coûte une acquisition et une libération pour tout le balayage, pas une par cellule. C’est le même instinct de conception derrière le travail d’analyse XLSX parallèle et d’allocateur mémoire : payez la coordination à la frontière, jamais dans la boucle intérieure. La règle symétrique tient aussi — AcquireReadLease lève EXLSWorkbookReadLeaseUnavailable chaque fois que WriteDepth est non nul, si bien que vous ne pouvez pas ouvrir un bail depuis l’intérieur d’une transaction en écriture, pas même sur le thread écrivain

HotXLS paie la coordination à la frontière d’un balayage : la section critique ne couvre que l’acquisition et la libération de bail et les frontières de transaction en écriture, tandis que la garde en écriture est acquise à l’intérieur de TXLSCellRef.SetValue pour que chaque API de commodité au-dessus soit filtrée une fois
Une acquisition et une libération couvrent un balayage de cinquante mille cellules, et une seule garde à l’intérieur de TXLSCellRef.SetValue couvre chaque chemin d’écriture public au-dessus d’elle
uses
  lxHandle, lxWorkbookView;

procedure TReportThread.Execute;
var
  Lease: IXLSWorkbookReadLease;
  Sheet: TXLSWorksheet;
  Row: Integer;
  Total: Double;
begin
  // Lève EXLSWorkbookReadLeaseUnavailable si une écriture est en vol
  Lease := FWorkbook._AcquireReadLease;
  Sheet := FWorkbook.Sheets[1];
  Total := 0;
  for Row := 1 to 50000 do
    Total := Total + Sheet.Cells[Row, 3].Value;
  FTotal := Total;
  // Le bail sort de la portée ici : son compte de références tombe à zéro,
  // ReleaseReadLease s'exécute, et les écrivains redeviennent possibles
end;

Où se trouve réellement la garde en écriture ?

À la couche mutable la plus basse, jamais à l’API de commodité au-dessus d’elle. _AcquireWriteGuard est appelé depuis l’intérieur de TXLSCellRef.SetValue lui-même, ce qui signifie que chaque chemin public qui s’y déverse — Range.Value, affectation de texte de feuille, copie cellule par cellule, collage — est filtré une fois au lieu que chaque enveloppe répète une vérification qu’une future enveloppe oubliera. La couverture est délibérément large : 55 acquisitions de garde dans lxHandle.pas et 37 dans lxHandleX.pas à la date du lot qui a introduit le noyau

La surface filtrée couvre les valeurs de cellules et le formatage de cellules, TXLSWorkbook.Open, copier-coller, les noms définis (Add, renommage, RefersTo, Visible, IsMacro, Comment, Delete), les métadonnées de feuille comme Name, Zoom, Visible, StandardHeight, FreezePanes, Protect et Activate, la mise en page, les sauts de page, et Calculate. Le placement est tout le propos : la garde est acquise avant que le premier champ soit écrit, pas validée après coup par un crochet de notification, si bien qu’une mutation refusée laisse le modèle identique à l’octet près. La suite de régression affirme exactement cela, relisant nom de feuille, zoom, visibilité, hauteur standard, marges, orientation et compteurs de sauts de page après chaque appel refusé. Les chemins de chargement reçoivent le même traitement une couche plus bas, où le portail de lecture ZIP coordonne la décompression concurrente pour les formats de paquet

procedure TXLSWorksheet.Activate;
var
  WriteGuard: IXLSWorkbookWriteGuard;
begin
  // Acquise avant que le premier champ soit touché, jamais après
  WriteGuard := FWorkbook._AcquireWriteGuard;
  if not FSelected then
  begin
    FWorkbook.FWorkSheets.Deselect;
    FSelected := True;
  end;
  FWorkbook.FWorkSheets.FActiveSheet := Self;
  // Seule une garde extérieure accomplie avance la génération
  WriteGuard.Complete;
end;

Pourquoi une écriture imbriquée avance-t-elle la génération une seule fois ?

Parce qu’une transaction en écriture est définie par la garde la plus extérieure sur un thread, pas par chaque garde individuellement. Le noyau garde un état d’écrivain par thread portant un identifiant de thread, une profondeur et un drapeau d’accomplissement. Un second AcquireWriteGuard sur le même thread trouve cet état et incrémente Depth au lieu de créer une nouvelle transaction, et seulement quand Depth retombe à zéro — la garde extérieure ayant été marquée CompleteFGeneration avance. C’est ce qui permet à une opération de haut niveau comme Calculate ou Open d’appeler dix primitives gardées en dessous et de s’enregistrer quand même comme un seul changement. Les appels Complete intérieurs sont enregistrés mais ne déplacent pas le compteur d’eux-mêmes, et les gardes peuvent être libérées dans le désordre sans casser la comptabilité

La direction d’échec est tout aussi explicite. Si une garde est libérée sans Complete — la conséquence ordinaire d’une exception déroulant la référence d’interface — la génération n’avance pas, parce que la transaction en écriture n’a jamais revendiqué le succès. Ayez les yeux clairs sur ce que cela signifie : HotXLS ne restaure pas l’édition partielle. Le compteur enregistre qu’aucune transaction réussie ne s’est accomplie, ce qui est exactement le signal dont un cache a besoin, mais remettre le modèle dans son état précédent n’est pas quelque chose qu’une garde à compte de références peut faire pour vous. Si un échec en cours de transaction peut laisser le classeur dans une forme que vous ne pouvez pas livrer, gardez le fichier source et rouvrez-le, plutôt que de faire confiance à l’objet en mémoire

Deux chronologies de transaction en écriture HotXLS comparées : des gardes imbriquées sur un thread montent la profondeur et avancent le compteur de génération seulement quand la garde extérieure s’accomplit, tandis qu’une exception qui déroule les gardes sans Complete laisse la génération inchangée et l’édition partielle en place
La profondeur suit l’imbrication, mais seule une transaction extérieure accomplie avance la génération, et une transaction avortée laisse le compteur et l’édition partielle exactement où ils étaient

Ce que le compteur de génération vous achète

Une détection de péremption bon marché sans balayage. Generation est un UInt64 qui commence à 1 et saute 0 au bouclage, si bien que 0 n’est jamais une valeur que le noyau émet et sert de sentinelle fiable « jamais observée ». Deux invariants le rendent utilisable : la génération ne peut pas bouger tant qu’un bail de lecture existe, et chaque transaction en écriture réussie l’incrémente exactement une fois. Ainsi IXLSWorkbookReadLease.Generation est un instantané qui reste constant pendant toute la vie du bail, et IXLSWorkbookWriteGuard.StartGeneration dit à un écrivain à quoi ressemblait le modèle quand sa transaction s’est ouverte. Une grille, un aperçu avant impression, ou un index dérivé peut comparer un entier au lieu de comparer les lignes par différence

var
  Lease: IXLSWorkbookReadLease;
begin
  Lease := FWorkbook._AcquireReadLease;
  if Lease.Generation <> FCachedGeneration then
  begin
    FCachedGeneration := Lease.Generation;
    RebuildRowHeightCache;
  end;
  PaintVisibleRows;
  // FCachedGeneration commence à 0, une valeur que le noyau n'émet jamais,
  // si bien que la toute première passe reconstruit toujours
end;

Ce que cette coordination ne promet pas

Trois limites valent la peine d’être énoncées franchement, parce que supposer le contraire est la façon dont le mécanisme se fait mal utiliser. Premièrement, une garde en écriture n’est pas une exclusion mutuelle entre écrivains : le noyau exclut les lecteurs contre les écrivains, et deux threads différents peuvent chacun détenir une garde en écriture au même moment, chacun avançant la génération indépendamment — un test de régression affirme exactement ce comportement. Sérialiser vos propres threads écrivains reste votre travail. Deuxièmement, rien de tout cela n’est un verrou de fichier ou un mutex inter-processus ; il coordonne des threads à l’intérieur d’un processus contre une instance de classeur, et deux processus ouvrant le même .xlsx ne savent rien l’un de l’autre. Troisièmement, la garantie n’atteint que les appelants qui prennent réellement un bail — une lecture sans bail parcourt toujours un chemin chaud sans verrou, qui est rapide et entièrement non protégé. Ceci est un noyau de coordination, pas une base de données transactionnelle

Utilisé dans ces limites, c’est une petite primitive honnête : neuf tests de régression dédiés couvrent les lecteurs multiples, les deux directions de conflit, la réentrance, la libération dans le désordre, les transactions avortées, et les courses inter-threads lecture/écriture et écriture/écriture, à l’intérieur d’une suite de 1 328 tests passant sur Win32 et Win64. Associez-le au chemin d’enregistrement échelonné sûr contre les plantages via fichiers temporaires et un export en arrière-plan devient quelque chose que vous pouvez raisonner de bout en bout — cohérent pendant qu’il lit, atomique quand il écrit. Les baux de lecture, les gardes en écriture et le compteur de génération sont livrés au sein des moteurs classique et de paquet du composant HotXLS pour Delphi pour Delphi et C++Builder, sans aucune configuration nécessaire pour les activer