לדלג לתוכן

5.6 צינורות הרצאה

הקדמה

בהרצאה הקודמת למדנו על מתארי קבצים (fd-ים) וראינו שבלינוקס "הכל הוא קובץ". למדנו גם על dup2 שמאפשר להפנות את stdout לקובץ. עכשיו נלמד על אחד הכלים החשובים ביותר בלינוקס - צינורות (pipes).

צינורות מאפשרים לשני תהליכים לתקשר ביניהם. תהליך אחד כותב נתונים לצינור, ותהליך אחר קורא אותם מהצד השני. זה בדיוק מה שקורה כשאנחנו כותבים בshell פקודה כמו ls | grep .c - הפלט של ls עובר דרך צינור לתוך grep.


מה זה צינור - pipe

צינור הוא ערוץ נתונים חד-כיווני בין שני מתארי קבצים. אפשר לחשוב על צינור כמו תור (queue) בקרנל - כותבים מצד אחד, וקוראים מהצד השני.

הsyscall pipe יוצר צינור:

#include <unistd.h>

int pipefd[2];
pipe(pipefd);

אחרי הקריאה, יש לנו שני fd-ים חדשים:
- pipefd[0] - הצד של הקריאה (read end)
- pipefd[1] - הצד של הכתיבה (write end)

כל מה שנכתוב ל-pipefd[1] עם write - נוכל לקרוא מ-pipefd[0] עם read.

כתיבה: write(pipefd[1], data, len)  --->  [  באפר בקרנל  ]  --->  read(pipefd[0], buf, len) :קריאה

שימוש בצינורות עם fork

צינור בתוך תהליך בודד לא כל כך שימושי (למה שתהליך יכתוב לעצמו?). הכוח האמיתי של צינורות הוא בשילוב עם fork.

נזכיר: כשקוראים ל-fork, תהליך הבן יורש את כל הfd-ים של האב (למדנו את זה בהרצאה 5.5). ולכן, אם ניצור צינור לפני הfork, שני התהליכים יחלקו את אותם fd-ים של הצינור:

לפני fork:
  תהליך אב: pipefd[0] (read), pipefd[1] (write)

אחרי fork:
  תהליך אב: pipefd[0] (read), pipefd[1] (write)
  תהליך בן: pipefd[0] (read), pipefd[1] (write)

עכשיו, אם האב כותב ל-pipefd[1] והבן קורא מ-pipefd[0], יש לנו תקשורת חד-כיוונית מאב לבן.


חשוב: סגירת הצדדים שלא בשימוש

כלל חשוב: כל תהליך חייב לסגור את הצד של הצינור שהוא לא משתמש בו.

למשל, אם האב כותב והבן קורא:
- האב צריך לסגור את pipefd[0] (הוא לא קורא)
- הבן צריך לסגור את pipefd[1] (הוא לא כותב)

למה זה כל כך חשוב? בגלל זיהוי סוף הקובץ (EOF). הsyscall read על צינור מחזיר 0 (EOF) רק כאשר כל הצדדים של הכתיבה נסגרו. אם הבן שוכח לסגור את pipefd[1], אז read לעולם לא יחזיר 0, כי עדיין יש fd פתוח לכתיבה - גם אם אף אחד לא כותב דרכו. הבן ייתקע בloop אינסופי ויחכה לנתונים שלעולם לא יגיעו.


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

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

int main() {
    int pipefd[2];
    pipe(pipefd);

    pid_t pid = fork();

    if (pid == 0) {
        // תהליך הבן - קורא מהצינור
        close(pipefd[1]);  // סוגרים את צד הכתיבה

        char buf[128];
        int n = read(pipefd[0], buf, sizeof(buf) - 1);
        buf[n] = '\0';
        printf("child received: %s\n", buf);

        close(pipefd[0]);
        _exit(0);
    } else {
        // תהליך האב - כותב לצינור
        close(pipefd[0]);  // סוגרים את צד הקריאה

        const char *msg = "hello child!";
        write(pipefd[1], msg, strlen(msg));

        close(pipefd[1]);  // סוגרים את צד הכתיבה - הבן יקבל EOF
        wait(NULL);
    }

    return 0;
}

שימו לב לסדר הפעולות:
1. יוצרים צינור עם pipe
2. קוראים fork - שני התהליכים מקבלים את הfd-ים
3. כל תהליך סוגר את הצד שהוא לא צריך
4. האב כותב, הבן קורא
5. האב סוגר את צד הכתיבה - הבן יקבל EOF ויצא מread


איך הshell מממש את האופרטור |

עכשיו נגיע לחלק הכי מעניין - איך הshell מממש את הפקודה ls | grep .c?

הנה מה שהshell עושה מאחורי הקלעים:

  1. יוצר צינור עם pipe
  2. קורא fork פעמיים - יוצר שני תהליכי בן
  3. בתהליך הבן הראשון (ls):
  4. מפנה את stdout לצד הכתיבה של הצינור עם dup2(pipefd[1], 1)
  5. סוגר את שני הfd-ים המקוריים של הצינור
  6. קורא execve("/bin/ls", ...)
  7. בתהליך הבן השני (grep):
  8. מפנה את stdin לצד הקריאה של הצינור עם dup2(pipefd[0], 0)
  9. סוגר את שני הfd-ים המקוריים של הצינור
  10. קורא execve("/bin/grep", ...)
  11. האב סוגר את שני הצדדים של הצינור ומחכה לשני הבנים
ls (stdout -> pipe write)  --->  [  צינור  ]  --->  (pipe read -> stdin) grep

הנה מימוש מלא:

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

