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
¿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
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 Complete — FGeneration 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
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