Jedną z najważniejszych cech 32-bitowej platformy Windows jest obsługa aplikacji wielowątkowych. Umożliwia wykorzystanie wszelkich zalet programowania współbieżnego, upraszcza proces programowania i generalnie czyni aplikacje łatwiejszymi w obsłudze. W 16-bitowych wersjach Windows nie było wielowątkowości, dlatego jest ona jednym z głównych czynników przemawiających za przenoszeniem aplikacji z Delphi 1 do wyższych, 32-bitowych wersji. W niniejszym rozdziale opiszemy mechanizmy Win32 API służące do realizacji aplikacji wielowątkowych oraz elementy Delphi stanowiące odzwierciedlenie tych mechanizmów; przy okazji przedstawimy ograniczenia związane z programowaniem współbieżnym w Delphi i postaramy się uzasadnić ich przyczyny.
Wątek (thread) jest obiektem systemu operacyjnego, reprezentującym wydzieloną część kodu w ramach procesu. Każda aplikacja Win32 posiada przynajmniej jeden wątek zwany wątkiem głównym albo wątkiem pierwotnym (primary thread, default thread); aplikacja może także posiadać inne wątki, zwane wątkami pobocznymi lub drugorzędnymi (secondary threads).
Mechanizm wątków pozwala na niezależną, jednoczesną realizację wielu różnych funkcji aplikacji; jednoczesność ta jednak jest pozorna, gdyż w rzeczywistości polega to na szybkim przełączaniu procesora między poszczególnymi wątkami — na tyle szybkim, iż sprawia wrażenie realizacji jednoczesnej (chyba że komputer wyposażony jest w kilka procesorów, ale to już zupełnie inna sprawa).
Wskazówka
Wielowątkowość jest cechą środowiska 32-bitowego — nie istnieje ona (i nigdy nie będzie istnieć) w 16-bitowych wersjach Windows. Wielowątkowe aplikacje tworzone w Delphi nigdy nie będą więc kompatybilne z Delphi 1.
Wielozadaniowość z wykorzystaniem wątków jest czymś zgoła innym niż wielozadaniowość (a właściwie jej namiastka) w 16-bitowym środowisku Windows 3.x. W ramach Windows 3.x możliwe jest jednoczesne uruchamianie wielu aplikacji, trudno jednak mówić o całkowitym ich podporządkowaniu systemowi operacyjnemu. Aplikacja, otrzymawszy od systemu sterowanie, zyskuje tym samym kontrolę nad czasem procesora i może go zawłaszczyć do woli; takie zawłaszczenie — rozmyślne lub niezamierzone, np. na skutek zapętlenia, zawsze paraliżuje pracę systemu, a często prowadzi do jego załamania. Od aplikacji 16-bitowej wymaga się więc przestrzegania pewnych zasad współpracy z innymi aplikacjami; z tego względu wielozadaniowość Windows 3.x została nazwana wielozadaniowością kooperacyjną (cooperative multitasking).
W Win32 wielozadaniowość ma całkowicie odmienny charakter. Obiektami ubiegającymi się o czas procesora są nie zadania, lecz właśnie wątki, nie to jest jednak najważniejsze: znacznie istotniejsza jest niemożność zmonopolizowania czasu procesora przez pojedynczy wątek. Otrzymuje on jedynie kwant czasu, po wykorzystaniu którego jest po prostu wywłaszczany (bez ostrzeżenia) przez system operacyjny. Mamy więc do czynienia z sytuacją, kiedy to system operacyjny ustala reguły gry, przydzielając czas poszczególnym wątkom i odbierając im sterowanie, gdy uzna to za stosowne; tego typu wielozadaniowość została nazwana wielozadaniowością z wywłaszczaniem (preemptive multitasking).
Możliwość podziału aplikacji na niezależne wątki jest dla programisty (nie tylko w Windows) niezwykle atrakcyjna, i to z wielu względów. Zalety wielowątkowości stają się szczególnie widoczne w przypadku, gdy aplikacja wykonuje jedną lub kilka akcji „w tle”, niezależnie od dialogu, który jednocześnie prowadzi z użytkownikiem w ramach swego interfejsu. Dobrym tego przykładem może być obliczanie wartości komórek arkusza kalkulacyjnego równolegle z wprowadzaniem nowych danych lub — coraz powszechniejsze — drukowanie wyników aplikacji równolegle z innymi jej działaniami. Projektant aplikacji może się skupić na dialogu z użytkownikiem uznając, że cała reszta zostanie „załatwiona” w ramach innych wątków. Zresztą — tak bardzo pożądana w procesie projektowania — metoda dekompozycji problemów daje się bardzo łatwo zrealizować właśnie dzięki wielowątkowości; można więc powierzyć poszczególne aspekty aplikacji poszczególnym jej wątkom, opracowywanym niezależnie od siebie, z uwzględnieniem jedynie niezbędnej synchronizacji (o czym będziemy pisać w dalszej części rozdziału).
Tak się jednak składa, iż większa część biblioteki VCL nie jest „bezpieczna wątkowo” (thread-safe) — przy jej tworzeniu przyjęto bowiem założenie, iż w danej chwili dostęp do komponentów ma co najwyżej jeden wątek. Ograniczenie to dotyczy w większości komponentów tworzących interfejs użytkownika, chociaż wiele innych komponentów także nie jest przystosowanych do dostępu wielowątkowego. Dla niektórych z nich VCL udostępnia „wielowątkowe alternatywy” — na przykład TThreadList jest bezpieczną wątkowo odmianą komponentu TList. Przykładem mechanizmu przystosowanego do wielowątkowości jest natomiast strumieniowanie komponentów — dopuszcza się odczyt (lub zapis) strumieni (np. plików .DFM) jednocześnie przez kilka wątków.
W stosunku do komponentów tworzących interfejs użytkownika obowiązuje w VCL zastrzeżenie, iż ich obsługa może się odbywać jedynie w kontekście wątku głównego aplikacji — ważnym wyjątkiem od tej zasady jest obiekt płótna (Canvas), który posiada wbudowane mechanizmy obsługi wielowątkowej. Nie oznacza to oczywiście całkowitego odizolowania wątków pobocznych od komponentów, ponieważ Delphi udostępnia narzędzia umożliwiające modyfikowanie interfejsu użytkownika w kontekście wątku głównego, lecz z inicjatywy wątków pobocznych. Nie zmienia to jednak faktu, iż w warunkach aplikacji wielowątkowej obsługa interfejsu użytkownika musi być zrealizowana szczególnie starannie.
Nadmiar dobrego czasami przeobraża się w zło; ta zasada ma zastosowanie również w odniesieniu do wątków Win32 API. Choć podział aplikacji na niezależne wątki uwalnia programistę od wielu problemów, to jednocześnie przysparza mu wielu nowych kłopotów — tyle że innego rodzaju.
Przede wszystkim krytyczny staje się problem synchronizacji dwóch lub kilku wątków wykorzystujących te same zasoby. Wyobraź sobie wprowadzanie zmian do tekstu programu, który właśnie jest kompilowany: jeśli kompilator i edytor nie są nawzajem świadome swoich skutków, to taka sytuacja przypomina przestawienie zwrotnicy pod przejeżdżającym pociągiem. W tym szczególnym przypadku środki zaradcze są niemal banalne: można na przykład zablokować możliwość zmian w module na czas jego kompilacji, można też utworzyć kopię modułu i potraktować ją jako wejście dla kompilatora (jednak na czas kopiowania też trzeba zablokować edycję), można wreszcie śledzić postęp kompilacji (w przypadku kompilatorów jednoprzebiegowych) i umożliwić edycję tylko tej części tekstu, która została już skompilowana. Konkretne rozwiązanie nie jest tu istotne, ważne jest, aby nie traktować wielowątkowości jako panaceum na dotychczasowe problemy towarzyszące klasycznemu programowaniu sekwencyjnemu. Programowanie współbieżne, oferując ogromne możliwości i rozwiązując problemy, których rozwiązanie w ramach dotychczasowych środków mogło być jedynie połowiczne (lub żadne — bywa i tak), kryje jednocześnie wiele zdradliwych pułapek, gdy korzystamy z niego w niewłaściwy sposób.
Podstawową klasą Delphi, implementującą mechanizmy charakterystyczne dla wątków, jest klasa TThread. Chociaż jej właściwości i metody uwzględniają większość aspektów wielowątkowości (również tych specyficznych dla Delphi), to jednak w wielu wypadkach (jak później zobaczymy) konieczne stają się bezpośrednie odwołania do Win32 API: najbardziej oczywistym tego przykładem są mechanizmy synchronizacji wątków, o której przed chwilą wspominaliśmy. Obecnie skoncentrujmy się jednak na samej klasie TThread; jej deklaracja, prezentowana poniżej, znajduje się w module Classes.pas.
TThread = class
private
FHandle: THandle;
{$IFDEF MSWINDOWS}
FThreadID: THandle;
{$ENDIF}
{$IFDEF LINUX}
// ** FThreadID is not THandle in Linux **
FThreadID: Cardinal;
FCreateSuspendedSem: TSemaphore;
FInitialSuspendDone: Boolean;
FCreateSuspended: Boolean;
FTerminated: Boolean;
FSuspended: Boolean;
FFreeOnTerminate: Boolean;
FFinished: Boolean;
FReturnValue: Integer;
FOnTerminate: TNotifyEvent;
FMethod: TThreadMethod;
FSynchronizeException: TObject;
FFatalException: TObject;
procedure CheckThreadError(ErrCode: Integer); overload;
procedure CheckThreadError(Success: Boolean); overload;
procedure CallOnTerminate;
function GetPriority: TThreadPriority;
procedure SetPriority(Value: TThreadPriority);
procedure SetSuspended(Value: Boolean);
// ** Priority is an Integer value in Linux
function GetPriority: Integer;
procedure SetPriority(Value: Integer);
function GetPolicy: Integer;
procedure SetPolicy(Value: Integer);
protected
procedure DoTerminate; virtual;
procedure Execute; virtual; abstract;
procedure Synchronize(Method: TThreadMethod);
property ReturnValue: Integer read FReturnValue write FReturnValue;
property Terminated: Boolean read FTerminated;
public
constructor Create(CreateSuspended: Boolean);
destructor Destroy; override;
procedure AfterConstruction; override;
procedure Resume;
procedure Suspend;
procedure Terminate;
function WaitFor: LongWord;
property FatalException: TObject read FFatalException;
property FreeOnTerminate: Boolean read FFreeOnTerminate write FFreeOnTerminate;
property Handle: THandle read FHandle;
property Priority: TThreadPriority read GetPriority write SetPriority;
// ** Priority is an Integer **
property Priority: Integer read GetPriority write SetPriority;
property Policy: Integer read GetPolicy write SetPolicy;
property Suspended: Boolean read FSuspended write SetSuspended;
property ThreadID: THandle read FThreadID;
// ** ThreadId is Cardinal **
property ThreadID: Cardinal read FThreadID;
property OnTerminate: TNotifyEvent read FOnTerminate write FOnTerminate;
end;
Jak widać, klasa TThread jest bezpośrednim potomkiem klasy TObject, więc obiekt klasy TThread nie jest komponentem i nie znajdziemy go w palecie komponentów. Liczne dyrektywy $IFDEF w deklaracji klasy świadczą o tym, iż jest ona klasą uniwersalną w sensie zgodności z Delphi i z Kyliksem. Na uwagę zasługuje także fakt, iż metoda Execute(), realizująca wątek w sensie fizycznym, jest metodą abstrakcyjną; oznacza to, iż abstrakcyjna jest cała klasa TThread, a więc w konkretnej aplikacji musimy posługiwać się jej klasami pochodnymi, przedefiniowującymi metodę Execute() stosownie do specyfiki poszczególnych wątków.
Najprostszym sposobem utworzenia nowej klasy wątku jest wybranie pozycji Thread Object z karty New okna New Items (rys. 5.1):
Rysunek 5.1. Definiowanie nowego wątku za pomocą repozytorium
Po wybraniu obiektu Thread Object Delphi wyświetli pytanie o nazwę tworzonej klasy; przyjmijmy, iż jest nią TTestThread. Po wprowadzeniu nazwy Delphi utworzy nowy moduł zawierający deklarację nowej klasy z przedefiniowaną metodą Execute():
type
TTestThread = class(TThread)
{ Private declarations }
procedure Execute; override;
Nie siląc się w tym momencie na jakiś wyrafinowany przykład, uczyńmy treścią tej metody jakieś proste obliczenia, na przykład takie:
procedure TTestThread.Execute;
var
k: integer;
begin
for k := 1 to 2000000 do
Inc( Answer, Round(Abs(Sin(Sqrt(k)))));
Umieśćmy teraz na formularzu przycisk, którego kliknięcie spowoduje utworzenie obiektu zdefiniowanej klasy wątku:
procedure TForm1.Button1Click(Sender: TObject);
NewThread: TTestThread;
NewThread := TTestThread.Create(False);
Pojedynczy parametr wywołania konstruktora klasy wątkowej określa sposób postępowania z utworzonym obiektem wątku; jeżeli ma wartość False, wątek jest automatycznie uruchamiany, w przeciwnym razie wątek ten pozostaje w stanie zawieszenia — jego uruchomienie nastąpi dopiero w wyniku wywołania metody Resume(). Ta druga możliwość daje okazję do zmodyfikowania niektórych właściwości obiektu wątkowego przed jego uruchomieniem. Modyfikowanie działającego wątku jest w wielu przypadkach nieskuteczne, często też daje efekty różne od zamierzonych.
Możliwość wstrzymywania zawieszonego wątku nie jest cechą Delphi, lecz Win32; wstrzymanie takie następuje wówczas, gdy tworząca nowy wątek funkcja CreateThread() wywołana zostaje z parametrem CREATE_SUSPENDED.
W procedurze TForm1.Button1Click parametr wywołania konstruktora ma wartość False, zatem tworzony wątek jest automatycznie uruchamiany. Łatwo się wówczas przekonać, iż funkcjonowanie wątku pobocznego w niczym nie blokuje możliwości manipulowania formularzem — jego przemieszczania, minimalizacji, maksymalizacji, zmiany rozmiarów itp.
Przyjrzyjmy się zmiennej lokalnej k w procedurze TTestThread.Execute() i zastanówmy się, co się stanie w przypadku równoległej pracy kilku egzemplarzy wątku TTestThread: czy będą one wspólnie wykorzystywać tę zmienną, co, rzecz jasna, musiałoby doprowadzić do nieprzewidywalnych wyników? Czy może dostęp do niej będzie się odbywał według jakichś priorytetów? Nic z tych rzeczy: każdy wątek posiada własny, oddzielny obszar stosu, a ponieważ zmienne lokalne umieszczane są właśnie na stosie, każdy wątek będzie się posługiwał własną, oddzielną kopią zmiennej k.
Jednak zupełnie inaczej rzecz się ma ze zmiennymi globalnymi; ich rozłączność musi być zapewniona za pomocą specjalnych środków, które opiszemy w dalszej części rozdziału.
Zasadnicza akcja wątku reprezentowanego przez obiekt klasy wątkowej rozgrywa się w ramach metody Execute(), toteż jej zakończenie równoważne jest zakończeniu samego wątku. Po zakończeniu wątku wywoływana jest funkcja Delphi o nazwie EndThread(), wywołująca z kolei funkcję API ExitThread() zwalniającą przydzielony do wątku stos i związany z wątkiem obiekt Win32.
Należy także zadbać o zwolnienie obiektu klasy wątkowej w Delphi. Zwróć uwagę, iż zwykłe w...
b.senni