I asynkrone programmeringssituationer er det vigtigt at forstå, hvordan stack trace fungerer i relation til virtuelle stack traces. En stack trace er et koncept, der er knyttet til én tråd, og i asynkrone koder kan den faktiske stack for den nuværende tråd være meget forskellig fra den stack trace, som en exception producerer. Exceptionen fanger stack-tracen af programmørens intention, med de metoder, som programmøren har kaldt, i stedet for detaljerne om, hvordan C# har valgt at udføre dele af disse metoder. Denne opdeling mellem stack trace og den faktiske kørsel af metoder gør det muligt at få et mere præcist billede af fejlsituationer, men det kræver også en dyb forståelse af, hvordan asynkrone metoder fungerer.

Asynkrone metoder er kun asynkrone, når de kalder en asynkron metode med et await. Indtil det sker, kører de i den tråd, der kaldte dem, på samme måde som en synkron metode. Denne opførsel har konkrete implikationer, især når en kæde af asynkrone metoder kan afsluttes synkront. Husk, at den asynkrone metode kun pauser, når den når det første await. Selv da er det ikke nødvendigt at pauser, fordi den opgave, der er givet til await, muligvis allerede er afsluttet. En Task kan allerede være afsluttet i følgende situationer:

  • Den blev oprettet som afsluttet, ved hjælp af Task.FromResult-metoden.

  • Den blev returneret fra en asynkron metode, der aldrig nåede et await.

  • Den udførte en ægte asynkron operation, men blev afsluttet (måske fordi den nuværende tråd udførte noget andet, før den nåede await).

  • Den blev returneret fra en asynkron metode, som nåede et await, men den opgave, der blev ventet på, var også allerede afsluttet.

Derfor opstår der noget interessant, når du venter på en Task, der allerede er afsluttet, dybt i en kæde af asynkrone metoder. Hele kæden afsluttes sandsynligvis synkront, da den første await, der kaldes, altid er den dybeste. De andre awaits nås først, efter den dybeste metode har haft mulighed for at returnere synkront. Dette betyder, at det kan være ineffektivt at bruge asynkrone metoder i visse tilfælde, især når der ikke er behov for asynkronitet.

Men der er også situationer, hvor metoder undertiden vil returnere synkront. For eksempel, en metode, der cacher sine resultater i hukommelsen, kan returnere synkront, når resultatet er tilgængeligt fra cachen, men asynkront når den skal lave en netværksanmodning. Det kan også være nyttigt at sikre, at metoder returnerer Task eller Task<T> for at fremtidssikre kodebasen, især når man ved, at der en dag kan blive behov for at gøre metoderne asynkrone.

Når vi ser på TAP (Task-Based Asynchronous Pattern), som er en anbefaling fra Microsoft for at skrive asynkrone API'er i .NET ved brug af Task, ser vi på nogle konkrete retningslinjer. TAP skaber en struktureret måde at håndtere asynkrone opgaver på, der gør brug af Task som en abstraktion for langvarige operationer. Hovedidéen er, at en asynkron metode skal returnere en Task, som indeholder løftet om, at en langvarig operation vil afslutte i fremtiden. Dette adskiller sig fra tidligere asynkrone mønstre, som krævede ekstra parametre, metoder eller begivenheder for at understøtte callback-mekanismen.

Ved at bruge Task kan vi holde vores kode fri for de detaljer, der tidligere var nødvendige for at understøtte callback-mekanismerne. Desuden betyder det, at asynkrone callback-mekanismer kan blive mere kraftfulde, hvilket gør det muligt at gøre ting som at genskabe kontekst (inklusive synkroniseringskontekst) under callbacken. Task giver derfor en fælles API til at håndtere asynkrone operationer, hvilket gør compilerfunktioner som async realistiske.

I tilfælde af beregningstunge operationer, der ikke nødvendigvis kræver netværksadgang eller diskadgang, men stadig tager lang tid, er det vigtigt at kunne håndtere disse uden at fryse UI'en i et program. Task gør det muligt at køre sådanne beregninger på baggrundstråde uden at blokere brugergrænsefladen, hvilket betyder, at UI-tråden kan fortsætte med at behandle andre hændelser, mens beregningen kører. En simpel måde at gøre dette på er ved at bruge Task.Run:

csharp
Task t = Task.Run(() => MyLongComputation(a, b));
await t;

Dette er en enkel måde at udføre arbejde på en baggrundstråd, og man kan bruge Task.Run til at styre, hvordan og hvornår beregningen skal køres på en anden tråd. Task.Factory giver yderligere kontrol over trådene og hvordan de køres. Hvis du for eksempel arbejder på en bibliotek, der indeholder beregningstunge metoder, kan du blive fristet til at tilbyde asynkrone versioner af dine metoder, der kalder Task.Run for at undgå at blokere UI-tråden.

Det er dog vigtigt at forstå, at asynkrone metoder, selvom de kan virke som en måde at optimere ressourcehåndtering på, ikke altid nødvendigvis giver bedre ydeevne i alle scenarier. Den rigtige beslutning afhænger af den specifikke situation og det mønster, der er mest hensigtsmæssigt for den pågældende applikation.

Hvordan fungerer den asynkrone tilstandsmaskine og MoveNext-metoden?

I det asynkrone programmeringsparadigme, når vi arbejder med await og async, er den bagvedliggende mekanisme ofte ukendt for de fleste udviklere. Den asynkrone tilstandsmaskine, som anvendes af kompilatoren til at håndtere opgaver, er et vigtigt koncept at forstå. Denne tilstandsmaskine arbejder ved at opdele en metode, der anvender await, i flere tilstande og håndterer, hvornår og hvordan metoden skal fortsætte eksekveringen efter et await.

Når du skriver din kode og anvender await, omdanner kompilatoren din metode til en tilstandsmaskine, som arbejder med MoveNext-metoden. Denne metode, som er en del af den kompilator-genererede kode, er ansvarlig for at sikre, at din asynkrone metode bliver udført korrekt og effektivt.

For at forstå denne proces er det vigtigt at se på, hvordan koden transformeres. Den oprindelige kode, der indeholder await, bliver brudt op i mindre stykker, som derefter bliver udført i de relevante faser af MoveNext. Det første skridt er at kopiere din kode til MoveNext-metoden og ændre eventuelle variabelreferencer, så de peger på de nye medlemsvariabler for tilstandsmaskinen. For eksempel kan en variabel som 5__1 = 3; i din oprindelige kode blive overført til tilstandsmaskinen. Efterfølgende vil Task.Delay(500) eller en anden opgave blive ventet på, og det nødvendige await vil blive håndteret af tilstandsmaskinen.

En vigtig del af transformationen er at håndtere return-erklæringer. Da MoveNext-metoden ikke kan returnere noget (den har en void returtype), skal alle return-erklæringer i den oprindelige metode konverteres til kode, der afslutter den Task, der blev returneret af den oprindelige metode. Dette gøres ved at kalde <>t__builder.SetResult(5__1);, som afslutter opgaven og tillader fortsættelse af metoden.

Et af de mest interessante aspekter ved denne proces er, hvordan tilstandsmaskinen ved hjælp af IL-kode (Intermediate Language) kan hoppe til den rette position i metoden, afhængigt af den nuværende tilstand. Dette svarer til at bruge et switch-statement i den kompilator-genererede kode, som gør det muligt at fortsætte metoden fra det korrekte punkt. For at gøre dette bruges en mekanisme til at registrere en TaskAwaiter, som noterer sig, hvornår den ventede opgave er færdig.

I en asynkron metode kan vi møde flere scenarier, hvor metoden skal "pause" og vente på, at en opgave bliver fuldført. Dette gøres ved at registrere en TaskAwaiter, der holder styr på den asynkrone opgave, og sørger for, at tråden bliver frigivet til andre opgaver, mens vi venter. Når opgaven er færdig, vil MoveNext blive kaldt igen for at fortsætte fra det relevante punkt.

En anden central funktion ved asynkrone metoder er, hvordan de håndterer undtagelser. Hvis en undtagelse opstår under udførelsen af en asynkron metode, og der ikke findes en try..catch-blok til at fange den, vil kompilatoren generere kode, der fanger undtagelsen og sætter den returnerede Task til en fejlagtig tilstand. Det er derfor vigtigt, at alle undtagelser i en asynkron metode håndteres korrekt, enten ved brug af try..catch eller gennem kompilatorens automatiske håndtering af fejl.

