Async await
Dar 2007 Microsoft‘as sugalvojo “async/await’ programavimo modelį. Nuo 2012 jis buvo oficialiai išleistas ir C#’ui, o po daugiau nei dešimt metų šis modelis ne tik, kad nenumirė, tačiau buvo adaptuotas ir kitose programavimo kalbose. Pats Microsoft‘as jį vis dar ir dabar stumia savo naujose bibliotekose.
Jei technologija per tiek laiko nenumiršta, tai tikriausiai yra verta apsvarstymo panaudoti ir rimtuose projektuose. Šiandien ištirsime ar iš tiesų apsimoka lysti į šiuos MS spąstus.
Kas tai yra?
Su async/await galima rašyti asinchroninį kodą, atrodantį kaip sinchroninis. Iš esmės tai yra sintaksės cukriukas, bet labai dideliais kubeliais, nes už tų dviejų raktažodžių slepiasi sudėtinga būsenų mašina. Visi žino, kad tai, kas yra sudėtinga – blogai. Betgi žmonės sako, kad šios technologijos pliusai su kaupu atperka trūkumus, tad juos ir apžvelkime:
Pliusai
- asinhroninis kodas atrodo paprasčiau, suprantamiau nei kitokiais būdais parašytas asinchroninis kodas;
- didesnis apdorotų užduočių kiekis lyginant su sinchroniu metodu, nes nėra ilgų, blokuojančių kodo kelių;
- efektyvesnis kompiuterio resursų išnaudojimas, nes kol viena kodo dalis laukia resurso, kitai yra suteikiama galimybė toliau drožti kompą;
- paprastesnis klaidų gaudymas, lyginant su kitais asinchroninio programavimo modeliais.
Minusai
- sudėtingiau išmokstamas, perprantamas kodas nei sinchroninis;
- našumo sumažėjimas. Dėl savo sudėtingumo, reikia daugiau skaičiavimo resursų lyginant su sinchroniniu kodu;
- sudėtingesnis klaidų gaudymas, lyginant su sinchroninio kodu;
- viskios asinchroninio programavimo problemos (užsirakinimas, angl. deadlock, badavimas, angl. starvation ir kt.)
Panašu, kad async/await laimi prieš kitus asinchroninio programavimo metodus, tačiau lyginant su sincroniniu programavimu vienintelis pranašumas yra efektyvesnis kompiuterio resursų išnaudojimas. Reikia ištirti, ar tai yra pakankamas naudos prieaugis, norint pateisinti išaugusį sudėtingumą.
Tyrimas
Tyrimui pasirinkta ASP.NET web serverio biblioteka, nes ką daugiau su C# bepriprogramuosi, nei web‘ą. Projekte bus aprašyti tiek sinchroniniai, tiek asinchroniniai metodai ir palyginta, kiek užklausų per duotą laiko intervalą galima apdoroti tiek vienu, tiek kitu būdu.
Visi testai bus atliekami tame pačiame kompiuteryje, toje pačioje operacinėje sistemoje su tokiais pačiais nustatymais.
Testavimo aplinka
1 | OS: Arch Linux x86_64 |
Pagrindinė priežastis, dėl kurios gali atsirasti neatitikimai tarp testuojamų atvejų, tai CPU elektros taupymas. CPU gali laiku nespėti persijungti į spartesnį dažnį ir dėl to išsikraipys rezultati. Dėl šios priežasties testų leidimo metu naudojama tik performance CPU governor.
Taip pat testavimo metu bus išjungtas swap‘as.
Testavimo įrankiai
Testavimui pasirinktas įrankis wrk, nes jį labai paprasta naudoti ir galima padaryti taip, kad rezultatus pateiktų csv formatu. Wrk įrankis leidžia kiek galima daugiau užklausų pagal nurodytus parametrus – vienu metu besisukančių gijų kiekį ir kiek prisijungimų padalyti per gijas. Serveris yra brute force‘inamas pagal tuos parametrus.
Taip pat buvo svarstoma naudoti httperf, kuris būtų pravertęs patikrinti kaip atsako laikas priklauso nuo užklausų kiekio nedarant brute force, tačiau ir taip jau praleidau per daug laiko testuodamas, todėl šią užduotį palieku skaitytojams, namų darbams.
Dar vienas kandidatas – labai gražų GUI turintis oha. Jis rezultatus gali pateikti JSON formatu. Deja aš jį per vėlai radau, bet jei kada dar testuosiu HTTP serverių greitaveiką, rinksiuosi šį įrankį.
Visus testavimo skriptus, bei serverių kodą su konfigūracija patalpinau į githubą.
Atskaitos taškas
Tam, kad skaitytojui būtų paprasčiau vertinti testavimo rezultatus, pateiksiu ir kitų web bibliotekų greitaveikos testus šiame tame pačiame kompiuteryje.
Palyginimo testas labai paprastas – serveris atsako 200 OK, su body tekstu OK. Ir viskas.
Panšius testus darau ne aš vienas, netgi yra sukurtos atskiros svetainės, tokios kaip Web Framework Benchmarks ir Web Frameworks Benchmark. Tačiau dabar svarbiau kaip įvairios bibliotekos veikia būtent mano kompiuteryje.
Palyginimui pasirinktos šios bibliotekos:
- bun – labai nauja ir labai greita, šiuo metu itin populiari JavaScript biblioteka.
- nginx – labai populiarus HTTP(s) serveris, sukonfigūruotas kuo didesniam pralaidumui atsakant tik tekstu “OK”.
- httpbeast biblioteka. Pagal Web Framework Benchmarks yra greičiausia nim kalba parašyta web biblioteka.
- hyper-express – nodejs biblioteka, kuri pagal greitaveikos testus yra viena greičiausių node.js aplinkoje.
- may_minihttp – rust biblioteka, kuri pagal Web Framework Benchmarks yra greičiausia rust‘inė web biblioteka.
Dotnet bus testuojamas dviem skirtingai būdais – panaudojant kestrel web serverį su ASP.NET+MVC ir panaudojant paprastesnį HttpListener be jokių ASP ar MVC.
Prieš paleidžiant testą, kiekvienai bibliotekai suteikiamas šansas apšilti – dviems sekundėms paleidžiamas pilnas apkrovimas.
Po to eina tikras testas – 16 gijų bando palaikyti 100 prisijngimų visas 10 sekundžių (žr. function baseline-item).
Rezultatai tokie:
Rezultatai lentelėje
| Pavadinimas | Užklausos/sek. | Vėlinimas (latency) (µs) | |||
|---|---|---|---|---|---|
| Vidutinis | Minimalus | Didžiausias | Standartinis nuokrypis | ||
| node-hyper-express | 119082 | 805,01 | 67 | 2227 | 57,13 |
| bun | 128437 | 745,75 | 67 | 1766 | 80,11 |
| dotnet-httplistenter-net5 | 135486 | 799,48 | 34 | 11599 | 657,49 |
| dotnet-httplistenter-net5-threaded | 135571 | 868,44 | 35 | 21645 | 901,06 |
| dotnet-httplistenter-net6-threaded | 140784 | 867,21 | 31 | 23605 | 953,11 |
| dotnet-httplistenter-net6 | 144804 | 771,80 | 37 | 10408 | 686,43 |
| dotnet-httplistenter-net7 | 144919 | 786,45 | 39 | 10594 | 715,74 |
| dotnet-httplistenter-net7-threaded | 145047 | 875,89 | 32 | 22007 | 995,87 |
| dotnet-httplistenter-net8 | 154973 | 725,46 | 27 | 10050 | 615,62 |
| dotnet-httplistenter-net8-threaded | 175459 | 700,06 | 27 | 12837 | 723,93 |
| dotnet-asp-net5 | 269793 | 512,92 | 39 | 45418 | 1082,25 |
| dotnet-asp-net7 | 272273 | 538,80 | 32 | 50472 | 1220,54 |
| dotnet-asp-net6 | 292231 | 453,51 | 34 | 34759 | 953,10 |
| dotnet-asp-net8 | 328583 | 448,16 | 26 | 53958 | 1259,92 |
| rust-may_minihttp | 604084 | 150,41 | 12 | 21671 | 626,24 |
| nginx | 654915 | 211,60 | 11 | 10445 | 407,53 |
| nim-httpbeast | 675098 | 157,64 | 12 | 13873 | 250,60 |
JavaScript‘iniai libai (hyper-express ir bun) veikia naudodami tik vieną giją, tad nenuostabu, kad 16 loginių CPU turinčiame kompiuteryje pasiradė prasčiausiai. Realybėje jie būtų leidžiami per kokį nors tarpinį serverį (angl. proxy), tokį kaip nginx, haproxy, apache ar pan. Tada būtų galima paleisti daug JavaScript‘inių procesų, o darbų dalinimą atliktų tarpinis serveris. Bet tai ne mano problemos.
Antroje vietoje nuo galo atsidūrė dotnet su HttpListener. Nors tai ir paprastesnis variantas, sugebantis išnaudoti visus CPU resursus, tačiau ASP.NET, švelniai tariant, neaplenkia. Matyt Microsoft‘as su savo kestrel serveriu padarė kažką teisingo.
Trečioje vietoje nuo galo atsidūrė ASP.NET. Matome, kad tai tikrai nėra pati greičiausia biblioteka, tačiau nėra ir labai lėta. Pagal Web Framework Benchmarks, kai kuriuose testuose pakiūna netgi į dešimtuką.
Prizines vietas užėmė httpbeast, nginx ir may_minihttp. Visi šie trys prizininkai paliko savo važovus toli dulkėse, lenkdami juos ne procentais, o kartais. Jeigu tau rūpi vien tik užklausos per sekundę, tai tikriausiai reikėtų apsvarstyti šias, vis dar labiau egzotines programavimo kalbas: rust ir nim.
Testuojame ASP.NET
Kai jau nubrėžėme atskaitos tašką, galime pradėti lyginti kaip smarkiai async lenkia sync dotnet‘e. Testuosime visas .net versijas nuo 5–tos iki 8–tos (rc2 versija).
Kadangi testus leisime multiprocesorinėje mašinoje, iš pradžių dar turime panagrinėti kaip mūsų rezultatus veikia minimalus gijų (angl. threads) kiekis baseine (angl. ThreadPool). Jeigu jų trūksta – kestrel automatiškai vis prideda po vieną per sekundę. Tai yra per lėtai mūsų testams, nes juos leisime tik po 10s. Išsiaiškinsime, gal reikėtų iš karto pridėti kokį 1000, pvz ThreadPool.SetMinThreads(1000, 1)?
Verta paminėti, kad kai kuriems testams bus naudojama postgresql duomenų bazė. Dažniausiai web programose būtent DB ir tampa ribojančiuoju faktoriumi, o ne CPU ar RAM resurstai. DB specialiai sukonfigūruota ne pačiam efektyviausiam veikimui, kad kuo anksčiau pasireikštų tas ribijimas. Maksimalus vienu metu prisijungusių sesijų kiekis max_connections apribotas iki 100. Tiesa, npgsql prisijungimo eilutėje Maximum Pool Size dar labiau apribotas – iki 50. DB serveris veikia tame pačiame kompiuteryje, kur ir atliekami visi testai.
Leisime testą function asp-thread-test. Testo metu bus kviečiamos 2x2 tipų užklausos:
- be kreipimosi į DB sync (/sync/get)
- su kreipimusi į DB sync (/sync/db-fast)
- be kreipimosi į DB async (/async/get)
- su kreipimusi į DB async (/async/db-fast)
Be kreipimosi į DB
Tai pati paprasčiausia užklausa, gražinanti atsakymą OK. Microsoft‘as net nerekomenduoja tam naudoti async/await. C# kodas atrodo taip:
1 | [] |
Rezultatai atrodo taip:
Rezultatai lentele
| Gijų kiekis | .NET 5 | .NET 6 | .NET 7 | .NET 8 | ||||
|---|---|---|---|---|---|---|---|---|
| Užklausos per sek. | Užklausos per sek. | Užklausos per sek. | Užklausos per sek. | |||||
| sync | async | sync | async | sync | async | sync | async | |
| 2 | 268650 | 264234 | 289113 | 285231 | 274383 | 269215 | 329826 | 324628 |
| 8 | 268508 | 264773 | 291400 | 286972 | 274946 | 268796 | 329713 | 327854 |
| 16 | 265564 | 260471 | 291070 | 282886 | 269980 | 267655 | 331655 | 325464 |
| 17 | 272571 | 262828 | 296341 | 289516 | 276140 | 269953 | 330466 | 327571 |
| 32 | 265430 | 258506 | 289790 | 284551 | 278676 | 269586 | 330344 | 331614 |
| 100 | 218143 | 214165 | 198287 | 196091 | 267461 | 258033 | 321386 | 316543 |
| 1000 | 212607 | 205878 | 195368 | 186061 | 269624 | 263172 | 324177 | 317931 |
Matome, kad async visais atvejais pralaimi. Taip pat pastebime milžinišką spartos šuolį .net8 versijoje. Nepamirškime ką testuojame – kaip gijų kiekis veikia užklausų per sekundę kiekį. Akivaizdu, kad viršijus 32 gijų kiekį (kiekvienam loginiam CPU po dvi), stebimas ženklus spartos sumažėjimas, ypač žemesnėse .net vesijose.
Su kreipimusi į DB
Ši užklausa per parametrus paima kiekį num – kiek pirminių skaičių sugeneruoti, perleidžia tą skaičių per DB ir suranda tuos priminius skaičius:
1 | [] |
Tai kompleksinis testas, realiau parodantis ganėtinai dažną atvejį įpratose web programose – duomenų ištraukimą ir jų apdorojimą prieš pateikiant vartotojui. SQL užklausa yra tokia „tuščia“ todėl, kad mes testuojame ne postgresql‘ą, o dotnet‘ą ir jo spartą dirbant su nutolusiais resursais.
Rezultatai lentele
| Gijų kiekis | .NET 5 | .NET 6 | .NET 7 | .NET 8 | ||||
|---|---|---|---|---|---|---|---|---|
| Užklausos per sek. | Užklausos per sek. | Užklausos per sek. | Užklausos per sek. | |||||
| sync | async | sync | async | sync | async | sync | async | |
| 2 | 50513 | 71249 | 66936 | 73672 | 55933 | 74047 | 74103 | 82567 |
| 8 | 59108 | 74235 | 64445 | 75769 | 62003 | 72817 | 74583 | 82557 |
| 16 | 64461 | 68623 | 66921 | 74411 | 65598 | 71890 | 74353 | 83440 |
| 17 | 65294 | 68503 | 68254 | 73564 | 66531 | 71656 | 74257 | 83302 |
| 32 | 67375 | 60644 | 69580 | 62865 | 68662 | 70824 | 75905 | 81759 |
| 100 | 40025 | 56526 | 39760 | 54349 | 54767 | 69623 | 58254 | 80778 |
| 1000 | 39100 | 56189 | 39032 | 54162 | 54715 | 69530 | 58359 | 80706 |
Čia rezultatai jau labiau išsimėtę, o async ženkliai laimi. Optimalus minimalus gijų kiekis labai priklauso nuo dotnet versijos bei async ar sync naudojimo. Sync labiau patinka 32 gijos, o async patogiausiai jaučiasi su 16–ka. Tiesa, aštuntoto dotnet async išvis beveik nereaguoja į tą gijų kiekį. Nuspręstą, kad visi tolimesni testai bus atliekami su 17 gijų.
Roundas 1. CPU apkraunantis darbas
Ši užklausa suranda norimą kiekį pirminių skaičių be jokių kreipimusį į DB. Pirminių skaičių paieška reikalauja nemažai skaičiavimo resursų, todėl turėtų gerokai apkrauti CPU. Microsoft‘as nerekomenduoja tam naudoti async/await, nes nėra kreipiamasi į lėtus išorinius resursus.
1 | [] |
Kreipiniai:
- /sync/primes
- /async/primes
Rezultatai lentele
| .net verija | Pirminių sk. kiekis | Užklausos per sekundę | Vėlinimas (µs) | ||
|---|---|---|---|---|---|
| sync | async | sync | async | ||
| .net5 | 0 | 186523 | 183542 | 858,94 | 740,66 |
| 1 | 188818 | 185634 | 701,94 | 696,03 | |
| 5 | 188369 | 184414 | 797,30 | 762,72 | |
| 10 | 186177 | 183492 | 870,62 | 745,53 | |
| 100 | 161475 | 158043 | 686,02 | 808,43 | |
| .net6 | 0 | 208510 | 203010 | 758,78 | 687,95 |
| 1 | 206390 | 201937 | 768,73 | 816,97 | |
| 5 | 204097 | 200486 | 596,00 | 624,22 | |
| 10 | 202644 | 199423 | 719,88 | 689,52 | |
| 100 | 171822 | 169881 | 736,89 | 667,59 | |
| .net7 | 0 | 204264 | 197752 | 789,68 | 667,71 |
| 1 | 200577 | 196267 | 721,66 | 671,06 | |
| 5 | 199007 | 196447 | 718,75 | 723,38 | |
| 10 | 198994 | 195180 | 813,43 | 897,32 | |
| 100 | 169093 | 166429 | 1081,49 | 1125,86 | |
| .net8 | 0 | 248093 | 245842 | 589,82 | 530,99 |
| 1 | 249041 | 244232 | 507,90 | 464,65 | |
| 5 | 248068 | 243070 | 497,06 | 508,24 | |
| 10 | 245636 | 242115 | 487,63 | 490,63 | |
| 100 | 202080 | 199667 | 544,67 | 588,12 | |
Nėra nieko netikėto – sync laimi prieš async, bet labiausiai laimi .net8. Rezultatai labai panašūs į /[a]sync/get, tik matome ženklų užklausų sumažėjimą padidėjus CPU darbui. Tas sumažėjimas yra proporcingas tiek metodo, tiek dotnet versijos atžvilgiu.
Roundas 2. Greita užklausa į DB + CPU apkraunantis darbas
Jau minėjau, kad DB prisijungimų kiekis yra specialiai apribotas tiek DB, tiek npgsql draiver‘io lygyje – užklausų kiekį riboja npgsql ConnectionPool, todėl vienu metu lygiagrečiai gali veikti ne daugiau nei 50 užklausų. Šiame teste tai neturėtų daryti daug įtakos, nes užklausa veikia taip greitai, kad teoriškai net nespės išnaudoti pool‘lo.
Naudojami tie patys kreipiniai kaip ir į gijų kiekio nustatymo su kreipimusi į DB teste, tik šį kartą keisis ne gijų kiekis, o parametras num – kiek pirminių skaičių sugeneruoti.
Rezultatai lentele
| .net verija | Pirminių sk. kiekis | Užklausos per sekundę | Vėlinimas (µs) | ||
|---|---|---|---|---|---|
| sync | async | sync | async | ||
| .net5 | 0 | 63325 | 66417 | 1520,63 | 1468,21 |
| 1 | 63172 | 66263 | 1521,77 | 1470,28 | |
| 5 | 63558 | 66282 | 1517,03 | 1467,21 | |
| 10 | 63323 | 66272 | 1517,32 | 1468,86 | |
| 100 | 60132 | 62500 | 1600,82 | 1541,12 | |
| .net6 | 0 | 65540 | 71629 | 1470,84 | 1361,83 |
| 1 | 65561 | 70984 | 1471,13 | 1380,23 | |
| 5 | 65964 | 69805 | 1458,37 | 1417,94 | |
| 10 | 65272 | 71295 | 1479,91 | 1370,30 | |
| 100 | 62279 | 67429 | 1543,91 | 1448,51 | |
| .net7 | 0 | 66012 | 69664 | 1462,97 | 1393,98 |
| 1 | 64848 | 69808 | 1484,67 | 1398,44 | |
| 5 | 63871 | 70078 | 1506,19 | 1384,12 | |
| 10 | 64434 | 69984 | 1495,24 | 1383,82 | |
| 100 | 61023 | 65743 | 1581,07 | 1466,76 | |
| .net8 | 0 | 72406 | 81786 | 1334,66 | 1195,63 |
| 1 | 73180 | 81449 | 1320,34 | 1201,71 | |
| 5 | 72554 | 81588 | 1331,14 | 1198,08 | |
| 10 | 71984 | 81425 | 1339,61 | 1202,68 | |
| 100 | 70431 | 75757 | 1371,34 | 1288,48 | |
Matome aiškų async ir .net8 dominavimą. Kuo aukštesnė .net versiją, tuo labiau async atsiplėčia nuo sync.
Roundas 3. Lėta užklausa į DB + CPU apkraunantis darbas
Šis testas skirtas išsiaiškinti kaip web serveris veikia, kai užklausų kiekis smarkiai viršija teorines jo galimybes. SQL užklausa yra parašyta taip, kad veiktų lygiai 3 sekundes. Kadangi dėl ConnectionPool apribojimų vienu metu gali veikti iki 50 prisijungimų, per sekundę galima atlikti ne daugiau nei 50 / 3 = 16.6(6) užklausų.
Šis testas, skirtingai nuo kitų yra leidžiamas ne 10, o 30 sekundžių.
1 | [] |
Kreipiniai:
- /sync/db-slow
- /async/db-slow
Rezultatai lentele
| .net verija | Pirminių sk. kiekis | Užklausos per sekundę | Vėlinimas (µs) | ||
|---|---|---|---|---|---|
| sync | async | sync | async | ||
| .net5 | 0 | 9 | 16 | 9224754,69 | 5493095,56 |
| 1 | 8 | 16 | 9729859,12 | 5486736,04 | |
| 5 | 16 | 16 | 5481754,81 | 5485915,20 | |
| 10 | 13 | 16 | 6435612,75 | 5486341,93 | |
| 100 | 16 | 16 | 5388995,88 | 5474949,13 | |
| .net6 | 0 | 14 | 16 | 6225872,86 | 5486872,23 |
| 1 | 16 | 15 | 5310704,33 | 5457394,85 | |
| 5 | 15 | 15 | 5949481,82 | 5473819,59 | |
| 10 | 10 | 16 | 8182056,95 | 5487310,02 | |
| 100 | 15 | 16 | 5863315,22 | 5486677,48 | |
| .net7 | 0 | 13 | 16 | 6931314,34 | 5486716,42 |
| 1 | 16 | 16 | 5577143,48 | 5496805,72 | |
| 5 | 14 | 16 | 6353138,31 | 5486931,93 | |
| 10 | 16 | 16 | 5609165,67 | 5486559,81 | |
| 100 | 16 | 16 | 5682021,33 | 5485962,92 | |
| .net8 | 0 | 10 | 15 | 7755583,43 | 5463086,54 |
| 1 | 11 | 16 | 7554419,36 | 5487017,25 | |
| 5 | 11 | 16 | 7413075,37 | 5492450,94 | |
| 10 | 16 | 16 | 5470831,23 | 5491894,16 | |
| 100 | 16 | 16 | 5426746,80 | 5488043,92 | |
Matome, kad async variantas labai priartėjęs prie teorinio maksimumo, o sync‘ui sekasi šiek tiek sunkiau. Šis testas parodo, kad nesvarbu kokią metodologiją naudosi – iš šūdo sviesto neišspausi. Jeigu parašei tokį kodą, kad jis per tam tikrą laiką gali aprodoti tik tam tikrą kiekį užklausų, tai nesvarbu ar tai bus sync, ar async, vis tiek teoretinio maksimumo neviršysi, klausimas tik kiek arti jo gali priartėti.
Išvados
- .NET 8 ir ASP.NET 8 versija yra labai ženklus atnaujinimas spartos prasme.
- Miscrosoft atsakingai pažiūrėjo į savo rekomendacijų teikimą ir pakankamai išsamiai ištestavo įvairius atvejus. Skaičiai rodo, kad jų rekomendacijos kada naudoti async, o kada async pasiteisina.
Todėl mano išvados sutampa su jų – sync geriau naudoti:
- jei nendaudojamos užklausos į išorinius resursus;
- jei tau vienodai tos užklausos per sekundę, svarbu kuo greičiau sukodint ir kuo mažiau debugint.
Visais kitais atvejais verta rinktis async.