HotXLS, la librería de componente Excel nativa para Delphi y C++Builder, infla varias hojas de cálculo XLSX al mismo tiempo a partir de un único paquete ZIP abierto. El mecanismo es TZipReadGate, una clase pequeña en lxZipArchive.pas que retiene el flujo del paquete más una sección crítica y expone exactamente un método. Serializa el par de posicionamiento y lectura. Todo lo que hay por encima de ese par se ejecuta de forma concurrente
El problema que forzó este diseño es uno que todo desarrollador Delphi que haya abierto un libro grande ha encontrado. Un xlsx de 80 MB son 80 MB de XML deflado, y las partes de hoja de cálculo dentro de él se expanden aproximadamente entre cinco y diez veces. Si tu ruta de apertura extrae cada hoja en un flujo de memoria antes de analizarla, pagas por los bytes inflados encima del libro que estás construyendo, y el pico llega antes de que se haya creado una sola celda. Este artículo trata sobre la concurrencia a nivel de paquete que elimina ese paso de puesta en escena. El techo del asignador que se sitúa por encima se cubre en el artículo sobre el análisis paralelo de XLSX y el gestor de memoria, y la API de lectura única, nunca materializar se cubre en el recorrido por el lector directo en streaming
Por qué la ruta de apertura antigua ponía en escena cada hoja en RAM
La apertura paralela original en HotXLS era un pipeline de tres fases, y la fase intermedia era la única que se ejecutaba en workers. La fase A recorría la lista de hojas de forma secuencial, creaba cada hoja, leía su parte de relaciones, y copiaba todo el XML de hoja inflado en un TMemoryStream privado. La fase B repartía ParseWorksheetXml por el conjunto de workers. La fase C volvía al archivo en el hilo llamante para las partes satélite pequeñas: comentarios, comentarios encadenados, dibujos, gráficos, tablas. Esa forma se eligió por un motivo declarado. El comentario de cabecera de lxParallelParse.pas solía decir, con esas palabras, que el archivo zip y su estado de inflado no son seguros para hilos, y las notas internas iban más allá: no te molestes en bloquear el archivo, porque una vez que el estado de inflado está serializado por entrada, el bloqueo no aporta nada. La fase A existía para mantener cada acceso al archivo en un solo hilo. El coste era que un libro con ocho hojas activas mantenía ocho búferes de XML de hoja completamente inflados en memoria simultáneamente, y esos búferes son los objetos transitorios más grandes de toda la ruta de apertura
¿Pueden dos hilos inflar desde un único flujo ZIP?
Sí, y el juicio antiguo estaba equivocado de una manera específica y localizable: colapsaba dos piezas de estado distintas en una única frase. El estado de inflado genuinamente no se puede compartir. Un z_stream de zlib lleva la ventana deslizante, las tablas de Huffman y la posición de bit para un miembro comprimido, y dos hilos empujando bytes a través del mismo producen basura. La fuente de bytes subyacente es una cuestión completamente distinta, y la respuesta ahí es que un flujo de archivo tiene exactamente una pieza de estado mutable compartido que merece la pena proteger, su cursor de posición
El contenedor ZIP hace legal la separación. Cada miembro de un archivo ZIP se comprime de forma independiente: su propia cabecera de archivo local, su propio flujo de bits deflate en su propio DataOffset, su propio CRC32 y tamaños en el directorio central. No hay ningún diccionario compartido que abarque miembros como sí lo tiene un bloque sólido de 7z, así que la entrada N puede inflarse sin tocar la entrada M. Dale a cada worker su propio z_stream sobre su propio rango de bytes y lo único con lo que colisionan es el posicionamiento. Esa colisión es lo que elimina TZipReadGate, y toda la clase es lo bastante corta como para leerla en una sola pantalla
type
TZipReadGate = class
private
FBaseStream: TStream;
FLock: TRTLCriticalSection;
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;
EnterCriticalSection(FLock);
try
FBaseStream.Position := AOffset;
Result := FBaseStream.Read(Buffer, Count);
finally
LeaveCriticalSection(FLock);
end;
end;
Qué protege TZipReadGate y qué deliberadamente no protege
TZipReadGate.ReadAt protege una única operación indivisible, posicionar el flujo compartido y leer de él, y nada más. TZipArchive.OpenArchive construye la puerta sobre FInputStream una vez que el directorio central se ha analizado con éxito, y TZipArchive.Close la libera. Los archivos abiertos para escritura nunca reciben una. Por tanto, cada lectura que un worker realiza sobre el paquete pasa por una única sección crítica retenida durante la duración de una lectura en búfer
Todo lo demás queda fuera del bloqueo porque ya es privado o ya es inmutable. TZipSubStream mantiene su propio FPosition, así que cada worker rastrea su propio lugar en su propia entrada. El TZLibStream que construye TZipEntry.GetStream sobre ese subflujo es por entrada, creado con un windowBits de -15 para deflate en bruto, y nunca se comparte. El directorio central se analiza por completo antes de que arranque ningún worker, incluida cada cabecera local, así que GetEntryByName es una búsqueda hash de solo lectura para cuando comienza la concurrencia. El propio enrutamiento son tres líneas en TZipSubStream.Read, y la rama sin puerta es lo que mantiene a cada llamador existente de un solo hilo en la ruta de código antigua
function TZipSubStream.Read(var buffer; Count: longint): longint;
var
rest: Int64;
rc: longint;
begin
rest := FSize - FPosition;
if (Count > rest) then
Count := rest;
if FReadGate <> nil then
rc := FReadGate.ReadAt(FOffset + FPosition, buffer, Count)
else
begin
FBaseStream.Position := FOffset + FPosition;
rc := FBaseStream.Read(buffer, Count);
end;
FPosition := FPosition + rc;
Result := rc;
end;
¿Cuánto cuesta la puerta bajo contención?
Menos de lo que sugiere la frase "bloqueo global sobre el archivo", gracias a la granularidad que resulta usar TZLibStream. Su búfer de entrada es BufferSize, definido como $4000, así que ReadInputBuffer extrae 16 KB de bytes comprimidos por recarga y se los entrega a zng_inflate. Una adquisición de bloqueo cubre por tanto 16 KB de entrada deflate, que para XML de hoja de cálculo se expande a algo del orden de 100 KB de marcado que el worker luego decodifica y analiza sin retener nada. El bloqueo se mantiene para una lectura posicionada contra la caché del sistema operativo; el trabajo que protege se mide en milisegundos
El límite honesto es donde esa proporción se invierte. Las entradas almacenadas en lugar de defladas se leen a través de la puerta uno a uno sin ningún trabajo de inflado que oculte la latencia, así que un paquete lleno de miembros almacenados se serializaría mucho más. Un archivo frío en medios lentos amplía la sección crítica, porque la lectura dentro de ella ahora es una transferencia real de disco en lugar de un acierto de caché. Y más allá de un puñado de workers la puerta no es lo primero con lo que te encuentras de todos modos: el análisis de hojas de cálculo consume muchas asignaciones, y el gestor de memoria de Delphi serializa las asignaciones entre hilos mucho antes de que la puerta de lectura se convierta en la restricción. Por eso TXLSXWorkbook.ParallelParseThreads tiene por defecto un tope automático en lugar de un hilo por núcleo
El cuerpo del worker, y el bucle de vaciado que es fácil de olvidar
Con la puerta en su sitio, HotXLS eliminó directamente la puesta en escena de la fase A. El worker ahora abre su propio flujo de entrada y lo alimenta directamente al analizador. Dos campos transitorios llevan las entradas: FParZip mantiene el archivo durante la fase paralela, FParSheetPartNames mantiene los nombres de las partes, y ambos se limpian en el bloque finally para que ningún puntero obsoleto sobreviva a una apertura fallida. El flujo que devuelve TZipArchive.OpenFile es un TZipVerifiedStream que envuelve un TZLibStream que envuelve un TZipSubStream, y liberar el exterior libera toda la cadena
procedure TXLSXWorkbook.ParseSheetJob(AIndex: Integer);
var
Stream: TStream;
DrainBuffer: array [0..32767] of Byte;
PartName: WideString;
begin
PartName := WideString(FParSheetPartNames[AIndex]);
Stream := FParZip.OpenFile(PartName);
if Stream = nil then
Exit;
try
ParseWorksheetXml(Stream, FSheets.ByPos[AIndex], FParSst,
FParRels[AIndex], FParFontMap, FParFillMap, FParBorderMap,
FParNumFmtMap, FParAlignMap, FParProtMap, FParDateMap);
// Consume any trailing bytes so the ZIP entry size and CRC are verified.
while Stream.Read(DrainBuffer, SizeOf(DrainBuffer)) > 0 do
;
finally
Stream.Free;
end;
end;
El bucle de vaciado es el detalle que una migración directa del código antiguo dejaría caer, y dejarlo caer desactiva silenciosamente la comprobación de integridad. TZipVerifiedStream acumula un CRC32 corriente a medida que los bytes pasan y llama a VerifyComplete solo cuando su posición alcanza el tamaño sin comprimir registrado en el directorio central; ahí es de donde vienen las excepciones de discrepancia de tamaño y de discrepancia de CRC32, más una lectura de sondeo de un byte que detecta una entrada más larga de lo declarado. Un lector XML se detiene en el elemento de cierre y normalmente deja sin leer un salto de línea o unos pocos bytes de espacio en blanco final, así que sin el vaciado la posición nunca alcanza el tamaño declarado y las comprobaciones nunca se disparan. Leer el resto en un búfer de trabajo no cuesta nada y las restaura. Cuando existían los flujos de puesta en escena, XlsxCopyStreamAll hacía esto por accidente
Lo que todavía se ejecuta de forma serial, y el indicador que lo desactiva todo
La fase A sobrevive, menos la extracción. Todavía crea cada hoja y lee sus relaciones en el hilo llamante, que es lo que deja cada mapa compartido inmutable una vez que arrancan los workers. La fase C todavía recorre las hojas de forma secuencial después para comentarios, dibujos, gráficos y tablas, y su guarda cambió de una comprobación nula sobre el array de puesta en escena antiguo a zip.Exists contra el nombre de la parte. Las entradas compartidas de solo lectura que tocan los workers, la tabla de cadenas compartida y los mapas cellXf, están completas antes de que empiece la fase B y nunca se escriben durante ella
var
Wb: TXLSXWorkbook;
begin
Wb := TXLSXWorkbook.Create;
try
Wb.ParallelParse := True; // default; False forces one sheet at a time
Wb.ParallelParseThreads := 4; // 0 selects the automatic cap
Wb.Open('quarterly-consolidation.xlsx');
// ... workbook is identical either way ...
finally
Wb.Free;
end;
end;
Fijar ParallelParse en False antes de Open despacha el mismo procedimiento de trabajo con un recuento de hilos de uno, y RunParallelJobs degenera en un simple bucle en el hilo llamante. Vale la pena saberlo por dos motivos: es la respuesta de una línea si alguna vez surge una duda de hilos en el campo, y significa que las rutas serial y paralela comparten un único cuerpo de código de análisis en lugar de divergir. Las excepciones de los workers se capturan, gana el índice de trabajo más bajo, y el error se vuelve a lanzar en el hilo llamante después de que cada worker se una, así que una hoja corrupta sigue apareciendo como una única excepción en el lugar esperado. El ajuste general de la ruta de apertura circundante se cubre en la guía de rendimiento de libros grandes en Delphi
La puerta de lectura, la fase de apertura paralela y el acceso a entradas en streaming descritos aquí se incluyen como parte del estándar componente Excel HotXLS para Delphi y C++Builder, con código fuente completo; la página del producto lleva la referencia completa de TXLSXWorkbook, incluidas las propiedades de apertura paralela