Når koden bliver mere kompleks, eksempelvis ved introduktion af try..catch..finally-blokke, betingelser som if og switch, eller løkker, bliver den genererede kode for MoveNext langt mere kompleks. Det er dog værd at bemærke, at kompilatoren håndterer disse konstruktioner korrekt, så udvikleren ikke behøver at bekymre sig om detaljerne. Ved at bruge et dekompileringsværktøj kan man få indsigt i, hvordan den oprindelige asynkrone metode bliver oversat til MoveNext, og man vil hurtigt forstå de forenklinger, som kompilatoren har lavet.

En vigtig funktion i asynkrone operationer er, at man kan skrive sine egne "awaitable" typer. For at gøre en type "awaitable" kræver det, at den implementerer specifikke metoder og interfaces, som f.eks. INotifyCompletion, som bruges til at registrere færdiggørelse af operationer. Denne proces gør det muligt for udviklere at oprette deres egne typer, der kan bruges med await. Det er dog vigtigt at overveje at bruge eksisterende typer som TaskCompletionSource, da disse allerede indeholder mange nyttige funktioner, som kan være svære at implementere korrekt fra bunden.

En af de store fordele ved den kompilator-genererede kode er, at man ikke behøver at vedligeholde den. Selvom den underliggende mekanisme er kompleks, gør det muligt for udviklere at fokusere på deres forretningslogik, mens kompilatoren håndterer alt det tekniske. Derudover er debugging af asynkrone metoder blevet betragteligt lettere, fordi kompilatoren skaber en forbindelse mellem kildekoden og den genererede MoveNext-metode, hvilket betyder, at man kan sætte breakpoints og gennemgå koden på en effektiv måde.

Endelig, når man arbejder med asynkrone metoder, er det vigtigt at huske på, at de kan blive kaldt fra både den oprindelige kaldende metode og fra en fuldført Task. Derfor skal eventuelle undtagelser håndteres forsvarligt for at sikre, at koden fungerer korrekt og forhindrer utilsigtede undtagelser i at slippe ud.

Hvordan Håndtere Asynkrone Operationer Effektivt i Programmering?

En simpel tilgang til at håndtere asynkrone operationer, uden at bruge async og await, involverer at passere en callback-funktion som parameter til en metode. Denne metode er nemmere at bruge end de mere komplekse mønstre, da det kræver færre trin, men den har sine egne ulemper. I dette kapitel skal vi udforske den grundlæggende struktur af asynkrone opkald ved hjælp af callbacks, og hvordan vi kan gøre koden mere overskuelig ved at udnytte Task Parallel Library og Task-klassen i .NET.

Et typisk eksempel på asynkron adfærd uden async-nøgleordet kan se sådan ud:

csharp
void GetHostAddress(string hostName, Action callback)
private void LookupHostName() { GetHostAddress("oreilly.com", OnHostNameResolved); } private void OnHostNameResolved(IPAddress address) { // Gør noget med adressen }

I dette eksempel bliver metoden GetHostAddress kaldt, og når den er færdig med at hente værtsadressen, bliver den påkaldte metode OnHostNameResolved kaldt som en callback. En anden mulighed er at bruge en anonym metode eller lambdaudtryk til callback-funktionen, som giver adgang til variabler i den oprindelige metode:

csharp
private void LookupHostName() { int aUsefulVariable = 3; GetHostAddress("oreilly.com", address => { // Gør noget med adressen og aUsefulVariable }); }

Mens denne tilgang giver fleksibilitet, har den en stor ulempe: Hvis der opstår en fejl i callbacken, bliver undtagelsen ikke automatisk kastet tilbage til den oprindelige metode, som den ville være i traditionelle synkrone opkald. Dette kan føre til, at fejl håndteres forkert, eller endda ikke bliver opdaget.

Introduktion til Task og Task Parallel Library

Task Parallel Library (TPL) blev introduceret i version 4.0 af .NET Framework. En af de vigtigste klasser er Task, som repræsenterer en igangværende operation. Denne klasse giver en mere struktureret og abstrakt måde at håndtere asynkrone opgaver på. Når du bruger Task, kan du registrere en callback ved at bruge ContinueWith-metoden, hvilket skaber en bedre organiseret kode.

Et eksempel kunne se sådan ud:

csharp
private void LookupHostName() {
Task ipAddressesPromise = Dns.GetHostAddressesAsync("oreilly.com"); ipAddressesPromise.ContinueWith(_ => { IPAddress[] ipAddresses = ipAddressesPromise.Result; // Gør noget med adressen }); }

Fordelen ved at bruge Task er, at du kun behøver at kalde én metode på API'en (her Dns.GetHostAddressesAsync), hvilket gør koden renere og lettere at følge. TPL tager sig af de fleste tekniske detaljer omkring asynkrone opkald, såsom undtagelseshåndtering og synkronisering, som kan være vanskelige at implementere manuelt.

Problemet med Manuel Asynkron Programmering

Når du skriver asynkrone metoder manuelt, bliver koden hurtigt uoverskuelig, især hvis der er mange asynkrone opkald, der skal håndteres. Når du skal lave flere asynkrone kald i rækkefølge eller i en løkke, bliver koden hurtigt kompliceret. Et simpelt eksempel på dette kunne være en metode, der laver flere asynkrone kald i rækkefølge:

csharp
private void LookupHostNames(string[] hostNames) { LookUpHostNamesHelper(hostNames, 0); } private static void LookUpHostNamesHelper(string[] hostNames, int i) { Task ipAddressesPromise = Dns.GetHostAddressesAsync(hostNames[i]); ipAddressesPromise.ContinueWith(_ => { IPAddress[] ipAddresses = ipAddressesPromise.Result; // Gør noget med adressen if (i + 1 < hostNames.Length) { LookUpHostNamesHelper(hostNames, i + 1); } }); }

Selvom denne tilgang virker, er den langt fra optimal. Brug af rekursive metoder som denne gør koden vanskelig at læse og vedligeholde. Desuden kan den føre til en uønsket dyb indrykning, der gør det svært at følge programflowet.

Når du skriver asynkrone programmer manuelt, skal du også tænke på, hvordan de skal bruges i resten af programmet. Hvis du skriver en asynkron metode, som du senere vil anvende et andet sted, skal du sørge for, at der er en måde at håndtere den asynkrone opkald på. Problemet forstærkes, hvis du forsøger at indkapsle asynkrone operationer i synkrone metoder, da de vil have en tendens til at sprede sig gennem hele programmet, hvilket gør koden rodet og svær at arbejde med.

Konvertering af Eksempler til Manuel Asynkron Kode

Lad os tage et konkret eksempel på en UI-applikation, der blev langsom og ikke-reaktiv, fordi den downloadede data fra internettet på UI-tråden. For at løse dette, kan vi bruge en manuel asynkron metode ved hjælp af Event-based Asynchronous Pattern (EAP):

csharp
private void AddAFavicon(string domain) {
WebClient webClient = new WebClient(); webClient.DownloadDataCompleted += OnWebClientOnDownloadDataCompleted; webClient.DownloadDataAsync(new Uri("http://" + domain + "/favicon.ico")); } private void OnWebClientOnDownloadDataCompleted(object sender, DownloadDataCompletedEventArgs args) { Image imageControl = MakeImageControl(args.Result); m_WrapPanel.Children.Add(imageControl); }

Selvom denne tilgang holder UI'en responsiv, kan der opstå problemer med den måde, dataene bliver behandlet på. Hvis flere downloads starter samtidig, vil ikonerne blive vist i den rækkefølge, de er blevet hentet, i stedet for i den rækkefølge, de blev anmodet om. Det er muligt at løse dette ved at omstrukturere koden til at bruge rekursive metoder, men det kræver en grundigere forståelse af asynkron programmering.

Vigtige Overvejelser

Når man arbejder med manuel asynkron programmering, er der flere aspekter, der er vigtige at forstå:

  • Asynkron programmering er ikke kun et teknisk valg, men et designvalg, der kan påvirke hele strukturen i et program.

  • Undtagelseshåndtering i asynkrone opkald skal tænkes grundigt igennem, da fejl ikke automatisk bliver sendt tilbage til kaldende metoder.

  • Kodekvalitet kan hurtigt forringes, hvis man ikke holder styr på dyb indrykning og komplicerede callbacks, især når man arbejder med flere asynkrone opkald på samme tid.

  • Det er ofte bedre at bruge Task og andre moderne asynkrone teknikker til at opnå bedre organisering og vedligeholdelse af koden.

Endtext