מודל הזיכרון של השפה¶
נניח שיצרן כותב רשומה ואז מרים דגל שמודיע לצרכן שהיא מוכנה. על הנייר שתי השורות מופיעות בסדר הנכון. אבל סדר כתיבת השורות בתוך תהליכון אחד אינו מספיק כדי לקבוע מה מותר לתהליכון אחר לראות. נצטרך לקשור בין הפרסום לבין הקריאה באמצעות כלל של השפה.
מודל הזיכרון של C++ אינו תיאור ישיר של cache מסוים במעבד. הוא חוזה שהמהדר והמימוש צריכים לקיים. המהדר רשאי לשנות קוד כל עוד ההתנהגות נשארת במסגרת החוזה. אם בתוכנית יש data race, ההנחה שהיא רק תחזיר מדי פעם ערך ישן כבר אינה תקפה.
פרסום של אובייקט רגיל¶
התוכנית הבאה מפרסמת רשומה פעם אחת. שדות הרשומה אינם אטומיים, אבל יש סדר שמחבר את כתיבתם לקריאתם:
#include <atomic>
#include <cassert>
#include <string>
#include <thread>
struct Message {
int id = 0;
std::string text;
};
int main() {
Message message;
std::atomic<bool> ready{false};
std::jthread reader([&] {
while (!ready.load(std::memory_order_acquire))
std::this_thread::yield();
assert(message.id == 42);
assert(message.text == "ready");
});
message.id = 42;
message.text = "ready";
ready.store(true, std::memory_order_release);
reader.join();
}
הכתיבות לרשומה קודמות, בתוך תהליכון היצרן, לכתיבת הדגל. קריאת acquire שרואה את הערך שנכתב בפעולת release יוצרת קשר סנכרון מתאים. הקריאות של הרשומה קורות אחריה בתוך הצרכן. השרשרת הזו יוצרת happens-before מן הכתיבות אל הקריאות, ולכן הקריאות אינן מתחרות עם הכתיבות האלה.
ההוכחה נשענת על פרסום יחיד. אם היצרן ממשיך לשנות את המחרוזת אחרי שהרים את הדגל, הצרכן יכול לקרוא אותה במקביל לשינוי. אותו דגל אינו מנעול שמגן על כל העדכונים העתידיים. נצטרך להעביר בעלות, להוסיף שלב אישור מהצרכן או להשתמש במנגנון שמתאים לעדכונים חוזרים.
מה נחשב התנגשות¶
שתי פעולות מתנגשות כאשר הן נוגעות לאותו מקום זיכרון ולפחות אחת משנה אותו, או כאשר מעורבים תחילת חיים או סיום חיים של אובייקט באותו אחסון. כאשר הפעולות נעשות בתהליכונים שונים, לפחות אחת אינה אטומית ואין ביניהן סדר מתאים, עלול להיווצר data race. המקרה אינו מוגבל לשני סימני השמה.
למשל, thread אחד שקורא שדה מתוך node בזמן שאחר מוחק את ה־node מפר את חוזה החיים גם אם שדה המצביע שמוביל אליו אטומי. באופן דומה, קריאת size של vector אינה בטוחה במקביל ל־push_back רק משום שהקריאה נראית קטנה. המכולה מורכבת ממצב פנימי, וחוזי הספרייה קובעים אילו פעולות מקבילות מותרות.
בין פעולות בתוך thread נשתמש במושג sequenced-before בהתאם לכללי השפה. בין threads נצטרך קשר שמתקבל מנעילה, מפעולות אטומיות מתאימות, מיצירה והצטרפות לתהליכון או ממנגנון אחר שהחוזה שלו קובע סנכרון. ציור של שלוש חוליות כזה שימושי יותר ממשפט כללי כמו "המעבד רואה את הכול בסוף".
אטומי אינו עסקה שלמה¶
נבחן מונה אטומי. רצף של load ואחריו store אינו זהה לפעולה אטומית משולבת:
std::atomic<unsigned> count{0};
// Two threads may both read the same old value.
auto old = count.load(std::memory_order_relaxed);
count.store(old + 1, std::memory_order_relaxed);
// Use this instead for an atomic increment.
count.fetch_add(1, std::memory_order_relaxed);
הקטע הוא השוואה בין שתי חלופות, ולא הוראה להפעיל את שתיהן עבור אותו אירוע. בחלופה הראשונה אין data race על המונה עצמו, אך עדכון יכול ללכת לאיבוד. בחלופה השנייה הפעולה היא read-modify-write אחת. ההבחנה בין תקינות מבחינת מודל הזיכרון לבין נכונות האלגוריתם חיונית: תוכנית יכולה להיות נקייה מ־data races ועדיין לחשב תוצאה שגויה.
פעולת compare_exchange מנסה לעדכן רק אם הערך תואם לציפייה. במקרה של כישלון היא מעדכנת את משתנה expected בערך שנצפה. בגרסת weak ייתכן גם כישלון מדומה, ולכן בדרך כלל משתמשים בה בתוך לולאה שבודקת מחדש את התנאי. בכל ניסיון צריך לחשב את המועמד מתוך הציפייה העדכנית, ולא מתוך עותק ישן.
סדר שינוי אינו סדר עולמי¶
לכל אובייקט אטומי יש modification order משלו. העובדה שיש סדר מוגדר לעדכוני מונה אחד אינה מספקת סדר יחיד לכל המשתנים בתוכנית. שני דגלים אטומיים יכולים להשתתף בתצפיות שאינן מתיישבות עם הסיפור האינטואיטיבי של "כל thread רואה כל דבר באותו סדר".
כאן נכנסות אפשרויות memory order. נבחר סדר לפי המידע שאנחנו מפרסמים ולפי מי שצריך לראות אותו. לפני אופטימיזציה כדאי לכתוב את שרשרת ה־happens-before של התוכנית. אם אי אפשר להסביר מי מפרסם את המצב ומי רוכש אותו, החלפת seq_cst ב־relaxed היא ניחוש.
קריאה להעמקה¶
ההגדרות הרשמיות של תחרות נתונים וסדר בין פעולות ושל פעולות אטומיות נותנות בסיס להוכחה. הקורס משתמש ביכולות C++20, בעוד טיוטת התקן המקוונת יכולה להכיל ניסוחים ותוספות מאוחרים יותר; יש לזהות את הכלל שבו משתמשים ולא להניח שכל תוספת זמינה במהדר.