מה נשאר אחרי שה-parent מת — מחזיקים child processes ב-Job Object

· עודכן בתאריך: · · Windows, פיתוח Windows, C#, C++, Win32 API, מדידה ובקרה

היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 29 Aug 2026)
פרסום ראשון

סיימתם את ה-UI ב-Task Manager, ובכל זאת אי אפשר לפתוח מחדש את המצלמה. אפליקציית הניטור נעלמה, ובכל זאת ה-helper של ה-SDK עדיין מחזיק את ה-COM port. הפעלה מחדש של ה-parent מייצרת instance שני, ובעיות מופיעות בזיכרון המשותף וב-named pipes. ברגע שמבודדים SDK של מכשיר ב-process נפרד, נתקלים בכשלי “אחרי שה-parent מת” האלה.

נקודת ההתחלה לחשיבה על הסיבה היא שב-Windows, child ו-grandchild processes לא מסתיימים אוטומטית כש-parent process יוצא. יחס parent-child בהפעלה וניהול משך החיים ביציאה הם שני דברים שונים. הכלי שסוגר את הפער הזה הוא ה-Job Object.

המאמר הזה קודם מאשר למה טיפול היציאה של ה-parent לבדו אינו מספיק, ואז מסדר איך מכניסים processes ל-Job, מדיניות הסיום, ושיטת הניטור. לבסוף הוא מחבר אותם לכשלים שקורים באינטגרציית מכשירים ולהליך החקירה.

קהל היעד הוא מפתחי WinForms / WPF / שירותים שמבודדים SDK של מכשיר ב-process נפרד. סביבת הבסיס היא Windows 10/11 (לחלקים שמשתמשים ב-nested jobs וב-PROC_THREAD_ATTRIBUTE_JOB_LIST), והקוד מוצג ב-C++ (Win32 API) וב-C# (.NET 6 ואילך). רמת הקושי היא בינונית.

המאמר הזה מרחיב את מאמרי “Not Responding”, כיבוי, שינה/חזרה ו-named pipes, ומטפל ב-משך החיים מחוץ ל-process.

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

נקודת ההתחלה של התכנון היא להחליט “מה אסור שישאר מאחור, ומה רוצים לשמור” לפני שמחליטים “איך מסיימים”.

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

  • היחידה שמנהלת משך חיים היא ה-Job, לא ייחוס parent-child. הוא מצמיד מגבלות, התראות וסיום גורף לקבוצת processes. ברגע ש-process משויך הוא לא יכול לעזוב עד שהוא יוצא, ומ-Windows 8 ואילך אפשר לקנן Jobs.12
  • סוגרים את חברות ה-Job לפני שה-child רץ. אם עושים Assign אחרי ההפעלה, מפספסים את הנכדים שנולדים באמצע. CREATE_SUSPENDED ו-JOB_LIST ביצירה סוגרים חלונות race שונים (פרק 4).
  • איסוף אוטומטי ושמירת מידע אבחון נבחרים כמדיניות סיום. תנאי ההפעלה של KillOnJobClose הוא “handle ה-Job האחרון נסגר”. זה חזק מול קריסת parent אבל חלש לניתוח post-mortem של הצאצאים שמסתיימים בכוח יחד איתו, כך שאם צריך את שניהם, צד הניטור אוסף קודם ומסיים אחר כך (פרק 5).3

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

מה רוצים לדעת פרקים לקרוא
למה טיפול היציאה של ה-parent לבדו אינו מספיק פרקים 2–3: יחס parent-child ותפקיד ה-Job
איך מנהלים צאצאים בלי לפספס פרקים 4–6: יצירה, מדיניות סיום, ניטור
מה הופך לבעיה עם SDKs ושירותים פרקים 7–9: מקרי כשל, מגבלות משאבים, קינון ו-breakaway
מה לחקור בשטח, ואיך לבחור פרקים 10–11: הליך חקירה וטבלת החלטה

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

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

2. למה WaitForExit אינו מספיק

“המתנה” ליציאה וקשירת משכי חיים הם דברים שונים

Process.WaitForExit() הוא API ל”ה-parent ממתין שה-child ייצא”. מה שהמאמר הזה שוקל הוא הכיוון ההפוך: מה לעשות עם ה-child כשה-parent מת קודם. עם יחס parent-child של Windows לבדו, יציאת ה-parent לא מועברת ל-child.

גם Process.Kill() ו-CloseMainWindow() לבדם לא מטפלים ב-grandchild processes או ב-device handles שאותם נכדים מחזיקים. ה-handles של ה-process שיצא משוחררים, אבל הבעיה היא ה-handles שמוחזקים על ידי הצאצאים ששרדו.

Kill(entireProcessTree: true) של .NET עובר על הצאצאים ומסיים אותם, אבל הוא יכול לפספס processes שנוצרו במהלך המנייה או אחרי שה-parent מת קודם, והוא אף פעם לא נקרא ברגע שה-parent עצמו קרס. צריך מנגנון שלא תלוי רק בקוד ה-cleanup של ה-parent.

לכל אחת מהסיבות העיקריות ש-parent יוצא, הנה מה שנשאר בשטח.

למה ה-parent מת מה קורה ל-child מה נשאר בשטח
UI נסגר בלחצן X, טיפול יציאה לא הושלם כלום (אובייקט ה-Process רק עובר dispose) helper process, נעילת מצלמה
רק ה-parent נהרג ב-Task Manager ה-child ממשיך לחיות COM port, USB, זיכרון משותף
קריסה מ-unhandled exception אין הבטחה ש-finally של ה-parent רץ קבצים זמניים, נעילות בלעדיות
timeout של עצירת שירות ה-SCM מטפל ב-parent בלבד ילדים שנשארים ב-session 0
הפער בין משך החיים של ה-parent לבין משך התפיסה של המכשירכש-parent process יוצא, ההמתנה של ה-parent ואובייקט ה-Process שלו נעלמים, אבל ה-child ו-grandchild processes ממשיכים לחיות, וגם האחיזה שלהם ב-device handles, named pipes וקבצי נעילה נשארתparent process יוצאנעלם: המתנת ה-parent, אובייקט Processנשאר: child ו-grandchild processesאחיזה ב-device handlesצד ה-server של named pipesקבצי נעילה, זיכרון משותף

איור 1: משך החיים של ה-parent ומשך התפיסה של המכשיר לא מסונכרנים. קוד ה-cleanup של ה-parent הכי פחות סביר שירוץ בדיוק כשה-parent מת מוות חריג.

היעד הוא המקרה שבו “מערכת ההפעלה רצה ורק ה-parent יצא”

הזרימות שבהן כיבוי או שינה עוצרים את כל מערכת ההפעלה שייכות ל-מאמר הכיבוי ול-מאמר שינה/חזרה. המאמר הזה מטפל ב-המקרה שבו מערכת ההפעלה נשארת בריאה ורק ה-parent יוצא. בסביבות מדידה זה המקרה התכוף יותר, ואת ה-processes שנשארים קשה יותר להבחין בהם.

3. מה זה Job Object

הפעולות הבסיסיות הן Create, Assign, Set ו-Query

Job Object הוא kernel object שמנהל קבוצת processes כיחידה אחת. חלוקת הפעולות הבסיסיות לפי תפקיד נותנת את ארבע אלה.14

API תפקיד
CreateJobObject ליצור Job שעדיין אף process לא שייך אליו
AssignProcessToJobObject לשייך process ל-Job
SetInformationJobObject להגדיר מגבלות והגדרות אחרות
QueryInformationJobObject לקרוא נתוני accounting כמו זמן CPU, page faults ומספר processes

החברות בלתי הפיכה; process לא יכול לעזוב עד שהוא יוצא. גם, נתוני ה-accounting כוללים סכומים שהצטברו מ-processes שכבר יצאו.

child ש-process חבר יוצר עם CreateProcess שייך לאותו Job כברירת מחדל. כלומר, ליבת הערך של Job היא שנכדים ונינים נכנסים אוטומטית.1 הנתיבים שבורחים מחברות, כמו breakaway והפעלות proxy דרך WMI, נבדקים בנפרד בפרק 9.

