תהליכים

fork#

יוצר תהליך חדש המריץ את אותה תוכנית באותה נקודה.

fork מנפיק את קריאת המערכת fork(2). לאחר שהיא חוזרת, שני תהליכים מבצעים את אותה תוכנית Perl באותו משפט - המקורי (הורה) ועותק כמעט זהה (ילד). השניים נבדלים רק לפי ערך ההחזרה של fork. descriptors של קבצים, ובמערכות מסוימות גם הנעילות המוחזקות עליהם, משותפים בין הורה לילד; כל השאר מועתק. ב־Linux ההעתקה זולה: דפי נתונים משותפים בהעתקה־בכתיבה, כך שהשכפול בפועל מתרחש רק כאשר כל צד כותב לזיכרון.

תקציר#

my $pid = fork;

מה מקבלים בחזרה#

שלושה ערכי החזרה, אחד לכל תפקיד:

  • הורה: ה־PID של הילד (מספר שלם חיובי).

  • ילד: 0.

  • כשל: undef, כאשר $! מוגדר ל־errno שמגיע מ־fork(2) הבסיסי (בדרך כלל EAGAIN - הוגעה למגבלת משאבים - או ENOMEM).

הניתוב הקנוני הוא הסתעפות תלת־כיוונית. יש לבדוק תחילה undef, אחר כך 0, כך שמסלול ההורה הוא ברירת המחדל:

my $pid = fork // die "fork failed: $!";
if ($pid == 0) {
    # child
    exec $cmd, @args;
    die "exec failed: $!";
}
# parent continues here, with $pid = child's PID

הניב ”fork or die“ בן שתי השורות:

defined(my $pid = fork) or die "fork: $!";

כתיבת fork or die (ללא defined) היא באג: היא מתייחסת להחזרת 0 הלגיטימית של הילד ככשל ומתה בכל ילד.

לעשות flush לפני fork#

Perl מנסה לעשות flush לכל מטפלי הפלט לפני הקריאה ל־fork(2), אך אין להסתמך על כך לבדו - נתונים מאוחסנים ב־buffer שלא עברו flush ומשתחלים דרכם משוכפלים בשני התהליכים, כך שאותם בייטים נכתבים פעמיים. ההגנה הקנונית היא לבטל buffering על כל מטפל שמתכוונים להשתמש בו לרוחב fork:

STDOUT->autoflush(1);           # or: $| = 1 while STDOUT is selected
STDERR->autoflush(1);

print "about to fork\n";        # now actually on the wire
my $pid = fork // die "fork: $!";

טעינת IO::Handle אינה נדרשת עבור autoflush ב־Perl מודרני - המתודה זמינה בכל מטפלי הקובץ. הגדרת $| על המטפל הנבחר הנוכחי בעלת אותה השפעה עבור אותו מטפל יחיד.

איסוף ילדים - או $SIG{CHLD}#

כל ילד שמסתיים לפני שההורה אוסף אותו הופך ל־zombie: רשומה בטבלת התהליכים של הקרנל המוחזקת פתוחה כדי שההורה יוכל לקרוא את סטטוס היציאה. התעלמות מהם תמלא את טבלת התהליכים.

שתי דרכים לשמור על ניקיון:

  • איסוף פעיל עם wait או waitpid:

    my $pid = fork // die "fork: $!";
    if ($pid == 0) {
        # ... child work ...
        exit 0;
    }
    my $reaped = waitpid $pid, 0;     # block until this child exits
    my $status = $?;                  # exit status of the child
    
  • לומר לקרנל שלא אכפת לכם:

    $SIG{CHLD} = 'IGNORE';            # children auto-reaped, no status
    

    שימושי לעובדי שגר־ושכח. מאבדים את סטטוס היציאה - wait ו־waitpid גם לא יראו עוד את הילדים.

עבור הורים ארוכי־טווח שכן אכפת להם מסטטוס היציאה, יש להתקין מטפל אוסף שמרוקן את כל הילדים הממתינים מבלי לחסום:

$SIG{CHLD} = sub {
    while ((my $pid = waitpid -1, POSIX::WNOHANG) > 0) {
        # optionally inspect $? here
    }
};

ראו perlipc לדפוסים מלאים יותר.

מה הילד יורש#

  • descriptors של קבצים פתוחים: משותפים. print בכל אחד מהתהליכים מקדם את אותו offset קובץ בסיסי. יש לסגור את ה־descriptors שהילד אינו זקוק להם, ולפתוח מחדש מטפלים שהילד לא צריך לחלוק עם ההורה - בפרט STDIN/STDOUT/STDERR כשהם מחוברים ל־pipe או socket שמניע את הקורא של ההורה. סקריפט CGI ברקע שעושה fork ויוצא מבלי לסגור את ה־STDOUT שירש ישאיר את לקוח ה־HTTP תלוי, כי הילד עדיין מחזיק את ה־socket פתוח:

    open STDIN,  '<', '/dev/null' or die $!;
    open STDOUT, '>', '/dev/null' or die $!;
    open STDERR, '>', '/dev/null' or die $!;
    
  • זיכרון: לוגית מועתק, פיזית בהעתקה־בכתיבה. שינוי מבנה נתונים גדול בצד אחד ממש רק את הדפים שנגעו בהם.

  • מזהה תהליך: משתנה. $$ (גם $PID / $PROCESS_ID תחת English) נקרא מחדש מהקרנל בעת גישה לאחר fork, כך שגם ההורה וגם הילד רואים את ה־PID שלהם.

  • מטפלי אות, %ENV, ספריית עבודה, umask, קבוצת תהליכים, טרמינל שולט: הכל מועתק.

  • התראות וטיימרים ממתינים: אינם עוברים בירושה לילד (לפי POSIX).

  • נעילות המוחזקות דרך flock: משותפות עם ההורה - שני התהליכים מחזיקים אותה נעילה על אותו תיאור קובץ פתוח. נעילות שנרכשו דרך נעילות רשומות של fcntl מתנהגות אחרת; יש לעיין ב־fcntl(2).

