Artigo Técnico

Leases de leitura e guardas de escrita HotXLS em Delphi

Uma thread de fundo estava a exportar um relatório de 40,000 linhas quando a thread da UI definiu uma célula, e o ficheiro que chegou ao disco não correspondia a nenhum livro que já tivesse existido. O HotXLS trata essa classe de erro em lxWorkbookView.pas, onde o IXLSWorkbookViewCore emite leases de leitura O(1) e guardas de escrita fail-fast: enquanto um lease está aberto, todos os pontos de entrada de mutação lançam exceções em vez de escrever

A falha que chega sem stack trace

Ler um livro nunca é uma única operação atómica. Um percurso de relatório são dezenas de milhares de leituras individuais de células espalhadas por segundos, e um único SetValue a aterrar entre dois deles é suficiente para mudar o que o resto do percurso vê. O motor clássico torna isto concreto: o TXLSCellRef.SetValue pode chamar FSST.Remove para largar uma entrada de string partilhada, repor o FValueType, e invalidar um estado de cache de fórmulas, tudo enquanto outra thread está a meio de desreferenciar exatamente essas estruturas. Nada rebenta no local. Obtém um relatório cujos subtotais não batem certo, ou uma exportação que silenciosamente lê um índice de string que agora aponta para outro sítio

O HotXLS deliberadamente não resolve isto fazendo os escritores esperar. Um leitor pode segurar um livro durante vários segundos, e numa aplicação VCL o escritor é muitas vezes um callback de UI ou um handler de eventos na thread principal — bloquear essa thread até uma exportação de fundo terminar é um desfecho pior do que falhar a edição. Por isso o núcleo de coordenação lança EXLSWorkbookWriteGuardUnavailable no momento em que uma escrita é tentada contra um lease aberto, antes de um único campo ter sido tocado, e o chamador decide se põe a edição em fila, tenta de novo, ou informa o utilizador. Conflitos fail-fast, não em fila

Uma matriz de coordenação do HotXLS mostrando que os leases de leitura coexistem livremente, que uma escrita tentada contra um lease aberto lança EXLSWorkbookWriteGuardUnavailable, que um lease pedido dentro de uma transação de escrita lança EXLSWorkbookReadLeaseUnavailable, e que duas threads escritoras nunca são excluídas uma da outra
Os leitores coexistem e os escritores falham rápido contra eles, mas o núcleo nunca exclui uma thread escritora de outra

Um livro é seguro para ler a partir de duas threads?

Sim, desde que ambos os leitores segurem um lease e ninguém escreva. O IXLSWorkbookViewCore.AcquireReadLease toma uma TCriticalSection, incrementa um contador, tira um instantâneo da geração atual, e devolve um IXLSWorkbookReadLease — tempo constante independentemente de o livro ter mil células ou um milhão. Qualquer número de leases coexiste, podem ser libertados em qualquer ordem, e cada um mantém o núcleo vivo através da sua própria referência de interface, pelo que um lease que sobreviva ao objeto que o criou é seguro em vez de um ponteiro pendente. Ambos os motores participam: o TXLSWorkbook em lxHandle.pas e o TXLSXWorkbook em lxHandleX.pas constroem cada um um núcleo no seu construtor e expõem _AcquireReadLease e _AcquireWriteGuard

O que importa tanto quanto isso é o que o lease não acrescenta ao caminho de leitura. A secção crítica cobre a aquisição de lease, a libertação de lease, e os limites de transação de escrita — nada mais. A leitura ordinária por célula nunca entra num lock, num monitor, ou num contador atómico, pelo que segurar um lease custa uma aquisição e uma libertação por toda a varredura, não uma por célula. Esse é o mesmo instinto de design por trás do trabalho de parsing paralelo de XLSX e alocador de memória: pagar pela coordenação no limite, nunca no ciclo interno. A regra simétrica também vale — o AcquireReadLease lança EXLSWorkbookReadLeaseUnavailable sempre que o WriteDepth é diferente de zero, pelo que não pode abrir um lease de dentro de uma transação de escrita, nem sequer na thread escritora