מבנה בסיסי של Job Objectchild ו-grandchild שייכים ל-Job Object שה-parent process יצר, וה-Job אוכף מגבלות, שולח התראות ל-completion port, ומבצע סיום גורף לפי process treeparent processJob Objectchild (מארח SDK של מכשיר)grandchild (helper של הספק)מגבלות (זיכרון, CPU)התראות (completion port)סיום גורף

איור 2: Job הוא מיכל שמספק שלושה דברים לפי process tree: מגבלות, התראות וסיום גורף.

הבדלי דור של מערכת ההפעלה, ואיך זה שונה מ-sandbox

ב-Windows 7 ומוקדם יותר process יכול היה להשתייך ל-job אחד בלבד; מ-Windows 8 ואילך קינון (חברות מרובה) הפך לאפשרי.5 גוף המאמר מניח Windows 10/11; הנקודות שצריך לשים לב אליהן ב-Windows 7 ומוקדם יותר מכוסות בפרק 9 וב-FAQ.

הכנסת process ל-Job לא הופכת אותו ל-container או ל-sandbox. אי אפשר להגביל גישת רשת, וה-access token (הרשאות) הוא מנגנון נפרד. גם הגבלות UI לבדן לא יכולות ליצור גבול אבטחה. התפקיד במאמר הזה הוא אך ורק להפוך את משך החיים ואת המשאבים של process tree ליחידה אחת.

4. הדרך הנכונה פנימה — ה-race בין יצירה ל-assignment

אם עושים Assign אחרי ההפעלה, נכדים נולדים באמצע

child process יכול להוליד נכדים באלפיות השנייה הראשונות אחרי שהוא מתחיל לרוץ. SDK שמפעיל את ה-helper שלו הוא המקרה הטיפוסי. אם מקבלים את ה-PID אחרי Process.Start() ואז עושים Assign, כל grandchild שנולד לפני ה-Assign נגמר מחוץ ל-Job.

assignment אחרי שה-child מתחיל לרוץ מפספס נכדיםה-child כבר רץ מיד אחרי Process.Start, וכל grandchild שהוא מוליד בחלון ה-race לפני שקוראים ל-AssignProcessToJobObject נגמר מחוץ ל-Jobה-child מתחיל לרוץ ב-Process.Startחלון race עד Assignנכדים שנולדים בחלון הזהממשיכים לרוץ מחוץ ל-JobAssignProcessToJobObjectרק נכדים שנולדים אחר כך נכנסים

איור 3: חלון ה-race עשוי להיות רק כמה אלפיות שנייה, אבל הפעלת ה-helper של ה-SDK קורה בדיוק שם.

הליך A: יוצרים מושהים, משייכים, ואז מריצים

השיטה הקלאסית עם התאימות הרחבה ביותר משתמשת ב-CREATE_SUSPENDED. היא סוגרת את החלון שבו ה-child רץ קודם ומוליד נכדים בסדר הבא.67

  1. יוצרים את ה-Job עם CreateJobObject
  2. מגדירים את המגבלות קודם עם SetInformationJobObject
  3. קוראים ל-CreateProcess עם CREATE_SUSPENDED (ה-thread הראשוני לא רץ)
  4. מכניסים עם AssignProcessToJobObject
  5. אם זה נכשל, לא עושים Resume; קוראים ל-TerminateProcess במקום (לא נותנים להוראה אחת לרוץ מחוץ ל-Job)
  6. מריצים עם ResumeThread

מה שהליך A סוגר הוא חלון ה-race מול יצירת נכדים. החלון מול קריסה של ה-parent עצמו נשאר. אם ה-parent קורס בין שלבים 3 ו-4, child מושהה שעדיין לא ב-Job נשאר מאחור. הוא לא רץ, אבל הוא גם לא נעלם לבד.

אם רוצים לסגור את החלון הזה כולל עמידות לקריסת parent, משתמשים בהליך B למטה.

הליך להפעלה עם SUSPENDED ואז הכנסת ה-child ל-Jobיוצרים את ה-Job ומגדירים מגבלות, מפעילים את ה-child עם CREATE_SUSPENDED, משייכים עם AssignProcessToJobObject, עוצרים עם TerminateProcess בלי Resume אם זה נכשל, ומריצים עם ResumeThread אם זה מצליחהצלחהכישלוןיוצרים את ה-Job עם CreateJobObjectמגבלות עם SetInformationJobObjectמפעילים child עם CREATE_SUSPENDEDAssignProcessToJobObjectמריצים עם ResumeThreadמסיימים מיד, בלי Resume

איור 4: השלד של הליך A. מימוש שמשמיט את הענף “אם נכשל, לא מריצים” מייצר process תועה רק כשדברים משתבשים.

// C++: הליבה המינימלית של הליך A (טיפול בשגיאות הוא שלד בלבד)
HANDLE job = CreateJobObjectW(nullptr, nullptr);   // unnamed בסדר. לא עושים inheritable
if (!job) return HRESULT_FROM_WIN32(GetLastError());

JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = {};
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation,
                             &limits, sizeof(limits))) {
    DWORD err = GetLastError();         // שומרים לפני ש-CloseHandle דורס
    CloseHandle(job);                   // לא מריצים את ה-child ב-Job בלי מגבלות
    return HRESULT_FROM_WIN32(err);
}

STARTUPINFOW si = { sizeof(si) };
PROCESS_INFORMATION pi = {};
if (!CreateProcessW(exePath, cmdline, nullptr, nullptr, FALSE,
                    CREATE_SUSPENDED, nullptr, nullptr, &si, &pi)) {
    DWORD err = GetLastError();
    CloseHandle(job);                   // לא מדליפים את handle ה-Job בניסיונות הפעלה חוזרים
    return HRESULT_FROM_WIN32(err);
}

if (!AssignProcessToJobObject(job, pi.hProcess)) {
    DWORD err = GetLastError();         // שומרים לפני ש-Terminate דורס
    TerminateProcess(pi.hProcess, 1);   // לא נותנים לו לרוץ מחוץ ל-Job
    // סוגרים את ה-handles ומעלים את err כשגיאה
} else if (ResumeThread(pi.hThread) == (DWORD)-1) {
    DWORD err = GetLastError();
    TerminateProcess(pi.hProcess, 1);   // לא משאירים אותו מושהה
    // סוגרים את ה-handles ומעלים את err כשגיאה
}
CloseHandle(pi.hThread);
// בהצלחה, הבעלות על job ו-pi.hProcess עוברת לאובייקט ניהול משך החיים של הקורא
// (המקבילה של ה-wrapper ב-C# בפרק 4). סגירת job יורה KillOnJobClose, ו-pi.hProcess
// משמש בפרק 6 כדי לקבוע "מי מת"

הליך B: ב-Windows 10 ואילך, יוצרים את ה-process כבר בתוך ה-Job

שמים את handle ה-Job על רשימת המאפיינים של STARTUPINFOEX עם PROC_THREAD_ATTRIBUTE_JOB_LIST. ואז מעבירים אותו ל-CreateProcess עם הדגל EXTENDED_STARTUPINFO_PRESENT. בלי הדגל, המבנה לא מפורש כמורחב, והמאפיינים מתעלמים.89

בשיטה הזו, ה-process שייך ל-Job לפני שה-thread הראשוני שלו רץ. חלון ה-race של “נוצר, אבל עדיין לא חבר” כבר לא קיים בכלל, כך שלא נחוצים לא SUSPENDED ולא הענף שמשייך אחרי יצירה ומטפל בכישלון.

שיטה מתי החברות נסגרת הסתייגויות שנשארות
הליך A: SUSPENDED → Assign → Resume אחרי שה-child נוצר, לפני שה-thread הראשוני שלו רץ אם ה-parent קורס לפני ה-Assign, child עצור נשאר מאחור
הליך B: יצירה עם JOB_LIST ביצירת ה-process דורש Windows 10 ואילך. מציינים את רשימת המאפיינים ואת דגל ה-startup המורחב
איך חלונות ה-race של הליך A והליך B נבדליםהליך A יוצר את ה-process מושהה ואז עושה Assign ו-Resume, כך שהוא צריך ענף שמסיים בכישלון, ואילו הליך B יוצר את ה-process עם ה-Job על רשימת המאפיינים, כך שהוא כבר חבר ברגע שהוא נולד ואין לו לא חלון race ולא ענף כישלוןהליך A: יוצרים מושהיםמצטרפים עם Assignמתחילים עם Resumeנחוץ ענף כישלוןהליך B: יוצרים עם רשימת מאפייניםחבר ברגע שהוא נולדאין חלון race, אין ענף כישלון

