לדלג לתוכן

בפרק הקודם למדנו על 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:

#include <signal.h>

int kill(pid_t pid, int sig);
  • 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 <signal.h>

int raise(int sig);

לדוגמה:

#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 <unistd.h>

unsigned int alarm(unsigned int seconds);

דוגמה:

#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