רשימת בדיקה לניהול בטוח של child processes באפליקציית Windows

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

היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 20 Mar 2026)
פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22173688)

מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.

Go Komura (2026). רשימת בדיקה לניהול בטוח של child processes באפליקציית Windows. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173688 https://comcomponent.com/he/blog/windows-app-safe-child-process-handling-job-object-exit-propagation-stdio-watchdog/

DOI (הגרסה האחרונה)
10.5281/zenodo.22173688
DOI (הגרסה הזו)
10.5281/zenodo.22173689

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

כלי המרה, updater, worker לניתוח, CLI חיצוני, PowerShell, ffmpeg, כלי עזר פנימיים. אפליקציית Windows נשענת על child processes בקלות רבה יותר ממה שנדמה.

אבל התקלה היא לא “האם הצלחנו להפעיל”.

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

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

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

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

איור 1: בטיחות child process נקבעת בבעלות, בסיום וב-I/O, לא ב-API להפעלה.

מונחים במאמר

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

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

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

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

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

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

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

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

1. קודם המסקנה

קודם רק מה שעובד הכי חזק בפועל.

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

ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 17, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle

2. מה מסוכן

הפעלת child process נכתבת בהתחלה בערך בעשר שורות. התקלה קורית מחוץ לעשר השורות האלה.

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

כאן חשוב: “ניהול child process” הוא לא שיח על API אחד.

לפחות ארבע אלה כדאי להפריד כדי לראות בבירור.

  1. מי הבעלים של עץ התהליכים
  2. איך מבקשים graceful shutdown
  3. איך מזרים את ה-stdio
  4. איך מנטרים סיום חריג ו-hang
ארבע שאלות שמפרידיםניהול child process הוא לא שיח על API אחד; מפרידים בין מי הבעלים של עץ התהליכים, איך מבקשים graceful shutdown, איך מזרים stdio, ואיך מנטרים סיום חריג ו-hang.1. בעלות על עץ התהליכים2. איך מבקשים graceful shutdown3. איך מזרים stdio4. ניטור סיום חריג ו-hangזה לא שיח על API אחד

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

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

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

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

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

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

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

מה רוצים Win32 / C++ .NET / C#
להפעיל תהליך CreateProcessW Process.Start
ליצור Job ולשים הגבלה CreateJobObjectW + SetInformationJobObject אותם API ב-P/Invoke. אין wrapper ל-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 anonymous pipe, קריאה ב-thread נפרד RedirectStandardOutput ו-BeginOutputReadLine
לבקש מ-GUI child להיסגר לשלוח WM_CLOSE Process.CloseMainWindow
לשלוח Ctrl+Break ל-console child CREATE_NEW_PROCESS_GROUP + GenerateConsoleCtrlEvent אין API מקביל, לכן P/Invoke
לסיים בכוח את כל העץ TerminateJobObject, או לסגור את ה-job handle האחרון Process.Kill(entireProcessTree: true) (.NET Core 3.0 ומעלה), או אותו P/Invoke
לחכות לסיום של הרבה children RegisterWaitForSingleObject / SetThreadpoolWait אירוע Process.Exited, או WaitForExitAsync

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

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

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

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

החוזק הגדול של Job Object הוא שאפשר לאגד process tree לפי “לאיזה Job שייך”, לא לפי “של מי ה-child”. תהליך שנכנס ל-Job ויוצר child ב-CreateProcess מכניס אותו כברירת מחדל לאותו Job.

ועוד: עם JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, כשנסגר ה-job handle האחרון כל התהליכים שמשויכים ל-Job מסתיימים.

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

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

4.1 ארבעה דברים לקבע קודם

1. אם רוצים לנקות את העץ עם סיום ה-parent: KILL_ON_JOB_CLOSE

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

2. לא לשים BREAKAWAY בקלות

JOB_OBJECT_LIMIT_BREAKAWAY_OK ו-JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK נראים נוחים, אבל הם גם סיבה לכך שחלק מהעץ שחשבתם שאפשר לנקות בורח. בלי כוונה מפורשת, בלי breakaway שיעור התקלות יורד.

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

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

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

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

לא משאירים עמום מי הבעלים של job handleKILL_ON_JOB_CLOSE נכנס לפעולה כשנסגר ה-handle האחרון; אם משכפלים job handle או נותנים לו לעבור בירושה, ה-parent יכול למות בלי cleanup, ולכן קובעים קודם מי הבעלים האחרון.לכןמשכפלים או מורישים job handleה-handle האחרון לא נסגרה-parent מת, cleanup לא קורהקובעים קודם מי הבעלים האחרון

איור 6: KILL_ON_JOB_CLOSE עובד רק כשנסגר ה-handle האחרון.

4.2 Job Object שימושי גם ל-observability, אבל ההודעות לא חסינות

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

