Go vs C #, část 1: Goroutines vs Async-Await

Jen pro případ, část 2 je zde: Go vs C #: Garbage Collection.

Budu psát řadu příspěvků porovnávajících některé funkce Go a C #. Klíčovou vlastností Go - goroutines - je ve skutečnosti velmi dobrý bod, ze kterého začít. Alternativou C # je toto: Paralelní knihovna úloh (TPL) a asynchronní podpora.

Implementace těchto funkcí jsou zcela odlišné:

  • Async-await v C # je implementován jako transformace těla metod poskytovaná kompilátorem podobně jako to, co C # dělá pro metody IEnumerable / IEnumerator . Kompilátor generuje metodu vracení stavového stroje (instance typu generovaného kompilátorem), který je zodpovědný za vyhodnocení asynchronního výpočtu.
  • Goroutiny jsou ve skutečnosti běžnými funkcemi v Go. Veškerá magie spojená s nimi se stane, když je začnete syntaxí „go“: Go je spustí souběžně v lehkém vláknu - ve skutečnosti vlákno, které používá velmi malý zásobník (který však může růst) a je schopen asynchronně čekat na operace čtení kanálu pozastavením samotného a uvolněním podprocesu OS do jiného lehkého vlákna.
  • V Go není žádný koncept „čekat“: místo toho mají goroutiny používat ke komunikaci kanály. Později vysvětlím, proč to tam do značné míry nepotřebujete.
  • Existuje spousta dalších rozdílů - některé z nich zmíním později. Ale celkově, async-čeká v C # je něco, co je postaveno na horní části stávající platformy, tj. Nevyžaduje žádné změny v .NET CLR. A naopak, goroutiny jsou hluboce integrovány do Go runtime.

V tomto příspěvku se zaměřím na relativně jednoduchý test:

  • Vytvořte N goroutines, každý čeká na svůj vstupní kanál číslo, přidá k němu 1 a odešle jej na výstup.
  • Goroutiny a kanály jsou zřetězeny dohromady, takže zpráva poslaná prvnímu kanálu se nakonec dostane k poslední.

Přejít kód:

C # kód:

Výstup pro Go:

Výstup pro C #:

Než začneme diskutovat o výsledcích, několik poznámek k samotnému testu:

  • Tento test „předem vytvořený“ pro Go - v C # obvykle nepotřebujete kanály pro komunikaci asynchronních úkolů. Úlohy zde obvykle volají jeden druhému a asynchronně čekají na výsledek. Jedinou možností, kterou má Go pro goroutinovou komunikaci, jsou kanály, a proto jsem se rozhodl navrhnout test, který je použije.
  • C # zatím nemá oficiální implementaci pro kanály. Používám implementaci, která bude brzy oficiální: System.Threading.Tasks.Channels. Aktuálně je k dispozici přes NuGet, verze balíčku je v tuto chvíli 0,1.
  • Aby bylo srovnání spravedlivější, kromě testu založeného na kanálech v C # jsem implementoval navíc jeden, který se spoléhal pouze na asynchronní úkoly. V této implementaci každý úkol čeká na svůj „vstupní“ úkol, přidá na svůj výstup 1 a vrátí výsledek.
  • C # kód má logiku „warmup“, která provádí stejný test pro 1 zprávu před skutečným spuštěním pro 1M zprávy, přestože Go kód není. Důvod je: .NET vydává kód metody při vyvolání, tj. První spuštění jakékoli „malé“ funkce trvá mnohem déle. Logika zahřívání zajišťuje, že nezachytáváme čas kompilace JIT.

Porovnání hrubých výsledků:

  • První běh tohoto testu trvá téměř úplně stejnou dobu jak na Go, tak na C #
  • Druhý běh je na Go mnohem rychlejší: faktor zrychlení je ~ 4.3x. V C # kódu není druhý běh, ale v C # není nic, co by ho mohlo zrychlit.
  • Verze založená na úkolech je na C # ~ 2,05x rychlejší, ale stále je ~ 2x pomalejší než při druhém spuštění na Go.

Proč je tedy druhý běh na Go mnohem rychlejší? Vysvětlení je jednoduché: když spustíte goroutinu, Go pro ni musí přidělit zásobník 8 kB. Tyto zásobníky jsou opakovaně použity, tj. Go nemusí tyto zásobníky při druhém spuštění přidělit. Důkaz:

Go přiděluje téměř ~ 9 GB pro 1M goroutiny a kanály. Předpokládejme, že každý goroutin zabírá alespoň 8 kB pro svůj stack, 8 GB je nezbytných právě pro tyto zásobníky.

Pokud zvýšíme počet zpráv předaných v tomto testu na 2M, na mém počítači již selže. 3M zprávy, a to bude selhat, i když nejsou spuštěny žádné jiné aplikace (kromě těch na pozadí).

Takže rozdíl je víceméně jasný. Podívejme se na to, proč je C # v těchto testech obecně pomalejší:

  • System.Threading.Tasks.Channels je ve stavu náhledu, tj. Jeho výkon není v tomto bodě pravděpodobně ani zdaleka dokonalý. Např. je jasné, že čekání na kanálu je ~ dvakrát dražší než čekání na úkol.
  • Pokud se zbavíme kanálů, je verze založená na úkolech ještě dvakrát pomalejší. I když si všimněte, že existuje další „čekající úloha.Yield ()“ - musel jsem ji přidat, protože když chybí, .NET se pokusí provést pokračování okamžitě po návratu úlohy bez návratu do hlavní smyčky aktuálního pozadí podprocesu a v důsledku toho rychle vyčerpá zásobník volání a zemře s StackOverflowException. V reálném životě to nikdy není problém - neměli byste mít dlouhé rekurzivní řetězce v asynchronním kódu; nicméně tento kód pravděpodobně asi 1,5krát zpomalí.
  • Přestože jsou úkoly v C # relativně lehké objekty, stále jsou přidělovány na haldy. Samotný stavový stroj je také referenčním typem. Hromadné přidělování haldy je ve všech moderních jazycích s GC relativně rychlé - ale stále jsou pravděpodobně 5-10x pomalejší než podobné rozšíření zásobníku + volání.

Nyní trochu upravíme test a snížíme počet odeslaných zpráv na 20 kB - číslo, které je mnohem blíže maximu, jaké jsme očekávali v reálném životě (otevřené 20 sokety na serverech atd.):

Jak můžete vidět, C # se blíží k Jdi sem:

  • Bije během prvního průchodu
  • Test založený na kanálech v C # je 2,7x pomalejší
  • Test založený na úkolech v C # je ~ 1,5x pomalejší

A konečně stejný test na 5 kB zpráv:

Vidíme, že test založený na úkolech v C # překonává test na Go, i když test na C # založený na kanálech je stále ~ dvakrát pomalejší než druhý průchod na Go.

Proč C # těží z menšího počtu úkolů?

  • Test 5K on Go používá ~ 5 MB RAM, což je stále menší velikost paměti cache L3 pro Core i7, ale mnohem více než velikost vyrovnávací paměti L2; na druhé straně není zcela jasné, proč výkon není tak dobrý, jako by měl být při druhém průchodu - CPU přesto ukládá pouze přístupnou podmnožinu dat.
  • C # verze, prob. 10x efektivnější využití paměti, používá při tomto testu ~ 500 kB RAM, což je mnohem blíže velikosti mezipaměti L2 pro Core i7 (256 kB na jádro).

Goroutiny vs async-čekají: závěry

