Útmutató a hamis megosztáshoz és a @Contended

1. Áttekintés

Ebben a cikkben meglátjuk, hogy a hamis megosztás néha hogyan fordíthatja ellenünk a többszálas szálat.

Először is kezdünk egy kicsit a gyorsítótár és a térbeli lokalitás elméletével. Akkor átírjuk a LongAdder párhuzamos hasznosság és viszonyítsa azt a java.util.egyidejű végrehajtás. A cikk során a benchmark eredményeket különböző szinteken felhasználjuk a hamis megosztás hatásainak kivizsgálására.

A cikk Java-val kapcsolatos része nagymértékben függ az objektumok memóriaelrendezésétől. Mivel ezek az elrendezési részletek nem részei a JVM specifikációinak, és a megvalósító belátására bízzák őket, csak egy meghatározott JVM megvalósításra fogunk összpontosítani: a HotSpot JVM-re. A cikkben felcserélhető módon használhatjuk a JVM és a HotSpot JVM kifejezéseket is.

2. Gyorsítótár sor és koherencia

A processzorok különböző szintű gyorsítótárat használnak - amikor a processzor kiolvas egy értéket a fő memóriából, akkor a teljesítmény javítása érdekében gyorsítótárazhatja ezt az értéket.

Ahogy kiderül, a legtöbb modern processzor nemcsak a kért értéket tárolja, hanem még néhány közeli értéket is. Ez az optimalizálás a térbeli lokalitás gondolatán alapul, és jelentősen javíthatja az alkalmazások általános teljesítményét. Egyszerűen fogalmazva: a processzor gyorsítótárai gyorsítótár-sorokban működnek, az egyes gyorsítótárazott értékek helyett.

Ha több processzor működik ugyanazon vagy a közeli memóriahelyeken, akkor végül ugyanazt a gyorsítótárat használhatják. Ilyen helyzetekben elengedhetetlen, hogy az egymást átfedő, különböző magokban lévő gyorsítótárak összhangban legyenek egymással. Az ilyen konzisztencia fenntartásának aktusát gyorsítótár-koherenciának nevezzük.

A CPU magok közötti gyorsítótár-koherencia fenntartása érdekében jó néhány protokoll létezik. Ebben a cikkben a MESI protokollról fogunk beszélni.

2.1. A MESI protokoll

A MESI protokollban minden gyorsítótár sor a négy különálló állapot egyikében lehet: Módosított, Kizárólagos, Megosztott vagy Érvénytelen. A MESI szó ezeknek az állapotoknak a rövidítése.

Annak érdekében, hogy jobban megértsük, hogyan működik ez a protokoll, nézzünk át egy példát. Tegyük fel, hogy két mag fog olvasni a közeli memóriahelyekről:

Mag A értékét beolvassa a a fő memóriából. Mint fent látható, ez a mag lekér még néhány értéket a memóriából, és egy gyorsítótárba tárolja őket. Ezután azt a gyorsítótár sort jelöli kizárólagos mag óta A az egyetlen mag, amely ezen a gyorsítótáron működik. Mostantól kezdve, ha lehetséges, ez a mag elkerüli az elégtelen memória-hozzáférést, ha inkább a gyorsítótárból olvas.

Egy idő után mag B is úgy dönt, hogy elolvassa az értékét b a fő memóriából:

Mivel a és b olyan közel vannak egymáshoz, és ugyanazon a gyorsítótárban helyezkednek el, mindkét mag megcímkézi a gyorsítótár sorait megosztva.

Tegyük fel, hogy ez a mag A úgy dönt, hogy megváltoztatja a a:

A mag A ezt a változást csak a tároló pufferében tárolja, és a gyorsítótár vonalát mint módosított. Ezenkívül a lényeget is közli ezzel a változással B, és ez a mag viszont a cache vonalát jelöli érvénytelen.

Így biztosítják a különböző processzorok, hogy gyorsítótáraik koherensek legyenek egymással.

3. Hamis megosztás

Most nézzük meg, mi történik, amikor a mag B úgy dönt, hogy újra elolvassa a b. Mivel ez az érték nem változott a közelmúltban, gyors olvasásra számíthatunk a gyorsítótár sorából. A megosztott multiprocesszoros architektúra jellege azonban érvényteleníti ezt az elvárást.

Mint korábban említettük, az egész gyorsítótár sor megosztva volt a két mag között. Mivel a core gyorsítótár sora B van érvénytelen most ki kell olvasnia az értéket b ismét a fő memóriából:

Amint fentebb látható, ugyanazt olvasva b a fő memóriából származó érték itt nem az egyetlen hatástalanság. Ez a memória hozzáférés kényszeríti a magot A hogy ürítse ki a tároló puffert, mint a magot B a legújabb értéket kell megszereznie. Az értékek átmosása és beolvasása után mindkét mag a fájlban található legújabb cache sor verzióval rendelkezik megosztva állítsa újra:

Tehát ez gyorsítótár-hiányt okoz az egyik magnak, és a korai puffer átfolyik a másikba, annak ellenére, hogy a két mag nem ugyanazon a memóriahelyen működött. Ez a hamis megosztás néven ismert jelenség károsíthatja az általános teljesítményt, különösen akkor, ha a gyorsítótár hiányzik. Pontosabban: ha ez az arány magas, a processzorok folyamatosan elérik a fő memóriát, ahelyett, hogy kiolvasnák a gyorsítótárukat.

4. Példa: Dinamikus csíkozás

Annak bemutatására, hogy a hamis megosztás hogyan befolyásolhatja az alkalmazások teljesítményét vagy késését, ebben a szakaszban fogunk csalni. Határozzunk meg két üres osztályt:

absztrakt Striped64 osztály kiterjeszti a számot {} a LongAdder nyilvános osztály kiterjeszti a Striped64 eszközöket Serializable {}

Természetesen az üres osztályok nem annyira hasznosak, ezért illesszünk be beléjük néhány logikát.

A mi Csíkos64 osztályban mindent lemásolhatunk a java.util.concurrent.atomic.Striped64 osztályba, és illessze be az osztályunkba. Kérjük, győződjön meg róla, hogy lemásolta a import nyilatkozatok is. Továbbá, ha Java 8-at használunk, mindenképpen cseréljünk le minden hívást sun.misc.Unsafe.getUnsafe () metódus egy egyedi módszerhez:

privát statikus Nem biztonságos getUnsafe () {try {Field field = Unsafe.class.getDeclaredField ("theUnsafe"); field.setAccessible (true); return (Nem biztonságos) field.get (null); } catch (e kivétel) {dob új RuntimeException (e); }}

Nem hívhatjuk a sun.misc.Unsafe.getUnsafe () az alkalmazás classloaderünkből, ezért újra meg kell csalnunk ezzel a statikus módszerrel. A Java 9-től kezdve ugyanez a logika valósul meg a VarHandles, így nem kell semmi különöset tennie ott, és elegendő lenne egy egyszerű copy-paste.

A LongAdder osztály, másoljunk mindent a java.util.concurrent.atomic.LongAdder osztály és illessze be a miénkbe. Ismét le kell másolnunk a import nyilatkozatok is.

Most vesszük összehasonlításba ezt a két osztályt: a szokásunkat LongAdder és java.util.concurrent.atomic.LongAdder.

4.1. Viszonyítási alap

Ezen osztályok összehasonlításához írjunk egy egyszerű JMH-referenciaértéket:

@State (Scope.Benchmark) public class FalseSharing {private java.util.concurrent.atomic.LongAdder builtin = new java.util.concurrent.atomic.LongAdder (); privát LongAdder custom = új LongAdder (); @Benchmark public void builtin () {builtin.increment (); } @Benchmark public void custom () {custom.increment (); }}

Ha ezt a benchmarkot két villával és 16 szálal futtatjuk átviteli benchmark módban (az átadás egyenértékű) -bm thrpt -f 2 -t 16 ″ érvek), akkor a JMH kinyomtatja ezeket a statisztikákat:

Benchmark Mode Cnt Score Hibaegységek FalseSharing.builtin thrpt 40 523964013.730 ± 10617539.010 ops / s FalseSharing.custom thrpt 40 112940117.197 ± 9921707.098 ops / s

Az eredménynek egyáltalán nincs értelme. A JDK beépített megvalósítása majdnem 360% -kal nagyobb átviteli sebességgel eltörpíti a másolással beillesztett megoldásunkat.

Lássuk a késések közötti különbséget:

Benchmark Mode Cnt Score Hibaegységek FalseSharing.builtin avgt 40 28.396 ± 0.357 ns / op FalseSharing.custom avgt 40 51.595 ± 0.663 ns / op

Amint a fentiekből látható, a beépített megoldás jobb késleltetési jellemzőkkel is rendelkezik.

