פריימר SMP ל-Android

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

מבוא

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

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

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

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

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

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

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

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

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

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

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

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

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

דוגמה פשוטה: קוד שרץ בשני שרשורים:

שרשור 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, אינן עקביות ברצף. לדוגמה, מקובל לראות לאחסון במאגר נתונים זמני בדרך לזיכרון, לא מגיעים לזיכרון באופן מיידי והופכים להיות גלויים לליבות אחרות.

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

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

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

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

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

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

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

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

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

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

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

מכיוון שאין סידור מחדש של הפעולות, שני התנאים יקבלו את הערך False אף משתנה לא מתעדכן אף פעם. לכן לא יכול להיות מרוץ נתונים. יש אין צורך לחשוב מה עשוי לקרות אם הטעינה מ-A ואחסון ב-B הסדר של שרשור 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. אם יש באג בתוכנה שגורם למרוץ נתונים לא מכוון, טרנספורמציות מהדר (compiler) וחומרה עשויות להופיע, של התוכנית שלכם עשוי להפתיע. לדוגמה, אם שכחנו להצהיר (declare) flag עם תנודתיות בדוגמה הקודמת, פרוטוקול 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 אחד, וסדר הזיכרון אסורה במפורש על ידי הוספת הגדרות או מכשולים. לשם כך נדרשת נימוק מפורש לגבי גישה סידור מחדש והבנה של מודלים של זיכרון חומרה. סגנון תכנות לאורך השורות האלה עדיין נמצא בשימוש בליבה (kernel) של 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 או C++11 std::mutex) במקום בפעילות אטומית, אבל נשתמש בשיטה השנייה כדי להדגים איך הם יהיו בשימוש במצב מעשי.

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 אבל השדות שלו עדיין לא הוגדרו ומוכנים לשימוש. לפרטים נוספים ומצבי כשל נוספים, ניתן לעיין בתכונה 'DoubleClick Checked' לפרטים נוספים, אפשר ללחוץ על הקישור 'נעילה 'לא תקין' שבנספח. 71 ("השתמשו באתחול עצלני באופן שיקולי") ב-Effective Java של ג'וש בלוך, מהדורה שנייה.

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

  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 לכתיבה עם קריאה-שינוי אטומית ב-AI. 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 ייכשל ואנחנו ננסה שוב. אפשר להשתמש בטעינה הראשונית של x ארגומנט memory_order_relaxed; רק הזמנת זיכרון לענייני 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 (או הפעולה read-modify-write) קוראת את הערך הכתוב, ואז היא לצפות גם בחנויות (רגילות או אטומיות) שקדמו חנות ב-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;
    }
};

מאגר הטעינה והשחרור מוודאים שאם רואים ערך שאינו null helper, אז גם נראה שהשדות שלו מאותחלים כראוי. שילבנו גם את התצפית הקודמת שלפיה טעינות שלא קשורות למרוצים יכול להשתמש ב-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++ זה בדרך כלל מגיע מהמודל "ניתן להעתקה" דרישה שנאכפת לכל המשתמשים סוגים אטומיים; סוגים עם מצביעים שבבעלותם בתצוגת עץ יחייבו במיקום הגיאוגרפי של העסק העתק את ה-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), ומניעת סידור מחדש על ידי החומרה. כאן נתמקד ב-2019.

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

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

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

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

כדי להבין מה עוד אנחנו צריכים, נבחן את קטע האלגוריתם של Dekker שהזכרנו בקצרה קודם. flag1 ו-flag2 הם C++ atomic או משתנים של Java volatile, שניהם בהתחלה 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
ההצהרה 'נעילה כפולה' מנותקת
ההסבר המפורט של ביל פיו לגבי הדרכים השונות שבהן נעילה כפולה מבוטלת בלי volatile או atomic. כולל C/C++ ו-Java.
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleClickCheckedLocking.html
[ARM] מבדקי מחסום ליטא וספר הבישול
דיון על בעיות ARM SMP, עם קטעים קצרים של קוד ARM. אם הדוגמאות בדף הזה לא ספציפיות מדי, או שרציתם לקרוא את התיאור הרשמי של הוראת ה-DMB, כדאי לקרוא את המידע הזה. מתארת גם את ההוראות שמשמשות למחסומי זיכרון בקוד הפעלה (אולי שימושי אם אתם יוצרים קוד בזמן אמת). שימו לב שהשיטה הזו קודמת ל-ARMv8, שגם תומך בהוראות נוספות להזמנת זיכרון ומודל חזק קצת יותר מודל זיכרון. (פרטים נוספים זמינים במאמר 'ARM® Architecture Reference Guide ARMv8, לפרופיל של ארכיטקטורה ARMv8-A).
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf
מחסומי זיכרון ליבה (Kernel) של Linux
תיעוד למחסומי זיכרון בליבה (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.ff.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
"הפתרון הידוע הראשון והנכון לבעיית ההחרגה ההדדית בתוכניות בו-זמנית". המאמר ב-wikipedia כולל את האלגוריתם המלא, עם דיון לגבי האופן שבו יש לעדכן אותו כדי לעבוד עם מהדרים מתקדמים לאופטימיזציה וחומרת 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 לכותבי מהדרים
דאג ליה כתב את הפוסט הזה כמלווה למסמכי התיעוד של JSR-133 (Java Memory Model). הוא מכיל את הקבוצה הראשונית של הנחיות להטמעה את מודל הזיכרון של 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