HotXLS, la librería de componente Excel nativo para Delphi y C++Builder, infla varias hojas XLSX al mismo tiempo desde un único paquete ZIP abierto. El mecanismo es TZipReadGate, una clase pequeña en lxZipArchive.pas que mantiene el flujo del paquete más una sección crítica y expone exactamente un método. Serializa el par de búsqueda y lectura. Todo por encima de ese par se ejecuta de forma concurrente
El problema que forzó este diseño es uno que todo desarrollador Delphi que ha abierto un libro grande ha encontrado. Un xlsx de 80 MB son 80 MB de XML deflactado, y las partes de hoja de cálculo dentro de él se expanden aproximadamente de cinco a diez veces. Si tu ruta de apertura extrae cada hoja a 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 preparación. El techo del asignador que se encuentra por encima se cubre en el artículo sobre análisis paralelo de XLSX y el gestor de memoria, y la API de lectura única sin materializar se cubre en el recorrido del lector directo de streaming
Por qué la ruta de apertura antigua preparaba 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 serialmente, creaba cada hoja, leía su parte de relación, y copiaba todo el XML de hoja inflado en un TMemoryStream privado. La fase B distribuía ParseWorksheetXml a través del pool. La fase C volvía al archivo en el hilo que llama para las partes satélite pequeñas: comentarios, comentarios en hilo, dibujos, gráficos, tablas. Esa forma se eligió por una razón declarada. El comentario de encabezado en lxParallelParse.pas solía decir, en tantas palabras, que el archivo zip y su estado de inflate 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 inflate se serializa por entrada, el bloqueo no compra nada. La fase A existía para mantener cada contacto con el archivo en un solo hilo. El costo 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 en toda la ruta de apertura
¿Pueden dos hilos inflar desde un flujo ZIP?
Sí, y el juicio antiguo estaba equivocado de una manera específica y localizable: colapsaba dos piezas diferentes de estado en una sola oración. El estado de inflate genuinamente no es compartible. 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 pregunta completamente diferente, y la respuesta ahí es que un flujo de archivo tiene exactamente una pieza de estado mutable compartido que vale la pena proteger, su cursor de posición
El contenedor ZIP hace legal la separación. Cada miembro en un archivo ZIP se comprime independientemente: su propio encabezado 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 diccionario compartido que abarque miembros como lo tiene un bloque sólido 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 la búsqueda. Esa colisión es lo que elimina TZipReadGate, y toda la clase es lo suficientemente corta como para leerla en una 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 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 exitosamente, y TZipArchive.Close la libera. Los archivos abiertos para escritura nunca obtienen una. Por lo tanto, cada lectura que un worker realiza sobre el paquete se canaliza a través de una única sección crítica mantenida por la duración de una lectura con búfer
Todo lo demás permanece fuera del bloqueo porque ya es privado o ya es inmutable. TZipSubStream mantiene su propia FPosition, así que cada worker rastrea su propio lugar en su propia entrada. El TZLibStream que TZipEntry.GetStream construye sobre ese sub-flujo es por entrada, creado con windowBits de -15 para deflate crudo, y nunca compartido. El directorio central se analiza completamente antes de que cualquier worker comience, incluyendo cada encabezado local, así que GetEntryByName es una búsqueda hash de solo lectura para cuando comienza la concurrencia. El enrutamiento en sí son tres líneas en TZipSubStream.Read, y la rama sin puerta es lo que mantiene a cada llamador de un solo hilo existente 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 en el archivo", debido a la granularidad que TZLibStream resulta usar. Su búfer de entrada es BufferSize, definido como $4000, así que ReadInputBuffer extrae 16 KB de bytes comprimidos por recarga y los entrega a zng_inflate. Una adquisición de bloqueo por lo tanto cubre 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 mantener nada. El bloqueo se mantiene por 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 vez de deflactadas se leen a través de la puerta uno a uno sin trabajo de inflate que esconda la latencia, así que un paquete lleno de miembros almacenados se serializaría mucho más. Un archivo frío en medios lentos ensancha la sección crítica, porque la lectura dentro de ella ahora es una transferencia de disco real en vez de un acierto de caché. Y pasado un puñado de workers, la puerta no es de todos modos lo primero que topas: el análisis de hojas es intensivo en asignaciones, y el gestor de memoria de Delphi serializa asignaciones a través de hilos mucho antes de que la puerta de lectura se convierta en la restricción. Por eso TXLSXWorkbook.ParallelParseThreads por defecto usa un límite automático en vez de un hilo por núcleo
El cuerpo del worker, y el bucle de drenaje fácil de olvidar
Con la puerta en su lugar, HotXLS eliminó por completo la preparación 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 partes, y ambos se limpian en el bloque finally para que ningún puntero obsoleto sobreviva a una apertura fallida. El flujo que regresa de TZipArchive.OpenFile es un TZipVerifiedStream que envuelve un TZLibStream que envuelve un TZipSubStream, y liberar el exterior libera 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 drenaje es el detalle que un port directo del código antiguo omitiría, y omitirlo desactiva silenciosamente la verificación de integridad. TZipVerifiedStream acumula un CRC32 en ejecución a medida que los bytes pasan y llama a VerifyComplete solo cuando su posición llega al tamaño no comprimido registrado en el directorio central; ahí es de donde vienen las excepciones de desajuste de tamaño y desajuste 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 usualmente deja un salto de línea o unos pocos bytes de espacio en blanco final sin leer, así que sin el drenaje la posición nunca llega al tamaño declarado y las verificaciones nunca se disparan. Leer el resto en un búfer de trabajo no cuesta nada y las restaura. Cuando existían los flujos de preparación, XlsxCopyStreamAll hacía esto por accidente
Qué sigue ejecutándose serialmente, y la bandera que apaga todo
La Fase A sobrevive, menos la extracción. Todavía crea cada hoja y lee sus relaciones en el hilo que llama, que es lo que deja cada mapa compartido inmutable una vez que comienzan los workers. La Fase C todavía recorre las hojas serialmente después para comentarios, dibujos, gráficos y tablas, y su guardia cambió de una verificación nula en el arreglo de preparación 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 comience 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;
Establecer ParallelParse en False antes de Open despacha el mismo procedimiento de trabajo con un conteo de hilos de uno, y RunParallelJobs degenera en un bucle simple en el hilo que llama. Vale la pena saberlo por dos razones: es la respuesta de una línea si alguna vez surge una preocupación de hilos en el campo, y significa que las rutas serial y paralela comparten un único cuerpo de código de análisis en vez de divergir. Las excepciones de los workers se capturan, el índice de trabajo más bajo gana, y el error se relanza en el hilo que llama después de que cada worker se une, así que una hoja corrupta todavía aparece como una sola 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 de entrada por streaming descritos aquí vienen como parte del componente Excel HotXLS estándar para Delphi y C++Builder, con código fuente completo; la página de producto lleva la referencia completa de TXLSXWorkbook incluyendo las propiedades de apertura paralela