לדלג לתוכן

מודל הזכרון - memory model and volatile

הקדמה

בפרק 5.8 למדנו על threads ועל הבעיות שנוצרות כשכמה threads ניגשים לאותו זכרון - race conditions, צורך במנעולים, וכו'. בפרק 8 למדנו על ארכיטקטורת מעבד מודרנית - pipeline, out-of-order execution, ו-cache.

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


הבעיה: סידור מחדש - reordering

יש שני מקורות לסידור מחדש של פעולות זכרון:

1. סידור מחדש של הקומפיילר - compiler reordering

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

int a = 0, b = 0;

void thread1(void) {
    a = 1;  // כתיבה ל-a
    b = 1;  // כתיבה ל-b
}

הקומפיילר רשאי להחליף את סדר השורות! כי מנקודת המבט של 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 מודל זכרון רשמי ותמיכה בפעולות אטומיות:

#include <stdatomic.h>

atomic_int counter = 0;

פעולות אטומיות בסיסיות

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), אבל אין שום הבטחות על סדר מול פעולות אחרות:

atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed);

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

מתי להשתמש בכל סדר

סדר שימוש טיפוסי
seq_cst כשלא בטוחים, ברירת מחדל בטוחה
acquire/release Producer/consumer, מנעולים, פרסום נתונים
relaxed מונים פשוטים, סטטיסטיקות

מחסומי קומפיילר - compiler barriers

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

asm volatile("" ::: "memory");

זה inline assembly ריק (למדנו בפרק 10.1), אבל ה-clobber "memory" אומר לקומפיילר: "הזיכרון עשוי להשתנות כאן - אל תניח שום דבר על ערכים שנשמרו ברגיסטרים."

a = 1;
asm volatile("" ::: "memory");  // הקומפיילר לא יזיז את a=1 אחרי הנקודה הזו
b = 1;

שימו לב: מחסום קומפיילר מונע רק סידור מחדש של הקומפיילר. המעבד עדיין יכול לסדר מחדש.


מחסומי מעבד - 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 או במנעול