רשימת בדיקה לטיפול בטוח בתהליכי ילד ביישום Windows

· עודכן בתאריך: · · Windows, Process, Job Object, IPC, C++, .NET, C#

הורדת רשימת בדיקה ב-Excel, שכוללת גיליון ביפנית ובאנגלית

כלי המרה,‏ updater,‏ worker לניתוח,‏ CLI חיצוני,‏ PowerShell,‏ ffmpeg, כלי שירות פנים-ארגוניים. יישום Windows תלוי בתהליכי ילד בקלות רבה יותר משנדמה.

אבל התאונה לא קורית ב-‘האם הצלחנו להפעיל’.

  • האב קורס, אבל הילד נשאר
  • רק תהליך הנכד נשאר בחיים
  • stdout‏/‏stderr נתקעים, ו-WaitForExit לא חוזר
  • ה-watchdog מת יחד עם מטרת הניטור
  • חושבים ש-Kill(entireProcessTree: true) סיים, אבל רק התצפית הסתיימה קודם

הטריק לטיפול בטוח בתהליכי ילד ב-Windows הוא לא בחירת ה-API להפעלה, אלא קביעת בעל עץ התהליכים, ותכנון נוהל הסיום וה-I/O.

המאמר הזה מסדר את Job Object, הפצת הסיום, קלט/פלט סטנדרטי, ו-watchdog כתכנון אחד.

התאונה מחוץ להפעלהתרשים המראה שתאונת תהליך הילד לא קורית ב'האם הצלחנו להפעיל', אלא בצורות כמו האב קורס והילד נשאר, או stdout נתקע, ושהטריק הוא לא בחירת ה-API להפעלה, אלא קביעת בעל עץ התהליכים ותכנון נוהל הסיום וה-I/O.בוחרים API להפעלהזה לא גוף התאונהקובעים את בעל עץ התהליכיםטיפול בטוח בתהליך ילדמתכננים נוהל סיוםמתכננים I/O

איור 1: הבטיחות של תהליך הילד נקבעת לא ב-API להפעלה, אלא בתכנון הבעלות, הסיום וה-I/O.

מונחים שבהם משתמש המאמר הזה

מסדרים כאן מראש, שורה לכל מונח, את המילים שמופיעות באנגלית כמות שהן.

מונח משמעות בשורה אחת
process tree עץ תהליכים. המשפחה שכוללת את הילד שהאב הפעיל, ועד הנכד שהילד הפעיל
graceful shutdown סיום מתואם. דרך שבה מבקשים ‘בבקשה תסתיים’, והצד השני מסיים בעצמו אחרי שהוא מסדר את עצמו. ניגוד לסיום כפוי
I/O completion port מנגנון ההודעה על השלמת I/O אסינכרוני של Windows. אם מקשרים אותו ל-Job Object, אפשר לקבל הודעות על הפעלה וסיום של תהליכים
message pump לולאת הודעות. מנגנון שבו thread שמחזיק חלון שולף ומעבד ללא הפסקה הודעות מה-OS. אם זה נעצר, המסך נתקע
heartbeat אות שנשלח מעת לעת מתהליך הילד, לבדיקת חיים. משמש לזיהוי מצב שבו התהליך ‘חי אבל לא מתקדם’
restart budget תקציב הפעלה מחדש. תקרה של כמה פעמים מותר להפעיל מחדש בפרק זמן נתון. מוחזק כדי לעצור crash loop
drain ליניקה. קריאה מלאה של הפלט שהצטבר ב-pipe, כדי שצד הכתיבה לא ייתקע

התמונה הכללית

מקדימים כאן תרשים אחד עם היחסים בין המשתתפים.

התמונה הכללית - watchdog בחוץ, עץ התהליכים בתוך ה-Jobתרשים המראה ש-watchdog ממוקם מחוץ ל-Job Object עם JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, בעוד שבתוך ה-Job נמצאים האפליקציה או ה-worker כבעל ה-job handle האחרון, הילד helper.exe, והנכדים converter.exe ו-ffmpeg.exe, כש-watchdog מזהה סיום דרך exit handle, מזהה תקיעה דרך heartbeat, ובונה מחדש את ה-Job בטווח ה-restart budget.Job Object עם JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEמזהה סיום דרך exit handleמזהה תקיעה דרך heartbeatבונה מחדש בטווח ה-restart budgetאפליקציית האב / גוף ה-worker - בעל ה-job handle האחרוןילד - helper.exeנכד - converter.exeנכד - ffmpeg.exewatchdog - ממוקם מחוץ ל-Job

איור 2: התמונה הכללית. גבול ה-Job הוא גבול עץ התהליכים, ורק ה-watchdog נמצא בחוץ.

שני דברים חשובים לשים לב אליהם.

  • גבול ה-Job הוא גבול עץ התהליכים. מכיוון שהאיגוד הוא לפי השתייכות ל-Job ולא לפי חיי האב, גם אם נוספים נכדים, לא מתרחשת החמצה באיסוף
  • רק ה-watchdog נמצא מחוץ ל-Job. אם מכניסים אותו פנימה, הוא מסודר יחד עם מטרת הניטור

1. קודם כל, המסקנה

מקדימים כאן רק את מה שהכי משפיע בעבודה מעשית.

  • אם רוצים לקשור בין חיי האב לחיי עץ תהליכי הילד, נקודת הייחוס היא Job Object
  • בקשת סיום ל-console ו-איסוף עץ התהליכים הם שני דברים שונים
    • הראשון הוא process group ו-GenerateConsoleCtrlEvent
    • השני הוא Job Object
  • אם רוצים להכניס ל-Job כבר מרגע ההפעלה, תכנון עם STARTUPINFOEX ו-PROC_THREAD_ATTRIBUTE_JOB_LIST טבעי יותר
  • הבסיס הוא ליניקה במקביל של הפלט והשגיאה הסטנדרטיים
  • אם משתמשים ב-stdin, מתכננים עד כדי סגירה (close) בסיום הכתיבה כדי להעביר EOF
  • בטוח יותר למקם את ה-watchdog מחוץ ל-Job של מטרת הניטור
  • Kill(entireProcessTree: true) של .NET נוח כ-API לעצירה מפורשת, אך אינו תחליף לתכנון שכולל איסוף אוטומטי בקריסת האב וסיום מתואם (graceful shutdown)

מפת הידע של המאמר

נקודת הייחוס לתכנון טיפול בטוח בתהליכי ילד ביישום Windows היא Job Object - הוספת JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE מאפשרת איסוף אוטומטי של עץ התהליכים גם בקריסת האב, ו-Process.Kill(entireProcessTree: true) לבדו אינו תחליף לכך. הסיום פחות נוטה לתקלות כשמבצעים אותו בשלושה שלבים - בקשת סיום מתואם (graceful shutdown), המתנה עם timeout קצר, ולבסוף סיום כפוי של כל ה-Job - כשסיום מתואם לילד console נעשה עם process group ואירוע בקרה של הקונסולה. אם לא מלינקים את stdout ואת stderr במקביל, נוצר קיפאון בקלט/פלט הסטנדרטי, מכיוון שה-pipe של Windows הוא חוצץ סופי והאב והילד נתקעים בהמתנה לקריאה ולכתיבה בהתאמה. תכנון יציב ממקם את ה-watchdog מחוץ ל-Job של מטרת הניטור, מזהה תקיעה עם heartbeat, ומונע crash loop עם restart budget.

