Artículo técnico

Leases de lectura y guardas de escritura en HotXLS

Un hilo en segundo plano estaba exportando un informe de 40.000 filas cuando el hilo de la UI estableció una celda, y el archivo que aterrizó en disco no coincidía con 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 guardas de escritura fail-fast: mientras un lease está abierto, cada punto de entrada de mutación lanza una excepción en lugar de escribir

El fallo que llega sin stack trace

Leer un libro nunca es una única operación atómica. Un recorrido de informe son decenas de miles de lecturas individuales de celdas repartidas a lo largo de segundos, y un único 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 a FSST.Remove para eliminar una entrada de cadena compartida, restablecer FValueType e invalidar un estado de caché de fórmula, todo mientras otro thread está a mitad de desreferenciar exactamente esas estructuras. Nada se estrella en el momento. Obtienes un informe cuyos subtotales no cuadran, o una exportación que lee en silencio un índice de cadena que ahora apunta a otro sitio

HotXLS deliberadamente no resuelve esto haciendo esperar a los escritores. 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 en segundo plano termine es un resultado peor que rechazar la edición. Así que el núcleo de coordinación lanza EXLSWorkbookWriteGuardUnavailable en el momento en que se intenta una escritura contra un lease abierto, antes de que un solo campo haya sido tocado, y el llamador decide si encolar la edición, reintentar o decírselo 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 nunca se excluyen entre sí
Los lectores coexisten y los escritores fallan rápido contra ellos, pero el núcleo nunca 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 número de leases coexiste, pueden liberarse en cualquier orden, y cada uno mantiene el núcleo vivo a través de su propia referencia de interfaz, así que un lease que sobreviva al objeto que lo creó es 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 igualmente es lo que el lease no añade 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 parsing XLSX paralelo y del asignador de memoria: pagar la coordinación en el límite, nunca en el bucle interno. La regla simétrica también se sostiene — AcquireReadLease lanza EXLSWorkbookReadLeaseUnavailable siempre que WriteDepth sea distinto de cero, así que no puedes 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 la guarda de escritura se adquiere dentro de TXLSCellRef.SetValue para que cada API de conveniencia por encima quede controlada una vez
Una adquisición y una liberación cubren un escaneo de cincuenta mil celdas, y una única guarda dentro de TXLSCellRef.SetValue cubre cada ruta de escritura pública por encima de ella
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 ámbito aquí: su recuento de referencias cae a cero,
  // ReleaseReadLease corre, y los escritores vuelven a ser posibles
end;

¿Dónde se sitúa realmente la guarda de escritura?

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

La superficie controlada abarca valores y formato de celdas, TXLSWorkbook.Open, copiar y pegar, nombres definidos (Add, renombrar, RefersTo, Visible, IsMacro, Comment, Delete), metadatos de hoja como Name, Zoom, Visible, StandardHeight, FreezePanes, Protect y Activate, configuración de página, saltos de página y Calculate. La colocación es el punto entero: la guarda se adquiere antes de que el primer campo se escriba, no se valida después mediante un hook de notificación, así que una mutación rechazada deja el modelo byte-idéntico. La suite de regresión afirma precisamente eso, releyendo nombre de hoja, zoom, visibilidad, altura estándar, márgenes, orientación y recuentos de saltos de página tras 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
  // Se adquiere 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 una guarda exterior completada 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 la define la guarda más exterior en un hilo, no cada guarda individualmente. El núcleo mantiene un estado de escritor por hilo que sostiene un id de hilo, una profundidad y una bandera 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 vuelve a cero — con la guarda más exterior marcada como Complete — avanza FGeneration. Esto es lo que permite que una operación de alto nivel como Calculate u Open llame a diez primitivas controladas por debajo y aun así se registre como un cambio. Las llamadas internas a Complete se registran pero no mueven el contador por sí solas, y las guardas pueden liberarse fuera de orden sin romper la contabilidad

La dirección de fallo es igual de explícita. Si una guarda se libera sin Complete — la consecuencia ordinaria de una excepción desenrollando la referencia de interfaz — la generación no avanza, porque la transacción de escritura nunca reclamó éxito. Sé lúcido con 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 una caché necesita, pero restaurar el modelo a su estado anterior no es algo que una guarda con recuento de referencias pueda hacer por ti. Si un fallo a mitad de transacción puede dejar el libro en una forma que no puedes enviar, conserva el archivo fuente y reábrelo, en lugar de fiarte del objeto en memoria

Dos líneas de tiempo de transacciones de escritura de HotXLS comparadas: las guardas anidadas en un hilo suben la profundidad y avanzan el contador de generación solo cuando la guarda más exterior se completa, mientras que una excepción que desenrolla las guardas sin Complete deja la generación sin cambios y la edición parcial en su sitio
Depth sigue el anidamiento, pero solo una transacción exterior completada avanza la generación, y una abortada deja tanto el contador como la edición parcial exactamente donde estaban

Lo que te compra el contador de generación

Detección de obsolescencia barata sin escaneo. Generation es un UInt64 que empieza en 1 y se salta el 0 en el desbordamiento, así que 0 nunca es un valor que el núcleo emita y funciona como un centinela fiable de «nunca observado». Dos invariantes lo hacen usable: la generación no puede moverse mientras exista cualquier 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 era el modelo cuando su transacción se 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 primera pasada siempre reconstruye
end;

Lo que esta coordinación no promete

Tres límites conviene enunciarlos sin rodeos, porque asumir lo contrario es cómo se abusa del mecanismo. Primero, una guarda de escritura no es exclusión mutua entre escritores: el núcleo excluye a los lectores frente a los escritores, y dos hilos distintos pueden sostener cada uno una guarda de escritura al mismo tiempo, avanzando cada uno la generación de forma independiente — una prueba de regresión afirma exactamente este comportamiento. Serializar tus propios hilos escritores sigue siendo tu trabajo. 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 abren 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 sigue recorriendo 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 en verde en Win32 y Win64. Emparejado con la ruta de guardado en archivo temporal por etapas a prueba de fallos, una exportación en segundo plano se convierte en algo que puedes razonar de principio a fin — consistente mientras lee, atómica cuando escribe. Los leases de lectura, las guardas de escritura y el contador de generación se envían como parte de los motores clásico y de paquete en el HotXLS Delphi Component para Delphi y C++Builder, sin configuración alguna para activarlos