Artigo Técnico

Read leases e write guards de workbook em Delphi com HotXLS

Uma thread de fundo estava exportando um relatório de 40.000 linhas quando a thread da UI definiu uma célula, e o arquivo que chegou ao disco não correspondia a nenhuma pasta de trabalho que já existiu. O HotXLS trata essa classe de bug em lxWorkbookView.pas, onde IXLSWorkbookViewCore emite read leases O(1) e write guards fail-fast: enquanto um lease está aberto, todo ponto de entrada de mutação levanta exceção em vez de escrever

A falha que chega sem stack trace

Ler uma pasta de trabalho nunca é uma única operação atômica. Uma caminhada de relatório são dezenas de milhares de leituras individuais de células espalhadas por segundos, e um único SetValue pousando entre duas delas é suficiente para mudar o que o resto da caminhada vê. O engine clássico torna isso concreto: TXLSCellRef.SetValue pode chamar FSST.Remove para derrubar uma entrada de shared string, redefinir FValueType, e invalidar um estado de cache de fórmula, tudo enquanto outra thread está no meio de desreferenciar exatamente essas estruturas. Nada trava na hora. Você recebe um relatório cujos subtotais não batem, ou uma exportação que silenciosamente lê um índice de string que agora aponta para outro lugar

O HotXLS deliberadamente não resolve isso fazendo os escritores esperarem. Um reader pode segurar uma pasta de trabalho por vários segundos, e em uma aplicação VCL o writer costuma ser um callback de UI ou um event handler na thread principal — bloquear essa thread até uma exportação de fundo terminar é um resultado pior do que falhar a edição. Então o núcleo de coordenação levanta EXLSWorkbookWriteGuardUnavailable no momento em que uma escrita é tentada contra um lease aberto, antes de um único field ter sido tocado, e o chamador decide se enfileira a edição, tenta de novo, ou avisa o usuário. Conflitos fail-fast, não enfileirados

Uma matriz de coordenação do HotXLS mostrando que read leases coexistem livremente, que uma escrita tentada contra um lease aberto levanta EXLSWorkbookWriteGuardUnavailable, que um lease solicitado dentro de uma write transaction levanta EXLSWorkbookReadLeaseUnavailable, e que duas threads de escrita nunca são excluídas uma da outra
Readers coexistem e writers falham rápido contra eles, mas o núcleo nunca exclui uma thread de escrita de outra

Uma pasta de trabalho é segura para leitura de duas threads?

Sim, desde que ambos os readers segurem um lease e ninguém escreva. IXLSWorkbookViewCore.AcquireReadLease recebe um TCriticalSection, incrementa um contador, captura a geração atual, e retorna um IXLSWorkbookReadLease — tempo constante independentemente de a pasta de trabalho ter mil células ou um milhão. Qualquer número de leases coexiste, podem ser liberados em qualquer ordem, e cada um mantém o núcleo vivo por meio de sua própria referência de interface, então um lease que sobrevive ao objeto que o criou é seguro em vez de um dangling pointer. Ambos os engines participam: TXLSWorkbook em lxHandle.pas e TXLSXWorkbook em lxHandleX.pas constroem um núcleo em seu constructor e expõem _AcquireReadLease e _AcquireWriteGuard

O que importa tanto quanto é o que o lease não adiciona ao caminho de leitura. A critical section cobre aquisição de lease, liberação de lease, e fronteiras de write transaction — nada mais. A leitura comum por célula nunca entra em um lock, um monitor, ou um contador atômico, então segurar um lease custa uma aquisição e uma liberação para a varredura inteira, não uma por célula. Esse é o mesmo instinto de design por trás do trabalho de parsing paralelo de XLSX e memory allocator: pague pela coordenação na fronteira, nunca no loop interno. A regra simétrica também vale — AcquireReadLease levanta EXLSWorkbookReadLeaseUnavailable sempre que WriteDepth é não zero, então você não pode abrir um lease de dentro de uma write transaction, nem mesmo na thread que escreve