מפת הידע של ניהול בטוח בתהליכי ילד ב-Windowsתרשים המראה איך Job Object אוגד את עץ התהליכים ומאפשר איסוף אוטומטי בקריסת האב, את נוהל הסיום משלושה שלבים - בקשת סיום מתואם עד לסיום כפוי, את מניעת קיפאון stdio על ידי ליניקה מקבילה של stdout/stderr, ואת ניטור ה-watchdog מחוץ ל-Job עם heartbeat ו-restart budget.מוגדר באמצעותמונעמענה מומלץ לשימוש לא מומלץ למחייבמשתמש במחייבצריך לקדום למשתמש בשימוש לא מומלץ למשתמש במאוטמט אתמונעמונעמשתמש במממש אתעלול לגרום לJob Objectקיפאון בקלט/פלט הסטנדרטיתהליך ניטור (watchdog)JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEתהליך בן ששרד אחרי היעלמות ההורהאיסוף אוטומטי בעת קריסת ההורהProcess.Kill(entireProcessTree: true)PROC_THREAD_ATTRIBUTE_JOB_LISTקבוצת תהליכים (קונסולה)אירוע בקרה של הקונסולהgraceful shutdownבדיקת חיוּת באמצעות heartbeatתקציב הפעלות מחדש (restart budget)crash loopריקון מקבילי של stdout/stderrיציאת השלמת קלט/פלט (IOCP)עץ התהליכים (process tree)JOB_OBJECT_LIMIT_BREAKAWAY_OK

בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 17, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle

2. מה מסוכן

יישום הפעלת תהליך ילד אפשר לכתוב בהתחלה בערך ב-10 שורות. אבל התאונה קורית מחוץ לעשר השורות האלה.

  • אחרי שהאב קורס, הילד והנכד ממשיכים להישאר
  • ה-helper מפעיל helper נוסף, ומסתפקים בהמתנה לילד הישיר בלבד
  • צד אחד של stdout‏/‏stderr נתקע, והאב והילד ממתינים זה לזה
  • ממתינים ב-UI thread, והמסך וגם ה-COM נתקעים
  • ה-watchdog נמצא באותו גורל כמו מטרת הניטור, ונופל יחד איתה בעת תקלה

מה שחשוב כאן הוא ש-‘ניהול תהליך ילד’ אינו סיפור של API אחד.

לפחות ארבעת אלה, כדאי לחשוב עליהם בנפרד כדי לראות בבירור.

  1. מי בעל עץ התהליכים
  2. איך מבקשים סיום מתואם
  3. איך מזרימים את הקלט/פלט הסטנדרטי
  4. איך מנטרים סיום חריג ותקיעה
ארבע השאלות שחושבים עליהן בנפרדתרשים המראה שניהול תהליך ילד אינו סיפור של API אחד, ושחושבים בנפרד על מי בעל עץ התהליכים, איך מבקשים סיום מתואם, איך מזרימים את הקלט/פלט הסטנדרטי, ואיך מנטרים סיום חריג ותקיעה - וכך רואים בבירור.1. בעל עץ התהליכים2. אופן בקשת הסיום המתואם3. אופן הזרמת הקלט/פלט הסטנדרטי4. ניטור סיום חריג ותקיעהזה לא סיפור של API אחד

איור 3: ניהול תהליך ילד מתוכנן על ידי פירוק לארבע השאלות האלה.

3. לא מערבבים בין תפקידי המנגנונים

process handle‏/process group‏/Job Object נראים דומים, אך התפקיד שלהם שונה.

מנגנון תפקיד עיקרי מצב מתאים מה לא מספיק בו לבד
process handle המתנה לסיום תהליך יחיד, קבלת exit code המתנה להשלמת כלי חד-פעמי איסוף תהליכי נכד
process group הפצת Ctrl+Break ל-console סיום מתואם של ילד console cleanup בקריסת האב, תהליך ילד GUI
Job Object איגוד עץ תהליכים, הגבלות, סיום מרוכז עץ worker,‏ updater, שרשרת helper ‘שמירה לפני סגירה’ ספציפית לאפליקציה

process group הוא מנגנון שקובע לאן לשלוח את ה-console signal, ולא מנגנון ל-סידור העץ כולו כשהאב מת. לעומת זאת,‏ Job Object הוא מנגנון מצד Windows שמנהל קבוצת תהליכים כיחידה אחת.

3.1 טבלת התאמה לפי שפה

המאמר הזה מערבב בין Win32 ל-.NET. מסדרים מראש את ההתאמה, כדי שתוכלו ללקט רק את העמודה של השפה שלכם.

מה רוצים לעשות Win32‏/C++ .NET‏/C#
הפעלת תהליך CreateProcessW Process.Start
יצירת Job והוספת הגבלות CreateJobObjectW + SetInformationJobObject קוראים לאותם API-ים דרך P/Invoke. בספרייה הסטנדרטית אין עטיפה ל-Job Object
הכנסה ל-Job כבר מרגע ההפעלה STARTUPINFOEX + PROC_THREAD_ATTRIBUTE_JOB_LIST כנ”ל. אי אפשר לציין מ-ProcessStartInfo
הכנסה ל-Job בהמשך AssignProcessToJobObject קוראים לאותו API דרך P/Invoke, ומעבירים את Process.Handle
המתנה לסיום WaitForSingleObject Process.WaitForExit, אסינכרוני: WaitForExitAsync(מ-.NET 5 ואילך)
קבלת exit code GetExitCodeProcess Process.ExitCode
קריאת stdout/stderr יוצרים pipe אנונימי וקוראים ב-thread נפרד RedirectStandardOutput ו-BeginOutputReadLine
לבקש מילד GUI להיסגר שולחים WM_CLOSE Process.CloseMainWindow
שליחת Ctrl+Break לילד console CREATE_NEW_PROCESS_GROUP + GenerateConsoleCtrlEvent אין API מקביל, ולכן P/Invoke
סיום כפוי של העץ כולו TerminateJobObject, או סגירת ה-job handle האחרון Process.Kill(entireProcessTree: true)(מ-.NET Core 3.0 ואילך), או אותו P/Invoke כמו למעלה
המתנה לסיום של הרבה ילדים RegisterWaitForSingleObject‏/SetThreadpoolWait אירוע Process.Exited, או WaitForExitAsync

מה שמתברר כאן הוא ש-רק סביב Job Object, גם ב-.NET, קוראים ישירות ל-API של Win32. מה שמוכן בצד .NET מגיע רק עד פעולות ברמת תהליך יחיד.

תחום הכיסוי של .NET וגבול ה-Jobתרשים המראה שמה שמוכן בספרייה הסטנדרטית של .NET מגיע רק עד פעולות ברמת תהליך יחיד כמו הפעלה והמתנה לסיום, ושאין עטיפה לפעולות סביב Job Object, ולכן קוראים ישירות ל-API של Win32 דרך P/Invoke.פעולות ברמת תהליך יחידמספיק ה-Process הסטנדרטי של .NETפעולות סביב Job Objectאין עטיפהקוראים ל-API של Win32 דרך P/Invoke

איור 4: גם ב-.NET, רק החלק שאוגד את עץ התהליכים קורא ישירות ל-API של Win32.

4. Job Object כנקודת ייחוס

היתרון החזק ביותר של Job Object הוא היכולת לאגד את ה-process tree לפי ‘לאיזה Job שייכים’ ולא ‘של מי הילד’. ילד שתהליך ששייך ל-Job יוצר עם CreateProcess נכנס כברירת מחדל לאותו Job.

בנוסף, אם מוסיפים JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, כל התהליכים המשויכים ל-Job מסתיימים כשה-job handle האחרון נסגר.

איגוד לפי Job מבטל החמצה באיסוףתרשים המראה ש-Job Object אוגד את עץ התהליכים לא לפי מי הילד אלא לפי השתייכות ל-Job, שילד שתהליך ב-Job יוצר נכנס כברירת מחדל לאותו Job, ושהוספת KILL_ON_JOB_CLOSE גורמת לכל התהליכים להסתיים כשה-job handle האחרון נסגר.עוקבים לפי 'של מי הילד'ככל שיש יותר נכדים, יש החמצהאוגדים לפי 'השתייכות ל-Job'גם הילד שהילד יוצר נכנס לאותו JobKILL_ON_JOB_CLOSEכשה-handle האחרון נסגר, הכול מסתיים

איור 5: איגוד לפי השתייכות ל-Job, לא לפי יחסי אב-ילד, מאפשר לאסוף גם את הנכד.

4.1 ארבעה דברים שכדאי לתפוס קודם