דוגמאות#

fork/exec קלאסי - הפעלת פקודה חיצונית מבלי לעבור דרך ה־shell:

my $pid = fork // die "fork: $!";
if ($pid == 0) {
    exec '/usr/bin/gzip', '-9', $file;
    die "exec: $!";                   # only reached if exec fails
}
waitpid $pid, 0;
die "gzip failed: $?" if $?;

פיזור N עובדים, אחר כך איסוף כולם:

my @kids;
for my $i (1 .. 4) {
    my $pid = fork // die "fork: $!";
    if ($pid == 0) {
        do_work($i);
        exit 0;
    }
    push @kids, $pid;
}
waitpid $_, 0 for @kids;

תקשורת הורה/ילד דרך pipe. יש לפתוח את ה־pipe לפני ה־fork כך ששני הצדדים יורשים את שני הקצוות:

pipe(my $reader, my $writer) or die "pipe: $!";
my $pid = fork // die "fork: $!";
if ($pid == 0) {
    close $reader;
    $writer->autoflush(1);
    print $writer "hello from $$\n";
    exit 0;
}
close $writer;
chomp(my $line = <$reader>);
waitpid $pid, 0;
print "got: $line\n";

יש לשים לב ל־close על הקצה שאינו בשימוש בכל תהליך - השארת שני הקצוות פתוחים בשני התהליכים והקורא לעולם לא יראה EOF.

ניתוק ילד בסגנון דמון ונתינה לקרנל לאסוף אותו:

$SIG{CHLD} = 'IGNORE';
my $pid = fork // die "fork: $!";
if ($pid == 0) {
    # child: will be auto-reaped on exit
    run_background_task();
    exit 0;
}
# parent moves on, never calls wait

מקרי קצה#

  • fork בתוך thread או אחרי קוד רגיש ל־DESTROY: רק ה־thread הקורא שורד בילד; כל מה שממתין על mutex או thread מצורף ב־thread אחר נעלם. יש להחזיק נקודות fork לפני יצירת threads.

  • מטפלי קובץ עם buffer בילד: כל נתון שעדיין ב־buffer ברגע ה־fork נוכח בשני ה־buffers של התהליכים. כאשר כל צד עושה flush בסופו של דבר, הבייטים נכתבים פעמיים. יש לעשות autoflush לפני ה־fork.

  • exit בילד מריץ בלוקי END ו־DESTROY-ים שנכתבו על ידי ההורה. זה לעיתים רחוקות מה שרוצים - מומלץ POSIX::_exit בילד לאחר כשל ב־exec, או לוודא שבלוקי END בודקים את $$ מול ה־PID שנרשם בתחילת התוכנית.

  • בלבול שגיאה מול ילד: fork מחזיר 0 באופן לגיטימי בילד ו־undef בכשל. יש להשתמש ב־// וב־defined, לעולם לא ב־|| / בדיקות אמת.

  • EAGAIN: זמני - הוגעה למגבלת התהליכים של הקרנל לכל משתמש. יש לנסות שוב עם sleep קצר, או להוריד את רמת המקביליות.

  • ספריית עבודה ו־chdir בילד אינן משפיעות על ההורה. אותו דבר נכון לגבי %ENV, umask, ומטפלי אותות: לילד יש עותק משלו מרגע ה־fork והלאה.

  • Seeks על descriptors משותפים הם משותפים: אם הורה וילד כותבים שניהם לאותו STDOUT שנירש, הפלט שלהם משתלב בכל מה ש־scheduler מערכת ההפעלה מחליט. יש לתאם עם נעילה מפורשת או לתת לכל ילד מטפל פלט משלו.

הבדלים מהמקור#

תואם במלואו ל־Perl 5.42 במקור.

pperl מכוון ל־Linux בלבד, כך ש־fork הוא תמיד fork(2) אמיתי. שלא כמו perl5 מסורתי ב־Windows - שבו fork מדומה עם פסאודו־תהליכים ברמת המפרש החולקים תהליך OS יחיד, עם המוזרויות המתועדות ב־perlfork - ה־fork של pperl יוצר תהליך OS עצמאי עם PID משלו, מרחב כתובות, ורשומה בטבלת התהליכים. קוד שהניח סמנטיקת פסאודו־תהליך (מצב גלובלי משותף לרוחב הצדדים ה־“forked“) לא יועבר ל־pperl.

ראו גם#

  • exec - מחליף את תמונת התהליך הנוכחית; הקריאה הטיפוסית הבאה בילד מיד אחרי fork

  • wait - חוסם עד שילד כלשהו יוצא ומחזיר את ה־PID והסטטוס שלו ב־$?

  • waitpid - אוסף ילד מסוים, אופציונלית ללא חסימה דרך WNOHANG

  • exit - מסיים את התהליך הנוכחי; יש להשתמש בילד כדי לסיים אותו מבלי ליפול לקוד של ההורה

  • pipe - יוצר זוג מטפלי קובץ מחוברים לפני fork כדי לתת להורה ולילד ערוץ תקשורת

  • $$ - מזהה התהליך הנוכחי, נקרא מחדש אחרי fork כך שכל צד רואה את ה־PID שלו

  • %SIG - בפרט $SIG{CHLD}, השולט אם ילדים נאספים אוטומטית או חייבים להיאסף עם wait / waitpid

  • perlipc - טיפול מלא יותר ב־fork, אותות, pipes, ואיסוף ילדים גוססים