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:
אחרי הקריאה, יש לנו שני fd-ים חדשים:
- pipefd[0] - הצד של הקריאה (read end)
- pipefd[1] - הצד של הכתיבה (write end)
כל מה שנכתוב ל-pipefd[1] עם write - נוכל לקרוא מ-pipefd[0] עם 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 עושה מאחורי הקלעים:
- יוצר pipe עם
pipe - קורא
forkפעמיים - יוצר שני processes בן - בprocess הבן הראשון (ls):
- הredirect את stdout לצד הכתיבה של הpipe עם
dup2(pipefd[1], 1) - סוגר את שני הfd-ים המקוריים של הpipe
- קורא
execve("/bin/ls", ...) - בprocess הבן השני (grep):
- הredirect את stdin לצד הקריאה של הpipe עם
dup2(pipefd[0], 0) - סוגר את שני הfd-ים המקוריים של הpipe
- קורא
execve("/bin/grep", ...) - האב סוגר את שני הצדדים של הpipe ומחכה לשני הבנים
הנה מימוש מלא:
#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:
ה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 הודעות, וסוקטים