Quien en Delphi trabaja con hilos, tarde o temprano llega a TThread.Synchronize. Y es precisamente ahí donde ocurren los problemas: bloqueos esporádicos, «la UI no responde», deadlocks aparentemente aleatorios al cerrar o al abrir un diálogo. La causa rara vez es «Delphi está roto», sino casi siempre una combinación desfavorable de Synchronize, operaciones de espera bloqueantes y un hilo de la UI que deja de procesar correctamente su bucle de mensajes (el procesamiento de eventos de la VCL). Esta entrada muestra patrones robustos y prácticos en contextos legacy para TThread y Synchronize sin deadlocks en la UI — incluyendo una variante con timeout, una propagación limpia de errores, reglas de shutdown y pistas de depuración que ayudan en aplicaciones heredadas reales.
Por qué se generan deadlocks alrededor de Synchronize en la práctica
Synchronize significa: un worker encola un procedimiento que se ejecuta en el hilo principal y normalmente espera hasta que dicho procedimiento termine. En aplicaciones VCL el hilo principal es a la vez el hilo de la UI (ventanas, controles, eventos). Además, en muchas instalaciones allí se ejecutan objetos COM en el modelo STA (Single-Threaded Apartment: las llamadas COM deben procesarse en el mismo hilo), lo que refuerza la dependencia de un bucle de mensajes que funcione.
Los deadlocks suelen surgir por una de estas constelaciones:
- WaitFor en el hilo principal: el hilo de la UI espera a un worker (p. ej.
MyThread.WaitFor), mientras el worker está usandoSynchronizey necesita el hilo de la UI. Ambos esperan — fin. - Lock-Inversion: el worker mantiene un lock (p. ej.
TCriticalSectionoTMonitor) y llama aSynchronize. El procedimiento sincronizado en la UI intenta tomar el mismo lock (directa o indirectamente, a menudo vía logging/caché/singletons) — deadlock clásico. - Shutdown/Destroy: al cerrar un formulario se termina un hilo mientras aún hay tareas
Synchronizependientes. Especialmente problemático: llamadas sincronizadas que referencian controles que justo se están destruyendo. - Bucle de mensajes bloqueado: diálogos modales, operaciones largas en la UI, una llamada COM bloqueante o un manejador que «malamente» hace DB/REST mantienen bloqueado el hilo principal. Las tareas
Synchronizese procesan con retraso o no se procesan en absoluto.
La consecuencia más importante para arquitectura y explotación: Synchronize es un punto de bloqueo. En software empresarial a medida con importaciones, BDE-Ablosung mit nativer Anbindung-consultas, trabajos de integración o servicios en segundo plano con componente de UI, este punto debe controlarse conscientemente — si no, de «raro» acabará siendo «siempre cuando hay prisa».
Regla básica: nunca dejar que el hilo de la UI espere a un worker (cuando Synchronize está en juego)
Si un worker usa en algún lugar Synchronize, el hilo principal no debe esperar de forma bloqueante a ese worker. Parece trivial, pero en código legacy es una de las causas más frecuentes, porque «esperemos un momento al cierre» o «el diálogo de progreso espera a que termine» se añaden rápidamente.
Consecuencias prácticas:
- No realizar llamadas
WaitForen el hilo de la UI, tan pronto como exista en el Worker una vía que utiliceSynchronize. - Señalizar la finalización del hilo mediante Event/Callback: la UI permanece responsiva y solo realiza la limpieza tras la señal.
- Realizar actualizaciones de la UI siempre mediante
TThread.Queueo un Dispatcher, para que los Worker no queden bloqueados.
TThread.Queue suele ser la opción predeterminada preferible: el Worker publica trabajo en el Main Thread, continúa ejecutándose y no se bloquea. Eso evita muchos deadlocks. No resuelve todos los casos límite – por ejemplo, cuando en un Worker necesita obligatoriamente un resultado que se genera en el Main Thread (p. ej., acceso a un recurso ligado a la UI o a un componente que depende del hilo).
TThread y Synchronize sin deadlocks en la UI: modelo mental para transferencias limpias
Un modelo mental robusto es: sólo hay unas pocas transferencias síncronas legítimas al Main Thread. Todo lo demás es estado, representación o telemetría – y por tanto asíncrono.
Una clasificación simple ayuda en las revisiones y en la estabilización de proyectos existentes:
- «Solo mostrar»: progreso, línea de log, contador, semáforo, habilitar/deshabilitar – siempre
Queue. - «Transferir estado»: el Worker entrega un objeto de datos/DTO, la UI renderiza –
Queue, pero con copia/inmutabilidad (es decir, sin estructuras mutadas en común). - «La UI debe decidir»: sólo aquí necesita semántica síncrona (p. ej. consulta al usuario). Entonces la cuestión real es: ¿debe realmente esperar un Worker, o se puede reestructurar el flujo de trabajo (máquina de estados, cancelar el job, reanudar más tarde)?
La tercera categoría es especialmente una trampa de deadlocks: si el Worker espera un resultado de la UI, la UI suele verse tentada a esperar al Worker (o, de forma indirecta, mediante locks). Esto falla con más facilidad bajo carga, con bases de datos lentas o en entornos de escritorio remoto.
Fragmento de código: UI-Dispatcher con Queue, timeout opcional y apagado limpio
El siguiente patrón encapsula las transferencias a la UI en una pequeña clase auxiliar. Obtendrá:
- Post: fire-and-forget mediante
TThread.Queue(típico para actualizaciones de estado). - Call: llamada síncrona con Timeout (poco habitual, pero útil en situaciones legacy), sin usar directamente
Synchronizecomo punto de bloqueo. - Protección en el shutdown: no aceptar más UI-jobs, y los jobs en cola comprueban un flag antes de manipular controles.
Clasificación técnica: usamos Queue más TEvent (un Kernel-Event) para la retroalimentación. El Worker no espera a Synchronize, sino a un event que se establece en el Main Thread después de que la acción en cola se haya ejecutado. El timeout evita quedarse «colgado» indefinidamente si, por alguna razón, el hilo de la UI deja de procesar.
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.Propósito del código y dónde es deliberadamente „inusual“
El patrón no reemplaza completamente a Synchronize, pero hace que las transferencias sincrónicas sean controlables: el worker no espera a la mecánica de Synchronize, sino a un evento. Con ello puede imponer timeouts, dejar visible en operación que el hilo de UI está bloqueado, y rechazar de manera consistente nuevos trabajos de UI durante la fase de apagado.
La parte „inusual“ no es el evento, sino la decisión de representar la semántica sincrónica con Queue + Event. Esto tiene sentido precisamente cuando necesita reforzar gradualmente la estabilidad en aplicaciones existentes sin poder reestructurar arquitectónicamente cada punto de Synchronize de inmediato.
Condiciones y riesgos
- Visibilidad de memoria:
DoneEventes la frontera de sincronización. De este modo la lectura deRaisedObjtrasWaitFores consistente. Aun así,RaisedObjdebe permanecer local por cada llamada (como aquí), nunca global.
AcquireExceptionObject evita que la excepción „desaparezca“ en el hilo principal. Al relanzarla en el Worker, el stacktrace no es idéntico al origen, pero el mensaje de error permanece en el log del Worker y el job puede fallar de forma limpia.BeginShutdown debe formar parte de una secuencia central de apagado (p. ej. muy pronto en OnCloseQuery del formulario principal). Si no, se seguirán encolando jobs de UI mientras las ventanas ya se están destruyendo.Estrategia de locks: cómo evitar inversiones de bloqueo con callbacks de UI
Muchos deadlocks no se deben a WaitFor, sino a un orden de locks poco claro. Secuencia típica: el Worker bloquea el „modelo de datos“, llama a una actualización de UI mediante Synchronize, y la actualización de UI vuelve a acceder al „modelo de datos“. Lógicamente comprensible, pero técnicamente fatal.
Reglas prácticas que funcionan en equipos:
- No mantener locks a través de fronteras de hilos: Antes de que un Worker encole/sincronice cualquier cosa hacia la UI, los locks de dominio deben estar liberados.
- La UI lee snapshots: Los callbacks de UI no deben inspeccionar en „vivo“ las estructuras del Worker; deben mostrar copias/snapshots (p. ej. DTO, Record, valores simples).
- El logging puede ser candidato a lock: Si el logging usa internamente una cola, un file-lock o un singleton, puede formar parte de un deadlock. Los callbacks de UI deberían minimizar el logging o escribir a través de una pipeline de logs separada y no bloqueante.
Si ya dispone de una arquitectura Layer-3 (UI, servicios/dominio, infraestructura como acceso a datos): idealmente los callbacks de UI solo deben encargarse de la UI. Todo lo que sea „servicio“ no pertenece al callback. Esto reduce notablemente los efectos de reentrancia.
Shutdown sin bloqueos: «no WaitFor, sino parada cooperativa»
Al cerrar suele fallar: la UI se cierra, un hilo debe terminar, pero hay jobs de UI encolados. Un shutdown ordenado no es „matar hilos“, sino una pequeña coreografía:
- Marcar flag de shutdown (p. ej.
TUiDispatcher.BeginShutdown): a partir de ahora no se aceptan nuevos jobs de UI. - Parar los Workers de forma cooperativa: el Worker comprueba un flag de cancelación (p. ej.
TEvento similar aTCancellationToken) y termina bucles/esperas. - No bloquear la UI: evitar bucles de espera rígidos en el hilo principal. Si debe „esperar“, háganlo con el message loop en marcha (o mejor: evitarlo pasando la finalización por callback).
- Últimas tareas de limpieza en la UI solo si las ventanas/controles existen con seguridad. En VCL el momento es crítico: como muy tarde, cuando el handle ya no existe, los jobs encolados no deben dirigirse a controls.
Este procedimiento es relevante para operación y soporte: „la aplicación se queda colgada al cerrar“ es un problema clásico de aceptación, aunque técnicamente todo se haya procesado correctamente. Un shutdown definido ahorra tiempo real.
Depuración: cómo hacer tangible el deadlock (sin adivinar)
Cuando hay un bloqueo, la pregunta central es: ¿quién espera a quién? Algunos enfoques probados en proyectos existentes:
- Inventariar todos los puntos de espera: búsqueda de texto completo de
WaitFor,Sleepen bucles,TEvent.WaitFor,INFINITE. Muchos problemas son «esperas» ocultas (también en bibliotecas). - Estado del hilo en el log: registre en los límites de hilo: «Job iniciado», «UI encolada», «UI ejecutada», «Job finalizado». Así verá si el hilo principal procesa siquiera los trabajos encolados.
- Comprobar sospecha de Message Loop: si el bloqueo ocurre solo con diálogos modales o ciertas interacciones COM, el bucle de mensajes suele ser el cuello de botella. Entonces el objetivo es: aligerar los handlers de UI, aislar las llamadas COM y no ejecutar operaciones largas en la UI.
- Hacer visibles los locks: con
TCriticalSection/TMonitorconviene un build de depuración con metadatos de «Owner» (p. ej. ID de hilo al entrar) y medición temporal. Así verá qué lock mantiene el hilo principal mientras los workers esperan a la UI.
Lo importante es la actitud: los deadlocks rara vez son «accidentales». Son ciclos deterministas que solo se desencadenan en condiciones concretas. Una vez que identifique claramente el ciclo, la corrección suele ser evidente.
Variantes para el acceso a datos y los jobs de interfaz (FireDAC, REST, sistema de archivos)
Especialmente con FireDAC (u otros accesos a BD) se cumple: conexión, transacción y datasets están en la práctica ligados al hilo. Un hilo worker debe poseer su contexto de BD de forma exclusiva. Las llamadas desde la UI deben limitarse a la representación, no a operaciones de BD. Un patrón robusto es:
- El worker ejecuta la consulta/llamada
REST, calcula el resultado y genera el DTO. - El worker envía el DTO vía
Queue/TUiDispatcher.Posta la UI. - La UI recibe el DTO y actualiza los controles (sin recurrir a objetos del worker).
Si tiene mezclas heredadas («UI dispara BD, callback de BD dispara UI»), conviene una desconexión gradual: primero aislar los puntos de transferencia (Dispatcher), luego trasladar estados a servicios/modelo. Esto es menos arriesgado que una reestructuración amplia, pero reduce los deadlocks de forma perceptible.
Conclusión: evitar deadlocks significa controlar los traspasos
TThread y Synchronize sin UI-deadlocks no es tanto una técnica aislada como una disciplina: minimizar bloqueos, mantener orden de locks limpio, definir el shutdown y reducir dependencias sincrónicas hacia la UI. El UI-Dispatcher mostrado es especialmente útil en situaciones legacy porque usa Queue por defecto, pero para las transferencias sincrónicas necesarias añade Timeout y reglas claras de shutdown.
Permanece un límite de uso: si el hilo principal está bloqueado de forma permanente (por lógica UI pesada, cadenas de diálogos modales o llamadas COM-STA), un dispatcher solo puede diagnosticar y abortar de forma controlada. La solución sostenible es descargar la UI y separar responsabilidades. Si necesita apoyo en una aplicación Delphi existente —desde trampas de threading hasta estabilización gradual— puede encuadrar el proyecto aquí: discutir proyecto o iniciativa de modernización con Net-Base.
En el ámbito profesional también cobran importancia el Multithreading Delphi y los Synchronize Deadlock cuando integraciones, flujos de datos y evolución deben operar de forma coordinada.
Discutir proyecto o iniciativa de modernización con Net-Base.