פריימר SMP ל-Android

גרסאות Android 3.0 ואילך מותאמות במיוחד לתמיכה של ארכיטקטורות מרובות מעבדים. במסמך הזה נסביר על בעיות שעשויות להתעורר כשכותבים קוד עם כמה שרשורים למערכות סימטריות עם כמה מעבדים בשפות C,‏ C++‎ ושפת התכנות Java‏ (שנקראת פשוט Java, למען הפשטות). היא מיועדת לשמש כמדריך למפתחים של אפליקציות ל-Android, אבל היא לא מקיפה את הדיון בנושא.

מבוא

SMP הוא ראשי תיבות של 'Symmetric Multi-Processor' (מעבד סימטרי רב-מעבדי). הוא מתאר תכנון אילו שתי ליבות זהות או יותר של המעבד (CPU) חולקות גישה לזיכרון הראשי. עד לפני כמה שנים, כל מכשירי Android היו עם מעבד יחיד (UP).

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

רוב מכשירי Android שנמכרים כיום, מבוססים על עיצובי SMP, כך שזה עשוי להיות קצת יותר מורכב עבור מפתחי התוכנה. תנאי המרוץ בתוכנית עם ריבוי תהליכונים (multi-threads), לא תגרום לבעיות נראות בחד-מעבד, אבל עלול להיכשל באופן קבוע כששני שרשורים או יותר פועלים בו-זמנית על ליבות שונות. בנוסף, יכול להיות שהקוד חשוף יותר או פחות לכשלים כשמפעילים אותו של הארכיטקטורות של המעבדים, או אפילו בהטמעות שונות של של הארכיטקטורה, קוד שנבדק ביסודיות ב-x86 עלול להשתבש בצורה לא טובה ב-ARM. הקוד עלול להתחיל להיכשל כשהוא עובר הידור מחדש באמצעות מהדר (compiler) מודרני יותר.

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

מודלים של עקביות בזיכרון: למה קובצי SMP שונים קצת

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

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

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

המודל שרוב המתכנתים רגילים אליו הוא רצף מודל עקביות הדרגתי, שמתואר כך (Adve Gharachorloo):

  • נראה שכל פעולות הזיכרון מבוצעות בנפרד
  • נראה שכל הפעולות בשרשור אחד מתבצעות לפי הסדר שמתואר בתוכנה של אותו מעבד.

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

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

כדי להמחיש את הנקודות האלה כדאי להתייחס לקטעי קוד קטנים, נקראות בדרך כלל בדיקות litmus.

הנה דוגמה פשוטה, שבה הקוד פועל בשני חוטים:

שרשור 1 שרשור 2
A = 3
B = 5
reg0 = B
reg1 = A

בדוגמה הזו ובכל הדוגמאות העתידיות ללמידה, מיקומי זיכרון מיוצגים על ידי באותיות רישיות (A, B, C) ורישומי מעבד (CPU) מתחילים ב-'reg'. כל הזיכרון הוא בהתחלה הוא אפס. ההוראות מתבצעות מלמעלה למטה. כאן, שרשור 1 שומרת את הערך 3 במיקום A ואז את הערך 5 במיקום B. אשכול 2 טוען את הערך ממיקום B ל-reg0, ואז טוען את הערך ממיקום A ל-reg1. (שימו לב שאנחנו כותבים בסדר אחד וקוראים בסדר אחר).

ההנחה היא ש-thread 1 ו-thread 2 פועלים בליבות מעבד (CPU) שונות. שלך צריך תמיד להניח את ההנחה הזו כשחושבים על עם קוד מרובה-שרשורים.

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

הרשמות מדינות
reg0=5, reg1=3 אפשרי (שרשור 1 הופעל ראשון)
reg0=0, reg1=0 אפשרי (שרשור 2 הופעל ראשון)
reg0=0, reg1=3 אפשרית (הפעלה בו-זמנית)
reg0=5, reg1=0 אף פעם

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

למעבדי Uni, כולל x86 ו-ARM, הם בדרך כלל עקביים ברצף. נראה ששרשורים פועלים בצורה משולבת, בזמן העברת הליבה של מערכת ההפעלה ביניהם. רוב מערכות ה-SMP, כולל x86 ו-ARM, אינן עקביות ברצף. לדוגמה, לרוב החומרה מאחסנת ב-buffer שמירות בדרך לזיכרון, כדי שהן לא יגיעו לזיכרון באופן מיידי ויהפכו להיות גלויות ליבות אחרות.

הפרטים משתנים באופן משמעותי. לדוגמה, x86 אבל לא ברצף באופן עקבי, עדיין מבטיח ש-reg0 = 5 ו-reg1 = 0 יישארו בלתי אפשריים. החנויות בתהליך אגירת נתונים, אבל ההזמנה שלהן נשמרת. ב-ARM, לעומת זאת, לא. הסדר של חנויות בתהליך אגירת נתונים הוא לא מתוחזק, והחנויות לא עשויות להגיע לכל שאר הליבה באותו זמן. ההבדלים האלה חשובים להרכבת מתכנתים. עם זאת, כפי שנראה בהמשך, מתכנתים ב-C,‏ C++‎ או Java יכולים וצריכים לתכנת באופן שמסתיר הבדלים כאלה בארכיטקטורה.

עד עכשיו, הנחנו באופן לא מציאותי שרק החומרה היא זו שמשנה את סדר ההוראות. בפועל, המהדר גם משנה את הסדר של ההוראות כדי לשפר את הביצועים. בדוגמה שלנו, המהדר יכול להחליט שקוד מאוחר יותר ב-Thread 2 נזקק לערך של reg1 לפני שנזקק ל-reg0, ולכן הוא יטען את reg1 קודם. או שיכול להיות שקוד קודם כלשהו כבר טען את A, והמהדר (compiler) יכול להחליט להשתמש שוב בערך הזה במקום לטעון את A שוב. בכל מקרה, יכול להיות שצריך לשנות את הסדר של הטעינות ל-reg0 ול-reg1.

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

מכיוון שמהדרים יכולים גם לשנות את הסדר של הגישה לזיכרון, הבעיה הזו לא חדשה למעבדים מרובי ליבות. אפילו במעבד מיחידה, מהדר יכול לסדר מחדש את הטעינות את reg0 ואת reg1 בדוגמה שלנו, ואפשר לתזמן את Thread 1 בין הוראות שמסודרים מחדש. אבל אם המהדר שלנו לא יבצע הזמנה מחדש, ייתכן אף פעם לא תבחינו בבעיה הזו. ברוב מעבדי ה-SMP של ARM, גם בלי סידור מחדש על ידי המהדר, סביר להניח שאפשר יהיה לראות את הסדר מחדש, אולי אחרי מספר גדול מאוד של פעולות מוצלחות. אלא אם תכנות בהרכבה היא מגדילה את הסבירות שתראו בעיות שם לאורך הדרך.

