בפרק הקודם למדנו על processes - איך יוצרים אותם עם fork, איך מחליפים את הקוד שלהם עם exec, ואיך מחכים שיסיימו עם wait.
עכשיו נלמד על מנגנון חשוב שמאפשר תקשורת בין processes ובין הקרנל לprocesses - סיגנלים.
סיגנלים - signals¶
מה זה סיגנל¶
סיגנל הוא הודעה אסינכרונית שנשלחת לprocess.
אפשר לחשוב על זה כמו interrupt תוכנה - בדיוק כמו שinterrupts חומרה (שלמדנו עליהן בפרק 1) מפסיקות את המעבד ומעבירות שליטה לhandler מסוים, סיגנלים מפסיקים את הprocess ומעבירים שליטה לhandler שהוגדר מראש.
סיגנלים יכולים להישלח:
- מהקרנל לprocess - לדוגמה, כשprocess ניגש לכתובת לא חוקית בזיכרון, הקרנל שולח לו SIGSEGV
- מprocess לprocess - לדוגמה, כשאנחנו רוצים לעצור process אחר
- מהמשתמש לprocess - לדוגמה, כשלוחצים Ctrl+C בטרמינל
טבלת הסיגנלים החשובים¶
לכל סיגנל יש מספר ושם. הנה הסיגנלים החשובים ביותר:
| מספר | שם | מתי נשלח | התנהגות ברירת מחדל |
|---|---|---|---|
| 2 | SIGINT | לחיצה על Ctrl+C בטרמינל | סיום הprocess |
| 9 | SIGKILL | בקשה להרוג process בכוח | סיום מיידי - לא ניתן לתפוס |
| 11 | SIGSEGV | גישה לכתובת לא חוקית בזיכרון (segmentation fault) | סיום + core dump |
| 13 | SIGPIPE | כתיבה לpipe שבור (הצד השני סגור) | סיום הprocess |
| 14 | SIGALRM | טיימר שהגדרנו עם alarm() הסתיים | סיום הprocess |
| 15 | SIGTERM | בקשה מנומסת לסיום (הסיגנל שkill שולח כברירת מחדל) | סיום הprocess |
| 17 | SIGCHLD | הprocess הבן סיים | התעלמות |
| 19 | SIGSTOP | עצירת process (כמו Ctrl+Z) | עצירה - לא ניתן לתפוס |
| 18 | SIGCONT | המשך process שנעצר | המשך ריצה |
| 10 | SIGUSR1 | סיגנל מותאם אישית 1 | סיום הprocess |
| 12 | SIGUSR2 | סיגנל מותאם אישית 2 | סיום הprocess |
התנהגות ברירת מחדל¶
כשprocess מקבל סיגנל ולא הגדיר handler מותאם אישית, אחת מארבע פעולות קורה:
1. סיום (terminate) - הprocess מת. רוב הסיגנלים עושים את זה
2. התעלמות (ignore) - כאילו כלום לא קרה. לדוגמה SIGCHLD
3. עצירה (stop) - הprocess נעצר (אפשר להמשיך אותו עם SIGCONT)
4. סיום + core dump - הprocess מת ונוצר קובץ core עם תמונת הזיכרון שלו (שימושי לדיבוג). לדוגמה SIGSEGV
שליחת סיגנלים - הsyscall של kill¶
כדי לשלוח סיגנל לprocess, משתמשים בsyscall בשם kill:
pid- מזהה הprocess שאליו שולחים את הסיגנלsig- מספר הסיגנל
למרות השם, kill לא בהכרח הורג את הprocess - הוא פשוט שולח סיגנל. מה שקורה תלוי בסיגנל ובhandler שהprocess הגדיר.
#include <stdio.h>
#include <signal.h>
int main() {
pid_t target_pid = 1234;
// send SIGTERM to process 1234
kill(target_pid, SIGTERM);
// send SIGKILL to process 1234
kill(target_pid, SIGKILL);
return 0;
}
הפקודה kill בshell¶
הפקודה kill בshell היא בעצם wrapper לsyscall:
# send SIGTERM (default)
kill 1234
# send SIGKILL
kill -9 1234
# send SIGSTOP
kill -STOP 1234
# send SIGCONT
kill -CONT 1234
כשאנחנו כותבים kill -9 1234 בshell, הshell קורא לsyscall kill(1234, 9).
שליחת סיגנל לעצמך - raise¶
הפונקציה raise מאפשרת לprocess לשלוח סיגנל לעצמו:
לדוגמה:
#include <stdio.h>
#include <signal.h>
int main() {
printf("before the signal\n");
raise(SIGTERM); // sends SIGTERM to ourselves
printf("after the signal\n"); // this line won't print
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("received signal %d\n", signum);
}
int main() {
// register a handler for SIGINT
signal(SIGINT, my_handler);
printf("Press Ctrl+C to send SIGINT...\n");
while (1) {
sleep(1); // waiting
}
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); // the function that will run
sigset_t sa_mask; // signals to block while the handler runs
int sa_flags; // additional 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 = "received 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("waiting for signals...\n");
while (1) {
sleep(1);
}
return 0;
}
שימו לב שבתוך הhandler השתמשנו ב-write ולא ב-printf. מיד נסביר למה.
סיגנלים שלא ניתן לתפוס¶
יש שני סיגנלים שאי אפשר לתפוס, לחסום, או להתעלם מהם:
- SIGKILL (9) - הריגה מיידית. לא משנה מה, הprocess ימות
- SIGSTOP (19) - עצירה מיידית. לא משנה מה, הprocess ייעצר
למה? כי אם process יכול היה לתפוס SIGKILL, לא הייתה לנו שום דרך להרוג process שמתנהג רע. מערכת ההפעלה חייבת לשמור לעצמה את היכולת להרוג processes בכוח.
אם ננסה לרשום handler לSIGKILL או SIGSTOP, הקריאה תיכשל.
טיימר עם alarm¶
הפונקציה alarm() מגדירה טיימר שאחרי מספר שניות שולח לprocess SIGALRM:
דוגמה:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void alarm_handler(int signum) {
printf("time's up!\n");
_exit(0);
}
int main() {
signal(SIGALRM, alarm_handler);
alarm(5); // timer for 5 seconds
printf("you have 5 seconds...\n");
while (1) {
sleep(1);
printf("still running...\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("Program running. Send SIGTERM or press Ctrl+C for a clean shutdown.\n");
int counter = 0;
while (running) {
fprintf(logfile, "operation number %d\n", counter++);
fflush(logfile);
sleep(1);
}
// cleanup
printf("performing clean shutdown...\n");
fprintf(logfile, "clean shutdown after %d operations\n", counter);
fclose(logfile);
printf("File closed. Exiting.\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);
// memory allocation
char *buffer = malloc(1024);
if (!buffer) {
perror("malloc");
return 1;
}
printf("running... press Ctrl+C to exit\n");
while (!got_sigint) {
sleep(1);
printf("working...\n");
}
printf("\nreceived SIGINT, cleaning up and exiting...\n");
free(buffer);
printf("Memory freed. Goodbye!\n");
return 0;
}
בטיחות סיגנלים - signal safety¶
נקודה חשובה מאוד: כשhandler של סיגנל רץ, הוא מפסיק את הקוד הרגיל של התוכנית באמצע. זה אומר שאם הקוד הרגיל היה באמצע פעולה כלשהי (כמו allocation של זיכרון), וה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, רק תעדכנו דגל. את כל העבודה האמיתית תעשו בלולאה הראשית, כמו שראינו בדוגמאות למעלה.
חיבור למה שלמדנו¶
נסכם את החיבור לנושאים שכבר למדנו:
- כשprocess עושה segfault - הקרנל שולח SIGSEGV. עכשיו אתם מבינים מה קורה מאחורי הקלעים
- כשלוחצים Ctrl+C - הshell שולח SIGINT לprocess. עכשיו אתם יודעים גם לתפוס אותו
- fork ו-wait - כשprocess בן מסיים, הקרנל שולח SIGCHLD לאב. בפרק 5.2 למדנו על wait - עכשיו אנחנו מבינים את הסיגנל שמעורר את האב
- הinterrupts - סיגנלים הם למעשה "interrupts ברמת התוכנה". בפרק 1 למדנו על interrupts חומרה שמפסיקות את המעבד - סיגנלים עושים את אותו הדבר אבל ברמת הprocess