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
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
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
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