Artículo técnico

Inflado ZIP concurrente en Delphi

Dos hilos comparten un TFileStream sobre un paquete .xlsx. El hilo A pone Position en el primer byte comprimido de sheet1.xml, el hilo B lo pone un momento después en el primer byte de sheet2.xml, y el Read de A devuelve un buffer que pertenece al otro miembro. El inflater de A está ahora descodificando la mitad del flujo deflate de otro; zlib normalmente se para con Z_DATA_ERROR a los pocos bloques, y si por casualidad produce salida, el CRC32 del miembro no coincidirá. Deflate no es el problema aquí. La carrera vive por completo en el cursor de posición del flujo, y un solo bloqueo alrededor del par buscar-y-leer basta para que muchos trabajadores inflen miembros del mismo paquete a la vez

La razón por la que alguien quiere eso es la memoria. Supón que un libro ocupa 80 MB en disco y su XML de hojas se expande de cinco a diez veces al inflarse. Una ruta de apertura que extrae cada hoja a un buffer antes de analizarla paga esos bytes inflados encima del libro que está construyendo, y el pico llega antes de que exista una sola celda. Este artículo cubre la concurrencia a nivel de paquete que elimina el paso de extracción. El techo del asignador por encima se cubre en el artículo sobre parsing paralelo de XLSX y el gestor de memoria, y la API de lectura única que nunca construye un libro está en el recorrido del lector directo en streaming

El diseño de puesta en escena, y qué guarda en memoria

La manera cautelosa de paralelizar una apertura de XLSX es un pipeline de tres fases donde solo la fase del medio corre en trabajadores. La fase A recorre la lista de hojas en el hilo llamador, crea cada hoja, lee su parte de relaciones y copia el XML de hoja inflado entero a un TMemoryStream privado. La fase B reparte el parsing del XML por un pool de hilos. La fase C vuelve al archivo, todavía en el hilo llamador, por las pequeñas partes satélite: comentarios, comentarios encadenados, dibujos, gráficos y tablas

El argumento para esa forma suena hermético. Un archivo ZIP y su estado de inflado no son thread-safe, así que deja cada toque del archivo en un hilo y te saltas los bloqueos por completo. La factura es un libro con ocho hojas ocupadas sosteniendo ocho buffers de hoja completamente inflados al mismo tiempo, y esos buffers son los mayores objetos transitorios de toda la ruta de apertura. Con el paquete de 80 MB supuesto antes, son varios cientos de megabytes de texto XML que existen solo para que un parser los lea una vez

Diagrama de una ruta de apertura XLSX por fases en Delphi donde la fase A copia a memoria el XML de cada hoja inflado antes de que la fase B analice las hojas en paralelo
La fase A copia cada hoja inflada a un stream de memoria privado en un solo hilo, así que un libro con ocho hojas ocupadas sostiene ocho buffers grandes en el pico

HotXLS conserva las fases A y C pero suelta la copia. Cada trabajador de la fase B abre su propio stream de entrada sobre el paquete compartido y lo entrega directo al lector XML, así que la hoja inflada nunca existe como un solo bloque. Eso solo funciona porque el argumento de "no thread-safe" resulta ser sobre dos cosas distintas

¿Pueden dos hilos inflar desde un solo flujo ZIP?

Sí, siempre que cada hilo posea su inflater y la fuente de bytes compartida se serialice en la lectura. El argumento cauteloso pliega dos clases de estado en una frase. El estado de inflado de verdad no se puede compartir: un z_stream de zlib lleva la ventana deslizante de 32 KB que define el RFC 1951, las tablas Huffman vigentes y la posición de bit dentro de un miembro comprimido, y dos hilos empujando bytes por el mismo producen basura. La fuente de bytes es otra cosa. Un stream de archivo tiene exactamente una pieza de estado mutable que valga la pena proteger, su posición

