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

Před týdnem jsme otevřeli téma vláken v Delphi. Dnes nadešel čas na vytvoření prvního programu používajícího vlákna. Kromě toho si řekneme, kdy vlákna používat a kdy ne, ukážeme si problémy při práci s vlákny a mnoho dalšího. Víte třeba, jak svou aplikaci pomocí vláken úspěšně zpomalit?
První aplikace používající vlákna

Práce s vlákny ve Windows API je velmi rozmanitá a její naučení stojí určité úsilí. Jak už to tak bývá, Delphi přináší velmi výrazné zjednodušení při práci s vlákny: je jím třída TThread. Později se touto třídou budeme zabývat podrobně, dnes si jen ukážeme, jak s její pomocí snadno a rychle vytvořit aplikaci využívající vlákna. Upravíme za tím účelem aplikaci vykreslující úsečky, kterou jsme vytvořili před týdnem.

Nejprve je nutné oddědit potomka třídy TThread. Takto vzniklá třída bude reprezentovat námi vytvářené vlákno.

1. Vytvořte novou aplikaci (File – New Application) a na formulář umístěte následující komponenty: dvě tlačítka (btnStart a btnKonec) a jeden Image (imgClassic).

2. Nyní za deklaraci třídy TfrmHlavni přidejte deklaraci potomka třídy TThread, v našem případě třídy TCaryVlakno. Deklarace bude obsahovat atributy pro uchování informací o generované čáře a dvě metody: Execute (pro vlastní činnost vlákna, tedy generování náhodných čísel) a ObnovImage pro manipulaci s komponentou Image:

type
  TCaryVlakno = class(TThread)
  private
    SourX1, SourX2, SourY1, SourY2: Integer;
    Barva: TColor;
  protected
    procedure Execute; override;
    procedure ObnovImage;
  end;

3. Nyní musíme především implementovat metodu TCaryVlakno.Execute, ve které se bude provádět vlastní programový kód vlákna. Všimněte si především zvláštního způsobu volání metody ObnovImage pomocí metody Synchronize. Podrobně si vše popíšeme příště, dnes jen stručně naznačme, že pokud potřebujeme uvnitř vlákna provádět manipulace s některou komponentou knihovny VCL (jako je například Image), je nutné tyto manipulace provádět ve zvláštní metodě (v našem případě ObnovImage) a volat ji pomocí metody Synchronize. Není možné „strčit“ manipulaci s komponentou přímo do metody Execute. Všimněte si také, že nikde v programovém kódu není metoda Execute explicitně volána; její volání je zařízeno automaticky (metodou vlákna Resume, která je volána po stisknutí tlačítka btnStart):

procedure TCaryVlakno.Execute;
begin
  repeat
    SourX1 := Random(frmHlavni.imgClassic.Width);
    SourY1 := Random(frmHlavni.imgClassic.Height);
    SourX2 := Random(frmHlavni.imgClassic.Width);
    SourY2 := Random(frmHlavni.imgClassic.Height);

    Barva := TColor(Random($FFFFFF));

    Synchronize(ObnovImage);
  until (Terminated = True);
end;

4. Nyní implementujeme zmíněnou metodu vlákna ObnovImage. Jejím úkolem je pouze manipulace s vizuální komponentou Image:

procedure TCaryVlakno.ObnovImage;
begin
  frmHlavni.imgClassic.Canvas.Pen.Color := Barva;
  frmHlavni.imgClassic.Canvas.MoveTo(SourX1, SourY1);
  frmHlavni.imgClassic.Canvas.LineTo(SourX2, SourY2);
end;

5. Nyní nadefinujeme globální proměnnou, která bude představovat objekt vlákna. Definici připište za definici globální proměnné frmHlavni:

var
  frmHlavni: TfrmHlavni;
  Vlakno: TCaryVlakno;

6. Nyní ošetříme událost OnCreate hlavního formuláře. Kromě standardních inicializací musíme také vytvořit objekt vlákna. Parametr konstruktoru True znamená, že vlákno se po vytvoření ihned nespustí, vytvoří se pozastavené a spuštění se provede až později zavoláním jeho metody Resume:

procedure TfrmHlavni.FormCreate(Sender: TObject);
begin
  frmHlavni.Caption := `Ukázka práce s vlákny`;
  btnStart.Caption := `&Start`;
  btnKonec.Caption := `&Konec`;

  Vlakno := TCaryVlakno.Create(True);
end;

7. Ošetříme událost OnClick tlačítka btnStart. V těle události pouze zakážeme tlačítko Start a pozastavené vlákno spustíme:

procedure TfrmHlavni.btnStartClick(Sender: TObject);
begin
  btnStart.Enabled := False;
  Vlakno.Resume;
end;

8. Nyní ještě musíme zajistit korektní obsluhu klepnutí na tlačítko btnKonec:

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

9. Na úplný závěr se postaráme o zrušení objektu vlákna při ukončení aplikace:

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

Aplikace je hotova. Aby bylo její vytvoření co možná nejjednodušší, pozměnili jsme trochu funkčnost (především chybí údaje o čase, apod.). Smyčka generující nové čáry je nyní nekonečná, takže dokud neukončíme aplikaci, jsou stále vykreslovány další a další čáry.

Pro úplnost si uveďme kompletní zdrojový kód tohoto modulu:

unit Hlavni;

interface

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

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

type
  TCaryVlakno = class(TThread)
  private
    SourX1, SourX2, SourY1, SourY2: Integer;
    Barva: TColor;
  protected
    procedure Execute; override;
    procedure ObnovImage;
  end;

var
  frmHlavni: TfrmHlavni;
  Vlakno: TCaryVlakno;

implementation

{$R *.DFM}
procedure TCaryVlakno.Execute;
begin
  repeat
    SourX1 := Random(frmHlavni.imgClassic.Width);
    SourY1 := Random(frmHlavni.imgClassic.Height);
    SourX2 := Random(frmHlavni.imgClassic.Width);
    SourY2 := Random(frmHlavni.imgClassic.Height);

    Barva := TColor(Random($FFFFFF));

    Synchronize(ObnovImage);
  until (Terminated = True);
end;

procedure TCaryVlakno.ObnovImage;
begin
  frmHlavni.imgClassic.Canvas.Pen.Color := Barva;
  frmHlavni.imgClassic.Canvas.MoveTo(SourX1, SourY1);
  frmHlavni.imgClassic.Canvas.LineTo(SourX2, SourY2);
end;

procedure TfrmHlavni.FormCreate(Sender: TObject);
begin
  frmHlavni.Caption := `Ukázka práce s vlákny`;
  btnStart.Caption := `&Start`;
  btnKonec.Caption := `&Konec`;

  Vlakno := TCaryVlakno.Create(true);
end;

procedure TfrmHlavni.btnStartClick(Sender: TObject);
begin
  btnStart.Enabled := False;
  Vlakno.Resume;
end;

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

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

end.

Aplikace není srozumitelná?

Je nanejvýše pravděpodobné, že vám teď není vůbec jasné, proč aplikace vypadá právě tak. Možná plně nerozumíte metodě Execute (kde se vzala? proč má tento název?) ani metodě ObnovImage (proč se úsečky nevykreslí přímo v Execute, když se tam generují náhodná čísla?). Je možné, že úplnou záhadou je pro vás volání metody Synchronize.

V tomto okamžiku to vůbec nevadí. Programování vláken není úplně triviální činnost a zatím jsme se nedozvěděli ani zdaleka všechny potřebné informace, které nám umožní do této problematiky zřetelně vidět. Prozatím si pouze aplikaci vyzkoušejte a programový kód prohlédněte. Důležité je, abyste věděli, že k programování vláken je nutné vytvořit potomka třídy TThread, která v Delphi zapouzdřuje objekty vláken, a že hlavní činnost vlákna se provádí v metodě Execute. Později si vysvětlíme všechny další podstatné detaily. Předtím nás ovšem čeká možná nudný, ale nezbytný teoretický rozbor, který by měl zájemcům osvětlit problematiku vláken podrobněji a který by také měl odpovědět na otázku, kdy vlákna používat a kdy ne.