איור 5: הליך A הוא “מכניסים, ואז מריצים”; הליך B הוא “נולד כבר בפנים”. אם אפשר להניח Windows 10 ואילך, הסיבה לבחור בו היא בדיוק נוכחות או היעדר חלון ה-race וענף הכישלון.

שלושה מימושים להימנע מהם בשני ההליכים

  • קבלת ה-PID אחרי Process.Start() ואז הכנסה (הנכדים יוצאים קודם)
  • מתן הורשה של handle ה-Job ל-child (ה-child ממשיך להחזיק את ה-handle גם אחרי שה-parent מת, כך ש-KillOnJobClose כבר לא נורה — פרק 5)
  • בליעת כישלון Assign והמשך תפעול (process מכשיר מחוץ ל-Job הוא מה שמניע את הכשל הבא)

ב-.NET, מבטאים את בעל handle ה-Job בקוד

ל-System.Diagnostics.Process אין מושג של Job, ואין wrapper רשמי. כותבים wrapper דק עם P/Invoke או CsWin32.

הנקודה היא לעטוף את handle ה-Job ב-SafeHandle ולהפוך אותו ל-IDisposable. סגירת handle ה-Job האחרון ב-Dispose() יורה KillOnJobClose. כוונת התכנון ש”משך החיים של ה-wrapper הוא משך החיים של עץ ה-child” יכולה להיות מבוטאת כבעלות.

למטה שלד שמראה בעלות על handle. היצירה נעשית בצד ה-P/Invoke של הליך A/B, ופרויקט אמיתי גם צריך תצורת CsWin32, סימוני unsafe, וכן הלאה.

// C#: wrapper דק שאחראי רק לבעלות על handle ה-Job (היצירה עוברת ב-P/Invoke של הליך A/B)
sealed class ChildProcessJob : IDisposable
{
    private readonly SafeFileHandle _job;    // ממשיכים להחזיק בשדה

    public ChildProcessJob()
    {
        _job = PInvoke.CreateJobObject(default, null);
        if (_job.IsInvalid) throw new Win32Exception();
        var limits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
        limits.BasicLimitInformation.LimitFlags =
            JOB_OBJECT_LIMIT.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
        if (!PInvoke.SetInformationJobObject(_job,
            JOBOBJECTINFOCLASS.JobObjectExtendedLimitInformation,
            &limits, (uint)sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)))
        {
            int err = Marshal.GetLastWin32Error();  // שומרים לפני Dispose
            _job.Dispose();              // לא מוציאים Job בלי KillOnJobClose
            throw new Win32Exception(err);
        }
    }

    public void Dispose() => _job.Dispose();  // העץ שמתחת מסתיים כאן
}

ולהפך, אם סוגרים את ה-handle בלי להתכוון לסגור אותו, מסיימים את עץ ה-child. שומרים הפניה ל-wrapper לכל משך החיים של ה-parent. אם לא, עץ ה-child נמחק בלי סיבה ברגע שה-GC אוסף את ה-SafeHandle.

5. KillOnJobClose — “מחזיקים” ו”מתים יחד”

ההדק הוא “סגירת ה-handle האחרון”, לא “מות ה-parent”

JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE הוא דגל מגבלה שמסיים כל process תחת ה-Job כש-handle ה-Job האחרון נסגר.3

בין אם ה-parent יוצא על exception, נהרג ב-Task Manager, או נעצר בכוח כשירות, הליבה סוגרת את כל ה-handles של אותו process. אם זה היה handle ה-Job האחרון, הילדים והנכדים מסתיימים. החוזק של המנגנון הזה הוא שהוא לא מניח שטיפול היציאה של ה-parent רץ.10

עם זאת, אם נותנים ל-child לרשת את handle ה-Job, ה-handle נשאר אחרי שה-parent יוצא. במקרה כזה זה לא “ה-handle האחרון”, ועץ ה-child לא מסתיים.

ציר הזמן של KillOnJobCloseבין אם ה-parent יוצא כרגיל, קורס, או מסתיים בכוח, הליבה סוגרת את כל ה-handles של ה-parent, ואם זה היה handle ה-Job האחרון ה-Job נסגר ועץ ה-processes שמתחת מסתיים בבת אחתכןלא (מורש)ה-parent נעלם (כולל קריסה)הליבה סוגרת את כל ה-handleshandle ה-Job האחרון?מסיימים את כל העץ שמתחתהילדים ממשיכים לחיות

איור 6: ההדק הוא “ה-handle האחרון נסגר”, לא “ה-parent מת”. לכן אסור שה-handle יורש על ידי ה-child.

בוחרים בין איסוף מיידי לבין שמירת חומר אבחון

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

ה-crash dump של ה-parent עצמו הוא עניין אחר. WER מטפל ב-unhandled exception כל עוד ה-parent עדיין חי, כך שאפשר לכתוב את ה-dump של ה-parent לפני שה-handles נסגרים. מה שעומד כאן הוא חומר ה-post-mortem של הילדים והנכדים בצד שמסתיים בכוח.

מדיניות מתאים ל מה מאבדים
עם KillOnJobClose אתרים שבהם פתיחה כפולה של המכשיר היא התוצאה הגרועה ביותר חומר post-mortem, הדגימות האחרונות
בלי (ניטור בלבד) אתרים שבהם dumps ולוגים הם נכס processes יתומים ופורטים מוחזקים אם מזניחים
בלי, ועוד process ניטור שקורא ל-TerminateJobObject כשיש שירות בקרה נפרד המימוש משוכפל

כקלט להחלטה, מציגים קודם “מה אסור שישאר מאחור” ו”מה רוצים לשמור”.

מה אסור שישאר מאחור: מצלמה או digitizer פתוחים, שימוש בלעדי בפורט serial או USB, צד ה-server של named pipe, סשן dongle רישיון, זיכרון משותף וקבצי נעילה.

מה רוצים לשמור: crash dumps, הפריים או המונים התקפים האחרונים, ההזדמנות לשלוח פקודה שמחזירה את המכשיר למצב בטוח (אם אפשר, שולחים אותה לפני ההריגה).

להרוג, או רק לנטר?אם פתיחה כפולה של המכשיר היא התוצאה הגרועה ביותר, מגדירים KillOnJobClose; אם crash dumps והפריים האחרון הם נכס, לא מגדירים ומנטרים במקום; אם יש שירות בקרה נפרד, קוראים ל-TerminateJobObject משםשחרור המכשיר קודםdumps ומצב סופי הם נכסיש שירות בקרה נפרדמה מגינים עליו ברגע שהוא מת?עם KillOnJobCloseניטור בלבד (לא הורגים)צד הניטור קורא ל-TerminateJobObject

איור 7: בוחרים לפי “מה מגינים עליו ברגע שהוא מת”, לא לפי “להרוג או לא”. אם רוצים את שניהם, מגיעים לסידור של השורה השלישית, שבו צד הניטור לוקח את ה-dump קודם ואז מפרק את העץ.

מעבירים את handle ה-Job לצד הניטור כל עוד ה-parent חי

השורה השלישית בטבלה, הסידור שבו process ניטור מסיים את העץ עם TerminateJobObject, צריכה הכנה. צד הניטור חייב לקבל את handle ה-Job לפני שה-parent מת.

ה-Job שנוצר בהליך של המאמר הזה הוא unnamed, כך שאין דרך להגיע אליו מבחוץ ברגע שה-parent נעלם. יש שתי דרכים להעביר אותו.

שיטה מה לעשות כל עוד ה-parent חי
משכפלים את handle ה-Job ה-unnamed מעבירים את ה-handle ל-process הניטור עם DuplicateHandle
משתמשים ב-Job named יוצרים אותו named מההתחלה; צד הניטור פותח אותו עם OpenJobObject ומחזיק אותו