Annak érdekében, hogy jobban megértsük, miben különböznek ezek a látszólag azonos megvalósítások, vizsgáljunk meg néhány alacsony szintű teljesítmény-ellenőrző pultot.

5. Tökéletes események

Alacsony szintű CPU események, például ciklusok, elakadás ciklusok, ciklusonkénti utasítások, gyorsítótár-betöltések / -hiányok vagy memóriaterhelések / -tárolások programozásához speciális hardverregisztereket programozhatunk a processzorokon.

Mint kiderült, az olyan eszközök, mint perf vagy eBPF már használják ezt a megközelítést a hasznos mutatók feltárásához. A Linux 2.6.31 verziótól kezdve a perf a standard Linux profilozó, amely képes hasznos teljesítményfigyelő számlálók vagy PMC-k feltárására.

Tehát a perf események segítségével megnézhetjük, mi történik a processzor szintjén, amikor e két referenciaértéket futtatjuk. Például, ha futunk:

perf stat -d java -jar benchmarks.jar -f 2 -t 16 --bm thrpt custom

A Perf arra készteti a JMH-t, hogy a referenciaértékeket lefuttassa a másolással beillesztett megoldás ellen, és kinyomtassa a statisztikákat:

161657.133662 task-clock (msec) # 3.951 CPU-k 9321 környezetkapcsolót használtak # 0.058 K / sec 185 cpu-migráció # 0.001 K / sec 20514 oldalhiba # 0.127 K / sec 0 ciklus # 0.000 GHz 219476182640 utasítások 44787498110 elágazások # 277.052 M / sec 37831175 branch-misses Az összes ág 0,08% -a 91534635176 L1-dcache-load # 566.227 M / sec 1036004767 L1-dcache-load-misses Az összes L1-dcache találat 1,13% -a

A L1-dcache-load-hiányzik mező az L1 adatgyorsítótár hiányzik a gyorsítótárból. Amint a fentiekből kiderült, ez a megoldás körülbelül egymillió gyorsítótár-hiányt tapasztalt (egészen pontosan 1 036 004 767). Ha ugyanazokat a statisztikákat gyűjtjük a beépített megközelítéshez:

161742.243922 task-clock (msec) # 3.955 CPU 9041 kontextus-kapcsolót használt # 0.056 K / sec 220 cpu-migráció # 0.001 K / sec 21678 page-hibák # 0.134 K / sec 0 ciklus # 0.000 GHz 692586696913 utasítások 138097405127 elágazások # 853.812 M / sec 39010267 branch-misses Az összes ág 0,03% -a 291832840178 L1-dcache-load # 1804.308 M / sec 120239626 L1-dcache-load-misses Az összes L1-dcache találat 0,04% -a

Látnánk, hogy sokkal kevesebb gyorsítótár-hiányt tapasztal (120 239 626 ~ 120 millió) az egyéni megközelítéshez képest. Ezért a gyorsítótár-kimaradások nagy száma lehet a bűnös az ilyen teljesítménybeli különbségekért.

Vájkozzunk még mélyebben a LongAdder hogy megtalálják a tényleges tettest.

6. Dinamikus csíkozás felülvizsgálva

A java.util.concurrent.atomic.LongAdder egy nagy átbocsátású atomszámláló megvalósítás. Ahelyett, hogy csak egy számlálót használna, egy tömböt használ a memóriaverseny elosztására közöttük. Így felülmúlja az egyszerű atomokat, mint pl AtomicLong erősen vitatott alkalmazásokban.

A Csíkos64 osztály felelős a memóriaversenyek ilyen megoszlásáért, és ez így vanosztály végrehajtja a számlálók tömbjét:

@ jdk.internal.vm.annotation.Contended static final class Cell {volatile long value; // kihagyva} tranziens volatile Cell [] cellák;

Minden egyes Sejt összefoglalja az egyes számlálók részleteit. Ez a megvalósítás lehetővé teszi, hogy a különböző szálak különböző memóriahelyeket frissítsenek. Mivel állapotok tömbjét (vagyis csíkokat) használjuk, ezt az elképzelést dinamikus csíkozásnak nevezzük. Érdekes módon, Csíkos64 elnevezése erről az ötletről és arról a tényről szól, hogy 64 bites adattípusokon működik.

Mindenesetre a JVM ezeket a számlálókat egymáshoz rendelheti a kupacban. Vagyis néhány ilyen számláló ugyanabban a gyorsítótárban lesz. Ebből kifolyólag, az egyik számláló frissítése érvénytelenítheti a közeli számlálók gyorsítótárát.

A legfontosabb elvitel itt az, hogy a dinamikus csíkozás naiv megvalósítása szenvedni fog a hamis megosztástól. Azonban, elegendő betét hozzáadásával az egyes számlálók köré, meggyőződhetünk arról, hogy mindegyik a gyorsítótárában található-e, megakadályozva ezzel a hamis megosztást:

Mint kiderült, a @jdk.internal.vm.annotation.Contended ennek a kitöltésnek a feliratozásáért felelős.

A kérdés csak az, miért nem működött ez a felirat a másolással beillesztett megvalósításban?

7. Találkozz @Contended

A Java 8 bemutatta a sun.misc.Folytatva megjegyzés (a Java 9 újracsomagolta a jdk.internal.vm.jegyzet csomag) a hamis megosztás megakadályozása érdekében.

Alapvetően, ha egy mezőt ezzel a feljegyzéssel jegyzünk fel, akkor a HotSpot JVM néhány betétet fog hozzáadni az annotált mező körül. Így megbizonyosodhat arról, hogy a mező a saját gyorsítótárában található-e. Sőt, ha egy egész osztályt feljegyezünk ezzel a megjegyzéssel, akkor a HotSopt JVM ugyanazt a kitöltést egészíti ki az összes mező előtt.

A @Contended az annotációt maga a JDK használja. Tehát alapértelmezésben nem befolyásolja a nem belső objektumok memória elrendezését. Ez az oka annak, hogy a másolással beillesztett összegzőnk nem teljesít olyan jól, mint a beépített.

A csak belső korlátozás eltávolításához használhatjuk a -XX: -RestrictContended tuning zászló a referenciaérték újrafuttatásakor:

Benchmark Mode Cnt Score Hibaegységek FalseSharing.builtin thrpt 40 541148225.959 ± 18336783.899 ops / s FalseSharing.custom thrpt 40 546022431.969 ± 16406252.364 ops / s

Amint azt fentebb bemutattuk, most a benchmark eredmények sokkal közelebb vannak, és a különbség valószínűleg csak egy kis zaj.

7.1. Párnázás mérete

Alapértelmezés szerint a @Contended az annotáció 128 bájt kitöltést ad hozzá. Ez elsősorban azért van, mert a gyorsítótár sorának mérete sok modern processzorban 64/128 bájt körül van.

Ez az érték azonban a -XX: ContendedPaddingWidth tuning zászló. Az írás kezdetén ez a zászló csak 0 és 8192 közötti értékeket fogad el.

7.2. A @Contended

Lehetőség van a @Contended hatása a -XX: -EnableContended hangolás. Ez hasznosnak bizonyulhat, ha a memória kiemelkedő, és megengedhetjük magunknak, hogy elveszítsünk egy kis (és néha sok) teljesítményt.

7.3. Használjon tokokat

Első megjelenése után a @Contended az annotációt elég széles körben alkalmazták a JDK belső adatstruktúráiban való hamis megosztás megakadályozására. Íme néhány figyelemre méltó példa az ilyen megvalósításokra:

  • A Csíkos64 osztály nagy áteresztőképességű számlálók és akkumulátorok megvalósításához
  • A cérna osztály a hatékony véletlenszám-generátorok megvalósításának megkönnyítése érdekében
  • A ForkJoinPool munkalopási sor
  • A ConcurrentHashMap végrehajtás
  • A. - ban használt kettős adatstruktúra Hőcserélő osztály

8. Következtetés

Ebben a cikkben azt láttuk, hogy a hamis megosztás néha hogyan okozhat kontraproduktív hatást a többszálas alkalmazások teljesítményére.

A dolgok konkrétabbá tétele érdekében megtettük a benchmarkot LongAdder megvalósítását a Java-ban annak másolatával szemben, és eredményeit kiindulópontként használta a teljesítményvizsgálatainkhoz.

Ezenkívül a perf eszköz, amely összegyűjt néhány statisztikát egy futó alkalmazás teljesítménymutatóiról a Linux rendszeren. További példák megtekintéséhez perf, erősen ajánlott elolvasni Branden Greg blogját. Ezenkívül az eBPF, amely a Linux Kernel 4.4-es verziójától elérhető, számos nyomkövetési és profilozási szcenárióban is hasznos lehet.

Szokás szerint az összes példa elérhető a GitHubon.