async vs. ASP.NET

Pár napja egy blogbejegyzésre hívták fel a figyelmem, amely az async kulcsszó használatának veszélyeivel foglalkozik. A cikk az oldalt látható Microsoft MVP logó ellenére egy alapjaiban rossz példaprogramot mutat be, két dolog mégis megfogott benne:

Egyrészt nem értettem, miért áll le a cikk példaprogramja, saját elképzelésem szerint annak működnie kellett volna. Biztos voltam benne, hogy hasznos tanulságok rejlenek a jelenség mögött.

Másrészt, ha egy rendszeres blog író MVP kitüntetéssel ilyen szinten rosszul használja az async-et és az aszinkron műveleteket, akkor a kulcsszó még annál is károsabb következményekkel járt a C# életében, mint azt eredetileg gondoltam.

Igen, magam részéről nem tartom jó ötletnek az async bevezetését. Valóban jól átláthatóvá tudja tenni a programot azzal, hogy a logikai szálat engedi egyben tartani, azzal ellentétben, hogy a többi pattern (Asynchronous Programming Model, Event-based Asynchronous Model) a fizikai szálaknak megfelelően szabdalja callback-ekre vagy eseményekre a kódot.

Másik oldalról, azok számára, akik nem tudják pontosan, hogy mi történik a háttérben, veszélyeket rejt magában – kitűnő példa erre az említett blog bejegyzés, illetve hogy nem tudtam megmondani, miért blokkol a példája.

Ebben a cikkben nem megyek bele az async részleteibe, ezt számos cikk megtette az elmúlt egy évben. Arra fogok koncentrálni, hogy érthetőek legyenek a hivatkozott blog példái.

A GUI és az ultimate szinkronizáció

Bár a fő téma az ASP.NET, az említett cikk is a GUI-val kezdni, illetve a továbbiak megértését talán könnyíti, ha egy ismertebb GUI-s jelenségből indulunk ki.

Ez az ismertebb jelenség az, hogy a GUI-hoz csak egyetlen kijelölt szál férhet hozzá .NET alatt. Miért? Mert bonyolult lett volna kidolgozni olyan szinkronizációs mechanizmusokat, amely megengedi, hogy a GUI-t több szálról használva az ne essen szét, és még gyors és könnyen használható is legyen. Emiatt az áthidaló megoldás az, hogy a GUI-t egy szál érheti el – ezzel nincs szükség szinkronizációra.

Az egyetlen GUI-szál ötlete azonban még csak a megoldás fele. Mi van akkor, ha egy másik szálnak mégiscsak fontos, hogy hozzáférjen a GUI-hoz? Ekkor azt lehet tenni, hogy a GUI manipulálását végző kódot delegálni kell a GUI szálnak – azaz meg kell oldani, hogy a GUI szál számára kódokat lehessen injektálni.

Hogy ez pontosabban hogyan működik, részletesen leírásra került egy korábbi cikkben, így most csak gyorstalpaló módjára a lényeg:

A Windows és a régi-jó Message Loop

A Windows alatt minden szál rendelkezik egy üzenetsorral. Ebbe a sorba az operációs rendszer illetve másik szálak helyezhetnek el üzeneteket. Ez az üzenetsor általában nincs használva – kivéve a GUI szál esetében.

Amikor az egeret mozgatjuk, vagy egy ablakot mozgatunk/átméretezünk, akkor az operációs rendszer üzenetekkel szórja a GUI szál üzenetsorát (egészen pontosan annak a szálnak az üzenetsorát, ami a kontrolt létrehozta), ezzel jelezve, hogy a programnak eseményekre kell reagálni. A GUI szál feladata az, hogy folyamatosan figyelje az üzenetsort, és az érkező üzeneteket feldolgozza.

Ez a folyamatos figyelés jellemzően egy ciklussal van implementálva, a ciklus folyamatosan kérdezi az üzenetsor tartalmát. Ezt a ciklust nevezzük message loop-nak. A message loop-pal a mai programozóknak már nemigen kell foglalkozniuk, ezt megoldja a keretrendszer – feltéve, hogy ezt engedik számára.

Porszem a fogaskerékben

Hogyan lehet nem engedni? Amikor egy üzenet érkezik az üzenetsorba, akkor a message loop közepe egy metódushívást tartalmaz, ami a megfelelő helyre továbbítja az üzenetet feldolgozás céljából. Miután az üzenet feldolgozása megtörtént, az említett metódushívás visszatér, és a message loop folytatódhat.

Ha azonban az üzenet feldolgozása sokáig tart, akkor a message loop nem tud futni, az üzenetek csak gyűlnek az üzenetsorban, és nem lesznek lekezelve. Ilyenkor tapasztaljuk azt, hogy megfagy az alkalmazás, az ablakot nem lehet átméretezni, nem frissül a tartalma, nem lehet gombokat nyomkodni.

Az üzenetsort azonban nem csak az operációs rendszer tölti üzenetekkel. Mi magunk is írhatunk programot, ami a GUI szál üzenetsorába üzeneteket küld. És bár ezt közvetlenül ritkán tesszük meg, amikor .NET alatt egy másik szálból akarjuk használni a GUI-t, közvetve a message loop lehetőségeit használjuk.

Hogyan lehet tehát nem GUI szálból a GUI szálnak feladatokat delegálni? Erre való a SynchronizationContext osztály.