כי שמות יכולים להתנגש גלובלית, כוללים GUID ייחודי או דומה. אם שוכחים את ההכנה הזו, לצד הניטור אין אמצעי לסיים את העץ גם כשהוא מזהה את החריגה של ה-parent.

לוקחים dumps והופכים את המכשיר לבטוח לפני סיום בכוח

child שמסתיים ב-KillOnJobClose לא מקבל אזהרה, בדיוק כמו עם TerminateProcess. לא קורה unhandled exception, כך שגם אם WER (LocalDumps) מוגדר על ה-child, לא נשאר dump של הסיום בכוח הזה. מה ש-WER יכול לתפוס הוא המקרה שבו ה-child יוצא בגלל קריסה משלו.

אם הדרישה היא “גם איסוף אוטומטי וגם dumps”, מעבירים את האחריות לסיום לצד הניטור. הסדר הוא: לוקחים את ה-dump כל עוד היעד חי, מחזירים את המכשיר למצב בטוח אם נחוץ, ולבסוף מסיימים עם TerminateJobObject.

פירוק כך שאיסוף אוטומטי ו-dumps מתקיימים יחדכש-process הניטור מזהה חריגה, הוא לוקח dump קודם, שולח פקודה שמחזירה את המכשיר למצב בטוח אם נחוץ, ולבסוף מפרק את העץ עם TerminateJobObject, כך שאיסוף אוטומטי וניתוח post-mortem מתקיימים יחדצד הניטור מזהה חריגהלוקחים את ה-dump קודםמחזירים את המכשיר למצב בטוחמפרקים עם TerminateJobObject

איור 8: התשובה היחידה ל”גם איסוף אוטומטי וגם dumps”. הופכים את הסדר והיעד ל-dump כבר לא קיים.

בסידור שבו KillOnJobClose מסיים בכוח את ה-child ברגע שה-parent נעלם, גם ל-child אין הזדמנות לשלוח פקודת “החזירו את המכשיר למצב בטוח”. למכשירים שצריכים את זה, בוחרים בסידור של השורה השלישית, ונותנים לצד הניטור לשלוח את פקודת ה-safing קודם ואז לקרוא ל-TerminateJobObject.11

6. ממתינים ל”ריק” עם completion port

המתנה על handle ה-Job לבדה לא יכולה לאשר שעץ יצא

handle ה-Job לא הופך ל-signaled כשכל ה-processes שמתחתיו יצאו. הוא מסומן רק כשכל ה-processes הסתיימו כי חרגו ממגבלת זמן ה-job.12

כדי “להמשיך ברגע שעץ ה-child ריק”, משייכים I/O completion port (IOCP) ל-Job.1314 עושים את השיוך כשה-Job ריק, לפני שמכניסים process כלשהו. אם משייכים באמצע, אפשר לפספס את ההתראות של processes שמצבם השתנה במהלך השיוך.15

צופים ביצירה, יציאה, חריגה ואפס עם ארבע הודעות

הודעה מה היא אומרת
JOB_OBJECT_MSG_NEW_PROCESS process הצטרף ל-Job. גם מזהה יצירת נכדים
JOB_OBJECT_MSG_EXIT_PROCESS process יצא
JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS process יצא עם קוד יציאה חריג כמו access violation. חשוב במיוחד באפליקציות מדידה13
JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO מספר ה-processes הפעילים הגיע ל-0

כל מה שחבילת NEW_PROCESS נושאת הוא ה-PID החדש. היא לא אומרת את יחס parent-child, כלומר מי הוליד את ה-process. אם צריך את הייחוס, מוסיפים אמצעי אחר כמו ETW.

זרימת התראות completion portה-Job שולח הודעות יצירת process, יציאה, יציאה חריגה ואפס ל-completion port, thread ניטור ייעודי מקבל אותן עם GetQueuedCompletionStatus, ורק התוצאות מועברות ל-UI threadUI threadthread ניטורcompletion portJob ObjectUI threadthread ניטורcompletion portJob ObjectNEW / EXIT_PROCESSABNORMAL_EXIT / ZEROממתינים לחבילת completionהודעה ו-PIDמעבירים רק את התראת התוצאה

איור 9: מריצים GetQueuedCompletionStatus על thread ייעודי. ממתינים על ה-UI thread, וה-UI הופך ל-“Not Responding” בכל פעם ש-child מתנהג רע.

ממתינים על thread ייעודי ועל port ייעודי, ושואלים את ה-accounting ב-timeout

מריצים את לולאת הניטור הזו על completion port שנוצר אך ורק להתראות ה-Job. אם רוכבים על אותו port כמו I/O קיים, החבילה כבר יצאה מהתור עד ש-GetQueuedCompletionStatus חוזר. אם זורקים אותה עם continue כי המפתח שונה, בעל ה-I/O ההוא ממתין להשלמה לנצח. אותו דבר לענף החבילה שנכשלה.

אם בכל זאת משתפים, צריך מנגנון נפרד שמעביר לכל בעלים לפי מפתח. המאמר הזה מפריד את ה-ports ומעביר רק תוצאות ל-UI thread.

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

// C++: שלד של thread הניטור (timeout + accounting כביטוח מול התראות שפספסו)
DWORD msg; ULONG_PTR key; LPOVERLAPPED info;
bool treeEmpty = false;
while (!treeEmpty) {
    if (!GetQueuedCompletionStatus(iocp, &msg, &key, &info, 5000)) {
        if (info != nullptr) continue;                // חבילת completion של I/O שנכשל. ממשיכים לנטר
        if (GetLastError() != WAIT_TIMEOUT) break;    // port נהרס ודומה: עוצרים
        JOBOBJECT_BASIC_ACCOUNTING_INFORMATION acct = {};
        if (QueryInformationJobObject(job, JobObjectBasicAccountingInformation,
                                      &acct, sizeof(acct), nullptr))
            treeEmpty = (acct.TotalProcesses > 0 &&   // לא טועים ב-Job ריק לפני הפעלה כהשלמה
                         acct.ActiveProcesses == 0);  // ביטוח להתראת ZERO שנפלה
            // הנחה: ה-Job נוצר מחדש בכל הפעלה (1 Job = דור הפעלה 1).
            // אם עושים reuse ל-Job בין ניסיונות, TotalProcesses עדיין סופר את הדור
            // הקודם, וההגנה הזו לא יכולה להבחין בין הדורות
        continue;                        // בכישלון שאילתה, לא מסיקים שזה ריק
    }
    if ((HANDLE)key != job) continue;    // התאמה מול CompletionKey ששימש בשיוך.
                                         // מניחים שה-port הזה נוצר אך ורק לניטור Job
                                         // (ראו בטקסט למטה. זריקה כזו על port משותף
                                         //  גורמת לבעלי I/O אחר להמתין לנצח)
    DWORD pid = (DWORD)(UINT_PTR)info;   // חלק מההודעות נושאות PID
    switch (msg) {
    case JOB_OBJECT_MSG_NEW_PROCESS:          /* לוג יצירת grandchild */ break;
    case JOB_OBJECT_MSG_EXIT_PROCESS:         /* לוג יציאה */ break;
    case JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS:/* יציאה חריגה: הולכים לבדוק dump */ break;
    case JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO:  treeEmpty = true; break;
    }                                    // ה-break של ה-switch לבדו לא מסיים את ההמתנה
}

התראות, accounting ו-handles כל אחד קובע מידע שונה

להתראות אין עקרונית הבטחת מסירה. היחידות המובטחות הן ההתראות על המגבלות שמוגדרות עם JobObjectNotificationLimitInformation. אי אפשר להסיק “אין התראה = זה לא קרה”.15

מצב מצטבר כמו “האם זה הפך לריק” מאושר גם ב-polling של נתוני ה-accounting. אבל accounting הוא קבוצת מונים מצטברים, כך שאי אפשר לשחזר את ה-PID, את קוד היציאה, או אם היציאה הייתה חריגה, ל-EXIT/ABNORMAL_EXIT שנפל. זה התחום של החזקת process handles ושל ETW.