Proč (a kdy) používat vlákna?

Zcela zásadní otázkou, kterou bychom si měli položit, je, ve kterých situacích používat vlákna. Vždyť celá řada aplikací, které jsme až dosud společně vytvořili, fungovala vcelku dobře a žádná vlákna nepotřebovala.

Řada programátorů žije v domnění, že když už se vlákna jednou naučila, musí je nutně využívat ve všech aplikacích. Samozřejmě je to možné – takřka jakoukoliv aplikaci, která nevyužívá vlákna, můžeme přepsat tak, aby vlákna používala. V tom problém nebude. Do aplikací je možné doslova „narvat“ tolik vláken, že až oči přecházejí. Nastanou možná problémy s jejich synchronizací (o ní si povíme později), nicméně v principu jsou tyto potíže řešitelné a jejich úspěšné „rozlousknutí“ většinou příslušného programátora motivuje k ještě hojnějšímu využívání vláken.

Každý vývojář by si ale měl uvědomit, že aplikace nepíše pro vývojáře, ale pro uživatele. Takže: nepoužívejte vlákna jen proto, abyste ukázali, že to umíte. Používejte je jen tam, kde by bez nich byla aplikace pro uživatele pomalá. V zásadě neexistuje žádný jiný smysluplný důvod. Takže jsme se přesunuli od problému „kdy používat vlákna“ k problému „jak určit, je-li aplikace pro uživatele pomalá“.

A nebo existuje ještě jeden možný pohled: problém není v tom, že by aplikace bez vláken byla pro uživatele pomalá. S použitím všech dostupných prostředků lze napsat velice rychlou aplikaci i bez vláken! Stojí to ale mnohonásobně více úsilí než při použití vláken a výsledkem je příšerný zdrojový kód. Důvodem použití vláken tedy není ani tak: "aplikace je bez nich pomalá", ale "potřebujeme zajistit efektivní souběžné provádění více činností". Takže se spíše od problému "Kdy použít vlákna" přesouváme k problému "Jak určit, je-li vhodné některé činnosti provozovat souběžně." I když totiž vyřešíme problém "určení, je-li aplikace pomalá" s výsledkem, že JE POMALÁ, pořad nemáme jasno v tom, jestli použít vlákno nebo ne.

Na používání vláken se tedy můžeme dívat z několika zorných úhlů; v každém případě však platí, že jejich užití by mělo adekvátně reagovat na reálný problém, nikoliv demonstrovat profesní zdatnost autora.

Je důležité mít stále na paměti, že pracujeme na jednoprocesorovém počítači, a že tedy počítač může v jednom okamžiku provádět jen jednu věc, i kdyby se tvářil sebeparalelněji. Pouhým „rozsekáním“ výpočtu do vláken tedy aplikaci nezrychlíte. Když budete programovat algoritmus počítající součin dvou matic, bude vcelku lhostejné, jestli použijete pro výpočet každého prvku matice samostatné vlákno a nebo jestli spočtete výslednou matici pěkně sériově, prvek za prvkem. Jednoprocesorový počítač bude v obou případech prostě postupně vypočítávat prvek za prvkem, jediný rozdíl může spočívat v pořadí prvků. Na víceprocesorovém stroji by byla situace jiná, tam by použití vláken bylo velmi výhodné: každý procesor by mohl počítat jeden prvek výsledné matice.

Podobným příkladem může třeba být hledání minimálního prvku v rozsáhlé matici. Jeden procesor musí prostě projít všechny prvky a nakonec „vyplivne“ jejich minimum. Použití vláken v tomto případě vůbec nepomůže. Máme-li však více procesorů, může každý z nich zároveň procházet určitou podmatici a na závěr se jen najde minimum z výsledků těchto podúloh – vláken.