A SynchronizationContext

A SynchronizationContext célja, hogy az aktuális környezet által támasztott szinkronizációs igényeket megvalósítsa. A SynchronizationContext-nek több implementációja van, és az az aktuális környezettől függ, hogy ez az implementáció pontosan mit is csinált. Tegyük fel, hogy készítettünk egy framework-öt, ami a hálózaton kommunikál. Ennek a programnak van egy programlogikája, ez tartalmazhat valamilyen algoritmusokat, amelyek futhatnak több szálon is. Vannak azonban olyan kódrészek, amelyek már “közel vannak” a hálózati interfészhez, ezek nyílván nem futhatnak rendezetlenül párhuzamosan, különben esetleg össze-vissza használnák (pl írnák) a hálózatot. Itt jön képbe a SynchronizationContext.

Ha “hálózat közeli” kódot futtatnak a szálaink, arra felhasználják a SynchronizationContext-et, hogy a szinkronizációval foglalkozzon. Ezután a SynchronizationContext, attól függően, hogy milyen szinkronizációs mechanizmusok állnak mögötte, valamilyen módon végrehajtják a “hálózat közeli” kódrészt.

Lehet, hogy a SynchronizationContext egy lock-ot használ, ezzel biztosítva, hogy csak egy szál férhessen a hálózatközeli API-hoz. Lehet, hogy a GUI-s példát követve a kódok delegate-jeit egy feladatsorba rakja, ahonnan egy kitüntetett szál azt kiveszi és sorban végrehajtja. A Framework felhasználójának ez mindegy, neki “csak” annyit kell tudni, hogy mi az érzékeny kód, ami gondot okozhat, és akkor azt a SynchronizationContext-en keresztül hajtja végre. Ha az érzékeny kódokat nem a szinkronizációs kontexten keresztül hajtja végre, akkor a program esetleg hibásan működik. Ha feleslegesen “nem veszélyes” kódot is a szinkronizációs kontexten keresztül hajt végre, feleslegesen túráztatja a szinkronizációs mechanizmusokat, belassítva ezzel az alkalmazást.

A GUI-hoz tartozó SynchronizationContext a végrehajtandó kód delegate-jét a message loop-on keresztül átjátssza a GUI-szálnak, ami azt egy üzenetként megkapja, és végrehajtja. Ez a folyamat részleteiben megtalálható az EAP-ról szóló cikkben. Az eddigiek alapján itt egy példaprogram, ami aszinkron műveletet tartalmaz, és az érzékeny műveleteket a SynchronizationContext-en keresztül végzi el.

namespace WinFormsAsync
{
    using System;
    using System.Threading;
    using System.Windows.Forms;

    public partial class Form1 : Form
    {
        // Az aszinkron műveletek ezt a kontextust használják az
        // érzékeny műveletek szinkronizációjához:
        private SynchronizationContext synchronizationContext;

        public Form1()
        {
            InitializeComponent();

            // A WinForms library már beállította a GUI thread-en a kontextust,
            // ez el kell mentenünk, hogy más szálakon is használhassuk.
            this.synchronizationContext = SynchronizationContext.Current;
        }

        private int MassiveCalculation()
        {
            Thread.Sleep(5000);
            return 1234;
        }

        private void button1_Click(object sender, EventArgs e)
        {
            // Ez a metódus a GUI szál message loopjából lett meghívva.
            // Emiatt nem végezhetünk hosszú műveletet, a thread pool-ból
            // használunk egy másik szálat a számításokhoz. Amint az enqueue
            // megtörtént (ami egy gyors művelet), a button1_Click() metódus
            // visszatér, a message loop pedig pöröghet tovább.
            ThreadPool.QueueUserWorkItem(
                _ =>
                {
                    // Az időigényes számítás:
                    var result = MassiveCalculation();

                    // Meg van a számítás eredménye, egy "érzékeny" művelettel
                    // kell megjeleníteni. Emiatt kell a szinkronizációs kontextus:
                    this.synchronizationContext.Post(
                        // A WinForms-hoz készült SynchronizationContext
                        // implementáció a message loop-on keresztül a következő
                        // delegate-et visszaküldi a GUI szálnak, ami azt az üzenetsorból
                        // kivéve végrehajtja.
                        __ =>
                        {                            
                            this.label1.Text = result.ToString();
                        }, null);
                });
        }
    }
}

Látható, hogy a kód elég zajos amiatt, hogy pontosan leírjuk, hogy a logikai szál hogyan legyen fizikai szálakra törve. A lényeges kód pedig csak a 39 és az 50-es sorok.

Egy absztrakciós szinttel feljebb: taszkok

A fenti feladatot már a .NET 4-ben is meg lehetett egy fokkal kulturáltabban oldani – ha valaki elég jól ismeri a taszkokat. A taszkok esetén egy úgynevezett TaskScheduler dönti el, hogy a taszkok milyen szálakon fussanak. A TaskScheduler-ekről részletesen lehet olvasni egy korábbi cikkben. Ekkor a zaj némileg csökken, legalábbis az osztály és metódus nevek talán kifejezőbbek:

namespace WinFormsAsync
{
    using System;
    using System.Threading;
    using System.Threading.Tasks;
    using System.Windows.Forms;
    
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private int MassiveCalculation()
        {
            Thread.Sleep(5000);
            return 1234;
        }

        private void button1_Click(object sender, EventArgs e)
        {
            // Az hosszú művelet indítása külön taszkként. A taszk indítása
            // gyors, így a button1_Click szinte azonnal visszatérhet.
            Task.Factory.StartNew(
                () => MassiveCalculation()
            ).ContinueWith(
                // Ennek a sornak kell lefutnia, miután meg van a számítás. A futtatást
                // viszont a GUI threaden kell elvégezni. Erre jók a schedulerek, olyat
                // választunk, ami az aktuális synchronization context-et használja a
                // taszk futtatásához
                r => this.label1.Text = r.Result.ToString(),
                TaskScheduler.FromCurrentSynchronizationContext());                
        }
    }
} 

Logikai szálak és az async/await

A fenti kód még mindig megtöri a logikai szálat azzal, hogy a fizikai szálak (vagy taszkok) útját terelgetjük. Pedig ez a terelgetés nagyon sok esetben ugyanaz. Miről van szó?

  1. indítunk egy műveletet, amit valamiért aszinkron szeretnénk.
  2. Az aszinkron művelet eredményét feldolgozzuk.

Mind az egyes ponthoz, mind a kettes ponthoz tartozik valamilyen lényegi kód, ami a logikai szál futása szerint van elrendezve. A fenti példák esetében a lényegi kód:

var result = MassiveCalclation();  // 1
this.label1.Text = result;       // 2

Ez a két sor a logikai szál lényege, a korábbi két példában az összes zajt okozó kód “csak” csomagolás volt az aszinkronitás miatt. Miért ne mehetne az aszinkronitásba való csomagolás automatán? Ez a nagy ötlet az await mögött. Ezzel a kulcsszóval jelöljük meg, hogy melyik művelet induljon aszinkron. Hogy ez az aszinkronitás pontosan milyen technikát rejt, az a metódus implementációjától függ – mindenesetre tipikusan valamilyen thread pool szál indul el, vagy a művelethez nem is kell a futtató számítógép, mivel azt a háttértár, vagy a hálózat végén egy másik számítógép végzi. A lényeg, hogy a fordítóprogram a kulcsszóval jelölt híváshoz olyasmi kódot generál, mint amikor mi task-ként indítottuk az aszinkron műveletet, a maradék kódot pedig continuation-ként futtatja le – ráadásul figyelembe véve a szinkronizációs kontextet.

Az új kulcsszó kihasználásával így már nem törik meg a logikai szál, pedig a háttérben kb ugyanaz történik, mint előzőleg:

namespace WinFormsAsync
{
    using System;
    using System.Threading;
    using System.Threading.Tasks;
    using System.Windows.Forms;
    
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private async Task<int> MassiveCalculation()
        {
            await TaskEx.Delay(5000);
            return 1234;
        }

        private async void button1_Click(object sender, EventArgs e)
        {
            int result = await MassiveCalculation();
            this.label1.Text = result.ToString();            
        }
    }
}

Remekül átlátható kód, nem igaz?

Az egyszerűség ára

Valószínű az olvasók nagy része most már egészen pontosan érti, hogy mi rejlik a fenti await-os kód működése mögött. Tudjuk, hogy taszkok, continuation-ök és szinkronizációs kontextek lépnek működésbe. Azt is tudjuk, hogy a WinForms-os környezetben körülbelül hogyan működik egy SynchronizationContext.

Aki viszont pár soros villám tutoriál alapján, vagy a StackOverflow.com alapján kezdi használni az async/await-et, könnyen leír ilyesmit:

namespace WinFormsAsync
{
    using System;
    using System.Threading;
    using System.Threading.Tasks;
    using System.Windows.Forms;
    
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private async Task<int> MassiveCalculation()
        {
            await TaskEx.Delay(5000);
            return 1234;
        }

        private void button1_Click(object sender, EventArgs e)
        {       
            this.label1.Text = MassiveCalculation().Result.ToString();         
        }
    }
}

Első olvasatra talán ártalmatlannak tűnik a fenti kód. Arról van szó, hogy a MassiveCalculation()-t most nem aszinkron módon akarjuk használni (rosszabb esetben nem értjük az aszinkronitás lényegét). Most nem az a fontos, hogy ez esetleg megakasztja a GUI-t a számítás idejére. Nézzük meg alaposabban, hogy mi történik:

A MassiveCalculation aszinkron módon van megírva, azaz aszinkron módon kezdi el a számítást (ami most itt egy aszinkron delay, de a célnak megfelel). Természetesen a számítás eredményét nem tudja visszaadni a MassiveCalculation() hívás, csak valamit, ami a jövőbeli számítási eredményt hordozza, ez a hordozó pedig egy Task<int>. A MassiveCalculation() által visszaadott Task<int> pedig akkor fog tudni eredményt szolgáltatni, amikor a MassiveCalculation() “await” utáni része is le tud futni, a mi esetünkben ez a rész a “return 1234”. Ez a “return 1234” állítja be a Task<int> Result-ját.