El contenedor ZIP hace legal la separación. El APPNOTE de PKWARE (sección 4.3) maqueta un paquete como miembros independientes, cada uno con su propia cabecera local de archivo, sus propios datos comprimidos en su propio offset, y su propio CRC32 y tamaños registrados en el directorio central. Ningún diccionario cruza miembros como hace un bloque sólido de 7z, así que la entrada N puede inflarse sin leer un byte de la entrada M. Dale a cada trabajador un inflater privado sobre su propio rango de bytes y la única colisión que queda es la búsqueda. En HotXLS esa colisión la maneja TZipReadGate en lxZipArchive.pas, y la clase entera cabe en una pantalla

type
  TZipReadGate = class
  private
    FBaseStream: TStream;
    FLock: TXLSCriticalSection;   // envoltorio lxPlatform sobre una sección crítica
  public
    constructor Create(ABaseStream: TStream);
    destructor Destroy; override;
    function ReadAt(AOffset: Int64; var Buffer; Count: Longint): Longint;
  end;

function TZipReadGate.ReadAt(AOffset: Int64; var Buffer;
  Count: Longint): Longint;
begin
  if Count <= 0 then
  begin
    Result := 0;
    Exit;
  end;
  XLSEnterCriticalSection(FLock);
  try
    FBaseStream.Position := AOffset;
    Result := FBaseStream.Read(Buffer, Count);
  finally
    XLSLeaveCriticalSection(FLock);
  end;
end;

Qué bloquea la puerta de lectura, y qué se queda fuera

TZipReadGate.ReadAt protege una única operación indivisible, posicionar el flujo compartido y leer de él. TZipArchive.OpenArchive construye la puerta una vez analizado el directorio central, tanto para la sobrecarga de nombre de archivo como para la de TStream, y liberar el archivo la suelta. Los archivos creados para escritura nunca reciben una, porque nada lee de ellos concurrentemente

Todo lo demás se queda fuera del bloqueo porque ya es privado o ya está congelado. Cada stream de entrada que TZipArchive.OpenFile devuelve mantiene su propia posición dentro de su propio miembro y la convierte en offset absoluto del paquete solo en el momento de llamar a ReadAt. El inflater envuelto alrededor se crea por entrada con un tamaño de ventana de -15, que zlib lee como deflate crudo sin cabecera zlib, y nunca se entrega a otro hilo. El directorio central, cabeceras locales incluidas, está completamente analizado antes de que ningún trabajador arranque, así que buscar una entrada por nombre es una consulta de hash de solo lectura para cuando empieza la concurrencia. Las búsquedas no distinguen mayúsculas y un nombre que lleva segmentos .. se rechaza

Diagrama de arquitectura de la puerta de lectura de HotXLS en Delphi donde los trabajadores concurrentes de hojas inflan rangos de bytes privados mientras una única sección crítica serializa solo la búsqueda y lectura del flujo compartido
Cada trabajador posee su posición de sub-stream y su z_stream, así que el único estado compartido es el cursor del paquete y TZipReadGate retiene el bloqueo para una sola lectura posicionada

Una consecuencia importa cuando llamas a TXLSXWorkbook.Open(Stream) con tu propio stream. La puerta se envuelve alrededor de ese stream, y su bloqueo coordina a los trabajadores de la biblioteca entre sí. No sabe nada de tu código. Si otro hilo de tu aplicación lee o reposiciona el mismo TStream mientras Open corre, la carrera de cursor del primer párrafo está de vuelta, así que entrega el stream y déjalo quieto hasta que Open devuelva

¿Cuánto cuesta un bloqueo bajo contención?

Menos de lo que sugiere "un bloqueo global del archivo", por la cantidad de trabajo que cubre cada adquisición. El inflater rellena su entrada en trozos de 64 KB de bytes comprimidos, así que un viaje por la puerta alimenta 64 KB de datos deflate a un trabajador que luego los descodifica y analiza sin retener nada. Con la expansión de cinco a diez veces supuesta antes, ese relleno se convierte en unos 320 a 640 KB de marcado de hoja. El bloqueo en sí cubre una lectura posicionada que normalmente se sirve desde la caché de archivos del sistema operativo

