לדלג לתוכן

5.8 תהליכונים הרצאה

הקדמה

בפרק 5.2 למדנו על תהליכים ועל fork - ראינו שכאשר תהליך קורא ל-fork, נוצר תהליך בן שהוא עותק של האב, עם מרחב כתובות נפרד, טבלת fd-ים נפרדת, ובעצם הכל נפרד. זה מצוין כשאנחנו רוצים בידוד בין שני חלקי התוכנה.

אבל מה אם אנחנו רוצים כמה "זרמי ריצה" בתוך אותו התהליך, שחולקים את אותו הזכרון? למשל, תוכנה שצריכה להוריד קובץ מהרשת ובמקביל להמשיך להציג ממשק גרפי למשתמש?

כאן נכנסים לתמונה התהליכונים - threads.


תהליכונים - threads מול תהליכים - processes

נזכר ברגע מה יש לכל תהליך:
- מרחב כתובות וירטואלי משלו (page table נפרד)
- טבלת file descriptors משלו
- הרשאות משלו (uid, gid)
- הPCB (Process Control Block) משלו עם כל הרגיסטרים

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

לעומת זאת, thread (תהליכון) הוא זרם ריצה נפרד בתוך אותו תהליך. תהליכונים חולקים:
- את אותו מרחב כתובות וירטואלי - כולם רואים את אותו הזכרון
- את אותה טבלת file descriptors
- את אותם הרשאות
- את אותם משתנים גלובליים

מה שכל thread מקבל משלו:
- מחסנית (stack) משלו - כל thread צריך מחסנית נפרדת כדי לנהל קריאות לפונקציות
- רגיסטרים משלו - כי כל thread נמצא בנקודה שונה בקוד
- הthread ID משלו

בגלל שthreads חולקים זכרון ומשאבים, יצירת thread היא מהירה הרבה יותר מיצירת תהליך. גם ההחלפה (context switch) בין threads באותו תהליך מהירה יותר, כי אין צורך להחליף page tables (זוכרים מפרק 2?).


מאחורי הקלעים - clone

בלינוקס, ברמת הקרנל, אין הבדל מבני אמיתי בין "תהליך" ל-"thread". שניהם מיוצגים על ידי אותו מבנה נתונים בקרנל (task_struct).

הsyscall שיוצר גם תהליכים וגם threads הוא clone(). ההבדל הוא בדגלים שמעבירים אליו:
- כשfork קורא ל-clone, הוא מעביר דגלים שאומרים "תעתיק את הכל - זכרון, fd-ים, וכו'"
- כשיוצרים thread, קוראים ל-clone עם דגלים שאומרים "תשתף את הכל - אותו זכרון, אותם fd-ים"

אנחנו לא ניגש ישירות ל-clone (הממשק שלו מורכב). במקום זאת, נשתמש בספרייה שנקראת pthreads.


ספריית pthreads

הספריה pthreads (קיצור של POSIX Threads) היא הממשק הסטנדרטי ליצירה וניהול של threads בC על מערכות UNIX ולינוקס.

כדי להשתמש בה, כוללים את הheader:

#include <pthread.h>

חשוב מאוד: כדי לקמפל תוכניות שמשתמשות ב-pthreads, חייבים להוסיף את הדגל -lpthread:

gcc program.c -lpthread -o program

בלי הדגל הזה הקומפיילר לא ימצא את הפונקציות של pthreads ותקבלו שגיאות linkage.


יצירת thread עם pthread_create

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine)(void *), void *arg);

נפרט:
- thread - פוינטר למשתנה מסוג pthread_t שישמור את הID של ה-thread החדש.
- attr - מאפיינים מיוחדים ל-thread (בדרך כלל נעביר NULL לברירת מחדל).
- start_routine - פוינטר לפונקציה שה-thread יריץ. הפונקציה חייבת לקבל void* ולהחזיר void*.
- arg - ארגומנט שיועבר לפונקציה (אפשר להעביר כל דבר דרך void*).

ערך ההחזרה: 0 בהצלחה, קוד שגיאה אחרת.

דוגמה בסיסית - יצירת thread

#include <stdio.h>
#include <pthread.h>

void *thread_function(void *arg) {
    printf("hello from thread!\n");
    return NULL;
}

