גרסאות הפלטפורמה של Android 3.0 ואילך עברו אופטימיזציה כדי לתמוך בארכיטקטורות של ריבוי מעבדים. במאמר הזה נציג בעיות שיכולות לצוץ כשכותבים קוד מרובה-תהליכים למערכות מרובות מעבדים סימטריות בשפות התכנות C, ++C ו-Java (שמעכשיו נקרא לה פשוט Java). הוא נועד לשמש כמדריך למפתחי אפליקציות ל-Android, ולא כסקירה מלאה של הנושא.
מבוא
SMP הוא ראשי תיבות של Symmetric Multi-Processor (ריבוי מעבדים סימטרי). זהו עיצוב שבו שתי ליבות מעבד זהות או יותר חולקות גישה לזיכרון הראשי. עד לפני כמה שנים, כל מכשירי Android היו UP (מעבד יחיד).
ברוב מכשירי Android, אם לא בכולם, תמיד היו כמה מעבדים, אבל בעבר רק אחד מהם שימש להפעלת אפליקציות, בעוד שהאחרים ניהלו חלקים שונים בחומרת המכשיר (לדוגמה, הרדיו). יכול להיות שלמעבדים היו ארכיטקטורות שונות, והתוכנות שפעלו בהם לא יכלו להשתמש בזיכרון הראשי כדי לתקשר זו עם זו.
רוב מכשירי Android שנמכרים היום מבוססים על עיצובי SMP, מה שמסבך קצת את העניינים למפתחי תוכנה. מרוצי תהליכים בתוכנית עם כמה תהליכונים לא בהכרח יגרמו לבעיות גלויות במעבד יחיד, אבל יכול להיות שהם ייכשלו באופן קבוע כששני תהליכונים או יותר פועלים בו-זמנית בליבות שונות. בנוסף, יכול להיות שהקוד יהיה פגיע יותר או פחות לכשלים כשהוא פועל בארכיטקטורות שונות של מעבדים, או אפילו בהטמעות שונות של אותה ארכיטקטורה. קוד שנבדק ביסודיות ב-x86 עלול להשתבש מאוד ב-ARM. יכול להיות שהקוד יתחיל להיכשל כשמקמפלים אותו מחדש עם קומפיילר מודרני יותר.
בהמשך המסמך נסביר למה, ומה צריך לעשות כדי לוודא שהקוד פועל בצורה תקינה.
מודלים של עקביות בזיכרון: למה SMPs קצת שונים
זו סקירה כללית מהירה ושטחית של נושא מורכב. יכול להיות שחלק מהתשובות לא יהיו מלאות, אבל הן לא אמורות להיות מטעות או שגויות. כפי שתראו בקטע הבא, הפרטים כאן בדרך כלל לא חשובים.
בסוף המסמך, בקטע קריאה נוספת, יש הפניות למידע מפורט יותר בנושא.
מודלים של עקביות בזיכרון, או לעיתים קרובות רק "מודלים של זיכרון", מתארים את ההתחייבויות של שפת התכנות או של ארכיטקטורת החומרה לגבי גישה לזיכרון. לדוגמה, אם כותבים ערך לכתובת א' ואז כותבים ערך לכתובת ב', המודל עשוי להבטיח שכל ליבת מעבד תראה את הכתיבות האלה מתרחשות בסדר הזה.
המודל שרוב המתכנתים רגילים אליו הוא עקביות סדרתית, שמתוארת כך (Adve ו-Gharachorloo):
- נראה שכל פעולות הזיכרון מבוצעות אחת אחרי השנייה
- כל הפעולות בשרשור יחיד נראות כאילו הן מבוצעות לפי הסדר שמתואר בתוכנית של אותו מעבד.
נניח באופן זמני שיש לנו קומפיילר או מפרש פשוט מאוד שלא גורם להפתעות: הוא מתרגם הקצאות בקוד המקור להוראות טעינה ואחסון בדיוק בסדר המתאים, הוראה אחת לכל גישה. לשם הפשטות, נניח גם שכל שרשור פועל במעבד משלו.
אם מסתכלים על קטע קוד ורואים שהוא מבצע כמה פעולות קריאה וכתיבה מהזיכרון, בארכיטקטורת מעבד (CPU) עם עקביות רציפה, יודעים שהקוד יבצע את פעולות הקריאה והכתיבה האלה בסדר הצפוי. יכול להיות שהמעבד (CPU) משנה את סדר ההוראות ומעכב את פעולות הקריאה והכתיבה, אבל אין דרך שבה הקוד שפועל במכשיר יכול לזהות שהמעבד מבצע פעולה כלשהי מלבד ביצוע ההוראות באופן ישיר. (אנחנו נתעלם מקלט/פלט של מנהל התקן שממופה לזיכרון).
כדי להמחיש את הנקודות האלה, כדאי להשתמש בקטעי קוד קטנים, שנקראים בדרך כלל בדיקות לקמוס.
הנה דוגמה פשוטה, עם קוד שפועל בשני שרשורים:
| שרשור 1 | שרשור 2 |
|---|---|
A = 3 |
reg0 = B |
בדוגמה הזו ובכל הדוגמאות הבאות של בדיקות ליטמוס, מיקומי הזיכרון מיוצגים על ידי אותיות רישיות (A, B, C) ורגיסטרים של המעבד (CPU) מתחילים ב-"reg". כל הזיכרון הוא אפס בהתחלה. ההוראות מבוצעות מלמעלה למטה. כאן, שרשור 1 מאחסן את הערך 3 במיקום A, ואז את הערך 5 במיקום B. Thread 2 טוען את הערך ממיקום B ל-reg0, ואז טוען את הערך ממיקום A ל-reg1. (שימו לב שאנחנו כותבים בסדר אחד וקוראים בסדר אחר).
ההנחה היא ששרשור 1 ושרשור 2 פועלים בליבות שונות של המעבד. תמיד צריך להניח את ההנחה הזו כשחושבים על קוד מרובה-תהליכים.
עקביות רציפה מבטיחה שאחרי ששני ה-threads יסיימו לפעול, הרגיסטרים יהיו באחד מהמצבים הבאים:
| הרשמות | מדינות |
|---|---|
| reg0=5, reg1=3 | אפשרי (שרשור 1 פעל קודם) |
| reg0=0, reg1=0 | אפשרי (שרשור 2 פעל קודם) |
| reg0=0, reg1=3 | אפשרי (הרצה בו-זמנית) |
| reg0=5, reg1=0 | אף פעם |
כדי להגיע למצב שבו אנחנו רואים את B=5 לפני שאנחנו רואים את האחסון ב-A, או שהקריאות או הכתיבות צריכות להתרחש בסדר שונה. במכונה עם עקביות רציפה, זה לא יכול לקרות.
מעבדים יחידים, כולל x86 ו-ARM, הם בדרך כלל עקביים ברצף. נראה שה-Threads פועלים בצורה משולבת, כי ליבת מערכת ההפעלה עוברת ביניהם. רוב מערכות ה-SMP, כולל x86 ו-ARM, לא עקביות ברצף. לדוגמה, מקובל שחומרה מאחסנת במאגר זמני את הפעולות של שמירת הנתונים בדרך לזיכרון, כך שהן לא מגיעות מיד לזיכרון וגלויות לליבות אחרות.
הפרטים משתנים באופן משמעותי. לדוגמה, x86, למרות שהוא לא עקבי ברצף, עדיין מבטיח ש-reg0 = 5 ו-reg1 = 0 נשאר בלתי אפשרי. החנויות נשמרות במאגר, אבל הסדר שלהן נשמר. לעומת זאת, ARM לא תומך בזה. הסדר של פעולות האחסון במאגר הזמני לא נשמר, ויכול להיות שפעולות האחסון לא יגיעו לכל הליבות האחרות בו-זמנית. ההבדלים האלה חשובים למתכנתי אסמבלי. עם זאת, כפי שנראה בהמשך, מתכנתי C, C++ או Java יכולים וצריכים לתכנת באופן שמסתיר את ההבדלים האלה בארכיטקטורה.
עד עכשיו הנחנו הנחה לא מציאותית שרק החומרה משנה את סדר ההוראות. בפועל, המהדר גם משנה את סדר ההוראות כדי לשפר את הביצועים. בדוגמה שלנו, יכול להיות שהקומפיילר יחליט שקוד מאוחר יותר ב-Thread 2 צריך את הערך של reg1 לפני שהוא צריך את reg0, ולכן יטען קודם את reg1. או שקוד קודם כבר טען את A, והקומפיילר עשוי להחליט להשתמש שוב בערך הזה במקום לטעון את A שוב. בשני המקרים, יכול להיות שהטעינות ל-reg0 ול-reg1 יסודרו מחדש.
מותר לשנות את סדר הגישה למיקומים שונים בזיכרון, בחומרה או בקומפיילר, כי זה לא משפיע על ההפעלה של שרשור יחיד ויכול לשפר משמעותית את הביצועים. כמו שנראה, עם קצת תשומת לב, אנחנו יכולים גם למנוע ממנו להשפיע על התוצאות של תוכניות מרובות-תהליכים.
מכיוון שמהדרים יכולים גם לשנות את הסדר של הגישות לזיכרון, הבעיה הזו לא חדשה למערכות SMP. גם במעבד יחיד, קומפיילר יכול לשנות את סדר הטעינות ל-reg0 ול-reg1 בדוגמה שלנו, וניתן לתזמן את Thread 1 בין ההוראות שסודרו מחדש. אבל אם המהדר שלנו לא היה משנה את הסדר, יכול להיות שלא היינו נתקלים בבעיה הזו. ברוב מעבדי ARM SMP, גם בלי שינוי סדר על ידי המהדר, כנראה ששינוי הסדר ייראה, אולי אחרי מספר גדול מאוד של הפעלות מוצלחות. אלא אם אתם מתכנתים בשפת assembly, בדרך כלל SMPs פשוט מגדילים את הסיכוי שתראו בעיות שהיו שם כל הזמן.
תכנות ללא מרוץ נתונים (data race)
למרבה המזל, בדרך כלל יש דרך קלה להימנע מלחשוב על כל הפרטים האלה. אם פועלים לפי כמה כללים פשוטים, בדרך כלל אפשר לשכוח את כל החלק הקודם, חוץ מהחלק של "עקביות רציפה". לצערנו, יכול להיות שיופיעו סיבוכים אחרים אם תפרו את הכללים האלה בטעות.
שפות תכנות מודרניות מעודדות סגנון תכנות שנקרא "ללא מרוץ נתונים". כל עוד אתם מבטיחים לא להכניס "מרוצי נתונים" (data races), ונמנעים מכמה מבנים שאומרים לקומפיילר אחרת, הקומפיילר והחומרה מבטיחים לספק תוצאות עקביות ברצף. זה לא באמת אומר שהם נמנעים משינוי הסדר של הגישה לזיכרון. זה אומר שאם תפעלו לפי הכללים, לא תוכלו לדעת שגישות לזיכרון מסודרות מחדש. זה כמו להגיד לכם שנקניק הוא אוכל טעים ומעורר תיאבון, כל עוד אתם מבטיחים לא לבקר במפעל הנקניקים. מרוצי נתונים הם מה שחושף את האמת המכוערת על סידור מחדש של הזיכרון.
מה זה "מרוץ נתונים"?
מרוץ נתונים מתרחש כששני תהליכים לפחות ניגשים בו-זמנית לאותם נתונים רגילים, ולפחות אחד מהם משנה אותם. המונח "נתונים רגילים" מתייחס לנתונים שהם לא אובייקט סנכרון שמיועד לתקשורת בין שרשורים. אובייקטים מסוג Mutex, משתני תנאי, משתנים נדיפים של Java או אובייקטים אטומיים של C++ הם לא נתונים רגילים, והגישה אליהם יכולה להתחרות. למעשה, הם משמשים למניעת מרוצי נתונים באובייקטים אחרים.
כדי לקבוע אם שני שרשורים ניגשים בו-זמנית לאותו מיקום בזיכרון, אנחנו יכולים להתעלם מהדיון על סידור מחדש של הזיכרון שלמעלה, ולהניח עקביות רציפה. בתוכנית הבאה אין מרוץ נתונים אם A ו-B הם משתנים בוליאניים רגילים שהערך הראשוני שלהם הוא false:
| שרשור 1 | שרשור 2 |
|---|---|
if (A) B = true |
if (B) A = true |
מכיוון שהפעולות לא מסודרות מחדש, שני התנאים יקבלו את הערך false, ואף אחד מהמשתנים לא יעודכן. לכן לא יכול להיות מצב של מרוץ נתונים. אין צורך לחשוב מה עלול לקרות אם הטעינה מ-A והאחסון ב-B בשרשור 1 היו מסודרים מחדש איכשהו. אסור לקומפיילר לשנות את הסדר של Thread 1 על ידי שכתוב שלו בתור "B = true; if (!A) B = false". זה כמו להכין נקניקיות באמצע העיר לאור יום.
מצבי מרוץ מוגדרים באופן רשמי בסוגים בסיסיים מובנים כמו מספרים שלמים, הפניות או מצביעים. הקצאה ל-int בזמן קריאה שלו בשרשור אחר היא בבירור מצב של מרוץ נתונים. אבל גם הספרייה הרגילה של C++ וגם ספריות Java Collections נכתבו כדי לאפשר לכם להסיק מסקנות לגבי מרוצי נתונים ברמת הספרייה. הם מבטיחים לא להציג מצבי מרוץ (data races) אלא אם יש גישות בו-זמניות לאותו מאגר, ולפחות אחת מהן מעדכנת אותו. עדכון של set<T> בשרשור אחד בזמן שקוראים אותו בשרשור אחר מאפשר לספרייה להציג מרוץ נתונים, ולכן אפשר להתייחס לזה באופן לא רשמי כ "מרוץ נתונים ברמת הספרייה".
לעומת זאת, עדכון של set<T> אחד בשרשור אחד, בזמן קריאה של
אחר בשרשור אחר, לא גורם למרוץ נתונים, כי הספרייה
מבטיחה לא להציג מרוץ נתונים (ברמה נמוכה) במקרה כזה.
בדרך כלל, גישות בו-זמניות לשדות שונים במבנה נתונים לא יכולות לגרום למרוץ נתונים. אבל יש חריג אחד חשוב לכלל הזה: רצפים רציפים של שדות ביטים ב-C או ב-++C נחשבים ל "מיקום בזיכרון" אחד. גישה לכל שדה סיביות ברצף כזה נחשבת כגישה לכולם לצורך קביעת קיומו של מרוץ נתונים. זה משקף את חוסר היכולת של חומרה נפוצה לעדכן ביטים בודדים בלי לקרוא ולכתוב מחדש גם ביטים סמוכים. למתכנתי Java אין בעיות דומות.
איך להימנע ממרוצי נתונים
שפות תכנות מודרניות מספקות כמה מנגנוני סנכרון כדי למנוע מצבי מרוץ בנתונים. הכלים הבסיסיים ביותר הם:
- נעילות או Mutexes
- אפשר להשתמש ב-Mutexes (
std::mutexב-C++ אוpthread_mutex_t) או בבלוקים שלsynchronizedב-Java כדי לוודא שחלק מסוים בקוד לא פועל במקביל לחלקים אחרים בקוד שניגשים לאותם נתונים. אנחנו נתייחס לאלה ולמנגנונים דומים אחרים באופן כללי בתור "נעילות". אם רוכשים באופן עקבי נעילה ספציפית לפני שניגשים למבנה נתונים משותף ומשחררים אותה לאחר מכן, אפשר למנוע מצבי מרוץ כשניגשים למבנה הנתונים. הוא גם מבטיח שהעדכונים והגישות הם אטומיים, כלומר, לא יכול לפעול עדכון אחר של מבנה הנתונים באמצע. זהו הכלי הנפוץ ביותר למניעת מרוצי נתונים, ובצדק. השימוש בבלוקים שלsynchronizedב-Java או ב-lock_guardאו ב-unique_lockב-++C מבטיח שהחסימות יבוטלו כמו שצריך במקרה של שגיאה. - משתנים נדיפים/אטומיים
- ב-Java יש שדות
volatileשתומכים בגישה בו-זמנית בלי לגרום למרוצי נתונים. מאז 2011, C ו-C++ תומכים במשתנים ובשדותatomicעם סמנטיקה דומה. בדרך כלל קשה יותר להשתמש בהם מאשר במנעולים, כי הם רק מבטיחים שגישות בודדות למשתנה יחיד הן אטומיות. (ב-C++ זה בדרך כלל כולל פעולות פשוטות של קריאה-שינוי-כתיבה, כמו הגדלות. ב-Java נדרשות הפעלות של method מיוחדות לשם כך). בניגוד לנעילות, אי אפשר להשתמש במשתניםvolatileאוatomicישירות כדי למנוע משרשורים אחרים להפריע לרצפי קוד ארוכים יותר.
חשוב לציין של-volatile יש משמעויות שונות מאוד ב-C++ וב-Java. ב-C++, המילה volatile לא מונעת מרוץ תהליכים, אבל בקוד ישן יותר משתמשים בה לעיתים קרובות כפתרון עקיף להיעדר אובייקטים מסוג atomic. השיטה הזו כבר לא מומלצת. ב-C++, צריך להשתמש ב-atomic<T> למשתנים שאפשר לגשת אליהם בו-זמנית באמצעות כמה שרשורים. volatile ב-C++ מיועד לאוגרי מכשירים וכדומה.
אפשר להשתמש במשתני C/C++ atomic או במשתני Java volatile כדי למנוע מרוצי נתונים במשתנים אחרים. אם flag מוגדר כסוג atomic<bool> או atomic_bool (C/C++) או volatile boolean (Java), והוא מוגדר בהתחלה כ-false, אז קטע הקוד הבא הוא ללא מרוץ נתונים:
| שרשור 1 | שרשור 2 |
|---|---|
A = ...
|
while (!flag) {}
|
מכיוון ששרשור 2 ממתין להגדרה של flag, הגישה אל A בשרשור 2 חייבת להתבצע אחרי ההקצאה ל-A בשרשור 1, ולא במקביל אליה. לכן אין מרוץ נתונים ב-A. המירוץ ב-flag לא נחשב למירוץ נתונים, כי גישות נדיפות/אטומיות הן לא "גישות רגילות לזיכרון".
ההטמעה נדרשת כדי למנוע או להסתיר את שינוי סדר הזיכרון במידה מספקת, כך שקוד כמו בדיקת הלקמוס הקודמת יפעל כצפוי. בדרך כלל, זה הופך את הגישה לזיכרון נדיף/אטומי ליקרה משמעותית מגישה רגילה.
למרות שבדוגמה הקודמת אין מרוץ נתונים, נעילות יחד עם Object.wait() ב-Java או משתני תנאי ב-C/C++ בדרך כלל מספקים פתרון טוב יותר שלא כולל המתנה בלולאה תוך כדי ריקון הסוללה.
מתי אפשר לשנות את סדר הזיכרונות
תכנות ללא מרוץ נתונים בדרך כלל חוסך לנו את הצורך להתמודד באופן מפורש עם בעיות של שינוי סדר הגישה לזיכרון. עם זאת, יש כמה מקרים שבהם שינוי הסדר כן גלוי:- אם בתוכנית יש באג שגורם למרוץ נתונים לא מכוון, יכול להיות שטרנספורמציות של הקומפיילר והחומרה יהפכו לגלויות, וההתנהגות של התוכנית תהיה מפתיעה. לדוגמה, אם שכחנו להצהיר על
flagכ-volatile בדוגמה הקודמת, יכול להיות ש-Thread 2 יראהAשלא אותחל. או שהמהדר עשוי להחליט שהדגל לא יכול להשתנות במהלך הלולאה של Thread 2, ולשנות את התוכנית ל:
כשמנפים באגים, יכול להיות שתראו שהלולאה ממשיכה לנצח למרות שהתנאישרשור 1 שרשור 2 A = ...
flag = truereg0 = flag; while (!reg0) {}
... = Aflagמתקיים. - C++ מספקת אפשרויות להרפיה מפורשת של עקביות רציפה, גם אם אין מרוצים. פעולות אטומיות יכולות לקבל ארגומנטים מפורשים מסוג
memory_order_... . באופן דומה, החבילהjava.util.concurrent.atomicמספקת קבוצה מוגבלת יותר של פונקציות דומות, בעיקרlazySet(). מתכנתי Java משתמשים לפעמים במרוצי נתונים מכוונים כדי להשיג אפקט דומה. כל אלה משפרים את הביצועים, אבל הם גם מסבכים את התכנות. אנחנו דנים בהם רק בקצרה בהמשך. - חלק מהקוד של C ו-C++ נכתב בסגנון ישן יותר, שלא תואם לחלוטין לתקני השפה הנוכחיים, שבהם משתמשים במשתני
volatileבמקום במשתניatomic, וסדר הזיכרון נאסר במפורש על ידי הוספת מה שנקרא גדרות או מחסומים. לשם כך נדרשת חשיבה מפורשת על שינוי סדר הגישה והבנה של מודלים של זיכרון חומרה. סגנון כתיבת קוד כזה עדיין נמצא בשימוש בליבת Linux. אין להשתמש בו באפליקציות חדשות ל-Android, והוא לא נדון כאן יותר.
תרגול
יכול להיות קשה מאוד לנפות באגים בבעיות שקשורות לעקביות בזיכרון. אם חסרה הצהרה על נעילה, atomic או volatile, וזה גורם לקוד מסוים לקרוא נתונים לא עדכניים, יכול להיות שלא תצליחו להבין למה זה קורה באמצעות בדיקה של קובצי dump של זיכרון עם מנפה באגים. עד שתוכלו להנפיק שאילתת מנפה באגים, יכול להיות שכל ליבות המעבד (CPU) כבר צפו בקבוצת הגישות המלאה, ותוכן הזיכרון ורגיסטרי המעבד (CPU) ייראו במצב "בלתי אפשרי".
מה לא לעשות ב-C
ריכזנו כאן כמה דוגמאות לקוד שגוי, ודרכים פשוטות לתקן אותו. לפני שנעשה את זה, אנחנו צריכים לדון בשימוש בתכונה בסיסית של השפה.
C/C++ ו-volatile
הצהרות volatile ב-C וב-C++ הן כלי למטרה מאוד ספציפית.
הם מונעים מהקומפיילר לשנות את הסדר של גישות volatile או להסיר אותן. האפשרות הזו יכולה לעזור בקוד שניגש לרגיסטרים של מכשיר חומרה, בזיכרון שממופה ליותר ממיקום אחד או בקשר ל-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 כ-atomic. ב-C++11:
atomic<MyThing*> gGlobalThing(NULL);
כך אפשר לוודא שהכתיבות יהיו גלויות לשרשורים אחרים בסדר הנכון. הוא גם מבטיח למנוע מצבי כשל אחרים שמותרים, אבל לא סביר שיקרו בחומרת Android אמיתית. לדוגמה, כך אנחנו לא יכולים לראות מצביע gGlobalThing שנכתב רק באופן חלקי.
מה לא לעשות ב-Java
לא דנו בכמה תכונות רלוונטיות של שפת Java, אז נתחיל בסקירה מהירה שלהן.
מבחינה טכנית, Java לא דורשת שהקוד יהיה ללא מרוץ נתונים. ויש כמות קטנה של קוד Java שנכתב בקפידה רבה ופועל בצורה תקינה במצבים של מרוץ נתונים. עם זאת, כתיבת קוד כזה היא מסובכת מאוד, ונדון בה רק בקצרה בהמשך. כדי להחמיר את המצב, המומחים שציינו את המשמעות של קוד כזה כבר לא מאמינים שהמפרט נכון. (המפרט מתאים לקוד ללא מרוץ נתונים).
בינתיים נשתמש במודל ללא מרוץ נתונים, שבו Java מספקת למעשה את אותן הבטחות כמו C ו-C++. שוב, השפה מספקת כמה פרימיטיבים שמפחיתים באופן מפורש את העקביות הרציפה, בעיקר הקריאות lazySet() ו-weakCompareAndSet() ב-java.util.concurrent.atomic.
כמו ב-C וב-C++, נתעלם מהם בינתיים.
מילות המפתח synchronized ו-volatile ב-Java
מילת המפתח "synchronized" מספקת את מנגנון הנעילה המובנה של שפת Java. לכל אובייקט יש "מוניטור" משויך שאפשר להשתמש בו כדי לספק גישה בלעדית. אם שני תהליכים מנסים "לסנכרן" את אותו אובייקט, אחד מהם ימתין עד שהשני יסיים.
כמו שציינו למעלה, volatile T של Java מקביל ל-atomic<T> של C++11. מותר לגשת בו-זמנית לשדות volatile, וזה לא גורם למרוצי נתונים.
אם מתעלמים מ-lazySet() וכו' וממרוצי נתונים, התפקיד של Java VM הוא לוודא שהתוצאה עדיין נראית עקבית מבחינה רציפה.
באופן ספציפי, אם שרשור 1 כותב לשדה volatile, ושרשור 2 קורא לאחר מכן מאותו שדה ורואה את הערך החדש שנכתב, אז שרשור 2 גם יראה את כל הכתיבות שבוצעו בעבר על ידי שרשור 1. מבחינת ההשפעה על הזיכרון, כתיבה ל-volatile דומה לשחרור של מוניטור, וקריאה מ-volatile דומה לרכישה של מוניטור.
יש הבדל אחד בולט מ-atomic של C++:
אם כותבים volatile int x;
ב-Java, אז x++ זהה ל-x = x + 1;
הוא מבצע טעינה אטומית, מגדיל את התוצאה ואז מבצע אחסון
אטומי. בניגוד ל-C++, ההגדלה כמכלול היא לא אטומית.
במקום זאת, פעולות של הגדלה אטומית מסופקות על ידי java.util.concurrent.atomic.
דוגמאות
(Java theory and practice: Managing volatility). הנה הטמעה פשוטה ושגויה של מונה מונוטוני:
class Counter { private int mValue; public int get() { return mValue; } public void incr() { mValue++; } }
נניח שקוראים ל-get() ול-incr() מכמה שרשורים, ואנחנו רוצים לוודא שכל שרשור רואה את הספירה הנוכחית כשקוראים ל-get(). הבעיה הכי בולטת היא שהפעולה mValue++ היא בעצם שלוש פעולות:
reg = mValuereg = reg + 1mValue = reg
אם שני שרשורים פועלים ב-incr() בו-זמנית, יכול להיות שאחד מהעדכונים יאבד. כדי להפוך את ההגדלה לאטומית, אנחנו צריכים להצהיר על incr() כ-synchronized.
אבל הוא עדיין לא עובד כמו שצריך, במיוחד ב-SMP. עדיין יש מרוץ נתונים, כי get() יכול לגשת ל-mValue במקביל ל-incr(). לפי הכללים של Java, יכול להיות שהקריאה ל-get() תופיע בסדר שונה ביחס לקוד אחר. לדוגמה, אם אנחנו קוראים שני מונים ברצף, יכול להיות שהתוצאות לא יהיו עקביות כי הקריאות ל-get() סודרו מחדש על ידי החומרה או הקומפיילר. כדי לתקן את הבעיה, אפשר להגדיר את get() כ-synchronized. עם השינוי הזה, הקוד נכון באופן ברור.
לצערנו, הוספנו את האפשרות של תחרות על נעילה, שעלולה לפגוע בביצועים. במקום להגדיר את get() כ-synchronized, אפשר להגדיר את mValue כ-volatile. (הערה: עדיין צריך להשתמש ב-synchronize ב-incr() כי אחרת 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 הוא double-checked locking (נעילה בבדיקה כפולה):
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() ייעודית. כדי למנוע מצב שבו שני שרשורים יוצרים את המופע, צריך לסנכרן את יצירת האובייקט. עם זאת, אנחנו לא רוצים לשלם את התקורה של הבלוק synchronized בכל קריאה, אז אנחנו עושים את החלק הזה רק אם helper הוא null כרגע.
יש כאן מצב מרוץ (race condition) בשדה helper. אפשר להגדיר אותו בו-זמנית עם helper == null בשרשור אחר.
כדי לראות איך זה יכול להיכשל, נבחן את אותו קוד שנכתב מחדש קצת, כאילו הוא עבר הידור לשפה דמוית C (הוספתי כמה שדות של מספרים שלמים כדי לייצג את הפעילות של ה-constructor של Helper’s):
if (helper == null) { synchronized() { if (helper == null) { newHelper = malloc(sizeof(Helper)); newHelper->x = 5; newHelper->y = 10; helper = newHelper; } } return helper; }
אין שום דבר שמונע מהחומרה או מהקומפיילר לשנות את סדר האחסון ל-helper עם אלה של השדות x/y. יכול להיות ששרשור אחר ימצא את helper כלא-ריק, אבל השדות שלו עדיין לא מוגדרים ומוכנים לשימוש.
לפרטים נוספים ולמצבי כשל נוספים, עיינו בקישור "'Double Checked Locking is Broken' Declaration" בנספח, או בפריט 71 ("Use lazy initialization judiciously") בספר Effective Java, 2nd Edition של ג'וש בלוך.
יש שתי דרכים לפתור את הבעיה:
- הדרך הפשוטה היא למחוק את הבדיקה החיצונית. כך אנחנו מוודאים שאנחנו אף פעם לא בודקים את הערך של
helperמחוץ לבלוק מסונכרן. - צריך להצהיר על
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(), אם שרשור 2 עדיין לא זיהה את העדכון של vol1, הוא לא יכול לדעת אם data1 או data2 כבר הוגדרו. כשהוא רואה את העדכון ל-vol1, הוא יודע שאפשר לגשת ל-data1 ולקרוא אותו בצורה נכונה בלי לגרום למרוץ נתונים. עם זאת, הוא לא יכול להניח הנחות לגבי data2, כי האחסון הזה בוצע אחרי האחסון הנדיף.
שימו לב שאי אפשר להשתמש ב-volatile כדי למנוע שינוי בסדר של גישות אחרות לזיכרון שמתחרות זו בזו. לא מובטח שהיא תיצור הוראה לגדר זיכרון של מכונה. אפשר להשתמש בו כדי למנוע מצבי מרוץ (race conditions) על ידי הפעלת קוד רק כששרשור אחר עומד בתנאי מסוים.
מה לעשות?
ב-C/C++, עדיף להשתמש במחלקות סנכרון של C++11, כמו std::mutex. אם לא, משתמשים בפעולות pthread המתאימות.
הן כוללות את מחסומי הזיכרון המתאימים, ומספקות התנהגות נכונה (עקבית ברצף, אלא אם צוין אחרת) ויעילה בכל הגרסאות של פלטפורמת Android. חשוב להשתמש בהן בצורה נכונה. לדוגמה, חשוב לזכור שהמתנות של משתני תנאי עשויות לחזור באופן שגוי בלי לקבל אות, ולכן הן צריכות להופיע בלולאה.
מומלץ להימנע משימוש ישיר בפונקציות אטומיות, אלא אם מבנה הנתונים שאתם מטמיעים הוא פשוט מאוד, כמו מונה. נעילה וביטול נעילה של pthread mutex דורשות פעולה אטומית אחת כל אחת, ולעיתים קרובות עולות פחות מאי מציאה אחת במטמון, אם אין מחלוקת, כך שלא תחסכו הרבה על ידי החלפת קריאות mutex בפעולות אטומיות. עיצובים ללא נעילה למבני נתונים לא טריוויאליים דורשים הרבה יותר תשומת לב כדי להבטיח שפעולות ברמה גבוהה יותר על מבנה הנתונים ייראו אטומיות (ככלל, לא רק החלקים האטומיים שלהן באופן מפורש).
אם אתם משתמשים בפעולות אטומיות, הרפיית הסדר באמצעות memory_order... או lazySet() עשויה לספק יתרונות בביצועים, אבל נדרשת הבנה עמוקה יותר ממה שהעברנו עד כה.
בדיעבד, מתגלים באגים בחלק גדול מהקוד הקיים שמשתמש בהם. מומלץ להימנע מהם אם אפשר.
אם תרחיש השימוש שלכם לא תואם בדיוק לאחד מהתרחישים שבקטע הבא, ודאו שאתם מומחים בתחום או שהתייעצתם עם מומחה.
אל תשתמשו ב-volatile לתקשורת בין שרשורים ב-C/C++.
ב-Java, הדרך הכי טובה לפתור בעיות של בו-זמניות היא בדרך כלל להשתמש במחלקת שירות מתאימה מהחבילה java.util.concurrent. הקוד כתוב היטב ונבדק היטב ב-SMP.
אולי הדבר הכי בטוח שאפשר לעשות הוא להפוך את האובייקטים לבלתי ניתנים לשינוי. אובייקטים ממחלקות כמו String ו-Integer של Java מכילים נתונים שאי אפשר לשנות אחרי שיוצרים אובייקט, וכך נמנע כל פוטנציאל למרוצי נתונים באובייקטים האלה. בספר Effective Java, 2nd Ed. יש הוראות ספציפיות בקטע "Item 15: Minimize Mutability". שימו לב במיוחד לחשיבות של הצהרה על שדות Java כ-final (Bloch).
גם אם אובייקט הוא לא ניתן לשינוי, חשוב לזכור שהעברה שלו לשרשור אחר בלי סנכרון כלשהו היא מרוץ נתונים. לפעמים זה יכול להיות מקובל ב-Java (ראו בהמשך), אבל צריך להיזהר מאוד, וסביר להניח שזה יגרום לקוד להיות שביר. אם זה לא קריטי לביצועים, הוסיפו הצהרת volatile. ב-C++, העברת מצביע או הפניה לאובייקט שלא ניתן לשינוי בלי סנכרון מתאים, כמו כל מרוץ נתונים, היא באג.
במקרה כזה, סביר להניח שהתוצאה תהיה קריסות לסירוגין, כי למשל, ה-thread המקבל עשוי לראות מצביע של טבלת שיטות שלא אותחל בגלל שינוי סדר האחסון.
אם לא מתאימה מחלקה קיימת של ספרייה או מחלקה שלא ניתנת לשינוי, צריך להשתמש בהצהרת synchronized של Java או ב-lock_guard / unique_lock של C++ כדי להגן על הגישה לכל שדה שיותר משרשור אחד יכול לגשת אליו. אם אי אפשר להשתמש ב-mutexes במצב שלכם, אתם צריכים להצהיר על שדות משותפים בתור volatile או atomic, אבל חשוב מאוד להבין את האינטראקציות בין ה-threads. ההצהרות האלה לא ימנעו מכם לעשות טעויות נפוצות בתכנות בו-זמני, אבל הן יעזרו לכם להימנע מכשלים מסתוריים שקשורים לאופטימיזציה של קומפיילרים ולתקלות ב-SMP.
כדאי להימנע מ"פרסום" של הפניה לאובייקט, כלומר להפוך אותו לזמין לשרשורים אחרים, ב-constructor שלו. זה פחות קריטי ב-C++ או אם אתם פועלים לפי העצה שלנו "ללא מרוצי נתונים" ב-Java. אבל זו תמיד עצה טובה, והיא הופכת לקריטית אם קוד ה-Java שלכם פועל בהקשרים אחרים שבהם מודל האבטחה של Java חשוב, וקוד לא מהימן עלול להוביל למצב מרוץ (race condition) על ידי גישה להפניה לאובייקט ש"דלף". הוא גם קריטי אם אתם בוחרים להתעלם מהאזהרות שלנו ולהשתמש בחלק מהשיטות שבקטע הבא. פרטים נוספים זמינים במאמר (Safe Construction Techniques in 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 לפעולות קריאה-שינוי-כתיבה אטומיות. המאפיין memory_order_consume עדיין לא מוגדר או מוטמע בצורה מספיק טובה כדי שיהיה שימושי, וצריך להתעלם ממנו בינתיים.
השיטות של lazySet ב-Java.util.concurrent.atomic דומות למאגרים של memory_order_release ב-C++. לפעמים משתמשים רגילים ב-Java מחליפים את הגישה ל-memory_order_relaxed, למרות שהם למעשה חלשים יותר. בניגוד ל-C++, אין מנגנון אמיתי לגישה לא מסודרת למשתנים שמוצהרים כ-volatile.
באופן כללי, כדאי להימנע מהשימוש בהם, אלא אם יש סיבות דחופות שקשורות לביצועים. בארכיטקטורות של מכונות עם סדר חלש כמו ARM, השימוש בהן יחסוך בדרך כלל כמה עשרות מחזורי מכונה לכל פעולה אטומית. ב-x86, השיפור בביצועים מוגבל לחנויות, וכנראה יהיה פחות מורגש. באופן קצת לא אינטואיטיבי, יכול להיות שהיתרון יקטן ככל שמספר הליבות יהיה גדול יותר, כי מערכת הזיכרון הופכת לגורם מגביל יותר.
הסמנטיקה המלאה של פעולות אטומיות בסדר חלש היא מורכבת. באופן כללי, כדי להשתמש בהן צריך להבין היטב את כללי השפה, ולא נרחיב על כך כאן. לדוגמה:
- הקומפיילר או החומרה יכולים להעביר גישות של
memory_order_relaxedלקטע קריטי שמוגבל על ידי רכישה ושחרור של נעילה, אבל לא להוציא אותן ממנו. כלומר, יכול להיות ששתי פעולות אחסון שלmemory_order_relaxedיהיו גלויות בסדר שונה, גם אם הן מופרדות על ידי קטע קריטי. - משתנה Java רגיל, כשמשתמשים בו לרעה כמונה משותף, עשוי להיראות לשרשור אחר כאילו הוא פוחת, למרות שהוא גדל רק על ידי שרשור אחר אחד. אבל זה לא נכון לגבי
memory_order_relaxedאטומי של C++.
כאזהרה, אנחנו מציגים כאן מספר קטן של ביטויים שנראה שהם מכסים הרבה ממקרי השימוש של פעולות אטומיות עם סדר חלש. רבים מהם רלוונטיים רק ל-C++.
גישה לאזורים שאינם אזורי המרוץ
זה די נפוץ שמשתנה הוא אטומי כי לפעמים קוראים אותו במקביל לכתיבה, אבל לא לכל הגישות יש את הבעיה הזו.
לדוגמה, יכול להיות שמשתנה צריך להיות אטומי כי הוא נקרא מחוץ לקטע קריטי, אבל כל העדכונים מוגנים על ידי נעילה. במקרה כזה, פעולת קריאה שמוגנת על ידי אותו מנעול לא יכולה לגרום למצב מירוץ, כי לא יכולות להיות פעולות כתיבה בו-זמניות. במקרה כזה, אפשר להוסיף הערה לגישה שאינה מרוץ (טעינה במקרה הזה) באמצעות memory_order_relaxed בלי לשנות את הנכונות של קוד C++.
הטמעת הנעילה כבר אוכפת את סדר הזיכרון הנדרש ביחס לגישה על ידי שרשורים אחרים, ו-memory_order_relaxed מציין שבעצם אין צורך לאכוף אילוצי סדר נוספים עבור הגישה האטומית.
אין לזה מקבילה אמיתית ב-Java.
התוצאה לא מסתמכת על נכונות
כשאנחנו משתמשים בטעינה מתחרה רק כדי ליצור רמז, בדרך כלל אפשר גם לא לאכוף סדר זיכרון כלשהו לטעינה. אם הערך לא מהימן, אי אפשר להשתמש בתוצאה באופן מהימן כדי להסיק מסקנות לגבי משתנים אחרים. לכן, אין בעיה אם סדר הזיכרון לא מובטח, והטעינה מסופקת עם ארגומנט memory_order_relaxed.
דוגמה נפוצה לכך היא שימוש ב-compare_exchange של C++ כדי להחליף באופן אטומי את 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, הפעולות האטומיות האלה מאפשרות להעביר את ההתקדמות של שרשור אחד לשרשור אחר.
לדוגמה, אפשר לשכתב את הדוגמה של double-checked locking שלמעלה ב-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; } };
הפעולות acquire load ו-release store מבטיחות שאם נראה ערך שאינו 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<mutex> lg(mtx); if (helper == nullptr) { helper = new Helper(); } return helper; }
כך מקבלים את אותו נתיב מהיר, אבל חוזרים לפעולות ברירת המחדל, העקביות ברצף, בנתיב האיטי שלא קריטי לביצועים.
גם כאן, סביר להניח ש-helper.load(memory_order_acquire) ייצור את אותו קוד בארכיטקטורות הנוכחיות שנתמכות ב-Android, כמו הפניה פשוטה (עקבית ברצף) אל helper. האופטימיזציה הכי מועילה כאן היא כנראה הוספת myHelper כדי למנוע טעינה שנייה, למרות שקומפיילר עתידי עשוי לעשות את זה באופן אוטומטי.
סדר הרכישה/השחרור לא מונע עיכובים גלויים בשמירות, ולא מבטיח שהשמירות יהיו גלויות לשרשורים אחרים בסדר עקבי. לכן, הוא לא תומך בתבנית קידוד מסובכת, אבל די נפוצה, שמודגמת על ידי אלגוריתם ההדרה ההדדית של דקר: כל השרשורים מגדירים קודם דגל שמציין שהם רוצים לעשות משהו. אם שרשור t מבחין שאף שרשור אחר לא מנסה לעשות משהו, הוא יכול להמשיך בבטחה, בידיעה שלא תהיה הפרעה. אף שרשור אחר לא יוכל להמשיך, כי הדגל של t עדיין מוגדר. הפעולה הזו נכשלת אם הגישה לדגל מתבצעת באמצעות סדר של רכישה/שחרור, כי זה לא מונע את האפשרות שהדגל של ה-thread יהיה גלוי לאחרים באיחור, אחרי שהם המשיכו בטעות. ערך ברירת המחדל memory_order_seq_cst
מונע את זה.
שדות שלא ניתן לשנות
אם שדה של אובייקט מאותחל בשימוש הראשון ואז לא משתנה, יכול להיות שאפשר לאתחל אותו ולקרוא אותו לאחר מכן באמצעות גישות בסדר חלש. ב-C++, אפשר להצהיר עליו בתור atomic ולגשת אליו באמצעות memory_order_relaxed. ב-Java, אפשר להצהיר עליו בלי volatile ולגשת אליו בלי אמצעים מיוחדים. כדי לעשות את זה, צריך לעמוד בכל התנאים הבאים:
- אפשר לדעת לפי הערך של השדה עצמו אם הוא כבר אותחל. כדי לגשת לשדה, ערך הבדיקה וההחזרה של הנתיב המהיר צריך לקרוא את השדה פעם אחת בלבד. ב-Java, האפשרות השנייה היא חיונית. גם אם השדה נבדק כמאותחל, טעינה שנייה עשויה לקרוא את הערך הקודם שלא אותחל. ב-C++, הכלל "קריאה פעם אחת" הוא רק שיטה מומלצת.
- גם ההפעלה וגם הטעינות הבאות צריכות להיות אטומיות, כלומר אסור שעדכונים חלקיים יהיו גלויים. ב-Java, השדה לא יכול להיות
longאוdouble. ב-C++, נדרשת הקצאה אטומית. בנייה במקום לא תעבוד, כי הבנייה שלatomicהיא לא אטומית. - אתחולים חוזרים חייבים להיות בטוחים, כי כמה שרשורים עשויים לקרוא את הערך שלא אותחל בו-זמנית. ב-C++, בדרך כלל זה נובע מהדרישה "trivially copyable" שמוטלת על כל הסוגים האטומיים. סוגים עם מצביעים בבעלות מקוננת ידרשו הקצאה מחדש ב-copy constructor, ולא יהיו trivially copyable. ב-Java, אפשר להשתמש בסוגים מסוימים של קובצי עזר:
- הפניות ב-Java מוגבלות לסוגים שלא ניתנים לשינוי, שמכילים רק שדות סופיים. ה-constructor של הסוג הבלתי ניתן לשינוי לא אמור לפרסם הפניה לאובייקט. במקרה כזה, הכללים של השדה הסופי של Java מבטיחים שאם הקורא רואה את ההפניה, הוא יראה גם את השדות הסופיים שנוצרו. ב-C++ אין מקבילה לכללים האלה, וגם מסיבה זו מצביעים לאובייקטים בבעלות אינם קבילים (בנוסף להפרת הדרישות של "trivially copyable").
הערות סגירה
המסמך הזה לא רק מגרד את פני השטח, אבל הוא לא מעמיק יותר מדי. זה נושא רחב ועמוק מאוד. כמה תחומים שכדאי לבדוק:
- מודלי הזיכרון בפועל של Java ו-++C מבוטאים במונחים של יחס happens-before, שמציין מתי מובטח ששתי פעולות יתרחשו בסדר מסוים. כשהגדרנו מרוץ נתונים, דיברנו באופן לא רשמי על שתי גישות לזיכרון שמתרחשות "בו-זמנית".
באופן רשמי, זה מוגדר כמצב שבו אף אחד מהם לא קורה לפני השני.
כדאי ללמוד את ההגדרות בפועל של happens-before ו-synchronizes-with במודל הזיכרון של Java או ++C.
למרות שהתפיסה האינטואיטיבית של "בו-זמנית" היא בדרך כלל מספיק טובה, ההגדרות האלה מאלפות, במיוחד אם אתם שוקלים להשתמש בפעולות אטומיות בסדר חלש ב-C++. (המפרט הנוכחי של Java מגדיר את
lazySet()רק באופן לא רשמי). - מה מותר ומה אסור לקומפיילרים לעשות כשהם משנים את סדר הקוד. (במפרט JSR-133 יש כמה דוגמאות מצוינות לטרנספורמציות חוקיות שמובילות לתוצאות לא צפויות).
- איך כותבים מחלקות שלא ניתנות לשינוי ב-Java וב-++C. (יש בזה יותר מאשר רק "לא לשנות שום דבר אחרי הבנייה").
- הפנימו את ההמלצות בקטע Concurrency בספר Effective Java, 2nd Edition. (לדוגמה, כדאי להימנע מקריאה לשיטות שמיועדות להחלפה בתוך בלוק מסונכרן).
- כדאי לקרוא את ממשקי ה-API של
java.util.concurrentו-java.util.concurrent.atomicכדי לראות מה זמין. כדאי להשתמש בהערות בו-זמניות כמו@ThreadSafeו-@GuardedBy(מ-net.jcip.annotations).
בקטע לקריאה נוספת בנספח יש קישורים למסמכים ולאתרים שמסבירים את הנושאים האלה בצורה טובה יותר.
נספח
הטמעת מאגרי סנכרון
(רוב המתכנתים לא יצטרכו ליישם את זה, אבל הדיון מאיר עיניים).
בסוגים קטנים מובנים כמו int, ובחומרה שנתמכת על ידי Android, הוראות טעינה ואחסון רגילות מבטיחות שהאחסון יהיה גלוי במלואו, או לא יהיה גלוי בכלל, למעבד אחר שטוען את אותו מיקום. כך שמושג בסיסי כלשהו של "אטומיות" מסופק בחינם.
כמו שראינו קודם, זה לא מספיק. כדי להבטיח עקביות ברצף, אנחנו צריכים גם למנוע שינוי בסדר הפעולות, ולוודא שפעולות הזיכרון יהיו גלויות לתהליכים אחרים בסדר עקבי. מסתבר שהאפשרות השנייה היא אוטומטית בחומרה שנתמכת ב-Android, בתנאי שאנחנו בוחרים בצורה מושכלת לאכוף את האפשרות הראשונה, אז אנחנו מתעלמים ממנה כאן במידה רבה.
סדר פעולות הזיכרון נשמר על ידי מניעת שינוי הסדר על ידי המהדר ועל ידי החומרה. כאן נתמקד באפשרות השנייה.
סדר הזיכרון ב-ARMv7, ב-x86 וב-MIPS נאכף באמצעות הוראות "גדר" שמונעות מהוראות שאחרי הגדר להופיע לפני הוראות שלפני הגדר. (ההוראות האלה נקראות גם הוראות "מחסום", אבל זה עלול לבלבל עם מחסומים בסגנון pthread_barrier, שעושים הרבה יותר מזה). המשמעות המדויקת של הוראות fence היא נושא די מורכב שצריך להתייחס לאופן שבו פועלות יחד ההבטחות שמספקים סוגים שונים של הוראות fence, ואיך הן משתלבות עם הבטחות אחרות לגבי סדר הפעולות שבדרך כלל מספקת החומרה. זו סקירה כללית, אז נדלג על הפרטים האלה.
הסוג הבסיסי ביותר של הבטחת סדר הוא זה שמסופק על ידי פעולות אטומיות של memory_order_acquire ו-memory_order_release: פעולות זיכרון שקודמות לפעולת אחסון של שחרור צריכות להיות גלויות אחרי פעולת טעינה של רכישה. ב-ARMv7, זה נאכף על ידי:
- הוספת הוראת גידור מתאימה לפני הוראת האחסון. כך נמנע שינוי הסדר של כל הגישות הקודמות לזיכרון באמצעות הוראת האחסון. (היא גם מונעת שלא לצורך שינוי סדר עם הוראת אחסון מאוחרת יותר).
- הוספת הוראת גדר מתאימה אחרי הוראת הטעינה, כדי למנוע את שינוי הסדר של הטעינה עם גישות עוקבות. (ושוב, מספק סדר מיותר עם לפחות טעינות מוקדמות יותר).
ביחד, הם מספיקים לסדר של רכישה/שחרור ב-C++.
הם נחוצים, אבל לא מספיקים, ל-Java volatile או ל-C++ עקבי ברצף atomic.
כדי לראות מה עוד אנחנו צריכים, נבחן את החלק של האלגוריתם של דקר שהזכרנו בקצרה קודם.
flag1 ו-flag2 הם משתני C++ atomic או Java volatile, שניהם false בהתחלה.
| שרשור 1 | שרשור 2 |
|---|---|
flag1 = true |
flag2 = true |
עקביות רציפה מרמזת שאחת מההקצאות ל-flagn חייבת להתבצע קודם, ולהיראות על ידי הבדיקה בשרשור השני. לכן, לעולם לא נראה את השרשורים האלה מבצעים את "הדברים הקריטיים" בו-זמנית.
אבל הגידור שנדרש לסדר acquire-release מוסיף גדרות רק בתחילת ובסוף כל שרשור, וזה לא עוזר במקרה הזה. בנוסף, אנחנו צריכים לוודא שאם אחרי אחסון של volatile/atomic יש טעינה של volatile/atomic, הסדר של שניהם לא משתנה.
בדרך כלל, כדי לאכוף את זה מוסיפים גדר לא רק לפני אחסון עקבי ברצף, אלא גם אחריו.
(גם כאן, זה הרבה יותר חזק מהנדרש, כי הגדרת הגבול הזו בדרך כלל מסדרת את כל הגישות הקודמות לזיכרון ביחס לכל הגישות המאוחרות יותר).
במקום זאת, אפשר לשייך את הגדר הנוספת לטעינות עקביות ברצף. מכיוון שחנויות הן פחות נפוצות, המוסכמה שתיארנו נפוצה יותר ומשמשת ב-Android.
כמו שראינו בקטע הקודם, אנחנו צריכים להוסיף מחסום store/load בין שתי הפעולות. הקוד שמופעל במכונה הווירטואלית לגישה נדיפה ייראה בערך כך:
| עומס תנודתי | מאגר נדיף |
|---|---|
reg = A |
fence for "release" (2) |
ארכיטקטורות של מכונות אמיתיות מספקות בדרך כלל כמה סוגים של גדרות, שמסדרות סוגים שונים של גישות ויכולות להיות להן עלויות שונות. ההבדל בין שתי האפשרויות האלה הוא דק, ומושפע מהצורך לוודא שהאחסון גלוי לליבות אחרות בסדר עקבי, ושהסדר בזיכרון שנוצר משילוב של כמה גדרות מורכב בצורה נכונה. לפרטים נוספים, עיינו בדף של אוניברסיטת קיימברידג' עם מיפויים שנאספו של פעולות אטומיות למעבדים בפועל.
בארכיטקטורות מסוימות, במיוחד x86, אין צורך במחסומי "רכישה" ו "שחרור", כי החומרה תמיד אוכפת באופן משתמע סדר מספיק. לכן ב-x86 רק הגדר האחרונה (3) נוצרת בפועל. באופן דומה, ב-x86, פעולות אטומיות של קריאה-שינוי-כתיבה כוללות באופן משתמע גדר חזקה. לכן, אף פעם לא צריך גדרות. ב-ARMv7, כל הגדרות ה-fence שצוינו למעלה נדרשות.
ARMv8 מספק הוראות LDAR ו-STLR שאוכפות באופן ישיר את הדרישות של טעינות ואחסונים של Java volatile או C++ באופן עקבי. כך אפשר להימנע מהאילוצים המיותרים של שינוי הסדר שציינו למעלה. קוד Android ב-64 ביט ב-ARM משתמש בהם. בחרנו להתמקד כאן במיקום של גדרות ARMv7 כי הוא שופך יותר אור על הדרישות בפועל.
קריאה נוספת
דפי אינטרנט ומסמכים שמספקים מידע מעמיק או רחב יותר. המאמרים השימושיים יותר באופן כללי נמצאים קרוב יותר לראש הרשימה.
- מודלים של עקביות בזיכרון משותף: מדריך
- המאמר הזה נכתב ב-1995 על ידי Adve ו-Gharachorloo, והוא מקום טוב להתחיל בו אם רוצים להעמיק במודלים של עקביות בזיכרון.
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf - מחסומי זיכרון
- מאמר קצר ונחמד שמסכם את הבעיות.
https://en.wikipedia.org/wiki/Memory_barrier - מידע בסיסי על Threads
- מבוא לתכנות מרובה-תהליכים ב-C++ וב-Java, מאת הנס בוהם. דיון על מרוצי נתונים ושיטות סנכרון בסיסיות.
http://www.hboehm.info/c++mm/threadsintro.html - Java Concurrency In Practice
- הספר הזה פורסם ב-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. בחלק התחתון של הדף יש קטע שנקרא "מאפייני עקביות הזיכרון" (Memory Consistency Properties) שמסביר את ההתחייבויות של המחלקות השונות.java.util.concurrentסיכום החבילה - Java Theory and Practice: Safe Construction Techniques in Java
- במאמר הזה מוסבר בפירוט על הסכנות של בריחת הפניות במהלך בניית אובייקטים, ומוצגות הנחיות לבנאים שהם thread-safe.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html - תיאוריה ופרקטיקה של Java: ניהול תנודתיות
- מאמר נחמד שמתאר מה אפשר ומה אי אפשר לעשות עם שדות תנודתיים ב-Java.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html - ההצהרה "Double-Checked Locking is Broken" (נעילה בבדיקה כפולה לא פועלת)
- ההסבר המפורט של ביל פיו על הדרכים השונות שבהן נעילה בבדיקה כפולה נשברת בלי
volatileאוatomic. כולל C/C++ ו-Java.
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.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
- מסמכי תיעוד של מחסומי זיכרון בליבת Linux. כולל כמה דוגמאות שימושיות ואומנות ASCII.
http://www.kernel.org/doc/Documentation/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 (שפת תכנות C) פרק 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 למעבדים (אוניברסיטת קיימברידג')
- אוסף התרגומים של Jaroslav Sevcik ו-Peter Sewell של אטומי C++ לסטים שונים של סט פקודות מעבד נפוצות.
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html - האלגוריתם של דקר
- "הפתרון הנכון הראשון הידוע לבעיית המניעה ההדדית בתכנות מקבילי". במאמר בוויקיפדיה מופיע האלגוריתם המלא, עם דיון על האופן שבו צריך לעדכן אותו כדי שיעבוד עם מהדרים מודרניים לביצוע אופטימיזציה ועם חומרת SMP.
https://en.wikipedia.org/wiki/Dekker's_algorithm - הערות לגבי ARM לעומת אלפא וטיפול ביחסי תלות
- אימייל ברשימת התפוצה של arm-kernel מקטלין מרינאס. כולל סיכום נחמד של תלויות כתובת ובקרה.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html - What Every Programmer Should Know About Memory
- מאמר ארוך ומפורט מאוד על סוגים שונים של זיכרון, במיוחד מטמוני מעבד (CPU), מאת אולריך דרפר.
http://www.akkadia.org/drepper/cpumemory.pdf - הסבר על מודל הזיכרון העקבי באופן חלש של ARM
- המאמר הזה נכתב על ידי צ'ונג ואישטיאק מ-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 for Compiler Writers
- דאג לי כתב את זה כחלק מהתיעוד של JSR-133 (מודל הזיכרון של Java). הוא מכיל את קבוצת ההנחיות הראשונית להטמעה של מודל הזיכרון של Java, ששימש כותבי קומפיילרים רבים, ועדיין מצוטט באופן נרחב ועשוי לספק תובנות.
לצערנו, ארבעת סוגי ה-fence שמוזכרים כאן לא מתאימים לארכיטקטורות שנתמכות ב-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