La proporción se invierte en tres sitios, todos sobre la entrada o la máquina. Los miembros guardados sin compresión pasan por la puerta una vez por llamada de lectura sin trabajo de inflado entre adquisiciones; la mayoría de los escritores comprimen con deflate las partes de hoja, pero un paquete producido por una herramienta que lo guarda todo serializará más. Un archivo frío en almacenamiento lento o de red ensancha la sección crítica, porque la lectura dentro se convierte en una transferencia real. Y pasados un puñado de trabajadores la puerta no es la primera pared de todos modos: el parsing de hojas asigna mucha memoria, y el gestor de memoria de Delphi serializa las asignaciones entre hilos bastante antes de que la puerta de lectura sature

Ese último punto decide el número de hilos. TXLSXWorkbook.ParallelParseThreads vale 0 por defecto, lo que arranca un trabajador por procesador lógico, nunca más que el número de hojas. En un portátil con de cuatro a ocho procesadores lógicos ese valor por defecto va bien. En un servidor de compilación de 32 hilos abriendo libros con docenas de hojas, pon un tope explícito pequeño como 4: los trabajadores extra hacen cola sobre todo en el asignador y cada uno sostiene su propio inflater y buffers. Para encontrar el número correcto para tus archivos, abre un libro representativo en un bucle con la propiedad a 1, 2, 4 y 8, y compara el tiempo de pared contra el pico de working set en el Administrador de tareas o en Process Explorer

Leer una entrada hasta el final es lo que dispara la comprobación CRC

Cada stream de entrada que el archivo devuelve se comprueba a sí mismo, pero solo al final. Acumula un CRC32 acumulado a medida que pasan los bytes, y cuando su posición alcanza el tamaño sin comprimir registrado en el directorio central compara ambos valores y sondea un byte extra. Los fallos salen como excepciones con mensajes literales: 'ZIP entry uncompressed size mismatch', 'ZIP entry CRC32 mismatch', 'ZIP entry exceeds its declared size', y 'ZIP entry ended before its declared size' para un miembro que se queda corto

Un lector XML se para en la etiqueta de cierre </worksheet>. Todo lo que venga detrás, normalmente solo un salto de línea final, queda sin leer, la posición nunca alcanza el tamaño declarado, y la comparación nunca corre. El diseño de puesta en escena obtenía la comprobación gratis, porque copiar una entrada a un buffer lee cada byte. Un parser que lee el stream de entrada directamente no, así que cada trabajador de HotXLS drena lo que el parser dejó con un buffer de trabajo de 32 KB antes de liberar el stream, y liberar el stream más externo suelta el inflater y el sub-stream de debajo

Diagrama de la cadena de streams de trabajador de HotXLS en Delphi desde el paquete compartido a través de la puerta de lectura hasta el stream verificado, con el bucle de drenaje que deja disparar las comprobaciones de tamaño y CRC32
El bucle de drenaje empuja la posición hasta el tamaño declarado de la entrada para que corran la comparación de tamaño y CRC32, atrapando corrupción que un lector XML dejaría sin leer

La misma regla aplica cuando tu propio código usa TZipArchive de lxZipArchive, por ejemplo para sanear un paquete entrante antes de entregarlo al cargador de libros. Una función que lee solo los primeros kilobytes de cada parte para olfatear su elemento raíz no prueba nada sobre la integridad. Lee hasta el final:

uses
  Classes, SysUtils, lxZipArchive;

// Lee una parte del paquete hasta su final declarado para que corran las comprobaciones de tamaño y CRC32.
// PartIsIntact es un helper que escribes tú; no forma parte de la biblioteca
function PartIsIntact(Zip: TZipArchive; const PartName: WideString;
  out Problem: string): Boolean;
var
  Stream: TStream;
  Buffer: array [0..32767] of Byte;
begin
  Result := False;
  Problem := '';
  Stream := Zip.OpenFile(PartName);   // nil cuando la parte no existe
  if Stream = nil then
  begin
    Problem := 'part not found';
    Exit;
  end;
  try
    try
      while Stream.Read(Buffer, SizeOf(Buffer)) > 0 do
        ;
      Result := True;
    except
      on E: Exception do
        Problem := E.Message;          // p. ej. 'ZIP entry CRC32 mismatch'
    end;
  finally
    Stream.Free;
  end;
end;

var
  Zip: TZipArchive;
  Problem: string;
begin
  Zip := TZipArchive.Create;
  try
    Zip.OpenArchive('quarterly-consolidation.xlsx');
    if not PartIsIntact(Zip, 'xl/worksheets/sheet1.xml', Problem) then
      Writeln('sheet1.xml: ', Problem);
  finally
    Zip.Free;
  end;
end;

La segunda trampa de ese código es rebobinar. Los streams de entrada son solo hacia adelante: leer Size funciona, porque el tamaño declarado se conoce de antemano, pero poner Position := 0 tras un vistazo lanza 'ZIP entry stream is forward-only'. Si un componente insiste en buscar, abre la entrada una segunda vez con OpenFile, que es barato y te da un stream fresco en el offset cero. Varios hilos pueden llamar cada uno a OpenFile sobre un archivo abierto para lectura, que es exactamente lo que hace el cargador de libros, mientras nada libere el archivo mientras sus streams vivan

Qué sigue corriendo en serial, y el interruptor que lo apaga

La fase A sigue corriendo, menos la extracción. Crea cada hoja y lee sus relaciones en el hilo llamador, que es lo que deja todos los mapas compartidos inmutables una vez los trabajadores arrancan. La tabla de cadenas compartidas y las tablas de formato de celda están completas antes de que la fase B empiece y nunca se escriben durante ella. La fase C sigue recorriendo las hojas en orden después por comentarios, dibujos, gráficos y tablas, saltándose cualquier hoja cuya parte falte en el paquete

uses
  SysUtils, lxHandleX;

var
  Wb: TXLSXWorkbook;
begin
  Wb := TXLSXWorkbook.Create;
  try
    Wb.ParallelParse := True;        // por defecto; False analiza una hoja cada vez
    Wb.ParallelParseThreads := 4;    // 0 = un trabajador por procesador lógico
    Wb.SelectedSheets.Add('Q3 Detail');  // opcional: analiza las celdas solo de esta hoja
    if Wb.Open('quarterly-consolidation.xlsx') <> 1 then
      raise Exception.Create('Workbook open failed');
    // El libro cargado es idéntico sea cual sea la ruta que lo analizó
  finally
    Wb.Free;
  end;
end;

Poner ParallelParse en False antes de Open ejecuta el mismo trabajo por hoja con un recuento de hilos de uno, y el pool degenera en un bucle plano en el hilo llamador. Un libro con una sola hoja toma esa ruta sin importar nada. Guarda el interruptor en la cabeza al diagnosticar un problema de apertura: quita los hilos de la ecuación en una línea, y ambas rutas corren el mismo código de análisis, así que el libro que recibes no depende del ajuste

Los errores conservan también la forma serial. Cuando un trabajador lanza, no se despachan más hojas, todos los trabajadores se unen, y la excepción del índice de hoja más bajo se relanza en el hilo llamador, así que un sheet3.xml corrupto sigue saliendo como una única excepción desde Open. Con RecoveryMode en True, una hoja que falla al analizarse se salta en su lugar y se registra como advertencia en los diagnósticos del libro. Las hojas dejadas fuera de SelectedSheets nunca abren su stream de entrada, y MetadataOnly se salta la fase paralela por completo. El ajuste más amplio de la ruta de apertura está cubierto en la guía de rendimiento con libros grandes en Delphi

La puerta de lectura, la fase de apertura paralela y el acceso ZIP a nivel de entrada mostrados aquí forman parte del componente Excel HotXLS para Delphi para Delphi y C++Builder, que viaja con fuente completa, así que puedes leer lxZipArchive.pas junto a las propiedades de apertura de TXLSXWorkbook descritas en la página de producto