תוכניות ללא מרוץ נתונים

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

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

מהו 'מרוץ נתונים'?

מרוץ נתונים מתרחש כאשר לפחות שני שרשורים ניגשים בו-זמנית את אותם נתונים רגילים, ולפחות אחד מהם משנה אותם. לפי "רגילים" "נתונים" אנחנו מתכוונים למשהו שהוא לא אובייקט ספציפי שמיועד לתקשורת בשרשור. השתקה, משתני תנאי, Java תנודתיים, או אובייקטים אטומיים של C++ אינם נתונים רגילים, והגישה אליהם מורשים להתחרות. למעשה, הם משמשים למניעת מרוץ נתונים אובייקטים.

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

שרשור 1 שרשור 2
if (A) B = true if (B) A = true

מכיוון שאין סידור מחדש של הפעולות, שני התנאים יקבלו את הערך False אף משתנה לא מתעדכן אף פעם. לכן לא יכול להיות מרוץ נתונים. אין צורך לחשוב מה יקרה אם הטעינה מ-A והאחסון ב-B ב-Thread 1 יבוצעו בסדר שונה. המהדר לא מורשה לשנות את סדר השרשורים 1 על ידי שכתוב שלו כ-"B = true; if (!A) B = false". זה היה כמו הכנת נקניקייה באמצע העיר באור יום.

מרוצי נתונים מוגדרים באופן רשמי בסוגים מובנים בסיסיים כמו מספרים שלמים הפניות או סמנים. הקצאה לint בו-זמנית אין ספק שקריאת הנתונים בשרשור אחר היא מרוץ נתונים. אבל גם התג C++ ספרייה רגילה ספריות האוספים של Java נכתבות כדי לאפשר לכם לחשוב גם על מרוצי נתונים ברמת הספרייה. הם מבטיחים לא ליצור מרוצי נתונים אלא אם יש גישה בו-זמנית לאותו מאגר, לפחות אחד שמעדכן אותו. העדכון של set<T> בשרשור אחד מתבצע בזמן שבו ניתן לקרוא אותו בו-זמנית, לספרייה תהיה גם אפשרות להציג מרוץ נתונים, ולכן אפשר להתייחס אליו באופן לא רשמי כ"מרוץ נתונים ברמת הספרייה". לעומת זאת, מתבצע עדכון של set<T> אחד בשרשור אחד, בזמן קריאה מודל אחר, לא מוביל למרוץ נתונים, כי מבטיח שלא תיצור מרוץ נתונים (ברמה נמוכה) במקרה הזה.

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

הימנעות ממרוץ נתונים

בשפות תכנות מודרניות יש כמה מנגנוני סנכרון כדי למנוע מרוץ נתונים. הכלים הבסיסיים ביותר הם:

