Skip to main content

Command Palette

Search for a command to run...

Double-checked locking w .NET – jak zatrzymać pędzące stado

Updated
5 min read
Double-checked locking w .NET – jak zatrzymać pędzące stado

Wprowadzenie

W systemach opartych na zdarzeniach oraz w aplikacjach wielowątkowych często pojawia się problem nagłego wzrostu obciążenia w momencie, gdy wiele wątków jednocześnie próbuje uzyskać dostęp do tego samego zasobu. Jeśli ten jest niedostępny, każdy proces może rozpocząć kosztowną operację jego pozyskania. Zjawisko to nazywamy problemem pędzącego stada (thundering herd problem). Wszystkie jednostki wykonawcze ruszają w tym samym kierunku, a system musi zmierzyć się ze skokowym wzrostem obciążenia.

Odpowiedzią na to wyzwanie jest zastosowanie double-checked locking. Technika pozwala uniknąć równoległego odświeżania tego samego zasobu i bezpiecznie udostępnia go wszystkim potrzebującym.

Na czym polega double-checked locking

Wzorzec powstał w świecie wielowątkowego programowania jako optymalizacja dla klasycznego podejścia z blokadą. Proste użycie lock przy każdym dostępie do współdzielonego elementu jest kosztowne. Każdy wątek musi wtedy wejść do sekcji krytycznej, czyli fragmentu kodu otoczonego blokadą, do którego w danym momencie może wejść tylko jedna jednostka wykonawcza.

Double-checked locking pozwala ograniczyć ten narzut. Polega na podwójnym sprawdzaniu:

  1. Poza sekcją krytyczną – sprawdzamy, czy instancja już istnieje i jest gotowa do użycia.

  2. Wewnątrz sekcji krytycznej – tuż po uzyskaniu blokady sprawdzamy, czy inny wątek nie wykonał tej pracy wcześniej i wynik nie jest już gotowy.

Dzięki temu blokada używana jest wyłącznie w momencie faktycznej inicjalizacji, a nie przy każdym dostępie.

Sterownik IoT – odczyt kosztownego czujnika

Wyobraźmy sobie urządzenie pomiarowe z wbudowanym czujnikiem jakości powietrza. Każdy odczyt z sensora jest kosztowny energetycznie i zajmuje kilka sekund. W tym samym procesie działa kilka modułów (np. monitor wentylacji, logger danych, moduł alarmowy), które co pewien czas potrzebują aktualnych wartości. Gdy wszystkie naraz zauważą, że poprzednie dane się przedawniły, jednocześnie spróbują uruchomić nowy, kosztowny pomiar. Zastosowanie mechanizmu double-checked locking rozwiązuje ten problem.

Implementacja

Najpierw sprawdzamy, czy trzymany w pamięci rekord AirQualitySensorData jeszcze się nie przedawnił. Jeśli odczyt jest “świeży” zwracamy wynik bez żadnych blokad. W innym wypadku wątki przechodzą przez semafor await semaphore.WaitAsync(), który wpuszcza tylko jeden z nich na raz do sekcji krytycznej. Jeśli na wejście czeka kilka wątków i nie wystąpił błąd, to pierwszy z nich przeprowadzi odczyt z czujnika. Dlatego tuż za przejściem próbujemy po raz drugi zwrócić rekord z cache. Jeśli odczyt wciąż jest nieprawidłowy, pobieramy nowy z urządzenia, aktualizujemy cache i zwracamy wynik. Na końcu, niezależnie od wyniku czy ewentualnego wyjątku, w bloku finally wywoływane jest semaphore.Release(), aby kolejne wątki mogły wejść do sekcji krytycznej. Dzięki temu rozwiązaniu unikamy wielokrotnych równoległych odczytów z sensora, a większość wywołań kończy się szybkim zwróceniem danych z pamięci.

/// <summary>
/// Gets current air quality data, performing expensive sensor read only if necessary.
/// Uses double-checked locking with SemaphoreSlim to prevent multiple concurrent expensive operations.
/// </summary>
public async Task<AirQualitySensorData> GetCurrentDataAsync()
{
    // First check: Is cached data still valid? (no lock needed for read)
    if (cachedData?.IsValid(validityDuration) == true)
    {
        return cachedData;
    }

    // Acquire semaphore to ensure only one thread can proceed to expensive operation
    await semaphore.WaitAsync();
    try
    {
        // Second check: Re-verify data validity after acquiring semaphore
        if (cachedData?.IsValid(validityDuration) == true)
        {
            return cachedData;
        }

        // Perform the expensive sensor read operation and update cache
        cachedData = await airQualitySensor.ReadSensorAsync();

        // Return updated value
        return cachedData;
    }
    finally
    {
        // Ensure semaphore is released even if an exception occurs
        semaphore.Release();
    }
}

Mechanizmy wspierające w .NET

Nie zawsze musimy pisać kod rozwiązujący problem pędzącego stada od zera. Platforma .NET udostępnia wysokopoziomowe narzędzia z których możemy skorzystać w zależności od modelu życia obiektu.

Jednorazowa inicjalizacja operacji asynchronicznych (bez odświeżania)

Wszyscy dobrze znamy mechanizm Lazy<T>, który służy do leniwej inicjalizacji obiektu. W przypadku operacji asynchronicznych możemy użyć Lazy<Task<T>>. Podejście sprawdzi się w sytuacji, gdy nie musimy odświeżać zasobu oraz nie potrzebujemy złożonego mechanizmu obsługi błędów lub ponawiania (retry) przy inicjalizacji.

// One-time asynchronous initialization using Lazy<Task<T>>
private static readonly Lazy<Task<string>> lazyConfig = 
    new Lazy<Task<string>>(LoadConfigurationAsync);

public static Task<string> GetConfigAsync() => lazyConfig.Value;

private static async Task<string> LoadConfigurationAsync()
{
    // Simulate expensive operation
    await Task.Delay(1000);
    return "Loaded configuration";
}

Dane odświeżane/zanikające (TTL, wiele kluczy)

Jeśli dane mają określony czas życia albo potrzebujemy przechowywać ich wiele (np. kolekcję konfiguracji, pomiary z różnych czujników), wygodnie skorzystać z IMemoryCache.

Model pozwala dla pobranych zasobów ustawić między innymi:

  • AbsoluteExpiration – wygaszanie wpisów po ustalonym czasie,

  • SlidingExpiration – „przedłużanie życia” przy odczytach,

  • priorytety i czyszczenie – system usuwa mniej istotne wpisy przy presji pamięci,

  • wywołania zwrotne (callbacks) – możliwość reagowania, gdy element zostanie usunięty z cache.

Jednak IMemoryCache nie eliminuje problemu pędzącego stada. Gdy wiele wątków jednocześnie zauważy brak lub wygaśnięcie wpisu, wszystkie mogą rozpocząć kosztowną operację jego odświeżenia.
Aby tego uniknąć, musimy dalej korzystać np. z mechanizmu double-checked locking.

Dodatkowo warto pamiętać, że IMemoryCache nie jest częścią podstawowej biblioteki .NET. Sam interfejs dostępny jest w Microsoft.Extensions.Caching.Abstractions. Domyślna implementacja znajduje się w Microsoft.Extensions.Caching.Memory, którą należy dodać do aplikacji, aby używać cache w runtime.

Odczyt z IMemoryCache

if (cache.TryGetValue("sensor-data", out AirQualitySensorData data))
{
    return data;
}

Zapis do IMemoryCache z TTL

cache.Set("sensor-data", freshData, TimeSpan.FromSeconds(30));

Podsumowanie

Problem pędzącego stada (thundering herd problem) może mieć różne oblicza w zależności od typu i dostępności zasobów. W platformie .NET mamy kilka sposobów, aby go powstrzymać.

Jeśli zasób musi być utworzony tylko raz i nie wymaga odświeżania, najprostsze rozwiązanie to Lazy<Task<T>>. Mechanizm jest prosty i bezpieczny, choć ogranicza możliwości obsługi błędów.

Dla danych, które tracą aktualność i wymagają okresowego odświeżania albo gdy operacja jest bardziej złożona, dobrze sprawdza się połączenie double-checked locking z IMemoryCache. Dzięki nim kosztowne wywołanie wykonuje się jeden raz w danym okresie, a wszystkie wątki korzystają z tego samego rezultatu.

Oba podejścia skutecznie ograniczają nagłe skoki obciążenia (spikes) i pozwalają bardziej efektywnie wykorzystać zasoby naszego systemu.

Ten artykuł jest częścią serii. Przykładową implementację double-checked locking oraz inne użyteczne algorytmy przy przetwarzaniu zdarzeń znajdziesz w repozytorium: useful-async-algorithms

Użyteczne algorytmy w systemach opartych na zdarzeniach

Part 1 of 3

W świecie .NET tworzymy systemy oparte na zdarzeniach. Reagujemy na pojawiające się informacje, przetwarzamy je asynchronicznie. W naszych rozwiązaniach często wracają te same problemy. Seria pokazuje praktyczne wzorce, które pomagają je rozwiązywać.

Up next

Jitter w .NET – jak rozkładać fale obciążeń

Wprowadzenie Systemy oparte na zdarzeniach często muszą radzić sobie z sytuacją, w której wiele procesów uruchamia się w tym samym momencie. Dzieje się tak na przykład wtedy, gdy zadania są planowane na określone godziny albo gdy klienci jednocześnie...