My se ovšem zabýváme obyčejnými počítači, tedy počítači s jedním procesorem. Možná by se teď zdálo, že vlákna prakticky postrádají význam – procesor stejně stíhá dělat jen jednu věc, tak jaképak urychlování?

Urychlování je samozřejmě možné i v tomto případě. Představte si jinou situaci. Programujete textový editor, který má mít dvě hlavní funkce: umožni uživateli psát text a provádět kontrolu pravopisu. Pokud nepoužijete vlákna, bude uživatel moci buďto psát text, nebo bude možné kontrolovat pravopis. Nic mezi tím (i když pozorný čtenář mě možná opraví – událost OnIdle by v tomto případě byla vcelku vhodnou alternativou vláken). Jak bychom si pomohli s vlákny? Samozřejmě tak, že jedno vlákno by uživateli umožňovalo psát text (staralo by se třeba o jeho formátování, apod.) a druhé by mezitím kontrolovalo pravopis napsaných slov. Za přepínání vláken je sice zodpovědný systém, ale vhodným nastavením priorit oběma vláknům můžeme dosáhnout optimálního výkonu aplikace.

Vidíme, že v uvedeném příkladě je použití vláken velmi vhodné. Je vhodné především proto, že aplikace provádí rozsáhlé vstupně/výstupní operace (čekání na uživatelský vstup je obecně jedna z nejpomalejších I/O operací). A to je právě situace, ve které (velmi obecně vzato) vlákna pomáhají. Pokud programujete rutinu s I/O operacemi, bývají vlákna opodstatněná, neboť I/O operace jsou v zásadě pomalé a „zároveň“ s nimi může počítač vykonávat ještě řadu dalších činností.

Takže shrňme: chceme najít součin dvou rozsáhlých matic. Pokud jsou tyto matice uloženy v paměti, použitím vláken výpočet nezrychlíme (naopak – ještě jej zpomalíme, jak si vysvětlíme záhy). Pokud jsou ovšem tyto matice uloženy na disku, může se již použití vláken vyplatit (každé vlákno počítá jeden prvek výsledku), protože zatímco jedno vlákno čeká na načtení svých dat z disku (což je pomalá operace), jiné vlákno může provádět výpočet se svými (již načtenými) daty.

Samozřejmě – tyto poučky jsou pouze teoretické, takže praktický význam je vždy nutné posuzovat konkrétně pro každý případ.

Jak aplikaci zpomalit použitím vláken?

Řekli jsme si, že nevhodným použitím vláken aplikaci nezrychlíme. To ovšem není vše – můžeme ji totiž také velmi úspěšně zpomalit. Pokud převedeme do vláken výpočet, který musí ze své podstaty probíhat sériově, a který neprovádí žádné I/O operace či jiné zdržující činnosti, prodloužíme jeho trvání. Důvod spočívá v tom, že k času potřebnému pro výpočet se přidá čas nutný pro režii přepínání mezi vlákny. Přestože vyspělé verze systémů Windows provádějí toto přepínání velmi rychle, může se stát, že s ním zabereme relativně velkou část výpočetního výkonu.

Máme tedy o důvod víc vždy se důkladně zamýšlet nad nutností použít vlákna, tj. řešit otázku, zdali by bylo efektivnější provádět některé činnosti souběžně.

Problémy a úskalí, která vlákna přinášejí

Vlákna, v celé šíři svých možností, nejsou vůbec jednoduchou záležitostí. Pojďme se podívat na možná úskalí, která nám vlákna mohou připravit.

Základní problém spočívá v tom, že systém určuje, které vlákno zrovna poběží a jak dlouho poběží. Když si dále uvědomíme, že všechna vlákna jednoho procesu sdílejí tentýž paměťový prostor, možná už vidíme, že problém je na světě. Pokud všechna vlákna sdílejí tentýž paměťový prostor, je logické, že také mohou data v tomto prostoru modifikovat. Tak vzniká tzv. zacyklení. Ukážeme si vše na modelovém příkladu.