גם ACTIVE_PROCESS_ZERO אינו ראיה ליציאה תקינה. המספר עשוי להגיע לאפס דרך סיום בכוח, וההודעה עצמה לא מבחינה. שופטים את איכות היציאה לפי EXIT/ABNORMAL_EXIT וקוד היציאה.

לא מחליטים שזה אותו process לפי PID לבדו

ה-PID בחבילת completion עושה reuse. אלא אם מחזיקים process handle, אין הבטחה שה-PID עדיין מפנה לאותו process.13

מה ש-pi.hProcess שמוחזק בפרק 4 יכול לנעוץ הוא רק ה-PID של ה-child שהפעלתם ישירות. לנכדים שה-SDK מוליד, קוראים ל-OpenProcess בנקודה ש-NEW_PROCESS מגיע כדי לקבל handle, ומכאן ואילך מתאימים מול ה-handle הזה.

גם אז, נשאר חלון קצר בין ההתראה ל-Open. אם ה-PID עושה reuse במרווח הזה, תופסים process אחר. כשנדרשת זיהוי קפדני של ה-process הבודד, מצליבים עם טלמטריה שנושאת זמן יצירה, כמו אירועי process-start של ETW.

ניטור שלא נשען על התראות לבדןכי הודעות completion port הן להתראה ואין להן הבטחת מסירה, משלבים אותן עם polling של נתוני accounting והחזקת process handles כדי להתכונן לפספוסים ול-reuse של PIDהתראות completion portאין הבטחת מסירה (מטרת התראה)עושים polling גם לנתוני accountingPIDs עושים reuseמחזיקים process handles

איור 10: התראות הן הנתיב הראשי; accounting ו-handles הם הביטוח. רק עם שניהם אפשר לומר ש”צופים”.

אגב, מימושים ש”הורגים את ה-thread שממתין שה-Job יתרוקן עם TerminateThread” פעם הסתובבו, אבל עם completion port אין צורך לסיים בכוח את ה-thread הממתין. גם Raymond Chen פרסם מאמר שכותב מחדש את הדפוס הישן הזה לגישת completion port.16

7. כשלים שקורים בפועל במדידה ובאינטגרציית מכשירים

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

כשל 1: רק ה-parent מת, והמצלמה נשארת פתוחה

בהתקנה שבה ל-SDK של הספק יש helper process להעברת פריימים, הריגת UI ה-parent ב-Task Manager משאירה רק את ה-helper. ה-parent שמופעל מחדש מקבל device busy במהלך אתחול ה-SDK. בחלק מהאתרים שום דבר לא מתאושש עד שמחזור חשמל למכשיר.

מה שנשאר: ה-helper process והפתיחה הבלעדית של המצלמה.

רק ה-parent מת והמצלמה נשארת פתוחהסיום בכוח של ה-UI מסיר את ה-parent, אבל helper process של ה-SDK נשאר מחזיק את handle המצלמה, כך שה-parent שמופעל מחדש נכשל בפתיחה מחדש עם device busyמסיימים את ה-UI ב-Task Managerה-parent נעלםhelper של ה-SDK נשארעדיין מחזיק את המצלמהdevice busy אחרי הפעלה מחדש

איור 11: הפנים האמיתיות של “ה-process נעלם, ובכל זאת אי אפשר לפתוח את המכשיר”. האשם לעתים קרובות אינו ה-process ששמו הוצג ב-Task Manager.

כשל 2: grandchild נגמר מחוץ ל-Job

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

(a) ה-SDK משתמש ב-Job משלו. זה המקרה שבו הצד השני כבר ב-Job אחר. ב-Windows 7 זה job אחד לכל process, כך שה-Assign שלכם נכשל. ב-Windows 8 ואילך קינון יכול לאסוף אותו, אבל אם ל-Job שלכם יש הגבלות UI, הקינון עצמו בלתי אפשרי (פרק 9).

(b) ה-SDK מוליד את ה-grandchild עם CREATE_BREAKAWAY_FROM_JOB. זה עובד רק כשה-Job שלכם מאפשר BREAKAWAY_OK, וה-grandchild נולד מחוץ לעץ מההתחלה. גם קינון לא יכול לאסוף אותו. אם לא מאפשרים, יצירת ה-SDK נכשלת, כך שאם הניטור בעדיפות, הגישה הבסיסית היא לא לאפשר ולזהות את זה ככישלון.

מה שנשאר: grandchild שרץ מחוץ לניטור, ו-Assign שכישלונו נבלע.

שני נתיבים שבהם grandchild נגמר מחוץ ל-Jobבנתיב שבו ה-SDK משתמש ב-Job משלו, קינון יכול לאסוף ב-Windows 8 ואילך אבל נכשל אם מוגדרות הגבלות UI, ואילו בנתיב שבו ה-SDK מוליד את ה-grandchild עם breakaway, זה עובד רק אם אפשרתם וה-grandchild מחוץ לעץ מההתחלהgrandchild נגמר מחוץ ל-Job(a) ה-SDK משתמש ב-Job משלו(b) נוצר עם breakawayWin8+ אוסף בקינוןנכשל עם הגבלות UIעובד רק אם מותרgrandchild מחוץ לעץ מההתחלה

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

כשל 3: process מכשיר שהופעל משירות

כששירות נעצר, ה-SCM ממתין ל-parent בלבד; הילדים והנכדים שהופעלו ב-session 0 הם מחוץ להיקף טיפול העצירה. Job + KillOnJobClose מכסה גם את האזור מחוץ להיקף הזה. תכנון של סידור שחוצה שירות וסשן אינטראקטיבי מושאר ל-מאמר גבול המשתמש.

מה שנשאר: processes שנשארים ב-session 0, וזיהוי שווא בבדיקת instance כפול בהפעלה הבאה.

כשל 4: child שמת חודש אחר כך

אם לא רושמים מעת לעת את ה-accounting של ה-Job (PeakJobMemoryUsed, מוני I/O, מספר processes כולל), אי אפשר לעקוב אחר כך “איזה דור של ה-helper התחיל להתנפח, ומתי”.17 המבנה של מוות חודש אחר כך מ-handle leak מנותח ב-מאמר הכשל הארוך של מצלמה תעשייתית, וה-accounting של ה-Job הוא נקודת הכניסה לחקירה הזו.

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

כשל 5: טיפול היציאה של ה-parent ממתין שה-child ייצא

אם קוראים ל-WaitForExit או ממתינים שהעץ ייצא על ה-UI thread, ה-parent הופך ל-“Not Responding” ביום שה-child קופא. משאירים את המתנת היציאה ל-thread ה-IOCP, ושמים על ה-UI רק התקדמות ולחצן ביטול. המנגנון כמתואר ב-מאמר “Not Responding”.

מה שנשאר: parent שנתקע יחד עם ה-child.

לא ממתינים ליציאה על UI thread של ה-parentהמתנה ליציאת ה-child על ה-UI thread מפיצה hang של child ל-Not Responding של ה-parent, לכן משאירים את המתנת היציאה ל-thread ניטור ה-IOCP ושמים על ה-UI thread רק תצוגת התקדמות ולחצן ביטולממתינים ליציאת child על ה-UI threadהיום שה-child נתקעגם ה-parent Not Responding (נגרר)ממתינים על thread ה-IOCPל-UI יש רק התקדמות וביטול

איור 13: הצד שצופה בחריגה של ה-child אסור שיקפא בגלל החריגה של ה-child. רק הפרדת איפה ממתינים מסירה את ה-hang הנלווה.

כשל 6: נכשל רק תחת debugger

כלי פיתוח ומפעילים עשויים כבר להכניס את parent process שלכם לאיזה Job. ב-Windows 8 ואילך קינון בדרך כלל מציל, אבל במחשבי מכשיר שרצים 7 או מוקדם יותר ה-Assign הופך ל-ERROR_ACCESS_DENIED, ומייצר הבדלים כמו “זה לא עובד רק במכונת הפיתוח” או “רק בייצור”. הצעד הראשון הסטנדרטי הוא לבדוק את החברות שלכם עם IsProcessInJob.18

מה שנשאר: זמן אימות שבוזבז בלי לנעוץ את סיבת הבדל הסביבה.

8. אילו מגבלות להצמיד