נעילה או השתקה
השתקה (C++11 std::mutex, או pthread_mutex_t), או ניתן להשתמש בבלוקים של synchronized ב-Java כדי להבטיח קטע הקוד לא פועל בו-זמנית עם קטעי קוד אחרים שניגשים אותם נתונים. נתייחס למתקנים האלה ולמתקנים דומים באופן כללי בתור "מנעולים". נעילה ספציפית בעקביות לפני גישה לשיתוף משותף את מבנה הנתונים ולפרסם אותו לאחר מכן, כדי למנוע מרוץ תהליכים בנתונים בגישה את מבנה הנתונים. הוא גם מוודא שהעדכונים והגישה הם אטומיים, כלומר לא עדכון אחר במבנה הנתונים יכול לרוץ באמצע. זה מצדיק ללא ספק, הכלי הנפוץ ביותר למניעת מרוצי נתונים. השימוש ב-Java synchronized בלוקים או C++ lock_guard או unique_lock מוודאים שהנעילות משוחררות בצורה תקינה אירוע חריג.
משתנים נדיפים/אטומיים
ב-Java יש volatile שדות שתומכים בגישה בו-זמנית בלי להציג מרוצי נתונים. מאז 2011, התמיכה של C ו-C++ atomic משתנים ושדות עם סמנטיקה דומה. הנושאים האלה בדרך כלל קשה יותר להשתמש בה מאשר נעילות, מכיוון שהן רק מבטיחות גישות בודדות למשתנה יחיד הן אטומיות. (ב-C++ זה קורה בדרך כלל כוללת פעולות פשוטות של קריאה-שינוי-כתיבה, כמו מעברים במרווחים. ג'אווה דורש הפעלות שיטה מיוחדות לשם כך). בניגוד למנעולים, אי אפשר להשתמש במשתני volatile או atomic ישירות כדי למנוע משרשראות קוד ארוכות יותר להפריע לשרשראות אחרות.

חשוב לציין של-volatile יש משמעויות שונות מאוד ב-C++‎ וב-Java. ב-C++‏, volatile לא מונע מירוצי נתונים, אבל בקוד ישן יותר הוא משמש לרוב כפתרון זמני לבעיית היעדר אובייקטים מסוג atomic. פעולה זו כבר לא מומלצת. באזור C++, צריך להשתמש ב-atomic<T> למשתנים שיכולים להיות בו-זמנית שאפשר לגשת אליהם מכמה שרשורים. C++ volatile מיועד של מכשירים וכדומה.

משתני C/C++ atomic או משתני volatile של Java יכול לשמש למניעת מרוץ נתונים במשתנים אחרים. אם flag הוא הוצהר שהסוג atomic<bool> או atomic_bool(C/C++ ) או volatile boolean (Java), והוא בהתחלה לא נכון, אז קטע הקוד הבא הוא ללא מרוץ נתונים:

שרשור 1 שרשור 2
A = ...
  flag = true
while (!flag) {}
... = A

מכיוון ששרשור 2 ממתין להגדרת flag, הגישה אל A ב-thread 2 צריך להתרחש אחרי, ולא בו-זמנית, עם למשימה A בשרשור 1. לכן אין מרוץ נתונים ב- A המרוץ בתאריך flag לא נחשב כמרוץ נתונים, כי גישות תנודתיות/אטומיות הן לא 'גישה רגילה לזיכרון'.

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

הדוגמה הקודמת לא כוללת תחרות על נתונים, אבל בדרך כלל נעילת קבצים בשילוב עם Object.wait() ב-Java או משתני תנאי ב-C/C++ מספקים פתרון טוב יותר שלא כרוך בהמתנה בלולאה תוך שחיקה של הסוללה.

מה קורה כשסידור מחדש של הזיכרון מופיע

בדרך כלל, תכנות ללא מרוץ נתונים חוסך לנו את הצורך להתמודד באופן מפורש עם בעיות של סידור מחדש של הגישה לזיכרון. עם זאת, יש כמה מקרים שבו ניתן לראות את השינוי מחדש:
  1. אם בתוכנית יש באג שגורם לתחרות נתונים לא מכוונת, יכול להיות שתראו טרנספורמציות של מעבדים וחומרה, וההתנהגות של התוכנית עשויה להיות מפתיעה. לדוגמה, אם שכחנו להצהיר על flag כ-volatile בדוגמה הקודמת, יכול להיות ש-Thread 2 יראה A ללא איפוס. לחלופין, המהדר עשוי להחליט שהדגל לא יכול עשוי להשתנות במהלך הלולאה של Thread 2 ולהפך את התוכנה
    שרשור 1 שרשור 2
    A = ...
      flag = true
    reg0 = דגל; בזמן (!reg0) {}
    ... = A
    במהלך ניפוי הבאגים, יכול להיות שתראו שהלולאה תימשך לנצח למרות את העובדה ש-flag נכון.
  2. C++ מספק אפשרויות להירגעות באופן מפורש עקביות ברצף גם אם אין מרוצים. פעולות אטומיות יכול להכיל ארגומנטים מפורשים מסוג memory_order_.... באופן דומה, החבילה של java.util.concurrent.atomic מוגבלת יותר קבוצה של מתקנים דומים, בעיקר lazySet(). ו-Java מדי פעם מתכנתים משתמשים במרוץ נתונים מכוון להשגת תוצאות דומות. כל אלה משפרים את הביצועים באופן כללי העלות של מורכבות התכנות. נסביר עליהם בקצרה בהמשך.
  3. חלק מהקוד ב-C וב-C++ נכתב בסגנון ישן יותר, שלא תואם לחלוטין לתקני השפה הנוכחיים, שבו נעשה שימוש במשתני volatile במקום במשתני atomic, והסדרת הזיכרון אסורה במפורש על ידי הוספת גדרות או מחסומים. לשם כך נדרשת נימוק מפורש לגבי גישה סידור מחדש והבנה של מודלים של זיכרון חומרה. עדיין נעשה שימוש בסגנון תכנות כזה בליבה של Linux. לא מומלץ להשתמש בו באפליקציות חדשות ל-Android, וגם לא נדון בו בהמשך.

תרגול

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

מה לא לעשות בשפת C

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

C/C++ ו"תנודתיות"

הצהרות volatile ו-C++ הן כלי מיוחד מאוד. הם מונעים ממהדר אפשרות לשנות את הסדר שלהם או להסיר תוכן תנודתי מקבל גישה. האפשרות הזו יכולה להיות שימושית לקוד שמשתמש ברשמים של מכשירי חומרה, בזיכרון שממופה למספר מיקומים או בקשר ל-setjmp. אבל C ו-C++ volatile, בניגוד ל-Java volatile, לא מיועד לתקשורת בשרשור.

בשפות C ו-C++ יכול להיות שיתבצע שינוי בסדר הגישה לנתונים של volatile יחד עם הגישה לנתונים לא יציבים, ואין ערבויות לאטומיות. לכן לא ניתן להשתמש ב-volatile לשיתוף נתונים בין בקוד נייד, גם במעבד משני. בדרך כלל, ה-C volatile לא מונע מהחומרה לשנות את סדר הגישה, ולכן הוא שימושי עוד פחות בסביבות SMP עם מספר רב של שרשורים. זו הסיבה לכך שהתמיכה ב-C11 וב-C++11 atomic אובייקטים. במקום זאת, צריך להשתמש בהם.

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

דוגמאות

ברוב המקרים עדיף להשתמש בנעילה (כמו pthread_mutex_t או std::mutex ב-C++11) במקום בפעולה אטומית, אבל נשתמש בפעולה אטומית כדי להמחיש איך משתמשים בה במצב מעשי.

MyThing* gGlobalThing = NULL;  // Wrong!  See below.
void initGlobalThing()    // runs in Thread 1
{
    MyStruct* thing = malloc(sizeof(*thing));
    memset(thing, 0, sizeof(*thing));
    thing->x = 5;
    thing->y = 10;
    /* initialization complete, publish */
    gGlobalThing = thing;
}
void useGlobalThing()    // runs in Thread 2
{
    if (gGlobalThing != NULL) {
        int i = gGlobalThing->x;    // could be 5, 0, or uninitialized data
        ...
    }
}

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

הבעיה היא שאפשר לראות את החנות ב-gGlobalThing לפני אתחול השדות, בדרך כלל בגלל שהמהדר או מעבד הזמנה מחדש את החנויות ל-gGlobalThing ו thing->x. יכול להיות ששרשור אחר של thing->x נקרא לראות 5, 0 או אפילו נתונים לא מאותחלים.

הבעיה העיקרית כאן היא מרוץ נתונים ב-gGlobalThing. אם בשרשור 1 קוראים initGlobalThing() במהלך שרשור 2 שיחות useGlobalThing(), gGlobalThing יכולות להיות לקרוא בזמן שכותבים.

כדי לפתור את הבעיה, צריך להצהיר על gGlobalThing בתור משתנה אטומי. ב-C++11:

atomic<MyThing*> gGlobalThing(NULL);

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

מה לא לעשות ב-Java

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

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

בינתיים נישאר לפי המודל 'ללא מרוץ נתונים', שעבורו Java מספק הן בעצם אותן ערבויות כמו C ו-C++. שוב, השפה מספקת כמה פרימיטיביים שמקלים במפורש את העקביות הרציפה, במיוחד lazySet() ו-weakCompareAndSet() שיחות ב-java.util.concurrent.atomic. כמו במקרה של C ו-C++, נתעלם מהם בינתיים.

ההגדרה "מסונכרן" של Java ו"תנודתי" מילות מפתח

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

כפי שציינו למעלה, volatile T של Java הוא atomic<T> של C++11. גישות בו-זמנית אל מותר להשתמש בשדות volatile, והם לא מובילים למרוצי נתונים. מתעלמים מ-lazySet() ואחרים ומרוצי נתונים, התפקיד של ה-VM של Java הוא מוודאים שהתוצאה עדיין מופיעה ברצף.

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

יש הבדל אחד משמעותי ביחס ל-atomic ב-C++‎: אם כותבים volatile int x; ב-Java, ‏x++ זהה ל-x = x + 1. הפונקציה מבצעת טעינת אטום, מגדילה את התוצאה ומבצעת אחסון אטומי. שלא כמו C++, ההגדלה בכללותה היא לא אטומית. במקום זאת, פעולות במרווחים אטומיים נקבעות על ידי java.util.concurrent.atomic.

דוגמאות

הנה יישום פשוט שגוי של מונה מונוטוני: (Java) תיאוריה ותרגול: ניהול תנודתיות).

class Counter {
    private int mValue;
    public int get() {
        return mValue;
    }
    public void incr() {
        mValue++;
    }
}

נניח ש-get() ו-incr() נקראים מכמה מקורות ואנחנו רוצים לוודא שכל שרשור יראה את הספירה הנוכחית מתבצעת שיחה אל get(). הבעיה הבולטת ביותר היא mValue++ היא למעשה שלוש פעולות:

  1. reg = mValue
  2. reg = reg + 1
  3. mValue = reg

