O HotXLS vigia um workbook em disco com o TXLSFileMonitor e dispara OnFileChanged quando outro processo grava por cima do ficheiro. Definir Monitor.Active := False a partir de dentro desse handler costumava encravar para sempre, porque a worker thread continuava bloqueada à espera do callback que acabara de despachar. A correção é deixar o objeto da thread carregar a identidade do callback que está a correr nesse momento
Porque é que parar um file monitor dentro do seu próprio handler encrava?
Porque a chamada de desativação e o callback estão a correr em duas threads diferentes, e cada uma está à espera de a outra terminar. O TXLSFileMonitor vem com o SynchronizeEvent a True, o que significa que a thread de polling não chama o seu handler sozinha. Chama o TThread.Synchronize, entrega o método à thread principal, e depois bloqueia até a thread principal o ter corrido e sinalizado a conclusão. É exatamente o que se quer para um handler que recarrega uma grid ou repinta um form, e é também o que torna a chamada de desativação óbvia uma armadilha
procedure TMainForm.HandleFileChanged(Sender: TObject);
begin
// Estamos na thread principal aqui, cortesia do TThread.Synchronize
FBook.LoadFromFile(FMonitor.FileName);
RefreshGrid;
// O utilizador só queria um reload, portanto deixa de vigiar
FMonitor.Active := False; // costumava nunca retornar
end;
Trace as duas stacks e o ciclo é óbvio. A thread principal está dentro de SetActive(False), que termina a worker e depois chama Worker.WaitFor para ter a certeza de que a thread saiu mesmo antes de o objeto ser libertado. A worker thread, entretanto, está estacionada dentro do TThread.Synchronize, à espera de a thread principal retornar do próprio handler que está a chamar SetActive. Nenhum dos lados se mexe. A aplicação não crasha, não levanta exceção, simplesmente deixa de repintar, que é a pior assinatura de diagnóstico que um bug pode ter
O mesmo deadlock tem uma segunda cara
Desligar a sincronização não evita o problema, apenas o realoca. Com o SynchronizeEvent a False — a escolha sensata num serviço ou numa ferramenta de consola sem message loop — o TXLSFileMonitor invoca o handler diretamente na thread de polling. O seu handler corre agora na worker, pelo que uma chamada a Monitor.Active := False a partir de dentro dele chega ao Worker.WaitFor na própria worker thread. Uma thread não pode esperar pela própria saída, portanto bloqueia até algo de fora matar o processo. As duas variantes são o mesmo bug: o SetActive(False) assumia que era sempre chamado a partir de algum sítio fora do callback. Esse pressuposto aguenta-se num botão Close ou num destrutor de form e falha no call site a que as pessoas chegam primeiro, que é o motivo por que sobreviveu tanto tempo. Qualquer componente que seja dono de uma thread e despache código de utilizador a partir dela herda o mesmo risco, e a resolução é a mesma em todos os casos — o path de teardown tem de conseguir distinguir se está a ser corrido a partir de dentro de um callback que a thread que vai fazer join ainda está a segurar
Deixar a worker thread carregar a identidade do callback
O HotXLS resolve-o ao registar, no próprio objeto da thread, qual a thread do SO que está a executar o handler naquele momento. O TXLSFileMonitorThread.FireEvent carimba o FCallbackThreadID com GetCurrentThreadId imediatamente antes de invocar o handler e limpa-o num bloco finally depois. Esse único campo chega para o path de desativação distinguir "chamado a partir de código não relacionado" de "chamado a partir do meu próprio callback", e funciona de forma idêntica nos dois modos de dispatch, porque no caso sincronizado o GetCurrentThreadId dentro do FireEvent já reporta a thread principal
procedure TXLSFileMonitorThread.FireEvent;
var
Owner: TXLSFileMonitor;
Handler: TNotifyEvent;
begin
// Um callback em fila pode ser despachado pelo WaitFor durante a desativação
if Terminated then
Exit;
Owner := FOwner;
Handler := Owner.FOnFileChanged;
if not Assigned(Handler) then
Exit;
FCallbackThreadID := GetCurrentThreadId;
try
Handler(Owner);
finally
// O handler pode ter destruído Owner, mas esta thread continua viva
FCallbackThreadID := 0;
end;
end;
O ramo de desativação do SetActive faz depois três coisas numa ordem deliberada. Desliga a thread do monitor ao limpar o FThread primeiro, para que o GetActive reporte False a partir desse instante e um Active := False reentrante dentro do mesmo handler saia imediatamente em vez de tentar derrubar a thread duas vezes. Chama o Terminate. Só depois decide como reclamar o objeto da thread
Worker := TXLSFileMonitorThread(FThread);
FThread := nil; // o monitor está inativo a partir deste ponto
Worker.Terminate;
FHasBaseline := False;
if Worker.FCallbackThreadID = GetCurrentThreadId then
// Tanto os handlers diretos como os sincronizados têm de retornar antes de a worker
// poder sair; ela liberta-se depois de o callback desenrolar
Worker.FreeOnTerminate := True
else
begin
Worker.WaitFor;
Worker.Free;
end;
Quando a identidade casa, o HotXLS nunca espera. Define FreeOnTerminate := True e retorna, deixando o handler terminar, a chamada Synchronize completar, o Execute observar Terminated, e a RTL libertar o objeto da thread assim que a stack do callback estiver totalmente desenrolada. Quando a identidade não casa — um botão Close, um destrutor, um job em segundo plano — o clássico WaitFor mais Free continua a ser o certo, porque dá uma garantia dura de que nenhum callback posterior pode disparar depois de Active := False retornar. Dois paths de saída, um campo para escolher entre eles. Uma consequência merece ser explicitada: no path de auto-libertação a worker sobrevive ao handler. Nada no caminho de retorno do FireEvent pode voltar a tocar no objeto do monitor, porque o handler tem direito a tê-lo libertado. É por isso que o FireEvent coloca Owner e Handler em locals logo à partida e toca apenas no seu próprio campo na cláusula finally. Um FOwner.Something perdido depois da chamada ao handler transformaria um deadlock corrigido num use-after-free, que é um resultado estritamente pior
Porque é que o FireEvent tem de verificar Terminated antes de fazer seja o que for?
Porque o WaitFor não é uma espera passiva. Na thread principal, a implementação de TThread.WaitFor na RTL continua a esvaziar a fila de sincronização enquanto bloqueia, precisamente para que uma thread estacionada em Synchronize possa completar e sair em vez de entrar em deadlock contra o shutdown. Isso é uma funcionalidade, e significa que um callback posto em fila microssegundos antes de chamar Active := False a partir de um botão Close ainda pode ser despachado durante o teardown, depois de o monitor já ter largado a sua referência FThread. O guard no topo do FireEvent torna esse dispatch um no-op. Sem ele, uma aplicação que fecha o seu form principal enquanto um save aterra no ficheiro vigiado pode correr um handler de reload contra um estado meio eliminado, e a access violation resultante não se parece nada com um problema de ordem de shutdown. Se o seu componente despacha trabalho através de Synchronize, a regra generaliza: todo o método sincronizado precisa de uma verificação de Terminated como primeira instrução, não porque a thread possa ter desaparecido mas porque a fila sobrevive à decisão de parar
A mesma forma de bug no path de importação CSV
A auditoria que produziu a correção do monitor encontrou um defeito irmão no importador de texto, e vale a pena lê-lo lado a lado porque o erro subjacente é idêntico: uma peça de configuração a ser consumida como se fosse estado temporário. O TXLSCSVLoader expõe uma propriedade SkipRows para as linhas de banner que ficam por cima do header real em relatórios exportados. O parser precisa de uma contagem decrescente enquanto trabalha essas linhas, e a implementação original contava para baixo no próprio campo de apoio da propriedade. Importe um ficheiro e o SkipRows tornou-se silenciosamente zero; reutilize o mesmo loader para um segundo ficheiro e as linhas de banner aterraram na grid
Loader := TXLSCSVLoader.Create;
try
Loader.SkipRows := 2; // duas linhas de banner por cima do header
Loader.ParseFile('jan-export.csv');
ReadGrid(Loader);
// SkipRows ainda é 2 aqui: FSkipRemaining é reposto por parse
Loader.ParseFile('feb-export.csv');
ReadGrid(Loader);
finally
Loader.Free;
end;
O HotXLS mantém agora o FSkipRows como a definição declarada e repõe um contador FSkipRemaining separado no início de cada parse, tanto no path delimitado como no de largura fixa. A regra geral é barata de aplicar e paga-se a si própria: se um loop o decrementa, não é um campo de apoio de propriedade. A mesma leva apertou também o XlsCsvTryParseISODateTime, que estava disposto a aceitar um prefixo parsável como data completa. Agora valida o campo inteiro — comprimento, posições dos separadores e cada dígito — para que um valor como uma referência de encomenda que apenas começa com algo com forma de data continue texto em vez de se tornar um TDateTime errado. Coerção de tipo silenciosa na importação é muito mais cara de depurar a jusante do que um campo rejeitado, um tema que também percorre como o HotXLS protege um workbook contra leitores e escritores concorrentes
O que esta correção dá e não dá
Seja preciso sobre a garantia. A mudança de thread ownership torna seguro exatamente um padrão anteriormente fatal: desativar ou libertar um TXLSFileMonitor a partir de dentro do seu próprio handler OnFileChanged, em qualquer dos modos de dispatch. Não é um design de concorrência geral, e não torna o componente seguro para ser conduzido a partir de threads arbitrárias
- Os writes de propriedades continuam a não ser thread-safe. Defina
FileName,IntervalMseSynchronizeEventa partir de uma thread, normalmente a que criou o monitor - O
IntervalMsaplica-se a partir da próxima ativação, e a worker dorme em fatias curtas para que oTerminateseja honrado prontamente mesmo com um período de polling longo - A deteção é pela hora da última escrita mais o tamanho, pelo que uma alteração que preserve ambos é invisível; um ficheiro que desapareça a meio do save não dispara, e o evento chega assim que o conteúdo novo está no lugar
- Com o
SynchronizeEventaFalse, o seu handler corre na worker thread, e todas as regras sobre tocar em controlos VCL a partir de uma thread em segundo plano aplicam-se ao que fizer lá dentro - Recarregar um workbook a partir do handler continua a precisar da sua própria disciplina, especialmente se outro código tiver uma referência ao livro que está a ser substituído
Na prática, o handler de reload é a parte que merece mais cuidado, não o monitor. Um evento de alteração costuma chegar enquanto o processo que escreve ainda está a assentar, pelo que um load que abra o ficheiro imediatamente pode encontrar um save parcialmente cometido; tentar outra vez depois de um pequeno atraso é mais robusto do que confiar no primeiro carimbo. Se o seu próprio código é o que escreve o ficheiro vigiado, o ficheiro temporário em fases e o rename atómico descritos em como o HotXLS torna cada save crash-safe também dão ao monitor um evento limpo de transição única para observar em vez de um rio de tamanhos intermédios. E se vários watchers alimentam a mesma rotina de reload, as preocupações de serialização em o read gate à volta da inflação ZIP concorrente são a próxima leitura. A lição mais ampla sobrevive ao componente específico. Qualquer classe que seja dona de uma thread e chame de volta código de utilizador precisa de um path de teardown que reconheça o seu próprio frame de callback, porque o handler é o sítio mais natural do mundo para um utilizador dizer para. Registar o id da thread de callback custa um DWORD e transforma um hang inexplicável numa pergunta vulgar de tempo de vida de objeto. File monitoring, saves atómicos e importação de texto vêm todos no HotXLS Delphi Component para Delphi e C++Builder, sem configuração extra para ativar qualquer um deles