מודל הזכרון - memory model and volatile¶
הקדמה¶
בפרק 5.8 למדנו על threads ועל הבעיות שנוצרות כשכמה threads ניגשים לאותו זכרון - race conditions, צורך במנעולים, וכו'. בפרק 8 למדנו על ארכיטקטורת מעבד מודרנית - pipeline, out-of-order execution, ו-cache.
בהרצאה הזו נחבר את שני הנושאים ונבין שאלה קריטית: כשthread אחד כותב לזכרון, מתי thread אחר רואה את הכתיבה? התשובה היא לא פשוטה כמו שנדמה, כי גם הקומפיילר וגם המעבד מסדרים מחדש גישות לזכרון.
הבעיה: סידור מחדש - reordering¶
יש שני מקורות לסידור מחדש של פעולות זכרון:
1. סידור מחדש של הקומפיילר - compiler reordering¶
הקומפיילר רשאי לסדר מחדש פעולות קריאה וכתיבה לזכרון, כל עוד התוצאה זהה מנקודת המבט של thread בודד:
הקומפיילר רשאי להחליף את סדר השורות! כי מנקודת המבט של thread1, אין הבדל אם כותבים קודם ל-a או קודם ל-b.
אבל מה אם thread אחר מסתמך על הסדר?
void thread2(void) {
if (b == 1) {
// אנחנו מצפים ש-a == 1 כי thread1 כתב ל-a *לפני* b
// אבל אם הקומפיילר סידר מחדש, ייתכן ש-a == 0!
assert(a == 1); // יכול להיכשל!
}
}
2. סידור מחדש של המעבד - CPU reordering¶
גם אם הקומפיילר שומר על הסדר, המעבד עצמו יכול לבצע פעולות בסדר שונה. למדנו בפרק 8 על out-of-order execution - המעבד מבצע פקודות בסדר שונה מסדר התוכנית כדי לנצל את הpipeline בצורה מיטבית.
ב-x86, המעבד נותן הבטחה חזקה יחסית (total store ordering), אבל עדיין יכול לסדר מחדש כתיבה-ואז-קריאה (store-load reordering). בארכיטקטורות אחרות כמו ARM, ההבטחה חלשה הרבה יותר.
מילת המפתח volatile¶
מה volatile עושה¶
volatile אומר לקומפיילר: אל תבצע אופטימיזציות על הגישות למשתנה הזה. כל קריאה חייבת לקרוא מהזיכרון, וכל כתיבה חייבת לכתוב לזיכרון.
בלי volatile:
int x = 0;
void wait_for_x(void) {
while (x == 0) {
// הקומפיילר יכול לקרוא את x פעם אחת, לשמור ברגיסטר,
// ולבדוק את הרגיסטר בלולאה - ולעולם לא לצאת!
}
}
עם volatile:
volatile int x = 0;
void wait_for_x(void) {
while (x == 0) {
// הקומפיילר חייב לקרוא מהזיכרון בכל איטרציה
}
}
מתי להשתמש ב-volatile¶
1. רגיסטרים של חומרה - memory-mapped I/O:
// רגיסטר חומרה שממופה לכתובת קבועה
volatile uint32_t *status_reg = (volatile uint32_t *)0x40000000;
volatile uint32_t *data_reg = (volatile uint32_t *)0x40000004;
void wait_and_read(void) {
// ממתינים שביט READY ידלק
while (!(*status_reg & READY_BIT))
;
// קוראים את הנתונים
uint32_t data = *data_reg;
}
בלי volatile, הקומפיילר עשוי:
- לקרוא את status_reg פעם אחת ולהשתמש בערך הישן בלולאה
- לסדר מחדש את הקריאה מ-data_reg לפני שstatus_reg מוכן
- להסיר את הכתיבה ל-status_reg אם "לא משתמשים בתוצאה"
2. משתנים שמשתנים ב-signal handler:
volatile sig_atomic_t got_signal = 0;
void handler(int sig) {
got_signal = 1;
}
int main(void) {
signal(SIGINT, handler);
while (!got_signal) {
do_work();
}
printf("caught signal!\n");
return 0;
}
למדנו על signal handlers בפרק 5.4. בלי volatile, הקומפיילר לא יודע שהhandler משנה את got_signal ועלול לבצע אופטימיזציה.
מתי volatile לא מספיק¶
volatile לבד לא מספיק לthread safety!
volatile int counter = 0;
// שני threads מריצים את זה
void increment(void) {
counter++; // זה לא אטומי! (read-modify-write)
}
למרות ש-volatile מבטיח שהגישה תהיה לזיכרון, counter++ הוא עדיין שלוש פעולות: קריאה, הגדלה, כתיבה. שני threads יכולים לקרוא את אותו ערך, להגדיל, ולכתוב - ולאבד עדכון אחד.
volatile גם לא מונע סידור מחדש של המעבד ולא נותן ערבויות סדר בין משתנים שונים.
מודל הזכרון של C11 ופעולות אטומיות - atomics¶
החל מ-C11, יש לשפת C מודל זכרון רשמי ותמיכה בפעולות אטומיות:
פעולות אטומיות בסיסיות¶
atomic_int val = 0;
atomic_store(&val, 42); // כתיבה אטומית
int x = atomic_load(&val); // קריאה אטומית
atomic_fetch_add(&val, 1); // הגדלה אטומית (val++)
atomic_fetch_sub(&val, 1); // הקטנה אטומית (val--)
השוואה והחלפה - compare and swap¶
int expected = 5;
int desired = 10;
if (atomic_compare_exchange_strong(&val, &expected, desired)) {
// ההחלפה הצליחה: val היה 5, עכשיו הוא 10
} else {
// ההחלפה נכשלה: val לא היה 5
// expected עכשיו מכיל את הערך האמיתי של val
}
זה שקול ל-cmpxchg שראינו בפרק 10.1, אבל בממשק C סטנדרטי.
סדרי זכרון - memory orderings¶
כל פעולה אטומית יכולה לקבל פרמטר שמציין את סדר הזכרון הנדרש. זה אומר לקומפיילר ולמעבד איזה סידור מחדש מותר:
memory_order_seq_cst - עקביות רציפה (ברירת מחדל)¶
הסדר הכי חזק. מבטיח שכל הפעולות האטומיות נראות בסדר קבוע ומוסכם לכל הthreads:
atomic_store_explicit(&val, 42, memory_order_seq_cst);
int x = atomic_load_explicit(&val, memory_order_seq_cst);
זו ברירת המחדל - כשכותבים atomic_store(&val, 42) זה שקול ל-seq_cst. הכי קל לחשוב עליו, אבל הכי איטי.
memory_order_acquire ו-memory_order_release¶
release (לכתיבה): מבטיח שכל הקריאות והכתיבות לפני הפעולה הזו נראות לthreads אחרים לפני שהם רואים את הכתיבה הזו:
// thread 1: מכין נתונים ואז "משחרר"
data = 42;
ready_flag = 1; // אם זו release, thread 2 יראה data=42
atomic_store_explicit(&flag, 1, memory_order_release);
acquire (לקריאה): מבטיח שכל הקריאות והכתיבות אחרי הפעולה הזו מתבצעות אחרי שהערך נקרא:
// thread 2: "רוכש" ואז קורא נתונים
if (atomic_load_explicit(&flag, memory_order_acquire) == 1) {
// מובטח ש-data == 42
printf("%d\n", data);
}
acquire/release יוצרים "סינכרון" בין הthread שכותב (release) לthread שקורא (acquire). כל מה שthread 1 כתב לפני הrelease מובטח שייראה על ידי thread 2 אחרי הacquire.
דוגמה מעשית - producer/consumer:
#include <stdatomic.h>
#include <pthread.h>
#include <stdio.h>
int data;
atomic_int ready = 0;
void *producer(void *arg) {
data = 42; // הכנת הנתונים
atomic_store_explicit(&ready, 1, memory_order_release); // פרסום
return NULL;
}
void *consumer(void *arg) {
while (atomic_load_explicit(&ready, memory_order_acquire) == 0)
; // ממתין
printf("data = %d\n", data); // מובטח 42
return NULL;
}
memory_order_relaxed - ללא הבטחות סדר¶
הפעולה אטומית (לא ייגרם data race), אבל אין שום הבטחות על סדר מול פעולות אחרות:
שימושי כשצריך רק מונה מדויק ולא אכפת מהסדר - למשל, ספירת אירועים.
מתי להשתמש בכל סדר¶
| סדר | שימוש טיפוסי |
|---|---|
seq_cst |
כשלא בטוחים, ברירת מחדל בטוחה |
acquire/release |
Producer/consumer, מנעולים, פרסום נתונים |
relaxed |
מונים פשוטים, סטטיסטיקות |
מחסומי קומפיילר - compiler barriers¶
מחסום קומפיילר מונע מהקומפיילר לסדר מחדש פעולות זכרון מעבר לנקודה הזו:
זה inline assembly ריק (למדנו בפרק 10.1), אבל ה-clobber "memory" אומר לקומפיילר: "הזיכרון עשוי להשתנות כאן - אל תניח שום דבר על ערכים שנשמרו ברגיסטרים."
שימו לב: מחסום קומפיילר מונע רק סידור מחדש של הקומפיילר. המעבד עדיין יכול לסדר מחדש.
מחסומי מעבד - CPU memory barriers¶
כדי למנוע סידור מחדש של המעבד, צריך פקודות מעבד מיוחדות:
// x86 memory barriers
asm volatile("mfence" ::: "memory"); // מחסום מלא - כל סוגי הגישות
asm volatile("lfence" ::: "memory"); // מחסום קריאות - loads
asm volatile("sfence" ::: "memory"); // מחסום כתיבות - stores
- mfence (memory fence): מבטיח שכל הקריאות והכתיבות לפני הmfence מסתיימות לפני שפעולות אחרי הmfence מתחילות
- sfence (store fence): מבטיח שכל הכתיבות לפניו מסתיימות לפני כתיבות אחריו
- lfence (load fence): מבטיח שכל הקריאות לפניו מסתיימות לפני קריאות אחריו
בלינוקס, הקרנל מגדיר מאקרואים נוחים:
// מתוך הקרנל (מפושט)
#define smp_mb() asm volatile("mfence" ::: "memory")
#define smp_rmb() asm volatile("lfence" ::: "memory")
#define smp_wmb() asm volatile("sfence" ::: "memory")
ההבדל בין מחסום קומפיילר למחסום מעבד¶
חשוב להבין את ההבדל:
| מחסום קומפיילר | מחסום מעבד | |
|---|---|---|
| מונע מ... | קומפיילר לסדר מחדש | מעבד לסדר מחדש |
| פקודת אסמבלי | ריקה (אין פקודה) | mfence/lfence/sfence |
| עלות ביצועים | אפס (רק מגביל קומפיילר) | גבוהה (flush של buffers) |
| מספיק ל... | thread בודד, signal handlers | ריבוי threads, ריבוי ליבות |
כלל אצבע: כשצריך סינכרון בין threads, השתמשו ב-atomics עם הסדר המתאים. הatomics כוללים את שני סוגי המחסומים אוטומטית.
דוגמה מעשית: תור חד-יצרן חד-צרכן - lock-free SPSC queue¶
#include <stdatomic.h>
#include <string.h>
#include <stdio.h>
#define QUEUE_SIZE 1024
struct spsc_queue {
int buffer[QUEUE_SIZE];
atomic_int head; // הצרכן קורא מכאן
atomic_int tail; // היצרן כותב לכאן
};
void queue_init(struct spsc_queue *q) {
atomic_store(&q->head, 0);
atomic_store(&q->tail, 0);
}
// נקרא רק מthread היצרן
int queue_push(struct spsc_queue *q, int value) {
int tail = atomic_load_explicit(&q->tail, memory_order_relaxed);
int next_tail = (tail + 1) % QUEUE_SIZE;
// בודק אם התור מלא
if (next_tail == atomic_load_explicit(&q->head, memory_order_acquire))
return -1; // התור מלא
q->buffer[tail] = value;
// מפרסם את הזנב החדש - release מבטיח שהכתיבה ל-buffer נראית
atomic_store_explicit(&q->tail, next_tail, memory_order_release);
return 0;
}
// נקרא רק מthread הצרכן
int queue_pop(struct spsc_queue *q, int *value) {
int head = atomic_load_explicit(&q->head, memory_order_relaxed);
// בודק אם התור ריק
if (head == atomic_load_explicit(&q->tail, memory_order_acquire))
return -1; // התור ריק
*value = q->buffer[head];
// מפרסם את הראש החדש - release מבטיח שהקריאה מ-buffer הושלמה
atomic_store_explicit(&q->head, (head + 1) % QUEUE_SIZE,
memory_order_release);
return 0;
}
שימו לב: התור הזה עובד בלי מנעולים! הacquire/release מבטיחים שהנתונים ב-buffer נראים בסדר הנכון:
- כשהיצרן כותב ל-buffer ואז עושה release store ל-tail, הצרכן שעושה acquire load מ-tail מובטח שיראה את הנתונים שהיצרן כתב
- כשהצרכן קורא מ-buffer ואז עושה release store ל-head, היצרן שעושה acquire load מ-head מובטח שהקריאה הסתיימה
למה זה חשוב¶
בלי הבנה של מודל הזכרון, קוד מרובה threads יכול "לעבוד" על מכונה אחת ולהיכשל על אחרת, או לעבוד בגרסה אחת של הקומפיילר ולהישבר בגרסה הבאה.
הנה תקציר של מה להשתמש במה:
| מצב | כלי |
|---|---|
| משתנה שנקרא/נכתב על ידי signal handler | volatile sig_atomic_t |
| רגיסטר חומרה | volatile |
| מונה משותף בין threads | atomic_int עם relaxed |
| דגל "מוכן" בין threads | atomic_int עם acquire/release |
| כשלא בטוחים | atomic_int עם seq_cst (ברירת מחדל) |
| שיתוף מבנים מורכבים | מנעול (pthread_mutex) |
סיכום¶
- גם הקומפיילר וגם המעבד יכולים לסדר מחדש גישות לזיכרון
volatileמונע אופטימיזציות קומפיילר אבל לא מספיק ל-thread safety- C11 atomics (
<stdatomic.h>) מספקים פעולות אטומיות עם ערבויות סדר - סדרי זכרון: seq_cst (חזק, ברירת מחדל) > acquire/release (בינוני) > relaxed (חלש)
- מחסום קומפיילר מונע סידור מחדש של הקומפיילר, מחסום מעבד מונע סידור מחדש של החומרה
- atomics כוללים את שני סוגי המחסומים אוטומטית
- כשלא בטוחים, השתמשו ב-seq_cst או במנעול