Artículo técnico

Inflate 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 búfer que pertenece al otro miembro. El inflater de A está ahora decodificando la mitad del flujo deflate de otro; zlib normalmente se detiene con Z_DATA_ERROR a los pocos bloques, y si llega a producir salida, el CRC32 del miembro no va a coincidir. Deflate no es el problema aquí. La carrera vive por completo en el cursor de posición del flujo, y un candado alrededor del par buscar-y-leer basta para que muchos workers infilen miembros del mismo paquete a la vez

La razón por la que alguien quiere eso es la memoria. Suponga un libro de 80 MB en disco cuyo XML de hoja se expande de cinco a diez veces al inflarse. Una ruta de apertura que extrae cada hoja a un búfer 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 de eso está cubierto en el artículo sobre análisis 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 de streaming

El diseño de preparación, y lo que retiene en memoria

La forma cauta de paralelizar una apertura de XLSX es un pipeline de tres fases donde solo la fase intermedia corre en workers. La fase A recorre la lista de hojas en el hilo que llama, crea cada hoja, lee su parte de relaciones, y copia todo el XML de hoja inflado a un TMemoryStream privado. La fase B reparte el análisis de XML por un pool de hilos. La fase C vuelve al archivo, todavía en el hilo que llama, por las partes satélite pequeñas: comentarios, comentarios en hilo, dibujos, gráficos y tablas

El argumento para esa forma suena hermético. Un archivo ZIP y su estado de inflate no son thread-safe, así que mantenga cada contacto con el archivo en un hilo y olvídese del bloqueo. La cuenta es un libro con ocho hojas activas sosteniendo ocho búferes de hoja totalmente inflados al mismo tiempo, y esos búferes son los objetos transitorios más grandes de toda la ruta de apertura. Con el paquete de 80 MB supuesto arriba, 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 escalonada en Delphi donde la Fase A copia a memoria todo el XML de 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 activas retiene ocho búferes grandes en el pico

HotXLS conserva las fases A y C pero suelta la copia. Cada worker de la fase B abre su propio flujo de entrada sobre el paquete compartido y lo entrega directo al lector de 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 flujo ZIP?

Sí, siempre que cada hilo sea dueño de su inflater y la fuente de bytes compartida se serialice en la lectura. El argumento cauto pliega dos clases de estado en una sola frase. El estado de inflate 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 de Huffman actuales 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 otro asunto. Un flujo de archivo tiene exactamente una pieza de estado mutable que vale la pena proteger, su posición

El contenedor ZIP hace legal la separación. El APPNOTE de PKWARE (sección 4.3) organiza un paquete como miembros independientes, cada uno con su propio encabezado de archivo local, sus propios datos comprimidos en su propio offset, y su propio CRC32 y tamaños registrados en el directorio central. Ningún diccionario abarca miembros como lo hace un bloque sólido de 7z, así que la entrada N puede inflarse sin leer un byte de la entrada M. Déle a cada worker 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 toda la clase 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é queda fuera de ella

TZipReadGate.ReadAt protege una operación indivisible, posicionar el flujo compartido y leer de él. TZipArchive.OpenArchive construye la puerta una vez que el directorio central se analizó, 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 candado porque ya es privado o ya está congelado. Cada flujo de entrada que TZipArchive.OpenFile devuelve lleva su propia posición dentro de su propio miembro y la convierte a un 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 encabezado zlib, y nunca se le entrega a otro hilo. El directorio central, encabezados locales incluidos, se analiza por completo antes de que cualquier worker arranque, así que buscar una entrada por nombre es una búsqueda hash de solo lectura para cuando comienza la concurrencia. Las búsquedas no distinguen mayúsculas y rechazan nombres que llevan segmentos .. dentro

Diagrama de arquitectura de la compuerta de lectura de HotXLS en Delphi donde trabajadores de hojas concurrentes inflan rangos de bytes privados mientras una sección crítica serializa solo el seek y la lectura del stream compartido
Cada worker 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 lock para una sola lectura posicionada

Una consecuencia importa cuando usted llama a TXLSXWorkbook.Open(Stream) con su propio flujo. La puerta se envuelve alrededor de ese flujo, y su candado coordina a los workers de la biblioteca entre sí. No sabe nada de su código. Si otro hilo de su aplicación lee o reposiciona el mismo TStream mientras Open corre, la carrera de cursor del primer párrafo está de vuelta, así que entregue el flujo y déjelo solo hasta que Open regrese

¿Cuánto cuesta la puerta bajo contención?

Menos de lo que sugiere "un bloqueo global en el archivo", por la cantidad de trabajo que cubre cada adquisición. El inflater recarga su entrada en fragmentos de 64 KB de bytes comprimidos, así que un viaje por la puerta alimenta 64 KB de datos deflate a un worker que luego los decodifica y analiza sin sostener nada. Con la expansión de cinco a diez veces supuesta antes, esa recarga se vuelve unos 320 a 640 KB de marcado de hoja. El candado en sí cubre una lectura posicionada que normalmente se atiende desde la caché de archivos del sistema operativo