int main() {
    pthread_t thread;

    // יוצרים thread שירוץ את thread_function
    int ret = pthread_create(&thread, NULL, thread_function, NULL);
    if (ret != 0) {
        fprintf(stderr, "pthread_create failed\n");
        return 1;
    }

    // מחכים שה-thread יסיים
    pthread_join(thread, NULL);

    printf("thread finished, back in main\n");
    return 0;
}

קמפלו והריצו:

gcc example.c -lpthread -o example
./example


המתנה ל-thread עם pthread_join

int pthread_join(pthread_t thread, void **retval);

הפונקציה pthread_join עוצרת את ה-thread הקורא עד שה-thread שציינו מסיים לרוץ. זה דומה ל-wait() שלמדנו ב-5.2 עבור תהליכי בן.

  • thread - הthread ID שאנחנו ממתינים לו.
  • retval - פוינטר שבו ישמר ערך ההחזרה של ה-thread (מה שהפונקציה שלו החזירה). אפשר להעביר NULL אם לא מעניין אותנו.

אם לא קוראים ל-pthread_join ולא עושים detach, יש דליפת משאבים - בדומה לתהליכי zombie שלמדנו.


יציאה מ-thread עם pthread_exit

void pthread_exit(void *retval);

הפונקציה מסיימת את ה-thread הנוכחי ומחזירה ערך. זה כמו return מהפונקציה של ה-thread, אבל אפשר לקרוא לזה מכל מקום בקוד (לא רק מהפונקציה הראשית של ה-thread).


הבעיה: זכרון משותף ומרוץ תחרות - Race Condition

עכשיו שאנחנו יודעים ליצור threads, נגיע לנושא הכי חשוב: מה קורה כשכמה threads ניגשים לאותו המשתנה?

נסתכל על הדוגמה הבאה:

#include <stdio.h>
#include <pthread.h>

int counter = 0;

void *increment(void *arg) {
    for (int i = 0; i < 1000000; i++) {
        counter++;
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;

    pthread_create(&t1, NULL, increment, NULL);
    pthread_create(&t2, NULL, increment, NULL);

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    printf("final counter = %d\n", counter);
    return 0;
}

קמפלו והריצו כמה פעמים:

gcc race.c -lpthread -o race
./race
./race
./race

ציפינו לקבל 2000000, אבל ברוב הפעמים נקבל מספר שונה וקטן יותר. למה?

מרוץ תחרות - Race Condition

הפעולה counter++ נראית כמו פעולה אחת, אבל בפועל היא שלוש פעולות ברמת המעבד:
1. קריאת הערך הנוכחי של counter מהזכרון לרגיסטר
2. הוספת 1 לרגיסטר
3. כתיבת הערך החדש חזרה לזכרון

עכשיו דמיינו שני threads שרצים במקביל:

Thread A                    Thread B
--------                    --------
קורא counter = 5
                            קורא counter = 5
מוסיף 1 -> 6
                            מוסיף 1 -> 6
כותב counter = 6
                            כותב counter = 6

שני ה-threads קראו את אותו ערך (5), שניהם הוסיפו 1 וקיבלו 6, ושניהם כתבו 6. אבל counter היה אמור להיות 7! איבדנו עדכון אחד.

זה נקרא מרוץ תחרות (race condition) - כאשר התוצאה של התוכנית תלויה בתזמון של ה-threads, ולא רק בלוגיקה של הקוד.


נעילה הדדית - Mutex

כדי לפתור את בעיית מרוץ התחרות, אנחנו צריכים מנגנון שמבטיח שרק thread אחד בכל רגע נתון ניגש לאזור הקריטי (הקוד שמשנה את המשתנה המשותף).

המנגנון הזה נקרא mutex (קיצור של Mutual Exclusion - נעילה הדדית).

הרעיון פשוט: לפני שthread ניגש למשתנה משותף, הוא "נועל" את המנעול. אם thread אחר מנסה לנעול את אותו מנעול, הוא נתקע ומחכה. כשה-thread הראשון מסיים ו"משחרר" את המנעול, ה-thread שחיכה יכול להמשיך.

הפונקציות:

pthread_mutex_t lock;

// אתחול המנעול
pthread_mutex_init(&lock, NULL);

// נעילה - אם כבר נעול, מחכים עד שישתחרר
pthread_mutex_lock(&lock);

// שחרור הנעילה
pthread_mutex_unlock(&lock);

// מחיקת המנעול כשסיימנו
pthread_mutex_destroy(&lock);

דוגמה - תיקון מרוץ התחרות עם mutex

#include <stdio.h>
#include <pthread.h>

int counter = 0;
pthread_mutex_t lock;

void *increment(void *arg) {
    for (int i = 0; i < 1000000; i++) {
        pthread_mutex_lock(&lock);   // נועלים לפני הגישה
        counter++;
        pthread_mutex_unlock(&lock); // משחררים אחרי הגישה
    }
    return NULL;
}

int main() {
    pthread_mutex_init(&lock, NULL);

    pthread_t t1, t2;

    pthread_create(&t1, NULL, increment, NULL);
    pthread_create(&t2, NULL, increment, NULL);

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    printf("final counter = %d\n", counter);

    pthread_mutex_destroy(&lock);
    return 0;
}

עכשיו התוצאה תמיד תהיה 2000000, כי בכל רגע רק thread אחד יכול להריץ את counter++.

שימו לב שהmutex מאט את הביצועים - כי threads נאלצים לחכות אחד לשני. לכן חשוב לנעול רק את הקטע ההכרחי (ה"אזור הקריטי") ולא יותר מדי קוד.


קיפאון - Deadlock

כשעובדים עם מנעולים, יש סכנה שנקראת deadlock (קיפאון). זה קורה כשיש לנו שני מנעולים (או יותר) ושני threads שנועלים אותם בסדר הפוך:

Thread A                    Thread B
--------                    --------
נועל lock1                  נועל lock2
מנסה לנעול lock2            מנסה לנעול lock1
   (מחכה...)                   (מחכה...)

שניהם מחכים אחד לשני לנצח. התוכנית נתקעת.

הדרך הפשוטה ביותר למנוע deadlock: תמיד לנעול מנעולים באותו סדר. אם כל ה-threads תמיד נועלים קודם את lock1 ואז את lock2, לא ייתכן deadlock.


אחסון מקומי לthread - Thread-Local Storage

לפעמים אנחנו רוצים משתנה גלובלי שהוא "גלובלי" רק בתוך ה-thread - כל thread מקבל עותק פרטי משלו.

בC אפשר לעשות את זה עם המילה __thread:

__thread int my_counter = 0;

עכשיו כל thread שניגש ל-my_counter ניגש לעותק שלו. שינוי בthread אחד לא משפיע על האחרים.

זה שימושי למשל עבור errno - כל thread צריך errno משלו, אחרת thread אחד היה יכול לדרוס את ה-errno של thread אחר.


מתי להשתמש ב-threads ומתי בתהליכים?

נעדיף threads כאשר:
- צריכים לשתף זכרון בקלות בין זרמי הריצה
- רוצים ביצועים - יצירת thread והחלפה בין threads מהירים יותר
- יש משימה שאפשר לחלק לחלקים שרצים במקביל (למשל חישוב מקבילי על מערך)

נעדיף תהליכים (fork) כאשר:
- רוצים בידוד מלא - אם חלק אחד של התוכנה קורס, השאר ממשיך לעבוד
- נושאי אבטחה - תהליכים עם הרשאות שונות
- רוצים להריץ תוכנה חיצונית (fork + execve כפי שלמדנו ב-5.2)

בפועל, הרבה תוכנות משתמשות בשילוב של שניהם. למשל, דפדפן אינטרנט משתמש בתהליכים נפרדים לכל tab (בידוד), ובתוך כל תהליך יש threads לדברים שונים (ריצת JavaScript, טעינת תמונות, ציור המסך).


סיכום

למדנו על threads - זרמי ריצה שחולקים את אותו מרחב זכרון בתוך תהליך. ראינו שב-Linux גם תהליכים וגם threads נוצרים עם clone, וההבדל הוא בדגלי השיתוף. למדנו להשתמש ב-pthreads ליצירת threads, ולהתמודד עם מרוץ תחרות באמצעות mutex. ולבסוף, הבנו מתי כדאי להשתמש ב-threads ומתי בתהליכים נפרדים.