Umíme to s Delphi, 54. díl – vlákna a paralelní programování: pokračování

Dnes jsou na řadě další témata spojená s používáním vláken v Delphi. Nejprve si prakticky ukážeme používání kritických sekcí, pak si povíme o dalších možnostech synchronizace vláken. Ve druhé části tohoto dílu nás čekají priority vláken – a to není všechno.
Před týdnem jsme skončili teoretickým popisem jednoho z nejčastěji používaných synchronizačních mechanismů při práci s vlákny – kritických sekcí. Dnes začneme tím, že společně vytvoříme aplikaci, která nám jejich použití prakticky předvede.

Zopakujme si, že kritické sekce používáme v těch případech, kdy potřebujeme zajistit, aby k určitému globálnímu objektu nemohlo přistoupit více vláken najednou. Přesněji řečeno – když vlákno A provádí nějakou operaci s globálním objektem (např. proměnnou), jiné vlákno nesmí s toutéž proměnnou manipulovat až do chvíle, kdy vlákno A manipulaci kompletně nedokončí.

A právě to si ukážeme na velmi jednoduché aplikaci. Nejprve ji vytvoříme bez použití kritických sekcí a ukážeme si, kde vznikají problémy.

Vytvořte novou aplikaci, formulář nazvěte frmHlavni a umístěte na něj dvě komponenty – tlačítko (btnKonec) a nápis (lblHodnota). Aplikace bude obsahovat dvě vlákna, obě se vytvoří a spustí při startu aplikace. První z vláken bude obsahovat smyčku, která milionkrát inkrementuje hodnotu globální proměnné. Druhé z vláken odečte od hodnoty globální proměnné třeba číslo 200 a výsledek vypíše.

Nebudeme krok za krokem popisovat vytvoření této aplikace, je velmi podobná příkladu z minulého dílu seriálu, který je popsán velmi podrobně. Prohlédněte si proto celý zdrojový kód:

unit Hlavni;

interface

uses
  Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs,
  StdCtrls;

type
  TfrmHlavni = class(TForm)
    lblHodnota: TLabel;
    btnKonec: TButton;
    procedure FormCreate(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
    procedure btnKonecClick(Sender: TObject);
  private
    { Private declarations }
  public
    { Public declarations }
  end;


type TPrictiVlakno = class(TThread)
  protected
    procedure Execute; override;
end;

type TOdectiVlakno = class(TThread)
  protected
    procedure Execute; override;
    procedure ObnovLabel;
end;

var
  frmHlavni: TfrmHlavni;
  PrictiVlakno: TPrictiVlakno;
  OdectiVlakno: TOdectiVlakno;
  Hodnota: LongInt;

implementation

{$R *.DFM}

procedure TPrictiVlakno.Execute;
var
  Pomocna, I: LongInt;

begin
  for I := 1 to 1000000 do begin
    Pomocna := Hodnota;
    Inc(Pomocna);
    Hodnota := Pomocna;
  end;
end;

procedure TOdectiVlakno.Execute;
var
  Pomocna: LongInt;

begin
  Pomocna := Hodnota;
  Pomocna := Pomocna - 200;
  Hodnota := Pomocna;

  Synchronize(ObnovLabel);
end;

procedure TOdectiVlakno.ObnovLabel;
begin
  frmHlavni.lblHodnota.Caption := IntToStr(Hodnota);
end;


procedure TfrmHlavni.FormCreate(Sender: TObject);
begin
  Self.Caption := `Kritické sekce`;
  btnKonec.Caption := `&Konec`;
  lblHodnota.Caption := ``;

  PrictiVlakno := TPrictiVlakno.Create(False);
  OdectiVlakno := TOdectiVlakno.Create(False);
end;

procedure TfrmHlavni.FormDestroy(Sender: TObject);
begin
  PrictiVlakno.Free;
  OdectiVlakno.Free;
end;

procedure TfrmHlavni.btnKonecClick(Sender: TObject);
begin
  Application.Terminate;
end;

end.

Po spuštění aplikace se objeví formulář s vypočtenou hodnotou. Když si aplikaci spustíte několikrát za sebou, zjistíte, že výsledek je pokaždé jiný. Je to způsobeno tím, že vlákno počítající smyčku je v nějakém okamžiku systémem pozastaveno, druhé vlákno vezme aktuální hodnotu globální proměnné (tedy nějaké číslo z intervalu 0 až 1000000, jehož výše závisí na tom, jak dlouho nechal systém první vlákno pracovat), odečte od něj 200 a vypíše jej. Pak pokračuje druhé vlákno ve své smyčce.

Poznámka pro majitele rychlých strojů: Zde můžete narazit na menší problém – výsledek běhu aplikace bude pořád stejný, a to milion bez 200. Problém je v tom, že na rychlejších počítačích stihne 1. vlákno napočítat do milionu ještě než se vůbec spustí 2. vlákno. Je tedy nutné empiricky vyzkoušet a nastavit horní mez podle výkonu vašeho PC...:)

