לדלג לתוכן

5.2 תהליכים הרצאה

הקדמה

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

"תהליך" או באנגלית "Process" הוא קונספט במערכת הפעלה שבא לתאר תוכנה שרצה.
כאשר אנחנו מריצים תוכנה מסוימת, אפשר להגיד שהיא "תהליך".
לכל תהליך יש המון מאפיינים:
- משאבים שמוקצים לו בזמן ריצה- ספריות שהוא משתמש בהן, קבצים שהוא פותח (fd-ים).
- stdout, stdin, stderr משלו
- אזור זכרון משלו (paging).
- "קונטקסט" משלו (PCB).
- מגנון הרשאות- התוכנה רצה עם הרשאות מסוימות של משתמש במערכת.
- מזהה מיוחד- הprocces ID שמזהה אותו.
ועוד המון.

עץ התהליכים.

בלינוקס ובמערכות הפעלה מודרנית, תהליכים יוזר מודים נראים כמו עץ.
כאשר יש את התהליך היוזר מודי הראשון, והוא יוצר תהליכים אחרים, והתהליכים האלו מריצים עוד תהליכים. וככה נוצר עץ של תהליכים.
Pasted image 20260214135153.png
למשל, הטרמינל הוא תהליך שיוצר תהליכים. כל פקודה "חיצונית" שאנחנו מריצים בטרמינל (פקודה שמריצה תוכנה אחרת) יוצרת תהליך חדש.

התהליך היוזר מודי הראשון בלינוקס נקרא תהליך הinit, (הקרנל מרים אותו כשהוא מסיים להיטען) והוא אחראי להריץ רשימה של תהליכים שאנחנו מגדירים "שירותים"- הservice-ים והdaemon-ים (חזרו לקורס לינוקס אם שכחתם).
מטרת התהליך היא להרים את מערכת ההפעלה (הצד היוזר מודי שלה), כדי שלנו המשתמשים יהיה ממשק בסיסי עם מערכת ההפעלה כשאנחנו מריצים אותה.

אנחנו כאן פחות לדבר על init, על איך הrun levels עובדים, ועל systemd ולמה כל linux distotubtion משתמשת בזה- (אם זה מעניין אתכם, תחזרו לקורס לינוקס שכבר עשיתם) אלה יותר על איך הכל עובד מאחורי הקלעים.

כל תהליך בלינוקס מכיל pid- תכונה שמייצגת את מזהה הפרוסס (process id).
בנוסף, כל תהליך מכיל ppid- תכונה שמייצגת את מזהה הפרוסס אב (parent process id) של הפרוסס.
- אני מזכיר, התהליכים בלינוקס נראים כמו עץ, כאשר התהליך הראשון הוא התחלת העץ וכל תהליך יוצר עוד תהליכים-> כך שלכל תהליך חייב להיות תהליך אב (ppid).

כיצד זה נראה בלינוקס?

ישנם 2 syscall-ים שבדרך כלל נשתמש בהם כדי ליצור תהליך חדש.

בלינוקס פרוססים חדשים נוצרים באמצעות הsyscall שנקרא fork.
פורק, הוא syscall שפשוט משכפל את הפרוסס הנוכחי, ויוצר פרוסס בן שזהה לחלוטין לפורסס אב, רק עם pid ו- ppid חדשים. (הppid זה הpid של הפרוסס אב).
למעשה, הפרוסס בן שנוצר, הוא חולק אותם fd-ים, אותם הרשאות, אותם אזורי זכרון, כמו האב.
למעשה, הדבר היחידי ששונה זה הpcb, הpid ו- ppid.
למעשה, ישר לאחר קיראה לfork יהיה לנו תהליך זהה שיריץ בדיוק אותו קוד כמו תהליך האב.
חשבו שfork ממש מיצר העתק של התהליך אב- שניהם ממש מריצים את אותו הקוד.
לכן ישר אחרכך נכתוב תנאי if שבודק מה הpid של התהליך, כדי שנוכל ליצור קטע קוד שהאב יריץ וקטע קוד שהבן יריץ.
ואז בקטע הקוד שמיועד לתהליך הבן תבוא קריאת execve.

בלינוקס הsyscall שנקרא execve אחראי לשנות את הזכרון של תהליך לזכרון של תוכנה חדשה שמוגדרת בקובץ הרצה.
למעשה, אחרי שיצרנו תהליך בן עם fork שהוא זהה לחלוטין לתהליך שלנו (האב), נרצה לשנות לו את הזכרון- (את הקוד שהוא מריץ), ונעשה זאת עם execve ונתיב של התוכנה שאנחנו רוצים שהתהליך יריץ.
לאחר שאנחנו קוראים לfork אנחנו נקרא לexecve כדי שתהליך הבן ישנה את התוכנה שהוא מריץ (שלא יריץ את התוכנה של האב)

אנחנו יכולים לשלב את שני הsyscall-ים האלו כדי לגרום לתוכנה שלנו (לפרוסס האב) להריץ תוכנה.

הרעיון הוא כזה:

  1. התוכנית הראשית (האב) קוראת ל־fork כדי ליצור תהליך חדש. (זה יצור שני תהליכים זהים שמריצים את אותו הקוד)
  2. ניצור תנאי שבודק את ערך החזרה מfork, למעשה ערך החזרה שיחזור מfork בתהליך האב והבן הוא שונה- על סמך הערך חזרה אנחנו יכולים ליצור תנאי מסוים, שיצור קטע קוד שרק תהליך הבן יריץ וקטע קוד שתהליך האב יריץ.
  3. ב"תהליך הבן", מיד לאחר ה־fork, אנחנו קוראים ל־execve כדי להחליף את הקוד שרץ בקובץ הרצה אחר (למשל usr/bin/ls/).
  4. התוכנית הראשית יכולה לדעת אם היא תהליך אב או בן לפי הערך שמוחזר מ־fork. וכך נוכל לדאוג שתהליך האב לא יבצע את הexecve.

דוגמה בקוד (פשטני):

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        puts("this is the child process!")

        char *args[] = {"/bin/ls", "-l", NULL};
        execve("/bin/ls", args, NULL);
        perror("execve");
    } else if (pid > 0) {
        puts("this is the parent process!")
    } else {
        perror("fork failed");
    }

    return 0;
}

מה חשוב להבין?

  • הקריאה ל־fork חוזרת פעמיים – פעם אחת לאב, ופעם אחת לבן:
  • באב היא מחזירה את ה־PID של הבן.
  • בבן היא מחזירה 0.

  • הקריאה ל־execve לא "חוזרת" אם הצליחה – היא מחליפה לגמרי את הקוד של התהליך.

רגע אבל מה זה execve?

כן, אני פחות אכנס בהרצאה זו על execve, איך הוא עושה את זה- ומה קורה, אבל כרגע תדעו שהוא מקבל 3 פרמטרים
1. הנתיב לקובץ ההרצה
2. הargument-ים של התוכנה
3. הenvrioment variables שאנחנו מביאים לו.

getpid ו־getppid

כאשר אנחנו יוצרים תהליך חדש עם fork, חשוב להבין כיצד ניתן לזהות מי התהליך שרץ כרגע – האם זה תהליך האב או תהליך הבן – ולא רק לפי הערך שחוזר מ־fork.

ב־Linux קיימים שני system call-ים פשוטים שמאפשרים לנו לקבל את מזהי התהליכים בזמן ריצה:

  • הקריאה getpid() – מחזיר את ה־PID (Process ID) של התהליך הנוכחי. כלומר, מזהה ייחודי שמוקצה לכל תהליך שרץ במערכת.

הקריאה - getppid() – מחזיר את ה־PPID (Parent Process ID), כלומר את ה־PID של התהליך שיצר אותנו.

דוגמה:

#include <stdio.h>
#include <unistd.h>

int main() {
    printf("PID: %d\n", getpid());
    printf("PPID: %d\n", getppid());
    return 0;
}

אם תריצו את הקוד הזה, תקבלו שני מספרים:

  • ה־PID הוא המספר שמזהה את התוכנית שלכם ברגע זה.
  • ה־PPID הוא המספר שמזהה את התוכנית שהריצה אתכם (למשל הטרמינל או shell).

במקרה של fork, תהליך הבן יקבל PPID זהה ל־PID של תהליך האב:

#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        printf("child – PID: %d, PPID: %d\n", getpid(), getppid());
    } else if (pid > 0) {
        printf("parent – PID: %d\n", getpid());
    }

    return 0;
}

פלט לדוגמה:

parent – PID: 12345  
child – PID: 12346, PPID: 12345

המתנה לסיום תהליך בן – wait ו־waitpid

כאשר תהליך אב יוצר תהליך בן עם fork, תהליך הבן ממשיך לרוץ במקביל לאב. במצב כזה, תהליך האב יכול:

  • להמשיך לרוץ בלי קשר לבן,

  • או להמתין שתהליך הבן יסיים, ואז להמשיך.

כדי להמתין לסיום של תהליך בן, משתמשים ב־wait() או waitpid().

מה הבעיה אם לא מחכים?

כאשר תהליך בן מסיים את הריצה שלו, מערכת ההפעלה לא מוחקת אותו מיד מהזיכרון. במקום זאת, היא מסמנת אותו כ־Zombie — תהליך שסיים לרוץ, אך עדיין נשמרת עליו מעט מידע (כמו הexit code), עד שתהליך האב יקרא ל־wait() ויאסוף את התוצאה.

אם תהליך האב אינו קורא ל־wait, הזומבים מצטברים, ועלולים לגרום לדליפת משאבים במערכת. (memory leak)


wait() – המתנה לתהליך בן כלשהו

#include <sys/wait.h>
wait(NULL);

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


waitpid() – המתנה לתהליך מסוים

int status;
waitpid(pid, &status, 0);

הפונקציה waitpid מאפשרת שליטה טובה יותר:

  • ניתן להמתין לתהליך מסוים לפי PID.
  • ניתן להשתמש באופציות מתקדמות (כמו לא לחכות אם הוא עדיין רץ).

דוגמה: תהליך אב שמחכה לבן

פעולת הexit שנמצאת בסוף הקוד של תהליך הבן בעצם מסיימת את הריצה שלו עם הexit code=0. (כמו return.)

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // תהליך הבן
        printf("child: PID=%d\n", getpid());
        _exit(0); // סיום מיידי
    } else if (pid > 0) {
        // תהליך האב
        printf("parent: waiting for child to exit\n");
        wait(NULL);
        printf("parent: child finished running!\n");
    }

    return 0;
}

קוד החזרה (return code) ו־exit()

כאשר תהליך מסיים את הריצה שלו – בין אם הוא תהליך ראשי ובין אם תהליך בן – הוא מחזיר ערך למערכת ההפעלה. זה נקרא קוד חזרה או באנגלית: exit code / return code.

למה זה חשוב?

  • מערכת ההפעלה שומרת את קוד החזרה של כל תהליך שהסתיים, כדי שתהליך האב יוכל לדעת אם הבן הסתיים בהצלחה או בשגיאה.

  • בתהליכים פשוטים זה לא קריטי, אבל בתהליכים מורכבים (כמו סקריפטים, מנהלי שירותים, ו־fork+exec) – זה כלי בסיסי לבדיקת הצלחה או כישלון.


return מול exit

יש שתי דרכים עיקריות לסיים תהליך ב־C:

1. return X; מתוך main()

זוהי הדרך הפשוטה – מחזירה ערך לסיום התוכנית.

int main() {
    return 0; // קוד החזרה 0 מציין הצלחה
}

2. exit(X); – סיום מכל מקום בקוד

הפונקציה exit() מגיעה מ־stdlib.h ומאפשרת לסיים את התוכנית בכל שלב, ולא רק מתוך main.

#include <stdlib.h>

void some_function() {
    if (בעיה_קריטית) {
        exit(1); // סיום מיידי עם קוד שגיאה
    }
}

בפועל, return 0 מתוך main() שקול ל־exit(0).


קריאת קוד החזרה עם wait

כאשר תהליך אב קורא ל־wait או waitpid, הוא יכול לקבל את קוד החזרה של תהליך הבן דרך פרמטר בשם status:

int status;
wait(&status);

למעשה עכשיו status יכיל את מספר שמציין את הקוד חזרה, ועוד פרמטרים על תהליך הבן.
כדי לקרוא אותם, אנחנו משתמשים בפונקציות WIFEXITED ו- WEXITSTATUS
if (WIFEXITED(status)) {
    int exit_code = WEXITSTATUS(status);
    printf("child finished with status code %d\n", exit_code);
}

- הפונקציה WIFEXITED(status) – בודקת אם הבן הסתיים רגיל (לא עבר kill- דיברנו על זה בקורס לינוקס)
- הפונקציה WEXITSTATUS(status) – מחלצת את הקוד שחזר מ־return או exit של הבן

מוסכמה חשובה:

בלינוקס ו־UNIX בכלל:

  • קוד חזרה 0 = הצלחה
  • קוד שגיאה (1 ומעלה) = כישלון

לכן:

return 0;     // הכל תקין
return 1;     // הייתה שגיאה
return 42;    // שגיאה מסוימת עם משמעות לבחירתך