Máme dvě vlákna A, B a sdílenou proměnnou X. Vlákno A čte hodnotu proměnné X a přičítá k ní hodnotu 1, inkrementovanou hodnotu pak zapisuje zpět do proměnné X. Vlákno B čte hodnotu X, umocní ji a výsledek opět zapíše. Představme si následující situaci: do proměnné X přiřadíme hodnotu 7 a chceme ji inkrementovat a umocnit. Správný výsledek je tedy 64. Sled akcí ukazuje následující tabulka:

Číslo akce Akce Hodnota proměnné X
1. Přiřadíme do X hodnotu 7 X = 7
2. Vlákno A přečte X X = 7
3. Vlákno A inkrementuje přečtenou hodnotu X = 7
4. Operační systém pozastaví vlákno A a spustí vlákno B X = 7
5. Vlákno B přečte X X = 7
6. Vlákno B umocní přečtenou hodnotu X = 7
7. Vlákno B zapíše výsledek do X X = 49
8. Systém pozastaví vlákno B a spustí vlákno A X = 49
9. Vlákno A zapíše výsledek do X X = 8

Je vidět, že výsledek je špatný. Je ale třeba si uvědomit, že takovéto chyby ve vícevláknovém programu se velmi, velmi obtížně hledají a ladí! Obě vlákna pracují správně, počítají korektně a vracejí správné výsledky. Chyba je jen v jejich špatné synchronizaci: vlákna spolu nespolupracují (případně spolupracují špatně) a výsledkem je, že se „přetahují“ o jednu hodnotu, kterou si navzájem „mění pod rukama“.

Řešením tohoto problému je zajištění synchronizace vláken. V uvedeném případě by zřejmě pomohlo, kdyby jedno vlákno nesmělo číst hodnotu X v době, kdy druhé vlákno ji již přečetlo, ale ještě do ní znovu nezapsalo. Toho je samozřejmě možné docílit a my si všechny důležité synchronizační mechanismy později ukážeme.

Další možná potíž souvisí vlastně s problémem, který jsme si právě ukázali. Nikdy nevíte, jak dlouho/jak rychle vlákno poběží. Může se stát celá řada věcí – může čekat na výsledek práce jiného vlákna, může mít nižší prioritu, apod. Nevíme ani, v jakém pořadí operační systém vlákna spustí! To, že jsme vlákno A vytvořili (Create) před vláknem B, vůbec nezaručuje, že vlákno A bude před vláknem B také spuštěno! Nikdy se nesmíme spoléhat na to, že jedno vlákno něco „stihne“ před tím, než bude výsledek potřebovat jiné vlákno. Je zkrátka nutné počítat s tím, že vlákna (pokud je důkladně nesynchronizujeme) běží na sobě přísně nezávisle. Musíme brát vždy v úvahu, že jakékoliv vlákno se může neočekávaně zdržet nebo se v předpokládanou dobu nemusí vůbec spustit. Vlákna běží vůči sobě předem neodhadnutelnou a pokaždé jinou relativní rychlostí.

Druhy paralelního zpracování

Přestože následující problematika nesouvisí přímo s tím, jak se programují vlákna v Delphi, je natolik zajímavá, že bych ji nerad opominul. Paralelismus, tedy paralelní zpracování nějaké úlohy, můžeme rozdělit do tří základních skupin:
  • funkční paralelismus – složitá činnost (popsaná nějakou funkcí) se rozdělí na více jednodušších, dílčích činností. Každou z těchto dílčích činností vykonává jedno vlákno. Funkční paralelismus se typicky používá pro složité úlohy nad jednoduchými daty. Analogie se životem: při stavbě rodinného domku se zároveň zavádí elektřina i plyn. Tento paralelismus má smysl i na jednoprocesorových počítačích.
  • datový paralelismus – používá se v případě relativně jednoduché činnosti nad rozsáhlými daty. Tato data se rozdělí „na kousky“ a nad každým kouskem dat provede jedno vlákno tutéž činnost. Analogie se životem: dlouhý výkop hloubí parta kopáčů, každý je zodpovědný za jeden úsek výkopu. Tento paralelismus prakticky nemá smysl provozovat na jednoprocesorovém stroji (až na výjimky, které jsme si popsali výše).
  • zřetězené zpracování dat – vlákna si „předávají“ nějaký datový záznam, každé s ním udělá nějaký „kus práce“ a předá jej dál. V tom okamžiku je již připraveno opět přijmout další záznam.