1. אם רוצים לסדר את העץ כולו עם סיום האב, KILL_ON_JOB_CLOSE

זהו הבסיס כשמטפלים ב-helper‏/worker ביישום Windows. תכנון שקורא במפורש ל-TerminateJobObject גם טוב, אבל אם רוצים להעביר את ה-cleanup לחיי האב, כולל סיום חריג שלו,‏ KILL_ON_JOB_CLOSE ברור יותר.

2. לא מוסיפים BREAKAWAY בקלות ראש

JOB_OBJECT_LIMIT_BREAKAWAY_OK ו-JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK נראים נוחים, אבל הם עלולים לגרום ל-חלק מהעץ שהתכוונתם ל-cleanup לחמוק ממנו. אלא אם יש כוונה ברורה, לא להוסיף breakaway מקטין את שיעור התקלות.

3. אם רוצים להכניס ל-Job כבר מרגע ההפעלה, PROC_THREAD_ATTRIBUTE_JOB_LIST

אפשר גם לקשר בהמשך עם AssignProcessToJobObject. עם זאת, במצב שבו רוצים להניח את ההשתייכות ל-Job מיד לאחר ההפעלה, נכון יותר להשתמש ב-STARTUPINFOEX וב-PROC_THREAD_ATTRIBUTE_JOB_LIST כדי לציין את ה-Job בזמן היצירה.

4. לא משאירים את בעל ה-job handle בעמימות

KILL_ON_JOB_CLOSE פועל כשה-handle האחרון נסגר. כלומר, אם משכפלים את ה-job handle לתהליך אחר, או מורישים אותו בלי כוונה, ה-cleanup לא יקרה כמצופה גם כשהאב מת. מי בעל ה-job handle האחרון צריך להיקבע מראש.

לא משאירים את בעל ה-job handle בעמימותתרשים המראה ש-KILL_ON_JOB_CLOSE פועל כשה-handle האחרון נסגר, ולכן שכפול או הורשה לא-מכוונת של ה-job handle לתהליך אחר גורמים לכך שה-cleanup לא יקרה כמצופה גם כשהאב מת, ושיש לקבוע מראש מי הבעלים האחרון.לכןמשכפלים או מורישים את ה-job handleה-handle האחרון לא נסגרגם כשהאב מת, אין cleanupקובעים מראש את הבעלים האחרון

איור 6: KILL_ON_JOB_CLOSE פועל רק כש-‘ה-handle האחרון’ נסגר.

4.2 אפשר להשתמש ב-Job Object גם ל-observability, אך ההודעה אינה כל-יכולה

ל-Job Object יש מנגנון לקבל הודעות על ידי קישור I/O completion port. עם זאת, בטוח יותר לא להתייחס להודעות ה-completion port כהודעה מובטחת במלואה בכל המקרים.

לכן, ה-completion port

  • ניטור
  • אגירה/סיכום
  • לוג
  • מדדים (metrics)

נוח עבור אלה, אבל עדיף לא לבנות correctness רק עליו.

מתי משתמשים בהודעת ה-completion portתרשים המראה של-Job Object יש מנגנון לקבל הודעות על ידי קישור I/O completion port, אך שלא רואים בו הודעה מובטחת במלואה, ומשתמשים בו לניטור, אגירה, לוג ומדדים, בלי לבנות עליו correctness בלבד.הודעת ה-completion port של ה-Jobנוח לניטור, אגירה, לוגלא רואים כהודעה מובטחת במלואהלא בונים correctness רק על זה

איור 7: ההודעה משמשת לתצפית, לא כבסיס לנכונות.

4.3 רואים בקוד מינימלי

זה קצר יותר ממילים, אז מציבים כאן את הצורה המינימלית בשתי השפות.

בצד C++, שלושת הצעדים הם יצירת Job ← הוספת KILL_ON_JOB_CLOSE ← ציון ה-Job בזמן ההפעלה.

// Windows 10 ואילך / C++17. מפעילים את helper.exe בתוך Job, וסיום האב מסדר את העץ כולו
#include <windows.h>
#include <memory>
#include <string>

int wmain()
{
    // 0. קובעים את הקובץ להפעלה בנתיב מוחלט.
    // אם מעבירים nullptr ל-lpApplicationName וגורמים לחיפוש מתחילת שורת הפקודה,
    // אובייקטי החיפוש כוללים את "התיקייה הנוכחית של תהליך האב" ואת "PATH".
    // במצב שבו helper.exe לא נמצא בתיקייה שלו, אם מניחים במקום שניתן
    // לכתיבה קובץ הפעלה באותו שם, הוא יפעל בהרשאות האב
    wchar_t modulePath[MAX_PATH]{};
    DWORD moduleLen = GetModuleFileNameW(nullptr, modulePath, MAX_PATH);
    if (moduleLen == 0 || moduleLen >= MAX_PATH)   // גם אם נחתך בגלל MAX_PATH, מטפלים בזה ככישלון
    {
        return 1;
    }

    std::wstring application(modulePath, moduleLen);
    application.resize(application.find_last_of(L'\\') + 1);   // התיקייה שבה נמצא קובץ ההפעלה של עצמנו
    application += L"helper.exe";

    // 1. יוצרים Job, וכשה-handle האחרון נסגר, כל התוכן מסתיים
    HANDLE job = CreateJobObjectW(nullptr, nullptr);
    if (job == nullptr)
    {
        return 1;
    }

    JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{};
    limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
    if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)))
    {
        CloseHandle(job);
        return 1;
    }

    // 2. יוצרים רשימת תכונות (attribute list) כדי להשתייך ל-Job כבר מרגע ההפעלה
    SIZE_T attributeSize = 0;
    InitializeProcThreadAttributeList(nullptr, 1, 0, &attributeSize);  // קריאה ריקה כדי לקבל את הגודל הדרוש
    auto storage = std::make_unique<BYTE[]>(attributeSize);
    auto attributes = reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.get());

    if (!InitializeProcThreadAttributeList(attributes, 1, 0, &attributeSize))
    {
        CloseHandle(job);
        return 1;
    }

    // ערך ה-job צריך להישאר חי עד לקריאה ל-DeleteProcThreadAttributeList
    if (!UpdateProcThreadAttribute(attributes, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST,
                                   &job, sizeof(job), nullptr, nullptr))
    {
        DeleteProcThreadAttributeList(attributes);
        CloseHandle(job);
        return 1;
    }

    // 3. מפעילים
    STARTUPINFOEXW startup{};
    startup.StartupInfo.cb = sizeof(startup);
    startup.lpAttributeList = attributes;

    PROCESS_INFORMATION info{};
    // CreateProcessW דורש חוצץ שניתן לכתיבה.
    // מניחים את אותו נתיב גם ב-argv[0]. מכיוון שהוא מכיל רווח, חובה לעטוף במרכאות
    std::wstring commandLine = L"\"" + application + L"\" --input data.bin";

    BOOL created = CreateProcessW(
        application.c_str(), commandLine.data(), nullptr, nullptr,
        FALSE,                          // מצמצמים אילו handle-ים מורישים
        EXTENDED_STARTUPINFO_PRESENT,
        nullptr, nullptr,
        &startup.StartupInfo, &info);

    DeleteProcThreadAttributeList(attributes);

    if (!created)
    {
        CloseHandle(job);
        return 1;
    }

    WaitForSingleObject(info.hProcess, INFINITE);

    DWORD exitCode = 0;
    GetExitCodeProcess(info.hProcess, &exitCode);

    CloseHandle(info.hThread);
    CloseHandle(info.hProcess);
    CloseHandle(job);   // ה-job handle האחרון. הצאצאים שנשארו כאן מסתיימים יחד
    return static_cast<int>(exitCode);
}

בקוד הזה, שתי מגבלות שכתובות בתיעוד של UpdateProcThreadAttribute נשמרות. שתיהן קל לפספס בקריאה.

  • PROC_THREAD_ATTRIBUTE_JOB_LIST ניתן לשימוש רק מ-Windows 10‏/Windows Server 2016 ואילך. אם מכוונים לגרסה מוקדמת יותר, יש לרדת ל-AssignProcessToJobObject
  • הערך שהועבר ל-UpdateProcThreadAttribute חייב להישאר חי עד לקריאה ל-DeleteProcThreadAttributeList. כתיבה שמעבירה משתנה מקומי ומיד יוצאת מה-scope תישבר

תמיד מצביעים על קובץ ההפעלה בנתיב מוחלט

הבנייה של הנתיב מ-GetModuleFileNameW ב-‘0.’ שבתחילת הקוד אינה עניין של טעם בכתיבה, אלא נועדה לקבוע איזה קובץ הפעלה יפעל.

אם מעבירים nullptr ל-lpApplicationName, המילה הראשונה בשורת הפקודה הופכת לשם המודול. אם היא לא כוללת נתיב,‏ Windows מחפש בסדר הבא.

  1. התיקייה שממנה נטענה האפליקציה
  2. התיקייה הנוכחית של תהליך האב
  3. תיקיית המערכת של 32 סיביות
  4. תיקיית המערכת של 16 סיביות
  5. תיקיית Windows
  6. התיקיות שרשומות במשתנה הסביבה PATH

הבעיה היא 2 ו-6. כש-helper.exe לא נמצא ב-1 - החמצת מיקום,‏ build עם תצורה אחרת, שאריות של הסרת התקנה - החיפוש ממשיך ל-2. אם התיקייה הנוכחית היא מקום שניתן לכתיבה (הופעלה ישירות מתיקיית ההורדות של המשתמש, או שתיקייה משותפת משמשת כתיקיית עבודה), אז helper.exe שהונח שם יפעל באותן הרשאות כמו האב. אם ה-PATH ניתן לשינוי, זה נכון גם עבור 6.

גם תיעוד Microsoft מקדיש לנקודה הזו סעיף נפרד בשם ‘הערות לגבי אבטחה’, וכותב במפורש ‘כדי להימנע מהבעיה הזו, אל תעבירו NULL ל-lpApplicationName. הדוגמה המפורסמת שאם לא עוטפים במרכאות נתיב שמכיל רווח,‏ C:\Program.exe עלול להיות מופעל, נמצאת באותו סעיף. לכן גם שורת הפקודה עטופה ב-"...".

אותו דבר גם ב-ProcessStartInfo של C#. כש-UseShellExecute = false,‏ .NET בונה את FileName והארגומנטים לשורת פקודה אחת ומעבירה null ל-lpApplicationName, ולכן אם מעבירים רק שם קובץ, החיפוש שלמעלה קורה בדיוק כך. העבירו נתיב מוחלט שנבנה מ-AppContext.BaseDirectory.

גם בסביבה שנראה שבה ‘טעות מיקום כזו לא תקרה’, עלות הכתיבה כמעט אפסית. בקוד שמפעיל תהליך ילד, אין בעצם סיבה לכתוב את קובץ ההפעלה בשם יחסי.

התאונה שהחיפוש בהפעלה בשם יחסי גורם להתרשים המראה שאם מעבירים NULL ל-lpApplicationName ומפעילים רק עם שם קובץ, אובייקטי החיפוש כוללים את התיקייה הנוכחית של תהליך האב ואת PATH, ושאם ה-helper.exe האמיתי לא נמצא, קובץ הפעלה באותו שם שהונח במקום שניתן לכתיבה יפעל בהרשאות האב, ולכן מצביעים תמיד בנתיב מוחלט.למניעהמפעילים רק עם שם קובץהחיפוש כולל את התיקייה הנוכחית ואת PATHכש-helper.exe לא נמצא במקום המקוריEXE באותו שם שהונח שם פועל בהרשאות האבמצביעים בנתיב מוחלט, עוטפים במרכאות

איור 8: קובעים את קובץ ההפעלה בנתיב מוחלט, בלי להישען על החיפוש.

ל-.NET אין עטיפה ל-Job Object, ולכן זה הופך ל-P/Invoke. הגדרות המבנים נראות ארוכות, אבל בפועל קוראים רק לשתי פונקציות.

// .NET 8 / C# 12. יוצרים Job, מוסיפים KILL_ON_JOB_CLOSE, ומכניסים תהליך שכבר הופעל
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;

internal static class KillOnCloseJob
{
    private const int JobObjectExtendedLimitInformation = 9;
    private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;

    [StructLayout(LayoutKind.Sequential)]
    private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
    {
        public long PerProcessUserTimeLimit;
        public long PerJobUserTimeLimit;
        public uint LimitFlags;
        public nuint MinimumWorkingSetSize;
        public nuint MaximumWorkingSetSize;
        public uint ActiveProcessLimit;
        public nuint Affinity;
        public uint PriorityClass;
        public uint SchedulingClass;
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct IO_COUNTERS
    {
        public ulong ReadOperationCount;
        public ulong WriteOperationCount;
        public ulong OtherOperationCount;
        public ulong ReadTransferCount;
        public ulong WriteTransferCount;
        public ulong OtherTransferCount;
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
    {
        public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
        public IO_COUNTERS IoInfo;
        public nuint ProcessMemoryLimit;
        public nuint JobMemoryLimit;
        public nuint PeakProcessMemoryUsed;
        public nuint PeakJobMemoryUsed;
    }

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern SafeJobHandle CreateJobObjectW(IntPtr attributes, IntPtr name);

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool SetInformationJobObject(
        SafeJobHandle job, int infoClass, ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION info, uint infoSize);

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool AssignProcessToJobObject(SafeJobHandle job, IntPtr process);

    /// <summary>יוצרים Job. ה-handle שמוחזר נשאר פתוח למשך כל חיי האפליקציה.</summary>
    public static SafeJobHandle Create()
    {
        var job = CreateJobObjectW(IntPtr.Zero, IntPtr.Zero);
        if (job.IsInvalid)
        {
            throw new InvalidOperationException($"CreateJobObject に失敗しました。code={Marshal.GetLastWin32Error()}");
        }

        var info = default(JOBOBJECT_EXTENDED_LIMIT_INFORMATION);
        info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;

        var size = (uint)Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
        if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, ref info, size))
        {
            // לא זורקים כמות שהוא Job חצי-מוגמר שנוצר אך לא הוגדר.
            // אם הקורא תופס שגיאת אתחול ומנסה שוב, בכל ניסיון דולף
            // handle של kernel אחד (הגרסה ב-C++ למעלה קוראת ל-CloseHandle
            // בנתיב הזה)
            var error = Marshal.GetLastWin32Error();
            job.Dispose();
            throw new InvalidOperationException($"SetInformationJobObject に失敗しました。code={error}");
        }

        return job;
    }

    public static void Add(SafeJobHandle job, Process process)
    {
        if (!AssignProcessToJobObject(job, process.Handle))
        {
            throw new InvalidOperationException($"AssignProcessToJobObject に失敗しました。code={Marshal.GetLastWin32Error()}");
        }
    }
}

// אם מחזיקים ב-IntPtr גולמי, בנתיב של כישלון אתחול אף אחד לא סוגר.
// אם משתמשים ב-SafeHandle, בנתיב הכישלון מספיק לקרוא ל-Dispose פעם אחת
internal sealed class SafeJobHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    // ה-marshaller יוצר את זה כערך החזרה של P/Invoke, ולכן צריך להיות ניתן ליצירה בלי ארגומנטים
    private SafeJobHandle() : base(ownsHandle: true) { }

    protected override bool ReleaseHandle() => CloseHandle(handle);

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool CloseHandle(IntPtr handle);
}

צד הקריאה נראה כך. אם קוראים רק ל-Create ושוכחים את Add, מתקבל המצב הכי קשה לזיהוי - יש Job אבל אין בו ילד.

// מחזיקים את ה-job handle בשדה וכדומה, ולא סוגרים עד שהאפליקציה מסתיימת.
// מכיוון שמצורף KILL_ON_JOB_CLOSE, ברגע שסוגרים, כל הילדים שב-Job מסתיימים.
// אסור להוסיף כאן using (הילד ימות ברגע היציאה מה-scope)
SafeJobHandle job = KillOnCloseJob.Create();

try
{
    // מעבירים את הקובץ להפעלה בנתיב מוחלט. אם מעבירים רק שם קובץ,
    // אובייקטי החיפוש של CreateProcess כוללים את התיקייה הנוכחית ואת PATH
    string helperPath = Path.Combine(AppContext.BaseDirectory, "helper.exe");

    var startInfo = new ProcessStartInfo(helperPath, "--input data.bin")
    {
        UseShellExecute = false,
        CreateNoWindow = true,
    };

    using var child = Process.Start(startInfo)
        ?? throw new InvalidOperationException("helper.exe を起動できませんでした。");

    try
    {
        KillOnCloseJob.Add(job, child);   // אם שוכחים כאן, ה-Job נשאר ריק
    }
    catch (Exception assignFailed)
    {
        // Add נכשל, למשל, כשבצד האב יש הגבלת Job שלא תואמת. באותו רגע
        // helper.exe כבר רץ. ה-Dispose של ה-`using` רק זורק את העטיפה של
        // Process, אבל לא מסיים את תהליך ה-OS, וה-Job ריק כך שגם
        // job.Dispose() לא מסדר. כאן עוצרים בעצמנו וממתינים עד לסיום
        try
        {
            if (!child.HasExited)
            {
                child.Kill(entireProcessTree: true);
            }

            // Kill מבקש סיום וחוזר מיד. אם זורקים בלי להמתין,
            // זה עלול לפעול במקביל ל-helper השני שהאתחול שלו בוצע מחדש
            child.WaitForExit();
        }
        catch (Exception killFailed)
        {
            // אי-היכולת לעצור חמורה יותר מכישלון ה-Add. אם בולעים את זה,
            // ממשיכים הלאה עם "ילד שלא נכנס ל-Job וגם לא נעצר"
            throw new AggregateException(
                "Job への割り当てに失敗し、helper.exe も停止できませんでした。",
                assignFailed, killFailed);
        }

        throw;
    }
}
catch
{
    // אם נכשלו גם ההפעלה וגם ההכנסה ל-Job, לא משתמשים יותר ב-Job הזה.
    // אם יוצאים בלי לסגור, בכל ניסיון אתחול מחדש נשאר handle של kernel אחד.
    // בשלב הזה ה-Job ריק (או שהילד כבר נעצר ב-catch שלמעלה), ולכן
    // אין בעיה בסגירה
    job.Dispose();
    throw;
}

עם זאת, לגרסת ה-.NET הזו יש פער בין ההפעלה להכנסה ל-Job. אם באותו זמן הילד יוצר נכד, הנכד נולד מחוץ ל-Job. הסיבה שהגרסה ב-C++ משתמשת ב-PROC_THREAD_ATTRIBUTE_JOB_LIST היא בדיוק כדי לבטל את הפער הזה. אם מתמודדים עם helper שיוצר נכדים, שווה גם ב-.NET לרדת עד P/Invoke עם STARTUPINFOEX.

הפער בין ההפעלה ל-Assignתרשים המראה שאם באמצע הפער שבין ההפעלה להכנסה ל-Job עם AssignProcessToJobObject, הילד יוצר נכד, הנכד נולד מחוץ ל-Job, ולכן כשמתמודדים עם helper שיוצר נכדים, שווה לבטל את הפער על ידי ציון ה-Job בזמן היצירה עם PROC_THREAD_ATTRIBUTE_JOB_LIST.לביטול הפערמכניסים ל-Job אחרי ההפעלהפער בין ההפעלה ל-Assignנכד שנולד באמצע נמצא מחוץ ל-Jobמציינים את ה-Job בזמן היצירה ומפעילים

איור 9: בשיטה שמכניסה ל-Job בהמשך, יש פער שדרכו נכד עלול לחמוק.

5. מתכננים את הפצת הסיום עם protocol ו-timeout

סיום תהליך הילד אינו סיפור שמסתיים בקריאת kill API אחת. הצורה הכי פחות נוטה לתקלות היא לעבור בשלושה שלבים.

  1. בקשת סיום מתואם
  2. המתנה עם timeout קצר
  3. בסוף, סיום כפוי של כל ה-Job

אם שומרים על הסדר הזה, נתיב הסיום התקין נשמר, ובמצב תקיעה עדיין אפשר לאסוף.

נוהל הסיום בשלושה שלביםתרשים המראה שסיום תהליך ילד אינו מסתיים בקריאת kill API אחת, ושאם מבקשים סיום מתואם, ממתינים עם timeout קצר, ולבסוף מבצעים סיום כפוי של כל ה-Job, שומרים על נתיב הסיום התקין ועדיין אוספים במצב תקיעה.1. בקשת סיום מתואם2. המתנה עם timeout קצר3. בסוף - סיום כפוי של כל ה-Jobשומרים נתיב תקין, אוספים במצב תקיעה

איור 10: מתכננים את הסיום בשלושה שלבים - בקשה, המתנה, וכפייה.

5.1 ילד GUI

אם לתהליך הילד יש GUI, ב-.NET,‏ CloseMainWindow שולח close message. עם זאת זו בקשת סיום, לא סיום כפוי. לכן,

  • CloseMainWindow
  • ממתינים זמן קבוע
  • אם לא, kill לכל ה-Job

זרימה כזו טבעית יותר.

5.2 ילד console

בילד console, אי אפשר להשתמש ב-close message של GUI. במקרה הזה משתמשים ב-process group ו-console signal.

הזרימה היא להפעיל עם CREATE_NEW_PROCESS_GROUP, ולשלוח CTRL_BREAK_EVENT עם GenerateConsoleCtrlEvent. מה שחשוב כאן הוא,

  • CTRL_C_EVENT לא מתאים להגבלה ל-group ספציפי
  • רק תהליכים ששותפים ל-console מקבלים את ה-signal
  • שימוש ב-CREATE_NEW_PROCESS_GROUP משנה גם את המשמעות של CTRL+C

אלה הנקודות.

5.3 ילד Worker/headless

worker וילד headless לרוב אינם GUI ולא console. במקרה הזה, בטוח יותר להחזיק פרוטוקול סיום ייעודי לתהליך הילד.

  • שולחים quit ל-stdin
  • שולחים פקודת shutdown דרך named pipe/socket/RPC
  • מעבירים בקשת עצירה עם event object

ההפרדה שבה Job Object אחראי ל-tree cleanup מבחינת Windows, ו-pipe או stdin אחראים ל-graceful shutdown מבחינת האפליקציה, פחות נוטה לתקלות.

מפרידים את הסיום המתואם לפי סוג הילדתרשים המראה שלילד GUI משתמשים ב-close message כמו CloseMainWindow, לילד console משתמשים ב-CREATE_NEW_PROCESS_GROUP ו-CTRL_BREAK_EVENT, ול-worker משתמשים בפרוטוקול סיום דרך stdin או pipe - כשאמצעי בקשת הסיום המתואם נבחר לפי סוג הילד.בקשת סיום מתואםילד GUI: close messageילד console: CTRL_BREAK_EVENTworker: פרוטוקול סיום דרך stdin או pipeJob Object אחראי ל-tree cleanup

איור 11: בוחרים את אמצעי הסיום המתואם לפי סוג הילד, ואת האיסוף מפקידים ל-Job.

6. לא נותנים לקלט/פלט הסטנדרטי להיתקע

6.1 stdout‏/‏stderr: ליניקה במקביל

הבסיס הראשון הוא זה. לינוקים את stdout ואת stderr במקביל. קריאה מלאה של צד אחד ורק אז השני, נוטה להיתקע.

ה-pipe של Windows אינו חוצץ אינסופי. אם הילד כותב הרבה ל-stderr, והאב קורא רק stdout, זה קורה באופן רגיל שהילד נתקע ב-write, והאב נתקע בהמתנה לסיום.

בתרשים, זה נראה כך.

קריאה רק מ-stdout גורמת לתקיעה הדדיתתרשים רצף המראה שאם האב ממשיך לקרוא רק את stdout בזמן שהילד כותב הרבה ל-stderr, חוצץ ה-pipe של stderr מתמלא, הילד נעצר ב-write, והאב נעצר בהמתנה לקריאה - כך שגם WaitForExit לא חוזר.תהליך הילדpipe של stderrpipe של stdoutתהליך האבתהליך הילדpipe של stderrpipe של stdoutתהליך האבחוצץ ה-pipe מתמלאה-write לא חוזר. הילד נעצר כאןהילד תקוע, אז שום דבר לא מגיעהאב ממתין לקריאה, הילד ממתין לכתיבה. גם WaitForExit לא חוזרממשיך לקרוא רק stdoutכותב מעטנקרא בהצלחהכותב הרבה אזהרותמנסה לכתוב עודמנסה להמשיך לקרוא

איור 12: אם קוראים רק stdout, ה-pipe של stderr מתמלא, והאב והילד ממתינים זה לזה.

מכיוון שמקום העצירה הוא לא האב ולא הילד, אלא ה-pipe, הסיבה לא משתקפת גם אם בודקים את הלוג של אף אחד מהם. השמטת שורה אחת - ‘לא קוראים את stderr’ - הופכת ישירות לתקיעה.

אם מקבלים את stdout ואת stderr ב-handler-ים נפרדים, וממשיכים לקרוא כל אחד באופן עצמאי, הטבעת הזו לא נוצרת. ב-.NET, זה נראה כך.

// .NET 8 / C# 12. לינוקים את stdout ואת stderr במקביל, וממתינים עד לקריאה מלאה של הפלט
using System;
using System.ComponentModel;   // Win32Exception
using System.Diagnostics;
using System.IO;
using System.Text;

// מעבירים את הקובץ להפעלה בנתיב מוחלט (הסיבה בסעיף Job Object)
string helperPath = Path.Combine(AppContext.BaseDirectory, "helper.exe");

var startInfo = new ProcessStartInfo(helperPath, "--input data.bin")
{
    UseShellExecute = false,        // חובה אם משתמשים ב-redirect
    RedirectStandardOutput = true,
    RedirectStandardError = true,
    CreateNoWindow = true,
};

using var process = new Process { StartInfo = startInfo };

var stdout = new StringBuilder();
var stderr = new StringBuilder();

// לא קוראים צד אחד עד הסוף ורק אז את השני. מקבלים את שניהם באירועים
process.OutputDataReceived += (_, e) =>
{
    if (e.Data is not null)
    {
        stdout.AppendLine(e.Data);
    }
};

process.ErrorDataReceived += (_, e) =>
{
    if (e.Data is not null)
    {
        stderr.AppendLine(e.Data);
    }
};

process.Start();
process.BeginOutputReadLine();   // רישום בלבד לא מתחיל את הקריאה. חובה לקרוא לשניהם
process.BeginErrorReadLine();

if (!process.WaitForExit(30_000))
{
    // כאן זו החלטה של "מפסיקים להמתין", ולא תחליף ל-cleanup
    try
    {
        process.Kill(entireProcessTree: true);
    }
    catch (Exception ex) when (ex is Win32Exception or InvalidOperationException)
    {
        // יש מרוץ שבו הילד מסיים בעצמו "מיד" אחרי שפג ה-timeout של 30 שניות.
        // ב-.NET, Kill בזמן תהליך סיום הופך ל-Win32Exception("The process is
        // terminating."), וב-.NET Framework, Kill על תהליך שכבר הסתיים הופך
        // ל-InvalidOperationException.
        // אם זה כבר הסתיים, זו לא כישלון, אז בולעים את זה וממשיכים ל-
        // TimeoutException למטה. אם זה עדיין חי, זה אומר שבאמת לא הצלחנו
        // לעצור, ולכן זורקים שוב כמות שזה
        if (!process.HasExited)
        {
            throw;
        }
    }
    // לא בולעים AggregateException (לא הצלחנו לעצור חלק מהצאצאים).
    // זה בעצם "העץ לא סודר", ולכן זורקים אותו החוצה

    // Kill מבקש סיום וחוזר מיד. אם זורקים כאן בלי להמתין,
    // יכול להיות שהילד עדיין חי כשה-Dispose של ה-using מתבצע,
    // ואז "יצא חריג timeout = העץ סודר" לא מתקיים
    process.WaitForExit();

    throw new TimeoutException("helper.exe が 30 秒以内に終了しませんでした。");
}

// גם אם WaitForExit עם timeout מחזיר true, ייתכן שעיבוד הפלט האסינכרוני עדיין לא הסתיים.
// קוראים שוב ל-WaitForExit בלי ארגומנטים, וממתינים עד לקריאה מלאה של הפלט.
process.WaitForExit();

Console.WriteLine($"exit code : {process.ExitCode}");
Console.WriteLine($"stdout    : {stdout.Length} 文字");
Console.WriteLine($"stderr    : {stderr.Length} 文字");

בגבול ה-timeout, תמיד יש מרוץ. ייתכן שהילד יסיים בעצמו בפרק הזמן הקצר שבין WaitForExit(30_000) שמחזיר false לבין הקריאה ל-Kill. באותו רגע Kill לא מצליח - ב-.NET הופך ל-Win32Exception(”The process is terminating.”)בזמן עיבוד הסיום, וב-.NET Framework הופך ל-InvalidOperationException על תהליך שכבר הסתיים. אם נותנים לזה לעבור בלי טיפול, במקום ה-TimeoutException שהיה אמור להיזרק, נזרקת כישלון של ה-cleanup. צד הקורא מקבל ‘שגיאה לא ברורה’ במקום ‘זה עבר timeout’, וגם הקריאה המלאה של הפלט על ידי ה-WaitForExit() האחרון מדולגת. כמו למעלה, בודקים עם HasExited אם ‘זה באמת הסתיים’ לפני שבולעים. אם זה עדיין חי, זה אומר שבאמת לא הצלחנו לעצור, ולכן זורקים שוב כמות שזה. שימו לב ש-AggregateException(לא הצלחנו לעצור חלק מהצאצאים)שנזרק על ידי Kill(entireProcessTree: true) לא נבלע. זה בעצם ‘העץ לא סודר’, והמצב הזה בדיוק הוא מה שהסעיף הזה מנסה למנוע.

המרוץ בגבול ה-timeoutתרשים המראה שבפרק הזמן הקצר שבין WaitForExit שמחזיר false לקריאה ל-Kill, הילד עלול לסיים בעצמו, ושבמקום פשוט להעביר את זה, בודקים עם HasExited אם זה באמת הסתיים לפני שבולעים, ואם עדיין חי זורקים שוב.הסתייםעדיין חימיד אחרי שה-timeout פג, הילד מסיים בעצמוKill נכשלבודקים עם HasExitedלא כישלון, בולעיםלא הצלחנו לעצור, זורקים שובזורקים את ה-TimeoutException המקורי

איור 13: במרוץ בגבול, מבחינים עם HasExited אם כישלון ה-Kill הוא כישלון אמיתי.

ה-WaitForExit() האחרון אינו שכחה למחוק, אלא חובה. בתיעוד של WaitForExit(int) כתוב שאם ה-redirect של הפלט הסטנדרטי מופנה ל-event handler אסינכרוני, ייתכן שעיבוד הפלט עדיין לא הושלם ברגע שה-overload הזה חוזר, ומומלץ לקרוא ל-WaitForExit() בלי ארגומנטים אחרי קבלת true. אם מדלגים על זה, זה נשבר בצורה שקשה לשחזר - רק סוף הפלט חסר.

6.2 אם משתמשים ב-stdin, מתכננים עד ל-EOF