int main() {
    int pipefd[2];
    pipe(pipefd);

    // תהליך בן ראשון - מריץ ls
    pid_t pid1 = fork();
    if (pid1 == 0) {
        // מפנים stdout לצד הכתיבה של הצינור
        dup2(pipefd[1], 1);
        close(pipefd[0]);
        close(pipefd[1]);

        char *args[] = {"/bin/ls", NULL};
        execve("/bin/ls", args, NULL);
        perror("execve ls");
        _exit(1);
    }

    // תהליך בן שני - מריץ grep
    pid_t pid2 = fork();
    if (pid2 == 0) {
        // מפנים stdin לצד הקריאה של הצינור
        dup2(pipefd[0], 0);
        close(pipefd[0]);
        close(pipefd[1]);

        char *args[] = {"/bin/grep", ".c", NULL};
        execve("/bin/grep", args, NULL);
        perror("execve grep");
        _exit(1);
    }

    // האב סוגר את הצינור ומחכה
    close(pipefd[0]);
    close(pipefd[1]);
    wait(NULL);
    wait(NULL);

    return 0;
}

הקוד הזה מבצע בדיוק את מה ש-ls | grep .c עושה בshell. הרעיון העיקרי: הפלט של ls (שנכתב ל-stdout) הולך דרך הצינור ונכנס ל-stdin של grep.


צינורות עם שם - FIFO

הצינורות שראינו עד עכשיו הם צינורות אנונימיים - הם קיימים רק בזיכרון ומשמשים תהליכים שנוצרו מאותו fork. אבל מה אם שני תהליכים שלא קשורים אחד לשני (שלא נוצרו מfork משותף) רוצים לתקשר?

בשביל זה יש צינורות עם שם, שנקראים גם FIFO (ראשי תיבות של First In First Out). צינור עם שם הוא קובץ מיוחד שנוצר במערכת הקבצים. כל תהליך יכול לפתוח אותו - אחד לכתיבה ואחד לקריאה.

יצירת FIFO:

#include <sys/stat.h>

mkfifo("/tmp/my_pipe", 0644);

הsyscall mkfifo יוצר קובץ מיוחד (מסוג pipe) בנתיב שציינו. אחרי שהFIFO נוצר, כל תהליך יכול לעשות עליו open רגיל:
- תהליך שפותח אותו עם O_WRONLY כותב אליו
- תהליך שפותח אותו עם O_RDONLY קורא ממנו

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

דוגמה - תוכנה שכותבת:

#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <sys/stat.h>

int main() {
    mkfifo("/tmp/my_pipe", 0644);

    int fd = open("/tmp/my_pipe", O_WRONLY);
    const char *msg = "hello from writer!\n";
    write(fd, msg, strlen(msg));
    close(fd);

    unlink("/tmp/my_pipe");  // מוחקים את הFIFO מהדיסק
    return 0;
}

דוגמה - תוכנה שקוראת (מריצים בטרמינל נפרד):

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

int main() {
    int fd = open("/tmp/my_pipe", O_RDONLY);

    char buf[128];
    int n = read(fd, buf, sizeof(buf) - 1);
    buf[n] = '\0';
    printf("received: %s", buf);

    close(fd);
    return 0;
}

כדי שזה יעבוד, צריך להריץ את שתי התוכנות במקביל - כל אחת בטרמינל אחר.


מנגנוני תקשורת נוספים בין תהליכים - IPC

צינורות הם המנגנון הכי נפוץ ופשוט לתקשורת בין תהליכים (IPC - Inter-Process Communication), אבל הם לא היחידים. הנה סקירה קצרה של מנגנונים נוספים שלינוקס מציעה:

זיכרון משותף - shared memory (shmget, shmat):
מאפשר לשני תהליכים לגשת לאותו אזור זיכרון פיזי. זה הכי מהיר כי אין העתקה של נתונים - שני התהליכים רואים את אותו זיכרון. אבל צריך לנהל סנכרון (synchronization) בעצמנו.

תורי הודעות - message queues (msgget, msgsnd, msgrcv):
מאפשרים שליחה וקבלה של הודעות מובנות בין תהליכים. כל הודעה היא יחידה שלמה עם סוג ותוכן.

סוקטים - Unix domain sockets:
מאפשרים תקשורת דו-כיוונית (bidirectional) בין תהליכים על אותו מחשב. הם עובדים כמו סוקטים של רשת, אבל בלי לעבור דרך ה-network stack. נלמד על סוקטים של רשת בהמשך הקורס.


מתי להשתמש במה?

מנגנון מתאים ל... יתרון עיקרי
צינור אנונימי - pipe תקשורת אב-בן, שרשור פקודות פשטות
צינור עם שם - FIFO תקשורת בין תהליכים לא קשורים לא צריך fork משותף
זיכרון משותף העברת כמויות גדולות של נתונים מהירות
סוקטים תקשורת דו-כיוונית, תקשורת רשת גמישות

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


סיכום

בהרצאה הזו למדנו:
- צינור (pipe) הוא ערוץ נתונים חד-כיווני בין שני fd-ים
- הsyscall pipe יוצר שני fd-ים: אחד לקריאה ואחד לכתיבה
- צינורות עובדים הכי טוב בשילוב עם fork - האב והבן חולקים את הצינור
- חובה לסגור את הצדדים שלא בשימוש כדי ש-EOF יעבוד נכון
- הshell משתמש ב-pipe + fork + dup2 + execve כדי לממש את האופרטור |
- צינורות עם שם (FIFO) מאפשרים תקשורת בין תהליכים שלא נוצרו מאותו fork
- קיימים מנגנוני IPC נוספים: זיכרון משותף, תורי הודעות, וסוקטים