למגבלות יש מטרה ותופעות לוואי

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

מגבלה למה משתמשים אם מגזימים
KILL_ON_JOB_CLOSE לשחרר את המכשיר כשה-parent נעלם חומר post-mortem נעלם
ACTIVE_PROCESS לעצור ריבוי פרוע של ילדי ה-SDK גם helpers לגיטימיים נדחים ביצירה
JOB_MEMORY / PROCESS_MEMORY תקרה לדליפות ריצה ארוכה הקצאה של מאגרי תמונה ענקיים מתחילה להיכשל
DIE_ON_UNHANDLED_EXCEPTION בלי דיאלוגי שגיאה במכונות unattended דיבאג אינטראקטיבי הופך לכואב
בקרת CPU rate למנוע מ-child של עיבוד תמונה להרעיב את ה-UI דדליינים של פריימים מוחמצים
BREAKAWAY_OK להשאיר נתיב בריחה ל-SDK שצריך job משלו processes נעלמים מהניטור
הגבלות UI הידוק דמוי sandbox הקינון נשבר (פרק 9)

מגבלות התראה הן לתצפית; מגבלות אכיפה הן לדחייה ולסיום

מפרידים “מגבלות רופפות להתראה” מ”מגבלות שעוצרות כשחורגים”. JobObjectNotificationLimitInformation רק מודיע על החריגה; ה-process ממשיך לרוץ.15

מגבלות Extended Limit נאכפות, אבל צורת האכיפה שונה לפי מגבלה.3

מגבלה מה קורה כשחורגים
מגבלת זיכרון פעולת ה-commit שהייתה חורגת נכשלת. ה-process עצמו נשאר חי
ACTIVE_PROCESS היצירה או ה-assignment שהיו חורגים נכשלים. process שחרג ב-assignment מסתיים
זמן process (PROCESS_TIME) רק ה-process שחרג מסתיים
זמן Job (JOB_TIME) מגבלה על הערך המצטבר; כברירת מחדל כל ה-processes תחת ה-Job מסתיימים

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

מגבלות להתראה ומגבלות לאכיפהמגבלה של JobObjectNotificationLimitInformation רק מודיעה על החריגה וה-process ממשיך לרוץ, ואילו מגבלות Extended Limit נאכפות: מגבלת זיכרון מכשילה את הפעולה, ACTIVE_PROCESS מכשיל יצירה ו-assignment, ומגבלות זמן מסיימות את ה-processלצפותלעצורמטרת המגבלה?מגבלת התראה: ממשיך לרוץ כשחורגיםמגבלת אכיפה: דוחים או מסיימיםמזהים את הדור מלוגי accountingכישלון commit, יצירה נדחית, סיום

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

הקשר בין בקרת CPU rate לעיבוד מחזורי מושאר ל-מאמר soft real-time; כאן לא הולכים רחוק יותר מנגד ל”process המכשיר אוכל את ה-UI”.

9. קינון, Breakaway, וצד שכבר ב-Job

חושבים על קינון כ”הכלה של קבוצות processes”

כללי הקינון ב-Windows 8 ואילך אפשר לארגן לארבעה.5

  • ה-job ההורה הוא הקבוצה הרחבה יותר, ו-job הילד הוא תת-קבוצה שלו (שיוך בסדר ששובר את ההכלה הזו נכשל)
  • למגבלות המשאבים העיקריות, המגבילה ביותר לאורך השרשרת נכנסת לתוקף
  • Job עם הגבלות UI לא יכול להיות מקונן
  • התראות מועברות גם ל-completion ports של כל job הורה לאורך השרשרת (ל-job הילד לא חייב להיות port)
היררכיית jobs מקוננים ומגבלות בתוקףה-job ההורה הוא הקבוצה הרחבה יותר ו-job הילד הוא תת-קבוצה שלו, ולמגבלות המשאבים העיקריות הערך המגביל ביותר לאורך השרשרת נכנס לתוקף. Job עם הגבלות UI לא יכול להיות מקונןjob הורה (קבוצה רחבה יותר)job ילד (תת-קבוצה)processes חבריםמגבלה בתוקף = הערך המגביל ביותרJob עם הגבלות UIלא יכול להיות מקונן

איור 15: חושבים על קינון כ”הכלה של קבוצות”. הגבלות UI שוברות קינון, כך שבטוח יותר לא להצמיד אותן ל-Job שמשמש לניהול משך חיים.

Breakaway הוא הנתיב ליצירת process מחוץ ל-Job מההתחלה

Breakaway הוא הנתיב הלגיטימי שבו צאצאים שנוצרים עם CreateProcess עוזבים את העץ.3

הגדרה בצד ה-Job תנאי שה-child ייולד מחוץ ל-Job
JOB_OBJECT_LIMIT_BREAKAWAY_OK יוצרים אותו עם CREATE_BREAKAWAY_FROM_JOB שצוין
SILENT_BREAKAWAY_OK אין צורך בדגל. כל child נולד בחוץ

לפעמים זה נחוץ כי ה-SDK משתמש ב-Job משלו. עם זאת, process שנולד בנתיב הזה מוצא גם מסיום גורף וגם מניטור. אם מאפשרים, מחליטים גם מי מנהל את מה שברח.

הנתיב החוצה מהעץ ב-breakawayכש-BREAKAWAY_OK מוגדר על ה-Job, grandchild שנוצר עם CREATE_BREAKAWAY_FROM_JOB נולד מחוץ ל-Job ונעלם מהיקף הסיום הגורף והניטוריצירה רגילהיצירה עם BREAKAWAYJob (עם BREAKAWAY_OK)child processגם grandchild ב-Jobgrandchild יוצא מחוץ ל-Jobמחוץ לניטור ולסיום גורף

איור 16: ל-breakaway שני פנים: “נתיב בריחה ל-SDK שצריך אותו” ו”חור בניטור”. אם מצמידים, מחליטים מי משגיח על מה שברח.

הפעלת proxy דרך WMI אי אפשר למנוע גם כש-breakaway אסור

נתיבים שבהם process צד שלישי מפעיל בשמכם, כמו Win32_Process.Create של WMI, הם עניין אחר. ה-parent בפועל הוא ספק ה-WMI, כך שה-process שנולד מחוץ ל-Job מההתחלה. איסור breakaway לא סוגר את החור הזה.1

אם ה-SDK משתמש בנתיב הזה בודקים לא מלוג NEW_PROCESS אלא מיחסי parent-child ב-Process Explorer.

בודקים קודם חברות ב-Job קיים ואת מגבלות Windows 7 ומוקדם יותר

כשהצד השני כבר ב-Job (כשל 6), ההליך הוא: בודקים עם IsProcessInJob → אם אפשר להקים קינון, עושים Assign כמו שהוא → אם אי אפשר (Windows 7, או הגבלות UI), משנים את התכנון.18

ב-Windows 7 ומוקדם יותר, אי אפשר לעשות Assign שני לצד שכבר שייך ל-Job אחר. BREAKAWAY_OK אינו דגל שמסיר process שכבר שויך אחר כך. נתיב הבריחה הזה עובד רק כשצד ה-SDK מבקש breakaway כשהוא יוצר את ה-child. אם אי אפשר לצפות לזה, משנים את התכנון לפני ההפעלה על ההנחה של “process אחד, job אחד”.12

מחליטים אחרון אם להכניס את ה-parent עצמו ל-Job

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

10. איך חוקרים

חוקרים בסדר חברות → לוגים של accounting והתראות → תפיסת מכשיר. זה הליך האישור ללא להסיק “כלום לא נשאר” רק מהסתכלות על שמות processes.

  • Process Explorer: למאפייני ה-process יש לשונית Job, שמראה את ה-Job שה-process שייך אליו ואת המגבלות שלו. זו הדרך המהירה ביותר לאשר “באיזה Job ה-helper הזה”
  • IsProcessInJob: נקודת הכניסה לבדיקת החברות שלכם או של הצד השני מהקוד18
  • QueryInformationJobObject: רושמים מעת לעת Basic Accounting (מספר processes כולל, זמן CPU) ו-Extended Limit (PeakJobMemoryUsed וכן הלאה)417
  • שומרים את לוג ה-completion port בקובץ: ציר הזמן של NEW_PROCESS / EXIT / ABNORMAL_EXIT הופך לראיה היחידה בחקירה חודש אחר כך
  • לא בודקים שאריות לפי PID: על מה מסתכלים הוא device handles, שמות pipes, וקבצי נעילה. “אין process נראה ב-Task Manager” לא אומר “המכשיר שוחרר”