O HotXLS paga pela coordenação na fronteira de uma varredura: a critical section cobre apenas aquisição e liberação de lease e fronteiras de write transaction, enquanto o write guard é adquirido dentro de TXLSCellRef.SetValue para que toda API de convenção acima dele seja portilhada uma vez
Uma aquisição e uma liberação cobrem uma varredura de cinquenta mil células, e um único guard dentro de TXLSCellRef.SetValue cobre todo caminho público de escrita acima dele
uses
  lxHandle, lxWorkbookView;

procedure TReportThread.Execute;
var
  Lease: IXLSWorkbookReadLease;
  Sheet: TXLSWorksheet;
  Row: Integer;
  Total: Double;
begin
  // Levanta EXLSWorkbookReadLeaseUnavailable se uma escrita está em curso
  Lease := FWorkbook._AcquireReadLease;
  Sheet := FWorkbook.Sheets[1];
  Total := 0;
  for Row := 1 to 50000 do
    Total := Total + Sheet.Cells[Row, 3].Value;
  FTotal := Total;
  // O Lease sai de escopo aqui: sua contagem de referência cai a zero,
  // ReleaseReadLease roda, e escritores voltam a ser possíveis
end;

Onde o write guard realmente fica?

Na camada mutável mais baixa, nunca na API de convenção em cima dela. _AcquireWriteGuard é chamado de dentro do próprio TXLSCellRef.SetValue, o que significa que todo caminho público que desemboca nele — Range.Value, atribuição de texto de worksheet, cópia célula a célula, paste — é portilhado uma vez em vez de cada wrapper repetir uma verificação que um wrapper futuro esquecerá. A cobertura é deliberadamente ampla: 55 aquisições de guard em lxHandle.pas e 37 em lxHandleX.pas até o batch que introduziu o núcleo

A superfície portilhada abrange valores de célula e formatação de célula, TXLSWorkbook.Open, copy e paste, defined names (Add, rename, RefersTo, Visible, IsMacro, Comment, Delete), metadados de worksheet como Name, Zoom, Visible, StandardHeight, FreezePanes, Protect e Activate, page setup, quebras de página, e Calculate. O posicionamento é o ponto todo: o guard é adquirido antes do primeiro field ser escrito, não validado depois por um hook de notificação, então uma mutação rejeitada deixa o modelo byte-idêntico. A suíte de regressão afirma precisamente isso, relendo nome de sheet, zoom, visibilidade, altura padrão, margens, orientação e contagens de quebra de página após cada chamada recusada. Caminhos de load recebem o mesmo tratamento uma camada abaixo, onde o ZIP read gate coordena inflate concorrente para formatos de pacote

procedure TXLSWorksheet.Activate;
var
  WriteGuard: IXLSWorkbookWriteGuard;
begin
  // Adquirido antes do primeiro field ser tocado, nunca depois
  WriteGuard := FWorkbook._AcquireWriteGuard;
  if not FSelected then
  begin
    FWorkbook.FWorkSheets.Deselect;
    FSelected := True;
  end;
  FWorkbook.FWorkSheets.FActiveSheet := Self;
  // Só um guard mais externo completado avança a geração
  WriteGuard.Complete;
end;

Por que uma escrita aninhada avança a geração só uma vez?

Porque uma write transaction é definida pelo guard mais externo em uma thread, não por cada guard individualmente. O núcleo mantém um estado de writer por thread contendo um thread id, uma profundidade e uma flag de conclusão. Um segundo AcquireWriteGuard na mesma thread encontra esse estado e incrementa Depth em vez de criar uma nova transação, e só quando Depth volta a zero — com o guard mais externo tendo sido marcado Complete — é que FGeneration avança. É isso que permite que uma operação de alto nível como Calculate ou Open chame dez primitivas portilhadas por baixo e ainda se registre como uma única mudança. Chamadas internas de Complete são registradas mas não movem o contador por si, e os guards podem ser liberados fora de ordem sem quebrar a contabilidade