K aplikaci je nutné dodat tolik, že na několika místech je trochu vyumělkovaná a v reálné situaci by určitě vypadala jinak. Příkladem budiž operace, kterou vlákno provádí s globální proměnnou – nejprve si vytvoří její lokální kopii, pak provede operaci s touto kopií a nakonec zase přiřadí hodnotu do globálního objektu. Bylo by samozřejmě možné provést operaci přímo s globální proměnnou, nicméně tento příklad lépe simuluje reálnou situaci, při které se s globální proměnnou provádí složitá operace. Proto se obvykle vytvoří lokální kopie a pracuje se s ní.

Nyní pojďme implementovat kritické sekce. Budeme chtít, aby první vlákno nemohlo být přerušeno ve svém cyklu a aby druhé vlákno mohlo odečíst hodnotu až po průběhu celé smyčky. Proto uzavřeme celou smyčku do kritické sekce – a do kritické sekce uzavřeme také manipulaci s globální proměnnou, kterou provádí druhé vlákno. Pozor! Tím nezajistíme, že by první vlákno nemohlo být v průběhu svého běhu pozastaveno – ale zajistíme tím, že druhé vlákno nebude moci odečíst 200 v době, kdy první vlákno počítá uvnitř cyklu. Právě to je význam kritických sekcí.

Ve zdrojovém kódu tedy provedeme následující modifikace:

  • Do sekce Uses připíšeme modul SyncObjs, abychom mohli využívat třídu TCriticalSection.
  • Deklarujeme globální proměnnou KritickaSekce typu TCriticalSection.
  • V těle metody TPrictiVlakno.Execute „orámujeme“ celý cyklus příkazy KritickaSekce.Acquire, resp. KritickaSekce.Release.
  • Také do těla metody TOdectiVlakno.Execute přidáme tyto dva příkazy.
  • V obsluze události OnCreate hlavního formuláře kritickou sekci vytvoříme pomocí konstruktoru Create.
  • V obsluze události OnDestroy hlavního formuláře naopak kritickou sekci zrušíme pomocí metody Free.
Výsledný zdrojový kód aplikace (tučně jsou vyznačeny změny):

unit Hlavni;

interface

uses
  Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs,
  StdCtrls, Syncobjs;

type
  TfrmHlavni = class(TForm)
    lblHodnota: TLabel;
    btnKonec: TButton;
    procedure FormCreate(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
    procedure btnKonecClick(Sender: TObject);
  private
    { Private declarations }
  public
    { Public declarations }
  end;


type TPrictiVlakno = class(TThread)
  protected
    procedure Execute; override;
end;

type TOdectiVlakno = class(TThread)
  protected
    procedure Execute; override;
    procedure ObnovLabel;
end;

var
  frmHlavni: TfrmHlavni;
  PrictiVlakno: TPrictiVlakno;
  OdectiVlakno: TOdectiVlakno;
  Hodnota: LongInt;
  KritickaSekce: TCriticalSection;

implementation

{$R *.DFM}

procedure TPrictiVlakno.Execute;
var
  Pomocna, I: LongInt;

begin
  KritickaSekce.Acquire;
  for I := 1 to 1000000 do begin
    Pomocna := Hodnota;
    Inc(Pomocna);
    Hodnota := Pomocna;
  end;

  KritickaSekce.Release;
end;

procedure TOdectiVlakno.Execute;
var
  Pomocna: LongInt;

begin
  KritickaSekce.Acquire;
  Pomocna := Hodnota;
  Pomocna := Pomocna - 200;
  Hodnota := Pomocna;

  KritickaSekce.Release;
  Synchronize(ObnovLabel);
end;

procedure TOdectiVlakno.ObnovLabel;
begin
  frmHlavni.lblHodnota.Caption := IntToStr(Hodnota);
end;


procedure TfrmHlavni.FormCreate(Sender: TObject);
begin
  Self.Caption := `Kritické sekce`;
  btnKonec.Caption := `&Konec`;
  lblHodnota.Caption := ``;

  PrictiVlakno := TPrictiVlakno.Create(False);
  OdectiVlakno := TOdectiVlakno.Create(False);

  KritickaSekce := TCriticalSection.Create;

end;

procedure TfrmHlavni.FormDestroy(Sender: TObject);
begin
  PrictiVlakno.Free;
  OdectiVlakno.Free;

  KritickaSekce.Free;
end;

procedure TfrmHlavni.btnKonecClick(Sender: TObject);
begin
  Application.Terminate;
end;

end.

Námět pro zvídavé: prohlédněte si důkladně metodu TfrmHlavni.FormCreate a zkuste přijít na to, proč její implementace obsahuje jednu velmi hrubou chybu. Až na to přijdete, raději ji hned opravte (správná verze i s vysvětlením je uvedena hned pod touto poznámkou). Na tomto příkladu je velmi hezky vidět, že paralelní programování není žádná legrace, protože najít takovou chybu v rozsáhlé vícevláknové aplikaci je takřka nemožné.

/* správné řešení metody FormCreate */

procedure TfrmHlavni.FormCreate(Sender: TObject);
begin
  Self.Caption := `Kritické sekce`;
  btnKonec.Caption := `&Konec`;
  lblHodnota.Caption := ``;

  KritickaSekce := TCriticalSection.Create;

  PrictiVlakno := TPrictiVlakno.Create(False);
  OdectiVlakno := TOdectiVlakno.Create(False);

end;

Vysvětlení metody FormCreate: pokud nejprve vytvoříme a spustíme (TVlakno.Create) obě vlákna (jak jsme to předvedli v původní verzi), a pak teprve vytvoříme kritickou sekci (TCriticalSection.Create), není jisté, že se kritická sekce vytvoří včas! Teoreticky se mohou obě vlákna rozběhnout, chvíli pracovat, ale v okamžiku přístupu k objektu KritickaSekce program pravděpodobně zhavaruje na chybu přístupu do paměti, protože KritickaSekce s největší pravděpodobností v tu chvíli ukazuje na NIL!

Toť vše! Když si nyní program několikrát spustíte, uvidíte pokaždé jeden a tentýž výsledek, a to 999800. Jakmile první vlákno vstoupí do kritické sekce, nemůže do téže kritické sekce vstoupit druhé vlákno a musí tedy čekat, až první vlákno ze sekce vystoupí (sekci uvolní).

Námět pro zvídavé uživatele: Kromě výsledku 999800 můžete čas od času spatřit i jeden jiný. Přijdete na to, jaký? A proč? Řešení bude uvedeno v úvodu příštího dílu.

Poznámka ke kritickým sekcím:

Používání kritických sekcí by správně mělo být kombinováno s mechanismem výjimek. Po vstupu do kritické sekce (Acquire) by se všechny příkazy měly provádět uvnitř chráněného bloku try a odemknutí sekce (Release) by mělo ležet v bloku finally. Důvodem je fakt, že kdyby v kterémkoliv příkazu uvnitř kritické sekce došlo k chybě, provádění programu by se nedostalo k odemčení kritické sekce (k příkazu Release) a všechna ostatní vlákna by zůstala trvale blokována! (Vzpomeňme si, že po vzniku výjimky pokračuje provádění programu příkazem následujícím za její obsluhou.)

Další možnosti synchronizace vláken

Kromě čekání na vlákno a kritických sekcí existuje samozřejmě celá řada dalších mechanismů, některé jsou složitější, jiné jednodušší. Zmíníme se o nich už stručněji, protože na podrobný popis nemáme dostatek prostoru.

Zamykání objektů

Některé objekty mají již přímo implementovaný mechanismus zamykání, který se při práci s vlákny dá úspěšně využít. Například třída TCanvas a její potomci (nám dobře známé plátno) disponuje metodou Lock, která zamezuje ostatním vláknům k manipulaci s plátnem až do okamžiku zavolání metody UnLock. Knihovna VCL dále obsahuje třídu TThreadList, která reprezentuje vláknům-bezpečný seznam objektů. Volání TThreadList.LockList vrátí seznam objektů a zároveň jej zablokuje pro ostatní vlákna až do okamžiku, kdy je zavolána metoda UnlockList.

Používání synchronizátoru Multi-Read/Exclusive-Write

Používáte-li kritické sekce k ochraně některých globálních datových objektů, může s těmito objekty manipulovat vždy pouze jedno vlákno v jednom okamžiku. To je někdy zbytečně silné omezení, zvláště v případech, kdy příslušný globální objekt nebo proměnná musí být velmi často čten, ale poměrně zřídka je do něho zapisováno. Pokud několik vláken zároveň čte stejný paměťový objekt (ale nikdo do něho nezapisuje), nehrozí přece žádné nebezpečí. V takovém případě je vhodné použít synchronizátor Mult-Read/Exclusive-Write, který je implementován ve třídě TMultiReadExclusiveWriteSynchronizer. Tento objekt funguje podobně jako kritická sekce, ale umožní přistupovat více vláknům k témuž objektu zároveň až do okamžiku, kdy některé vlákno chce do tohoto objektu zapsat. Každé vlákno, které chce přistupovat k objektu, zavolá metodu BeginRead, ta zkontroluje, zda nikdo do objektu nezapisuje a když ne, je čtení povoleno, bez ohledu na to, kolik dalších vláken v tomto okamžiku provádí současné čtení. Na konci čtení je nutné zavolat metodu EndRead. Vlákno, které chce zapisovat, musí zavolat metodu BeginWrite (která zjistí, zda jiné vlákno právě nečte či nezapisuje). Zapisování se ukončí metodou EndWrite. Stejně jako v případě kritických sekcí, také tento synchronizační mechanismus pracuje pouze v případě, že jej důsledně využívají všechna zúčastněná vlákna.

Mechanismus vzájemného vyloučení (MUTEX)

MUTual EXclusion, vzájemné vyloučení, se také podobá kritickým sekcím. Liší se však ve třech zásadních bodech:

  • Zatímco kritické sekce jsou lokální pro daný proces (a vlákna z jiného procesu je tedy nevidí), vzájemná vyloučení jsou globální v celém systému a jsou „viditelná“ ze všech procesů.
  • Na vzájemné vyloučení je možné čekat se stanovením časového limitu, zatímco při čekání na uvolnění kritické sekce není možné stanovit žádný limit.
  • Vzájemné vyloučení je mnohem pomalejší než kritická sekce.
Při implementaci vzájemného vyloučení se používá funkcí Windows API CreateMutex (vytvoření), CloseHandle (zrušení), WaitForSingleObject (obsazení vytvořeného mutexu) a ReleaseMutex (uvolnění obsazeného mutexu).

Semafory

Posledním synchronizačním mechanismem, o kterém se zmíníme, jsou tzv. semafory. Stejně jako dosud uvedené možnosti umožňují zakázat současný přístup k objektu, ale na rozdíl od všech zmíněných přinášejí jedno významné vylepšení: umožňují stanovit počet vláken, které mohou zároveň přistupovat. Teprve když počet paralelně přistupujících vláken převýší tuto hodnotu, semafor se „uzavře“ a další vlákno k objektu nepustí, dokud některé vlákno ze semaforu „nevypadne“. K využívání semaforů nedisponuje bohužel Delphi žádnou „vhodnou“ třídou, nicméně je možné použít třeba funkci Windows API CreateSemaphore.

Priorita vláken

Dalším důležitým bodem při práci s vlákny je jejich priorita. Priorita vláken určuje, kolik strojového (výpočetního) času bude vláknu přidělováno operačním systémem. Než si ukážeme, jak nastavovat prioritu námi vytvářených vláken, povíme si stručně něco o tom, jak operační systém priority stanovuje a používá.

Po vytvoření nového procesu (tedy při spuštění aplikace) je tomuto procesu operačním systémem přidělena tzv. třída priority. Ta je stanovena na základě důležitosti procesu (a uživatele) a určuje jakousi důležitost tohoto procesu. Z této třídy priority odvodí operační systém prioritu, kterou přiřazuje nově vytvářeným vláknům tohoto procesu (tzv. základní priorita). Pokud tedy explicitně nepřiřadíme svým vláknům žádnou prioritu, bude jejich priorita přiřazena operačním systémem na základě třídy priority našeho procesu.

K čemu systém prioritu vláken používá? Systém vlákna vyšších procesů plánuje přímo úměrně jejich prioritě. Operační systémy mají celou řadu strategií, podle kterých přiřazují strojový čas, ale (skoro) vždy nějakým způsobem vycházejí z priorit jednotlivých vláken.

Nyní se již dostáváme k tomu, jakým způsobem určit prioritu vytvářených vláken. Řekli jsme si, že tato priorita vychází z třídy priority procesu. Nastavováním třídy priority procesu se nebudeme podrobně zabývat, protože příliš nesouvisí s náplní našeho článku. Jen naznačíme, že programově je možné použít funkci Windows API SetPriorityClass s parametrem GetCurrentProcess(), případně lze prioritu nastavit ručně (ve Windows 2000 a XP) pomocí Správce úloh (klepnout na proces pravým tlačítkem a zvolit Nastavit prioritu).

V dalším textu si však podrobně ukážeme něco jiného: u každého vytvářeného vlákna můžeme stanovit relativní prioritu vzhledem k základní prioritě procesu.

Toto stanovení můžeme učinit dvěma způsoby:

  • Volním funkce Windows API SetThreadPriority.
  • Nastavením vlastnosti Priority objektu vlákna (tohoto způsobu s používá, pokud vlákna vytváříme pomocí třídy TThread – tedy námi používaným způsobem).
Jak jsme si řekli, nemůžeme určit třídu priority procesu, ale můžeme určit, v jakém vztahu k prioritě procesu (k základní prioritě) bude priorita každého z vláken. Možné priority (možné hodnoty vlastnosti TThread.Priority) ukazuje následující tabulka:

Označení Popis
tpIdle Vlákno běží pouze v případě, že systém je jinak zcela nečinný. Systém nepřeruší provádění žádného vlákna, aby přidělil strojový čas vláknu s prioritou tpIdle.
tpLowest Priorita vlákna je dva stupně pod základní prioritou.
tpLower Priorita vlákna je jeden stupeň pod základní prioritou.
tpNormal Vlákno má základní prioritu.
tpHigher Priorita vlákna je jeden stupeň nad základní prioritou procesu.
tpHighest Priorita vlákna je dva stupně nad základní prioritou procesu.
tpTimeCritical Vlákno má nejvyšší prioritu

Poznámka k přiřazování priorit

Ještě než si ukážeme, jak prioritu nastavit, poznamenáme něco k programátorské kultuře. Samozřejmě – vývojářům v zásadě nic nebrání v tom, aby svým vláknům nastavovali nejvyšší prioritu. Není to ovšem slušné – a ani praktické, protože pokud se budou všechna vlákna rvát o nejvyšší priority, bude význam priorit stejně devalvován.

Nejvyšší priority nastavujte skutečně jen v případech, kdy si to povaha úkolu vyžaduje. Pro obyčejné akce si zpravidla vystačíte se základní prioritou.

Na závěr

Tolik pro dnešek k vláknům v Delphi. Za týden budeme pokračovat – nejprve společně vytvoříme ukázkovou aplikaci, na které si předvedeme nastavování priorit vláken a také jejich význam, a to rozhodně nebude všechno: už jste vytvářeli vícevláknovou MDI aplikaci, ve které každé vlákno pracuje s jedním otevřeným dokumentem?
Diskuze (14) Další článek: nVidia a Keyhole

Témata článku: , , , , , , , , , , , , , , , , , , , , , , , , ,