Klávesové zkratky na tomto webu - rozšířené Na obsah stránky

Asynchronní vs. paralelní zpracování úloh

| Moje práce

S příchodem async/await do C# 5.0 se nám v různých APIs objevily asynchronní metody, které vracejí Task nebo Task<T> a jejich název je vybaven kouzelným postfixem Async. Jde o takzvaný Task-based Asynchronous Pattern (TAP). A protože je postavený na datové struktuře Task může svádět k vlastním implementacím, které nejsou tak říkajíc zrovna optimální.

Začneme malou ukázkou z praxe. V předchozím tweetu Jirka ukazuje na „asynchronní“ implementaci metody ToFirstAsync() ze SQLite:

public Task<T> ToFirstAsync ()
{
  return Task<T>.Factory.StartNew(() => {
    using (((SQLiteConnectionWithLock)_innerQuery.Connection).Lock ()) {
      return _innerQuery.ToFirst ();
    }
  });
}

Co je na ní špatného?

Když pominu maďarské podtržítko, mezery před závorkama, zbytečné složené závorky a vůbec… Zkrátka vidíme ukázkový pokus o implementaci TAP pomocí Task Parallel Library (TPL). A to je špatně. Ve skutečnosti nejde o asynchronní operaci, ale o operaci odpálenou na jiném threadu.

Asynchronní vs. paralelní

Nejprve bychom si asi měli ujasnit, jaký rozdíl je mezi asynchronním a paralelním zpracováním, protože to stále většině lidí není jasné. Ano, i já se v tom občas ztrácím, ale snažím se. :)

Představte si dlouhotrvajících operaci. Řekněme takovou, která není přímo spojená s výpočtem (CPU-bounded), ale takovou, která si šahá na některé ze vstupně/výstupních zařízení (IO-bounded). Tak tedy vězte, že asynchronní operace je taková, která běží v tomto zařízení. Pokud třeba čteme z disku, databáze nebo z cloudu, pošleme požadavek na zařízení a nečekáme na odpověď přímo, ale necháme zařízení ať se nám ozve samo, až bude jeho odpověď připravená. Mezi tím se může aplikace věnovat jiným úlohám. Žádná vlákna zde není třeba zmiňovat.

Pokud zadáme úlohu ke zpracování jinému vláknu, zavádíme paralelizaci. Ukázka výše odstartuje nový Task, který se s největší pravděpodobností spustí na jiném threadu a záhy ho zamkne. Sice neblokuje vlákno, ze kterého operaci voláme, ale blokujeme vlákno jiné. Jaký je účel? A náklady?

Paralelizace je vhodná tam, kde máme výpočetně náročné operace a dají se rozdělit na několik kroků, které mohou běžet zároveň. Tj. nesdílí stav a slouží třeba jako mezivýsledky v nějakém větším výpočtu. Typickým příkladem může být Map/Reduce. Mapování může běžet paralelně, redukce pak proběhne po doběhnutí mapování, případně může také probíhat paralelně v nějakých clusterech dat.

Tak, snad je to už jasné. :)

Dalším velkým rozdílem je, že paralelizace zpracování je implementační detail, a ten by jako takový neměl prosakovat do APIs. Pokud tedy uvidíte metodu, která vrací Task nebo Task<T> a v její implementaci je Task.Run potažmo starší verze Task.Factory.StartNew, považujte to za smell.

Taky jsem ji měl

Popravdě, taky jsem takovou metodu měl. Zjednodušeně vypadala asi nějak tak:

class Client : ConsumerOf<Message> {
  readonly ManualResetEvent sync = new ManualResetEvent(false);

  public Task<Result> QueryAsync(Request request) {
    return Task.Factory.StartNew(() => {
      SendQuery(request.ToQuery());
      sync.Wait();
      return Result;
    });
  }

  public void Consume(Message msg) {
    sync.Set();
    Result = msg.ToResult();
  }
}

Jak vidíte, spouští se tu nový Task, který odešle dotaz a čeká na odpověď. Dotaz i odpověď jsou zprávy cestující po service busu. Všimněte si synchronizace (sic!) pomocí signální události. Navenek se to tváří jako asynchronní, ale je to celkem pěkně drahá sranda. Jak to udělat opravdu asynchronně?

Lepší řešení

Klíčem k úspěchu je využití třídy TaskCompletionSource:

class Client : ConsumerOf<Message> {
  readonly TaskCompletionSource tcs = new TaskCompletionSource();

  public Task<Result> QueryAsync(Request request) {
    SendQuery(request.ToQuery());
    return tcs.Task;
  }

  public void Consume(Message msg) {
    tcs.TrySetResult(msg.ToResult());
  }
}

Task nevytváříme pomocí TaskScheduleru, který ho alokuje někde na ThreadPoolu, ale pomocí TaskCompletionSource. Result se nastaví ve chvíli, kdy přijde odpověď. Nepotřebujeme žádné semafory, vše funguje pěkně asynchronně. Můžeme snadno přidat řešení chybových stavů, timeout nebo cancellation. Dle potřeb a celkem levně.

Závěr

Asynchronní APIs má cenu vystavovat pouze u IO operací. Výpočetně náročné operace můžeme vnitřně paralelizovat, ale nikdy tento detail nezveřejňujeme v APIs. Pro asynchronní operace nepoužíváme TaskFactory ani synchronizační objekty, ale TaskCompletionSource. Já už si to budu pamatovat. Doufám, že i vy. :)

Našli jste v článku chybu? Máte námět na reportáž? Založte mi ticket.