Mutex éles/lightweight módban

Fórumok

Szép napot !

Alaphelyzet: egy darab Solaris processz, van egy jó nagy platformunk, (C++-ban) ami
tartalmaz egy (konkrétan topológia) adatstruktúrát, ami néha változhat (újra kell olvasni, mert módositják, törölnek/hozzáadnak, stb.)
Ilyenkor (1) write lockoljuk a globális platform lockot (konkrétan omni thread mutex), hogy ne akadjuk össze az alábbival:

A processzünk sok threadben, több CPU magon bejövő eseményeket dolgoz fel, amikből jó sok tud jönni per sec. Mikor bejön egy esemény, turkálni (olvasni) kell az adatstruktúrában, ehhez (2) read lockoljuk a globális platform lockot.
(a sok (2)-es egymást nem zavarja, mert mind csak olvas, mehetnek egyszerre)

Probléma: a mutex megszerzésnek/eleresztésnek van költsége, ami nagy esemény intenzitás esetén jelentős lehet, még akkor is, ha szabad a lock. (nyilvántartjuk az olvasásra várók számát, if-elgetünk, külön mutex van az olvasásra várók számának változtatásához és vizsgálatához, conditional variable a várakozók felébresztéséhez, stb.)

Amin agyalok:
Mivel az (1) viszonylag ritkán történik, ezért az idő túlnyomó részében de facto fölösleges a lockot megszerezni a (2) esetben. Az ötlet az, hogy limitálnám, hogy az (1) pl. csak minden 10. másodpercben történhetne (pl. kivárnánk), ezáltal a többi 9 másodpercben (azaz az idő 90%-ában) ki lehetne hagyni a lockolást a (2) esetekben.
(alternativa, hogy nem idő szerint, hanem iráskor tennénk éles módba a lockot)

A gondom, hogy ehhez is kell egy flag, ami jelzi, hogy 'éles' módban vagyunk-e (azaz egy 10. másodpercben) vagy nem. Ennek a flagnak az állitásához ill. vizsgálatához ugyancsak kéne egy lock, különben a 10. másodperc elején/végén nem lesz biztonságos a működés. Akkor meg ott vagyok, ahol a part szakad, egy mutexet kiváltottam egy másikkal, amit vizsgálni kell rengetegszer, minden eseménynél a (2)-ben.
És ha egy nem éles módban (2)-ben elkezdett olvasás közben üt az óra, és hirtelen éles módba megyünk, akkor az (1) thread simán megkapja a write lockot, és összekuszálja az adatstruktúrát, amin a (2) dobhat egy hátast.

Szóval itt tartok, nem találok fogást a problémán. Pedig *valahogy* meg lehetne úszni a nagyrészt fölösleges lockolásokat, azt sejtem.
Köszi minden konstruktiv tippet.

Hozzászólások

Probaltal mar spinlockokat hasznalni? Ha viszonylag ritka a varakozas a mutexen, akkor jo lehet, mivel kifejezetten alacsony a koltsege (nem feltetlen jar kontextusvaltassal, peldaul).

----------------------
while (!sleep) sheep++;

Aha, ez esetleg jó lehet, köszi a tippet.

Ritka az irás, az olvasás nagyon gyakori.
Mondjuk a spinlock felpörgeti a CPU ciklusokat kissé, ezáltal nagyobbnak tűnik a CPU utilization mint a tényleges, de ez nem nagy baj.

Ha jól értem, megúsznánk vele a conditional variable-t, mert nem kéne ébresztgetni a várakozókat.

A spinlockok atomikus művelettel működnek, pl. atomic_swap vagy atomic_cas ?
Ilyenkor ugye addig próbáljuk az atomic_swap-ot, amig nem sikerül 1-es értéket visszaolvasni, és akkor miénk a pálya ?
Hogy csinálnál külön read ill. write lockolást ? Többszintű szemafor, pl. ++ olvasás előtt, utána -- ? (már persze ha miénk lett a spinlock)

Tudom, jelentősen elkalandozok az eredeti felvetéstől:

sajnos se azt nem tudni, hogy milyen események vannak, milyen adatstruktúrát kell szétházmozni (és ez mennyi erőforrásba kerül) illetve hogy mennyire sebességkritikus a rendszer.
Ha kevésbé sebességkritikus és az adatstruktúra viszonylagosan konzisztens és gyorsan lehet házmoni, illetve belefér egy minimál latency az eseményekre való reagálásra, akkor elgondolkoznék valami belső message pump rendszeren. Így mivel külön thread foglalkozik ezzel a résszel egyedül, nem kell lockolni az adatstruktúrát, a message pump pedig már helyből sorba szedi az üzeneteket (így elvileg minden oldalról elkerülhető a lockolás). Ez a message pump hasonló mint egy FIFO, igazából egy queue rendszer, de pont az ilyen helyzetekre jó. Bár ez nagyon sok helyzetben nem működik, viszont kevésbé igényes mint a lockolás, viszont ha a cél engedi, akkor nagyon jól működik, viszont hardware oldalról kell sok és nagyon gyors ram alá

jó esetben pont nem kell, mivel ha a kód jól van megcsinálva, a pump egy paranccsal történik (a fő ciklusban az ellenőrzés nem egy, hanem kettő, de ha ő false eredményt kap arra, hogy a queue üres, úgy is újrakezdi, így problémát nem jelent, csak kiesik egy ciklusnyi esemény, de mivel ezek nagy terhelésü rendszerekre vannak kitalálva, elvileg nem sűrűn van olyan, hogy a queue üres).

Mcsiv: egy központi event loopra gondolsz, mint pl. a GUI toolkitek fő event loopja, ami visszahivogatja az érdeklődőket ? A lock problémát megoldaná, de itt kell a multithreading, hogy kihasználjunk akár 64 magot.

Ha úgy érted, h. multithread és FIFO, amit viszont csak az egyik thread ir, ahhoz át kéne irni a kódot sok helyen, ahol ir bele, hogy átpasszolja az irnivaló infót az iró threadnek, de a fő baj, hogy az olvasás viszont sok másik threadben történik, és azokat nem lehet központositani, ld. fent.

Elég sebességkritikus, sajna pont azért kell valamit csinálni vele. Latency viszont belefér.

Igazából mind a kettő message pump rendszer, ezen belül szoktak úgy válogatni, hogy melyik a legcélravezetőbb. Egy minimális latency-vel jár, mivel az eventek belekerülnek a queue-ba illetve mire onnan kiesnek. Ez statisztikai alapon elég sok minden függvénye, hogy mekkorával (pl mekkora adatokat kell a queue-ban mozgatni).
Egy eszmefuttatást megért, bár ahogy mondtad is, jelentős kód átalakítást igényel egy ilyen rendszer migrálása mutexről message pump rendszerbe.

1. Ha jól értem, akkor "hagyományos" mutexekből és conditional variable-kből hekkeltetek össze egy read-write lockot. Mi lenne, ha inkább "igazi" RW lockot használnátok, pl. pthread_rwlock_t? Mivel a szinkronizáció lényegileg nem változik, ezt lehet, hogy viszonylag kevés változtatással meg lehetne úszni.

2. Ha tényleg nagyon sokkal több olvasás van, mint írás, és a szóbanforgó megosztott adatstruktúra nem túl nagy vagy az írások ritkák, és szinkronizálás szempontjából elég, ha az olvasó szálak egy konzisztens snapshotot látnak a megosztott adatstruktúrából, akkor meg lehet próbálkozni egy copy-on-write adatstruktúrával.

1. Nagyon jól látod, egy RW lockot ácsoltak össze omni_mutexekből. Amiből úgy látom, sima Solaris mutex lesz rövid úton. Van arról tapasztalatod, hogy a rendes pthread_rwlock mennyivel gyorsabb ?

2. Úgy érted, a write egy másolatba történik, és a legvégén, mikor kész és konzisztens, átírunk egy pointert, és onnantól az lesz az éles ? Ez vszleg jó, csak sok helyen kell a kódban a másolást megcsinálni, mindenhol, ahol Write lockolni akarunk. Fa struktúrában biztos használható, ahol elég a gyökér pointert átírni, de kétirányú láncolt listánál már nem biztos. Utána nézek, elég sok helyen lockol a kód.
Ha a másolgatás pedig nem ilyen atomikus, akkor azt kell lock-kal védni.

Közben olvasva a neten, pl. a Solaris kernelben adaptív mutex lockot használnak, azaz egy kis ideig spinlock, és ha nem sikerül megszereznie, akkor rendes mutex lockol és újraütemezés. Viszont nem látom, hogy user space-ben is elérhető lenne az adaptív lock. Vagy valakinek sikerült ?

1. Nem tudom, még sose tákoltam össze RW lockot mutexekből, hogy utána igazi RW lockkal helyettesítsem (;

2. Nagyjából így értem, persze a pointert nyilván valami atomi művelettel kell átírni, és a copy-on-write szemantikáról az adatstruktúrának magának kellene gondolskodni, nem a callsite-oknak. Inspirációnak nézd meg a java CopyOnWriteArrayList-jét vagy CopyOnWriteMap-jét (C++ példa nem jutott hirtelen az eszembe).

Utána kutattam ennek, hátha másnak is hasznos:

"Viszont nem látom, hogy user space-ben is elérhető lenne az adaptív lock. Vagy valakinek sikerült ?"

A Solaris process szintű (vagyis nem inter-processz) mutex adaptív.

Ld. egy Sun mérnök válaszát.

...ill. az Opensolaris source-ot, aminek remélhetőleg van némi köze a Solarishoz:
mutex_trylock_adaptive függvény, ld. a source-ot itt.

Gondolom, van az adatstruktúrádnak egy kiindulási pontja, legyen ez egy globális pointer. Az update menjen úgy, hogy a pointert csak akkor frissíti be, ha teljesen felépült az új adatszerkezet. Tehát azonnal átváltasz a régi adatról az újra, nem kell várakozni arra, hogy legyen adat. Ha ezen felül fontos, hogy a változást azonnal lássák az olvasók, akkor megteheted azt, hogy olvasás előtt és után összehasonlítod a pointert, és ha nem egyezik, akkor újra elvégzed az olvasást. Gyk. optimista lock.

Ennek egy egyszerűbb verziója, ha nem akarsz olvasást ismételni, hogy van egy globális boolean változód, ami mutatja, hogy éppen folyamatban van-e az update. Ha ez true, akkor valamilyen módon az olvasók kivárják, hogy false legyen. Összeakadással nem kell foglalkozni, csak garantáld azt, hogy ha valaki elkezdett a régi adatokkal dolgozni, akkor az mindig be is tudja fejezni, tehát csak később semmisítsd meg a korábbi adatokat. Ezt mondjuk menedzselt nyelvben lenne sokkal egyszerűbb megoldani.

By the way, ez csak egy ötlet, nehéz jót mondani konkrét számok nélkül. Igazából ha már egy read lock is lassú, akkor ott valamit nagyon elszúrtál, és amiket itt ajánlgatunk, az lehet hogy x%-kal gyorsabb lesz, de 10x%-kal hekkelősebb. Én inkább erőltetném a library vagy OS által ajánlott szinkronizációs primitíveket, mint a fentebbi trükkjeimet.

--
joco voltam szevasz

olvasás előtt és után összehasonlítod a pointert, és ha nem egyezik, akkor újra elvégzed az olvasást

Ez így önmagában, végig C-ben, C++-ban maradva, nem biztos, hogy elég:

  1. Ha az R/O feldolgozás alatt megváltozott a pointer, és a régi pointer által mutatott területet fel is szabadították, akkor a régi pointert kiértékelni undefined behavior. Ez ellen például lehet referenciaszámlálással védekezni (elvileg az R/O feldolgozás ugyanazt a snapshot-ot használja), de ha az R/O feldolgozáshoz deep copy készült valamiért, és az eredeti pointer alatti terület elszállhatott, akkor az undefined behavior lehetősége fennáll.
  2. ABA probléma.

Egy kis magyarázat az első ponthoz: az alábbi (egyszálú) kód undefined behavior-t hordoz:


char *p;

p = malloc(1);
if (p) {
  free(p);
  p; /* itt */
}

A gyakorlatban persze semmi disznóság nem fog történni, de akkor már jobb nyíltan a C alá menni.

Igazából ha már egy read lock is lassú, akkor ott valamit nagyon elszúrtál, és amiket itt ajánlgatunk, az lehet hogy x%-kal gyorsabb lesz, de 10x%-kal hekkelősebb. Én inkább erőltetném a library vagy OS által ajánlott szinkronizációs primitíveket

+100

1. A témaindító leírás nem győzött meg arról, hogy a mutexért versengnének, sem arról, hogy a mutexet drága volna használni. Szerintem amíg ezt nem bizonyítottátok be (= nem mértétek meg), addig szerintem felesleges és veszélyes bonyolítani a szinkronizációt.

2. Ha az első ponton már túl vagytok, akkor javaslom, hogy olvasd végig ezt a fejezetet. Nem lehetetlen, hogy platformfüggő vagy C++0x primitívekre lesz szükséged.

Én az rwlock-tól egy kicsit idegenkedem. Ha olyan implementációt használsz, amely az olvasóknak kedvez, akkor az író éhezhet. Ha olyan implementációt használsz, amely az író szándéknyilvánítása után az új olvasókat már nem engedi be, akkor az írón keresztül a már futó olvasók tartják fel az új olvasókat.

Az MVCC nekem (is) jobban tetszik. Alant egy atomgagyi referenciaszámlált változat. (Lefordítani sem próbáltam.) Elvileg akárhány reader és writer konkurálhat, és a cucc READ COMMITTED láthatóságot biztosít.

Az adatszerkezet gyökerében van egy számláló. (Gyökéren valójában egy vékony wrapper-t, "verziót" értek a lényegi adatok körül.) A számlálót egy mutex védi. A gyökérre mutat egy pointer ("friss verzió"). A pointert szintén egy mutex védi. Az alábbiak számítanak gyökérreferenciának (= verzióreferenciának):

  • A gyökérre mutató "friss verzió" pointer.
  • Minden szál, amely az adott verzióval dolgozik.

Csak read-only referenciák léteznek.

Utility definíciók, pszeudokód:


struct root
{
  unsigned refno;
  mutex_t refno_lock;

  void *user_data;
};

static struct root *rootptr;
static mutex_t rootptr_lock;

/* Return the current version and add a reference to it. */
struct root *
acquire_current(void)
{
  struct root *current;

  lock(&rootptr_lock);
  current = rootptr;
  lock(&current->refno_lock);
  ++current->refno;
  unlock(&current->refno_lock);
  unlock(&rootptr_lock);

  return current;
}

/*
  Release the reference. Return a boolean value corresponding
  to whether this was the last reference. If this was the last
  reference, free the wrapper, and store the user data in
  *old_user_data.
*/
int
release_copy(struct root *mycopy, void **old_user_data)
{
  unsigned after;

  lock(&mycopy->refno_lock);
  after = --current->refno;
  unlock(&mycopy->refno_lock);
  if (0u == after) {
    *old_user_data = mycopy->user_data;
    lock_uninit(&mycopy->refno_lock);
    free(mycopy);
    return 1;
  }

  return 0;
}

/*
  Set the next version of the object. Return values:
  -1 -- malloc() failed; nothing is changed.
   0 -- success, and the replaced version
        is still referenced by others.
   1 -- success, and the last reference
        to the replaced version was removed.
*/
int
set_next(void **old_user_data, void *new_user_data)
{
  struct root *next;

  next = malloc(sizeof *next);
  if (0 != next) {
    struct root *current;

    next->refno = 1u;
    lock_init(&next->refno_lock);
    next->user_data = new_user_data;

    lock(&rootptr_lock);
    current = rootptr;
    rootptr = next;
    unlock(&rootptr_lock);

    return release_copy(current, old_user_data);
  }

  return -1;
}

Reader-ek kódja:


struct root *mycopy;
void *old_user_data;

mycopy = acquire_current();

use(mycopy->user_data);

if (release_copy(mycopy, &old_user_data)) {
  destroy(old_user_data);
}

Writer-ek kódja:


struct root *mycopy;
void *new_user_data,
    *old_user_data;

mycopy = acquire_current();

prepare_next(&new_user_data, mycopy->user_data);

if (release_copy(mycopy, &old_user_data)) {
  destroy(old_user_data);
}

switch (set_next(&old_user_data, new_user_data)) {
  case -1:
    destroy(new_user_data);
    break;
  case 1:
    destroy(old_user_data);
}

De mondom, ez a zárakkal megoldott MVCC jellegű izé atomgagyi; legjobb esetben is csak annyi mondható el róla, hogy hordozható. Normális esetben valamilyen CAS-t használnak.