Όποιος δουλεύει με Threads στο Delphi αργά ή γρήγορα καταλήγει στο TThread.Synchronize. Εκεί εμφανίζονται τα προβλήματα: σποραδικά «παγώματα», «UI δεν αποκρίνεται», φαινομενικά τυχαία deadlocks κατά το κλείσιμο ή το άνοιγμα ενός διαλόγου. Ο πυρήνας σπάνια είναι «Delphi είναι χαλασμένο», αλλά σχεδόν πάντα ένας άστοχος συνδυασμός από Synchronize, αποκλειστικές αναμονές και ένα UI-νήμα που δεν επεξεργάζεται καθαρά τη Message Loop (την επεξεργασία γεγονότων της VCL). Αυτό το άρθρο παρουσιάζει ανθεκτικά, στο πλαίσιο legacy πρακτικά πρότυπα για TThread und Synchronize ohne UI-Deadlocks — συμπεριλαμβανομένης παραλλαγής με timeout, σωστής προώθησης σφαλμάτων, κανόνων shutdown και ενδεικτικών συμβουλών debugging που βοηθούν σε πραγματικές υπάρχουσες εφαρμογές.
Γιατί προκύπτουν στην πράξη deadlocks γύρω από το Synchronize
Synchronize σημαίνει: Ένας worker-thread τοποθετεί μια διαδικασία σε μια ουρά, που εκτελείται στο Main Thread, και περιμένει συνήθως μέχρι αυτή η διαδικασία να ολοκληρωθεί. Σε εφαρμογές VCL το Main Thread ταυτόχρονα είναι το UI-νήμα (παράθυρα, controls, γεγονότα). Επιπλέον, σε πολλές εγκαταστάσεις τρέχουν εκεί αντικείμενα COM στο STA-Modell (Single-Threaded Apartment: οι κλήσεις COM πρέπει να επεξεργάζονται στο ίδιο νήμα), κάτι που ενισχύει την εξάρτηση από μια λειτουργική Message Loop.
Deadlocks προκύπτουν συνήθως από μια από τις ακόλουθες συνθέσεις:
- WaitFor im Main Thread: Το UI-νήμα περιμένει έναν worker (π.χ.
MyThread.WaitFor), ενώ ο worker χρειάζεται αυτή τη στιγμή το UI-νήμα μέσωSynchronize. Και οι δύο περιμένουν — τέλος. - Lock-Inversion: Ο worker κρατά ένα Lock (π.χ.
TCriticalSectionήTMonitor) και καλείSynchronize. Η συγχρονισμένη UI-διαδικασία προσπαθεί να πάρει το ίδιο Lock (άμεσα ή έμμεσα, συχνά μέσω logging/cache/singletons) — κλασικό deadlock. - Shutdown/Destroy: Κατά το κλείσιμο μιας φόρμας ένας νήμα τερματίζεται ενώ υπάρχουν εκκρεμείς κλήσεις
Synchronize. Ιδιαίτερα προβληματικό: οι συγχρονισμένες κλήσεις αναφέρονται σε controls που βρίσκονται σε διαδικασία καταστροφής. - Message Loop blockiert: Μονάδες διαλόγων (modal), μακροχρόνιες UI-ενέργειες, μια αποκλειστική κλήση COM ή ένας χειριστής που «γρήγορα» εκτελεί DB/REST κρατούν δεσμευμένο το Main Thread. Οι εργασίες
Synchronizeεκτελούνται με καθυστέρηση ή καθόλου.
Η σημαντικότερη συνέπεια για την αρχιτεκτονική και τη λειτουργία: Synchronize είναι μια ακμή αποκλεισμού. Σε εξατομικευμένο λογισμικό επιχειρήσεων με imports, BDE-Ablosung mit nativer Anbindung-Queries, διεπαφές-εργασίες ή background services με συστατικό UI, αυτή η ακμή πρέπει να ελέγχεται σκόπιμα — αλλιώς το «σπάνια» θα γίνει «πάντα όταν χρειάζεται γρήγορα».
Βασικός κανόνας: Ποτέ το UI-νήμα να μην περιμένει σκληρά έναν Worker (όταν υπάρχει Synchronize)
Εάν ένας worker χρησιμοποιεί κάπου Synchronize, το Main Thread δεν πρέπει να μπλοκάρει σκληρά περιμένοντας αυτόν τον worker. Ακούγεται προφανές, αλλά σε legacy κώδικα είναι μία από τις συνηθέστερες αιτίες, επειδή «περιμένουμε λίγο στο κλείσιμο» ή «ο διάλογος προόδου περιμένει το τέλος» προστίθεται γρήγορα.
Πρακτικές συνέπειες:
- Μη χρησιμοποιείτε κλήσεις
WaitForστο UI-νήμα, εφόσον στον Worker υπάρχει διαδρομή που χρησιμοποιείSynchronize. - Σηματοδοτήστε την ολοκλήρωση του νήματος μέσω Event/Callback: το UI παραμένει ανταποκρινόμενο και κάνει τον καθαρισμό μόνο μετά το σήμα.
- Ενημερώσεις του UI κατ‘ αρχήν να δημοσιεύονται μέσω
TThread.Queueή ενός Dispatcher, ώστε οι Worker να μην μπλοκάρουν.
TThread.Queue είναι συχνά η καλύτερη προεπιλεγμένη επιλογή: Ο Worker αναρτά εργασία στο κύριο νήμα, συνεχίζει να τρέχει και δεν μπλοκάρει. Αυτό αποτρέπει πολλά αδιέξοδα. Ωστόσο δεν επιλύει όλες τις περιπτώσεις άκρων — για παράδειγμα όταν σε έναν Worker χρειάζεστε απολύτως ένα αποτέλεσμα που δημιουργείται στο κύριο νήμα (π.χ. πρόσβαση σε πόρο δεμένο με το UI ή σε ένα component που είναι δεμένο στο νήμα).
TThread και Synchronize χωρίς αδιέξοδα στο UI: Μοντέλο σκέψης για καθαρές μεταβιβάσεις
Ένα αξιόπιστο μοντέλο σκέψης είναι: Υπάρχουν λίγες νόμιμες συγχρονικές μεταβιβάσεις προς το κύριο νήμα. Όλα τα υπόλοιπα είναι κατάσταση, απεικόνιση ή τηλεμετρία — και επομένως ασύγχρονα.
Μια απλή κατηγοριοποίηση βοηθά σε ανασκοπήσεις και στη σταθεροποίηση υπαρχόντων έργων:
- «Μόνο εμφάνιση»: πρόοδος, γραμμή καταγραφής, μετρητής, φωτεινός δείκτης, ενεργοποίηση/απενεργοποίηση — πάντα
Queue. - «Παράδοση κατάστασης»: Ο Worker επιστρέφει αντικείμενο δεδομένων/DTO, το UI αποδίδει —
Queue, αλλά με αντιγραφή/αμεταβλητότητα (δηλαδή χωρίς κοινά μεταβαλλόμενες δομές). - «Το UI πρέπει να αποφασίσει»: Μόνο εδώ χρειάζεστε συγχρονική σημασιολογία (π.χ. ερώτημα προς τον χρήστη). Τότε το πραγματικό ερώτημα είναι: Πρέπει όντως ο Worker να περιμένει, ή μπορεί το workflow να ανασχεδιαστεί (μηχανή καταστάσεων, ακύρωση εργασίας, συνέχιση αργότερα);
Η τρίτη κατηγορία είναι παγίδα για αδιέξοδα: αν ο Worker περιμένει ένα αποτέλεσμα από το UI, το UI συχνά θα παρασυρθεί να περιμένει τον Worker (ή έμμεσα μέσω locks). Αυτό οδηγεί σε αποτυχία πιο εύκολα υπό φορτίο, με αργές βάσεις δεδομένων ή σε περιβάλλοντα Remote Desktop.
Απόσπασμα κώδικα: UI-Dispatcher με Queue, προαιρετικό Timeout και καθαρό τερματισμό
Το ακόλουθο πρότυπο περικλείει τις μεταβιβάσεις προς το UI σε μια μικρή βοηθητική κλάση. Αυτό παρέχει:
- Post: fire-and-forget μέσω
TThread.Queue(τυπικά για ενημερώσεις κατάστασης). - Call: Συγχρονική κλήση με Timeout (ασυνήθιστο, αλλά χρήσιμο σε legacy καταστάσεις), χωρίς να χρησιμοποιείται απευθείας το
Synchronizeως σημείο μπλοκαρίσματος. - Προστασία κατά τον τερματισμό: Μη δέχεστε νέα UI-jobs και τα queued jobs ελέγχουν ένα flag πριν χειριστούν controls.
Τεχνική τοποθέτηση: Χρησιμοποιούμε Queue συν TEvent (ένα Kernel-Event) για την επιστροφοδότηση. Ο Worker δεν περιμένει το Synchronize, αλλά ένα Event που τίθεται στο κύριο νήμα αφού εκτελεστεί η ενέργεια στην ουρά. Το Timeout αποτρέπει το «αιώνιο» πάγωμα, αν το UI-νήμα για κάποιο λόγο δεν προχωρήσει στην επεξεργασία.
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, αλλά ένα Event. Με αυτό μπορείτε να επιβάλετε timeouts, να καταστήσετε ορατό κατά τη λειτουργία ότι το UI-thread έχει κολλήσει, και σε φάση shutdown να απορρίπτετε συστηματικά νέες UI-εργασίες.
Το «ασυνήθιστο» στοιχείο δεν είναι το Event, αλλά η απόφαση να αποδοθεί η συγχρονική σημασιολογία μέσω Queue + Event. Αυτό αξίζει όταν σε υπάρχουσες εφαρμογές πρέπει να ενσωματώσετε σταδιακά βελτιώσεις στη σταθερότητα, χωρίς να ανασχεδιάσετε άμεσα κάθε θέση που χρησιμοποιεί Synchronize.
Περιορισμοί και παγίδες
- Ορατότητα μνήμης:
DoneEventείναι η ακμή συγχρονισμού. Έτσι η ανάγνωση τουRaisedObjμετά τοWaitForείναι συνεπής. Παρ‘ όλα αυτά, τοRaisedObjπρέπει να παραμένει τοπικό ανά κλήση (όπως εδώ), ποτέ παγκόσμιο.
AcquireExceptionObject αποτρέπει να „εξαφανιστεί“ η εξαίρεση στο κύριο νήμα. Όταν γίνει ξανά throw στο νήμα εργασίας, το stacktrace δεν είναι ταυτόσημο με την αρχική προέλευση, αλλά το μήνυμα σφάλματος παραμένει στο αρχείο καταγραφής του νήματος εργασίας και η εργασία μπορεί να αποτύχει καθαρά.BeginShutdown ανήκει σε μια κεντρική ακολουθία shutdown (π.χ. πολύ νωρίς στο OnCloseQuery της κύριας φόρμας). Διαφορετικά θα τοποθετούνται ακόμα UI-εργασίες σε ουρά ενώ τα παράθυρα έχουν ήδη καταστραφεί.Στρατηγική κλειδώματος: πώς να αποφεύγετε τις αντιστροφές κλειδώματος σε UI-Callbacks
Πολλά deadlocks δεν προκύπτουν από WaitFor, αλλά από μια ασαφή σειρά κλειδωμάτων. Τυπική ροή: το νήμα εργασίας κλειδώνει το «μοντέλο δεδομένων», καλεί ενημέρωση UI μέσω Synchronize, η ενημέρωση UI προσπελαύνει ξανά το «μοντέλο δεδομένων». Λογικά κατανοητό, αλλά τεχνικά μοιραίο.
Πρακτικοί κανόνες που μπορούν να εφαρμοστούν σε ομάδες:
- Μην κρατάτε locks πέρα από τα όρια νημάτων: Πριν ένα νήμα εργασίας τοποθετήσει σε ουρά/συγχρονίσει οτιδήποτε προς την UI, τα επιχειρησιακά locks πρέπει να έχουν απελευθερωθεί.
- Η UI διαβάζει snapshots: Τα UI-callbacks δεν πρέπει να βλέπουν „ζωντανά“ δομές του νήματος εργασίας, αλλά να εμφανίζουν αντίγραφα/snapshots (π.χ. DTO, Record, απλές τιμές).
- Το logging είναι υποψήφιο για lock: Εάν το logging χρησιμοποιεί εσωτερικά ουρά, κλείδωμα αρχείου ή singleton, μπορεί να γίνει μέρος ενός deadlock. Τα UI-callbacks πρέπει να κρατούν το logging στο ελάχιστο ή να γράφουν μέσω μιας ξεχωριστής, μη μπλοκαριστικής pipeline καταγραφής.
Εάν έχετε ήδη μια Layer-3-αρχιτεκτονική (UI, Services/Domain, υποδομή όπως πρόσβαση δεδομένων): τα UI-callbacks ιδανικά να κάνουν μόνο UI. Ό,τι είναι «Service» δεν ανήκει στο callback. Αυτό μειώνει σημαντικά τα φαινόμενα επανεισόδου.
Shutdown χωρίς μπλοκάρισμα: „όχι WaitFor, αλλά συνεργατικός τερματισμός“
Στο κλείσιμο συχνά προκύπτει πρόβλημα: η UI κλείνει, ένα νήμα πρέπει να σταματήσει, αλλά υπάρχουν ακόμη UI-εργασίες στην ουρά. Ένας καθαρός shutdown είναι λιγότερο „να σκοτώσεις νήμα“ και περισσότερο μια μικρή χορογραφία:
- Ορισμός flag shutdown (π.χ.
TUiDispatcher.BeginShutdown): Από εδώ και στο εξής κανένα νέο UI-job δεν γίνεται δεκτό. - Σταματήστε τα νήματα εργασίας συνεργατικά: Το νήμα εργασίας ελέγχει ένα cancel-flag (π.χ.
TEventή αντίστοιχοTCancellationToken) και τερματίζει βρόχους/αναμονές. - Μην μπλοκάρετε την UI: Καμία σκληρή βρόχος αναμονής στο κύριο νήμα. Αν πρέπει να „περιμένετε“, τότε μόνο με συνεχιζόμενο βρόχο μηνυμάτων (ή καλύτερα: να το αποφύγετε εντελώς χειριζόμενοι την ολοκλήρωση μέσω callback).
- Τελευταίες εργασίες καθαρισμού UI μόνο εφόσον τα παράθυρα/controls υπάρχουν σίγουρα ακόμα. Στην VCL ο χρόνος είναι κρίσιμος: το αργότερο όταν το handle έχει χαθεί, οι τοποθετημένες σε ουρά εργασίες δεν πρέπει πλέον να απευθύνονται σε controls.
Αυτή η ακολουθία είναι σημαντική για λειτουργία και υποστήριξη: «Η εφαρμογή κολλάει κατά το κλείσιμο» είναι ένα κλασικό πρόβλημα αποδοχής, παρότι λειτουργικά όλα μπορεί να έχουν επεξεργαστεί σωστά. Ένας καθορισμένος shutdown εξοικονομεί εδώ πραγματικό χρόνο.
Debugging: Πώς να κάνετε τον deadlock απτά ανιχνεύσιμο (χωρίς εικασίες)
Όταν κολλάει, η βασική ερώτηση είναι: Ποιος περιμένει ποιον; Μερικές προσεγγίσεις που έχουν αποδειχθεί σε υπάρχοντα έργα:
- Καταγραφή όλων των σημείων αναμονής: Πλήρης αναζήτηση για
WaitFor,Sleepσε βρόχους,TEvent.WaitFor,INFINITE. Πολλά προβλήματα είναι «κρυφές» αναμονές (ακόμη και σε βιβλιοθήκες). - Κατάσταση νημάτων στο Log: Καταγράψτε στα όρια των νημάτων: «Job ξεκινά», «UI σε ουρά», «UI εκτελέστηκε», «Job ολοκληρώθηκε». Με αυτό βλέπετε αν ο Main Thread επεξεργάζεται καθόλου τα queued jobs.
- Έλεγχος υπόπτου βρόχου μηνυμάτων: Εάν το πάγωμα εμφανίζεται μόνο σε modal διαλόγους ή σε συγκεκριμένες COM-αλληλεπιδράσεις, ο βρόχος μηνυμάτων (Message Loop) συχνά είναι το σημείο συμφόρησης. Στόχος τότε: αποφορτίστε τους χειριστές του UI, απομονώστε τις κλήσεις COM, μη διεξάγετε μεγάλες λειτουργίες στο UI.
- Κάντε τα locks ορατά: Σε
TCriticalSection/TMonitorαξίζει ένα Debug-Build με μεταδεδομένα «Owner» (π.χ. Thread-ID στην είσοδο) και χρονική μέτρηση. Έτσι βλέπετε ποιο lock κρατάει ο Main Thread τη στιγμή που οι Worker περιμένουν το UI.
Σημαντική είναι η προσέγγιση: Deadlocks σπάνια είναι «τυχαία». Είναι ντετερμινιστικοί κύκλοι που σπάνια ενεργοποιούνται. Αν εντοπίσετε τον κύκλο με σαφήνεια, η διόρθωση είναι στις περισσότερες περιπτώσεις προφανής.
Παραλλαγές για πρόσβαση σε δεδομένα και εργασίες διεπαφής (FireDAC, REST, σύστημα αρχείων)
Ειδικά για FireDAC (ή άλλες προσβάσεις στη DB) ισχύει: σύνδεση, συναλλαγή και datasets στην πράξη είναι συσχετισμένα με το νήμα. Ένας Worker-Thread πρέπει να κατέχει αποκλειστικά το δικό του DB-context. Κλήσεις από το UI πρέπει να περιορίζονται στην παρουσίαση, όχι στις DB-επεξεργασίες. Ένα ανθεκτικό μοτίβο είναι:
- Ο Worker εκτελεί Query/REST-κλήση, υπολογίζει το αποτέλεσμα, δημιουργεί DTO.
- Ο Worker δημοσιεύει το DTO μέσω
Queue/TUiDispatcher.Postπρος το UI. - Το UI παραλαμβάνει το DTO και ενημερώνει τα Controls (χωρίς αναφορά σε αντικείμενα του Worker).
Αν έχετε ιστορικά αναπτυγμένα μεικτά σχήματα («UI ενεργοποιεί DB, DB-callback ενεργοποιεί UI»), αξίζει μια σταδιακή αποσύνδεση: πρώτα απομονώστε τα σημεία παράδοσης (Dispatcher), στη συνέχεια μεταφέρετε καταστάσεις σε Services/Model. Αυτό είναι λιγότερο ριψοκίνδυνο από μια μεγάλη αναδόμηση, αλλά μειώνει αισθητά τα deadlocks.
Συμπέρασμα: Αποφυγή deadlocks σημαίνει έλεγχος των μεταβιβάσεων
TThread und Synchronize ohne UI-Deadlocks είναι λιγότερο μια μεμονωμένη τεχνική και περισσότερο πειθαρχία: ελαχιστοποίηση αποκλεισμών, διατήρηση καθαρών ακολουθιών κλειδώματος, ορισμός Shutdown και μείωση συγχρονικών εξαρτήσεων του UI. Ο παρουσιαζόμενος UI-Dispatcher είναι σε legacy καταστάσεις ιδιαίτερα χρήσιμος, επειδή χρησιμοποιεί ως προεπιλογή την Queue, ενώ για αναγκαίες συγχρονικές μεταβιβάσεις προσθέτει Timeout και σαφείς κανόνες Shutdown.
Παραμένουν όρια εφαρμογής: Εάν ο Main Thread είναι μόνιμα μπλοκαρισμένος (λόγω βαριάς λογικής UI, αλυσίδων modal διαλόγων ή κλήσεων COM-STA), ακόμη και ένας Dispatcher μπορεί μόνο να διαγνώσει και να τερματίσει ελεγχόμενα. Η μακροπρόθεσμη λύση είναι τότε να αποφορτωθεί το UI και να διαχωριστούν οι ευθύνες. Αν χρειάζεστε υποστήριξη σε μια υπάρχουσα Delphi εφαρμογή – από παγίδες threading έως σταδιακή σταθεροποίηση – μπορείτε να καταχωρήσετε το έργο εδώ: Συζητήστε έργο ή πρόγραμμα εκσυγχρονισμού με Net-Base.
Στο τεχνικό πλαίσιο παίζουν επίσης ρόλο το Delphi Multithreading και το Synchronize Deadlock, όταν ενσωματώσεις, ροές δεδομένων και περαιτέρω ανάπτυξη πρέπει να συνεργαστούν καθαρά.