lock (mit?)

Az egyik cikk kommentjében merült fel egy lock-olással kapcsolatos kérdés. A kérdés előtt azonban nézzük meg az alapproblémát.

Ha van egy adatszerkezet/algoritmus, amely hajlamos inkonzisztens állapotot felvenni abban az esetben, ha több szál fut a kódon, akkor azokat egy “kerettel” védhetjük, amely keretet a lock kulcsszó (vagy a Monitor.Enter/Exit) jelöli ki.

Kezdetben kézenfekvő ötletnek tűnt, hogy szinkronizációs objektumnak magát a példányt használjuk, aminek a többszálúságra érzékeny kódját védeni szeretnénk:

class Stack
{
...
  void Push(T item) 
  {
    lock (this)
    {
      this.items[top++] = item;
    } 
  } 
}

Ilyet ma már eretnekség leírni, pedig az elv működik, bár bizonyos helyzetekben valóban veszélyes. Miért veszélyes? Nézzük a következő példát:

class Brother
{
   void BrotherThing(Sister sister)
   {
      lock (this)
      lock (sister)
      {
        ...
      } 
   }
}

class Sister
{
   void SisterThing(Brother brother)
   {
      lock (this)
      lock (brother)
      {
        ...
      }
   }
}

Igen, a kód erőltetett, mint általában a kicsi példakódok, de a lényeg érthető. Két objektumpéldány keresztbe használja egymást, mind a kettő szinkronizációt végez magán, illetve a használt példányon. Ha a BrotherThing és a SisterThing metódusok jó ütemben hívódnak, akkor a kód leáll. Első lépésben mind a BrotherThing, mind a SisterThing lockolja a saját példányát. Ezután szeretnék lockolni a másik példányt, amit viszont az előző pillanatban a másik szál lockolt, így mind a két szál futása beakad.

A fenti példában a fő gond nem az, hogy a this-en van a lock. A fő gond az, hogy ugyanazt a szinkronizációs objektumot (pl a Brother példányt) két különböző célra használjuk. Az egyik cél a példány belső állapotának konzisztensen tartása (ez a lock (this)), a másik pedig valami külső mechanizmus szinkronizálását szolgálja. Ez a két mechanizmus akad össze a példakódban.

A megoldást szinte mindenki ismeri, a belső konzisztencia védelmére nem szabad kívülről elérhető objektumot használni, így azzal nem lehet összeakadni sem:

class Brother
{
   private object sync = new object();

   public void BrotherThing(Sister sister)
   {
      lock (sync)
      lock (sister)
      {
        ...
      }
   }
}

class Sister
{
   private object sync = new object();

   public void SisterThing(Brother brother)
   {
      lock (sync)
      lock (brother)
      {
        ...
      }
   }
}

Cargo Cult programming

A gond az, hogy annyira elterjedt a külön célra létrehozott szinkronizációs objektum – akár okkal akár ok nélkül – hogy már csúnyának tűnik nem úgy csinálni. Az olvasót is ez a jelenség zavarta meg, és a kérdés úgy szólt, hogy miért nem jó ez a kód:

public void AddToList(List<int> list)
{
  lock (list)
  {
    list.Add(111);
  }
}

Ez a kód egyetlen dolog miatt tűnhet csúnyának – megszegi a privát szinkronizációs objektumra vonatkozó, tévesen kialakított ökölszabályt. Miről van szó a kódban? Tudjuk, hogy a List<T> nem szinkronizált, emiatt nem szabad több szálat ráengedni például az Add() metódusra. Ha van egy kódom, ami hívogatja az Add()-ot, biztosítanom kell, hogy ugyanarra a List<T> példányra ezt nem teszem meg több szálból. A fenti kódrész ezt megoldja.

Mikor lehet baj? Ha az List<T>.Add() valami olyan bonyolult dolgot végez, ami visszahat az én szinkronizációs rendszeremre. Nem árt óvatosnak lenni szinkronizáció esetében, de azért azt feltételezhetjük, hogy az Add() nem csinál ilyet. Az is problémát okozhat, ha más kódrészek valamilyen oknál fogva hosszú időre lockolják a lista példányt. Ekkor a mi kódunk akadályoztatva lesz, azonban nehéz lenne értelmes példát találni arra, hogy valaki a List példány-t lockolja, ugyanakkor más (például mi) egy másik szálon legálisan módosíthatná az állapotát.

Ökölszabályból baj nem lehet?

Baj talán nem, de teljesítményproblémák igen, ha vakon követem a “protokolt”. A fenti példában csinálhatok “szokás szerint” egy szinkronizációs objektumot, amely védi az AddToList kódját:

private object sync = new object();

public void AddToList(List<int> list)
{
  lock (sync)
  {
    list.Add(111);
  }
}

Ez most jobb? Hát nem éppen.. Ha több szálból hívodik az AddToList(), különböző List példányokkal, a hívások sorba lesznek állítva a lock-on, pedig semmi baj nem történne, ha párhuzamosan lefutnának. Az eredeti verzió nem szenved ettől a hibától, és helyesen csak az azonos példányra vonatkozó hívások állnak sorba.

  1. #1 by eMeL on 2011. November 22. - 21:06

    Köszönöm az infót, valóban kimerítő volt.

    Vagyis nem a lock(this) maga a probléma, hanem “csupán” a deadlock lehetősége, amit okozhat, ahogy az az általad mutatott példában látható.

    A kérdés csupán az, vajon az MSDN miért csak egyszerűen az “így használd és punktum” szintig jut el.

  2. #2 by Tóth Viktor on 2011. November 23. - 08:00

    Igen, a fő gond a lock (this)-szel, illetve akármelyik más kódrész által is elérhető objektummal, hogy egyik oldalról én használom valahogyan szinkronizációra, másik oldalról esetleg más is használja másmilyen módon szinkronizációra, és ez a két mód zavarja egymást (pl akár okozhat deadlock-ot). Emiatt biztos ami biztos alapon érdemes ezeket a helyzeteket elkerülni, innen az ökölszabály.
    Ez alapján elvileg az AddToList() sem szerencsés, mivel más is elérheti elvileg a list példányt, és használhatja szinkronizációra. Ugyanakkor nehéz értelmes példát kitalálni arra, hogy ez hol okoz hibát.
    Ha már a listák lockolásáról van szó, érdemes megemlíteni, hogy a List of T-nek van szinkronizációs objektuma, amit vissza is ad az ICollection.SyncRoot-tal. Ebben az a szerencsétlen, hogy mivel ez egy explicit interfész implementáció, alapból nem látszik a példányon. Emiatt az ember hajlamos lehet magára a lista példányra lock-olni, hogy védje a párhuzamos műveletektől. Ugyanakkor elvileg előfordulhat, hogy a csapatban egy másik programozó rutinja IList-ten dolgozik, és ő meg azt találta ki, hogy az IList.SyncRoot-ra szinkronizál, ha már arra találták ki. Ez egy a szinkronizáció céljára létrehozott object-et fog visszaadni. Ha most párhuzamosan fut az AddToList(), illetve a másik programozó metódusa, akkor egymáshoz képest nem szinkronizálnak, és rosszul működhet a kód.
    Ez a példa kicsit megint erőltetett, ugyanakkor jobban megvilágítja ennek a területnek a problémáját: nem az a lényeg, hogy mikor mit használunk szinkronizációra, hanem hogy a szinkronizációs mechanizmusok együtt tudjanak működni.

  3. #3 by mjanoska on 2011. November 23. - 10:01

    A másik tipikus dead lock use case, amikor valaki mindenféle más (elvileg egymással párhuzamosan futtatható) metódusokat is ugyanarra szinkronizál. Elvetemült dictionary add és getitem pl ha valaki úgy gondolta, hogy a read ágon is kell szink. Ekkor nem lehet majd egyszerre olvasni és írni, mert bár jó a metódus szinten a sync obj, a teljes osztályt tekintve egymást blokkolhatják az elvileg független metódusok.
    Még 1-et hiányolok, ami tipikus szokott lenni lock(typeof(T)) s társai, szinkronizáció statikus kívülről elérhető objektumokon…

    • #4 by Tóth Viktor on 2011. November 23. - 18:59

      Jól van, ez egy csípőből tüzelős cikk, egy adott kérdésre válaszolva 🙂 A háttérben készül egy szinkronizációról szóló sorozat, pár nap múlva kész lesz az első rész. Az teljesebb lesz.

  4. #5 by Mező Gábor on 2011. November 23. - 11:07

    SOLIDan nem írunk ilyen kódot, és akkor a téma sem jön elő. 🙂 A lista felelőssége lockolni az Add-ot, az őt használó osztály felelőssége meg ezt kikötni:

    class Program
    {
    static void Main(string[] args)
    {
    try
    {
    var blist = new SynchronizedCollection();
    AddToList(blist, 1);

    var list = new List();
    AddToList(list, 1);
    }
    catch (Exception ex)
    {
    Console.WriteLine(ex);
    }
    Console.WriteLine(“\nPress any …”);
    Console.ReadKey();
    }

    public static void AddToList(TList list, TItem item) where TList : IList, IList
    {
    Contract.Requires(list != null);
    Contract.Requires(list.IsSynchronized);

    list.Add(item);
    }
    }

    • #6 by Tóth Viktor on 2011. November 23. - 18:56

      Igen, ez így tényleg szofisztikáltabb. Az általad használt elvnek meg van az az előnye is, hogy ha nincs készen kapható többszálú testvére a használni kívánt típusnak (mint a List-nek), akkor kénytelen leszek egy wrappert/adaptert írni hozzá, ami a többszálúságot kezeli, és ezen keresztül történik a szinkronizáció. Így a szinkronizációval foglalkozó kód egy helyen van, a felhasználó kódokat (mint AddToList()) pedig nem kell összehangolni.

  5. #7 by sanya on 2011. November 23. - 21:30

    Hello,

    Lehet, hogy benézek valamit, de szerintem az utolsó példában a teljesítményprobléma a kisebbik gond. A nagyobbik az, hogy ha mondjuk egy másik osztály egy metódusa módosítja ugyanazt a List objektumot, akkor egyáltalán nincs szinkronizáció, hisz ez a másik osztály nem tud ugyanarra a private sync objektumra lockolni.

  6. #9 by flata on 2011. November 24. - 22:44

    huuu, lehet cikk témát ajánlani / kívánni? 🙂

    • #10 by Tóth Viktor on 2011. November 25. - 07:13

      Van jó témád?

      • #11 by flata on 2011. November 25. - 13:54

        ahha,

        AOT-ról (NGen) olvasgattam nem olyan rég, de nem igazán találtam számomra nagyon jó cikket, felmerült kérdések:

        mennyit gyorsít a starupon,
        tényleg ront-e a teljesítményen, mennyit,
        strong-named assemly kell-e hozzá,
        setup projectbe intergrálhatóság,
        mikor érdemes használni,
        out of box .net assemblyk és az ngen,
        gac és ngen kapcsolata

      • #12 by Tóth Viktor on 2011. November 25. - 17:21

        Hát, ezt majd valamikor a nagyon távoli jövőben 🙂

Leave a comment