לדלג לתוכן

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

הקדמה

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

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


מה זה צינור - pipe

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

הsyscall pipe יוצר 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: write(pipefd[1], data, len)  --->  [  kernel buffer  ]  --->  read(pipefd[0], buf, len) :read

שימוש בpipes עם fork

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

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

before fork:
  parent process: pipefd[0] (read), pipefd[1] (write)

after fork:
  parent process: pipefd[0] (read), pipefd[1] (write)
  child process: pipefd[0] (read), pipefd[1] (write)

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


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

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

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

למה זה כל כך חשוב? בגלל זיהוי סוף הקובץ (EOF). הsyscall read על pipe מחזיר 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) {
        // child process - reads from the pipe
        close(pipefd[1]);  // close the write end

        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 {
        // parent process - writes to the pipe
        close(pipefd[0]);  // close the read end

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

        close(pipefd[1]);  // close the write end - the child will get EOF
        wait(NULL);
    }

    return 0;
}

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


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

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

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

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

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

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

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

    // first child process - runs ls
    pid_t pid1 = fork();
    if (pid1 == 0) {
        // redirect stdout to the write end of the pipe
        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);
    }

    // second child process - runs grep
    pid_t pid2 = fork();
    if (pid2 == 0) {
        // redirect stdin to the read end of the pipe
        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);
    }

    // the parent closes the pipe and waits
    close(pipefd[0]);
    close(pipefd[1]);
    wait(NULL);
    wait(NULL);

    return 0;
}

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


הpipes עם שם - FIFO

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

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

יצירת FIFO:

#include <sys/stat.h>

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

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

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

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

#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");  // delete the FIFO from disk
    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;
}

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


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

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

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

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

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


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

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

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


סיכום

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