Asynkron programmering
I traditionel programmering, herunder C#, er det normalt at afvikle en samling af instruktioner i en proces - en instruktion af gangen. Det betyder, at en instruktion skal afvikles, før den næste instruktion kan afvikles. Dette kaldes synkron programmering og er enkel og let at skrive og forstå.
Information til undervisere
Asynkron programmering er vigtigt i alle moderne C# applikationer, men mange studerende har svært ved at forstå det. Det er vigtigt at forklare hvad asynkron programmering i virkeligheden er, og hvorfor det er vigtigt. Det er også vigtigt at forklare forskellen mellem synkron og asynkron kode. Derfor denne tekst og tilhørende eksempler - brug dem samtidigt med at du forklarer det..
Der er dog situationer, hvor det er nyttigt at kunne afvikle flere instruktioner samtidigt, eller i det mindste at kunne afvikle en instruktion, mens en anden instruktion stadig er i gang. Dette kaldes asynkron programmering, og det er en vigtig del af moderne softwareudvikling. Asynkron programmering kan forbedre ydeevnen og responstiden i en applikation, især når der er behov for at udføre lange eller ventende operationer, såsom store beregninger, netværksforespørgsler, databaseopkald eller filsystemoperationer.
Begreber i asynkron programmering
Lad os først se på nogle grundlæggende begreber i asynkron programmering:
Hvad er en proces, tråd og kerne
En proces er en kørende instans af et program. Hver proces har sin egen hukommelse og ressourcer og kan indeholde én eller flere tråde, som udfører arbejdet inden for processen.
En tråd er en grundlæggende enhed af udførelse i en proces. Tråde kan køre parallelt, hvilket gør det muligt for et program at udføre flere opgaver samtidigt uden at vente på, at en anden opgave afsluttes.
En kerne er en fysisk enhed i en processor, der udfører tråde. En computer med flere kerner kan håndtere flere tråde samtidigt, hvilket forbedrer ydeevnen.
Tråde og kerner er ikke det samme. En tråd er en softwareenhed, mens en kerne er en hardwareenhed. En proces kan have flere tråde, der kan køres på én eller flere kerner, afhængigt af ressourcerne.
Hvad er samtidighed og parallellisme
Samtidighed og parallellisme er to begreber, der ofte bruges i sammenhæng med tråde og kerner i programmering.
Samtidighed refererer til evnen til at have flere opgaver i gang på samme tid. På en enkelt kerne opnås dette ved hurtigt at skifte mellem tråde, hvilket skaber en illusion af, at opgaverne kører samtidigt. Dette er nyttigt til opgaver, der kan udføres uafhængigt og ikke kræver meget CPU-tid.
Parallellisme refererer til evnen til at udføre flere opgaver samtidig på flere kerner. Hver kerne kan køre sin egen tråd, hvilket betyder, at opgaverne faktisk kører på samme tid. Dette er især effektivt til CPU-intensive opgaver, der kan opdeles i mindre dele og fordeles over flere kerner.
Hvad er en thread pool
En thread pool er en samling af genbrugelige tråde, som bruges til at udføre opgaver i en applikation uden at oprette nye tråde hver gang. Dette forbedrer ydeevnen og reducerer omkostningerne ved gentagne trådskabelser. Thread pool’en administreres af både operativsystemet og .NET runtime, der sørger for at tildele og prioritere tråde effektivt.
Thread pool’en kan justere antallet af aktive tråde dynamisk afhængigt af behovet, og tråde fra puljen bruges typisk til opgaver som asynkron programmering, netværksanmodninger og baggrundsarbejde. Ved at lade .NET håndtere tråde bliver applikationer både enklere og mere ressourceeffektive.
Andre begreber
Måske vil du også støde på disse begreber i forbindelse med asynkron programmering:
Context switching opstår, når CPU’en skifter mellem forskellige tråde eller processer. Dette giver illusionen af samtidighed, men kan medføre en vis overhead.
Lock bruges til at sikre, at kun én tråd ad gangen kan få adgang til en delt ressource. Dette forhindrer uønskede konflikter.
Deadlock sker, når to eller flere tråde venter på hinanden og aldrig kan fortsætte. Dette blokerer programmet.
Race condition opstår, når flere tråde forsøger at ændre en delt ressource samtidigt, hvilket kan føre til uforudsigelige resultater.
Udfordringer med asynkron kode
Nu ved du lidt om asynkron kode generelt, og det lyder jo oplagt, at du bare skal skrive så meget asynkron kode som muligt. Men i virkeligheden er det meget komplekst at jonglere mange tråde, fordi der er så mange ting, du skal tage hensyn til. Du skal jo regne med, at asynkron kode bliver afviklet på sin helt egen tråd, så du ved som udgangspunkt ikke, hvornår instruktionerne er afviklet, og har dermed svært ved at få fat på en eventuel returværdi. Det er også komplekst at sende og modtage data fra en eller flere tråde. Og fejlhåndtering er et kapitel for sig! Hvad hvis der sker en fejl i en af de tråde, du har sat i gang. Hvordan får du besked om det?
I de seneste versioner af C# er der tilføjet forskellige muligheder for at gøre det nemmere at arbejde med asynkron kode. Det er især kodeordene async og await samt klasserne Task
og Task<T>
, vi skal se nærmere på.
.NET biblioteker
.NET inkluderer to primære metoder til understøttelse af asynkron programmering: Task Parallel Library (TPL) og async/await (som er en del af C# sproget).
-
Task Parallel Library (TPL): Dette bibliotek blev introduceret i .NET 4.0 og giver en højniveau API til at skabe og styre asynkrone operationer. Den centrale del af TPL er
Task
klassen, som repræsenterer en asynkron operation, der potentielt kan returnere en værdi (Task<TResult>
). TPL tillader også parallel udførelse af opgaver ved hjælp afParallel
klassen, som kan parallelisere for-løkker (medParallel.For
ogParallel.ForEach
) og udføre parallelle invokationer (medParallel.Invoke
). -
Async/Await: Dette er ikke et bibliotek i sig selv, men en del af C# og VB.NET sproget. Async/await blev introduceret i C# 5.0 og VB.NET 11, og er bygget oven på TPL. Det tilføjer
async
ogawait
nøgleordene til sproget, hvilket gør det meget nemmere at skrive asynkron kode. Enasync
metode er en metode, der kan have en eller flereawait
udtryk. Etawait
udtryk suspenderer udførelsen af metoden, indtil den ventede opgave er fuldført, men uden at blokere tråden.
Valget mellem at bruge Task Parallel Library (TPL) og async/await i din .NET kode afhænger primært af den specifikke opgave, du forsøger at løse, og hvilken del af din kode, der skal være asynkron. Men i de fleste tilfælde vil du bruge async/await, da det er enklere og mere læsevenligt end TPL.
Eksempel på asynkron kode
Lad os se på nogle brugsmønstre for asynkron programmering i C#.
Optimering af tidskrævende metoder
Der kan være flere årsager til, at du ønsker kode afviklet over flere tråde. Den oplagte er, at du kan afvikle flere instruktioner på samme tid, og hvis du har blokke af instruktioner i din kode, som ikke er afhængig af hinanden, kan det være en fordel at afvikle dem alle på “samme tid” (context switching betyder, at CPU’en skifter mellem tråde, så det er ikke helt korrekt at sige, at de afvikles på samme tid).
Se følgende eksempel:
using System.Diagnostics;
Stopwatch s = new Stopwatch();
s.Start();
Console.WriteLine($"[Proces: {Process.GetCurrentProcess().Id}] [Tråd: {Thread.CurrentThread.ManagedThreadId}] Start");
Console.WriteLine($"[Proces: {Process.GetCurrentProcess().Id}] [Tråd: {Thread.CurrentThread.ManagedThreadId}] Sleep 1");
Thread.Sleep(500);
Console.WriteLine($"[Proces: {Process.GetCurrentProcess().Id}] [Tråd: {Thread.CurrentThread.ManagedThreadId}] Sleep 2");
Thread.Sleep(500);
Console.WriteLine($"[Proces: {Process.GetCurrentProcess().Id}] [Tråd: {Thread.CurrentThread.ManagedThreadId}] Sleep 3");
Thread.Sleep(500);
s.Stop();
Console.WriteLine($"[Proces: {Process.GetCurrentProcess().Id}] [Tråd: {Thread.CurrentThread.ManagedThreadId}] Stop");
Console.WriteLine($"Tid: {s.ElapsedMilliseconds}");
Her er et muligt resultat:
[Proces: 8856] [Tråd: 1] Start
[Proces: 8856] [Tråd: 1] Sleep 1
[Proces: 8856] [Tråd: 1] Sleep 2
[Proces: 8856] [Tråd: 1] Sleep 3
[Proces: 8856] [Tråd: 1] Stop
Tid: 1526
Koden benytter et stopur til at måle, hvor lang tid det tager at afvikle tre simulerede operationer, der tager 500 ms i rækkefølge (Thread.Sleep
kan bruges til at holde en pause). Hvis du afvikler koden, vil du konstatere, at det tager omkring 1,5 sekund at afvikle applikationen – hvilket jo ikke er super overraskende. Samtidig kan du se, at koden afvikles i en proces og at der kun benyttes en enkelt tråd (hovedtråden).
Men hvis operationerne ikke er afhængige af hinanden, kan de jo godt sættes i gang samtidig, og koden vil dermed have 4 tråde på et tidspunkt – nemlig hovedtråden og tre enkelte tråde til de simulerede operationer. Hvis du ser bort fra nye kodeord, du endnu ikke kender til, kunne det se således ud:
using System.Diagnostics;
Stopwatch s = new Stopwatch();
s.Start();
Console.WriteLine($"[Proces: {Process.GetCurrentProcess().Id}] [Tråd: {Thread.CurrentThread.ManagedThreadId}] Start");
Task t1 = Task.Run(() =>
{
Console.WriteLine($"[Proces: {Process.GetCurrentProcess().Id}] [Tråd: {Thread.CurrentThread.ManagedThreadId}] Sleep 1");
Thread.Sleep(500);
});
Task t2 = Task.Run(() =>
{
Console.WriteLine($"[Proces: {Process.GetCurrentProcess().Id}] [Tråd: {Thread.CurrentThread.ManagedThreadId}] Sleep 2");
Thread.Sleep(500);
});
Task t3 = Task.Run(() =>
{
Console.WriteLine($"[Proces: {Process.GetCurrentProcess().Id}] [Tråd: {Thread.CurrentThread.ManagedThreadId}] Sleep 3");
Thread.Sleep(500);
});
await Task.WhenAll(t1, t2, t3).ConfigureAwait(false);
Console.WriteLine($"[Proces: {Process.GetCurrentProcess().Id}] [Tråd: {Thread.CurrentThread.ManagedThreadId}] Stop");
s.Stop();
Console.WriteLine($"Tid: {s.ElapsedMilliseconds}");
Her er et muligt resultat:
[Proces: 7872] [Tråd: 1] Start
[Proces: 7872] [Tråd: 4] Sleep 1
[Proces: 7872] [Tråd: 9] Sleep 3
[Proces: 7872] [Tråd: 7] Sleep 2
[Proces: 7872] [Tråd: 4] Stop
Tid: 527
Som det fremgår, bruges der nye kodeord som async
og await
samt en ny klasse kaldet Task
. Det kommer vi tilbage til senere, men hvis du afvikler koden, vil du konstatere, at den afvikles på cirka 0,5 sekund – altså 1 sekund hurtigere end i eksemplet som kun benyttede hovedtråden. Og det giver jo fint mening. Hvis operativsystemet kan afvikle de tre operationer på “samme” tid (cirka), burde det tage 0,5 sekunder i alt at afvikle alle tre.
Samtidig kan du se, at der nu benyttes flere tråde, og at hovedtråden ikke afvikler de tre operationer. Ved hver Task.Run
-operation anmodes om en ny tråd fra threadpool’en, som .NET runtime administrerer. Når operationen er færdig, returneres tråden til trådpoolen, så den kan genbruges. Bemærk i øvrigt, at hovedtråden ikke er den samme som når applikationen starter. Det skyldes, at await
-udtrykket midlertidigt suspenderer udførelsen af koden, og når den genoptages, tildeles en tilgængelig tråd fra threadpool’en i stedet for den oprindelige hovedtråd. Denne adfærd er typisk for konsolapplikationer, fordi de ikke har en synkroniseringskontekst, der kræver, at koden genoptages på samme tråd som tidligere. Dette design optimerer ressourceudnyttelsen og gør applikationen mere effektiv, men kan være en vigtig detalje at huske, især hvis tråd-specifikke ressourcer eller operationer anvendes.
Men der er en anden vigtig pointe i eksemplet. Hvis du afvikler koden flere gange, vil du se, at trådene ikke nødvendigvis afvikles i samme rækkefølge. Det er fordi, at trådene afvikles parallelt, og det er operativsystemet, der bestemmer, hvilken tråd der afvikles først. Det er en vigtig pointe, fordi det betyder, at du ikke har kontrol over, hvornår en asynkron operation er færdig. Det er operativsystemet, der bestemmer det.
Faktum er, at du logisk nok ikke har kontrol over, hvornår en asynkron operation er færdig, og måske er det også ligegyldigt. Men hvis det har betydning, skal du i hvert fald være bevidst om det.
Samme kan opnås i “ægte parallel” kode med TPL:
using System.Diagnostics;
using System.Runtime.InteropServices;
class Program
{
[DllImport("kernel32.dll")]
private static extern uint GetCurrentProcessorNumber();
static void Main()
{
Stopwatch s = new Stopwatch();
s.Start();
Console.WriteLine($"[Proces: {Process.GetCurrentProcess().Id}] [Tråd: {Thread.CurrentThread.ManagedThreadId}] [Kerne: {GetCurrentProcessorNumber()}] Start");
Parallel.For(1, 4, nr =>
{
Console.WriteLine($"[Proces: {Process.GetCurrentProcess().Id}] [Tråd: {Thread.CurrentThread.ManagedThreadId}] [Kerne: {GetCurrentProcessorNumber()}] Sleep " + nr);
Thread.Sleep(500);
});
Console.WriteLine($"[Proces: {Process.GetCurrentProcess().Id}] [Tråd: {Thread.CurrentThread.ManagedThreadId}] [Kerne: {GetCurrentProcessorNumber()}] Stop");
s.Stop();
Console.WriteLine($"Tid: {s.ElapsedMilliseconds}");
}
}
Her er et muligt resultat:
[Proces: 2884] [Tråd: 1] [Kerne: 0] Start
[Proces: 2884] [Tråd: 1] [Kerne: 3] Sleep 3
[Proces: 2884] [Tråd: 4] [Kerne: 2] Sleep 2
[Proces: 2884] [Tråd: 7] [Kerne: 1] Sleep 1
[Proces: 2884] [Tråd: 1] [Kerne: 0] Stop
Tid: 533
Her fordeles afviklen ud over flere kerner, og du burde kunne se en forskel på to metoder i en grafisk repræsentation af afvikling i eksempelvis Windows Task Manager - enten er en kerne brugt meget eller også er det fordelt ud over mange kerner.
Optimering af brugerflade applikationer
I applikationstyper, som benytter brugerflader i en eller anden form, er asynkron programmering typisk også brugt. Ikke nødvendigvis for at spare afviklingstid, men måske mest for at frigøre hovedtråden, som styrer brugerfladen.
I en applikationstype, som Windows Forms (WinForm) eller Windows Presentation Foundation (WPF), vil en længerevarende operation låse brugerfladen, så brugeren ikke kan flytte vinduer eller trykke på knapper. Dermed kan en operation heller ikke afbrydes. Hele applikationen låser simpelthen, indtil operationen er afsluttet.
Hvis du har lyst, kan du se et eksempel ved at hente min demo fra: devcronberg/async-winform-task-await
På siden kan du finde en download-knap, hente hele projektet som en zip-fil og åbne og afvikle det gennem VS/VSC. Applikationen ser således ud:
Hvis du klikker på knapperne Sync eller ASync, bliver der hentet et tilfældigt tal fra nettet, og det tager et par sekunder. Applikationen har også en grøn linje nederst i vinduet, som løbende bliver fyldt ud.
Forskellen på de to knapper er naturligvis, at Sync-knappen henter tallet synkront og dermed låser brugerfladen. Når du klikker på knappen, kan du ikke flytte vinduet eller trykke på andre knapper. Den grønne linje bliver heller ikke tegnet mere. Først når tallet er hentet, er der liv i applikationen igen.
Hvis du klikker på ASync-knappen, kan du derimod flytte rundt på vinduet, mens tallet bliver hentet, og sågar klikke på Afbryd-knappen. Den grønne linje kører også uden problemer.
Hvis du kigger nærmere i koden, vil du også tydeligt kunne se, at Sync-knappen henter tallet via hovedtråden, mens ASync-knappen benytter en asynkron metode og dermed arbejder på en tråd for sig selv.
Så asynkron kode er meget benyttet i denne slags Windows-applikationer, men også i mobile applikationer, med en brugerflade, og i kode til afvikling på en webserver (ASP.NET Core). Sidstnævnte handler ikke om brugerfladen, fordi den dannes på serveren under alle omstændigheder, men om at give webserveren så meget luft som muligt til at håndtere forespørgsler fra andre klienter.
Udvidet konsol eksempel
Det er lidt sværere at vise i en simpel konsol applikation fordi der ikke er en brugerflade, men her er et eksempel på en asynkron konsol applikation, der afvikler en HTTP-anmodning asynkront (der tager 5 sekunder) og imens afvikler andet arbejde:
using System.Net.Http;
Console.WriteLine("Starter asynkron operation...");
// Start den asynkrone HTTP-anmodning
var httpTask = GetHttpContentAsync();
// Udfør andet arbejde, mens vi venter på HTTP-anmodningen
while (!httpTask.IsCompleted)
{
Console.Beep();
Console.WriteLine("Arbejder...");
await Task.Delay(1000); // Vent et sekund
}
// Når HTTP-anmodningen er færdig, får vi resultatet
string result = await httpTask;
Console.WriteLine("HTTP-anmodningen er færdig.");
Console.WriteLine($"Resultat: {result}");
async Task<string> GetHttpContentAsync()
{
using (HttpClient client = new HttpClient())
{
// Dette kalder en side, der forsinker svaret i 5 sekunder
HttpResponseMessage response = await client.GetAsync("https://httpbin.org/delay/5");
return await response.Content.ReadAsStringAsync();
}
}
Du behøve ikke forstå alt koden men det vigtige er at du kan se at koden afvikler en HTTP-anmodning asynkront og imens den venter på svaret, så afvikler den andet arbejde.
Optimering af matematisk tunge operationer
Et mere komplet eksempel i brug af parallel kode kan du finde i følgende kode. Den beregner primtal (på en super langsom og “forkert” måde) på to forskellige måder.
BeregnPrimtal
gør det serielt medens BeregnPrimtalParallel
gør det parallelt. Når du prøver koden så hold øje med Windows Task Manager og se på alle CPU-forbrug.
I det første eksempel vil du bemærke et meget lavt CPU-forbrug samt en processor som er meget belastet end de andre
mens det sidste eksempel vil bruge meget mere CPU - tæt på 100% og alle kerner vil være belastet.
Det viser sig også i tiden det tager at beregne primtal - det første eksempel tager lang tid, mens det sidste tager kort tid. Samtidigt vil du kunne se, at det første eksempel kun bruger en tråd.
mens det sidste bruger flere tråde.
Prøv det selv!
using System.Collections.Concurrent;
using System.Diagnostics;
BeregnPrimtal(50_000_000);
BeregnPrimtalParallel(50_000_000);
void BeregnPrimtal(int antal)
{
ConcurrentDictionary<int, int> threads = new ConcurrentDictionary<int, int>();
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
Console.WriteLine("Start");
int minValue = 1;
int maxValue = antal;
List<int> primeNumbers = new List<int>();
for (int i = minValue; i < maxValue; i++)
{
if (IsPrime(i))
{
primeNumbers.Add(i);
// Hvor mange gange er en tråd benyttet
threads.AddOrUpdate(Thread.CurrentThread.ManagedThreadId, 1, (_, oldValue) => oldValue + 1);
}
}
stopwatch.Stop();
Console.WriteLine("End");
// Threads
foreach (var item in threads)
{
Console.WriteLine($"Thread: {item.Key} - {item.Value}");
}
Console.WriteLine($"Time: {stopwatch.ElapsedMilliseconds} ms");
}
void BeregnPrimtalParallel(int antal)
{
ConcurrentDictionary<int, int> threads = new ConcurrentDictionary<int, int>();
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
Console.WriteLine("Start");
int minValue = 1;
int maxValue = antal;
ConcurrentBag<int> primeNumbers = new ConcurrentBag<int>();
Parallel.For(minValue, maxValue + 1, (i) =>
{
if (IsPrime(i))
{
primeNumbers.Add(i);
// Hvor mange gange er en tråd benyttet
threads.AddOrUpdate(Thread.CurrentThread.ManagedThreadId, 1, (_, oldValue) => oldValue + 1);
}
});
stopwatch.Stop();
Console.WriteLine("End");
// Threads
foreach (var item in threads)
{
Console.WriteLine($"Thread: {item.Key} - {item.Value}");
}
Console.WriteLine($"Time: {stopwatch.ElapsedMilliseconds} ms");
}
// Meget simpel primtalstest (kun til demonstration - ikke så hurtig)
bool IsPrime(int number)
{
if (number <= 1) return false;
if (number == 2) return true;
if (number % 2 == 0) return false;
var boundary = (int)Math.Floor(Math.Sqrt(number));
for (int i = 3; i <= boundary; i += 2)
{
if (number % i == 0) return false;
}
return true;
}
Pyramid of hell
“Pyramid of Hell” er et udtryk, der ofte bruges til at beskrive, hvordan bland andet callbacks (en funktion som afvikler en anden funktion når den er færdig) kan føre til dybt indrykket og vanskeligt læsbar kode, især i forbindelse med asynkron programmering. I moderne C# kode anvendes async
og await
for at undgå dette problem, men her er koden der viser hvordan det kan se ud uden disse nøgleord:
class Program
{
static void Main(string[] args)
{
// Pyramid of hell
Operation1().ContinueWith(task1 =>
{
string result1 = task1.Result;
Operation2(result1).ContinueWith(task2 =>
{
string result2 = task2.Result;
Operation3(result2).ContinueWith(task3 =>
{
string result3 = task3.Result;
Operation4(result3).ContinueWith(task4 =>
{
string result4 = task4.Result;
Console.WriteLine($"Final result:\r\n{result4}");
});
});
});
});
Console.ReadKey();
}
static Task<string> Operation1()
{
return Task.Run(async () =>
{
Console.WriteLine("1");
await Task.Delay(1000);
return "Resultat fra Operation 1";
});
}
static Task<string> Operation2(string input)
{
return Task.Run(async () =>
{
Console.WriteLine("2");
await Task.Delay(1000);
return input + " -> Resultat fra Operation 2";
});
}
static Task<string> Operation3(string input)
{
return Task.Run(async () =>
{
Console.WriteLine("3");
await Task.Delay(1000);
return input + " -> Resultat fra Operation 3";
});
}
static Task<string> Operation4(string input)
{
return Task.Run(async () =>
{
Console.WriteLine("4");
await Task.Delay(1000);
return input + " -> Resultat fra Operation 4";
});
}
}
Callback hell
Hvis du er bekendt med sprog som JavaScript sker det samme “helvede” med de såkaldte callback functioner:
using System;
using System.Threading.Tasks;
class Program
{
static void Main(string[] args)
{
Operation1((result1) =>
{
Operation2(result1, (result2) =>
{
Operation3(result2, (result3) =>
{
Operation4(result3, (result4) =>
{
Console.WriteLine($"Final result: {result4}");
});
});
});
});
Console.ReadLine();
}
static void Operation1(Action<string> callback)
{
Task.Run(() =>
{
Console.WriteLine("1");
Task.Delay(1000).Wait(); // Simulere asynkron operation
callback("Resultat fra Operation 1");
});
}
static void Operation2(string input, Action<string> callback)
{
Task.Run(() =>
{
Console.WriteLine("2");
Task.Delay(1000).Wait(); // Simulere asynkron operation
callback(input + " -> Resultat fra Operation 2");
});
}
static void Operation3(string input, Action<string> callback)
{
Task.Run(() =>
{
Console.WriteLine("3");
Task.Delay(1000).Wait(); // Simulere asynkron operation
callback(input + " -> Resultat fra Operation 3");
});
}
static void Operation4(string input, Action<string> callback)
{
Task.Run(() =>
{
Console.WriteLine("4");
Task.Delay(1000).Wait(); // Simulere asynkron operation
callback(input + " -> Resultat fra Operation 4");
});
}
}
Ved brug af async
og await
kan koden simplificeres så meget at den faktisk ligner synkron kode:
class Program
{
static async Task Main(string[] args)
{
string result1 = await Operation1();
string result2 = await Operation2(result1);
string result3 = await Operation3(result2);
string result4 = await Operation4(result3);
Console.WriteLine($"Final result:\r\n{result4}");
}
static Task<string> Operation1()
{
return Task.Run(async () =>
{
Console.WriteLine("1");
await Task.Delay(1000);
return "Resultat fra Operation 1";
});
}
static Task<string> Operation2(string input)
{
return Task.Run(async () =>
{
Console.WriteLine("2");
await Task.Delay(1000);
return input + " -> Resultat fra Operation 2";
});
}
static Task<string> Operation3(string input)
{
return Task.Run(async () =>
{
Console.WriteLine("3");
await Task.Delay(1000);
return input + " -> Resultat fra Operation 3";
});
}
static Task<string> Operation4(string input)
{
return Task.Run(async () =>
{
Console.WriteLine("4");
await Task.Delay(1000);
return input + " -> Resultat fra Operation 4";
});
}
}
En asynkron konsol-applikation
Denne bog benytter udelukkende konsol-applikationer for at kunne holde fokus på kode og ikke brugerflade, og som du lige har lært, så er asynkron kode brugt en del i applikationer, hvor man gerne vil holde en brugerflade levende. Det er jo ikke tilfældet i en konsolapplikation, men derfor kan der godt være situationer, hvor du ønsker at afvikle asynkron kode i en konsolapplikation (se bare eksemplet i starten af kapitlet).
Men en konsolapplikation er som udgangspunkt ikke asynkron. Skabelonen ser således ud, som du ved:
Men hvis du ønsker at afvikle moderne asynkron C# kode skal Main-metoden ændres således, at den supporterer de nyeste features.
Derfor kan du fra C# 8 ændre skabelonen til dette i stedet:
using System;
using System.Threading.Tasks;
namespace Demo
{
class Program
{
static async Task Main(string[] args)
{
}
}
}
Den eneste ændring er, at Main
nu ikke længere returnerer void
, men en reference til et Task
objekt, og at metoden er markeret med async
-kodeordet. Sidstnævnte er en forudsætning for, at du efterfølgende kan benytte await
-kodeordet, som i den grad simplificerer asynkron kode. Faktisk vil Visual Studio give en advarsel om, at du benytter async
-kodeordet uden at bruge await
-kodeordet, fordi der bliver autogeneret og afviklet noget kode uden grund.
En Async funktion i en synkron app
Hvis det er muligt så start en applikation asynkront men nogen gange er det ikke muligt. Følgende kan benyttes men bør undgås hvis det er muligt:
namespace ConsoleApp5
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Start");
Run().GetAwaiter().GetResult();
Console.WriteLine("End");
}
static async Task Run()
{
await Task.Delay(100);
await Console.Out.WriteLineAsync("Hello World");
}
}
}
Brugen er GetAwaiter().GetResult()
er nødvendig for at afvente den asynkrone Run metode men kan give nogle udfordringer. Men nogen gange er det nødvendigt i ældre applikationer.
Om async og await - historisk
Asynkron programmering er en essentiel del af moderne softwareudvikling, da det muliggør mere effektiv brug af ressourcer ved at undgå blokeringer under ventetider. Når systemer skal kommunikere med eksterne kilder eller udføre tidskrævende opgaver, som netværksanmodninger eller filhåndtering, giver asynkron programmering mulighed for at frigøre CPU’en til andre opgaver, mens den venter. Begreberne “async” og “await” er blevet centrale for mange programmeringssprog for at kunne håndtere disse asynkrone operationer.
Historisk baggrund
Den tidlige historie for asynkron programmering involverede ofte brug af callbacks. Callbacks var funktioner, der blev kaldt, når en given opgave var fuldført. Dog viste brugen af callbacks sig at være problematisk og kompliceret i mere komplekse applikationer, hvilket resulterede i det velkendte problem kendt som “callback hell” eller “pyramiden af død”. Dette gjorde koden uoverskuelig og svær at vedligeholde.
For at afhjælpe dette problem introducerede mange programmeringssprog koncepter som promises og senere async/await. Promises forenklede håndteringen af asynkrone opgaver ved at tilbyde en klarere model for, hvordan man håndterer success eller fejl i en asynkron proces. Men promises kunne stadig være komplicerede at arbejde med i lange sekvenser af operationer.
Async og await blev udviklet som et forsøg på at forenkle skrivning og læsning af asynkron kode, så den ligner mere almindelig, sekventiel kode. Koncepterne blev populære på tværs af mange programmeringssprog, da de gjorde det lettere at læse, skrive og vedligeholde asynkron kode.
Implementering i forskellige sprog
Async og await er nu integreret i mange moderne programmeringssprog, og hvert sprog har sin egen unikke tilgang til implementering af disse nøgleord. Her er en oversigt over, hvordan async og await er blevet brugt i forskellige sprog, sorteret efter årstal:
-
C#: C# introducerede async og await i version 5.0 (2012), hvilket gjorde asynkron programmering meget lettere. C#’s async/await-mekanik gør det muligt at skrive asynkron kode, der ser ud som synkron kode, hvilket forbedrer læsbarheden. Dette var en stor forbedring i forhold til tidligere metoder som callbacks og Task Parallel Library (TPL), hvilket gjorde det lettere for udviklere at skrive mere vedligeholdelig kode.
-
Python: Python introducerede async/await i version 3.5 i 2015. Her blev det hurtigt populært til opgaver som netværkskommunikation og filhåndtering. Python’s async/await-mekanik minder meget om JavaScripts implementering, hvilket gør det lettere for udviklere at arbejde asynkront på tværs af sprog.
-
JavaScript: JavaScript var et af de første sprog, der for alvor tog async/await til sig efter introduktionen af promises. Med ES2017 blev async/await indført for at forenkle koden og gøre det nemmere at skrive og forstå asynkrone operationer, såsom API-kald i browseren. Async-funktioner returnerer en promise, og await suspenderer udførelsen, indtil promise’en er afgjort.
-
Rust: I Rust har async/await været en del af sproget siden version 1.39 (2019). Rust har fokuseret på effektiv hukommelseshåndtering, hvilket har gjort deres implementering af async/await anderledes ved at sikre nul-cost abstractions og sikre, at der ikke er data races, selv ved asynkron eksekvering.
-
Kotlin: Kotlin, et moderne sprog til JVM, har også understøttet async/await. Kotlin tilbyder coroutines, som fungerer som lette tråde, hvilket tillader udviklere at skrive asynkron kode på en sekventiel måde, hvor async og await bruges til at suspendere og genoptage koden.
-
Java: Java tog længere tid om at omfavne async/await direkte. I stedet blev Futures og CompletableFuture introduceret i Java 8 (2014) for at håndtere asynkrone opgaver, og senere har Java yderligere styrket brugen af asynkron programmering med introduktionen af nye biblioteker og frameworks som Project Loom, der bringer mere brugervenlige asynkrone konstruktioner.
Hvordan Async/Await har påvirket udvikling
Async og await har haft en dybtgående effekt på softwareudvikling. De giver udviklere mulighed for at skrive mere intuitiv og overskuelig kode, som lettere kan vedligeholdes. Samtidig gør de det muligt at udnytte ressourcer bedre, hvilket er essentielt i moderne applikationer, hvor responstider og effektivitet spiller en stor rolle.
Ved at gøre asynkrone operationer lettere at arbejde med, har async/await øget produktiviteten og kvaliteten af softwareudvikling. Udviklere kan nu fokusere mere på forretningslogik fremfor kompleksiteten i asynkron kontrolstrukturer.