
A TThread.Suspend eljárás igazából rosszul van elnevezve. A helyes név így hangzik: TThread.RandomlyHangTheProgram. Nem vicc, ezt a helyzet. A legjobb, amit tehetünk, ha egyáltalán nem használjuk, de talán még jobb, ha el is felejtjük.
A Suspend ugyanis megállítja a szálat, akármit is csinál az éppen. Fogva tart egy fájlt? Várólistára mindenkit. Belépett egy critical sectionbe? Nincs több belépés. Képzeljük csak el azt, ha a szál éppen memóriát foglalt. A memóriakezelő is osztott erőforrás, critical section védi a szálak versengése ellen. Ha a szál éppen fogva tartja a critical sectiont amikor a Suspend meghívódik, a memóriakezelő ettől kezdve zárva van minden arrajáró számára. Egy egyszerű szövegművelet is lehetlenné válik, ha ahhoz memóriafoglalás vagy felszabadítás kellene. Ha pedig éppen az a szál próbál memóriát foglalni, amelyik a Suspendet meghívta, és majd később a Resume-ot hívná, kialakul a csuka fogta róka esete, amit csak úgy hívunk: deadlock.
Az olvasóra bíznánk annak végiggondolását, hogy mi történik, ha meghívjuk a win32 API TerminateThread eljárását.
Szál vagy TThread objektum. Kezdő programozónak gyakran nem megy a fejébe elsőre, hogy hogy is vannak ezek a szálak és objektumok egymással. Hajlamosak vagyunk azt hinni, hogy ami a TThread része, az szálon fut, ami meg nem a TThread része, ez nem fut szálon (azaz a fő VCL szálon fut).
Nagyon fontos, és mindenki vésse az eszébe: a kódnak és a szálnak semmi köze egymáshoz! Bármely kódrészlet futhat szálról is és nem szálról is. Attól függ csupán, hogy hívjuk-e onnan. Akármilyen eljárást hívunk meg szálról, az a szálon fog lefutni. Akkor is, ha a fő ablak metódusa. És amit nem szálról hívunk, az pedig nem szálról hívódik, még ha a TThread metódusa is.
Nézzük példaként időrendben, hogy mi történik, ha létrehozunk egy TThread objektumot.
constructor TThread.Create(CreateSuspended: Boolean);
begin
inherited Create;
AddThread;
FSuspended := CreateSuspended;
FCreateSuspended := CreateSuspended;
FHandle := BeginThread(nil, 0, @ThreadProc, Pointer(Self),
CREATE_SUSPENDED, FThreadID);
if FHandle = 0 then
raise EThread.CreateResFmt(@SThreadCreateError,
[SysErrorMessage(GetLastError)]);
end;
Emlékeztető: mivel ezt a fő VCL szál hívta meg, így ez is azon fut. A lényeg a BeginThread, amely a delphiben a szálak indítására szolgál. Paraméterben kapja meg az eljárást, amit egy másik szálon akarunk végrehajtani. Kap még egy paramétert, ami ahhoz kell, hogy ama bizonyos eljárásnak üzenni tudjunk valamit. Erre azért van szükség, mert amikor egy szálat létrehozunk, valahogy el kell neki juttatni az adatokat, amivel dolgozni fog. Ha nem is magukat az adatokat, de legalább azt, hogy hol találhatni. Ez akkor válik igazán érdekessé, ha több teljesen egyforma szálat akarunk beindítani, azaz ugyanaz kód fog futni több példányban. Mondjuk ez matematikai alkalmazás, amiben több párhuzamos munkaszál egy egy számot analizál. A szál paramétere például lehet a szám, amit vizsgálnia kell. Jelen esetben maga a TThread objektum lesz ez a paraméter (Self). Mivel a ThreadProc egy sima eljárás, máskülönben nem tudná, hogy melyik TThread objektum indította őt be. Nézzük csak a BeginThreadet.
function BeginThread(SecurityAttributes: Pointer;
StackSize: LongWord; ThreadFunc: TThreadFunc; Parameter: Pointer;
CreationFlags: LongWord; var ThreadId: LongWord): Integer;
var
P: PThreadRec;
begin
New(P);
P.Func := ThreadFunc;
P.Parameter := Parameter;
IsMultiThread := TRUE;
Result := CreateThread(SecurityAttributes, StackSize,
@ThreadWrapper, P, CreationFlags, ThreadID);
end;
A BeginThread tulajdonképpen csak keret a CreateThread körül. A CreateThread az a win32 API hívás, amely paraméterben kapja meg azt az eljárást, amit aztán már egy másik szálról fog futtatni. Tulajdonképpen az eljárás maga lesz a szál élete. Ahogy a megadott eljárás kilép, a szál tevékenysége végetér, és a szál megszűnik.
Észreveendő, hogy a Delphi nem a mi általunk adott eljárást adja tovább, hanem egy saját eljárását (ThreadWrapper), aminek paraméterben adja meg, hogy mit kértünk, és majd az hívja meg. Ez egy csúnya assembly rutin, mindenféle inicializációkat végez, számunkra most nem fontos. Ami fontos, hogy közben meghívja az általunk kívánt eljárást is.
Amire még érdemes figyelni, az a matrjóska rendszerű paraméterátadás. Gyakran előforduló eset, amikor kereteljárásokat írunk valamely funkció köré, hogy az egyetlen csatolható paraméterre nekünk is szükségünk volna, de ugyanakkor kívülről is kapunk egyet, amit továbbítani kell. Jelen esetben a Delphi nem a mi paraméterünket adta át, hanem készített egy kis csomagocskát, amibe beletette a majd valóban hivandó eljárást, és a mi paraméterünket. Majd ezt a csomagocskát (illetve arra mutató pointert) adta át a szállétrehozó API hívásnak. Később majd a ThreadWrapper a csomagból kiszedi, hogy mit kell hívni, és milyen paramétert kell neki továbbítani. A csomagocska felszabadítását is ronda ThreadWrapper fogja csinálni. De most még nem tartunk itt.
A szál életéről a továbbiakban nem tudunk semmit. Valamikor be fog indulni, de hogy az jövő héten lesz, vagy most azonnal, azt nem tudni.
Ezekután gyors ütemben az összes eljárás, amerre jöttünk, visszatér. A hívó fél folytatja a dolgát.
Mindettől függetlenül, valahol a számítógép másik zugában, életre kel egy szál. Ezen belül a windows kialakítja a körülményeket, majd elindítja az általunk (azaz a Delphi által) megadott eljárást, ami a ThreadWrapper. A ThreadWrapper előszedi a tényleg általunk (akarom mondani, a TThread objektum által) adott eljárást, és meghívja a szintén TThreadtől kapott paraméterrel. Így jutunk a ThreadProchoz.
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;
Az eljárás paraméterben kapja meg azt az objektumot, ami őt létrehozta. Ezt ugye a TThread.Create adta át a BeginThreadnek, az továbbadta a CreateThreadnek becsomagolva, majd a ThreadWrapper megkapta, kicsomagolta, és most itt van. A fontos számunkra az, hogy meghívódik a TThread.Execute, itt jutottunk tehát el a szál objektum lényegéig. Az Execute tehát szintén a szálon fut, de ezen nem lepődtünk meg, hiszen ez az értelme neki.
A másik említendő a DoTerminate hívás. A DoTerminate hívja meg az OnTerminate eseménykezelőt, na de nem mindegy, hogy hogyan:
procedure TThread.DoTerminate;
begin
if Assigned(FOnTerminate) then Synchronize(CallOnTerminate);
end;
A synchronize-ról majd később. Vonjuk le az első tanulságot:
1. A create metódus a hívó szálon fut, azaz leggyakrabban a fő szálon. Hiába tartozik a TThread objektumhoz.
2. Minden, amit az Execute-ból meghívunk, akár ha az az MyEdit.OnChange is, szálról fut.
Most nézzük azt a Synchronize-t. Ha simán CallOnTerminate szerepelne, akkor az az előbbiek értelmében azt jelentené, hogy szálról fog hívódni az OnTerminate, mintegy hattyúdalként. Hiszen az őt hívó ThreadProc is a szálon fut. Ezen változtat a Synchronize. A Synchronize ugyanis levelet küld a fő szálnak, hogy futtasson már le egy eljárást a kedvünkért. Ezután a szál guru meditációba süllyed. A fő szál, ha akad egy kis ideje, lefuttatja a kedvünkért az eljárást, majd válaszlevelet küld. A válaszlevél megérkeztével a szál feléled, és folytatja a munkáját, jelen esetben a megsemmisülést.
Ismét vigyázzunk! Teljesen mindegy, hogy az eljárás, amit átadtunk, hol van, kié, mié. Akár a TThread objektum egyik metódusát is átadhatjuk, az is a fő szálon fog futni. Mivel a kód, az kód, CPU utasítások sorozata, teljesen mindegy, hogy mely objektumé, mely unitban van, vagy mi a neve, de főleg attól, hogy mit szerettünk volna elérni.
További tanulságok tehát:
3. Az OnTerminate a fő szálon fut.
4. A Synchronize-nak átadott eljárás a fő szálon fut, akármi is az.
Házi feladat.
Adott egy szál objektum, a TWorkerThread. Ennek Execute metódusában az alábbi részletet látjuk:
...
Synchronize(UpdateForm)
...
Az UpdateForm az alábbi műveletet végzi:
procedure TWorkerThread.UpdateForm;
begin
FDisplayPanel.Caption := GetWorkProgressDesc;
end;
procedure TWorkerThread.GetWorkProgressDesc;
begin
Result := PhaseStrings[FPhase] + ' ' +
FCurrentObject.Description;
end;
Kérdés: GetWorkProgressDesc függvény szálon fut vagy nem? A helyes válasz egy pontot ér, a helytelen válasz meg négy mázsa virgácsot.
Szorgalmi házi feladat profiknak. Szükséges ismeretek: message loop, ablakok és szálak viszonya.
Legyen a következő TThread implementációnk:
type
TSomeThread = class(TThread)
protected
FReceiver: THandle;
FStuffs: TThreadList;
procedure Execute; override;
procedure MessageHandler(var Msg: TMessage);
procedure ProcessStuff(Stuff: pointer);
public
procedure AddStuff(Stuff: pointer);
constructor Create;
destructor Destroy; override;
end;
constructor TSomeThread.Create;
begin
FStuffs := TThreadList.Create;
FStuffs.Duplicates := dupAccept;
FReceiver := Classes.AllocateHwnd(MessageHandler);
inherited Create(false);
end;
destructor TSomeThread.Destroy;
begin
Terminate;
PostMessage(FReceiver, WM_USER, 0, 0);
inherited;
DeallocateHWnd(FReceiver);
end;
procedure TSomeThread.AddStuff(Stuff: pointer);
begin
FStuffs.Add(Stuff);
PostMessage(FReceiver, WM_USER, 0, 0);
end;
procedure TSomeThread.Execute;
var
LockedStuffs: TList;
Stuff: pointer;
Msg: TMsg;
begin
while not Terminated do begin
GetMessage(Msg, 0, 0, 0);
DispatchMessage(Msg);
repeat // checkpoint 1 - we never get here
LockedStuffs := FStuffs.LockList;
try
if LockedStuffs.Count > 0
then Stuff := LockedStuffs[0]
else Stuff := nil;
finally
FStuffs.UnlockList;
end;
if Stuff = nil then break;
ProcessStuff(Stuff);
FStuffs.Remove(Stuff);
until Terminated;
end;
end;
procedure TSomeThread.MessageHandler(var Msg: TMessage);
begin
// null
end; // checkpoint 2 - this runs fine
procedure TSomeThread.ProcessStuff(Stuff: pointer);
begin
sleep(1000); // imitate work
end;
A dolog nem bonyolult, a FStuffs egy lista, amiben tennivalók vannak. Az AddStuff metódussal adhatunk az objektumnak Stuffokat, amiket az majd feldolgoz. Az AddStuff hozzáadja a Stuffot a listához, majd üzenetet küld egy dedikált üzenetfogadó ablaknak. Ez azért lesz hasznos, mert az Execute guru meditációba zuhan mindjárt az elején (GetMessage), és csak akkor ébred fel, ha üzenet jön. Ekkor megnézi, hogy valóban van-e tennivaló, ha van, azokat megcsinálja, majd ismét meditációba zuhan. Így a szál nem fogyaszt semmilyen erőforrást amikor éppen nincs mit tennie. Érdekesség, hogy magával az üzenettel nem kezdünk semmit, elég nekünk az a tény, hogy a GetMessage visszatért.
Tesztelés céljából dobjunk össze egy kis programot, hozzunk létre egy TSomeThread példányt, és akár ott rögtön, akár egy kis idő múlva, vagy akár gombnyomásra adjunk neki AddStuffal valami munkát.
A program nem működik, soha nem jut túl a GetMessage soron. Az a furcsa, hogy ha a checkpoint 2-vel jelölt sorra töréspontot teszünk, akkor a program ott szépen megáll, vagyis az üzenet megérkezik az ablaknak. Ennek ellenére a checkpoint 1-et a program soha nem éri el. Mi lehet az oka?
procedure TMainForm.EnableControls(Enable: boolean); begin NumberEdit.ReadOnly := not Enable; CalcButton.Enabled := Enable; StopButton.Enabled := not Enable; end;Maga a számítás pedig nagyon hasonló az OnIdle megoldáshoz, csak itt nem hagyjuk abba a műveletet egy részlet után, hanem egyvégtében megcsináljuk. Közben az ott ismertetett módon számáló és GetTickCount segítségével időzítve néha megnézzük az üzeneteket.
Aborted := false;
Done := false;
CurrentDivisor := 1;
i := 0;
LastTime := GetTickCount;
repeat
CurrentDivisor := CurrentDivisor + 1;
Done := (TheNumber mod CurrentDivisor = 0) or (CurrentDivisor > MaxDivisor);
i := (i + 1) and $3FFF;
if (i = 0) and (GetTickCount - LastTime > 50) then begin
ResultLabel.Caption := 'is still being tested, ' + IntToStr(CurrentDivisor);
LastTime := GetTickCount;
Application.ProcessMessages;
end;
until Aborted or Done;
Az Aborted változó kezdetben false. Az A.PM lehetővé teszi, hogy a stop gomb megkapja a lenyomásról szóló üzenetet, így az OnClick eseménye meghívódhat, hogy az Aborted-et true-ra állítsa.
procedure TMainForm.StopButtonClick(Sender: TObject); begin Aborted := true; end;A tényleges végrehajtási sorrend valami ilyen:
...
ResultLabel.Caption := 'is still being tested, ' + IntToStr(CurrentDivisor);
LastTime := GetTickCount;
Application.ProcessMessages;
... barangolás a delhi VCL dzsungelében
... win32 PeekMessage / DispatchMessage
... barangolás a delhi VCL dzsungelében
... StopButtonClick meghívása
Aborted := true;
... StopButtonClick vége, ismét barangolás sokfelé
... végül folytatódik a TestIfPrime
end;
until Aborted or Done;
...
Ha ezt a hurkos futási sorrendet megértjük, és el tudjuk képzelni az eseménykezelőinket az A.PM után beszúrva, soha nem érhet meglepetés. Szükséges a nemkívánatos GUI elemeket letiltogatni, vagy az eseménykezelőiket úgy megírni, hogy figyelemmel legyenek a folyamatban levő számításra. Ha a form túlságosan tele van mindenfélével, kézenfekvő megoldás egy "kérem várjon" ablakot mutatni folyamat alatt.
Ez tehát az Application.ProcessMessages megoldás. Nem nagy ügy, kényelmes, és hatékony. Azért legközelebb mutatunk szálas megoldást is.
private
TheNumber: Int64;
CurrentDivisor: Int64;
Running: boolean;
A TheNumber változóban fogjuk tárolni a megadott számot. A CurrentDivisor tartalmazza a legutóbb tesztelt osztót. A program innen folytatja majd a nagyobb osztók felé az ellenőrzést. A számítás kezdetén ez egy, ami ugyan nem volt tesztelve, de így az első valóban tesztelt osztó a kettő lesz. A Running változó elkerülhető lett volna, ha a CurrentDivisor valamely érvénytelen értékével (pl nulla) jelezzük azt, hogy nincs tennivaló. De a jobb érthetőség kedvéért egy boolean változó helyfoglalása igazán jutányos ár.
A program inicializációjában feliratkozunk az Application.OnIdle eseményre.
procedure TMainForm.FormCreate(Sender: TObject); begin Application.OnIdle := AppIdle; end;Ha a felhasználó megváltoztatja a beírt számot, ennek megfelelően változtatjuk a feladat állapotát. Az eseménykezelőben a try..except között részt az alábbi módon készítjük el.
TheNumber := StrToInt64(NumberEdit.Text);
if TheNumber < 2 then begin
ResultLabel.Caption := 'is too tiny';
Running := false;
end else begin
CurrentDivisor := 1;
Running := true;
ResultLabel.Caption := 'is still being tested';
end;
Ha a beírt szám megfelelő, a Running beállításával engedélyezzük a futást, és a felhasználót tájékoztatjuk, hogy a kért feladaton dolgozunk. A többi az OnIdle dolga.
procedure TMainForm.AppIdle(Sender: TObject; var Done: Boolean);
var
StartTime: cardinal;
i: cardinal;
MaxDivisor: Int64;
begin
if Running then begin
StartTime := GetTickCount;
i := 0;
MaxDivisor := Trunc(Sqrt(0.0+TheNumber));
repeat
CurrentDivisor := CurrentDivisor + 1;
Done := (TheNumber mod CurrentDivisor = 0) or
(CurrentDivisor > MaxDivisor);
i := (i + 1) and $3FFF;
until Done or ((i = 0) and (GetTickCount - StartTime > 50));
if Done then begin
if MaxDivisor < CurrentDivisor then
ResultLabel.Caption := 'is prime'
else
ResultLabel.Caption := 'is not a prime, a divisor is ' +
IntToStr(CurrentDivisor);
end else begin
ResultLabel.Caption := 'is still being tested, ' +
IntToStr(CurrentDivisor);
end;
Running := not Done;
end else begin
Done := true;
end;
end;
Az algoritmus nem szorul sok magyarázatra, a szám gyökéig ellenőrizzük a potenciális osztókat, ha osztható, kész vagyunk, ha elértük a maximumot, akkor nincs osztó, és kész vagyunk. Ami érdekesebb, az az i változó szerepe, valamint a GetTickCount használata. Az ilyen egyszerű feladatra valószínűleg túlzottan összetett megoldást állatorvosi lónak szántuk. Hogy a programunk élő maradjon, de egyben optimumközeli számítási teljesítménye is legyen, se túl sokszor, se túl ritkán nem szabad a munkát megszakítani. Önkényesen kijelölhetünk két elvárható értéket, a minimális hasznos processzoridőt, és a program maximális reakcióidejét. Kézenfekvő a processzorkihasználtságot 99-99,9% környékén kijelölni, a minimális válaszadási időt pedig 10-100ms között.
Hogy mennyi ideig tart egy üres PeekMessage hívás, azt csak találgatni lehet, de ha például tízmillió óraciklusnyi számolásra jut egy PeekMessage, joggal gondolhatjuk, hogy annak aránya jócskán 1% alatt lesz. Tízmillió óraciklus GHz órafrekvenciás CPU esetén miliszekundum időtartományba esik, tehát van kényelmes mozgásterünk.
A fenti programrészlet a túlbonyolítás módszerét használja. Célunk, hogy 50ms legyen az egyvégtében megcsinált számítás. Erre a Windows által biztosított 10-20ms pontosságú számlálót használjuk fel, amit a GetTickCount API hívással szerezhetünk meg. Feltételezhetjük, hogy a GetTickCount igen gyors, de mégis lassú a prímteszt ciklusmagjában található kódhoz hasonlítva (modulo, összehasonlítások, összeadás és bináris és). Ezért itt egy trükköt alkalmazunk, egy párhuzamosan modulo-növelünk egy változót, és csak ha az nulla, akkor ellenőrizzük, hogy eltelt-e a tervezett idő. A modulo nagyságát úgy határozzuk meg, hogy a GetTickCount várhatóan elenyésző legyen a számításhoz képest. Ezt az értéket most találomra $4000-ben határoztuk meg, ami decimális 16384, és azt gondoljuk, hogy ennyi osztáshoz képest egy egész érték visszahozása nem lehet számottevő. A szám választásakor egy bináris kerek számot választottunk, hogy a modulo számítása bináris és művelettel elvégezhető legyen, ami sokkal gyorsabb, mint a modulo.
A ciklusból ki kell lépni, ha osztót találtunk, elértük a maximum osztót, vagy lejárt az idő. Előbbi két esetben a Done értéke true lesz, az időtúllépéskor false. Mindhárom esetben a felhasználót tájékoztatjuk a feladat állapotáról.
A végeredmény pedig letölthető itt
Ha az ember programozó, és még rigolyás is, gyakran lát nem éppen elmés megoldásokat programokban. Ezeket kipellengérezzük, és ha kedvet érzünk, esetleg közlünk egy vagy több helyes megfejtést is.
RSS
balinto 2006