O HotXLS paga pela coordenação no limite de uma varredura: a secção crítica cobre apenas aquisição de lease, libertação e limites de transação de escrita, enquanto a guarda de escrita é adquirida dentro de TXLSCellRef.SetValue para que todas as API de conveniência acima dela sejam controladas uma vez
Uma aquisição e uma libertação cobrem uma varredura de cinquenta mil células, e uma única guarda dentro de TXLSCellRef.SetValue cobre todos os caminhos de escrita públicos acima dela
uses
  lxHandle, lxWorkbookView;

procedure TReportThread.Execute;
var
  Lease: IXLSWorkbookReadLease;
  Sheet: TXLSWorksheet;
  Row: Integer;
  Total: Double;
begin
  // Lança EXLSWorkbookReadLeaseUnavailable se uma escrita estiver 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: a sua contagem de referências cai a zero,
  // o ReleaseReadLease corre, e os escritores voltam a ser possíveis
end;

Onde se situa realmente a guarda de escrita?

Na camada mutável mais baixa, nunca na API de conveniência por cima dela. O _AcquireWriteGuard é chamado de dentro do próprio TXLSCellRef.SetValue, o que significa que todos os caminhos públicos que desaguam nele — Range.Value, atribuição de texto da folha, cópia célula a célula, colar — são controlados uma vez em vez de cada wrapper repetir uma verificação que um wrapper futuro se esquecerá. A cobertura é deliberadamente larga: 55 aquisições de guarda em lxHandle.pas e 37 em lxHandleX.pas à data do lote que introduziu o núcleo

A superfície controlada abrange valores de células e formatação de células, TXLSWorkbook.Open, copiar e colar, nomes definidos (Add, renomear, RefersTo, Visible, IsMacro, Comment, Delete), metadados de folha de cálculo como Name, Zoom, Visible, StandardHeight, FreezePanes, Protect e Activate, configuração de página, quebras de página, e Calculate. A colocação é todo o ponto: a guarda é adquirida antes de o primeiro campo ser escrito, não validada depois por um hook de notificação, pelo que uma mutação recusada deixa o modelo idêntico byte a byte. A suite de regressão afirma precisamente isso, relendo o nome da folha, o zoom, a visibilidade, a altura padrão, as margens, a orientação e as contagens de quebras de página após cada chamada recusada. Os caminhos de carregamento recebem o mesmo tratamento uma camada abaixo, onde o portão de leitura ZIP coordena o inflate concorrente para os formatos de pacote

procedure TXLSWorksheet.Activate;
var
  WriteGuard: IXLSWorkbookWriteGuard;
begin
  // Adquirida antes de o primeiro campo ser tocado, nunca depois
  WriteGuard := FWorkbook._AcquireWriteGuard;
  if not FSelected then
  begin
    FWorkbook.FWorkSheets.Deselect;
    FSelected := True;
  end;
  FWorkbook.FWorkSheets.FActiveSheet := Self;
  // Só uma guarda exterior concluída avança a geração
  WriteGuard.Complete;
end;

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

Porque uma transação de escrita é definida pela guarda mais exterior numa thread, não por cada guarda individualmente. O núcleo mantém um estado de escritor por thread que guarda um id de thread, uma profundidade e uma flag de conclusão. Um segundo AcquireWriteGuard na mesma thread encontra esse estado e incrementa o Depth em vez de criar uma nova transação, e só quando o Depth volta a zero — com a guarda mais exterior tendo sido marcada como Complete — é que o FGeneration avança. É isto que permite que uma operação de alto nível como Calculate ou Open chame dez primitivas controladas por baixo e ainda se registe como uma única alteração. As chamadas Complete internas são registadas mas não movem o contador por si, e as guardas podem ser libertadas fora de ordem sem partir a contabilidade

A direção de falha é igualmente explícita. Se uma guarda é libertada sem Complete — a consequência ordinária de uma exceção a desenrolar a referência de interface — a geração não avança, porque a transação de escrita nunca reclamou sucesso. Seja clarividente quanto ao que isso significa: o HotXLS não faz rollback da edição parcial. O contador regista que nenhuma transação com sucesso concluiu, que é exatamente o sinal de que uma cache precisa, mas restaurar o modelo ao seu estado anterior não é algo que uma guarda com contagem de referências possa fazer por si. Se uma falha a meio da transação pode deixar o livro numa forma que não pode enviar, guarde o ficheiro de origem e abra-o de novo, em vez de confiar no objeto em memória

Duas cronologias de transação de escrita do HotXLS comparadas: guardas aninhadas numa thread elevam a profundidade e avançam o contador de geração só quando a guarda mais exterior conclui, enquanto uma exceção que desenrola as guardas sem Complete deixa a geração inalterada e a edição parcial no lugar
A profundidade acompanha o aninhamento, mas só uma transação exterior concluída avança a geração, e uma abortada deixa tanto o contador como a edição parcial exatamente onde estavam

O que o contador de geração lhe compra

Deteção de obsolescência barata sem varrimento. O Generation é um UInt64 que começa em 1 e salta o 0 no envolvimento, pelo que 0 nunca é um valor que o núcleo emita e funciona como uma sentinela «nunca observado» fiável. Dois invariantes tornam-no utilizável: a geração não pode mover-se enquanto existir qualquer lease de leitura, e cada transação de escrita com sucesso incrementa-a exatamente uma vez. Por isso o IXLSWorkbookReadLease.Generation é um instantâneo que permanece constante por toda a vida do lease, e o IXLSWorkbookWriteGuard.StartGeneration diz a um escritor como o modelo estava quando a sua transação abriu. Uma grelha, uma pré-visualização de impressão, ou um índice derivado podem 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;
  // O FCachedGeneration começa em 0, um valor que o núcleo nunca emite,
  // pelo que a primeira passagem reconstrói sempre
end;

O que esta coordenação não promete

Três limites valem a pena enunciar claramente, porque assumir o contrário é como o mecanismo é mal usado. Primeiro, uma guarda de escrita não é exclusão mútua entre escritores: o núcleo exclui leitores contra escritores, e duas threads diferentes podem cada uma segurar uma guarda de escrita ao mesmo tempo, cada uma avançando a geração independentemente — um teste de regressão afirma exatamente este comportamento. Serializar as suas próprias threads escritoras continua a ser trabalho seu. Segundo, nada aqui é um lock de ficheiro ou um mutex entre processos; coordena threads dentro de um processo contra uma instância de livro, e dois processos a abrir o mesmo .xlsx não sabem nada um do outro. Terceiro, a garantia só chega aos chamadores que realmente tomam um lease — uma leitura sem lease ainda percorre um caminho quente sem lock, que é rápido e inteiramente desprotegido. Isto é um núcleo de coordenação, não uma base de dados transacional

Usado dentro desses limites é uma primitiva pequena e honesta: nove testes de regressão dedicados cobrem múltiplos leitores, ambas as direções de conflito, reentrância, libertação fora de ordem, transações abortadas, e corridas de leitura/escrita e escrita/escrita entre threads, dentro de uma suite de 1,328 testes a passar em Win32 e Win64. Emparelhe-o com o caminho de gravação em ficheiro temporário escalonado e à prova de falhas e uma exportação de fundo torna-se algo que consegue raciocinar de ponta a ponta — consistente enquanto lê, atómica quando escreve. Os leases de leitura, as guardas de escrita e o contador de geração chegam como parte dos motores clássico e de pacote no HotXLS Delphi Component para Delphi e C++Builder, sem configuração necessária para os ativar