Net-Base Revista

15.05.2026

TThread e Synchronize sem deadlocks de UI: padrões robustos para VCL e código legado

Como trabalhar de forma confiável com TThread, Synchronize e Queue sem que a UI trave: causas típicas de deadlocks, um padrão de UI-dispatcher prático (incl. timeout), proteção contra shutdown, estratégias de bloqueio e verificações de depuração para aplicações Delphi consolidadas.

15.05.2026

Quem trabalha com Threads em Delphi acaba mais cedo ou mais tarde em TThread.Synchronize. E é exatamente aí que surgem os problemas desagradáveis: travamentos esporádicos, “UI não responde”, deadlocks aparentemente aleatórios ao encerrar ou ao abrir uma caixa de diálogo. A raiz raramente é “Delphi está quebrado”; quase sempre trata‑se de uma combinação desfavorável de Synchronize, operações de espera bloqueantes e um UI‑Thread que deixa de processar corretamente sua Message Loop (o processamento de eventos da VCL). Este artigo mostra padrões robustos e praticáveis no contexto legacy para TThread e Synchronize sem deadlocks na UI — incluindo variante com timeout, passagem de erro limpa, regras de shutdown e dicas de depuração que ajudam em aplicações de produção existentes.

Por que deadlocks em torno de Synchronize acontecem na prática

Synchronize significa: uma thread de trabalho coloca uma procedure numa fila que será executada no Main Thread e normalmente aguarda até essa procedure terminar. Em aplicações VCL o Main Thread é também o UI‑Thread (janelas, controles, eventos). Além disso, em muitas instalações há objetos COM rodando no STA‑Modell (Single‑Threaded Apartment: chamadas COM precisam ser processadas no mesmo thread), o que aumenta ainda mais a dependência de uma Message Loop funcional.

Deadlocks surgem tipicamente por uma destas constelações:

  • WaitFor no Main Thread: o UI‑Thread espera por um worker (por exemplo MyThread.WaitFor), enquanto o worker precisa do UI‑Thread via Synchronize. Ambos ficam esperando — fim de jogo.
  • Lock‑Inversion: o worker segura um lock (por exemplo TCriticalSection ou TMonitor) e chama Synchronize. A procedure sincronizada na UI tenta adquirir o mesmo lock (direta ou indiretamente, frequentemente via logging/cache/singletons) — deadlock clássico.
  • Shutdown/Destroy: ao fechar uma form uma thread é terminada enquanto ainda há chamadas Synchronize pendentes. Especialmente problemático: chamadas sincronizadas referenciam controles que estão sendo destruídos.
  • Message Loop bloqueada: diálogos modais, operações longas na UI, uma chamada COM bloqueante ou um handler que “só rapidinho” faz DB/REST prendem o Main Thread. Chamadas Synchronize são processadas tardiamente ou nem chegam a ser processadas.

A consequência arquitetural e operacional mais importante: Synchronize é uma aresta bloqueante. Em software empresarial personalizado com importações, BDE‑Ablosung com ligação nativa‑queries, jobs de interface ou serviços em background com componente UI essa aresta deve ser controlada conscientemente — caso contrário o “raro” vira “sempre que há pressa”.

Regra básica: nunca deixar o UI‑Thread esperar por um worker (quando Synchronize está em uso)

Se um worker usa Synchronize em qualquer lugar, o Main Thread não deve bloquear duramente esperando por esse worker. Isso parece trivial, mas em código legacy é uma das causas mais frequentes, porque “vamos esperar um pouco ao fechar” ou “o diálogo de progresso espera pelo fim” é inserido rapidamente.

Consequências práticas:

  • Nenhuma chamada WaitFor no UI-Thread assim que existir no Worker um caminho que utilize Synchronize.
  • Sinalizar o encerramento da thread via event/callback: a UI permanece responsiva e só realiza a limpeza após o sinal.
  • Atualizações de UI, em regra, postar via TThread.Queue ou um dispatcher, para que o Worker não bloqueie.

TThread.Queue é frequentemente a melhor opção padrão: o Worker posta trabalho para o Main Thread, continua executando e não bloqueia. Isso previne muitos deadlocks. Porém não resolve todos os casos-limite – por exemplo quando você, em um Worker, necessariamente precisa de um resultado gerado no Main Thread (p. ex. acesso a um recurso vinculado à UI ou a um componente com afinidade de thread).

TThread und Synchronize ohne UI-Deadlocks: Denkmodell für saubere Übergaben

Um modelo mental robusto é: existem poucas passagens síncronas legítimas para o Main Thread. Todo o resto é estado, apresentação ou telemetria – e, portanto, assíncrono.

Uma classificação simples ajuda em revisões e na estabilização de projetos existentes:

  • „Apenas exibir“: progresso, linha de log, contador, semáforo, habilitar/desabilitar – sempre Queue.
  • „Transferir estado“: o Worker entrega um objeto de dados/DTO, a UI renderiza – Queue, mas com cópia/imutabilidade (ou seja, sem estruturas mutadas em comum).
  • „A UI precisa decidir“: só aqui você precisa de semântica síncrona (p. ex. uma pergunta ao usuário). A questão real é: o Worker precisa realmente esperar, ou o fluxo pode ser reestruturado (máquina de estados, cancelar o job, retomar depois)?

Justamente a terceira categoria é uma armadilha de deadlock: se o Worker aguarda um resultado da UI, a UI tende a esperar pelo Worker (ou indiretamente via locks). Isso falha mais rapidamente sob carga, com bancos de dados lentos ou em ambientes de Remote Desktop.

Source-Schnipsel: UI-Dispatcher mit Queue, optionalem Timeout und sauberem Shutdown

O padrão abaixo encapsula as passagens para a UI em uma pequena classe utilitária. Você obtém:

  • Post: fire-and-forget via TThread.Queue (típico para atualizações de status).
  • Call: chamada síncrona com Timeout (incomum, mas útil em situações legacy), sem usar diretamente Synchronize como ponto de bloqueio.
  • Proteção contra shutdown: não aceitar novos UI-jobs e fazer com que jobs enfileirados verifiquem um flag antes de tocar nos controles.

Enquadramento técnico: usamos Queue mais TEvent (um evento de kernel) para feedback. O Worker não espera por Synchronize, mas por um evento que é sinalizado no Main Thread depois que a ação enfileirada for executada. O timeout evita um bloqueio “eterno” caso o UI-Thread, por algum motivo, deixe de processar as ações.

Delphi
unit UiDispatch;

interface

uses
  System.SysUtils,
  System.Classes,
  System.SyncObjs;

type
  EUiDispatchTimeout = class(Exception);
  EUiDispatchShuttingDown = class(Exception);

  /// <summary>
  ///  Kapselt UI-Aufrufe aus Worker-Threads.
  ///  Post: asynchron (Queue).
  ///  Call: synchron mit Timeout, ohne TThread.Synchronize direkt zu blocken.
  /// </summary>
  TUiDispatcher = class
  strict private
    class var FShuttingDown: Integer;
  public
    class procedure BeginShutdown; static;
    class function IsShuttingDown: Boolean; static;

    class procedure Post(const AProc: TProc); static;
    class procedure Call(const AProc: TProc; ATimeoutMs: Cardinal = 5000); static;
  end;

implementation

{ TUiDispatcher }

class procedure TUiDispatcher.BeginShutdown;
begin
  TInterlocked.Exchange(FShuttingDown, 1);
end;

class function TUiDispatcher.IsShuttingDown: Boolean;
begin
  Result := TInterlocked.CompareExchange(FShuttingDown, 0, 0) = 1;
end;

class procedure TUiDispatcher.Post(const AProc: TProc);
begin
  if not Assigned(AProc) then
    Exit;

  // Im Shutdown keine neuen UI-Jobs mehr annehmen.
  if IsShuttingDown then
    Exit;

  // Queue blockiert den Worker nicht.
  TThread.Queue(nil,
    procedure
    begin
      if IsShuttingDown then
        Exit;
      AProc();
    end);
end;

class procedure TUiDispatcher.Call(const AProc: TProc; ATimeoutMs: Cardinal);
var
  DoneEvent: TEvent;
  RaisedObj: TObject;
begin
  if not Assigned(AProc) then
    Exit;

  if IsShuttingDown then
    raise EUiDispatchShuttingDown.Create('UI-Dispatcher ist im Shutdown.');

  DoneEvent := TEvent.Create(nil, True, False, '');
  try
    RaisedObj := nil;

    TThread.Queue(nil,
      procedure
      begin
        try
          if not IsShuttingDown then
            AProc();
        except
          // Exception-Objekt über die Thread-Grenze reichen.
          // Achtung: Kein "raise" hier, sonst landet es im Main Thread.
          RaisedObj := AcquireExceptionObject;
        end;
        DoneEvent.SetEvent;
      end);

    case DoneEvent.WaitFor(ATimeoutMs) of
      wrSignaled:
        begin
          if Assigned(RaisedObj) then
            raise Exception(RaisedObj);
        end;
      wrTimeout:
        raise EUiDispatchTimeout.CreateFmt(
          'Timeout nach %d ms: Main Thread hat UI-Aufruf nicht abgearbeitet.',
          [ATimeoutMs]);
    else
      raise Exception.Create('Unerwarteter WaitFor-Status im UI-Dispatcher.');
    end;
  finally
    DoneEvent.Free;
  end;
end;

end.

Objetivo do código e onde ele é deliberadamente „incomum“

O padrão não substitui completamente o Synchronize, mas torna as chamadas síncronas controláveis: o worker não espera pelo mecanismo de Synchronize, mas por um evento. Isso permite impor timeouts, detectar em operação que a thread de UI está travada e recusar de forma consistente novos jobs de UI durante a fase de shutdown.

A parte „incomum“ não é o evento, mas a decisão de representar a semântica síncrona com Queue + Event. Isso vale a pena precisamente quando é necessário melhorar a estabilidade gradualmente em aplicações legadas, sem ter que refatorar arquitetonicamente todos os pontos que usam Synchronize de imediato.

Condições e armadilhas

  • Visibilidade de memória: DoneEvent é a borda de sincronização. Isso garante que a leitura de RaisedObj após WaitFor seja consistente. Ainda assim, RaisedObj deve permanecer local por chamada (como aqui), nunca global.
  • Tratamento de exceções: AcquireExceptionObject impede que a exceção „desapareça“ no thread principal. Ao relançar no Worker, o stacktrace não é idêntico à origem, mas a mensagem de erro permanece no log do Worker, e o job pode falhar de forma limpa.
  • Timeout é diagnóstico e proteção: Ele não „repara“ um thread principal bloqueado. Mas evita que Workers prendam recursos indefinidamente (p.ex., mantenham transações BDE-Ablosung mit nativer Anbindung abertas), e torna a classe de erro mensurável.
  • Shutdown deve começar cedo: BeginShutdown pertence a uma sequência central de shutdown (p.ex., bem cedo em OnCloseQuery do formulário principal). Caso contrário, ainda serão enfileirados UI-jobs enquanto janelas já estiverem destruídas.
  • Estratégia de bloqueios: como evitar inversões de bloqueio com callbacks de UI

    Muitos deadlocks não surgem por WaitFor, mas por uma ordem de bloqueios pouco clara. Fluxo típico: o Worker bloqueia o „modelo de dados“, chama a atualização de UI via Synchronize, a atualização de UI acessa novamente o „modelo de dados“. Isso é logicamente compreensível, mas tecnicamente fatal.

    Regras práticas que podem ser adotadas por equipes:

    • Não manter bloqueios além dos limites de thread: Antes de um Worker enfileirar/sincronizar algo em direção à UI, os bloqueios de domínio devem ser liberados.
    • UI lê snapshots: Callbacks de UI não devem olhar „ao vivo“ para estruturas do Worker, mas exibir cópias/snapshots (p.ex., DTO, Record, valores simples).
    • Logging é um candidato a bloqueio: Se o logging usa internamente uma fila, bloqueio de arquivo ou um singleton, pode fazer parte de um deadlock. Callbacks de UI devem manter o logging ao mínimo ou escrever por meio de uma pipeline de log separada e não bloqueante.

    Se você já tem uma arquitetura Layer-3 (UI, Services/Domäne, infraestrutura como acesso a dados): callbacks de UI idealmente devem fazer apenas UI. Tudo o que for „Service“ não pertence ao callback. Isso reduz significativamente efeitos de reentrância.

    Shutdown sem travamentos: „não WaitFor, mas parada cooperativa“

    No encerramento costuma dar problema: a UI fecha, um thread deve encerrar, mas UI-jobs enfileirados ainda estão abertos. Um shutdown limpo é menos „matar threads“ e mais uma pequena coreografia:

    1. Definir flag de shutdown (p.ex., TUiDispatcher.BeginShutdown): A partir de agora, nenhum novo UI-job deve ser aceito.
    2. Parar os Workers de forma cooperativa: O Worker verifica um cancel-flag (p.ex., TEvent ou similar a TCancellationToken) e encerra loops/esperas.
    3. Não bloquear a UI: Nenhum loop duro de espera no thread principal. Se precisar „esperar“, faça-o apenas com a message loop em funcionamento (ou melhor: evitar totalmente, tratando a conclusão via callback).
    4. Últimas limpezas da UI somente se janelas/controles garantidamente ainda existirem. Na VCL o timing é importante: assim que o handle sumir, jobs enfileirados não devem mais acessar controles.

    Esse procedimento é relevante para operação e suporte: „A aplicação trava ao fechar“ é um problema clássico de aceitação, embora funcionalmente tudo tenha sido processado corretamente. Um shutdown definido economiza tempo real aqui.

    Depuração: Como tornar o deadlock palpável (sem adivinhações)

    Quando há um bloqueio, a pergunta central é: Quem está esperando em quem? Algumas abordagens que se mostraram eficazes em projetos existentes:

    • Inventariar todos os pontos de espera: Pesquisa em texto completo por WaitFor, Sleep em loops, TEvent.WaitFor, INFINITE. Muitos problemas são esperas ocultas (também em bibliotecas).
    • Estado da thread no log: Registre nos limites de thread: „Job iniciado“, „UI enfileirada“, „UI executada“, „Job concluído“. Assim você vê se o thread principal realmente processa jobs enfileirados.
    • Verificar suspeita da Message Loop: Se o travamento ocorre apenas em diálogos modais ou em certas interações COM, a Message Loop costuma ser o gargalo. O objetivo então é: aliviar os manipuladores de UI, isolar chamadas COM, não executar operações longas na UI.
    • Tornar locks visíveis: Para TCriticalSection/TMonitor vale a pena um build de debug com metadados de “Owner” (p. ex. ID da thread no Enter) e medição temporal. Assim você vê qual lock o thread principal mantém enquanto os threads de trabalho aguardam pela UI.

    O importante é a postura: deadlocks raramente são “acidentais”. São ciclos determinísticos que raramente são desencadeados. Uma vez que você tenha identificado o ciclo de forma limpa, a correção normalmente fica clara.

    Variantes para acesso a dados e jobs de interface (FireDAC, REST, sistema de arquivos)

    Especialmente com FireDAC (ou outros acessos a DB) aplica-se: conexão, transação e datasets são na prática vinculados à thread. Um thread de trabalho deve possuir exclusivamente seu próprio contexto de DB. Chamadas da UI devem se limitar à apresentação, não a operações de DB. Um padrão robusto é:

    1. O thread de trabalho executa a query/chamada REST, calcula o resultado e gera um DTO.
    2. O thread de trabalho posta o DTO via Queue/TUiDispatcher.Post para a UI.
    3. A UI assume o DTO e atualiza os controles (sem recorrer a objetos do thread de trabalho).

    Se você tem formas mistas herdadas („UI aciona DB, callback do DB aciona UI“), vale a pena uma desacoplagem gradual: primeiro isolar pontos de passagem (Dispatcher), depois deslocar estados para Services/Model. Isso é menos arriscado que uma grande reestruturação, mas reduz deadlocks de forma perceptível.

    Conclusão: Evitar deadlocks significa controlar as transferências

    TThread e Synchronize sem deadlocks de UI é menos uma técnica isolada do que uma disciplina: minimizar bloqueios, manter a ordem de locks limpa, definir o shutdown e reduzir dependências síncronas da UI. O UI-Dispatcher mostrado é particularmente útil em cenários legados, porque usa Queue como padrão, mas adiciona Timeout e regras claras de shutdown para transferências síncronas necessárias.

    Existem limites de aplicação: se o thread principal ficar bloqueado de forma persistente (por lógica pesada de UI, cadeias de diálogos modais ou chamadas COM-STA), um Dispatcher apenas consegue diagnosticar e abortar de forma controlada. A solução sustentável é então aliviar a UI e separar responsabilidades. Se precisar de suporte para isso em uma aplicação existente Delphi – desde armadilhas de threading até estabilização gradual – você pode enquadrar o projeto aqui: discutir projeto ou iniciativa de modernização com Net-Base.

    No contexto técnico, Delphi Multithreading e deadlocks por Synchronize também desempenham papel importante quando integrações, fluxos de dados e evolução precisam atuar de forma coordenada.

    Discutir projeto ou iniciativa de modernização com Net-Base.

    Partilhar publicação

    Compartilhar esta publicação diretamente

    LinkedIn, X, XING, Facebook, WhatsApp e e‑mail estão imediatamente disponíveis. Para o Instagram, preparamos o link e um texto curto de imediato.

    E-mail

    O Instagram abre numa nova aba. O link e o texto curto são copiados previamente para a área de transferência.