הליך לחקירת שאריותקודם מאשרים חברות עם IsProcessInJob ועם לשונית Job של Process Explorer, קוראים את ה-accounting עם QueryInformationJobObject, ולבסוף שופטים שאריות לפי device handles, שמות pipes וקבצי נעילה ולא לפי אם process קייםמאשרים חברות עם IsProcessInJobלשונית Job של Process Exploreraccounting עם QueryInformationJobObjectשופטים שאריות לפי device handles ושמות pipes

איור 17: חוקרים בסדר “חברות → accounting → תפיסה”. לא מסיקים “כלום לא נשאר” רק מהסתכלות על שמות processes.

11. מדריך גס לבחירה (טבלת החלטה)

כאן הבחירות עד כאן מסוכמות לפי מצב. חוזרים לפרק 5 למדיניות הסיום, לפרק 6 להנחות הניטור, ולפרק 9 לקשר עם Jobs קיימים.

מצב המלצה
גוף ה-UI ו-SDK המכשיר פוצלו ל-processes נפרדים מכניסים ל-Job, ומנטרים עם completion port
המקרה הגרוע ביותר הוא שהמכשיר מוחזק אחרי שה-parent נעלם מגדירים KillOnJobClose
הפריים או ה-dump ברגע הקריסה הוא נכס לא מגדירים KillOnJobClose; צד הניטור הופך דברים לבטוחים ואז קורא ל-TerminateJobObject
SDK הספק מוליד helpers יוצרים עם SUSPENDED או JOB_LIST, ורושמים NEW_PROCESS
Assign מחזיר ERROR_ACCESS_DENIED בודקים קודם את ה-job הקיים ואם קינון אפשרי. חושדים בהגבלות UI
מולידים ילדים בסשן האינטראקטיבי משירות לא מצמידים הגבלות UI. חוזרים לתכנון ב-מאמר גבול המשתמש
ממתינים ליציאת נכדים על ה-UI thread עוצרים. מעבירים ל-thread ה-IOCP

12. סיכום

משך החיים של child אינו משך החיים של אובייקט ה-Process של ה-parent. גם יציאת ה-parent לא מועברת ל-child אוטומטית. Job Object הוא המנגנון שהופך את ה-process tree הזה ליחידה אחת ומטפל במגבלות, בהתראות ובסיום גורף.

כדי לנהל עד הנכדים, סוגרים חברות לפני שה-child רץ. הליך A הוא SUSPENDED + Assign; הליך B, ב-Windows 10 ואילך, הוא JOB_LIST. KillOnJobClose עובד בסגירת handle ה-Job האחרון לא משנה למה ה-parent יצא, אבל הוא גם מאבד את חומר ה-post-mortem של הצאצאים שמסתיימים יחד איתו.

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

הדבר הבא לכתוב יהיה הפרטים של הפעלת processes בין session 0 לסשן האינטראקטיבי, או המתנה על מכשירים עם overlapped I/O. ברגע שיש שליטה ב”משך החיים החיצוני” של process, משך החיים של I/O מחכה הבא בתור.

מאמרים קשורים

תחומי ייעוץ קשורים

KomuraSoft LLC מטפלת בתכנון בידוד processes של אפליקציות Windows שעובדות עם מצלמות, מכשירי מדידה ומכשירי serial/USB, בחקירת תקלות תפיסת מכשיר כמו helpers של SDK שנשארים ו-device busy, ובבניית מנגנוני ניטור והתאוששות אוטומטית לאפליקציות ריצה ארוכה. אפשר להתייעץ גם ממקרה בודד של “מפעילים מחדש את ה-parent ואי אפשר לפתוח את המכשיר”.

קישורים

  1. Microsoft Learn, Job Objects. על כך ש-Job Object הוא kernel object שמנהל קבוצת processes כיחידה אחת, child processes שנוצרים על ידי process חבר משויכים לאותו Job כברירת מחדל (למעט דרך Win32_Process.Create), שני דגלי המגבלה ל-breakaway, סיום גורף עם TerminateJobObject, ואיך לנהל process tree בסביבות שבהן קינון אינו זמין.  2 3 4 5

  2. Microsoft Learn, AssignProcessToJobObject function (jobapi2.h). על כך שהשיוך בין process ל-Job בלתי הפיך, job אחד לכל process ב-Windows 7 ומוקדם יותר עם חברות מרובה (קינון) אפשרית מ-Windows 8, והמגבלות בתוקף והתפשטות breakaway תחת קינון.  2

  3. Microsoft Learn, JOBOBJECT_BASIC_LIMIT_INFORMATION structure (winnt.h). על דגלי המגבלה JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE (מסיים את כל ה-processes כש-handle ה-Job האחרון נסגר), ACTIVE_PROCESS (תקרה על processes פעילים בו-זמנית), JOB_MEMORY (תקרת commit לכל ה-job), DIE_ON_UNHANDLED_EXCEPTION, ו-BREAKAWAY_OK / SILENT_BREAKAWAY_OK.  2 3 4 5

  4. Microsoft Learn, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION structure (winnt.h). על כך שה-Job שומר נתוני accounting כמו מספר processes כולל, זמן CPU ומספר page faults, כולל סכומים שהצטברו מ-processes שיצאו, וקבלתם עם QueryInformationJobObject.  2

  5. Microsoft Learn, Nested Jobs. על כך ש-jobs מקוננים יוצרים היררכיית parent-child (job הילד הוא תת-קבוצה של ה-processes של job ההורה), Job עם הגבלות UI לא יכול לקנן, המגבלה בתוקף היא הערך המגביל ביותר לאורך השרשרת, התראות נשלחות לכל completion ports של שרשרת job ההורה, וההיררכיה מסתיימת מהרמה הנמוכה ביותר.  2

  6. Microsoft Learn, Process Creation Flags. על CREATE_SUSPENDED (יוצרים את ה-thread הראשוני מושהה ולא מריצים אותו עד ResumeThread) ו-CREATE_BREAKAWAY_FROM_JOB (ה-Job של הקורא חייב שיהיה לו JOB_OBJECT_LIMIT_BREAKAWAY_OK). 

  7. Raymond Chen, Closing the race window between creating a suspended process and putting it in a job (The Old New Thing). על ההליך הקלאסי של יצירה עם CREATE_SUSPENDED ואז הכנסת ה-process ל-Job, ואיך סוגרים את חלון ה-race שלו. 

  8. Microsoft Learn, UpdateProcThreadAttribute function (processthreadsapi.h). על כך ש-PROC_THREAD_ATTRIBUTE_JOB_LIST משייך handles של Job ל-child process שנוצר בסדר שצוין, ועל כך שהוא נתמך ב-Windows 10 / Windows Server 2016 ואילך. 

  9. Raymond Chen, A more direct and mistake-free way of creating a process in a job object (The Old New Thing). על שימוש ב-PROC_THREAD_ATTRIBUTE_JOB_LIST כדי לגרום ל-process להשתייך ל-Job מרגע היצירה. 

  10. Raymond Chen, Destroying all child processes (and grandchildren) when the parent exits (The Old New Thing). על הסידור שמסיים צאצאים יחד כשה-parent נעלם באמצעות Job עם KILL_ON_JOB_CLOSE, ועל החשיבות של לא לתת ל-handle ה-Job להיות מורש. 

  11. Microsoft Learn, TerminateJobObject function (jobapi2.h). על סיום בכוח של כל ה-processes שמשויכים ל-Job, כאילו קראו ל-TerminateProcess על כל אחד בנפרד. 

  12. Microsoft Learn, Job Objects - Managing Job Objects. על כך שאובייקט ה-Job הופך ל-signaled כשכל ה-processes מסתיימים בגלל חריגה ממגבלת זמן ה-job, ה-Job נהרס כש-handle האחרון נסגר, וסגירה גורמת לסיום כל ה-processes החברים כש-KILL_ON_JOB_CLOSE צוין. 

  13. Microsoft Learn, JOBOBJECT_ASSOCIATE_COMPLETION_PORT structure (winnt.h). על רשימת ההודעות שנשלחות ל-completion port כמו JOB_OBJECT_MSG_NEW_PROCESS / EXIT_PROCESS / ABNORMAL_EXIT_PROCESS / ACTIVE_PROCESS_ZERO, קודי היציאה שנשפטים כיציאות חריגות, אי אפשר לשלול reuse של PID להודעות שמחזירות PID אלא אם מחזיקים process handle, ומסירת התראות אינה מובטחת.  2 3

  14. Raymond Chen, How do I wait until all processes in a job have exited? (The Old New Thing). על כך שהמתנה על handle ה-Job לא יכולה לזהות “הפך לריק”, ועל הצורך להמתין ל-JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO על ה-completion port. 

  15. Microsoft Learn, Job Objects - Job Limits and Notifications. על כך שעדיף לשייך את ה-completion port כשה-Job לא פעיל (מצמצם את האפשרות לפספס התראות ל-processes שמצבם משתנה במהלך השיוך), מסירת הודעות אינה מובטחת למעט המגבלות שמוגדרות עם JobObjectNotificationLimitInformation, ו-processes ממשיכים לרוץ אחרי חריגה ממגבלת התראה.  2 3

  16. Raymond Chen, Removing the TerminateThread from code that waits for a job object to empty (The Old New Thing). על כתיבה מחדש של הדפוס הישן של הריגת ה-thread הממתין עם TerminateThread להמתנה מבוססת completion port. 

  17. Microsoft Learn, JOBOBJECT_EXTENDED_LIMIT_INFORMATION structure (winnt.h). על הגדרת מגבלות זיכרון לפי process ולפי job, וקבלת שיא זיכרון עם PeakProcessMemoryUsed / PeakJobMemoryUsed.  2

  18. Microsoft Learn, IsProcessInJob function (jobapi.h). על קביעה אם process רץ ב-Job שצוין (או בכל Job).  2 3

  19. Microsoft Learn, JOBOBJECT_CPU_RATE_CONTROL_INFORMATION structure (winnt.h). על שליטה ב-CPU rate (חלק ממחזורי שעון או משקל) לפי Job. 

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

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

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

