5.6 צינורות הרצאה
הקדמה¶
בהרצאה הקודמת למדנו על מתארי קבצים (fd-ים) וראינו שבלינוקס "הכל הוא קובץ". למדנו גם על dup2 שמאפשר להפנות את stdout לקובץ. עכשיו נלמד על אחד הכלים החשובים ביותר בלינוקס - צינורות (pipes).
צינורות מאפשרים לשני תהליכים לתקשר ביניהם. תהליך אחד כותב נתונים לצינור, ותהליך אחר קורא אותם מהצד השני. זה בדיוק מה שקורה כשאנחנו כותבים בshell פקודה כמו ls | grep .c - הפלט של ls עובר דרך צינור לתוך grep.
מה זה צינור - pipe¶
צינור הוא ערוץ נתונים חד-כיווני בין שני מתארי קבצים. אפשר לחשוב על צינור כמו תור (queue) בקרנל - כותבים מצד אחד, וקוראים מהצד השני.
הsyscall pipe יוצר צינור:
אחרי הקריאה, יש לנו שני fd-ים חדשים:
- pipefd[0] - הצד של הקריאה (read end)
- pipefd[1] - הצד של הכתיבה (write end)
כל מה שנכתוב ל-pipefd[1] עם write - נוכל לקרוא מ-pipefd[0] עם read.
שימוש בצינורות עם 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 עושה מאחורי הקלעים:
- יוצר צינור עם
pipe - קורא
forkפעמיים - יוצר שני תהליכי בן - בתהליך הבן הראשון (ls):
- מפנה את stdout לצד הכתיבה של הצינור עם
dup2(pipefd[1], 1) - סוגר את שני הfd-ים המקוריים של הצינור
- קורא
execve("/bin/ls", ...) - בתהליך הבן השני (grep):
- מפנה את stdin לצד הקריאה של הצינור עם
dup2(pipefd[0], 0) - סוגר את שני הfd-ים המקוריים של הצינור
- קורא
execve("/bin/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:
ה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 נוספים: זיכרון משותף, תורי הודעות, וסוקטים