לכן completion port נוח ל-

  • ניטור
  • צבירה
  • לוג
  • metrics

אבל לא בונים עליו לבד correctness.

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

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

4.3 מבט בקוד מינימלי

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

בצד C++ שלושה מהלכים: יוצרים Job → שמים KILL_ON_JOB_CLOSE → מציינים Job כבר בהפעלה.

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

int wmain()
{
    // 0. מקבעים את קובץ ההפעלה בנתיב מוחלט.
    // אם lpApplicationName הוא nullptr והחיפוש מתחיל מתחילת שורת הפקודה,
    // יעדי החיפוש כוללים את תיקיית העבודה הנוכחית של ה-parent ואת PATH.
    // אם helper.exe לא בתיקייה שלכם, ומישהו שם קובץ הרצה באותו שם
    // במקום שניתן לכתיבה, הוא רץ בהרשאות של ה-parent
    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. בונים רשימת attributes כדי להשתייך ל-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 דורש buffer שאפשר לשכתב.
    // שמים את אותו נתיב גם ב-argv[0]. יש רווחים, אז חובה מרכאות
    std::wstring commandLine = L"\"" + application + L"\" --input data.bin";

    BOOL created = CreateProcessW(
        application.c_str(), commandLine.data(), nullptr, nullptr,
        FALSE,                          // מצמצמים אילו handles עוברים בירושה
        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 נשברת

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

השלב “0.” שבומרכבים נתיב מ-GetModuleFileNameW הוא לא סגנון. הוא כדי לקבע איזה קובץ הרצה באמת רץ.

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

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

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

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

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

גם בסביבה שנראית “כזו טעות פריסה לא קורית”, עלות הכתיבה כמעט אפס. בקוד שמפעיל child process, כמעט אין סיבה לכתוב את קובץ ההרצה בשם יחסי.

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

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

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

// .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 חצי-מוכן שנפתח אבל לא הוגדר.
            // אם הקורא תופס שגיאת אתחול ומנסה שוב,
            // כל ניסיון מדליף kernel handle אחד (גרסת 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, אז חייבים ctor בלי ארגומנטים
    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 אבל ה-child לא בפנים. זה המצב שהכי קשה לשים לב אליו.

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

try
{
    // קובץ ההפעלה בנתיב מוחלט. שם קובץ בלבד מכניס
    // את תיקיית העבודה הנוכחית ואת PATH ליעדי החיפוש של CreateProcess
    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 נכשל למשל כשיש על ה-parent הגבלת Job לא תואמת.
        // helper.exe כבר רץ. Dispose של using זורק רק את ה-wrapper של Process,
        // תהליך ה-OS לא מסתיים, וה-Job ריק אז גם job.Dispose() לא מנקה.
        // עוצרים כאן ומחכים לסיום
        try
        {
            if (!child.HasExited)
            {
                child.Kill(entireProcessTree: true);
            }

            // Kill מבקש סיום וחוזר מיד. אם זורקים בלי לחכות,
            // helper שני אחרי אתחול מחדש עלול לרוץ במקביל
            child.WaitForExit();
        }
        catch (Exception killFailed)
        {
            // כישלון לעצור כבד יותר מכישלון Add. אם בולעים,
            // ממשיכים עם child שלא ב-Job וגם לא נעצר
            throw new AggregateException(
                "השיוך ל-Job נכשל, וגם לא הצלחנו לעצור את helper.exe.",
                assignFailed, killFailed);
        }

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

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

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

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

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

סיום של child process לא נגמר ב-kill API אחד. מה שפחות נשבר הוא שלושה שלבים.

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

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

תהליך סיום בשלושה שלביםסיום child process הוא לא kill API אחד; מבקשים graceful shutdown, מחכים עם timeout קצר, ובסוף מסיימים בכוח את כל ה-Job, וכך שומרים מסלול תקין ואוספים גם ב-hang.1. מבקשים graceful shutdown2. מחכים עם timeout קצר3. בסוף מסיימים בכוח את ה-Jobמסלול תקין נשמר, hang נאסף

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

5.1 GUI child

ל-child עם GUI, ב-.NET CloseMainWindow שולח close message. אבל זו בקשת סיום, לא סיום בכוח. לכן הזרימה הטבעית היא:

  • CloseMainWindow
  • לחכות זמן קצוב
  • אם לא, kill לכל ה-Job

5.2 Console child

ב-console child אין close message של GUI. כאן משתמשים ב-process group וב-console signal.

מפעילים עם CREATE_NEW_PROCESS_GROUP, ושולחים CTRL_BREAK_EVENT ב-GenerateConsoleCtrlEvent. חשוב:

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

5.3 Worker / headless child

Worker או headless child לרוב לא GUI ולא console. כאן בטוח יותר להחזיק protocol סיום ייעודי ל-child.

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

בסגנון Windows, Job Object אוסף את העץ, ובסגנון האפליקציה pipe או stdin עושים graceful shutdown. ההפרדה הזו פחות נשברת.

מפרידים graceful shutdown לפי סוג ה-childל-GUI שולחים close message כמו CloseMainWindow, ל-console משתמשים ב-CREATE_NEW_PROCESS_GROUP ו-CTRL_BREAK_EVENT, ל-worker ב-protocol סיום דרך stdin או pipe; איסוף העץ נשאר אצל Job Object.בקשת graceful shutdownGUI child: close messageconsole child: CTRL_BREAK_EVENTworker: protocol סיום ב-stdin או pipeאיסוף העץ אצל Job Object

איור 11: אמצעי graceful shutdown נבחר לפי סוג ה-child, והאיסוף נשאר ל-Job.

6. לא לחסום את ה-stdio

6.1 drain במקביל ל-stdout / stderr

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

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

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

תהליך childpipe של stderrpipe של stdoutתהליך parentתהליך childpipe של stderrpipe של stdoutתהליך parentbuffer של ה-pipe מתמלאwrite לא חוזר. ה-child נעצר כאןה-child עצור, לא מגיע כלוםה-parent מחכה לקריאה, ה-child לכתיבה. גם WaitForExit לא חוזרממשיך לקרוא רק stdoutכותב קצתנקראכותב הרבה אזהרותמנסה לכתוב עודמנסה לקרוא המשך

איור 12: אם קוראים רק stdout, pipe של stderr מתמלא וה-parent וה-child מחכים זה לזה.

מקום העצירה הוא לא ה-parent ולא ה-child, אלא ה-pipe, ולכן אף לוג לא מראה את הסיבה. שורה אחת חסרה, “לא קראנו stderr”, הופכת ישר ל-hang.

אם מקבלים stdout ו-stderr ב-handlers נפרדים וקוראים כל אחד בנפרד, המעגל הזה לא נסגר. ב-.NET זה נראה כך.

// .NET 8 / C# 12. drain במקביל ל-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)
    {
        // יש מרוץ על הגבול: מיד אחרי ש-30 השניות נגמרו ה-child מסיים בעצמו.
        // ב-.NET, Kill באמצע סיום זורק Win32Exception ("The process is
        // terminating."); ב-.NET Framework, Kill על תהליך שכבר הסתיים
        // זורק InvalidOperationException.
        // אם כבר הסתיים זו לא כשל, בולעים וממשיכים ל-TimeoutException.
        // אם עדיין חי, באמת לא הצלחנו לעצור, זורקים שוב
        if (!process.HasExited)
        {
            throw;
        }
    }
    // לא בולעים AggregateException (לא הצלחנו לעצור חלק מהצאצאים).
    // זה עצם המצב שהעץ לא נוקה, ומוציאים החוצה

    // Kill מבקש סיום וחוזר מיד. אם זורקים בלי לחכות,
    // Dispose של using עלול לרוץ כשה-child עדיין חי,
    // ו"יצא TimeoutException = העץ נוקה" לא מתקיים
    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, ה-child יכול לסיים בעצמו. אז Kill לא מצליח: ב-.NET זה Win32Exception באמצע סיום (“The process is terminating.”), ב-.NET Framework זה InvalidOperationException על תהליך שכבר הסתיים. אם נותנים לזה לעבור, במקום TimeoutException שהייתם אמורים לזרוק, יוצאת כשל של ה-cleanup. הקורא מקבל “שגיאה לא ברורה” במקום “timeout”, וגם קריאת הפלט עד הסוף ב-WaitForExit() האחרון מדולגת. כמו למעלה, בודקים ב-HasExited אם באמת הסתיים, ורק אז בולעים. אם עדיין חי, לא הצלחנו לעצור, וזורקים שוב. AggregateException ש-Kill(entireProcessTree: true) זורק (לא הצלחנו לעצור חלק מהצאצאים) לא נבלע. זה עצם המצב שהעץ לא נוקה, וזה מה שהפרק מנסה למנוע.

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

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

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

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

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

  • כותבים קלט ולא עושים close
  • ה-parent חושב “כבר מסרתי”
  • ה-child חושב “עוד יבוא המשך” וממשיך לחכות

זה המצב שנוצר. אם משתמשים ב-stdin, צריך לתכנן עד close אחרי הכתיבה כדי להעביר EOF.

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

איור 14: ב-stdin התכנון הוא לא היכולת לכתוב, אלא שה-EOF באמת מגיע.

6.3 סוגרים תמיד pipe end שלא בשימוש

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

6.4 לא משאירים עמום את UseShellExecute=false ואת ירושת handles

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

7. watchdog שמים בחוץ

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

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

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

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

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

ב-Win32 הגישה הישירה היא

  • WaitForSingleObject
  • WaitForMultipleObjects
  • RegisterWaitForSingleObject
  • SetThreadpoolWait

מול כמה children, wait handle טבעי יותר מ-timer polling.

7.2 לא מחכים אינסוף ב-UI thread

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

ניטור exit מבוסס wait handleתהליך עובר ל-signaled בסיום, ולכן ניטור exit הוא wait handle ולא polling של HasExited; המתנה אינסופית ב-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 חי אבל אין התקדמות
  • נעצר בהמתנה לקלט

את המצבים האלה אי אפשר לשפוט לפי “האם התהליך חי”. אם רוצים לראות גם hang, צריך בדיקת חיות ברמת האפליקציה כמו

  • heartbeat
  • progress sequence
  • last successful work timestamp
  • health probe
לזיהוי hang צריך heartbeatCPU 100%, deadlock או חוסר התקדמות אי אפשר לשפוט לפי האם התהליך חי; אם רוצים לראות hang, צריך בדיקת חיות ברמת האפליקציה כמו heartbeat או התקדמות.לכןהתהליך חיאבל אולי הוא לא מתקדםניטור exit לבד לא מספיק לשפוטבדיקה ברמת האפליקציה: heartbeat או התקדמות

איור 17: “האם חי” ו”האם מתקדם” הם שני ניטורים שונים.

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

בפועל רואים בעיקר שני דפוסים.

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

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

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

ברגע שיש watchdog, מתחיל crash loop.

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

כדי להימנע:

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

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

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

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

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

מצב תצורה מומלצת
אפליקציית שולחן עבודה מפעילה CLI helper חד-פעמי הפעלה אחת = Job אחד. שמים KILL_ON_JOB_CLOSE, drain במקביל ל-stdout / stderr. בביטול: graceful shutdown → timeout → Job kill
helper מפעיל גם נכדים מניחים Job Object, בלי breakaway. אם רוצים לקבע כבר בהפעלה, PROC_THREAD_ATTRIBUTE_JOB_LIST
service / watchdog מנטר worker tree לאורך זמן watchdog הוא תהליך או שירות חיצוני. Job לכל worker generation, ניטור ב-exit handle + heartbeat
רוצים לעצור כלי console בצורה מסודרת מפעילים עם CREATE_NEW_PROCESS_GROUP, graceful shutdown ב-CTRL_BREAK_EVENT, ואחר כך Job kill ב-timeout
רוצים לסגור GUI helper CloseMainWindow / מקביל ל-WM_CLOSE → timeout → Job kill
רוצים לנטר הרבה child processes במקום להרבות blocking threads, RegisterWaitForSingleObject / SetThreadpoolWait

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

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

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

9. מה לא לעשות

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

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

10. סיכום

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

מי הבעלים של process tree איך מעבירים בקשת סיום איך מזרים stdio עד הסוף איפה שמים watchdog

קובעים את ארבע אלה קודם.

משם, בגסות:

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

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

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

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

11. מקורות

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

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

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

שאלות נפוצות

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

למה child process נשאר אחרי שה-parent קרס?
כי process handle או process group לבדם אין להם מנגנון שמאסף את עץ התהליכים כשה-parent קורס. אם רוצים לקשור את חיי ה-parent לחיי עץ ה-child, נקודת הייחוס היא Job Object. עם JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, כל התהליכים ששייכים ל-Job מסתיימים כשנסגר ה-job handle האחרון, ואפשר להעביר את ה-cleanup לחיי ה-parent, כולל סיום חריג.
למה WaitForExit לא חוזר?
כנראה שה-pipe של stdout או stderr נתקע. pipe ב-Windows הוא לא buffer אינסופי. אם ה-child כותב הרבה ל-stderr וה-parent קורא רק stdout, ה-child נתקע ב-write וה-parent נתקע בהמתנה לסיום. הבסיס הוא drain במקביל של stdout ו-stderr. מימוש שקורא צד אחד עד הסוף ורק אז את השני נוטה להיתקע. בנוסף, אם לא סוגרים את הקצה הלא בשימוש של ה-pipe, EOF לא מגיע ותנאי הסיום מתפרק.
לא מספיק Kill(entireProcessTree: true) של .NET?
לא מספיק. זה נוח כ-API לעצירה מפורשת, אבל הוא לא תחליף לתכנון שכולל איסוף אוטומטי בקריסת parent ו-graceful shutdown. מה שפחות נשבר הוא שלושה שלבים: לבקש graceful shutdown, לחכות עם timeout קצר, ובסוף לסיים בכוח את כל ה-Job. אמצעי ה-graceful shutdown מתחלק לפי סוג ה-child: 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, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

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

חזרה לבלוג