Podívejme se na nejdůležitější rozdíly:

  • Goroutiny jsou zřetelně rychlejší. Ve scénářích skutečného života můžete očekávat něco jako 2x… 3x za očekávání. Na druhé straně jsou obě implementace poměrně efektivní: můžete očekávat něco jako 1M „čeká“ v C # za sekundu a možná 2–3M v Go, což je ve skutečnosti poměrně velké množství. Např. pokud zpracováváte síťové zprávy, pravděpodobně se to projeví na 100 000 zpráv za sekundu na Core i7 v C #, tj. více na skutečném serveru. Tj. neočekává se, že to bude překážkou.
  • Skutečný výkon asynchronního očekávání C # musí být blízko goroutinům - C # je náročnější na paměť a výkon většiny produkčních aplikací většinou závisí na tom, jak velká je jejich pracovní sada.
  • „Zásobník 8 kB na goroutinu“ také znamená, že v určitých scénářích je vyšší pravděpodobnost získání OOM v Go - např. pokud váš server zpracovává jakoukoli zprávu spuštěním goroutine, ale všichni procesoři prostě uvízli v očekávání nějaké externí (nebo interní) služby, která je nyní zaneprázdněna. Pokud je míra žádostí velmi vysoká, potřebujete doslova sekundy k získání OOM na základě výše uvedených testů. Vše, co potřebujete, je dostat 2–3M zprávy - a to na 32 GB počítači.
  • C # ve výchozím nastavení činí více pro asynchronní volání - to je další důvod, proč je pomalejší. Zejména prochází ExecutionContext a SynchronizationContext prostřednictvím asynchronně očekávaného řetězce volání (tj. Existuje více vyhledávání slovníků pro odpovídající lokální proměnné vlákna pro každé volání).
  • Model C # je explicitnější / robustnější (i když je sporné, zda je dobrý nebo ne - čtěte dále): veškerý asynchronní kód je ozdoben async-čekáním; kromě toho existuje spousta vestavěných primitivů - zejména několik plánovačů (např. předávání asynchronních volání zpět do UI vlákna v aplikacích UWP), podpora zrušení, synchronizace atd. Dobrým příkladem je knihovna kanálů Použil jsem: je relativně snadné přidat podporu pro kanály v C #, ale něco podobného async-await v Go vyžaduje mnohem více kódu kotlové desky.
  • Model C # je rozšiřitelnější: ve skutečnosti můžete změnit téměř cokoli přidáním vlastních plánovačů, čekajících a dokonce i vlastních typů úkolů. Takže pokud vám opravdu záleží na výkonu, můžete napsat mnohem lehčí úkoly nebo úkoly předem naladěné pro určité scénáře (dobrým příkladem je ValueTask , který je nyní součástí .NET). Další nadcházející funkcí je podpora asynchronních sekvencí (async streamů) ​​- která je také založena na stejné sadě API (i když vyžaduje změny kompilátoru C #).
  • Goroutiny se snáze učí. Zdá se, že pro jejich použití není třeba nic zvláštního: „go“ klíčové slovo + syntaxe kanálu je vše, co potřebujete vědět. Naopak async / čeká v C # rozhodně nestačí, aby se tam o asynchronním programování dozvěděl. Musíte vědět o úkolu / úkolu , úkolu.Run a zrušení jako naprosté minimum. Programování asynchronního přenosu v reálném čase znamená, že víte o plánování, .ConfigureAwait (false), o tom, jak tvůrci úloh fungují, jak jsou zpracovávány výjimky, kdy použít ValueTask atd. - tj. Je to mnohem více než v Go.
  • Goroutiny netrpí problémem „asynchronizovat celou cestu“. Async-čeká na to, že pokud provádíte řetězec funkčních volání (volání A, B volání C,… Y volání Z) a obě A a Z jsou asynchronní funkce, B… Y musí být také asynchronní funkce, jinak model nebude fungovat (non-async Y nemůže čekat na Z, non-async X nemůže čekat na Y atd. - to znamená, že buď musí „začít a zapomenout“ odpovídající asynchronní funkce, nebo na ně čekat synchronně nebo se stanou asynchronními). Naopak v Go neexistuje žádné takové omezení: můžete číst z kanálu v jakékoli funkci a bez ohledu na to je vždy asynchronní operace. To je vlastně velká výhoda, protože nemusíte předem plánovat, co bude asynchronní. Zejména můžete napsat dotazovací metodu, která vyvolá nějakého poskytovatele dotazů, aby získal výsledek, a tento poskytovatel to může provést synchronně nebo asynchronně v závislosti na implementaci - ale vy jako autor metody dotazu (…) don Když to píšete, nemusím o tom přemýšlet.
  • V důsledku toho je v Asii asynchronním kódem asociován méně režijních nákladů: „asynchronní cesta celou cestou“ znamená, že jakékoli potenciálně asynchronní API musí být asynchronní v .NET, tj. Očekává se, že zde bude vytvořeno více asynchronních úkolů, více alokací haldy atd. .
  • To také vysvětluje, proč není potřeba asynchronně čekat v Go: protože jakákoli funkce podporuje asynchronní čekání (na kanálu) a může být spuštěna souběžně (s „go“ syntaxí, tj. Jako goroutine), každá funkce vrací pravidelný výsledek může v sobě spustit nějakou asynchronní logiku - vše, co potřebuje, je spustit další goroutinu, předat jí kanál a čekat na výsledek na tomto kanálu. Proto většina API v Go vypadá synchronně, i když ve skutečnosti jsou asynchronní. A upřímně řečeno, je to docela úžasné.

Celkově jsou implementační rozdíly docela významné, stejně jako důsledky.

Existuje dobrá šance, že jednoho dne napíšu robustnější test na async-await-goroutines a prodiskutuji ho na jiném příspěvku. Ale protože je to určitě zajímavé téma, cítím, že musím zde odkazovat na někoho jiného. Bohužel jich není mnoho - tady je nejlepší mikropodniková úroveň blízká scénářům reálného života, které jsem dosud našel:

Jedná se o jednoduchý srovnávací test webového serveru s middlewarem, který deserializuje JSON a střílí požadavek HTTP na externí službu. Popis je právě tam; konečný výsledek pro .NET Core je v komentářích (podívejte se na celé vlákno, abyste pochopili, proč byl jeho původní benchmark pro .NET nesprávný): https://stefanprodan.com/2016/aspnetcore-vs-golang-data-ingestion-benchmark / # comment-3158140604:

  • Jdi úchyty ~ 9 000 požadavků za sekundu (úroveň souběžnosti = 100, střední doba na požadavek = 15 ms). Pro Go je několik výsledků, takže ve skutečnosti není jasné, který z nich si vybrat - vybral jsem ten nejlepší, kterého jsem viděl.
  • .NET Core zpracovává ~ 8,1 000 požadavků / sekundu (úroveň souběžnosti = 50, střední doba na požadavek = 6 ms)
  • Mám podezření, že se tento test provádí na .NET Core 1.0 (na základě data příspěvku) a .NET Core 1.1 je výrazně rychlejší. Autor slíbil aktualizaci výsledků při vydání .NET Core 2.0.
  • Jak možná zjistíte, výsledky jsou trochu divné: .NET Core hlásí kratší dobu odezvy, i když míra požadavků je nižší, + existuje rozdíl v úrovni souběžnosti. Takže možná můžete tento test spustit doma a podělit se o své poznatky :)

To je pro dnešek vše. Všimněte si, že nejsem zcela odborník na Go - všechny zde zobrazené kódy Go jsou pravděpodobně 50% všech kódů Go, které jsem doposud napsal. Takže pokud jste z tábora Go, jste rozhodně vítáni, abyste tento příspěvek komentovali - rádi jej na základě vaší zpětné vazby prodloužíme nebo upravíme.