Van még egy fontos részlet, amit figyelembe kell venni. A program a MassiveCalculation-t a GUI szálról hívja meg. Tudjuk, hogy az await mögötti mechanizmus ekkor a “return 1234”-et (ami persze átfordul egy kb Task.result = 1234-re, egészen pontosan egy TaskCompletionSource.SetResult-tá) az aktuális SynchronizationContext segítségével fogja futtatni. Ez a SynchronizationContext a WinForms esetében egy üzenetet küld a GUI szál üzenetsorába, a “return 1234”-et tartalmazó delegate-tel. Azt várjuk, hogy a GUI szálon tekerő message loop felveszi az üzenetet, és végrehajtja.

Viszont ha most visszanézünk a button1_Click metódusba, abban van egy MassiveCalculation().Result, ami egy Task<int>.Result property olvasás. A Task.Result property implementációja pedig olyan, hogy amíg a taszk nem tudja szolgáltatni az eredményt, addig blokkolja a hívást, és várja hogy a taszk végezzen. Amikor a MassiveCalculation() visszaadja a taszkot, akkor a taszknak nem lesz result-ja, az csak 5 másodperc múlva készül el a return 1234-gyel. A button1_Click a GUI szálon belehív a task.Result-ba, ami így blokkolja a hívást, ezzel együtt a message loop-ot. Amikor a MassiveCalculation-ban indított Delay(5000) “végez”, akkor a korábban említett módon continuation-ként lefuttattatja a SynchronizationContext-tel a return 1234-et, ez a lefuttattatás pedig egy üzenetet eredményez a GUI szál üzenetsorába.

A helyzet így most már biztosan világos. A GUI szál a message loop tekerése helyett várakozik a Task.Result hívásban, a Task.Result pedig nem tér vissza, amig a GUI szál üzenetsorából a “return 1234” delegate-et a GUI szál végre nem hajtja. Ez egy deadlock.

Na és az ASP.NET?

Most ott tartunk, hogy értjük mit csinál az async/await pár soros metódusoknál, hogyan jön a képbe a szinkronizációs kontextus, és van sejtésünk miről próbál írni a cikk elején említett blogbejegyzés a keretes részben. Készen állunk továbbá arra. hogy megértsük mi történik a blogbejegyzésben példaként adott MVC-s alkalmazásban.

A következőkben egy ugyanolyan felépítésű alkalmazást fogunk megvizsgálni, mint amit a blogbejegyzés példaként említ. A példakód egy súlyos hibát tartalmaz, mégpedig, hogy a kontroller osztálya a Controller-ből származik az AsyncController helyett. Ennek az a következménye, hogy blokkolni kell a végrehajtást addig, amíg az aszinkron műveletek be nem fejeződnek. Ellenkező esetben az MVC keretrendszer nem tudná, hogy nem küldheti el HTTP response-t. A hibát tehát a rossz MVC használat generálja, aszinkron kontrollernél nem jönne elő. Ennek ellenére, amikor először láttam a kódot, arra tippeltem volna, hogy minden csúnyasága ellenére a hasonló felépítésű GUI-s kóddal ellentétben ennek mégis működnie kell:

namespace MvcApplication1.Controllers
{
    using System;
    using System.Threading.Tasks;
    using System.Web.Mvc;

    public class AlmaController : Controller
    {
        private string FormatResult(string functionName, DateTime startTime)
        {
            return string.Format(
                        "{2}: started [{0}], finished [{1}]",
                        startTime.ToString("ss.ffff"),
                        DateTime.Now.ToString("ss.ffff"),
                        functionName);
        }

        private async Task<string> GetKorteAsync()
        {
            var start = DateTime.Now;
            await TaskEx.Delay(1000);

            // Ennek le kell futni, hogy legyen Task.Result
            return FormatResult("Korte", start); 
        }

        public ActionResult Index()
        {
            var korte = GetKorteAsync();
            ViewBag.KorteInfo = korte.Result; // Ez egy blokkoló hívás

            return View();
        }

    }
}

Habár a program felépítése hasonló a deadlock-ot okozó GUI-s példáéval, van egy fontos különbség: az ASP.NET működik több szálon. Éppen ezért – nyílván az ASP.NET-ben való járatlanságom miatt – meglepődtem, amikor nyomkövetés közben próbáltam kideríteni, hogy miért áll a program.

A következőkben az látszik, amit az Immediate Window-ban sikerült kicsiholni az SOS plugin segítségével, erősen letisztított formában (a call stack 95%-a törlésre került):


.load sos
extension C:\WINDOWS\Microsoft.NET\Framework\v4.0.30319\sos.dll loaded
!EEStack -EE
---------------------------------------------
Thread 5268
Current frame:

System.Threading.Monitor.Wait(System.Object, Int32, Boolean))
System.Threading.Tasks.Task`1[[System.__Canon, mscorlib]].get_Result())
MvcApplication1.Controllers.AlmaController.Index())
System.Web.HttpApplication.ExecuteStep(IExecutionStep, Boolean ByRef))
System.Web.HttpRuntime.ProcessRequest(System.Web.HttpWorkerRequest))
---------------------------------------------
Thread 5656
Current frame:

System.Threading.Monitor.Enter(System.Object, Boolean ByRef))
System.Web.AspNetSynchronizationContext.CallCallback(System.Threading.SendOrPostCallback, System.Object))
System.Web.AspNetSynchronizationContext.Post(System.Threading.SendOrPostCallback, System.Object))
System.Threading.Tasks.Task.FinishContinuations())
System.Threading._TimerCallback.PerformTimerCallback(System.Object))
---------------------------------------------

Van tehát két érdekes szál, az egyik, az 5268-as számú, ami egy request-et dolgoz fel, láthatólag az AlmaController Index action-jében, és egy Task.Result hívásnak köszönhetően most egy esemény bekövetkeztére vár. Ez az esemény az, hogy a task lefusson, és a Result elérhető legyen.

A másik szálat egy Timer indította, ez a Delay implementációja miatt indult. Most éppen a continuation taszkokat futtatja, amiben pedig az AspNetSynchronizationContext segítségével hajtana végre valamit – ez az await utáni rész a GetKorteAsync()-ból. Ehhez a végrehajtáshoz viszont előbb egy szinkronizációs objektumot lockolna, amit nem tud – mivel más lockolta.

Nézzük a lockolt objektumokat:


!SyncBlk
Index SyncBlock MonitorHeld Recursion Owning Thread Info SyncBlock Owner
43 001ae82c 3 1 5268 00d42b18 ASP.global_asax

Egyetlen egy objektum van jelenleg lockolva, mégpedig az 5268-as szál által, ami ugye az AlmaController Index action-ben várja, hogy a Task.Result megjelenjen. A lockolt objektum pedig nem más, mint az alkalmazás objektum, a keretrendszer által generált, HttpApplication/MvcApplication felmenőkből származó ASP.global_asax. Az AspNetSynchronizationContext tehát nem várhat másra, mint az ASP.global_asax-ra, azaz az alkalmazás példányra.

A helyzet innen tiszta. Az MVC-s action soha nem fog végezni, mivel arra vár, hogy az aszinkron művelet (a GetKorteAsync()) befejezze a munkát. Az aszinkron művelet viszont soha nem fogja befejezni a munkát, mivel ezt a szinkronizációs kontexten keresztül tenné – ami pedig vár arra, hogy az MVC-s action elengedje a HttpApplication-t. Klasszikus dead lock megint.

Mire jó az AspNetSynchronizationContext?

A SynchronizationContext ismertetésénél említésre került, hogy a célja az, hogy az érzékeny műveletek szinkronizálását rábízhassuk. Az érzékeny művelet a környezettől függ, GUI-s alkalmazásoknál a GUI manipulálása az érzékeny művelet. Mi lehet az érzékeny művelet az ASP.NET esetében?

Egy HTTP request kiszolgálása

Az ASP.NET szálainak fő célja, hogy egy HTTP kérést kiszolgáljanak. A kérés kiszolgálásához kell maga a kérés, ha a kérés egy session része, kellenek a session információk illetve egyéb ASP.NET-es infrastrukturális dolgok. Mindezeket egy HttpContext osztály fogja össze.

A HttpContext és az ExecutionContext

Egy korábbi cikkben szó volt az ExecutionContext osztályról és szerepéről. Segítségével olyan “ambient” vagy kontextus értékeket lehet egy szálhoz rendelni, amelyeket a szálon bármely metódusból elérhetünk, anélkül, hogy azt a metódusról metódusra adogatni kellene. Ilyen kontextus érték például a CurrentPrincipal vagy a Culture információk.

A HttpContext-et az ASP.NET alsóbb rétegei belefűzik az ExecutionContext-be, így a hívási láncon felfele az mindenhonnan elérhető. A HttpContext.Current statikus property nem csinál mást, csak előkeresi a szálhoz rendelt HttpContext-et az ExecutionContext-ből, és ugyanezt teszi a Page.Context is.

Logikai szálak és az ExecutionContext

Az ExecutionContext-nek van egy másik jó tulajdonsága azon felül, hogy kontextus értékeket tud hordozni: követi a logikai szálak folyamát. Mit jelent ez? Tegyük fel, hogy a programom egy file olvasást végez aszinkron módon. Ekkor a logikai szál csinál valamit a file olvasás előtt, elvégzi a file olvasást, majd feldolgozza az adatokat. Ez logikailag egy folyamat. Fizikailag azonban, egy fizikai szál a file olvasása előtt végez valamilyen műveleteket, ezután elindítja a file olvasást, amit átvesz az operációs rendszer, majd a merevlemez. Ekkor a fizikai szálra nincs szükség. Miután az olvasást elvégezte a merevlemez, az operációs rendszer indít egy fizikai szálat (pl a .NET Thread Pool-ból), amin az adatok feldolgozása történik. Tehát a logikai szál kettétört két fizikai szálra.

Ugyanakkor, ha a folyamatot egy logikai szálnak képzelem el, elvárhatom, hogy az ExecutionContext ugyanaz legyen a fizikai száltól függetlenül. Szerencsére erre a keretrendszer ügyel is, így ha egy szálból, például a thread pool-ra irányítok feladatokat, akkor az ExecutionContext követi a folyamatot a thread pool szálon is. Ugyanígy, az APM-es callbackokat követi az ExecutionContext, mint ahogy a Task-ok Continuation taszkjait is.

Kivéve a gyevi bíró – IllogicalCallContext

Az ExecutionContextről szóló cikkben említésre kerül azonban az ExecutionContext egy része, amely nem követi a logikai szálat. Ez az IllogicalCallContext. És ami az ASP.NET szempontjából lényeg, hogy a HttpContext az IllogicalCallContext része. Ennek az a következménye, hogy a HttpContext nem követi a logikai szálakat.

Mennyire több szálú az ASP.NET?

Miért lehet ez így? Pontos választ nem tudnék adni, de az látszik, hogy az ASP.NET tervezői nem akarták közvetlenül támogatni azt, hogy egy logikai szál fizikai szálakra essen szét – neadjisten párhuzamosan futó fizikai szálakra. Ezt a sejtést erősíti, hogy a HttpContext rendelkezik olyan property-kel, amelyről nehéz elképzelni, hogy a több szálúság figyelembevételével készült. Legjobb példa erre a HttpContext.CurrentThread, ami internal property ugyan, de mutatja, hogy a HttpContext szálhoz kötött.

A publikus property-k sem szálvédettek, az Items propery például egy szinkronizálatlan HashTable-t ad vissza, ráadásul lazy módon inicializálva – szintén nem szálvédetten. Több szálból hívva így előfordulhat, hogy a két szál két külön HashTable példányt kap, amiből az egyik később nem is tartozik majd a HttpContext-hez.

A HttpContext példány tehát érzékeny pontnak tekinthető, ha több szálúságról van szó. Eddig akár úgy is tűnhet, hogy az ASP.NET nem is támogatja a több szálú végrehajtást. Trükkök nélkül el sem lehet érni a HttpContext-et más szálakból. Paraméterként odaadhatjuk ugyan egy másik szálnak, esetleg elérhetjük a HttpApplication példányon keresztül, de a fentiek fényében nem biztos, hogy azt bölcs dolog lesz használni.

Az AspNetSynchronizationContext azonban ad lehetőséget arra, hogy HttpContext közeli műveleteket végrehajtsunk. Vajon úgy működik ez, mint a GUI esetében? Egy kijelölt szálra viszi vissza a vezérlést? Ez ASP.NET esetében nehézkes lenne, hiszen az “eredeti” szálnak is van munkája, és az nem valamiféle feladatsor olvasgatása, mint a GUI szál esetében. Az AspNetSynchronizationContext ennél egyszerűbben működik: a Send/Post a hívó szálon hajtja végre a műveletet, tehát ha adott szálon hívunk például egy synchronizationContext.Post-ot, akkor ugyanazon a szálon maradunk, az átadott delegate ugyanazon a szálon fut le (ráadásul szinkron módon, bár a Post()-tól mást várnánk). Ami plusz történik az az, hogy ideiglenesen a AspNetSynchronizationContext a szálhoz rendeli a HttpContext-et. Konkrétabban, szól a HttpApplication-nek, hogy rendelje az adott szálhoz a HttpContext-et, emiatt az láthatóvá válik a HttpContext.Current property-vel, amíg a delegate fut.

Mit jelent ez a hozzárendelés egészen pontosan? Honnan tudja a többi szál, hogy most nem hozzájuk van rendelve a HttpContext? Hát erre egy nagyon drasztikus megoldás született: egyszerre csak egy szál futtathat olyan régiót, amelyen legális módon látszik az adott requesthez tartozó HttpContext. A legális mód azt jelenti, hogy az ExecutionContext részeként. Amikor a AspNetSynchronizationContext segítségét kérjük, mert HttpContext közeli műveletre készülünk, akkor a AspNetSynchronizationContext blokkol addig, amíg egy másik szál el tudja érni a HttpContext-et. Belső implementációjában ez egy lock-kal történik, jelenleg a HttpApplication-re. Miután az AspNetSynchronizationContext végzett a Send/Post végrehajtásával, eltakarítja a HttpContext-et az ExecutionContext-ből, és elengedi a lock-ot a HttpApplication-ről.

Hogy kerek legyen a történet, meg kell említeni, hogy alapesetben, amikor a HttpApplication egy adott szálon elkezdi feldolgozni a request-et, akkor a HttpContext-et az adott szálhoz rendeli, és megszerzi a lock-ot saját magára. Ennek két következménye lesz: egyrészt a HttpContext.Current az egész feldolgozási folyamat alatt elérhető. Másrészt semelyik másik szálnak esélye sem lesz megszerezni a HttpContext-et a request feldolgozása alatt. Így talán már érthető az SOS stack trace és a SyncBlk parancs eredménye.

Aszinkron modú ASP.NET

Az ASP.NET átkapcsolható aszinkron módba, ekkor az a szál, amelyik a request feldolgozását elkezdte, nem fogja végig a lock-ot a teljes feldolgozási ciklus alatt. Ehelyett a lockot a feldolgozás egy fázisában befejezi, majd visszatér a thread pool-ba. Ezután külön kell jelezni a keretrendszernek, hogy folytathatja a feldolgozást. Emiatt marad lehetősége a saját szálainknak az AspNetSynchronizationContext-en keresztül elérni a HttpContext-et.

Az MVC framework ilyen módon működik, ugyanakkor a Controller osztályból származó kontrollerek action-jei lock alatt futnak. Ha itt blokkolunk, akkor a lock nem lesz elengedve, és a többi szál nem fog hozzáférni a HttpContext-hez. Ezek miatt minden AspNetSynchronizationContext.Post() is blokkolni fog. Ha nem blokkolunk a szinkron Controller action-jában, akkor a keretrendszer nem fogja megvárni az általunk indított aszinkron műveletek lefutását, hanem elkezdi renderelni a view-t és visszaküldi a response-t. Emiatt került kutyaszorítóba az eredeti mintaalkalmazás készítője.

Megoldásként aszinkron kontrollert kell használni. Ez az action “első fele” után elengedi a fentebb említett lock-ot, emiatt az await által hívott SynchronizationContext.Post-ok le tudnak futni. A blokkoló megoldással szemben további előny, hogy az ASP.NET worker thread, ami az action-t futtatta, visszatérhet a thread pool-ba, ahelyett, hogy feleslegesen állna egy blokkoló hívásban:

namespace MvcApplication1.Controllers
{
    using System;
    using System.Threading.Tasks;
    using System.Web.Mvc;

    public class AlmaController : AsyncController
    {
        private string FormatResult(string functionName, DateTime startTime)
        {
            return string.Format(
                        "{2}: started [{0}], finished [{1}]",
                        startTime.ToString("ss.ffff"),
                        DateTime.Now.ToString("ss.ffff"),
                        functionName);
        }

        private async Task<string> GetKorteAsync()
        {
            var start = DateTime.Now;
            await TaskEx.Delay(1000);
            return FormatResult("Korte", start);
        }

        public async void IndexAsync()
        {
            AsyncManager.OutstandingOperations.Increment();

            string korte = await GetKorteAsync();
            AsyncManager.Parameters["korte"] = korte;

            AsyncManager.OutstandingOperations.Decrement();
        }

        public ActionResult IndexCompleted(string korte)
        {
            ViewBag.KorteInfo = korte;
            return View();
        }        
    }
}

Több dologra érdemes felfigyelni. A kontroller itt egy AsyncController, emiatt az action feloldásánál ez az xxxAsync/xxxCompleted párt keresi egy action-höz. Az IndexAsync-t az async kulcsszó előzi meg, emiatt az metódus futása az await után azonnal visszatérhet a hívóhoz (és elengedheti a HttpApplication lockot).

A szép tiszta logikai szálat viszont megint egy kis zajjal kellett ellátni. Az aszinkron action-oknál a keretrendszer nem tudja, hogy mikor jön el a pillanat, amikor a response-t vissza lehet küldeni. Emiatt számoltatni kell a folyamatban levő aszinkron műveleteket, erre szolgál az AsyncManager.OutstandingOperations.Increment()/Decrement() páros. Amikor a számláló eléri a nullát, akkor kerül a response visszaküldésre, pontosabban akkor kerül hívásra az xxxCompleted pár, ami a View renderelését indítja.

Mindig szükséges az AspNetSynchronizationContext?

Az AspNetSynchronizationContext célja, hogy a HttpContext közeli műveleteket kontrollálja. Mivel ASP.NET worker thread-en indítunk await-es műveleteket, az await a AspNetSynchronizationContext-et fogja használni, hogy a GetKorteAsync második felét futtassa. Azonban erre ritkán van szükség. Az await mögött dolgozó mechanizmus a biztonság kedvéért azzal a feltételezéssel él, hogy ha van a thread-en az ExecutionContext-ben SynchronizationContext, az lehet, hogy nem véletlen, emiatt használni fogja. Egy GUI-s eseménykezelő esetén ez a feltételezés például nagyon jól jön, ha a GUI-t módosítjuk. Számos más esetben azonban az aszinkron művelet a szinkronizációs kontextustól független, a mi példánkban például a GetKorteAsync() lehet adatbázis olvasástól kezdve nagyon sok minden, de a lényeg, hogy csak egy string-et állít össze, amihez semmi szükség a HttpContext-re, így az AspNetSynchronizationContext-re sem.

Amikor a saját kódunkat írjuk, lehetőség van annak meghatározására, hogy az await utáni rész figyelembe vegye-e a szálon élő synchronization context-et. Ezt egy az alábbihoz hasonló hívással tehetjük meg:

  string korte = 
            await GetKorteAsync()
           .ConfigureAwait(continueOnCapturedContext: false);

Ebben az esetben a GetKorteAsync után futó kód nem használja a szinkronizációs kontext-et. Érdemes vajon akkor ezt használni az aszinkron könyvtáraink megvalósításánál? Ez azt a problémát veti fel, hogy nem tudhatjuk előre, hogy a könyvtárunkat milyen kódból hívják. Amikor egy aszinkron metódusba belehívnak, a hívó kezében van a kontrol, hogy az milyen szálon indítja. Lehet ez valami speciális thread vagy tread pool, a lényeg, hogy a hívást a hívó valamiért speciális szálon kezdeményezte. Lehet, hogy elvárja, hogy az egész logikai szál ott fusson le, és az alap await megvalósítás ezt a lehetséges igényt ki is elégíti. Ha a könyvtárunkban viszont ConfigureAwait-ezünk, akkor esetleg felülírjuk a hívó elvárását. Emiatt bár saját felhasználású kódban lehet használni a continueOnCapturedContext kapcsolót, egy általánosabban megírt könyvtáraknál ez már illetlenség. Kicsi eséllyel okoz gondot, de a lehetőség benne van. Ekkor inkább a hívónak kell a context-re figyelni. Ha nem akarom, hogy az aszinkron hívás AspNetSynchronizationContext-et használjon, a hívás idejére ezt felülírhatom például a default implementációval, ami a thread pool-ra fogja küldeni a műveleteket.

Több aszinkron szerviz használata

Aki nem ismeri pontosan, hogy hogyan működik az await, esetleg abban bízik, hogy a következő Action-ja optimális hatékonyságú:

public async void IndexAsync()
{
    AsyncManager.OutstandingOperations.Increment();

    string barack = await GetBarackAsync();
    AsyncManager.Parameters["barack"] = barack;

    string korte = await GetKorteAsync();
    AsyncManager.Parameters["korte"] = korte;

    AsyncManager.OutstandingOperations.Decrement();
}

A kódban két await hívás van. Hogyan hajtódik végre a fenti kód? Egy fizikai szál eljut az első await-ig, majd onnan visszatér a hívóhoz. Amikor a GetBarackAsync() végez, akkor valamilyen fizikai szálon elkezdi futtatni az await GetBarackAsync() hivás utáni kódrészt. Ekkor eléri a második await-et, ami mögött található GetKorteAsync()-et meghívja. Ami tehát a lényeg, hogy a második aszinkron hívás csak az első lefutása után indul, ami egyáltalán nem biztos, hogy optimális. Ha két független távoli (tehát CPU időt nem igénylő) szervizhívásról van szó, akkor ezek történhetnének párhuzamosan. Ennek kifejezésére nem jó az await. Ilyen esetekben talán hatékonyabb a Task-okat használni:

public void IndexAsync()
{
    AsyncManager.Parameters["startThread"] = Thread.CurrentThread.ManagedThreadId;

    AsyncManager.OutstandingOperations.Increment();
   
     Task.Factory.ContinueWhenAll(
           new [] 
           { 
               GetBarackAsync(), 
               GetKorteAsync() 
           },
           results => 
           {
               AsyncManager.Parameters["barack"] = results[0].Result;
               AsyncManager.Parameters["korte"] = results[1].Result;

               AsyncManager.OutstandingOperations.Decrement(); 
           });
}

Mit tud az ASP.NET 4.5?

Sajnos az új lehetőségeket még nem volt időm átnézni. Mindenesetre az ígéretek szertin van új szinlronizációs kontext. Ha találok valami hasznosat – és lesz rá időm, megírom.

Konklúzió

Az async/await az új C# egyik slágertémája. Ígérete szerint átláthatóbb több szálú programokat lehet írni. Ez így is van, azonban az apró betűs részekről kevesebb szó hallatszott. Az async/await biztonságos használatához nagyon jól kell érteni, hogy mi történik a háttérben, mind a compiler support részéről (milyen működésű kódot fordít) mind a keretrendszer részéről (mint WPF, ASP.NET, MVC).

Láthatjuk, egy Microsoft MVP súlyos hibáktól szenvedő kódot mutat be (szinkron kontroller + blokkolás), és ennek a következményeit az await-ra fogja. Saját tapasztalatomból pedig azt látom, hogy reflectorozással kell kideríteni a keretrendszer internal osztályaiból, hogy hogyan kell használni aszinkron kódokat például az ASP.NET-ben. Nem vagyok biztos benne, hogy mindenki lelkesen reflectorozza a keretrendszer kódját – és ez nem is elvárható. Ezek miatt azt gondolom, az async/await, de úgy általában a több szálú programozás sok fejfájást fog okozni a programozóvilágnak. Azzal, hogy a több szálú programozás látszólag könyebbé válik compiler support-tal vagy magas szintű osztályokal, maga a témakör még bonyolult marad, az új kulcsszó/osztályrendszer így inkább csak hamis illúziókeltésre jó.

  1. #1 by Attila Érsek on 2012. November 5. - 18:54

    Nagyon szépen összeszedett leírás, és csak pont annyira mély technikailag, amennyire szükséges! Köszi!

  2. #2 by eMeL on 2012. November 5. - 19:11

    Köszi, felettébb érdekes.

    Bár én amikor elértük az ASP.NET problematikát “gyorsolvasásba” kapcsoltam, de annyi ott is megmaradt, hogy “Figyelem, ha szálakat akarsz ASP.NET alatt, akkor olvasd el ezt a cikket mégegyszer”😉 [a linket lementettem, jó lesz az még valamire]

    A hagyományos GUI megoldás leírása azonban nálam tovább tisztította a többszálúság (gyakorlatban használt) elméletét.

  3. #3 by L on 2012. November 6. - 00:41

    Gratulálok a cikkhez.

  4. #4 by tflamich on 2012. November 6. - 12:26

    Örülök, hogy újra blogolsz🙂. A cikk nagyon, ahogy szokott. Annyi megjegyzést tennék, hogy MVC4-ben sokat egyszerűsödött az async használat, már nem szükséges az AsyncController és AsyncManager felhasználásása.

    • #5 by tflamich on 2012. November 6. - 12:36

      *nagyon jó

    • #6 by Tóth Viktor on 2012. November 6. - 12:47

      Hehe, kössz, hogy szóltál, megnéztem gyorsan, és tényleg sokkal kultúráltabb az MVC4-es aszinkron action.

  5. #7 by furniture on 2014. July 12. - 08:41

    Clean and Sand – When the perfect day arrives, you will need to
    remove any hardware you do not want painted and clean your laminate furniture off with TSP and a rag.
    They’re unsure how to “shop it” because it’s different from other retail experiences.

    Cats are not destructive by nature but they scratch the legs of the table or your sofa
    to sharpen their claws and to leave a territorial mark.

  1. MVC4 és az aszinkron action - pro C# tology - devPortal

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: