היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 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 כתכנון אחד.
flowchart TB
accTitle: התקלה מחוץ להפעלה
accDescr: תקלות child process הן לא האם ההפעלה הצליחה, אלא parent שקרס ו-child שנשאר או stdout שנתקע; מה שעוזר הוא לא בחירת API להפעלה אלא קביעת בעלות על עץ התהליכים ותכנון סיום ו-I/O.
a1["בוחרים API להפעלה"] -.-> a2["זה לא גוף התקלה"]
a3["קובעים בעלות על עץ התהליכים"] --> a6["ניהול בטוח של child process"]
a4["מתכננים תהליך סיום"] --> a6
a5["מתכננים I/O"] --> a6
איור 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, כדי שצד הכתיבה לא ייתקע |
התמונה הכללית
קודם תרשים אחד של היחסים בין המשתתפים.
flowchart TB
W["watchdog<br/>מחוץ ל-Job"]
subgraph JOB["Job Object עם JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"]
P["אפליקציית parent / גוף ה-worker<br/>הבעלים האחרון של job handle"]
C["child: helper.exe"]
G1["נכד: converter.exe"]
G2["נכד: ffmpeg.exe"]
P --> C
C --> G1
C --> G2
end
W -.->|"מזהה סיום דרך exit handle"| P
W -.->|"מזהה hang דרך heartbeat"| P
W -.->|"בונה מחדש בטווח restart budget"| JOB
איור 2: התמונה הכללית. גבול ה-Job הוא גבול עץ התהליכים, ורק ה-watchdog בחוץ.
שני דברים לשים לב אליהם.
- גבול ה-Job הוא גבול עץ התהליכים. האיגוד הוא לפי השתייכות ל-Job, לא לפי חיי ה-parent, ולכן גם אם נוספים נכדים אין דליפה באיסוף
- רק ה-watchdog מחוץ ל-Job. אם מכניסים אותו פנימה, הוא מסתיים יחד עם מטרת הניטור
1. קודם המסקנה
קודם רק מה שעובד הכי חזק בפועל.
- אם רוצים לקשור את חיי ה-parent לחיי עץ ה-child, נקודת הייחוס היא Job Object
- בקשת סיום ל-console ו-איסוף עץ התהליכים הם שני דברים
- הראשון: process group ו-
GenerateConsoleCtrlEvent - השני: Job Object
- הראשון: process group ו-
- אם רוצים להיכנס ל-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 אחד.
לפחות ארבע אלה כדאי להפריד כדי לראות בבירור.
- מי הבעלים של עץ התהליכים
- איך מבקשים graceful shutdown
- איך מזרים את ה-stdio
- איך מנטרים סיום חריג ו-hang
flowchart TB
accTitle: ארבע שאלות שמפרידים
accDescr: ניהול child process הוא לא שיח על API אחד; מפרידים בין מי הבעלים של עץ התהליכים, איך מבקשים graceful shutdown, איך מזרים stdio, ואיך מנטרים סיום חריג ו-hang.
b1["1. בעלות על עץ התהליכים"] --> b2["2. איך מבקשים graceful shutdown"]
b2 --> b3["3. איך מזרים stdio"]
b3 --> b4["4. ניטור סיום חריג ו-hang"]
b1 -.-> b5["זה לא שיח על 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 הוא פעולות ברמת תהליך אחד.
flowchart TB
accTitle: טווח .NET וגבול ה-Job
accDescr: בספרייה הסטנדרטית של .NET יש הפעלה והמתנה לסיום ברמת תהליך אחד; סביב Job Object אין wrapper, ולכן קוראים ל-Win32 API ב-P/Invoke.
c1["פעולות ברמת תהליך אחד"] --> c2["Process הסטנדרטי של .NET מספיק"]
c3["פעולות סביב Job Object"] --> c4["אין wrapper"]
c4 --> c5["קוראים ל-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 מסתיימים.
flowchart TB
accTitle: איגוד ב-Job מבטל דליפות באיסוף
accDescr: Job Object אוגד עץ תהליכים לפי לאיזה Job שייך ולא לפי של מי ה-child; child שנוצר מתהליך ב-Job נכנס כברירת מחדל לאותו Job, ועם KILL_ON_JOB_CLOSE כל התהליכים מסתיימים כשנסגר ה-handle האחרון.
d1["לעקוב לפי של מי ה-child"] -.-> d2["כשנוספים נכדים זה דולף"]
d3["לאגד לפי לאיזה Job שייך"] --> d4["גם child של child נכנס לאותו Job"]
d4 --> d5["KILL_ON_JOB_CLOSE"]
d5 --> d6["ה-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 צריך להיות החלטה מוקדמת.
flowchart TB
accTitle: לא משאירים עמום מי הבעלים של job handle
accDescr: KILL_ON_JOB_CLOSE נכנס לפעולה כשנסגר ה-handle האחרון; אם משכפלים job handle או נותנים לו לעבור בירושה, ה-parent יכול למות בלי cleanup, ולכן קובעים קודם מי הבעלים האחרון.
e1["משכפלים או מורישים job handle"] --> e2["ה-handle האחרון לא נסגר"]
e2 --> e3["ה-parent מת, cleanup לא קורה"]
e3 -.->|"לכן"| e4["קובעים קודם מי הבעלים האחרון"]
איור 6: KILL_ON_JOB_CLOSE עובד רק כשנסגר ה-handle האחרון.
4.2 Job Object שימושי גם ל-observability, אבל ההודעות לא חסינות
אפשר לקשור I/O completion port ל-Job Object ולקבל הודעות. אבל בטוח יותר לא לראות בהודעות completion port הודעה שמכוסה בכל המקרים.
לכן completion port נוח ל-
- ניטור
- צבירה
- לוג
- metrics
אבל לא בונים עליו לבד correctness.
flowchart TB
accTitle: איפה משתמשים בהודעות completion port
accDescr: אפשר לקשור I/O completion port ל-Job Object ולקבל הודעות, אבל לא רואים בזה הודעה שמכוסה בכל מקרה; משתמשים לניטור, צבירה, לוג ו-metrics, ולא בונים עליו לבד correctness.
f1["הודעות completion port של ה-Job"] --> f2["נוח לניטור, צבירה ולוג"]
f1 -.-> f3["לא רואים בזה הודעה שמכוסה לגמרי"]
f3 -.-> f4["לא בונים עליו לבד 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 מחפש בסדר הזה.
- התיקייה שממנה נטענה האפליקציה
- תיקיית העבודה הנוכחית של ה-parent
- תיקיית המערכת 32bit
- תיקיית המערכת 16bit
- תיקיית Windows
- תיקיות ב-
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, כמעט אין סיבה לכתוב את קובץ ההרצה בשם יחסי.
flowchart TB
accTitle: החיפוש בהפעלה בשם יחסי מביא לתקלה
accDescr: אם מפעילים עם NULL ב-lpApplicationName ועם שם קובץ בלבד, יעדי החיפוש כוללים את תיקיית העבודה הנוכחית של ה-parent ואת PATH; כש-helper.exe האמיתי חסר, קובץ הרצה באותו שם במקום שניתן לכתיבה רץ בהרשאות ה-parent. מונעים בנתיב מוחלט ובמרכאות.
g1["מפעילים לפי שם קובץ בלבד"] --> g2["החיפוש כולל תיקייה נוכחית ו-PATH"]
g2 --> g3["כש-helper.exe לא במקום האמיתי"]
g3 --> g4["EXE באותו שם רץ בהרשאות ה-parent"]
g4 -.->|"כדי למנוע"| g5["מציינים נתיב מוחלט ועוטפים במרכאות"]
איור 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.
flowchart TB
accTitle: הרווח בין הפעלה ל-Assign
accDescr: אם מכניסים ל-Job ב-AssignProcessToJobObject אחרי ההפעלה, נכד שנולד ברווח הזה נולד מחוץ ל-Job; מול helper שיוצר נכדים שווה לסגור את הרווח עם PROC_THREAD_ATTRIBUTE_JOB_LIST כבר ביצירה.
h1["מפעילים ואחר כך מכניסים ל-Job"] --> h2["יש רווח בין הפעלה ל-Assign"]
h2 --> h3["נכד שנולד ברווח הזה מחוץ ל-Job"]
h3 -.->|"כדי לסגור את הרווח"| h4["מציינים Job כבר ביצירה ומפעילים"]
איור 9: בשיטה שמכניסה ל-Job אחר כך יש רווח שנכדים יכולים לברוח דרכו.
5. מתכננים הפצת סיום כ-protocol ו-timeout
סיום של child process לא נגמר ב-kill API אחד. מה שפחות נשבר הוא שלושה שלבים.
- מבקשים graceful shutdown
- מחכים עם timeout קצר
- בסוף מסיימים בכוח את כל ה-Job
הסדר הזה שומר על מסלול סיום תקין, וגם אוסף במקרה hang.
flowchart TB
accTitle: תהליך סיום בשלושה שלבים
accDescr: סיום child process הוא לא kill API אחד; מבקשים graceful shutdown, מחכים עם timeout קצר, ובסוף מסיימים בכוח את כל ה-Job, וכך שומרים מסלול תקין ואוספים גם ב-hang.
i1["1. מבקשים graceful shutdown"] --> i2["2. מחכים עם timeout קצר"]
i2 --> i3["3. בסוף מסיימים בכוח את ה-Job"]
i2 -.-> i4["מסלול תקין נשמר, 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. ההפרדה הזו פחות נשברת.
flowchart TB
accTitle: מפרידים graceful shutdown לפי סוג ה-child
accDescr: ל-GUI שולחים close message כמו CloseMainWindow, ל-console משתמשים ב-CREATE_NEW_PROCESS_GROUP ו-CTRL_BREAK_EVENT, ל-worker ב-protocol סיום דרך stdin או pipe; איסוף העץ נשאר אצל Job Object.
j0["בקשת graceful shutdown"] --> j1["GUI child: close message"]
j0 --> j2["console child: CTRL_BREAK_EVENT"]
j0 --> j3["worker: protocol סיום ב-stdin או pipe"]
j3 -.-> j4["איסוף העץ אצל 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 נעצר בהמתנה לסיום. זה קורה באופן רגיל.
בתרשים זה נראה כך.
sequenceDiagram
participant P as תהליך parent
participant SO as pipe של stdout
participant SE as pipe של stderr
participant C as תהליך child
P->>SO: ממשיך לקרוא רק stdout
C->>SO: כותב קצת
SO-->>P: נקרא
C->>SE: כותב הרבה אזהרות
Note over SE: buffer של ה-pipe מתמלא
C->>SE: מנסה לכתוב עוד
Note over C: write לא חוזר. ה-child נעצר כאן
P->>SO: מנסה לקרוא המשך
Note over P: ה-child עצור, לא מגיע כלום
Note over P,C: ה-parent מחכה לקריאה, ה-child לכתיבה. גם WaitForExit לא חוזר
איור 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) זורק (לא הצלחנו לעצור חלק מהצאצאים) לא נבלע. זה עצם המצב שהעץ לא נוקה, וזה מה שהפרק מנסה למנוע.
flowchart TB
accTitle: מרוץ על גבול ה-timeout
accDescr: בין הרגע ש-WaitForExit מחזיר false לבין Kill, ה-child יכול לסיים בעצמו; אם לא בודקים, כשל cleanup מכסה את TimeoutException המקורי. בודקים ב-HasExited אם באמת הסתיים, בולעים רק אז, ואם עדיין חי זורקים שוב.
k1["מיד אחרי שההמתנה נגמרה ה-child מסיים בעצמו"] --> k2["Kill נכשל"]
k2 --> k3{"בודקים ב-HasExited"}
k3 -->|"הסתיים"| k4["זו לא כשל, בולעים"]
k3 -->|"עדיין חי"| k5["לא הצלחנו לעצור, זורקים שוב"]
k4 --> k6["זורקים את TimeoutException המקורי"]
איור 13: במרוץ על הגבול, HasExited מבדיל אם כשל Kill הוא כשל אמיתי.
ה-WaitForExit() האחרון הוא לא שכחה, הוא חובה.
בתיעוד של WaitForExit(int) כתוב שאם מפנים פלט סטנדרטי ל-handler אסינכרוני, ברגע שה-overload הזה חוזר עיבוד הפלט אולי עוד לא נגמר, ומנחים לקרוא ל-WaitForExit() בלי ארגומנטים אחרי true. בלי זה מקבלים חוסר רק בסוף הפלט, בצורה שקשה לשחזר.
6.2 אם משתמשים ב-stdin, מתכננים עד EOF
היכולת לכתוב ל-stdin והיכולת של ה-child לסיים הם לא אותו דבר.
- כותבים קלט ולא עושים close
- ה-parent חושב “כבר מסרתי”
- ה-child חושב “עוד יבוא המשך” וממשיך לחכות
זה המצב שנוצר. אם משתמשים ב-stdin, צריך לתכנן עד close אחרי הכתיבה כדי להעביר EOF.
flowchart TB
accTitle: stdin מתוכנן עד EOF
accDescr: אם כותבים ל-stdin ולא עושים close, ה-parent חושב שכבר מסר וה-child מחכה להמשך; מתכננים עד close אחרי הכתיבה כדי להעביר EOF.
m1["כותבים קלט בלי close"] --> m2["ה-parent חושב שכבר מסר"]
m1 --> m3["ה-child מחכה להמשך"]
m2 --> m4["אחרי הכתיבה close, ומעבירים EOF"]
m3 --> m4
איור 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 נופל ורוצים להפעיל מחדש, אין טעם שהגורם שמפעיל מחדש ימות איתו.
flowchart TB
accTitle: watchdog מחוץ למטרת הניטור
accDescr: אם מכניסים watchdog לאותו Job כמו מטרת הניטור, כשמנקים worker שנפל גם הגורם שמפעיל מחדש מת; לכן watchdog מחוץ ל-Job של מטרת הניטור.
n1["watchdog באותו Job"] --> n2["כשמנקים, גם הגורם שמפעיל מחדש מת"]
n2 -.->|"לכן"| n3["watchdog מחוץ ל-Job"]
n3 --> n4["אפשר להפעיל מחדש גם אחרי שה-worker נפל"]
איור 15: תנאי ראשון למיקום watchdog: הגורם שמפעיל מחדש לא יושב באותו גורל.
7.1 ניטור exit מבוסס wait handle
כשתהליך מסתיים הוא עובר למצב signaled.
לכן ניטור exit לא אמור להיות לולאת polling שבודקת HasExited כל 100ms.
ב-Win32 הגישה הישירה היא
WaitForSingleObjectWaitForMultipleObjectsRegisterWaitForSingleObjectSetThreadpoolWait
מול כמה children, wait handle טבעי יותר מ-timer polling.
7.2 לא מחכים אינסוף ב-UI thread
WaitForSingleObject(INFINITE) נוח, אבל ב-thread שמחזיק window הוא נוטה לעצור את ה-message pump.
ב-UI thread, ב-COM apartment thread, וב-thread עם message pump, בטוח יותר לחשוב קודם איפה שמים את ההמתנה.
flowchart TB
accTitle: ניטור exit מבוסס wait handle
accDescr: תהליך עובר ל-signaled בסיום, ולכן ניטור exit הוא wait handle ולא polling של HasExited; המתנה אינסופית ב-UI thread עוצרת message pump ולכן נמנעים ממנה.
p1["polling מחזורי של HasExited"] -.-> p2["בעצם לא נחוץ"]
p3["מחכים ל-handle שעובר ל-signaled בסיום"] --> p4["ניטור מבוסס wait handle"]
p4 -.-> p5["המתנה אינסופית ב-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
flowchart TB
accTitle: לזיהוי hang צריך heartbeat
accDescr: CPU 100%, deadlock או חוסר התקדמות אי אפשר לשפוט לפי האם התהליך חי; אם רוצים לראות hang, צריך בדיקת חיות ברמת האפליקציה כמו heartbeat או התקדמות.
q1["התהליך חי"] --> q2["אבל אולי הוא לא מתקדם"]
q2 --> q3["ניטור exit לבד לא מספיק לשפוט"]
q3 -.->|"לכן"| q4["בדיקה ברמת האפליקציה: 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.
flowchart TB
accTitle: restart budget עוצר crash loop
accDescr: כדי להימנע מ-crash loop של הפעלה מחדש מיד ונפילה מיד, מחזיקים restart budget: backoff, תקרה למספר הפעלות מחדש בפרק זמן, ואחרי כשלונות רצופים עוצרים ומודיעים.
r1["הפעלה מחדש מיד, נפילה מיד"] --> r2["crash loop והצפת לוגים"]
r2 -.->|"כדי למנוע"| r3["מוסיפים backoff"]
r3 --> r4["תקרה למספר פעמים בפרק זמן"]
r4 --> r5["אחרי כשלונות רצופים עוצרים ומודיעים"]
איור 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.
flowchart TB
accTitle: מנגנון בקשה ומנגנון ניקוי
accDescr: בכל דפוס טיפוסי מה שחשוב ביותר הוא להחזיק בנפרד מנגנון graceful shutdown כמו close message או protocol סיום, ומנגנון cleanup של Job Object.
s1["מנגנון graceful shutdown"] --> s3["מחזיקים את שניהם בנפרד"]
s2["מנגנון cleanup (Job)"] --> s3
s3 -.-> s4["במצב תקין הראשון עובד, בחריג השני"]
איור 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 באמת נשאב עד הסוף.
flowchart TB
accTitle: ארבעה דברים לקבע קודם
accDescr: מה שמשפיע ביותר על שיעור תקלות child process הוא לקבע קודם מי הבעלים של process tree, איך מעבירים בקשת סיום, איך מזרים stdio עד הסוף, ואיפה שמים watchdog.
t1["בעלות על העץ"] --> t5["קובעים קודם"]
t2["איך מעבירים סיום"] --> t5
t3["טיפול ב-stdio"] --> t5
t4["איפה שמים ניטור"] --> t5
t5 --> t6["API ההפעלה הוא רק נקודת כניסה"]
איור 20: מה שמשפיע על שיעור התקלות הוא לקבע את ארבע אלה לפני ההפעלה.
11. מקורות
- Microsoft Learn, Job Objects
- Microsoft Learn, JOBOBJECT_BASIC_LIMIT_INFORMATION
- Microsoft Learn, UpdateProcThreadAttribute
- Microsoft Learn, InitializeProcThreadAttributeList
- Microsoft Learn, Inheritance (Processes and Threads)
- Microsoft Learn, CreateProcessW
- Microsoft Learn, Creating a Child Process with Redirected Input and Output
- Microsoft Learn, Pipe Handle Inheritance
- Microsoft Learn, Process.Kill
- Microsoft Learn, Process.CloseMainWindow
- Microsoft Learn, GenerateConsoleCtrlEvent
- Microsoft Learn, WaitForSingleObject
- Microsoft Learn, RegisterWaitForSingleObject
- Microsoft Learn, GetExitCodeProcess
- Microsoft Learn, JOBOBJECT_ASSOCIATE_COMPLETION_PORT
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
למה arguments נשברים — כללי command-line arguments ב-Windows
Windows מעביר ל-CreateProcess מחרוזת אחת שהמקבל מפצל. מכסה את כללי CommandLineToArgvW, CRT ו-.NET, ArgumentList, ובניה ב-C++.
Named Pipes בפועל — ה-IPC הסטנדרטי של Windows, מתכנון עד אבטחה
מדריך מעשי ל-Named Pipes, ה-IPC הסטנדרטי ב-Windows. המאמר מסדר לפי מקורות ראשוניים את הבחירה בין byte mode ל-message mode, תכנון server ל...
Time Travel Debugging — להקליט ולהריץ אחורה באגים שלא משתחררים באפליקציות ארוכות-ריצה
באג פעם בחודש משאיר ב-crash dump רק את התוצאה. מקליטים ומריצים אחורה את הביצוע עם WinDbg Time Travel Debugging (TTD): TTD.exe, ring buffe...
מה נשאר אחרי שה-parent מת — מחזיקים child processes ב-Job Object
למה SDK helpers שורדים UI שנהרג ומחזיקים את המצלמה או את ה-COM port? מתכננים משך חיים של child process עם Job Objects, KillOnJobClose ו-c...
Spurious wakeup — למה condition variable מתעורר בלי notify, ואיך לחכות נכון ב-Windows
Wait של condition variable יכול להתעורר בלי notify (spurious wakeup). למה Windows מתיר זאת, וצורת ה-wait הנכונה עם while ו-predicate ב-Wi...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
פיתוח יישומי Windows
באפליקציית Windows שמריצה CLI חיצוני, כלי המרה, worker ו-updater, ניהול עץ התהליכים ותכנון הסיום משפיעים על היציבות יותר מאופן ההפעלה.
חקירת תקלות ואיתור גורמים
תקלות תפעול שקשה לשחזר, כמו parent שקרס ו-child שנשאר, stdout שנתקע, או watchdog שנופל יחד עם המערכת, משתפרות כשבודקים מחדש את תכנון ניהול התהליכים.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- למה 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.