Synkronisering i async-kode
Når du arbejder med async
og await
i C#, kan du stadig opleve problemer med delt tilstand og samtidighed – præcis som i klassisk multithreading. Selvom await
giver en mere sekventiel syntaks, betyder det ikke, at du automatisk er fri for race conditions eller datakorruption.
Det er derfor vigtigt at forstå, hvordan du beskytter adgang til delt tilstand – fx fælles variabler, objekter eller ressourcer – i et asynkront miljø.
Problemet med delt tilstand
Hvis to asynkrone metoder forsøger at ændre den samme ressource samtidigt, kan du opleve uforudsigelig opførsel. Det klassiske eksempel er en delt variabel eller samling:
int tæller = 0;
async Task ForøgAsync()
{
for (int i = 0; i < 100; i++)
{
tæller++;
await Task.Delay(1);
}
}
Console.WriteLine(tæller);
await Task.Run(async () =>
{
var task1 = ForøgAsync();
var task2 = ForøgAsync();
await Task.WhenAll(task1, task2);
});
Console.WriteLine(tæller);
Hvis du starter to instanser af ForøgAsync
samtidigt med Task.WhenAll
, ender du næsten altid med et andet resultat end 200 – fordi tæller++
ikke er en atomar operation. Hver iteration afbrydes med en await, som suspenderer metoden og tillader andre tasks at køre. Dermed kan task1
og task2
skiftes mellem hinanden midt i løkken, og begge tasks skriver til tæller uden synkronisering – hvilket fører til en race condition.
lock
virker ikke med await
En klassisk løsning på delt tilstand i synkron kode er lock
:
Men i asynkron kode kan du ikke bruge await
indenfor en lock
. Dette vil give en kompileringsfejl:
int tæller = 0;
object lås = new object();
async Task ForøgAsync()
{
for (int i = 0; i < 100; i++)
{
tæller++;
lock (lås)
{
await Task.Delay(1); // fejl
}
}
}
Console.WriteLine(tæller);
await Task.Run(async () =>
{
var task1 = ForøgAsync();
var task2 = ForøgAsync();
await Task.WhenAll(task1, task2);
});
Console.WriteLine(tæller);
Selv hvis det var muligt, ville det være farligt – fordi await
kan afbryde metoden og give kontrol tilbage til en anden tråd, mens låsen stadig er aktiv. Det kan føre til deadlocks og uforudsigelig adfærd.
SemaphoreSlim
– den async-venlige lås
Til async-kode bør du bruge SemaphoreSlim
. Den fungerer som en asynkron lås, som kan bruges med await
:
int tæller = 0;
SemaphoreSlim sem = new SemaphoreSlim(1, 1);
async Task ForøgAsync()
{
for (int i = 0; i < 100; i++)
{
await sem.WaitAsync();
try
{
tæller++;
await Task.Delay(1);
}
finally
{
sem.Release();
}
}
}
Console.WriteLine(tæller);
await Task.Run(async () =>
{
var task1 = ForøgAsync();
var task2 = ForøgAsync();
await Task.WhenAll(task1, task2);
});
Console.WriteLine(tæller);
Dette sikrer, at kun én tråd ad gangen kan få adgang til den kritiske sektion – men uden at blokere tråden. WaitAsync
suspenderer metoden, indtil låsen er tilgængelig, og Release
frigiver den igen. try/finally
sikrer, at låsen altid frigives, også hvis der opstår fejl.
Du bør altid bruge try/finally
omkring WaitAsync
og Release
– ellers risikerer du at blokere din applikation permanent, hvis der opstår en fejl undervejs.
Hvorfor hedder det SemaphoreSlim?
En semaphore er en klassisk mekanisme fra operativsystemteori, som styrer hvor mange der må få adgang til en ressource samtidigt. En “slim” semaphore er en lettere og mere effektiv version, som er optimeret til .NET og typisk kun bruges med én adgang ad gangen (new SemaphoreSlim(1, 1)
). Den er mere fleksibel end lock
og kan bruges med await
, hvilket gør den ideel i async-scenarier.
Andre mekanismer
Der findes også andre synkroniseringsmekanismer i .NET, men de er sjældent relevante i async-sammenhæng:
-
Mutex
er en systemomspændende lås, som tillader synkronisering på tværs af tråde og processer. Den er tung at arbejde med, kræver manuel håndtering og kan ikke bruges sammen medawait
. Typisk kun relevant i særlige systemscenarier. -
Monitor
er den mekanisme, somlock
bygger på. Den giver lidt mere kontrol, fx mulighed for timeout (TryEnter
), men fungerer kun i synkron kode. Hvis du serMonitor.Enter
/Exit
i kode, kan det altid erstattes medlock
, medmindre du har brug for timeout-logik. -
ReaderWriterLockSlim
tillader flere samtidige læsere og én skriver – og kan være nyttig i performancekritiske applikationer, hvor læseadgang er dominerende. Men den er svær at bruge korrekt og kan ikke anvendes i async-metoder, fordi den ikke understøtterawait
.
Info
Hvis du arbejder med async/await
, bør du som udgangspunkt altid bruge SemaphoreSlim
, og kun i de tilfælde, hvor du har delt tilstand.
Hvornår har du brug for synkronisering?
Synkronisering er kun nødvendig, hvis flere samtidige tasks har adgang til den samme ressource. Det kan være:
- Globale variabler
- Fælles datastrukturer (lister, ordbøger m.m.)
- Streams, sockets eller filer
Hvis du derimod arbejder med lokale variabler, eller hvis hver task har sit eget dataområde, behøver du ingen synkronisering.
Info
En god tommelfingerregel: Hvis flere tasks kan ændre det samme objekt samtidigt, skal du synkronisere adgangen.