אם שני שרשורים מופעלים ב-incr() בו-זמנית, אחד אתה עלול לאבד את העדכונים. כדי להפוך את האטום האטומי, אנחנו צריכים להצהיר incr() 'מסונכרן'.

עם זאת, הוא עדיין לא תקין, במיוחד ב-SMP. עדיין יש מרוץ נתונים, ב-get() תהיה אפשרות לגשת אל mValue בו-זמנית incr(). במסגרת כללי Java, הקריאה get() יכולה להיות נראה שיש סדר שונה ביחס לקוד אחר. לדוגמה, אם נקרא המונה בשורה, ייתכן שהתוצאות לא יהיו עקביות כי הקריאות של get() ארגנו מחדש, באמצעות החומרה או מהדר (compiler) . כדי לפתור את הבעיה, צריך להצהיר על get() כמשתנה מסונכרן. אחרי השינוי הזה, ברור שהקוד נכון.

לצערנו, הוספנו אפשרות לנעילה, עלולים לפגוע בביצועים. במקום להצהיר ש-get() הוא בסנכרון, אפשר להצהיר על mValue עם ערך "תנודתי". (הערה: incr() עדיין צריך להשתמש ב-synchronize מאחר במקרים אחרים, mValue++ אינו פעולה אטומית אחת.) כך גם נמנעים מכל מירוצי נתונים, כך ששומרים על עקביות רציפה. incr() יהיה מעט איטי יותר, מכיוון שיהיה צורך גם בניטור הכניסה או היציאה והתקורה שמשויכת לחנות תנודתית, ההורדה של get() תהיה מהירה יותר, כך שגם בהיעדר תחרות מנצחת אם קריאה גבוהה יותר ממספר גדול של אנשים שכותבים. (כדי לקבל הסבר מלא, אפשר לעיין גם ב-AtomicInteger מסירים את הבלוק המסונכרן).

הנה דוגמה נוספת, הדומה לדוגמאות ב-C הקודמות:

class MyGoodies {
    public int x, y;
}
class MyClass {
    static MyGoodies sGoodies;
    void initGoodies() {    // runs in thread 1
        MyGoodies goods = new MyGoodies();
        goods.x = 5;
        goods.y = 10;
        sGoodies = goods;
    }
    void useGoodies() {    // runs in thread 2
        if (sGoodies != null) {
            int i = sGoodies.x;    // could be 5 or 0
            ....
        }
    }
}

כאן יש את אותה בעיה כמו בקוד C, כלומר מרוץ נתונים בתאריך sGoodies. לכן המטלה ייתכן שהמדד sGoodies = goods יזוהה לפני האתחול ב-goods. אם מצהירים על sGoodies עם מילת מפתח אחת (volatile), העקביות הרציפה משוחזרת והדברים יפעלו כמצופה.

חשוב לשים לב שרק ההפניה sGoodies עצמה היא תנודתית. לשדות המפורטים בו אין גישה. ברגע ש-sGoodies volatile, וסדר הזיכרון תקין, השדות לא ניתן לגשת אליהם בו-זמנית. ההצהרה z = sGoodies.x תבצע עומס תנודתי של MyClass.sGoodies ולאחר מכן עומס לא תנודתי של sGoodies.x. אם אתם יוצרים הפניה אל MyGoodies localGoods = sGoodies, אז z = localGoods.x הבא לא יבצע טעינות תנודתיות.

מונח נפוץ יותר בתכנות Java הוא המונח "נעילה":

class MyClass {
    private Helper helper = null;
    public Helper getHelper() {
        if (helper == null) {
            synchronized (this) {
                if (helper == null) {
                    helper = new Helper();
                }
            }
        }
        return helper;
    }
}

הרעיון הוא שאנחנו רוצים שתהיה מופע אחד של Helper שמשויך למופע של MyClass. צריך רק ליצור אותו פעם אחת, אז אנחנו יוצרים ומחזירים אותו דרך getHelper() ייעודי מותאמת אישית. כדי להימנע ממרוץ שבו שני שרשורים יוצרים את המכונה, אנחנו צריכים לסנכרן את יצירת האובייקט. עם זאת, אנחנו לא רוצים לשלם את התקורה את הבלוק "המסונכרן" בכל קריאה, כך שנעשה את החלק הזה רק אם הערך של helper כרגע הוא null.

יש מרוץ נתונים בשדה helper. אפשר להגדיר אותו בו-זמנית עם helper == null בשרשור אחר.

כדי לראות איך הפעולה הזו יכולה להיכשל, את אותו הקוד נכתב מחדש מעט, כאילו הוא עבר עיבוד לשפה דמוית C (הוספתי כמה שדות של מספרים שלמים כדי לייצג את Helper’s constructor):

if (helper == null) {
    synchronized() {
        if (helper == null) {
            newHelper = malloc(sizeof(Helper));
            newHelper->x = 5;
            newHelper->y = 10;
            helper = newHelper;
        }
    }
    return helper;
}

אין מניעה מהחומרה או מהמהדר (compiler) מהזמנה מחדש של החנות ל-helper עם אלה x/y שדות. בשרשור אחר יכול להיות ש-helper לא יהיה null, אבל השדות שלו עדיין לא מוגדרים ומוכנים לשימוש. פרטים נוספים ודרכי כשל נוספות מופיעים בקישור 'הצהרה על 'Double Checked Locking is Broken'' שבנספח, או בעמ' 71 ('שימוש זהיר בהתחלה עצלה') בספר Effective Java, 2nd Edition של Josh Bloch.

יש שתי דרכים לפתור את הבעיה:

  1. עושים את הפעולה הפשוטה ומוחקים את הבדיקה החיצונית. כך אנחנו מוודאים שאף פעם לא לבחון את הערך של helper מחוץ לבלוק מסונכרן.
  2. צריך להצהיר על תנודתיות במדד helper. באמצעות שינוי קטן זה, הקוד בדוגמה J-3 יפעלו בצורה תקינה ב-Java 1.5 ואילך. (כדאי לבצע את הפעולות הבאות דקה אחת כדי לשכנע את עצמכם שזה נכון).

איור נוסף של התנהגות volatile:

class MyClass {
    int data1, data2;
    volatile int vol1, vol2;
    void setValues() {    // runs in Thread 1
        data1 = 1;
        vol1 = 2;
        data2 = 3;
    }
    void useValues() {    // runs in Thread 2
        if (vol1 == 2) {
            int l1 = data1;    // okay
            int l2 = data2;    // wrong
        }
    }
}

