Gå til indholdet

Asynkrone datastrukturer

Når du skriver async-kode i C#, vil du ofte have brug for at dele data mellem flere metoder eller baggrundsopgaver. Men de fleste datastrukturer i .NET – som List<T> og Dictionary<TKey,TValue> – er ikke trådsikre. Hvis flere tasks forsøger at tilgå eller ændre dem samtidigt, kan det føre til fejl eller uforudsigelig adfærd.

List<string> beskeder = new List<string>();

async Task TilføjAsync(string tekst)
{
    for (int i = 0; i < 100; i++)
    {
        beskeder.Add(tekst + i);
        await Task.Delay(1);
    }
}

var task1 = TilføjAsync("Hej");
var task2 = TilføjAsync("Verden");
await Task.WhenAll(task1, task2);
Console.WriteLine(beskeder.Count);

Hvis du starter flere instanser af TilføjAsync samtidigt, risikerer du en InvalidOperationException eller datakorruption. Det skyldes, at List<T> ikke er trådsikker – og at await giver mulighed for, at to tasks kører samtidigt.

Du kan beskytte samlingen med SemaphoreSlim, men det kræver omhyggelig håndtering.

Brug af SemaphoreSlim omkring samlinger

Hvis du vil bruge klassiske samlinger i async-kode, kan du sikre dem med en SemaphoreSlim:

SemaphoreSlim sem = new SemaphoreSlim(1, 1);
List<string> beskeder = new List<string>();

async Task TilføjAsync(string tekst)
{
    await sem.WaitAsync();
    try
    {
        for (int i = 0; i < 100; i++)
        {
            beskeder.Add(tekst + i);
            await Task.Delay(1);
        }
    }
    finally
    {
        sem.Release();
    }
}

var task1 = TilføjAsync("Hej");
var task2 = TilføjAsync("Verden");
await Task.WhenAll(task1, task2);
Console.WriteLine(beskeder.Count);

Dette virker fint, men kan hurtigt blive en flaskehals, hvis mange tasks kæmper om adgang til samme ressource.

Trådsikre samlinger i .NET

.NET tilbyder en række trådsikre samlinger i System.Collections.Concurrent. De kan bruges uden lock eller SemaphoreSlim:

  • ConcurrentBag<T> – uordnet samling (velegnet til “log alt”-scenarier)
  • ConcurrentQueue<T> – first-in-first-out
  • ConcurrentStack<T> – last-in-first-out
  • ConcurrentDictionary<TKey,TValue> – map med sikre opslag og opdateringer

Eksempel med ConcurrentBag<T>:

using System.Collections.Concurrent;

// bemærk - rækkefølgen er ikke garanteret
ConcurrentBag<string> beskeder = new ConcurrentBag<string>();

async Task TilføjAsync(string tekst)
{
    for (int i = 0; i < 100; i++)
    {
        beskeder.Add(tekst + i);
        await Task.Delay(1);
    }
}

var task1 = TilføjAsync("Hej");
var task2 = TilføjAsync("Verden");
await Task.WhenAll(task1, task2);
Console.WriteLine(beskeder.Count);

Hvorfor findes der ikke en ConcurrentList<T>?

Du undrer dig måske over, at .NET ikke tilbyder en ConcurrentList<T> – men det er der en god grund til. De fleste brugsscenarier for en List<T> kræver en eller anden form for ordnet adgang (f.eks. via index), og det er svært at gøre effektivt og trådsikkert samtidigt. Derfor findes der ingen officiel ConcurrentList<T> i .NET.

I stedet bør du:

  • Bruge ConcurrentBag<T> hvis rækkefølge er ligegyldig.
  • Bruge ConcurrentQueue<T> hvis du har brug for først-ind-først-ud (FIFO).
  • Beskytte en almindelig List<T> med SemaphoreSlim, hvis du har behov for ordnet adgang eller index-baseret læsning.

Immutable datastrukturer

En anden tilgang er at undgå delt tilstand helt. Med de immutable datastrukturer i System.Collections.Immutable arbejder du med kopier i stedet for ændringer:

using System.Collections.Immutable;

ImmutableList<string> navne = ImmutableList<string>.Empty;
navne = navne.Add("Alice");
navne = navne.Add("Bob");

Denne tilgang kræver, at du altid tildeler resultatet af en ændring til en ny variabel – eller den samme. Det er trådsikkert, fordi ingen kan ændre det gamle objekt.

Hvad skal du vælge?

Situation Anbefalet løsning
Du har eksisterende List<T> og vil beskytte den SemaphoreSlim
Du har høj samtidighed uden await Concurrent* samlinger
Du vil undgå delt tilstand helt Immutable* samlinger