Tänk dig ett scenario där flera användare kan påverka samma databasvärde men att de ändå inte får skriva över varandra, alltså ett ”först till kvarn”-scenario som t.ex. att reservera en specifik sittplats i en biograf. Vi tittar närmare på begreppet lock i C# och framförallt hur det kan nyttjas för att låsa en funktion på ett Id eller annat nyckelord så att funktionen kan köras samtidigt så länge parametrarna skiljer sig.

Lock

Nyckelordet låser ett kodblock för ett givet objekt och när kodblocket är färdigexekverat lyfts låset. Så om funktionen anropas två gånger samtidigt kommer det ena anropet att pausas tills dess att det andra anropet är fädig med kodblocket.

Mer detaljer finns här: https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/lock-statement

Följande kodexempel visar hur vi kan låsa reservationen av en sittplats så att om två anrop med samma seatId sker samtidigt kommer det ena att få igenom sin reservation medan det andra får felet SeatNotAvailableException.

readonly static object _reservationLockObject = new object();

[...]

lock(_reservationLockObject)
{
    var isSeatFree = IsThisSeatFree(seatId);  
    
    if(isSeatFree)
    {
        ReserveSeat(userId, seatId);
    }
    else 
    {
        throw SeatNotAvailableException();
    }
}

Problemet är bara att om två personer försöker boka olika sittplatser samtidigt så kan den ena behöva vänta på att den andras reservation slutförs. Det blir en onödig väntetid och med högt tryck kan svarstiderna bli långa.

Om vi tar bort lock och två personer försöker boka samma sittplats samtidigt är det troligt att koden rapporterar IsThisSeatFree(seatId) som true för båda anropen och även om ReserveSeat(userId, seatId) förmodligen resulterar i att bara en person kommer få bokningen så kommer efterföljande kod bete sig som att två personer bokat samma plats tills dess att en ny koll mot datalagret gjorts.

Så för att lösa dessa två problem kan vi skriva om koden såhär:

private readonly static ConcurrentDictionary<string, object> _lockList = new ConcurrentDictionary<string, object>();

[...]

var lockObject = _lockList.GetOrAdd(seatId, new object());
lock(lockObject)
{
    try
    {
        var isSeatFree = IsThisSeatFree(seatId);
        
        if(isSeatFree)
        {
            ReserveSeat(userId, seatId);
        }
        else 
        {
            throw SeatNotAvailableException();
        }
    }
    finally
    {
        _lockList.TryRemove(seatId, out _);
    }
}

Här försöker vi hämta ett låsobjekt från den statiska listan _lockList med seatId som nyckel. Om det inte finns så skapas ett nytt objekt som kopplas till seatId och används för låsningen.

Try Finally-blocket ser till att seatId tas bort från _lockList så fort ReserveSeat är färdig.

_lockList är en statisk och trådsäker dictionary vilket garanterar att alla anrop till funktionen kommer åt samma värden och att en nyckel bara kan finnas en gång.

Viktigt att notera är att detta inte kommer fungera i en lastbalanserad lösning eftersom att statiska variabler lever i processen som kör en applikation. I sådant fall behöver dessa låsobjekt istället hanteras i en distribuerad cache eller databas.

Wrapper

Vi kan göra wrapper-funktioner som tar en Func eller Action som parameter och ger dom ett lås för angivet nyckelord:

public static class KeywordLocker
{
    private readonly static ConcurrentDictionary<string, object> _lockList = new ConcurrentDictionary<string, object>();
    public static TResult WrapInLock<TResult>(Func<TResult> function, string keyword)
    {
        var lockObject = _lockList.GetOrAdd(keyword, new object());
        lock (lockObject)
        {
            try
            {
                return function();
            }
            finally
            {
                _lockList.TryRemove(keyword, out _);
            }
        }
    }

    public static void WrapInLock(Action function, string keyword)
    {
        var lockObject = _lockList.GetOrAdd(keyword, new object());
        lock (lockObject)
        {
            try
            {
                function();
            }
            finally
            {
                _lockList.TryRemove(keyword, out _);
            }
        }
    }
}

Då kan vi skriva om vår kod så här:

[...]

KeywordLocker.WrapInLock(() => 
{
    var isSeatFree = IsThisSeatFree(seatId);

    if (isSeatFree)
    {
        ReserveSeat(userId, seatId);
    }
    else
    {
        throw SeatNotAvailableException();
    }
}, seatId);

Demo

En konsollapp som med hjälp av async await sätter igång flera samtidiga anrop mot en funktion som sparar ner en inparameter i en lista om den inte redan finns där. Först visas resultatet utan en låsning och sen visas resultatet när vår KeywordLocker används:

Concurrency Result

Som syns ovan får vi ett kaotiskt resultat om vi inte låser funktionen när samtidiga anrop sker. Om vi däremot använder vår KeywordLocker.WrapInLock() så ser vi att det bara sparats ett resultat per biljett-id samtidigt som reservationen av TicketId1 inte blockerat en samtidig reservation av TicketId2 vilket hade varit fallet om vi bara använt lock.

Koden finns här: https://github.com/lenellsarn/blog.lockobjectsdemo

Relaterad läsning

Om du ute efter att “throttla” dina anrop, det vill säga begränsa antalet anrop som kan köras parallellt rekommenderar jag att kolla närmare på Semaphore Slim, väl sammanfattat här: https://techblogg.infozone.se/blog/throttling-using-semaphore-slim/

Lämna en kommentar