La proporción se invierte en tres lugares, todos sobre la entrada o la máquina. Los miembros almacenados sin compresión pasan por la puerta una vez por llamada de lectura sin trabajo de inflate entre adquisiciones; la mayoría de los escritores comprimen con deflate las partes de hoja, pero un paquete producido por una herramienta que almacena todo serializará más fuerte. Un archivo frío en almacenamiento lento o de red ensancha la sección crítica, porque la lectura dentro se vuelve una transferencia real. Y más allá de un puñado de workers la puerta no es la primera muralla de todos modos: el análisis de hojas asigna pesadamente, y el gestor de memoria de Delphi serializa asignaciones entre hilos mucho antes de que la puerta de lectura se sature

Ese último punto decide el conteo de hilos. TXLSXWorkbook.ParallelParseThreads viene en 0 por defecto, lo que arranca un worker por procesador lógico, nunca más que el número de hojas. En una laptop con cuatro a ocho procesadores lógicos ese valor por defecto está bien. En un servidor de compilación de 32 hilos que abre libros con docenas de hojas, ponga un tope explícito pequeño como 4: los workers extra casi siempre hacen fila en el asignador y cada uno carga su propio inflater y búferes. Para encontrar el número correcto para sus archivos, abra un libro representativo en un bucle con la propiedad en 1, 2, 4 y 8, y compare el tiempo de pared contra el pico de conjunto de trabajo en el Administrador de tareas o Process Explorer

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

Cada flujo de entrada que el archivo devuelve se comprueba a sí mismo, pero solo al final. Acumula un CRC32 en ejecución a medida que los bytes pasan, 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 acaba antes de tiempo

Un lector de XML se detiene en la etiqueta de cierre </worksheet>. Todo lo que venga despué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 preparación recibía esa comprobación gratis, porque copiar una entrada a un búfer lee cada byte. Un parser que lee el flujo de entrada directamente no, así que cada worker de HotXLS drena lo que el parser dejó con un búfer de trabajo de 32 KB antes de liberar el flujo, y liberar el flujo más externo suelta el inflater y el sub-flujo de abajo

Diagrama de la cadena de streams de trabajadores de HotXLS en Delphi desde el paquete compartido pasando por la compuerta de lectura hasta el stream verificado, con el bucle de drenaje que permite que las verificaciones de tamaño y CRC32 se disparen
El bucle de drenado empuja la posición hasta el tamaño de entrada declarado para que VerifyComplete pueda comparar el CRC32, atrapando la corrupción que un lector XML dejaría silenciosamente sin leer

La misma regla aplica cuando su propio código usa TZipArchive de lxZipArchive, por ejemplo para verificar de forma sanitaria un paquete entrante antes de entregarlo al cargador del libro. Una función que lee solo los primeros kilobytes de cada parte para olfatear su elemento raíz no prueba nada sobre la integridad. Lea 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 ayudante que usted escribe; no es 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 flujos de entrada son de solo avance: leer Size funciona, porque el tamaño declarado se conoce de antemano, pero poner Position := 0 después de un vistazo lanza 'ZIP entry stream is forward-only'. Si un componente insiste en buscar, abra la entrada una segunda vez con OpenFile, que es barato y le da un flujo fresco en offset cero. Varios hilos pueden llamar cada uno a OpenFile sobre un archivo abierto para lectura, que es exactamente lo que hace el cargador del libro, mientras nada libere el archivo mientras sus flujos están vivos

Qué sigue corriendo en serie, 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 que llama, que es lo que deja cada mapa compartido inmutable una vez que los workers arrancan. La tabla de cadenas compartida 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 a la vez
    Wb.ParallelParseThreads := 4;    // 0 = un worker por procesador lógico
    Wb.SelectedSheets.Add('Q3 Detail');  // opcional: analiza celdas solo para 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 corre el mismo trabajo por hoja con un conteo de hilos de uno, y el pool degenera en un bucle simple en el hilo que llama. Un libro con una sola hoja toma esa ruta de todos modos. Tenga el interruptor presente al diagnosticar un problema de apertura: saca el hilado de la ecuación en una línea, y ambas rutas corren el mismo código de análisis, así que el libro que recibe no depende del ajuste

Los errores también conservan la forma en serie. Cuando un worker lanza, no se despachan más hojas, todos los workers se unen, y la excepción del índice de hoja más bajo se relanza en el hilo que llama, así que un sheet3.xml corrupto igual sale como una sola excepción desde Open. Con RecoveryMode en True, una hoja que no se puede analizar se salta en su lugar y se registra como advertencia en los diagnósticos del libro. Las hojas que quedan fuera de SelectedSheets nunca abren su flujo 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 de libros grandes en Delphi

La puerta de lectura, la fase de apertura paralela y el acceso ZIP a nivel de entrada mostrados aquí son parte del componente Excel HotXLS para Delphi y C++Builder, que viene con código fuente completo, así que usted puede leer lxZipArchive.pas junto a las propiedades de apertura de TXLSXWorkbook descritas en la página de producto