מי שעובד ב- Delphi עם Threads יגיע מוקדם או מאוחר ל- TThread.Synchronize. ובדיוק שם מתרחשות הבעיות המטרידות: קפיאות ספוראדיות, „ה־UI לא מגיב“, דדלוקים שנראים אקראיים בעת סגירה או פתיחת דיאלוג. הגרעין נדיר שהוא „Delphi שבור“, ורוב הזמן מדובר בשילוב בלתי־מועיל של Synchronize, פעולות המתנה חוסמות ו־UI‑Thread שאינו מעבד כראוי את הלולאת ההודעות (עיבוד האירועים של VCL). מאמר זה מציג דפוסים חסינים, ישימים בהקשר לֶגֶסי, עבור TThread ו‑Synchronize ללא דדלוקים ב‑UI — כולל גרסת Timeout, העברת שגיאות מסודרת, כללי Shutdown והערות לדיבאג שמסייעות באפליקציות קיימות אמיתיות.
מדוע דדלוקים סביב Synchronize מתרחשים בפועל
Synchronize משמעותו: Worker‑Thread שם פרוצדורה בתור שמבוצעת ב‑Main Thread, וממתין בדרך כלל עד שהפרוצדורה תסתיים. ביישומי VCL ה‑Main Thread הוא גם ה‑UI‑Thread (חלונות, Controls, אירועים). בנוסף, בהרבה התקנות רצות שם עצמים COM במודל ה‑STA‑Modell (Single‑Threaded Apartment: קריאות COM חייבות להיות מעובדות באותו Thread), מה שמגביר עוד יותר את התלות בלולאת הודעות תקינה.
דדלוקים נובעים בדרך כלל מאחת מהתצורות הבאות:
- Keine
WaitFor-Aufrufe im UI-Thread, sobald im Worker ein Pfad existiert, derSynchronizenutzt. - Thread-Abschluss per Event/Callback signalisieren: UI bleibt responsiv, räumt erst nach Signal auf.
- UI-Updates grundsätzlich über
TThread.Queueoder einen Dispatcher posten, damit Worker nicht blockieren.
TThread.Queue ist häufig die bessere Default-Option: Der Worker postet Arbeit an den Main Thread, läuft weiter und blockiert nicht. Das verhindert viele Deadlocks. Es löst aber nicht alle Randfälle – etwa wenn Sie in einem Worker zwingend ein Ergebnis benötigen, das im Main Thread erzeugt wird (z. B. Zugriff auf eine UI-gebundene Ressource oder eine Komponente, die threadgebunden ist).
TThread ו-Synchronize ללא UI-Deadlocks: מודל חשיבה להעברות נקיות
מודל חשיבה עמיד הוא: קיימות רק מעט העברות סינכרוניות לגיטימיות אל ה-Main Thread. כל השאר הוא מצב, הצגה או טלמטריה — ובכך אסינכרוני.
חלוקה פשוטה עוזרת בביקורות ובייצוב של פרויקטים קיימים:
- «הצגה בלבד»: התקדמות, שורת לוג, מונה, נורת חיווי, הפעלה/השבתה — immer
Queue. - «העברת מצב»: Worker liefert Datenobjekt/DTO, UI rendert —
Queue, aber mit Copy/Immutability (also keine gemeinsam mutierten Strukturen). - «ה-UI חייב להחליט»: Nur hier brauchen Sie synchrone Semantik (z. B. Benutzerabfrage). Dann ist die eigentliche Frage: Muss wirklich ein Worker warten, oder kann der Workflow umgebaut werden (State Machine, Job abbrechen, später fortsetzen)?
במיוחד הקטגוריה השלישית היא מלכודת für Deadlocks: Wenn der Worker auf ein UI-Ergebnis wartet, wird die UI schnell dazu verleitet, auf den Worker zu warten (oder indirekt über Locks). Das kippt unter Last, bei langsamen Datenbanken oder bei Remote-Desktop-Umgebungen deutlich eher.
Source-Schnipsel: UI-Dispatcher mit Queue, optionalem Timeout und sauberem Shutdown
התבנית הבאה עוטפת העברות ל-UI בתוך מחלקת עזר קטנה. Sie bekommen:
- Post: Fire-and-forget über
TThread.Queue(typisch für Statusupdates). - Call: Synchronous Call mit Timeout (ungewöhnlich, aber in Legacy-Situationen hilfreich), ohne direkt
Synchronizeals Blockadepunkt zu verwenden. - Shutdown-Schutz: Keine neuen UI-Jobs mehr annehmen, und queued Jobs prüfen einen Flag, bevor Controls angefasst werden.
Technische Einordnung: Wir nutzen Queue plus TEvent (ein Kernel-Event) zur Rückmeldung. Der Worker wartet nicht auf Synchronize, sondern auf ein Event, das im Main Thread gesetzt wird, nachdem die queued Action ausgeführt wurde. Das Timeout verhindert „ewiges“ Hängen, wenn der UI-Thread aus irgendeinem Grund nicht mehr zum Abarbeiten kommt.
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.מטרת הקוד והיכן שבכוונה הוא ‚לא שגרתי‘
התבנית אינה מחליפה את Synchronize לחלוטין, אך היא הופכת העברות סינכרוניות לניתנות לבקרה: ה-Worker אינו מחכה למכניקת ה-Synchronize, אלא לאירוע. כך ניתן לכפות מגבלות זמן (timeout), להראות בזמן הריצה שה-UI-Thread תקוע, ובשלב Shutdown לדחות בעקביות משימות UI חדשות.
החלק „לא שגרתי“ אינו האירוע עצמו, אלא ההחלטה לממש סמנטיקה סינכרונית באמצעות Queue + Event. זה משתלם בדיוק כאשר יש צורך לשדרג בהדרגה יציבות ביישומים קיימים, בלי היכולת לשנות מיד את כל המקומות שבהם קוראים ל-Synchronize במישור הארכיטקטוני.
תנאים ומלכודות
- נראות בזיכרון:
DoneEventמהווה את נקודת הסנכרון. בכך קריאתRaisedObjאחריWaitForתהיה עקבית. עם זאת, יש להשאיר אתRaisedObjמקומי לכל קריאה (כמו כאן), ולא גלובלי.
AcquireExceptionObject מונע שה־Exception „ייעלם“ בשרשור הראשי. בעת ההשלכה המחודשת בוורקר ה־Stacktrace אינו זהה למקור, אך הודעת השגיאה נשארת בלוג של הוורקר, והמשימה יכולה להיכשל באופן מסודר.BeginShutdown צריך להיות בסדרת כיבוי מרכזית (למשל מוקדם מאוד ב־OnCloseQuery של הטופס הראשי). אחרת ייתכן שיוכנסו עדיין UI‑Jobs לתור בעוד החלונות כבר נהרסו.אסטרטגיית נעילה: כך תימנעו היפוכי נעילות עם קריאות חזרה ל‑UI
רבים מה־deadlock אינם נגרמים מ־WaitFor, אלא מסדר נעילות בלתי ברור. רצף טיפוסי: הוורקר נועל את „מודל הנתונים“, קורא לעדכון UI באמצעות Synchronize, העדכון של ה־UI ניגש שוב ל“מודל הנתונים“. זה מובן לוגית, אבל קטלני מבחינה טכנית.
כללים פרקטיים שניתן לאכוף בצוותים:
- אל תחזיקו נעילות מעבר לגבול השרשורים: לפני שוורקר ממקם משהו בתור או מסנכרן לכיוון ה‑UI, יש לשחרר את הנעילות הרלוונטיות.
- ה־UI קורא תמונות מצב (Snapshots): קריאות חזרה ל־UI לא צריכות להציץ „בחי“ במבני הוורקר, אלא להציג עותקים/סנאפשוטים (למשל DTO, Record, ערכים פשוטים).
- לוגינג הוא מועמד לנעילה: אם הלוגינג משתמש פנימית בתור, נעילת קובץ או Singleton, הוא עלול להפוך לחלק מ־deadlock. קריאות חזרה ל־UI צריכות למזער לוגינג או לכתוב דרך צנרת לוגים נפרדת ולא חוסמת.
אם כבר יש לכם ארכיטקטורת Layer-3 (UI, שירותים/דומיין, תשתית כמו גישת נתונים): קריאות חזרה ל‑UI רצוי שיעסקו רק ב־UI. כל מה שהוא „Service“ לא שייך לקריאת החזרה. זה מצמצם במידה ניכרת השפעות כניסה חוזרת (reentrancy).
כיבוי בלי תקיעות: „לא WaitFor, אלא עצירה שיתופית“
בעת סגירה זה נוטה לקרוס: ה‑UI נסגר, שרשור אמור להסתיים, אבל יש עדיין UI‑Jobs בתור. כיבוי מסודר הוא פחות „להרוג“ את השרשור, אלא כוריאוגרפיה קטנה:
- הגדרת דגל כיבוי (למשל
TUiDispatcher.BeginShutdown): מעתה אין עוד UI‑Jobs חדשים. - עצירה שיתופית של הוורקר: הוורקר בודק דגל ביטול (למשל
TEventאו משהו בדומה ל־TCancellationToken) ומסיים לולאות/המתנות. - לא לחסום את ה‑UI: אין לנהל לולאת המתנה נוקשה בשרשור הראשי. אם אתם „נדרשים להמתין“, עשו זאת רק תוך המשך ריצת לולאת הודעות (או עדיף: הימנעו כלל על ידי טיפול בסיום דרך callback).
- עבודות ניקוי UI אחרונות רק אם החלונות/ה־Controls קיימים באופן מובטח. ב‑VCL המועד קריטי: לא יאוחר מהמועד שבו ה‑Handle נעלם, אסור שמשימות UI בתור יפנו עוד ל־Controls.
מהלך זה רלוונטי לתפעול ותמיכה: „היישום תקוע בזמן סגירה“ הוא בעיית קבלה טיפוסית, אף על פי שכל העיבוד התפקודי בוצע כראוי. כיבוי מוגדר יחסוך כאן זמן במציאות.
דיבוג: איך להמחיש את ה־deadlock (ללא ניחושים)
כשזה נתקע, השאלה המרכזית היא: מי מחכה למי? כמה גישות שהתבררו כמועילות בפרויקטים קיימים:
- כל נקודות ה-Wait לרשום: חיפוש טקסט מלא אחרי
WaitFor,Sleepבלולאות,TEvent.WaitFor,INFINITE. בעיות רבות הן „Waits“ מוסתרים (גם בספריות). - מצב ה-Thread בלוג: רישום בלבולות ה-thread: „Job startet“, „queued UI“, „UI ausgeführt“, „Job fertig“. כך תראו האם ה-Main Thread בכלל מעבד Jobs שהוכנסו ל-queue.
- לבדוק חשד ל-Message-Loop: אם הקיפאון מופיע רק בדיאלוגים מודאליים או באינטראקציות COM מסוימות, ה-Message Loop לעתים קרובות הוא צוואר הבקבוק. המטרה אז: להקל על UI-Handler, לבודד קריאות COM, שלא להריץ פעולות ארוכות ב-UI.
- להפוך Locks לנראים: עבור
TCriticalSection/TMonitorמשתלם להפעיל Debug-Build עם מטא־נתוני „Owner“ (למשל Thread-ID בעת Enter) ומדידת זמנים. כך תראו איזה Lock ה-Main Thread מחזיק כרגע בזמן שה-Worker ממתין ל-UI.
חשוב בגישה: Deadlocks נדירים כ“אקראיים“. הם ציקליים ודטרמיניסטיים, ומופעלים לעיתים רחוקות. כשאתם מזהים את המחזור בצורה נקייה, פתרון הבעיה בדרך כלל ברור.
גרסאות לגישה לנתונים ולעבודות ממשק (FireDAC, REST, Dateisystem)
במיוחד ב-FireDAC (או בגישות DB אחרות) חלים הכללים: חיבור, טרנזקציה ו-Datasets הם בפועל תלויי-thread. Worker-Thread צריך להחזיק בבסיס הנתונים שלו בלבד. קריאות UI צריכות להסתפק בהצגה ולא לבצע פעולות DB. תבנית יציבה היא:
- ה-Worker מבצע Query/REST-קריאה, מחשב תוצאה ויוצר DTO.
- ה-Worker מפרסם DTO דרך
Queue/TUiDispatcher.Postל-UI. - ה-UI מקבל את ה-DTO ומעדכן Controls (בלי להישען על אובייקטי ה-Worker).
אם קיימות צורות מעורבות שהתפתחו היסטורית („UI מפעיל DB, Callback מה-DB מפעיל UI“), משתלמת הפרדה הדרגתית: תחילה לבודד נקודות העברה (Dispatcher), ואז להעביר מצבים ל-Services/Model. זה פחות מסוכן מאיתחול גדול, אבל מצמצם Deadlocks בצורה ניכרת.
מסקנה: הימנעות מ-Deadlocks פירושה שליטה על ההעברות
TThread und Synchronize ohne UI-Deadlocks היא פחות טכניקה בודדת ויותר משמעת: לצמצם חסימות, לשמור על סדר Locks נקי, להגדיר Shutdown ולהקטין תלותות סינכרוניות ב-UI. ה-UI-Dispatcher שהוצג שימושי במיוחד במצבי Legacy, שכן הוא מנצל את Queue כברירת מחדל, ועבור העברות סינכרוניות נחוצות מוסיף Timeout וכללי Shutdown ברורים.
יש מגבלות: אם ה-Main Thread נחסם באופן ממושך (עקב לוגיקת UI כבדה, שרשראות דיאלוגים מודאליים או קריאות COM-STA), גם Dispatcher יכול רק לאבחן ולבצע עצירה מבוקרת. הפתרון הבר־קיימא הוא להקל על ה-UI ולפצל אחריות. אם אתם זקוקים לתמיכה בכך ביישום קיים של Delphi – ממלכודות Threading ועד לייצוב הדרגתי – תוכלו לסווג את המיזם כאן: לדון בפרויקט או במיזם מודרניזציה עם Net-Base.
בהקשר המקצועי גם Delphi Multithreading ו-Synchronize Deadlock משחקים תפקיד חשוב, כאשר אינטגרציות, זרימות נתונים והתפתחות נוספת צריכים לפעול יחד בצורה מסודרת.