Artigo Técnico

Inflate ZIP Concorrente em Delphi: Portão de Leitura do HotXLS

O HotXLS, a biblioteca de componente Excel nativa para Delphi e C++Builder, infla várias planilhas XLSX ao mesmo tempo a partir de um único pacote ZIP aberto. O mecanismo é TZipReadGate, uma classe pequena em lxZipArchive.pas que mantém o stream do pacote mais uma seção crítica e expõe exatamente um método. Ela serializa o par seek-e-read. Tudo acima desse par roda concorrentemente

O problema que forçou esse design é um que todo desenvolvedor Delphi que já abriu uma pasta de trabalho grande já encontrou. Um xlsx de 80 MB é 80 MB de XML deflacionado, e as partes de planilha dentro dele expandem aproximadamente de cinco a dez vezes. Se seu caminho de abertura extrai cada planilha para um stream de memória antes de analisá-la, você paga pelos bytes inflados em cima da pasta de trabalho que está construindo, e o pico chega antes de uma única célula ter sido criada. Este artigo trata da concorrência em nível de pacote que remove essa etapa de preparação. O teto do alocador que fica acima dela é coberto em o artigo sobre análise paralela de XLSX e o gerenciador de memória, e a API de leitura única, nunca materializar é coberta no roteiro do leitor direto de streaming

Por que o caminho de abertura antigo preparava cada planilha em RAM

A abertura paralela original no HotXLS era um pipeline de três fases, e a fase do meio era a única que rodava em workers. A Fase A percorria a lista de planilhas serialmente, criava cada planilha, lia sua parte de relacionamento, e copiava o XML de planilha inflado inteiro para um TMemoryStream privado. A Fase B distribuía ParseWorksheetXml sobre o pool. A Fase C voltava ao arquivo na thread chamadora para as pequenas partes satélites: comentários, comentários encadeados, desenhos, gráficos, tabelas. Essa forma foi escolhida por um motivo declarado. O comentário de cabeçalho em lxParallelParse.pas costumava dizer, em tantas palavras, que o arquivo zip e seu estado de inflate não são thread-safe, e as notas internas iam além: não se incomode em travar o arquivo, porque uma vez que o estado de inflate é serializado por entrada, o lock não compra nada. A Fase A existia para manter todo toque no arquivo em uma thread. O custo era que uma pasta de trabalho com oito planilhas ativas mantinha oito buffers de XML de planilha totalmente inflados na memória simultaneamente, e esses buffers são os maiores objetos transitórios em todo o caminho de abertura

Duas threads podem inflar de um stream ZIP?

Sim, e o julgamento antigo estava errado de uma forma específica e localizável: ele colapsava dois pedaços diferentes de estado em uma frase. O estado de inflate genuinamente não é compartilhável. Um z_stream zlib carrega a janela deslizante, as tabelas de Huffman e a posição de bit para um membro comprimido, e duas threads empurrando bytes pelo mesmo produzem lixo. A fonte de bytes subjacente é uma questão inteiramente diferente, e a resposta ali é que um stream de arquivo tem exatamente um pedaço de estado compartilhado mutável que vale a pena proteger, seu cursor de posição

O contêiner ZIP torna a separação legal. Cada membro em um arquivo ZIP é comprimido independentemente: seu próprio cabeçalho de arquivo local, seu próprio stream de bits deflate em seu próprio DataOffset, seu próprio CRC32 e tamanhos no diretório central. Não há dicionário compartilhado abrangendo membros da forma que um bloco 7z sólido tem, então a entrada N pode ser inflada sem tocar na entrada M. Dê a cada worker seu próprio z_stream sobre seu próprio intervalo de bytes e a única coisa em que colidem é o seek. Essa colisão é o que TZipReadGate remove, e a classe inteira é curta o suficiente para ler em uma tela

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;

O que TZipReadGate protege e o que ele deliberadamente não protege

TZipReadGate.ReadAt guarda uma operação indivisível, posicionar o stream compartilhado e ler dele, e nada mais. TZipArchive.OpenArchive constrói o portão sobre FInputStream assim que o diretório central foi analisado com sucesso, e TZipArchive.Close o libera. Arquivos abertos para escrita nunca recebem um. Toda leitura que um worker realiza no pacote portanto passa por uma única seção crítica mantida pela duração de uma leitura em buffer

Tudo o mais fica fora do lock porque já é privado ou já é imutável. TZipSubStream mantém seu próprio FPosition, então cada worker rastreia seu próprio lugar em sua própria entrada. O TZLibStream que TZipEntry.GetStream constrói sobre esse substream é por entrada, criado com windowBits de -15 para deflate bruto, e nunca compartilhado. O diretório central é totalmente analisado antes de qualquer worker começar, incluindo todo cabeçalho local, então GetEntryByName é uma busca hash somente-leitura por quando a concorrência começa. O roteamento em si são três linhas em TZipSubStream.Read, e o ramo sem portão é o que mantém todo chamador single-threaded existente no caminho de código antigo

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;

Quanto o portão custa sob contenção?

Menos do que a frase "lock global no arquivo" sugere, por causa da granularidade que TZLibStream por acaso usa. Seu buffer de entrada é BufferSize, definido como $4000, então ReadInputBuffer puxa 16 KB de bytes comprimidos por reabastecimento e os entrega a zng_inflate. Uma aquisição de lock portanto cobre 16 KB de entrada deflate, que para XML de planilha se expande em algo da ordem de 100 KB de marcação que o worker então decodifica e analisa sem segurar nada. O lock é mantido para uma leitura posicionada contra o cache do sistema operacional; o trabalho que ele controla é medido em milissegundos

O limite honesto é onde essa proporção se inverte. Entradas armazenadas em vez de deflacionadas leem através do portão um-para-um sem trabalho de inflate para esconder a latência, então um pacote cheio de membros armazenados serializaria muito mais fortemente. Um arquivo frio em mídia lenta amplia a seção crítica, porque a leitura dentro dela agora é uma transferência de disco real em vez de um acerto de cache. E além de um punhado de workers o portão não é o que você atinge primeiro de qualquer forma: a análise de planilha é pesada em alocação, e o gerenciador de memória do Delphi serializa alocações entre threads bem antes de o portão de leitura se tornar a restrição. É por isso que TXLSXWorkbook.ParallelParseThreads tem como padrão um teto automático em vez de uma thread por núcleo

O corpo do worker, e o laço de drenagem que é fácil de esquecer

Com o portão no lugar, o HotXLS excluiu a preparação da Fase A completamente. O worker agora abre seu próprio stream de entrada e o alimenta diretamente ao parser. Dois campos transitórios carregam as entradas: FParZip mantém o arquivo pela duração da fase paralela, FParSheetPartNames mantém os nomes das partes, e ambos são limpos no bloco finally para que nenhum ponteiro obsoleto sobreviva a uma abertura falha. O stream que volta de TZipArchive.OpenFile é um TZipVerifiedStream envolvendo um TZLibStream envolvendo um TZipSubStream, e liberar o externo libera a cadeia

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;

O laço de drenagem é o detalhe que uma portagem direta do código antigo derrubaria, e derrubá-lo desativa silenciosamente a verificação de integridade. TZipVerifiedStream acumula um CRC32 corrente enquanto bytes passam por ele e chama VerifyComplete apenas quando sua posição alcança o tamanho descomprimido registrado no diretório central; é daí que vêm as exceções de incompatibilidade de tamanho e de CRC32, mais uma leitura de sonda de um byte que captura uma entrada mais longa do que declarada. Um leitor XML para no elemento de fechamento e geralmente deixa uma quebra de linha ou alguns bytes de espaço em branco à direita não lidos, então sem a drenagem a posição nunca alcança o tamanho declarado e as verificações nunca disparam. Ler o restante em um buffer de descarte não custa nada e as restaura. Quando os streams de preparação existiam, XlsxCopyStreamAll fazia isso por acidente

O que ainda roda serialmente, e o sinalizador que desliga tudo

A Fase A sobrevive, menos a extração. Ela ainda cria cada planilha e lê seus relacionamentos na thread chamadora, o que é o que deixa todo mapa compartilhado imutável assim que os workers começam. A Fase C ainda percorre as planilhas serialmente depois para comentários, desenhos, gráficos e tabelas, e sua proteção mudou de uma verificação nula no array de preparação antigo para zip.Exists contra o nome da parte. As entradas compartilhadas somente-leitura que os workers tocam, a tabela de strings compartilhada e os mapas cellXf, estão completas antes de a Fase B começar e nunca são escritas durante ela

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;

Definir ParallelParse como False antes de Open despacha o mesmo procedimento de tarefa com uma contagem de threads de um, e RunParallelJobs degenera para um laço simples na thread chamadora. Isso vale a pena saber por duas razões: é a resposta de uma linha se alguma preocupação de threading surgir em campo, e significa que os caminhos serial e paralelo compartilham um único corpo de código de análise em vez de divergir. Exceções de worker são capturadas, o índice de tarefa mais baixo vence, e o erro é redisparado na thread chamadora depois que todo worker se junta, então uma planilha corrompida ainda aparece como uma exceção no lugar esperado. O ajuste geral do caminho de abertura ao redor é coberto em o guia de desempenho de pastas de trabalho grandes em Delphi

O portão de leitura, a fase de abertura paralela e o acesso de entrada por streaming descritos aqui são fornecidos como parte do componente Excel HotXLS padrão para Delphi e C++Builder, com código-fonte completo; a página do produto traz a referência completa de TXLSXWorkbook incluindo as propriedades de abertura paralela