Artículo técnico

Leases de lectura y guards de escritura de HotXLS en Delphi

Un hilo de segundo plano estaba exportando un reporte de 40,000 filas cuando el hilo de la UI fijó una celda, y el archivo que aterrizó en disco no correspondía a ningún libro que hubiera existido jamás. HotXLS maneja esa clase de bug en lxWorkbookView.pas, donde IXLSWorkbookViewCore emite leases de lectura O(1) y write guards fail-fast: mientras haya un lease abierto, todo punto de entrada de mutación lanza en lugar de escribir

El fallo que llega sin stack trace

Leer un libro nunca es una sola operación atómica. Un recorrido de reporte son decenas de miles de lecturas individuales de celdas repartidas a lo largo de segundos, y un solo SetValue que aterrice entre dos de ellas basta para cambiar lo que el resto del recorrido ve. El motor clásico lo hace concreto: TXLSCellRef.SetValue puede llamar FSST.Remove para eliminar una entrada de cadena compartida, reiniciar FValueType e invalidar un estado de caché de fórmulas, todo mientras otro hilo está a mitad de desreferenciar exactamente esas estructuras. Nada se cae en el momento. Ustedes obtienen un reporte cuyos subtotales no cuadran, o una exportación que lee en silencio un índice de cadenas que ahora apunta a otro lado

HotXLS deliberadamente no resuelve esto haciendo que los escritores esperen. Un lector puede sostener un libro durante varios segundos, y en una aplicación VCL el escritor suele ser un callback de UI o un manejador de eventos en el hilo principal — bloquear ese hilo hasta que una exportación de segundo plano termine es un desenlace peor que fallar la edición. Así que el núcleo de coordinación lanza EXLSWorkbookWriteGuardUnavailable en el momento en que se intenta escribir contra un lease abierto, antes de que un solo campo haya sido tocado, y el llamador decide si encola la edición, reintenta o avisa al usuario. Conflictos fail-fast, no encolados

Una matriz de coordinación de HotXLS que muestra que los leases de lectura coexisten libremente, que una escritura intentada contra un lease abierto lanza EXLSWorkbookWriteGuardUnavailable, que un lease solicitado dentro de una transacción de escritura lanza EXLSWorkbookReadLeaseUnavailable, y que dos hilos escritores jamás se excluyen entre sí
Los lectores coexisten y los escritores fallan rápido contra ellos, pero el núcleo jamás excluye un hilo escritor de otro

¿Es seguro leer un libro desde dos hilos?

Sí, siempre que ambos lectores sostengan un lease y nadie escriba. IXLSWorkbookViewCore.AcquireReadLease toma una TCriticalSection, incrementa un contador, toma una instantánea de la generación actual y devuelve un IXLSWorkbookReadLease — tiempo constante sin importar si el libro contiene mil celdas o un millón. Cualquier cantidad de leases coexisten, pueden liberarse en cualquier orden, y cada uno mantiene vivo el núcleo a través de su propia referencia de interfaz, de modo que un lease que sobreviva al objeto que lo creó es algo seguro y no un puntero colgante. Ambos motores participan: TXLSWorkbook en lxHandle.pas y TXLSXWorkbook en lxHandleX.pas construyen cada uno un núcleo en su constructor y exponen _AcquireReadLease y _AcquireWriteGuard

Lo que importa tanto como eso es lo que el lease no agrega a la ruta de lectura. La sección crítica cubre la adquisición de leases, la liberación de leases y los límites de transacción de escritura — nada más. La lectura ordinaria por celda nunca entra en un lock, un monitor o un contador atómico, así que sostener un lease cuesta una adquisición y una liberación para todo el escaneo, no una por celda. Ese es el mismo instinto de diseño detrás del trabajo de parseo paralelo XLSX y del asignador de memoria: pagar por la coordinación en el límite, nunca en el bucle interno. La regla simétrica también vale — AcquireReadLease lanza EXLSWorkbookReadLeaseUnavailable siempre que WriteDepth sea distinto de cero, así que no pueden abrir un lease desde dentro de una transacción de escritura, ni siquiera en el hilo que escribe

HotXLS paga la coordinación en el límite de un escaneo: la sección crítica cubre solo la adquisición y liberación de leases y los límites de transacción de escritura, mientras que el write guard se adquiere dentro de TXLSCellRef.SetValue, de modo que cada API de conveniencia por encima queda gateada una sola vez
Una adquisición y una liberación cubren un escaneo de cincuenta mil celdas, y un solo guard dentro de TXLSCellRef.SetValue cubre toda ruta de escritura pública por encima
uses
  lxHandle, lxWorkbookView;

procedure TReportThread.Execute;
var
  Lease: IXLSWorkbookReadLease;
  Sheet: TXLSWorksheet;
  Row: Integer;
  Total: Double;
begin
  // Lanza EXLSWorkbookReadLeaseUnavailable si hay una escritura en vuelo
  Lease := FWorkbook._AcquireReadLease;
  Sheet := FWorkbook.Sheets[1];
  Total := 0;
  for Row := 1 to 50000 do
    Total := Total + Sheet.Cells[Row, 3].Value;
  FTotal := Total;
  // El lease sale de alcance aquí: su conteo de referencias cae a cero,
  // ReleaseReadLease corre, y los escritores vuelven a ser posibles
end;

¿Dónde se sitúa realmente el write guard?

En la capa mutable más baja, nunca en la API de conveniencia de encima. _AcquireWriteGuard se llama desde dentro del propio TXLSCellRef.SetValue, lo que significa que toda ruta pública que desemboca en él — Range.Value, asignación de texto de hoja, copia celda por celda, pegado — queda gateada una vez en lugar de que cada wrapper repita una verificación que un wrapper futuro olvidará. La cobertura es deliberadamente amplia: 55 adquisiciones de guard en lxHandle.pas y 37 en lxHandleX.pas al momento del lote que introdujo el núcleo

La superficie gateada abarca valores de celda y formato de celda, TXLSWorkbook.Open, copiar y pegar, nombres definidos (Add, renombrar, RefersTo, Visible, IsMacro, Comment, Delete), metadatos de hoja de cálculo como Name, Zoom, Visible, StandardHeight, FreezePanes, Protect y Activate, configuración de página, saltos de página y Calculate. La ubicación es todo el punto: el guard se adquiere antes de que se escriba el primer campo, no se valida después con un hook de notificación, así que una mutación rechazada deja el modelo idéntico byte por byte. La suite de regresión afirma precisamente eso, releyendo nombre de hoja, zoom, visibilidad, altura estándar, márgenes, orientación y conteos de saltos de página después de cada llamada rechazada. Las rutas de carga reciben el mismo tratamiento una capa más abajo, donde la compuerta de lectura ZIP coordena el inflate concurrente para los formatos de paquete

procedure TXLSWorksheet.Activate;
var
  WriteGuard: IXLSWorkbookWriteGuard;
begin
  // Adquirido antes de tocar el primer campo, nunca después
  WriteGuard := FWorkbook._AcquireWriteGuard;
  if not FSelected then
  begin
    FWorkbook.FWorkSheets.Deselect;
    FSelected := True;
  end;
  FWorkbook.FWorkSheets.FActiveSheet := Self;
  // Solo un guard externo completado avanza la generación
  WriteGuard.Complete;
end;

¿Por qué una escritura anidada avanza la generación solo una vez?

Porque una transacción de escritura queda definida por el guard más externo de un hilo, no por cada guard individualmente. El núcleo guarda un estado de escritor por hilo que contiene un thread id, una profundidad y un flag de completado. Un segundo AcquireWriteGuard en el mismo hilo encuentra ese estado e incrementa Depth en lugar de crear una nueva transacción, y solo cuando Depth regresa a cero — con el guard más externo marcado CompleteFGeneration avanza. Esto es lo que permite que una operación de alto nivel como Calculate u Open llame diez primitivas gateadas por debajo y aun así se registre como un solo cambio. Las llamadas Complete internas se registran pero por sí solas no mueven el contador, y los guards pueden liberarse fuera de orden sin romper la contabilidad

La dirección de fallo es igual de explícita. Si un guard se libera sin Complete — la consecuencia ordinaria de una excepción que deshace la referencia de interfaz — la generación no avanza, porque la transacción de escritura nunca reclamó éxito. Miren con claridad lo que eso significa: HotXLS no revierte la edición parcial. El contador registra que ninguna transacción exitosa se completó, que es exactamente la señal que necesita una caché, pero restaurar el modelo a su estado previo no es algo que un guard con conteo de referencias pueda hacer por ustedes. Si un fallo a mitad de transacción puede dejar el libro en una forma que no pueden entregar, conserven el archivo fuente y ábralo de nuevo, en lugar de confiar en el objeto en memoria

Dos líneas de tiempo de transacciones de escritura de HotXLS comparadas: guards anidados en un hilo elevan la profundidad y avanzan el contador de generación solo cuando el guard más externo completa, mientras que una excepción que deshace los guards sin Complete deja la generación sin cambios y la edición parcial en su lugar
Depth sigue el anidamiento, pero solo una transacción externa completada avanza la generación, y una abortada deja tanto el contador como la edición parcial exactamente donde estaban

Lo que les compra el contador de generación

Detección de obsolescencia barata y sin escaneo. Generation es un UInt64 que empieza en 1 y se salta el 0 en el rebasamiento, de modo que 0 nunca es un valor que el núcleo emita y funciona como un centinela confiable de "nunca observado". Dos invariantes lo hacen utilizable: la generación no puede moverse mientras exista algún lease de lectura, y cada transacción de escritura exitosa lo incrementa exactamente una vez. Así IXLSWorkbookReadLease.Generation es una instantánea que permanece constante durante toda la vida del lease, y IXLSWorkbookWriteGuard.StartGeneration le dice a un escritor cómo se veía el modelo cuando su transacción abrió. Una cuadrícula, una vista previa de impresión o un índice derivado pueden comparar un entero en lugar de hacer diff de filas

var
  Lease: IXLSWorkbookReadLease;
begin
  Lease := FWorkbook._AcquireReadLease;
  if Lease.Generation <> FCachedGeneration then
  begin
    FCachedGeneration := Lease.Generation;
    RebuildRowHeightCache;
  end;
  PaintVisibleRows;
  // FCachedGeneration empieza en 0, un valor que el núcleo nunca emite,
  // así que la primer pasada siempre reconstruye
end;

Lo que esta coordinación no promete

Tres límites conviene enunciar con claridad, porque suponer lo contrario es como se abusa del mecanismo. Primero, un write guard no es exclusión mutua entre escritores: el núcleo excluye lectores contra escritores, y dos hilos distintos pueden sostener cada uno un write guard al mismo tiempo, cada uno avanzando la generación independientemente — una prueba de regresión afirma exactamente este comportamiento. Serializar sus propios hilos escritores sigue siendo tarea suya. Segundo, nada de esto es un file lock ni un mutex entre procesos; coordina hilos dentro de un proceso contra una instancia de libro, y dos procesos que abran el mismo .xlsx no saben nada el uno del otro. Tercero, la garantía solo alcanza a los llamadores que realmente toman un lease — una lectura sin lease todavía camina por una ruta caliente sin bloqueo, que es rápida y completamente desprotegida. Esto es un núcleo de coordinación, no una base de datos transaccional

Usado dentro de esos límites es una primitiva pequeña y honesta: nueve pruebas de regresión dedicadas cubren múltiples lectores, ambas direcciones de conflicto, reentrancia, liberación fuera de orden, transacciones abortadas y carreras de lectura/escritura y escritura/escritura entre hilos, dentro de una suite de 1,328 pruebas que pasan en Win32 y Win64. Combínenlo con la ruta de guardado en archivo temporal por etapas a prueba de fallos y una exportación de segundo plano se vuelve algo que pueden razonar de punta a punta — consistente mientras lee, atómica cuando escribe. Los leases de lectura, los write guards y el contador de generación se entregan como parte de los motores clásico y de paquete en el HotXLS Delphi Component para Delphi y C++Builder, sin configuración necesaria para activarlos