שאלות נפוצות

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

האם Job Object הוא container או sandbox?
לא. Job Object הוא kernel object שמצמיד מגבלות, התראות וסיום גורף לקבוצת processes. הוא יכול להטיל תקרות על זיכרון, CPU ומספר processes, אבל הוא לא יכול להגביל גישת רשת, וה-access token (הרשאות) לא משתנה. יש לו הגבלות UI, אבל הן לבדן לא עושות אותו לגבול אבטחה. אם המטרה היא isolation או אבטחה, צריך לשלב אותו עם מנגנון אחר כמו AppContainer או containers. מה שהמאמר הזה מכסה הוא השימוש בטיפול במשך החיים ובמשאבים של process tree כיחידה אחת.
Process.Kill() לא מספיק?
Process.Kill() מסיים רק את אותו process אחד; הוא לא מגיע ל-grandchild processes. Kill(entireProcessTree: true) ב-.NET Core 3.0 ואילך עובר על הצאצאים ומסיים אותם, אבל כי הוא מונה את ה-process tree מיחסי parent-child באותו רגע, הוא יכול לפספס processes שנולדו במהלך המנייה ו-processes שהייחוס שלהם נחתך כי ה-parent מת קודם. גם, אף אחת מהשיטות לא נקראת כשה-parent עצמו קורס. אם רוצים שצאצאים ייאספו לא משנה אם ה-parent חי או מת, Job Object ועליו KillOnJobClose, שמפקיד את משך החיים לליבה, הוא הבחירה האמינה.
אם ה-parent ב-Job, האם ה-child נכנס ל-Job אוטומטית?
כברירת מחדל, כן. child process שנוצר עם CreateProcess על ידי process ששייך ל-Job שייך אוטומטית לאותו Job. החריג הוא breakaway. אם JOB_OBJECT_LIMIT_BREAKAWAY_OK מוגדר על ה-Job וה-child נוצר עם הדגל CREATE_BREAKAWAY_FROM_JOB, או אם JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK מוגדר, ה-child נולד מחוץ ל-Job. שימו לב גם ש-processes שנוצרים דרך Win32_Process.Create של WMI אינם משויכים ל-Job.
אפשר להוציא process מ-Job אחרי שהכניסו אותו?
לא. השיוך שנעשה ב-AssignProcessToJobObject הוא בלתי הפיך, והחברות נמשכת עד שה-process יוצא. אפשרויות התכנון הן לכן שלוש: לא להכניס, ליצור אותו בחוץ מההתחלה עם breakaway, או לקנן Job נפרד. אין "להוציא אחר כך". הבלתי-הפיכות הזו היא גם הסיבה שה-Job צריך להיות מוכן לפני היצירה.
האם KillOnJobClose עובד גם כשה-parent קורס?
כן. JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE הוא מנגנון שמסיים את ה-processes שמתחת כש-handle ה-Job האחרון נסגר, ובין אם ה-parent יוצא כרגיל, מת על unhandled exception, או נהרג ב-Task Manager, הליבה סוגרת את ה-handles כחלק מ-cleanup של ה-process, ולכן זה נורה. עם זאת, אם נותנים ל-child process לרשת את handle ה-Job, ה-handle שהילד מחזיק שורד את מות ה-parent, כך שזה לא ה-handle האחרון וזה לא נורה. לא נותנים ל-handle ה-Job להיות מורש.
האם התראות completion port תמיד מגיעות?
לא תמיד. התיעוד הרשמי קובע במפורש שלמעט התראות על המגבלות שמוגדרות עם JobObjectNotificationLimitInformation, מסירת הודעות ל-completion port אינה מובטחת. התראה שלא הגיעה אינה אומרת שהאירוע לא קרה. לניטור שצריך ודאות, משלבים עם polling של נתוני accounting דרך QueryInformationJobObject, ומחזיקים את process handles בעצמכם כדי לקבוע אם process חי או מת.
יש ל-.NET API רשמי ל-Job Object?
לא. ל-System.Diagnostics.Process אין מושג של Job, וגם ל-BCL אין wrapper. התשובה המעשית היא לקרוא ל-CreateJobObject / SetInformationJobObject / AssignProcessToJobObject דרך P/Invoke, או לייצר את החתימות עם CsWin32, ה-source generator של Microsoft, ולכתוב wrapper דק. אם עוטפים את handle ה-Job ב-SafeHandle וסוגרים אותו ב-Dispose של IDisposable, המשמעות של KillOnJobClose, "משך החיים של ה-wrapper = משך החיים של עץ ה-child processes", מופיעה ישירות בקוד.
איך מתכננים למחשב מכשיר שרץ Windows 7?
ב-Windows 7 ומוקדם יותר process יכול להשתייך ל-Job אחד בלבד, וקינון אינו אפשרי. אם ה-SDK של הצד השני משתמש ב-Job משלו, ה-AssignProcessToJobObject שלכם נכשל. JOB_OBJECT_LIMIT_BREAKAWAY_OK הוא דגל שמאפשר ל-process שכבר ב-Job שלכם ליצור child מחוץ ל-Job עם CREATE_BREAKAWAY_FROM_JOB; זה לא קסם שמעביר Assign שני אחרי שה-process כבר בפנים. כלומר, נתיב הבריחה קיים רק כשצד ה-SDK מבקש breakaway בזמן היצירה. אם אי אפשר לצפות לזה, האפשרות היחידה היא לשנות את התכנון לפני ההפעלה על ההנחה שיש רק Job אחד, שלכם. התיעוד של Microsoft גם מראה איך לנהל את העץ עם שני דגלי מגבלת breakaway בסביבות שבהן קינון אינו זמין. עם זאת, זו מערכת הפעלה מחוץ לתמיכה, ולכן אם בכלל אפשר, מעבר קודם.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג