לדלג לתוכן

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

הקדמה

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

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

עץ הprocesses.

בלינוקס ובמערכות הפעלה מודרנית, processes יוזר מודים נראים כמו עץ.
כאשר יש את הprocess היוזר מודי הראשון, והוא יוצר processes אחרים, והprocesses האלו מריצים עוד processes. וככה נוצר עץ של processes.
דיאגרמה: ארבעה processes (Process 1, 2, 3, 4) ממחישים מבנה עץ של תהליכים במערכת ההפעלה
למשל, הטרמינל הוא process שיוצר processes. כל פקודה "חיצונית" שאנחנו מריצים בטרמינל (פקודה שמריצה תוכנה אחרת) יוצרת process חדש.

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

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

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

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

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

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

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

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

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

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

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

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

getpid ו־getppid

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

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

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

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

דוגמה:

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

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

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

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

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

#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

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

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

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

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

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

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

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

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


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

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

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


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

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

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

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

דוגמה: הprocess אב שמחכה לבן

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

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

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

    if (pid == 0) {
        // child process
        printf("child: PID=%d\n", getpid());
        _exit(0); // immediate exit
    } else if (pid > 0) {
        // parent process
        printf("parent: waiting for child to exit\n");
        wait(NULL);
        printf("parent: child finished running!\n");
    }

    return 0;
}

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

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

למה זה חשוב?

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

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


return מול exit

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

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

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

int main() {
    return 0; // return code 0 indicates success
}

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

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

#include <stdlib.h>

void some_function() {
    if (critical_error) {
        exit(1); // immediate exit with an error code
    }
}

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


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

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

int status;
wait(&status);

למעשה עכשיו status יכיל את מספר שמציין את הקוד חזרה, ועוד פרמטרים על process הבן.
כדי לקרוא אותם, אנחנו משתמשים בפונקציות 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;     // everything is fine
return 1;     // there was an error
return 42;    // a specific error, meaning is up to you