לדלג לתוכן

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

הקדמה

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

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

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


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

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

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

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

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

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


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

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

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

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


ספריית pthreads

הספריה pthreads (קיצור של POSIX Threads) היא הinterface הסטנדרטי ליצירה וניהול של 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;

    // create a thread that will run thread_function
    int ret = pthread_create(&thread, NULL, thread_function, NULL);
    if (ret != 0) {
        fprintf(stderr, "pthread_create failed\n");
        return 1;
    }

    // wait for the thread to finish
    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 עבור processes בן.

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

אם לא קוראים ל-pthread_join ולא עושים detach, יש דליפת משאבים - בדומה לprocesses 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
--------                    --------
reads counter = 5
                            reads counter = 5
adds 1 -> 6
                            adds 1 -> 6
writes counter = 6
                            writes 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;

// initialize the lock
pthread_mutex_init(&lock, NULL);

// lock - if already locked, wait until it's released
pthread_mutex_lock(&lock);

// unlock
pthread_mutex_unlock(&lock);

// destroy the lock when done
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);   // lock before accessing
        counter++;
        pthread_mutex_unlock(&lock); // unlock after accessing
    }
    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
--------                    --------
locks lock1                 locks lock2
tries to lock lock2         tries to lock lock1
   (waiting...)                 (waiting...)

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

הדרך הפשוטה ביותר למנוע 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 ומתי בprocesses?

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

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

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


סיכום

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