מודל הזכרון - 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) {
// we expect a == 1 because thread1 wrote to a *before* b
// but if the compiler reordered things, a might be 0!
assert(a == 1); // could fail!
}
}
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) {
// the compiler can read x once, store it in a register,
// and check the register in the loop - and never exit!
}
}
עם volatile:
volatile int x = 0;
void wait_for_x(void) {
while (x == 0) {
// the compiler must read from memory on every iteration
}
}
מתי להשתמש ב-volatile¶
1. רגיסטרים של חומרה - memory-mapped I/O:
// a hardware register mapped to a fixed address
volatile uint32_t *status_reg = (volatile uint32_t *)0x40000000;
volatile uint32_t *data_reg = (volatile uint32_t *)0x40000004;
void wait_and_read(void) {
// waiting for the READY bit to turn on
while (!(*status_reg & READY_BIT))
;
// reading the data
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;
// two threads run this
void increment(void) {
counter++; // this is not atomic! (read-modify-write)
}
למרות ש-volatile מבטיח שהגישה תהיה לזיכרון, counter++ הוא עדיין שלוש פעולות: קריאה, הגדלה, כתיבה. שני threads יכולים לקרוא את אותו ערך, להגדיל, ולכתוב - ולאבד עדכון אחד.
volatile גם לא מונע סידור מחדש של המעבד ולא נותן ערבויות סדר בין משתנים שונים.
מודל הזכרון של C11 ופעולות אטומיות - atomics¶
החל מ-C11, יש לשפת C מודל זכרון רשמי ותמיכה בפעולות אטומיות:
פעולות אטומיות בסיסיות¶
atomic_int val = 0;
atomic_store(&val, 42); // atomic write
int x = atomic_load(&val); // atomic read
atomic_fetch_add(&val, 1); // atomic increment (val++)
atomic_fetch_sub(&val, 1); // atomic decrement (val--)
השוואה והחלפה - compare and swap¶
int expected = 5;
int desired = 10;
if (atomic_compare_exchange_strong(&val, &expected, desired)) {
// the swap succeeded: val was 5, now it's 10
} else {
// the swap failed: val was not 5
// expected now contains the actual value of val
}
זה שקול ל-cmpxchg שראינו בפרק 10.1, אבל בinterface 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: prepares data and then "releases"
data = 42;
ready_flag = 1; // if this is release, thread 2 will see data=42
atomic_store_explicit(&flag, 1, memory_order_release);
acquire (לקריאה): מבטיח שכל הקריאות והכתיבות אחרי הפעולה הזו מתבצעות אחרי שהערך נקרא:
// thread 2: "acquires" and then reads data
if (atomic_load_explicit(&flag, memory_order_acquire) == 1) {
// guaranteed that 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; // preparing the data
atomic_store_explicit(&ready, 1, memory_order_release); // publishing
return NULL;
}
void *consumer(void *arg) {
while (atomic_load_explicit(&ready, memory_order_acquire) == 0)
; // waiting
printf("data = %d\n", data); // guaranteed 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"); // full barrier - all types of accesses
asm volatile("lfence" ::: "memory"); // read barrier - loads
asm volatile("sfence" ::: "memory"); // write barrier - stores
- mfence (memory fence): מבטיח שכל הקריאות והכתיבות לפני הmfence מסתיימות לפני שפעולות אחרי הmfence מתחילות
- sfence (store fence): מבטיח שכל הכתיבות לפניו מסתיימות לפני כתיבות אחריו
- lfence (load fence): מבטיח שכל הקריאות לפניו מסתיימות לפני קריאות אחריו
בלינוקס, הקרנל מגדיר מאקרואים נוחים:
// from the kernel (simplified)
#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 כוללים את שני סוגי המחסומים אוטומטית.
דוגמה מעשית: הqueue חד-יצרן חד-צרכן - 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; // the consumer reads from here
atomic_int tail; // the producer writes to here
};
void queue_init(struct spsc_queue *q) {
atomic_store(&q->head, 0);
atomic_store(&q->tail, 0);
}
// called only from the producer 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;
// checks if the queue is full
if (next_tail == atomic_load_explicit(&q->head, memory_order_acquire))
return -1; // the queue is full
q->buffer[tail] = value;
// publishes the new tail - release guarantees that the write to buffer is visible
atomic_store_explicit(&q->tail, next_tail, memory_order_release);
return 0;
}
// called only from the consumer thread
int queue_pop(struct spsc_queue *q, int *value) {
int head = atomic_load_explicit(&q->head, memory_order_relaxed);
// checks if the queue is empty
if (head == atomic_load_explicit(&q->tail, memory_order_acquire))
return -1; // the queue is empty
*value = q->buffer[head];
// publishes the new head - release guarantees that the read from buffer completed
atomic_store_explicit(&q->head, (head + 1) % QUEUE_SIZE,
memory_order_release);
return 0;
}
שימו לב: הqueue הזה עובד בלי מנעולים! ה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 או במנעול