היכולת לכתוב ל-stdin והיכולת של הילד לסיים אינם אותו דבר.

  • לא סוגרים אחרי כתיבת הקלט
  • האב חושב ‘כבר העברתי’
  • הילד חושב ‘עוד יגיע המשך’ וממשיך להמתין

מצב כזה קורה. אם משתמשים ב-stdin, יש לתכנן גם עד כדי סגירה (close) בסיום הכתיבה כדי להעביר EOF.

מתכננים את stdin עד ל-EOFתרשים המראה שאם לא סוגרים אחרי כתיבת קלט ל-stdin, האב חושב שכבר העביר, והילד חושב שעוד יגיע המשך וממשיך להמתין, ולכן יש לתכנן עד כדי סגירה בסיום הכתיבה כדי להעביר EOF.לא סוגרים אחרי כתיבת קלטהאב חושב 'כבר העברתי'הילד חושב 'עוד יגיע' וממתיןסוגרים בסיום הכתיבה כדי להעביר EOF

איור 14: התכנון של stdin אינו רק יכולת כתיבה, אלא עד להעברת EOF.

6.3 תמיד סוגרים קצה pipe מיותר

אם לא סוגרים את הקצה הלא-בשימוש בצד האב ובצד הילד, ה-EOF לא מועבר, ותנאי הסיום מתמוטט. זה פשוט, אבל בעבודה מעשית זו תקלה נפוצה למדי.

6.4 לא משאירים בעמימות את UseShellExecute=false ואת הטיפול בהורשת handle

אם משתמשים ב-redirect של הקלט/פלט הסטנדרטי, ב-.NET,‏ UseShellExecute=false הוא תנאי מוקדם. גם ב-Win32, בטוח יותר לצמצם ככל האפשר מה מורישים. אם משאירים bInheritHandles=TRUE ומורישים הכול, זה עלול לגרום ל-handle leak בלתי צפוי.

7. ממקמים את ה-watchdog ‘בחוץ’

החשוב ביותר כשמכניסים watchdog הוא לא להכניס אותו לאותו Job כמו מטרת הניטור. אם ה-worker נופל ורוצים להפעיל מחדש, אין טעם אם גם תפקיד ההפעלה מחדש מת יחד איתו.

ה-watchdog ממוקם מחוץ למטרת הניטורתרשים המראה שאם מכניסים watchdog לאותו Job כמו מטרת הניטור, כשה-worker נופל וה-Job מסודר, גם תפקיד ההפעלה מחדש מת יחד איתו, ולכן ממקמים אותו מחוץ ל-Job.לכןמכניסים watchdog לאותו Jobכשמסדרים, גם תפקיד ההפעלה מחדש מתממקמים את ה-watchdog מחוץ ל-Jobגם אם ה-worker נופל, אפשר להפעיל מחדש

איור 15: לא לעשות מתפקיד ההפעלה מחדש שותף גורל - זהו התנאי הראשון למיקום ה-watchdog.

7.1 ניטור ה-exit מבוסס wait handle

כשתהליך מסתיים, הוא נכנס למצב signaled. לכן, ניטור ה-exit לא צריך במהותו polling loop שבודק HasExited כל 100ms.

ב-Win32,

  • WaitForSingleObject
  • WaitForMultipleObjects
  • RegisterWaitForSingleObject
  • SetThreadpoolWait

אלה הגישה הנכונה. אם מטפלים בכמה ילדים, גישה מבוססת wait handle טבעית יותר מ-timer polling.

7.2 לא ממתינים ללא הגבלה ב-UI thread

WaitForSingleObject(INFINITE) נוח, אבל אם משתמשים בו ב-thread שמחזיק חלון, קל לעצור את ה-message pump. ב-UI thread,‏ COM apartment thread, ו-thread שמחזיק message pump, בטוח יותר לחשוב מראש על המיקום של ההמתנה.

ניטור ה-exit מבוסס wait handleתרשים המראה שכשתהליך מסתיים הוא נכנס למצב signaled, ולכן ניטור ה-exit מתבצע לא על ידי polling תקופתי של HasExited אלא מבוסס wait handle, ושהמתנה ללא הגבלה ב-UI thread עוצרת את ה-message pump.polling תקופתי של HasExitedבעצם לא נחוץממתינים ל-handle שנכנס ל-signaled בסיוםניטור מבוסס wait handleהמתנה ללא הגבלה ב-UI thread עוצרת את המסך

איור 16: זיהוי הסיום מופקד ב-wait handle, לא ב-polling.

7.3 ל-hang watchdog דרוש heartbeat

ל-exit watchdog מספיק process handle. אבל hang watchdog שונה.

  • תקוע ב-CPU 100%
  • ב-deadlock
  • ה-event loop חי אך אין התקדמות
  • עצור בהמתנה לקלט

מצבים כאלה לא ניתנים לקביעה רק לפי ‘האם התהליך חי’. לכן, אם רוצים לראות גם תקיעה,

  • heartbeat
  • רצף התקדמות
  • חותמת זמן של עבודה מוצלחת אחרונה
  • בדיקת תקינות (health probe)

כמו אלה, דרושה בדיקת חיים ברמת האפליקציה.

לזיהוי תקיעה דרוש heartbeatתרשים המראה שמצבים כמו תקיעה ב-CPU 100%, deadlock, או היעדר התקדמות לא ניתנים לקביעה רק לפי האם התהליך חי, ולכן אם רוצים לראות גם תקיעה, דרושה בדיקת חיים ברמת האפליקציה כמו heartbeat או התקדמות.לכןהתהליך חיאבל אולי לא מתקדםניטור exit בלבד לא מספיק לקביעהבדיקת חיים ברמת האפליקציה כמו heartbeat

איור 17: ‘האם חי’ ו-‘האם מתקדם’ הם שני סוגי ניטור שונים.

7.4 תפקיד ההפעלה מחדש ממוקם מחוץ למטרת הניטור

בעבודה מעשית, שתי התבניות האלה נפוצות.

  • האפליקציה האם רק מפעילה helper באופן זמני
    • האב מחזיק את ה-Job, וסיום האב אוסף את עץ ה-helper
  • מריצים worker לטווח ארוך, ורוצים להפעיל מחדש אם הוא נופל
    • תהליך או שירות watchdog חיצוני יוצר Job לכל דור (generation) של ה-worker

במקרה השני, תכנון שמפריד בין עץ ה-worker לבין סמכות ההפעלה מחדש יציב יותר.

7.5 מדיניות ההפעלה מחדש מוחזקת כ-budget

אחרי שמכניסים watchdog, הבעיה הבאה היא crash loop.

  • הפעלה מחדש מיידית
  • נופל שוב מיד
  • רק כמות אדירה של לוגים יוצאת

כדי להימנע מזה,

  • backoff
  • תקרה למספר ה-restart בפרק זמן קבוע
  • בכישלון רצוף, עוצרים ומודיעים

עדיף להחזיק restart budget כזה.

עוצרים crash loop עם restart budgetתרשים המראה שכדי להימנע מ-crash loop שבו מפעילים מחדש מיד ונופלים שוב מיד, מחזיקים restart budget עם backoff, תקרה למספר הפעלות מחדש בפרק זמן קבוע, ועצירה עם הודעה בכישלון רצוף.למניעההפעלה מחדש מיידית → נופל שוב מידcrash loop ושיטפון לוגיםמוסיפים backoffתקרה למספר בפרק זמן קבועבכישלון רצוף - עוצרים ומודיעים

איור 18: מנהלים את ההפעלה מחדש כתקציב, וכשהוא נגמר, עוצרים ומודיעים לבן אדם.

8. תצורה מומלצת לפי תבנית טיפוסית

מצב תצורה מומלצת
יישום שולחני מפעיל CLI helper חד-פעמי הפעלה אחת = Job אחד. מוסיפים KILL_ON_JOB_CLOSE, ומלינוקים stdout‏/‏stderr במקביל. בביטול: סיום מתואם ← timeout ← Job kill
ה-helper מפעיל תהליך נכד נוסף מניחים Job Object, ולא מתירים breakaway. אם רוצים לקבע כבר מרגע ההפעלה, PROC_THREAD_ATTRIBUTE_JOB_LIST
service/watchdog מנטרים עץ worker לטווח ארוך ה-watchdog הוא process/service חיצוני. יוצרים Job לכל דור (generation) של ה-worker, ומנטרים עם exit handle + heartbeat
רוצים לעצור בעדינות כלי console מפעילים עם CREATE_NEW_PROCESS_GROUP, סיום מתואם עם CTRL_BREAK_EVENT. אחר כך Job kill עם timeout
רוצים לסגור GUI helper מקביל ל-CloseMainWindow‏/‏WM_CLOSE ← timeout ← Job kill
רוצים לנטר הרבה תהליכי ילד במקום להוסיף blocking thread, משתמשים ב-RegisterWaitForSingleObject‏/‏SetThreadpoolWait

החשוב ביותר כאן הוא להפריד בין מנגנון ה-graceful shutdown ל-מנגנון ה-cleanup.

מנגנון הבקשה ומנגנון הסידורתרשים המראה שבכל תבנית טיפוסית, החשוב ביותר הוא להחזיק בנפרד את מנגנון ה-graceful shutdown, כמו close message או פרוטוקול סיום, ואת מנגנון ה-cleanup באמצעות Job Object.מנגנון ה-graceful shutdownמחזיקים את שניהם בנפרדמנגנון ה-cleanup(Job)במצב תקין פועל הראשון, במצב חריג פועל השני

איור 19: בכל תבנית, מכינים בנפרד את נתיב הבקשה ואת נתיב האיסוף.

9. מה אסור לעשות

מרכזים כאן מחדש את נקודות הזהירות שהוזכרו בכל פרק, בצורה שאפשר להשתמש בה ישירות בביקורת (review). מציגים ‘מה קורה’ ו-‘איפה זה כתוב’ זה לצד זה, כך שאפשר לחזור לגוף המאמר משורה שנתקעתם בה.

מה אסור לעשות מה קורה פרק
לחשוב ש-Kill(entireProcessTree: true) לבדו פותר גם graceful shutdown וגם איסוף בקריסת האב פועל רק בעצירה מפורשת. חסרים האיסוף כשהאב נופל, והנתיב שגורם לילד לסדר את עצמו פרק 5
להשאיר bInheritHandles=TRUE ולהוריש הכול handle-ים לא מכוונים עוברים לילד, וגורמים ל-handle leak ולאי-הגעת EOF 6.4
לקרוא את כל ה-stdout ורק אז את stderr ה-pipe השני מתמלא, האב נעצר בהמתנה לקריאה, והילד בהמתנה לכתיבה 6.1
לא לסגור קצה pipe לא-בשימוש EOF לא מועבר, ותנאי הסיום של צד הקריאה לא מתקיים 6.3
לבצע WaitForSingleObject(INFINITE) ב-UI thread ה-message pump נעצר, והמסך וה-COM נתקעים 7.2
להכניס watchdog לאותו Job כמו מטרת הניטור כשמסדרים את מטרת הניטור, גם תפקיד ההפעלה מחדש נעלם יחד איתה פרק 7
להשתמש ב-259 כ-exit code רגיל GetExitCodeProcess מחזיר STILL_ACTIVE, כלומר 259, בזמן ריצה. אם הילד מסתיים כרגיל עם 259, נקבע בטעות שהוא בריצה למרות שהסתיים 7.1
להפוך את הודעת ה-completion port של ה-Job למקור האמת היחיד ההודעה מיועדת לניטור ואגירה, ואם בונים עליה לבד correctness, מפספסים 4.2

10. סיכום

כשמטפלים בבטחה בתהליכי ילד ביישום Windows, הסידור הזה הוא הכי משפיע.

מי בעל ה-process tree איך מעבירים את בקשת הסיום איך מזרימים עד הסוף את הקלט/פלט הסטנדרטי היכן ממקמים את ה-watchdog

מחליטים על ארבעת אלה מראש.

מעבר לזה, בגסות, זה כך.

  • נקודת הייחוס ל-tree cleanup היא Job Object
  • מפרידים את ה-graceful shutdown לפי GUI‏/console‏/worker
  • מתכננים את ה-stdio כולל ליניקה במקביל ועד ל-EOF
  • ממקמים את ה-watchdog מחוץ למטרת הניטור, ורואים לא ב-polling אלא ב-wait handle ו-heartbeat

CreateProcess או Process.Start עצמם הם רק הכניסה. מה שבאמת משפיע על שיעור התקלות הוא מיקום אחריות הסיום ו-ליווי ה-I/O עד הסוף.

ארבעה דברים שמחליטים עליהם מראשתרשים המראה שמי בעל עץ התהליכים, איך מעבירים את בקשת הסיום, איך מזרימים עד הסוף את הקלט/פלט הסטנדרטי, והיכן ממקמים את ה-watchdog - ארבעת אלה שמוחלטים מראש משפיעים הכי הרבה על שיעור תקלות תהליך הילד.בעל העץמחליטים מראשאופן העברת הסיוםהטיפול ב-stdioמיקום הניטורה-API להפעלה הוא רק הכניסה

איור 20: מה שמשפיע על שיעור התקלות הוא להחליט על ארבעת אלה לפני ההפעלה.

מקורות

מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.

העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.

המאמר קשור ישירות לשירותים הבאים.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

למה תהליך הילד נשאר גם אחרי שתהליך האב קרס?
מכיוון של-process handle או ל-process group בלבד אין מנגנון לאיסוף עץ התהליכים בעת קריסת האב. אם רוצים לקשור בין חייו של האב לחיי עץ תהליכי הילד, נקודת הייחוס היא Job Object. אם מוסיפים JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, כל התהליכים ששייכים ל-Job מסתיימים כשה-job handle האחרון נסגר, ולכן אפשר להעביר את ה-cleanup לחיי האב, כולל מקרה של סיום חריג שלו.
למה WaitForExit לא חוזר?
כנראה שה-pipe של הפלט או השגיאה הסטנדרטיים נתקע. מכיוון שה-pipe ב-Windows אינו חוצץ אינסופי, אם הילד כותב הרבה ל-stderr והאב קורא רק stdout, הילד נתקע ב-write והאב נתקע בהמתנה לסיום. הבסיס הוא לינוק את stdout ואת stderr במקביל - יישום שקורא לגמרי צד אחד ורק אז את השני נוטה להיתקע. בנוסף, אם לא סוגרים את הקצה הלא-בשימוש של ה-pipe, ה-EOF לא מועבר ותנאי הסיום מתמוטט.
האם לא מספיק להשתמש רק ב-Kill(entireProcessTree: true) של .NET?
לא מספיק. זה נוח כ-API לעצירה מפורשת, אך אינו תחליף לתכנון שכולל איסוף אוטומטי בעת קריסת האב וסיום מתואם (graceful shutdown). הפחות נוטה לתקלות הוא שלושת השלבים - בקשת סיום מתואם, המתנה עם timeout קצר, ולבסוף סיום כפוי של כל ה-Job. אמצעי הסיום המתואם מתחלק לפי סוג הילד - CloseMainWindow לילד GUI,‏ CREATE_NEW_PROCESS_GROUP ו-CTRL_BREAK_EVENT לילד console, ופרוטוקול סיום דרך stdin או pipe ל-worker.
היכן כדאי למקם את תהליך ה-watchdog?
החשוב ביותר הוא לא להכניס אותו לאותו Job כמו מטרת הניטור. אם ה-worker נופל ורוצים להפעיל מחדש, אין טעם אם גם תפקיד ההפעלה מחדש מת יחד איתו. כשמריצים worker לטווח ארוך, תצורה יציבה היא שתהליך או שירות watchdog חיצוני יוצר Job לכל דור (generation) של ה-worker. ניטור היציאה (exit) נעשה על בסיס wait handle ולא polling, ואם דרוש גם זיהוי תקיעה (hang), משלבים בדיקת חיים ברמת האפליקציה כמו heartbeat.

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג