מדריך לשיתוף שווא ו- @Contended

1. סקירה כללית

במאמר זה נראה כיצד לפעמים שיתוף כוזב יכול להפוך נגדי ריבוי הליכים.

ראשית, נתחיל במעט בתורת המטמון ובמקום המרחבי. ואז נכתוב מחדש את LongAdder שירות מקביל ומידו אותו נגד java.util.concurrent יישום. במהלך המאמר נשתמש בתוצאות המבחן ברמות שונות כדי לחקור את ההשפעה של שיתוף כוזב.

החלק הקשור ל- Java במאמר תלוי במידה רבה בפריסת הזיכרון של האובייקטים. מכיוון שפרטי פריסה אלה אינם חלק ממפרט ה- JVM ונותרים על פי שיקול דעתו של המיישם, אנו נתמקד רק ביישום JVM ספציפי אחד: ה- JVM של HotSpot. אנו עשויים גם להשתמש במונחי JVM ו- HotSpot JVM להחלפה לאורך המאמר.

2. קו מטמון וקוהרנטיות

מעבדים משתמשים ברמות מטמון שונות - כאשר מעבד קורא ערך מהזיכרון הראשי, הוא עשוי לשמור את הערך במטמון כדי לשפר את הביצועים.

כפי שמתברר, רוב המעבדים המודרניים לא רק שומרים את הערך המבוקש אלא גם שומרים עוד כמה ערכים סמוכים. אופטימיזציה זו מבוססת על רעיון היישוב המרחבי ויכולה לשפר משמעותית את הביצועים הכוללים של יישומים. במילים פשוטות, מטמונים של מעבדים עובדים במונחים של קווי מטמון, במקום ערכים ניתנים למטמון.

כאשר מספר מעבדים פועלים על אותם מיקומי זיכרון או בקרבת מקום, הם עשויים לשתף אותו קו מטמון. במצבים כאלה, חשוב לשמור על מטמונים חופפים אלה בליבות שונות בקנה אחד עם השני. הפעולה של שמירה על עקביות כזו נקראת קוהרנטיות במטמון.

ישנם לא מעט פרוטוקולים לשמירה על קוהרנטיות המטמון בין ליבות המעבד. במאמר זה נדבר על פרוטוקול MESI.

2.1. פרוטוקול MESI

בפרוטוקול MESI, כל קו מטמון יכול להיות באחת מארבע המצבים המובהקים האלה: שונה, בלעדי, משותף או לא חוקי. המילה MESI היא ראשי התיבות של מדינות אלה.

כדי להבין טוב יותר כיצד פועל פרוטוקול זה, בוא נעבור על דוגמה. נניח ששתי ליבות עתידות לקרוא ממיקומי זיכרון סמוכים:

הליבה א קורא את הערך של א מהזיכרון הראשי. כפי שמוצג לעיל, ליבה זו מביאה עוד כמה ערכים מהזיכרון ומאחסנת אותם בשורת מטמון. ואז זה מסמן את קו המטמון כ- בִּלעָדִי מאז הליבה א הוא הליבה היחידה הפועלת בשורת המטמון הזו. מעתה ואילך, במידת האפשר, הליבה הזו תמנע מגישה לא יעילה לזיכרון על ידי קריאה משורת המטמון במקום זאת.

לאחר זמן מה, הליבה ב מחליט גם לקרוא את הערך של ב מהזיכרון הראשי:

מאז א ו ב קרובים זה לזה ונמצאים באותו קו מטמון, שתי ליבות יתייגו את שורות המטמון כ- מְשׁוּתָף.

עכשיו, בואו נניח את הליבה הזו א מחליט לשנות את הערך של א:

הליבה א מאחסן שינוי זה רק במאגר החנות שלו ומסמן את קו המטמון שלו כ- שונה. כמו כן, היא משדרת את השינוי הזה עד היסוד ב, וליבה זו תסמן בתורו את קו המטמון שלה כ- לא חוקי.

כך מעבדים שונים מוודאים כי המטמון שלהם קוהרנטי זה עם זה.

3. שיתוף שווא

עכשיו, בואו נראה מה קורה כאשר הליבה ב מחליט לקרוא מחדש את הערך של ב. מכיוון שערך זה לא השתנה לאחרונה, אנו עשויים לצפות לקריאה מהירה משורת המטמון. עם זאת, אופייה של ארכיטקטורת המעבדים המשותפת מבטל את הציפייה הזו במציאות.

כפי שצוין קודם לכן, כל קו המטמון חולק בין שתי הליבות. מאז קו המטמון לליבה ב הוא לא חוקי עכשיו, זה צריך לקרוא את הערך ב מהזיכרון הראשי שוב:

כפי שמוצג לעיל, קריאת אותו דבר ב ערך מהזיכרון הראשי הוא לא חוסר היעילות היחיד כאן. גישה לזיכרון זו תכריח את הליבה א לשטוף את חיץ החנות שלה, בתור הליבה ב צריך לקבל את הערך העדכני ביותר. לאחר שטיפה ואחזור הערכים, שתי ליבות יגיעו לגרסת קו המטמון האחרונה שתויגה ב- מְשׁוּתָף ציין שוב:

אז זה מטיל החמצה של מטמון לליבה אחת ושטיפה מוקדמת למאגר אחר, למרות ששתי הליבות לא פעלו באותו זיכרון.. תופעה זו, המכונה שיתוף כוזב, עלולה לפגוע בביצועים הכוללים, במיוחד כאשר קצב החמצת המטמון גבוה. ליתר דיוק, כאשר קצב זה גבוה, המעבדים יגיעו כל הזמן לזיכרון הראשי במקום לקרוא מהמטמונים שלהם.

4. דוגמה: פסים דינמיים

כדי להדגים כיצד שיתוף כוזב יכול להשפיע על תפוקת היישומים או השהיה שלהם, אנו הולכים לרמות בסעיף זה. בואו נגדיר שתי כיתות ריקות:

מחלקה מופשטת Striped64 מרחיבה את מספר {} המחלקה הציבורית LongAdder מרחיבה את Striped64 מיישמת ניתן לבצע סידרליזציה {}

כמובן, שיעורים ריקים אינם כל כך שימושיים, אז בואו להעתיק ולהדביק בהם קצת היגיון.

בשביל שלנו 64. פסים בכיתה, אנחנו יכולים להעתיק הכל מה- java.util.concurrent.atomic.Striped64 בכיתה והדבק אותה בכיתה שלנו. אנא הקפד להעתיק את יְבוּא גם הצהרות. כמו כן, אם אנו משתמשים ב- Java 8, עלינו לוודא להחליף כל שיחה אליה sun.misc.Unsafe.getUnsafe () שיטה להתאמה אישית:

פרטי לא סטטי לא בטוח GetUnsafe () {נסה {שדה שדה = Unsafe.class.getDeclaredField ("theUnsafe"); field.setAccessible (נכון); return (לא בטוח) field.get (null); } לתפוס (חריג e) {לזרוק RuntimeException חדש (ה); }}

אנחנו לא יכולים להתקשר ל sun.misc.Unsafe.getUnsafe () מה- classloader של היישומים שלנו, אז עלינו לרמות שוב בשיטה הסטטית הזו. אולם נכון ל- Java 9, אותו לוגיקה מיושם באמצעות VarHandles, כך שלא נצטרך לעשות שם שום דבר מיוחד, ורק העתקת הדבקות פשוטה תספיק.

בשביל ה LongAdder בכיתה, בואו נעתיק הכל מה- java.util.concurrent.atomic.LongAdder בכיתה ולהדביק אותה לשלנו. שוב, עלינו להעתיק את יְבוּא גם הצהרות.

עכשיו, בואו נבדוק בין שתי המעמדות הללו זו נגד זו: המנהג שלנו LongAdder ו java.util.concurrent.atomic.LongAdder.

4.1. אמת מידה

כדי לשוות מידה לשיעורים אלה זה מול זה, בואו נכתוב מדד JMH פשוט:

@State (Scope.Benchmark) מחלקה ציבורית FalseSharing {פרטי java.util.concurrent.atomic.LongAdder builtin = חדש java.util.concurrent.atomic.LongAdder (); פרטי LongAdder מותאם אישית = LongAdder חדש (); @Benchmark חלל ציבורי builtin () {builtin.increment (); } @Benchmark ציבורי ריק מותאם אישית () {custom.increment (); }}

אם אנו מריצים אמת מידה זו עם שתי מזלגות ו -16 חוטים במצב תפיסת תפוקה (המקבילה למעבר -bm thrpt -f 2 -t 16 ″ ארגומנטים), ואז JMH ידפיס נתונים סטטיסטיים אלה:

יחידות שגיאה במצב ציון Cnt יחידות FalseSharing.builtin thrpt 40 523964013.730 ± 10617539.010 ops / s FalseSharing.custom thrpt 40 112940117.197 ± 9921707.098 ops / s

התוצאה בכלל לא הגיונית. היישום המובנה של JDK מגמד את הפתרון שהודבק להעתקה בכמעט 360% תפוקה רבה יותר.

בואו נראה את ההבדל בין השהיות:

יחידות שגיאה של ציון מצב מידוד FalseSharing.builtin avgt 40 28.396 ± 0.357 ns / op FalseSharing.custom avgt 40 51.595 ± 0.663 ns / op

כפי שמוצג לעיל, לפיתרון המובנה יש גם מאפייני חביון טובים יותר.

כדי להבין טוב יותר מה כל כך שונה ביישומים זהים לכאורה, בואו לבדוק כמה דלפקי ניטור ביצועים ברמה נמוכה.

5. אירועים מושלמים

כדי להכשיר אירועי מעבד ברמה נמוכה, כגון מחזורים, מחזורי דוכן, הוראות לכל מחזור, עומסי מטמון / החמצת מטמון או עומסי זיכרון / חנויות, אנו יכולים לתכנת רושמי חומרה מיוחדים על המעבדים.

כפי שמתברר, כלים כמו מושלם אוֹ eBPF כבר משתמשים בגישה זו כדי לחשוף מדדים שימושיים. נכון לינוקס 2.6.31, perf הוא הפרופילר הסטנדרטי של לינוקס המסוגל לחשוף מונים או PMC מועילים לניטור ביצועים.

לכן, אנו יכולים להשתמש באירועי perf כדי לראות מה קורה ברמת המעבד כאשר אנו מריצים כל אחד משני המדדים הללו. למשל, אם נפעיל:

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

Perf יגרום ל- JMH להריץ את אמות המידה מול הפתרון שהודבק להעתיק ולהדפיס את הנתונים הסטטיסטיים:

161657.133662 שעון משימות (msec) # 3.951 מעבדים מנוצלים 9321 מתגי הקשר # 0.058 K / sec 185 מעברי העברה # 0.001 K / sec 20514 תקלות עמוד # 0.127 K / sec 0 מחזורים # 0.000 GHz 219476182640 הוראות 44787498110 סניפים # 277.052 M / שנייה 37831175 החמצת סניפים # 0.08% מכל הסניפים 91534635176 L1-dcache-loads # 566.227 M / sec 1036004767 L1-dcache-load-misses # 1.13% מכל ההיטים L1-dcache

ה החמצת עומס L1-dcache שדה מייצג את מספר החמצות המטמון עבור מטמון הנתונים L1. כפי שמוצג לעיל, פתרון זה נתקל בכמיליארד החמצות מטמון (1,036,004,767 ליתר דיוק). אם נאסוף את אותם נתונים סטטיסטיים בגישה המובנית:

161742.243922 שעון משימות (msec) # 3.955 מעבדים השתמשו ב 9041 מתגי הקשר # 0.056 K / sec 220 מעברי העברה # 0.001 K / sec 21678 עמוד תקלות # 0.134 K / sec 0 מחזורים # 0.000 GHz 692586696913 הוראות 138097405127 סניפים # 853.812 M / שניה 39010267 החמצת ענף # 0.03% מכל הסניפים 291832840178 L1-dcache-loads # 1804.308 M / sec 120239626 L1-dcache-load-misses # 0.04% מכל ההיטים של L1-dcache

היינו רואים שהוא נתקל בהרבה פחות החמצות מטמון (120,239,626 ~ 120 מיליון) בהשוואה לגישה המותאמת אישית. לכן, המספר הגבוה של החמצות במטמון עשוי להיות האשם להבדל כזה בביצועים.

בואו נתעמק עוד יותר בייצוג הפנימי של LongAdder למצוא את האשם בפועל.

6. פסים דינמיים מחדש

ה java.util.concurrent.atomic.LongAdder הוא יישום נגד אטומי עם תפוקה גבוהה. במקום להשתמש רק במונה אחד, הוא משתמש במערך שלהם כדי להפיץ את מחלוקת הזיכרון ביניהם. בדרך זו, היא תעלה על האטומים הפשוטים כגון AtomicLong ביישומים מתמודדים ביותר.

ה 64. פסים המחלקה אחראית להפצה זו של מחלוקת זיכרון, וכך זהמחלקה מיישמת את מערך הדלפקים האלה:

@ jdk.internal.vm.annotation. מחלקה סופית סטטית מתמשכת תא {ערך ארוך נדיף; // הושמט} תאים נדיפים חולפים חולפים [];

כל אחד תָא עוטף את הפרטים עבור כל מונה. יישום זה מאפשר לשרשורים שונים לעדכן מיקומי זיכרון שונים. מכיוון שאנחנו משתמשים במערך (כלומר פסים) של מצבים, הרעיון הזה נקרא פסים דינמיים. מעניין, 64. פסים נקרא על שם רעיון זה והעובדה שהוא עובד על סוגי נתונים של 64 סיביות.

בכל מקרה, ה- JVM עשוי להקצות את הדלפקים האלה זה לזה בערימה. כלומר, כמה מונים אלה יהיו באותו קו מטמון. לָכֵן, עדכון מונה אחד עלול לפסול את המטמון עבור מונים בקרבת מקום.

המפתח העיקרי כאן הוא, יישום נאיבי של פסים דינמיים יסבול משיתוף שווא. למרות זאת, על ידי הוספת ריפוד מספיק סביב כל דלפק, אנו יכולים לוודא שכל אחד מהם שוכן על קו המטמון שלו, ובכך למנוע את שיתוף השווא:

כפי שמתברר, @jdk.internal.vm.annotation.Contended ההערה אחראית על הוספת ריפוד זה.

השאלה היחידה היא מדוע ההערה הזו לא עבדה ביישום המודבק להעתקה?

7. נפגשים @ מתוקן

ג'אווה 8 הציגה את sun.misc. נמשך ביאור (Java 9 ארזה אותו מחדש תחת jdk.internal.vm.annotation חבילה) כדי למנוע שיתוף כוזב.

בעיקרון, כאשר אנו מציינים שדה עם הערה זו, ה- HotVot JVM יוסיף כמה ריפודים סביב השדה המבואר. בדרך זו, היא יכולה לוודא שהשדה ממוקם על קו המטמון שלו. יתר על כן, אם אנו מציינים כיתה שלמה עם ההערה הזו, ה- JVM של HotSopt יוסיף את אותה הריפוד לפני כל השדות.

ה @ מתוקן ההערה נועדה לשמש באופן פנימי על ידי ה- JDK עצמו. אז כברירת מחדל, זה לא משפיע על פריסת הזיכרון של אובייקטים שאינם פנימיים. זו הסיבה לכך שהתוסף שלנו שהודבק בהעתקה לא מבצע ביצועים טובים כמו זה המובנה.

כדי להסיר מגבלה פנימית זו בלבד, אנו יכולים להשתמש ב- -XX: -RestrictContended כוונון הדגל בעת הפעלה מחדש של אמת המידה:

יחידות שגיאה במצב ציון Cnt יחידות FalseSharing.builtin thrpt 40 541148225.959 ± 18336783.899 ops / s FalseSharing.custom thrpt 40 546022431.969 ± 16406252.364 ops / s

כפי שמוצג לעיל, כעת תוצאות המבחן הרבה יותר קרובות, וההבדל הוא כנראה רק קצת רעש.

7.1. גודל ריפוד

כברירת מחדל, ה- @ מתוקן ביאור מוסיף 128 בתים של ריפוד. זה בעיקר בגלל שגודל קו המטמון במעבדים מודרניים רבים נע סביב 64/128 בתים.

עם זאת, ניתן להגדיר ערך זה באמצעות ה- -XX: ContendedPaddingWidth כוונון דגל. נכון לכתיבת שורות אלה, דגל זה מקבל רק ערכים בין 0 ל 8192.

7.2. השבתת ה- @ מתוקן

אפשר גם להשבית את @ מתוקן השפעה באמצעות -XX: -EnableContended כִּונוּן. זה עשוי להיות שימושי כאשר הזיכרון נמצא בפרמיה ואנחנו יכולים להרשות לעצמנו לאבד ביצועים מעט (ולפעמים הרבה).

7.3. השתמש במקרים

לאחר שחרורו הראשון, @ מתוקן ביאור נעשה שימוש נרחב למדי כדי למנוע שיתוף כוזב במבני הנתונים הפנימיים של JDK. להלן מספר דוגמאות בולטות ליישומים כאלה:

  • ה 64. פסים בכיתה ליישום דלפקים וצברים עם תפוקה גבוהה
  • ה פְּתִיל בכיתה כדי להקל על יישום מחוללי מספרים אקראיים יעילים
  • ה ForkJoinPool תור לגניבת עבודה
  • ה ConcurrentHashMap יישום
  • מבנה הנתונים הכפול המשמש ב- חַלְפָן מעמד

8. מסקנה

במאמר זה ראינו כיצד לפעמים שיתוף כוזב עלול לגרום להשפעות נגדיות על הביצועים של יישומים מרובי הברגה.

כדי להפוך את העניינים לקונרטיים יותר, עשינו מידות על ה LongAdder יישום ב- Java כנגד העותק שלו והשתמש בתוצאותיו כנקודת מוצא לחקירות הביצוע שלנו.

כמו כן, השתמשנו ב- מושלם כלי לאיסוף נתונים סטטיסטיים על מדדי הביצועים של יישום פועל ב- Linux. כדי לראות דוגמאות נוספות של מושלם, מומלץ לקרוא את הבלוג של ברנדן גרג. יתר על כן, eBPF, זמין החל מגרעין לינוקס גרסת 4.4, יכול להיות שימושי גם בתרחישים רבים של מעקב ורישום פרופיל.

כרגיל, כל הדוגמאות זמינות ב- GitHub.


$config[zx-auto] not found$config[zx-overlay] not found