בפרק הקודם למדנו על תהליכים - איך יוצרים אותם עם fork, איך מחליפים את הקוד שלהם עם exec, ואיך מחכים שיסיימו עם wait.
עכשיו נלמד על מנגנון חשוב שמאפשר תקשורת בין תהליכים ובין הקרנל לתהליכים - סיגנלים.
סיגנלים - signals¶
מה זה סיגנל¶
סיגנל הוא הודעה אסינכרונית שנשלחת לתהליך.
אפשר לחשוב על זה כמו פסיקת תוכנה - בדיוק כמו שפסיקות חומרה (שלמדנו עליהן בפרק 1) מפסיקות את המעבד ומעבירות שליטה לhandler מסוים, סיגנלים מפסיקים את התהליך ומעבירים שליטה לhandler שהוגדר מראש.
סיגנלים יכולים להישלח:
- מהקרנל לתהליך - לדוגמה, כשתהליך ניגש לכתובת לא חוקית בזיכרון, הקרנל שולח לו SIGSEGV
- מתהליך לתהליך - לדוגמה, כשאנחנו רוצים לעצור תהליך אחר
- מהמשתמש לתהליך - לדוגמה, כשלוחצים Ctrl+C בטרמינל
טבלת הסיגנלים החשובים¶
לכל סיגנל יש מספר ושם. הנה הסיגנלים החשובים ביותר:
| מספר | שם | מתי נשלח | התנהגות ברירת מחדל |
|---|---|---|---|
| 2 | SIGINT | לחיצה על Ctrl+C בטרמינל | סיום התהליך |
| 9 | SIGKILL | בקשה להרוג תהליך בכוח | סיום מיידי - לא ניתן לתפוס |
| 11 | SIGSEGV | גישה לכתובת לא חוקית בזיכרון (segmentation fault) | סיום + core dump |
| 13 | SIGPIPE | כתיבה לpipe שבור (הצד השני סגור) | סיום התהליך |
| 14 | SIGALRM | טיימר שהגדרנו עם alarm() הסתיים | סיום התהליך |
| 15 | SIGTERM | בקשה מנומסת לסיום (הסיגנל שkill שולח כברירת מחדל) | סיום התהליך |
| 17 | SIGCHLD | תהליך בן סיים | התעלמות |
| 19 | SIGSTOP | עצירת תהליך (כמו Ctrl+Z) | עצירה - לא ניתן לתפוס |
| 18 | SIGCONT | המשך תהליך שנעצר | המשך ריצה |
| 10 | SIGUSR1 | סיגנל מותאם אישית 1 | סיום התהליך |
| 12 | SIGUSR2 | סיגנל מותאם אישית 2 | סיום התהליך |
התנהגות ברירת מחדל¶
כשתהליך מקבל סיגנל ולא הגדיר handler מותאם אישית, אחת מארבע פעולות קורה:
1. סיום (terminate) - התהליך מת. רוב הסיגנלים עושים את זה
2. התעלמות (ignore) - כאילו כלום לא קרה. לדוגמה SIGCHLD
3. עצירה (stop) - התהליך נעצר (אפשר להמשיך אותו עם SIGCONT)
4. סיום + core dump - התהליך מת ונוצר קובץ core עם תמונת הזיכרון שלו (שימושי לדיבוג). לדוגמה SIGSEGV
שליחת סיגנלים - הsyscall של kill¶
כדי לשלוח סיגנל לתהליך, משתמשים בsyscall בשם kill:
pid- מזהה התהליך שאליו שולחים את הסיגנלsig- מספר הסיגנל
למרות השם, kill לא בהכרח הורג את התהליך - הוא פשוט שולח סיגנל. מה שקורה תלוי בסיגנל ובhandler שהתהליך הגדיר.
#include <stdio.h>
#include <signal.h>
int main() {
pid_t target_pid = 1234;
// שליחת SIGTERM לתהליך 1234
kill(target_pid, SIGTERM);
// שליחת SIGKILL לתהליך 1234
kill(target_pid, SIGKILL);
return 0;
}
הפקודה kill בshell¶
הפקודה kill בshell היא בעצם wrapper לsyscall:
# שליחת SIGTERM (ברירת מחדל)
kill 1234
# שליחת SIGKILL
kill -9 1234
# שליחת SIGSTOP
kill -STOP 1234
# שליחת SIGCONT
kill -CONT 1234
כשאנחנו כותבים kill -9 1234 בshell, הshell קורא לsyscall kill(1234, 9).
שליחת סיגנל לעצמך - raise¶
הפונקציה raise מאפשרת לתהליך לשלוח סיגנל לעצמו:
לדוגמה:
#include <stdio.h>
#include <signal.h>
int main() {
printf("לפני הסיגנל\n");
raise(SIGTERM); // שולח SIGTERM לעצמנו
printf("אחרי הסיגנל\n"); // השורה הזו לא תודפס
return 0;
}
טיפול בסיגנלים - signal handlers¶
עד עכשיו ראינו את התנהגות ברירת המחדל. אבל אנחנו יכולים לתפוס סיגנלים ולהגדיר פונקציה מותאמת אישית שתרוץ כשהסיגנל מגיע.
הגישה הבסיסית - signal()¶
הדרך הפשוטה ביותר לתפוס סיגנל:
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
הפונקציה signal מקבלת:
- מספר סיגנל
- מצביע לפונקציית handler (שמקבלת int - מספר הסיגנל)
דוגמה:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void my_handler(int signum) {
printf("קיבלתי סיגנל %d\n", signum);
}
int main() {
// רישום handler לSIGINT
signal(SIGINT, my_handler);
printf("לוחצים Ctrl+C כדי לשלוח SIGINT...\n");
while (1) {
sleep(1); // ממתינים
}
return 0;
}
כשנריץ את התוכנית הזו ונלחץ Ctrl+C, במקום שהתוכנית תמות - הפונקציה my_handler תרוץ ותדפיס הודעה.
הגישה המודרנית - sigaction()¶
הפונקציה signal() עובדת, אבל יש לה כמה בעיות - ההתנהגות שלה משתנה בין מערכות הפעלה שונות, ויש מגבלות על מה אפשר לעשות בתוך הhandler.
הדרך המומלצת היא להשתמש ב-sigaction():
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
מבנה הstruct:
struct sigaction {
void (*sa_handler)(int); // הפונקציה שתרוץ
sigset_t sa_mask; // סיגנלים לחסום בזמן ריצת הhandler
int sa_flags; // דגלים נוספים
};
sa_handler- הפונקציה שתטפל בסיגנל (כמו בsignal)sa_mask- קבוצת סיגנלים שיחסמו בזמן שהhandler רץ (כדי למנוע מצבי race)sa_flags- דגלים שמשפיעים על ההתנהגות. לדוגמהSA_RESTARTגורם לsyscalls שהופסקו להתחיל מחדש אוטומטית
דוגמה מלאה:
#include <stdio.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
void handler(int signum) {
const char *msg = "קיבלתי SIGINT!\n";
write(STDOUT_FILENO, msg, strlen(msg));
}
int main() {
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGINT, &sa, NULL);
printf("ממתין לסיגנלים...\n");
while (1) {
sleep(1);
}
return 0;
}
שימו לב שבתוך הhandler השתמשנו ב-write ולא ב-printf. מיד נסביר למה.
סיגנלים שלא ניתן לתפוס¶
יש שני סיגנלים שאי אפשר לתפוס, לחסום, או להתעלם מהם:
- SIGKILL (9) - הריגה מיידית. לא משנה מה, התהליך ימות
- SIGSTOP (19) - עצירה מיידית. לא משנה מה, התהליך ייעצר
למה? כי אם תהליך יכול היה לתפוס SIGKILL, לא הייתה לנו שום דרך להרוג תהליך שמתנהג רע. מערכת ההפעלה חייבת לשמור לעצמה את היכולת להרוג תהליכים בכוח.
אם ננסה לרשום handler לSIGKILL או SIGSTOP, הקריאה תיכשל.
טיימר עם alarm¶
הפונקציה alarm() מגדירה טיימר שאחרי מספר שניות שולח לתהליך SIGALRM:
דוגמה:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void alarm_handler(int signum) {
printf("הזמן נגמר!\n");
_exit(0);
}
int main() {
signal(SIGALRM, alarm_handler);
alarm(5); // טיימר ל-5 שניות
printf("יש לך 5 שניות...\n");
while (1) {
sleep(1);
printf("עדיין רץ...\n");
}
return 0;
}
דוגמה מעשית - כיבוי נקי עם SIGTERM¶
בתוכניות אמיתיות, הרבה פעמים נרצה "לנקות" לפני שהתוכנית נסגרת - לסגור קבצים, לשחרר זיכרון, לשמור מצב.
הנה דוגמה מלאה:
#include <stdio.h>
#include <signal.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
volatile sig_atomic_t running = 1;
void shutdown_handler(int signum) {
running = 0;
}
int main() {
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = shutdown_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT, &sa, NULL);
FILE *logfile = fopen("app.log", "w");
if (!logfile) {
perror("fopen");
return 1;
}
printf("התוכנית רצה. שלחו SIGTERM או לחצו Ctrl+C לכיבוי נקי.\n");
int counter = 0;
while (running) {
fprintf(logfile, "פעולה מספר %d\n", counter++);
fflush(logfile);
sleep(1);
}
// ניקוי
printf("מבצע כיבוי נקי...\n");
fprintf(logfile, "כיבוי נקי אחרי %d פעולות\n", counter);
fclose(logfile);
printf("הקובץ נסגר. יוצא.\n");
return 0;
}
שימו לב לכמה דברים חשובים:
- השתמשנו ב-volatile sig_atomic_t בשביל המשתנה running. הטיפוס הזה מבטיח שהגישה למשתנה היא אטומית (בפעולה אחת), וה-volatile אומר לקומפיילר לא לבצע אופטימיזציה על המשתנה (כי הוא משתנה באופן אסינכרוני).
- הhandler עצמו פשוט מעדכן דגל. כל הניקוי קורה בלולאה הראשית.
דוגמה מעשית - טיפול בSIGINT עם ניקוי¶
דוגמה נוספת - תוכנית שתופסת Ctrl+C ומנקה לפני יציאה:
#include <stdio.h>
#include <signal.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
volatile sig_atomic_t got_sigint = 0;
void sigint_handler(int signum) {
got_sigint = 1;
}
int main() {
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = sigint_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGINT, &sa, NULL);
// הקצאת זיכרון
char *buffer = malloc(1024);
if (!buffer) {
perror("malloc");
return 1;
}
printf("רץ... לחצו Ctrl+C ליציאה\n");
while (!got_sigint) {
sleep(1);
printf("עובד...\n");
}
printf("\nקיבלתי SIGINT, מנקה ויוצא...\n");
free(buffer);
printf("זיכרון שוחרר. להתראות!\n");
return 0;
}
בטיחות סיגנלים - signal safety¶
נקודה חשובה מאוד: כשhandler של סיגנל רץ, הוא מפסיק את הקוד הרגיל של התוכנית באמצע. זה אומר שאם הקוד הרגיל היה באמצע פעולה כלשהי (כמו הקצאת זיכרון), והhandler גם ינסה לעשות את אותה פעולה - נקבל באגים.
לכן, בתוך signal handler מותר להשתמש רק בפונקציות שנקראות async-signal-safe. רשימה חלקית:
- write() - כתיבה ישירה (לכן השתמשנו בwrite ולא בprintf בדוגמה למעלה)
- _exit() - יציאה מיידית
- signal() / sigaction() - שינוי handlers
- פעולות על volatile sig_atomic_t
פונקציות שאסור להשתמש בהן בhandler:
- printf() / fprintf() - לא signal-safe
- malloc() / free() - לא signal-safe
- רוב הפונקציות מlibc
הגישה הנכונה: בhandler, רק תעדכנו דגל. את כל העבודה האמיתית תעשו בלולאה הראשית, כמו שראינו בדוגמאות למעלה.
חיבור למה שלמדנו¶
נסכם את החיבור לנושאים שכבר למדנו:
- כשתהליך עושה segfault - הקרנל שולח SIGSEGV. עכשיו אתם מבינים מה קורה מאחורי הקלעים
- כשלוחצים Ctrl+C - הshell שולח SIGINT לתהליך. עכשיו אתם יודעים גם לתפוס אותו
- fork ו-wait - כשתהליך בן מסיים, הקרנל שולח SIGCHLD לאב. בפרק 5.2 למדנו על wait - עכשיו אנחנו מבינים את הסיגנל שמעורר את האב
- פסיקות - סיגנלים הם למעשה "פסיקות ברמת התוכנה". בפרק 1 למדנו על פסיקות חומרה שמפסיקות את המעבד - סיגנלים עושים את אותו הדבר אבל ברמת התהליך