Gå til indholdet

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:

lock (låsObjekt)
{
    tæller++;
}

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 med await. Typisk kun relevant i særlige systemscenarier.

  • Monitor er den mekanisme, som lock bygger på. Den giver lidt mere kontrol, fx mulighed for timeout (TryEnter), men fungerer kun i synkron kode. Hvis du ser Monitor.Enter/Exit i kode, kan det altid erstattes med lock, 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øtter await.

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.