Vlákna v Delphi – třída TThread

Chceme-li začít vytvářet vícevláknové (multithreading) aplikace v prostředí Delphi, nejjednodušší je použít třídu TThread (přesněji řečeno vytvořit si jejího potomka). V tom případě se také budeme často setkávat s pojmem objekt vlákna. Tento objekt reprezentuje výpočetní vlákno. Objekty vlákna zapouzdřují nejpoužívanější operace a způsoby použití vláken v aplikacích. Abychom mohli tyto objekty využívat, je nutné nejprve vytvořit potomka třídy TThread (v našem předchozím příkladu třída TCaryVlakno) a pak vytvořit vlastní objekt této vzniklé třídy (v našem předchozím příkladu konstruktor Create). Tak získáme objekt vlákna.

Potomka třídy TThread je možné vytvořit dvěma způsoby. První z nich si můžete vyzkoušet, když vyberete v Delphi z hlavní nabídky File – New a v otevřeném dialogu označíte Thread Object (viz obrázek):

Klepnete-li na OK, otevře se další dialog, ve kterém pouze zadáte jméno nově vytvořené třídy (v našem případě třeba TCaryVlakno, viz obrázek):

Po potvrzení vytvoří Delphi novou jednotku, která bude sloužit k implementaci vlákna.

Ať již vytvoříte novou třídu ručně nebo pomocí tohoto průvodce, výsledkem by mělo být cosi jako následující ukázka:

unit Unit2;

interface
uses
  Classes;
type
  TCaryVlakno = class(TThread)
  private
    { Private declarations }
  protected
    procedure Execute; override;
  end;

implementation
{ TCaryVlakno }

procedure TCaryVlakno.Execute;
begin
  { Place thread code here }
end;

end.

Do deklarace si samozřejmě většinou připisujeme další atributy a metody. Důležité je, že hlavní činnost vlákna, tedy to, co má vlákno vlastně dělat, se zapisuje do implementace metody Execute. K této metodě se ještě později dostaneme.

Bylo by samozřejmě úplným tmářstvím tvrdit, že třída TThread je jediným prostředkem k práci s vlákny v Delphi. Na další možnosti se podíváme později. Třída TThread má však jednu obrovskou výhodu, přesněji řečeno – je to jeden z jejích nejdůležitějších významů: s její pomocí je možné paralelně přistupovat ke knihovně VCL. Divíte se, proč to tak oslavuji?

Knihovna VCL (a zejména vizuální komponenty a ovládací prvky) totiž není ze svého principu stavěna na paralelní přístup. Knihovna VCL zkrátka není vůči vláknům vůbec bezpečná. Pokud byste přistupovali k jednomu formuláři z několika různých vláken, aplikace velice brzy havaruje s chybou narušení přístupu (Access Violation). Firma Borland však nabídla způsob, jak s touto knihovnou pracovat i z vláken, a to způsobem bezpečným. Tento způsob je implementován právě ve třídě TThread (jedná se o metodu Synchronize) a my si jej později popíšeme podrobně. Stručně řečeno - za aktualizaci obrazovky je odpovědné pouze primární vlákno aplikace (viz minulý díl seriálu). Pokud chce jakékoliv vlákno modifikovat obsah některé vizuální komponenty, zavolá pomocí metody Synchronize primární vlákno a sdělí mu svůj požadavek. Toto vlákno pak příslušné operace provede samo.

Za týden

Tolik stručný úvod do třídy TThread. V příštím díle seriálu si budeme postupně popisovat všechny důležité vlastnosti, události a metody této třídy, abychom mohli s vlákny konečně důkladně pracovat.
Diskuze (2) Další článek: Kanón na filmy

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