A direção de falha é igualmente explícita. Se um guard é liberado sem Complete — a consequência comum de uma exceção desfazendo a referência de interface — a geração não avança, porque a write transaction nunca reivindicou sucesso. Enxergue claramente o que isso significa: o HotXLS não faz rollback da edição parcial. O contador registra que nenhuma transação bem-sucedida completou, que é exatamente o sinal de que um cache precisa, mas restaurar o modelo ao estado anterior não é algo que um guard com contagem de referência possa fazer por você. Se uma falha no meio da transação pode deixar a pasta de trabalho em uma forma que você não pode entregar, mantenha o arquivo fonte e reabra-o, em vez de confiar no objeto em memória

Duas linhas do tempo de write transaction do HotXLS comparadas: guards aninhados em uma thread elevam a profundidade e avançam o contador de geração somente quando o guard mais externo completa, enquanto uma exceção que desfaz os guards sem Complete deixa a geração inalterada e a edição parcial no lugar
A profundidade acompanha o aninhamento, mas só uma transação mais externa completada avança a geração, e uma abortada deixa tanto o contador quanto a edição parcial exatamente onde estavam

O que o contador de geração compra para você

Detecção barata de obsolescência sem varredura. Generation é um UInt64 que começa em 1 e pula 0 no wraparound, então 0 nunca é um valor que o núcleo emite e funciona como um sentinela confiável de "nunca observado". Dois invariantes o tornam utilizável: a geração não pode se mover enquanto qualquer read lease existe, e cada write transaction bem-sucedida a incrementa exatamente uma vez. Então IXLSWorkbookReadLease.Generation é um snapshot que permanece constante por toda a vida do lease, e IXLSWorkbookWriteGuard.StartGeneration diz a um writer como o modelo estava quando sua transação abriu. Um grid, um print preview, ou um índice derivado pode comparar um inteiro em vez de fazer diff de linhas

var
  Lease: IXLSWorkbookReadLease;
begin
  Lease := FWorkbook._AcquireReadLease;
  if Lease.Generation <> FCachedGeneration then
  begin
    FCachedGeneration := Lease.Generation;
    RebuildRowHeightCache;
  end;
  PaintVisibleRows;
  // FCachedGeneration começa em 0, um valor que o núcleo nunca emite,
  // então o primeiro passe sempre reconstrói
end;

O que esta coordenação não promete

Três limites merecem ser declarados claramente, porque assumir o contrário é como o mecanismo é mal usado. Primeiro, um write guard não é exclusão mútua entre writers: o núcleo exclui readers contra writers, e duas threads diferentes podem cada uma segurar um write guard ao mesmo tempo, cada uma avançando a geração de forma independente — um teste de regressão afirma exatamente esse comportamento. Serializar suas próprias threads de escrita continua sendo trabalho seu. Segundo, nada aqui é um file lock ou um mutex entre processos; ele coordena threads dentro de um processo contra uma instância de pasta de trabalho, e dois processos abrindo o mesmo .xlsx não sabem nada um sobre o outro. Terceiro, a garantia só alcança chamadores que realmente pegam um lease — uma leitura sem lease ainda percorre um hot path destravado, que é rápido e inteiramente desprotegido. Este é um núcleo de coordenação, não um banco de dados transacional

Usado dentro desses limites é uma primitiva pequena e honesta: nove testes de regressão dedicados cobrem múltiplos readers, ambas as direções de conflito, reentrância, liberação fora de ordem, transações abortadas, e corridas entre threads de leitura/escrita e escrita/escrita, dentro de uma suíte de 1.328 testes passando no Win32 e Win64. Combine-o com o caminho de salvamento crash-safe com arquivo temporário em etapas e uma exportação de fundo se torna algo que você pode raciocinar de ponta a ponta — consistente enquanto lê, atômico quando escreve. Read leases, write guards e o contador de geração embarcam como parte dos engines clássico e de pacote no HotXLS Delphi Component para Delphi e C++Builder, sem nenhuma configuração necessária para ativá-los