מתבצעת בדיקה של useValues(), אם Thread 2 עדיין לא זיהה את מתבצע עדכון ל-vol1, ואז אין לו אפשרות לדעת אם data1 או ההגדרה data2 עדיין לא הושלמה. אחרי שהוא רואה את העדכון ל-vol1, הוא יודע שאפשר לגשת ל-data1 בבטחה ולקרוא אותו בצורה נכונה בלי לגרום למירוץ נתונים. עם זאת, הוא לא יכול להסיק מסקנות לגבי data2, כי האחסון הזה בוצע אחרי האחסון התנודת.

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

מה לעשות

ב-C/C++ עדיף להשתמש ב-C++11 מחלקות סנכרון, כמו std::mutex. אם לא, השתמשו את הפעולות המתאימות של pthread. הן כוללות את גדרות הזיכרון המתאימות, ומספקות תשובות נכונות (עקביות ברצף) אלא אם צוין אחרת) והתנהגות יעילה בכל הגרסאות של פלטפורמת Android. כדאי מאוד להשתמש בהן בצורה נכונה. לדוגמה, חשוב לזכור שמשתנה מסוג 'המתנה' עשוי באופן מובהק מוחזרים ללא סימון, כך שהם אמורים להופיע בלולאה.

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

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

מומלץ להימנע משימוש ב-volatile לתקשורת בין חוטים ב-C/C++.

ב-Java, בדרך כלל הפתרון הטוב ביותר לבעיות בו-זמניות (concurrency) הוא באמצעות מחלקה מתאימה החבילה java.util.concurrent. הקוד כתוב היטב, שנבדקו ב-SMP.

אולי הדבר הבטוח ביותר שאפשר לעשות הוא להפוך את האובייקטים לבלתי ניתנים לשינוי. חפצים ממחלקות כמו 'מחרוזת Java' ו'מספר שלם' של נתונים, שלא ניתן לשנות אובייקט שנוצר, כדי למנוע את כל הפוטנציאל למרוצי נתונים באובייקטים האלה. הספר בתוקף ב-Java, 2nd Ed. יש הוראות ספציפיות בסעיף 'פריט 15: צמצום יכולת ההשתנות'. הערה ב: במיוחד החשיבות של הצהרה על שדות Java כ"סופיים" (Bloch).

גם אם אובייקט לא ניתן לשינוי, חשוב לזכור שמעבירים אותו למכשיר אחר שרשור ללא סנכרון כלשהו הוא מרוץ נתונים. מצב כזה עשוי לפעמים מקובלים ב-Java (ראו בהמשך), אבל הם נדרשים קוד פריך. אם אין חשיבות גבוהה לביצועים, הוסיפו הצהרה אחת (volatile). ב-C++, העברת מצביע או הפניה לאובייקט שלא ניתן לשינוי ללא סנכרון מתאים, כמו כל מרוץ נתונים, הוא באג. במקרה הזה, סביר להניח שהדבר יגרום לקריסות לסירוגין, כי, לדוגמה, יכול להיות שהשרשור המקבל יראה טבלת שיטות לא מאומתת המצביע בגלל סידור מחדש של החנות.

אם מחלקת ספרייה קיימת לא או מחלקה שלא ניתנת לשינוי המתאים, ההצהרה synchronized של Java או C++ צריך להשתמש ב-lock_guard או ב-unique_lock כדי להגן ניגש לכל שדה שאפשר לגשת אליו דרך יותר משרשור אחד. אם ההשתקה לא מצליחה מתאימים למצב שלכם, עליכם להצהיר על שדות משותפים. volatile או atomic, אבל חשוב מאוד לשים לב להבין את האינטראקציות בין השרשורים. ההצהרות האלה לא למנוע טעויות נפוצות בתכנות בו-זמנית, אבל הן יעזרו לכם להימנע מהכשלים המסתוריים שקשורים לאופטימיזציה של מהדרים ו-SMP. והתקלות.

מומלץ להימנע 'פרסום' הפניה לאובייקט, כלומר להפוך אותו לזמין ב-constructor שלו. זה פחות קריטי ב-C++ או אם "מרוצי נתונים" ב-Java. אבל זו תמיד עצה טובה, והיא הופכת להיות קריטית אם קוד Java מופעל בהקשרים אחרים שבהם מודל האבטחה של Java חשוב, וקוד לא מהימן עלול לגרום למירוץ נתונים על ידי גישה למפנה האובייקט 'הדלף'. חשוב גם לבחור אם להתעלם מהאזהרות שלנו ולהשתמש בכמה מהטכניקות בקטע הבא. במאמר (שיטות בנייה בטוחות ב-Java) פרטים

מידע נוסף על הזמנות זיכרון חלשות

ב-C++11 ואילך יש מנגנונים מפורשים להקלה על ההתחייבויות לרצף עקבי בתוכניות ללא מרוץ נתונים. הארגומנטים המפורשים memory_order_relaxed,‏ memory_order_acquire (לטעינה בלבד) ו-memory_order_release (לאחסון בלבד) של פעולות אטומיות מספקים ערבויות חלשות יותר מאשר ברירת המחדל, בדרך כלל memory_order_seq_cst, שהיא משתמעת. memory_order_acq_rel מספק גם את memory_order_acquire וגם אחריות של memory_order_release לכתיבה עם קריאה-שינוי אטומית ב-Analytics. memory_order_consume עדיין לא מוגדר או מיושם בצורה מספיק טובה כדי להיות שימושי, ולכן כרגע צריך להתעלם ממנו.

השיטות lazySet ב-Java.util.concurrent.atomic דומות לחנויות C++ memory_order_release. של Java לפעמים משתמשים במשתנים רגילים כתחליף יש ל-memory_order_relaxed גישה, אבל בפועל עוד יותר חלשות. בשונה מ-C++, אין מנגנון אמיתי לביצוע שינויים לא מסודרים גישה למשתנים שמוצהרים כ-volatile.

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

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

  • המהדר או החומרה יכולים להעביר את memory_order_relaxed ניגש לקטע קריטי שמוגבל על ידי נעילה (אבל לא מחוצה לו) רכישה ושחרור. המשמעות היא שיכול להיות ששתי חנויות memory_order_relaxed יוצגו בסדר שגוי, גם אם הן מופרדות על ידי קטע קריטי.
  • אם משתנה Java רגיל מנוצל לרעה כמספר ספירה משותף, הוא עשוי להיראות לשרשור אחר כשהוא יורד, למרות שהוא עולה רק על ידי שרשור אחד אחר. אבל זה לא נכון עבור אטומי C++ memory_order_relaxed

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

גישה מחוץ למרוצים

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

אין אנלוגיות אמיתיות לכך ב-Java.

אי אפשר להסתמך על התוצאה לצורך תיקון

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

במקרה הזה הוא השימוש ב-C++ compare_exchange כדי להחליף את x באופן אטומי ב-f(x). הטעינה הראשונית של x לחישוב f(x) לא חייב להיות מהימן. אם טעינו, הניסוי compare_exchange ייכשל ואנחנו ננסה שוב. אפשר להשתמש בארגומנט memory_order_relaxed בעומס הראשוני של x, רק סדר הזיכרון של compare_exchange בפועל חשוב.

נתונים שעברו שינוי אטומי אבל לא נקראו

הנתונים משתנים במקביל על ידי מספר שרשורים, אבל לא נבחן עד להשלמת החישוב המקביל. טובה לדוגמה היא מונה שמבוסס על הגדלה אטומית (למשל: באמצעות fetch_add() ב-C++ או atomic_fetch_add_explicit() ב-C) במספר שרשורים במקביל, אבל התוצאה של הקריאות האלה המערכת תמיד מתעלמת ממנו. את הערך שמתקבל אפשר לקרוא רק בסוף, אחרי שכל העדכונים יושלמו.

במקרה הזה, אין דרך לדעת אם יש גישה לנתונים האלה הוזמן מחדש, ולכן קוד C++ עשוי להשתמש ב-memory_order_relaxed ארגומנט.

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

  • השימוש ב-memory_order_relaxed משפר את הביצועים, אבל לא בהכרח יטפלו בבעיית הביצועים הכי חשובה: כל עדכון נדרשת גישה בלעדית לשורת המטמון שבה נמצא המונה. הזה התוצאה היא התראה במטמון בכל פעם ששרשור חדש ניגש למונה. אם העדכונים מתבצעים בתדירות גבוהה ומתחלפים בין שרשורים, התהליך מהיר בהרבה כדי להימנע מעדכון המונה המשותף בכל פעם לדוגמה, להשתמש בדלפקים מקומיים של שרשורים ולסכם אותם בסוף.
  • אפשר לשלב את השיטה הזו עם הקטע הקודם: לקרוא בו-זמנית ערכים משוערים ולא מהימנים בזמן שהם מתעדכנים, כולל כל הפעולות באמצעות memory_order_relaxed. עם זאת, חשוב להתייחס לערכים שמתקבלים כאל ערכים לא מהימנים כלל. גם אם נראה שהספירה הוגדלה פעם אחת, לא בטוח שחוץ הגיע לנקודה שבה בוצעה ההגדלה. יכול להיות שההוספה בוצעה מחדש באמצעות קוד קודם. (בקשר למקרה הדומה הזכרנו) קודם לכן, C++ מבטיח שטעינה שנייה של מונה כזה לא יחזיר ערך שנמוך מטעינה קודמת באותו שרשור. אלא אם כן כמובן שהמונה נפל.)
  • לעיתים קרובות ניתן למצוא קוד שמנסה לחשב מונה ערכים באמצעות ביצוע קריאה וכתיבה ביחידות אטומיות (או לא), לא ליצור את ההגדלה כאטום שלם. הארגומנט הרגיל הוא זה "מספיק קרוב" למוני ביצועים וכדומה. ברוב המקרים זה לא המצב. כאשר העדכונים מתרחשים בתדירות גבוהה מספיק (במקרה שסביר להניח שאכפת לכם), חלק גדול מהספירה אבד. במכשיר עם ארבע ליבות, יכול להיות שיאבדו יותר ממחצית. (תרגיל פשוט: תיצרו תרחיש של שני שרשורים שבו המונה שעודכן מיליון פעמים, אבל ערך המונה הסופי הוא אחד).

תקשורת פשוטה עם סימונים

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

לדוגמה, אנחנו יכולים לשכתב את דוגמת הנעילה שנבדקה, שלמעלה ב-C++

class MyClass {
  private:
    atomic<Helper*> helper {nullptr};
    mutex mtx;
  public:
    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper == nullptr) {
        lock_guard<mutex> lg(mtx);
        myHelper = helper.load(memory_order_relaxed);
        if (myHelper == nullptr) {
          myHelper = new Helper();
          helper.store(myHelper, memory_order_release);
        }
      }
      return myHelper;
    }
};

הטעינה והשחרור של המאגר מבטיחים שאם נראה helper שאינו null, גם השדות שלו יאוכלסו כראוי. שילבנו גם את התצפית הקודמת שלפיה טעינות שלא קשורות למרוצים יכול להשתמש ב-memory_order_relaxed.

מתכנת Java יכול לייצג את helper java.util.concurrent.atomic.AtomicReference<Helper> ולהשתמש ב-lazySet() כמאגר הגרסאות. העומס הפעולות ימשיכו להשתמש בקריאות get() רגילות.

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

    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper != nullptr) {
        return myHelper;
      }
      lock_guard&ltmutex> lg(mtx);
      if (helper == nullptr) {
        helper = new Helper();
      }
      return helper;
    }

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

גם כאן helper.load(memory_order_acquire) שסביר להניח שהוא ייצור את אותו הקוד בגרסה הקיימת של Android כהתייחסות פשוטה (עקבית) helper. האופטימיזציה המועילה ביותר היא באמת עשוי להיות המבוא של myHelper לביטול של הטעינה השנייה, אבל מהדר בעתיד עשוי לעשות זאת באופן אוטומטי.

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

שדות שלא ניתן לשנות

אם שדה אובייקט מאותחל בשימוש הראשון ולא משתנה אף פעם, ייתכן שתהיה אפשרות לאתחל ולאחר מכן לקרוא אותו באמצעות שימוש חלש הגישה מסודרת. ב-C++ אפשר להצהיר עליו כ-atomic ולגשת אליו באמצעות memory_order_relaxed או ב-Java, ניתן להצהיר בלי volatile ולגשת ללא אמצעים מיוחדים. כדי לעשות את זה, צריך לעמוד בתנאים הבאים:

  • אפשר להבחין בערך של השדה עצמו האם הוא כבר אותחל. כדי לגשת לשדה, ערך הבדיקה וההחזרה של הנתיב המהיר צריך לקרוא את השדה פעם אחת בלבד. ב-Java, האפשרות השנייה חיונית. גם אם הבדיקה בשדה בוצעה כאותחלה, יכול להיות שטעינה שנייה תקרא את הערך הקודם שלא מולאה. ב-C++ פונקציית "קריאה פעם אחת" כלל היא רק שיטה טובה.
  • גם האתחול וגם הטעינות הבאות חייבים להיות אטומיים, שעדכונים חלקיים לא אמורים להיות גלויים. ב-Java, השדה לא יכול להיות long או double. ב-C++, נדרשת הקצאה אטומית; להקים אותו במקום לא תעבוד, כי מבנה atomic אינו אטומי.
  • אתחולים חוזרים חייבים להיות בטוחים, כי מספר שרשורים יכול לקרוא את הערך הלא מאותחל בו-זמנית. ב-C++‎, הכלל הזה נובע בדרך כלל מהדרישה 'ניתן להעתקה בקלות' שחלה על כל סוגי האטומים. סוגי נתונים עם מצביעים בתצוגת עץ בבעלות ידרשו ביטול הקצאה ב-copy constructor, ולא ניתן יהיה להעתיק אותם בקלות. ב-Java, מותר להשתמש בסוגים מסוימים של הפניות:
  • הפניות Java מוגבלות לסוגים לא משתנים, שמכילים רק סוגים סופיים . אסור שה-constructor של הסוג שלא ניתן לשינוי יתפרסם הפניה לאובייקט. במקרה הזה, כללי השדה הסופי ב-Java ולוודא שאם הקורא רואה את ההפניה, הוא יראה גם את שדות סופיים שאותחלו. ל-C++ אין אנלוגיות לכללים האלה גם סמנים לאובייקטים שנמצאים בבעלות לא יתקבלו מהסיבה הזו (ב בנוסף להפרת המדיניות "ניתן להעתקה" ).

הערות סיום

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

  • מודלים של זיכרון Java ו-C++ באים לידי ביטוי יחס מתרחש לפני שמציין מתי שתי פעולות מובטחות יתרחשו בסדר מסוים. כשהגדרתנו מרוץ נתונים, לא היינו משתמשים דיבר על שתי גישה לזיכרון שמתרחשות "בו-זמנית". באופן רשמי, מצב כזה מוגדר כמצב שבו אף אחד מהאירועים לא מתרחש לפני השני. חשוב ללמוד מהן ההגדרות בפועל של מתרחש לפני ומסתנכרן עם במודל הזיכרון של Java או C++. למרות שהמושג האינטואיטיבי של 'בו-זמנית' בדרך כלל מספיק טוב, ההגדרות האלה מועילות, במיוחד אם אתם שוקלים להשתמש בפעולות אטומיות עם סדר חלש ב-C++‎. (במפרט הנוכחי של Java, ההגדרה של lazySet() היא לא רשמית במיוחד).
  • כאן אפשר לבדוק מה מהדרים (compiler) ומהי דרך (compiler) אסורים לעשות כשאתם משנים את הסדר של הקוד. (במפרט של JSR-133 יש כמה דוגמאות מצוינות של טרנספורמציות משפטיות שמובילות מתוצאות בלתי צפויות).
  • איך כותבים כיתות שלא ניתנות לשינוי ב-Java וב-C++. (צריך להוסיף עוד שהוא לא רק "לא לשנות שום דבר אחרי הבנייה".)
  • להפנים את ההמלצות בקטע בו-זמניות (concurrency) ביעילות Java, מהדורה שנייה. (לדוגמה, צריך להימנע משימוש בשיטות קריאה שאמורה להיות שונה בתוך בלוק מסונכרן.)
  • כדאי לקרוא את ממשקי ה-API של java.util.concurrent ושל java.util.concurrent.atomic כדי לראות אילו ממשקי API זמינים. כדאי להשתמש הערות בו-זמניות כמו @ThreadSafe ו @GuardedBy (מ-net.jcip.annotations).

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

נספח

הטמעת מאגרי סנכרון

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

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

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

הסדר של פעולות הזיכרון נשמר על ידי מניעת סידור מחדש על ידי המהדר (compiler), ומניעת סידור מחדש על ידי החומרה. כאן נתמקד באפשרות השנייה.

סדר הזיכרון ב-ARMv7, x86 ו-MIPS נאכף באמצעות "גדר" הוראות למנוע באופן גס את הצגת ההוראות שאחרי הגדרת התג לפני ההוראות שמוצגות לפני הגדר. (גם שנקרא 'מחסום' חדשות, אך הדבר עלול לגרום לבלבול מחסומים בסגנון pthread_barrier, שעושים הרבה יותר מאשר כאן.) המשמעות המדויקת של הוראות הגדרת תחום (fence) היא נושא מורכב למדי, שצריך להתייחס בו לאופן שבו מתבצעת אינטראקציה בין ההתחייבויות שמספקות כמה סוגים שונים של תחומים, ואיך הן משולבות עם התחייבויות אחרות לגבי סדר הבקשות, שמספקת בדרך כלל החומרה. זוהי סקירה כללית ברמה גבוהה, לכן שמצטמצם מאוד עם הפרטים האלה.

הסוג הבסיסי ביותר של אחריות להזמנה הוא שניתן על ידי C++ memory_order_acquire ו-memory_order_release פעולות אטומיות: פעולות זיכרון לפני מאגר גרסאות צריך להיות גלוי לאחר טעינת אחזור. ב-ARMv7, נאכף על ידי:

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

יחד, הן מספיקות להזמנת רכישה/שחרור ב-C++. הם נדרשים אבל לא מספיקים ל-Java volatile או C++ עקביים ב-atomic.

כדי להבין מה עוד אנחנו צריכים, נבחן את קטע האלגוריתם של Dekker שהזכרנו בקצרה קודם. flag1 ו-flag2 הם משתני atomic ב-C++‏ או משתני volatile ב-Java, ושניהם בהתחלה שווים ל-false.

שרשור 1 שרשור 2
flag1 = true
if (flag2 == false)
    critical-stuff
flag2 = true
if (flag1 == false)
    critical-stuff

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

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

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

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

עומס תנודתי מאגר נתונים לא יציב
reg = A
fence for "acquire" (1)
fence for "release" (2)
A = reg
fence for later atomic load (3)

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

בארכיטקטורות מסוימות, במיוחד בארכיטקטורת x86, המילה 'acquire' ו"להשיק" אין צורך בחסמים, מכיוון שהחומרה תמיד מרומזת אוכפים כמות מספיקה של מיון. לכן ב-x86 רק המחסום האחרון (3) נוצר בפועל. באופן דומה, ב-x86, פעולות קריאה-שינוי-כתיבה אטומיות כוללות באופן משתמע גידור חזק. לכן המודלים האלה אף פעם לא לדרוש כל גדרות. ב-ARMv7, כל הגדרות שהסברנו למעלה נדרש.

ARMv8 מספק הוראות LDAR ו-STLR ישירות לאכוף את הדרישות של Java תנודתית או של C++ ברצף בטעינה ובחנויות. הן מונעות את המגבלות המיותרות של שינוי הסדר שהוזכרו למעלה. קוד Android בגרסת 64 ביט ב-ARM מבוסס על הפרטים האלה. בחרנו אנחנו מתמקדים כאן במיקום גדרות ARMv7 כי הוא שופך יותר אור על בדרישות בפועל.

קריאה נוספת

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

מודלים משותפים של עקביות זיכרון: מדריך
נכתב בשנת 1995 על ידי Adve & כדאי להתחיל מ-Gharachorloo, אם אתם רוצים להתעמק יותר במודלים של עקביות בזיכרון.
http://www.hpl.cp.com/techreports/Compaq-DEC/WRL-95-7.pdf
מחסומי זיכרון
מאמר קטן ונחמד שמסכם את הבעיות.
https://en.wikipedia.org/wiki/Memory_barrier
מידע בסיסי על שרשורים
מבוא לתכנות מרובה-שרשורים ב-C++ וב-Java, מאת הנס בוהם. דיון במרוצי נתונים ובשיטות סנכרון בסיסיות.
http://www.hboehm.info/c++mm/threadsintro.html
בו-זמניות ב-Java בפועל
הספר פורסם בשנת 2006 והוא עוסק במגוון רחב של נושאים. מומלץ מאוד למי שכותב קוד עם שרשורים מרובים ב-Java.
http://www.Javaconcurrencyinpractice.com
שאלות נפוצות בנושא JSR-133 (מודל זיכרון Java)
מבוא עדין למודל הזיכרון של Java, כולל הסבר על סנכרון, משתנים תנודתיים ובניית שדות סופיים. (קצת מיושן, במיוחד כשהוא מדבר בשפות אחרות).
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
תוקף טרנספורמציות בתוכנית במודל הזיכרון של Java
הסבר טכני למדי על הבעיות שנותרו עם את מודל הזיכרון של Java. הבעיות האלה לא חלות על תוכניות ללא מרוץ נתונים.
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf
סקירה כללית של חבילה java.util.concurrent
המסמכים לחבילת java.util.concurrent. בחלק התחתון של הדף מופיע הקטע 'מאפייני עקביות של זיכרון', שבו מוסבר מהן ההתחייבויות של הכיתות השונות.
java.util.concurrent סיכום חבילה
תיאוריה ותרגול של Java: טכניקות בנייה בטוחות ב-Java
המאמר הזה בוחן בפירוט את הסכנות של בריחה מהפניות במהלך בניית חפצים, ונותן הנחיות למבנים שלא בטוחים לשרשורים.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html
התיאוריה והתרגול של Java: ניהול תנודתיות
מאמר חמוד שמתאר מה אפשר ומה אי אפשר להשיג בעזרת שדות תנודתיים ב-Java.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html
ההצהרה 'נעילה כפולה' מנותקת
הסבר מפורט של Bill Pugh על הדרכים השונות שבהן נגרמת פגיעה בנעילה כפולה ללא volatile או atomic. כולל C/C++ ו-Java.
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleClickCheckedLocking.html
[ARM] מבדקי מחסום ליטא וספר הבישול
דיון על בעיות ARM SMP, עם קטעים קצרים של קוד ARM. אם הדוגמאות בדף הזה לא ספציפיות מדי, או שרציתם לקרוא את התיאור הרשמי של הוראת ה-DMB, כדאי לקרוא את המידע הזה. מתארת גם את ההוראות שמשמשות למחסומי זיכרון בקוד הפעלה (אולי שימושי אם אתם יוצרים קוד בזמן אמת). שימו לב שהשיטה הזו קודמת ל-ARMv8, שגם תומך בהוראות נוספות להזמנת זיכרון ומודל חזק קצת יותר מודל זיכרון. (פרטים נוספים זמינים במסמך 'ARM® Architecture Reference Manual ARMv8, for ARMv8-A architecture profile').
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf
Linux Kernel Memory Barriers
תיעוד למחסומי זיכרון בליבה (kernel) של Linux. כולל כמה דוגמאות שימושיות ופריטי ASCII.
http://www.kernel.org/doc/Docs/memory-barriers.txt
ISO/IEC JTC1 SC22 WG21 (תקני C++ ) 14882 (שפת תכנות עם C++ ), סעיף 1.10 וסעיף 29 ("ספריית פעולות אטומיות")
טיוטה של תקן לתכונות של פעולה אטומית C++. הגרסה הזו דומה לתקן C++14, שכולל שינויים קלים בתחום הזה בהשוואה ל-C++11.
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4527.pdf
(מבוא: http://www.hpl.hp.com/techreports/2008/HPL-2008-56.pdf)
ISO/IEC JTC1 SC22 WG14 (תקני C) 9899 (שפת תכנות) פרק 7.16 ("Atomics <stdatomic.h>")
טיוטת תקן לתכונות פעולה אטומית לפי תקן ISO/IEC 9899-201x C. לקבלת פרטים, אפשר גם לעיין בדוחות פגמים מאוחרים יותר.
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf
מיפויים של C/C++11 למעבדי מידע (אוניברסיטת קיימברידג')
אוסף התרגומים של ירוסלב סבצ'יק ופיטר סיוול של אטומי C++ למערכי הוראות נפוצים של מעבדי מידע.
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html
האלגוריתם של Dekker
"הפתרון הנכון הראשון הידוע לבעיית ההחרגה ההדדית בתכנות בו-זמנית". במאמר בוויקיפדיה מופיע האלגוריתם המלא, עם דיון על האופן שבו צריך לעדכן אותו כדי שיעבוד עם מכונות SMP ועם מהדרים מודרניים לאופטימיזציה.
https://en.wikipedia.org/wiki/Dekker's_algorithm
תגובות על יחסי תלות עם ARM לעומת Alpha (אלפא)
אימייל ברשימת התפוצה של ליבת הזרוע מ-Catalin Marinas. כולל סיכום נחמד של הכתובת ויחסי התלות.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html
מה כל מתכנת צריך לדעת על זיכרון
מאמר ארוך ומפורט מאוד על סוגים שונים של זיכרון, במיוחד מטמון של מעבד (CPU), מאת Ulrich DRepper.
http://www.akkadia.org/dRepper/cpumemory.pdf
הסבר על מודל הזיכרון של ARM עם מודל עקביות חלש
המאמר הזה נכתב על ידי צ'ונג & Ishtiaq של ARM, Ltd. הוא מנסה לתאר את מודל הזיכרון ARM SMP בצורה קפדנית אך נגישה. ההגדרה של 'יכולת תצפית' שמופיעה כאן מבוססת על המאמר הזה. שוב, זה היה לפני ARMv8.
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711
The JSR-133 Cookbook לכותבי מהדרים
דוג לי (Doug Lea) כתב את המסמך הזה כמסמך נלווה למסמכי העזרה של JSR-133 (מודל הזיכרון של Java). הוא מכיל את הקבוצה הראשונית של הנחיות להטמעה את מודל הזיכרון של Java ששימש את כותבי מהדרים רבים, שעדיין מצוטטים ברבים וסביר להניח שיספקו תובנות. לצערי, ארבעת סוגי ההגדרות שמוזכרים כאן אינם מתאימים התאמה לארכיטקטורות הנתמכות ב-Android, ולמיפויים של C++11 שצוינו למעלה הם עכשיו מקור טוב יותר למתכונים מדויקים, גם עבור Java.
http://g.oswego.edu/dl/jmm/cookbook.html
x86-TSO: מודל מתכנת קפדני ושימושי למעבדי x86
תיאור מדויק של מודל הזיכרון מסוג x86. תיאורים מדויקים של לצערנו, מודל הזיכרון ARM מסובך הרבה יותר.
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf