Багатопотокові програми в Delphi зсередини
В процесі розробки багатопотокового додатку доводиться вирішувати дві взаємозалежні проблеми - розмежування доступу до ресурсів і взаимоблокировки. Якщо кілька потоків звертаються до одного й того ж ресурсу (області пам'яті, файлу, пристрою) при недбалому програмуванні може виникнути ситуація, коли відразу кілька потоків спробують виконати якісь маніпуляції із загальним ресурсом. При цьому нормальна послідовність операцій при зверненні до ресурсу, швидше за все, буде порушена. Проблема з розмежуванням доступу може виникнути навіть при дуже простих операціях. Припустимо, у нас є програма, яка створює кілька потоків. Кожен потік виконує своє завдання, і потім завершується. Ми хочемо контролювати кількість потоків, активних в даний час, і з цією метою вводимо лічильник потоків - глобальну змінну Counter. Процедура потоку при цьому може виглядати так: procedure MyThread.Execute;
begin
Inc (Counter);
.
Dec (Counter);
end;
Одна з проблем, пов'язаних з цим кодом полягає в тому, що процедури Inc і Dec НЕ атомарний (наприклад, процедури Inc потрібно виконати три інструкції процесора - завантажити значення Counter в регістр процесора, виконати сам інкремент, потім записати результат з регістра процесора в область пам'яті Counter). Неважко здогадатися, що якщо два потоки спробують виконати процедуру Inc одночасно, значення Counter може бути збільшено на 1 замість 2. Такі помилки важко виявити при налагодженні, тому що ймовірність помилки не дорівнює одиниці. Хвилі може трапитися так, що при тестовому прогоні програми все буде працювати нормально, а помилку виявить вже замовник.
Рішенням проблеми може стати використання спеціальних функцій. Наприклад, замість процедури Inc можна використовувати процедуру Win API InterlockedIncrement, а замість Dec - InterlockedDecrement. Ці процедури гарантують, що в будь-який момент часу не більше одного потоку отримає доступ до змінної-лічильника.
У загальному випадку для вирішення проблеми поділу доступу використовуються блокування - спеціальні механізми, які гарантують, що в кожен даний момент часу тільки один потік має доступ до деякого ресурсу. У той час, коли ресурс заблокований одним потоком, виконання інших потоків, які намагаються отримати доступ до ресурсу, призупиняється. Однак використання блокувань створює іншу проблему - взаимоблокировки (deadlocks). Взаімоблокіровка настає тоді, коли потоку A блокує ресурс, необхідний для продовження роботи потоку B, а потік B блокує ресурс, необхідний для продовження роботи потоку A. Оскільки жоден потік не може продовжити виконання, заблоковані ресурси не можуть бути розблоковані, що призводить до повісанію потоків.
Потоки в Delphi 6
З усіх розглянутих реалізацій, реалізація потоків в Delphi 6 найпростіша. Це як би основа, на якій більш пізні версії будують більш складну модель взаємодії потоків.
У Delphi 6, як, втім, і в інших версіях Delphi, В якості опції потоку використовується функція ThreadProc з модуля Classes, яка, в свою чергу, викликає метод Execute об'єкта TThread: function ThreadProc (Thread: TThread): Integer;
var
FreeThread: Boolean;
begin
try
if not Thread.Terminated then
try
Thread.Execute;
except
Thread.FFatalException: = AcquireExceptionObject;
end;
finally
FreeThread: = Thread.FFreeOnTerminate;
Result: = Thread.FReturnValue;
Thread.FFinished: = True;
Thread.DoTerminate;
if FreeThread then Thread.Free;
EndThread (Result);
end;
end;
Важливо підкреслити, що метод Synchronize не просто синхронізує виконання переданого йому методу Method з виконанням інших методів в головному потоці, а й організовує виконання Method в контексті головного потоку. Тобто, наприклад, глобальні змінні, оголошені як threadvar, при виконанні Method матимуть значення, присвоєні їм в головному потоці, а не ті, які привласнені їм в потоці, що викликала Synchronize.
Для нормальної синхронізації головного і вторинного потоку методу Synchronize недостатньо. Уявіть собі таку ситуацію: в методі головного потоку ми очікуємо завершення вторинного потоку (не важливо, яким чином, важливо те, що цей метод не поверне управління головного потоку до тих пір, поки вторинний потік не завершиться), а в цей час вторинний потік викликає метод Synchronize. В результаті виникне взаімоблокіровка: метод головного потоку не може завершитися поки не завершиться вторинний потік, а вторинний потік не може завершитися, поки не буде виконано метод Synchronize (а для цього потрібно, щоб головний потік повернувся в цикл обробки повідомлень). Для вирішення цієї ситуації існує функція CheckSynchronize, виклик якої призводить до виконання всіх методів, які перебувають в даний момент в черзі SyncList, і повернення управління з усіх методів Synchronize, викликаних вторинними потоками. У Delphi 6 ця функція реалізована в такий спосіб: function CheckSynchronize: Boolean;
var
SyncProc: PSyncProc;
begin
if GetCurrentThreadID <> MainThreadID then
raise EThread.CreateResFmt (@SCheckSynchronizeError, [GetCurrentThreadID]);
if ProcPosted then
begin
EnterCriticalSection (ThreadLock);
try
Result: = (SyncList <> nil) and (SyncList.Count> 0);
if Result then
begin
while SyncList.Count> 0 do
begin
SyncProc: = SyncList [0];
SyncList.Delete (0);
try
SyncProc.Thread.FMethod;
except
SyncProc.Thread.FSynchronizeException: = AcquireExceptionObject;
end;
SetEvent (SyncProc.signal);
end;
ProcPosted: = False;
end;
finally
LeaveCriticalSection (ThreadLock);
end;
end else Result: = False;
end;
Як бачимо, функція CheckSynchronize працює просто: вона перевіряє значення ProcPosted, і, якщо це значення дорівнює True, послідовно вилучає записи з черги SyncList, виконує відповідні методи і встановлює відповідні сигнали. Зверніть увагу на те, що функція перевіряє (за допомогою GetCurrentThreadID), з якого потоку вона викликана. Виклик CheckSynchronize не з головного потоку може привести до хаосу, так що в цьому випадку генерується виняток EThread. Для обробки викликів методів, "вбудованих" у головний потік за допомогою Synchronize, цикл обробки повідомлень головного потоку також викликає метод CheckSynchronize.
Ще один цікавий для нас метод - метод WaitFor класу TThread. Цей метод блокує виконання викликав його потоку до тих пір, поки не завершиться потік, для об'єкта якого WaitFor був викликаний. Простіше кажучи, якщо в головному потоці ми хочемо дочекатися завершення потоку MyThread, ми можемо викликати MyThread.WaitFor; Ось як реалізований WaitFor в Delphi 6: function TThread.WaitFor: LongWord;
var
H: THandle;
WaitResult: Cardinal;
Msg: TMsg;
begin
H: = FHandle;
if GetCurrentThreadID = MainThreadID then
begin
WaitResult: = 0;
repeat
if WaitResult = WAIT_OBJECT_0 + 1 then
PeekMessage (Msg, 0, 0, 0, PM_NOREMOVE);
Sleep (0);
CheckSynchronize;
WaitResult: = MsgWaitForMultipleObjects (1, H, False, 0, QS_SENDMESSAGE);
Win32Check (WaitResult <> WAIT_FAILED);
until WaitResult = WAIT_OBJECT_0;
end else WaitForSingleObject (H, INFINITE);
CheckThreadError (GetExitCodeThread (H, Result));
end; Метод WaitFor може бути викликаний не тільки з головного потоку, але і з будь-якого іншого. Якщо WaitFor викликаний з головного потоку GetCurrentThreadID = MainThreadID, метод періодично викликає функцію CheckSynchronize для запобігання описаної вище взаимоблокировки, а так само періодично спустошує чергу повідомлень Windows. Якщо метод WaitFor викликаний для будь-якого іншого потоку, у якого як передбачається, своєї черги повідомлень бути не може, він просто чекає сигналу про завершення підконтрольного потоку за допомогою функції Win API WaitForSingleObject. Тут треба зазначити, що ідентифікатор потоку (поле FHandle) є за сумісництвом сигналом, який встановлюється системою Windows при завершенні роботи потоку.
А що буде, якщо потік викличе WaitFor для самого себе? Потік, що викликав Self.WaitFor; буде вічно очікувати свого власного завершення (по крайней мере, до тих пір, поки якийсь інший потік не викличе "жорстку" функцію Win API TerminateThread для даного потоку). Звичайно, навряд чи розсудлива програміст напише щось типу Self.WaitFor, але ситуації можуть бути і більш складними. Дивно, що розробники Delphi не запобігла таку можливість "самогубства", але ж зробити це дуже просто - достатньо порівняти значення FHandle і значення, повернене функцією GetCurrentThreadID.
Потоки в Delphi 7
У порівнянні з Delphi 6 змін в роботі з потоками в Delphi 7 не так вже й багато. Розглянемо реалізацію функції CheckSynchronize: function CheckSynchronize (Timeout: Integer = 0): Boolean;
var
SyncProc: PSyncProc;
LocalSyncList: TList;
begin
if GetCurrentThreadID <> MainThreadID then
raise EThread.CreateResFmt (@SCheckSynchronizeError, [GetCurrentThreadID]);
if Timeout> 0 then
WaitForSyncEvent (Timeout)
else
ResetSyncEvent;
LocalSyncList: = nil;
EnterCriticalSection (ThreadLock);
try
Integer (LocalSyncList): = InterlockedExchange (Integer (SyncList), Integer (LocalSyncList));
try
Result: = (LocalSyncList <> nil) and (LocalSyncList.Count> 0);
if Result then
begin
while LocalSyncList.Count> 0 do
begin
SyncProc: = LocalSyncList [0];
LocalSyncList.Delete (0);
LeaveCriticalSection (ThreadLock);
try
try
SyncProc.SyncRec.FMethod;
except
SyncProc.SyncRec.FSynchronizeException: = AcquireExceptionObject;
end;
finally
EnterCriticalSection (ThreadLock);
end;
SetEvent (SyncProc.signal);
end;
end;
finally
LocalSyncList.Free;
end;
finally
LeaveCriticalSection (ThreadLock);
end;
end;
У новій версії замість прапора ProcPosted використовується подія SyncEvent, для управління яким створено кілька функцій: SetSyncEvent, ResetSyncEvent, WaitForSyncEvent. Метод WaitFor використовує подія SyncEvent для оптимізації циклу обробки повідомлень. Установка SyncEvent сигналізує про те, що в черзі з'явився новий метод, який чекає синхронізації, і потрібно викликати CheckSynchronize.
У методу CheckSynchronize з'явився параметр TimeOut, який вказує, скільки часу метод повинен чекати події SyncEvent, перш ніж повернути управління. Вказувати час очікування зручно там, де метод CheckSynchronize викликається в циклі (при цьому потік, що викликав CheckSynchronize, віддає своє процесорний час іншим потокам, замість того, щоб крутити виклики вхолосту), однак і тривалість виклику методу CheckSynchronize може невиправдано зрости. Зверніть увагу так само на те, як в Delphi 7 змінилася робота з чергою SyncList. У попередній версії CheckSynchronize чергу SyncList захоплювалась (за допомогою ThreadLock) на весь час обробки поміщених в чергу методів (а це час могло бути порівняно великим). Але ж поки CheckSynchronize володіє об'єктом SyncList, операції з чергою SyncList, що виконуються з інших потоків, блокуються. Для того щоб вивільнити SyncList якомога швидше, зберігає покажчик на поточний об'єкт черги (за допомогою функції Win API InterlockedExchange) в локальній змінній LocalSyncList, а змінної SyncList привласнює значення nil. Після цього доступ до змінної SyncList відкривається знову. Тепер, якщо інший потік захоче знову синхронізувати метод, йому знадобиться створити новий об'єкт SyncList, проте доступ до черги блокується тільки на час, необхідний для обміну покажчиками, так що загальний виграш продуктивності повинен бути значним.
Робота методу в блокує режимі схожа на роботу методу Synchronize в Delphi 7: система створює подія SyncProc.Signal, яке буде сигналізувати про виконання методу в головному потоці, потім формує структуру SyncProc, що описує синхронізований метод, додає цю структуру в чергу SyncList, встановлює сигнал SyncEvent і чекає, поки функція CheckSynchronize не встановив сигнал SyncProc.Signal, який свідчить про те, що синхронізований метод виконаний. Для опису викликається методу як і раніше використовується запис типу TSyncProc, яка, однак, виглядає по-іншому: TSyncProc = record
SyncRec: PSynchronizeRecord;
Queued: Boolean;
Signal: THandle;
end;
Поле SyncRec є покажчик на структуру TSynchronizeRecord. Поле Queued вказує, чи є виклик асинхронним, а поле Signal використовується при блокирующем виклику.
Якщо в параметрі QueueEvent передається значення True, виклик методу додається в чергу асинхронно. В цьому випадку ми створюємо новий екземпляр запису TSyncProc (для асинхронного виклику не можна використовувати локальну змінну, оскільки структура повинна існувати після завершення виклику Synchronize).
Недоліки реалізації потоків в Delphi
Найголовнішим недоліком слід визнати метод, застосовуваний для припинення та поновлення виконання потоку. З цією метою в VCL використовуються функції Windows API SuspendThread і ResumeThread, які взагалі кажучи. Призначені для налагоджувальних цілей. Функція SuspendThread може зупинити виконання потоку в будь-якій точці. Потік не може заборонити припинення на час виконання критичного фрагмента коду і не отримує оповіщення про те, що він буде припинений. Обмін повідомленнями між вторинними потоками і головним потоком продуманий досить добре, в останніх версіях Delphi додані навіть асинхронні виклики, а ось стандартних механізмів передачі повідомлень від головного потоку до другорядних не існує. Тут треба зазначити, що під "головним потоком" ми розуміємо потік, в якому виконується метод Application.Run, і обробляються події. Delphi погано підходить для моделі, в якій всі потоки рівноправні.