לדלג לתוכן

בפרק הקודם למדנו על תהליכים - איך יוצרים אותם עם 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:

#include <signal.h>

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

int raise(int sig);

לדוגמה:

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

unsigned int alarm(unsigned int seconds);

דוגמה:

#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 למדנו על פסיקות חומרה שמפסיקות את המעבד - סיגנלים עושים את אותו הדבר אבל ברמת התהליך