부모가 죽은 뒤에 무엇이 남는가 — Job Object로 자식 프로세스를 기르기

· 업데이트: · · Windows, Windows 개발, C#, C++, Win32 API, 계측 제어

수정 이력(초판, 2026년 08월 29일 공개)
최초 공개

UI를 작업 관리자에서 종료했는데 카메라를 다시 열 수 없다. 감시 애플리케이션은 죽었는데 SDK의 헬퍼가 COM 포트를 붙잡은 채로 있다. 부모를 다시 띄우면 이중 기동이 되고 공유 메모리나 이름 있는 파이프에 문제가 생긴다. 장비 SDK를 별도 프로세스로 분리하면 이런 ‘부모가 죽은 뒤’의 사고를 만나게 됩니다.

원인을 생각하는 출발점은 Windows에서는 부모 프로세스가 종료해도 자식과 손자 프로세스는 자동으로 종료되지 않는다는 점입니다. 기동 시의 부모-자식 관계와 종료 시의 수명 관리는 서로 다른 것입니다. 이 공백을 메우는 도구가 Job Object입니다.

이 글에서는 먼저 부모의 종료 처리만으로는 부족한 이유를 확인하고, 이어서 Job에 넣는 방법, 종료 방침, 감시 방법을 정리합니다. 마지막으로 장비 연동에서 일어나는 사고와 조사 절차로 이어갑니다.

대상 독자는 장비 SDK를 별도 프로세스로 분리하고 있는 WinForms / WPF / 서비스 개발자입니다. 전제 환경은 Windows 10/11(중첩 잡과 PROC_THREAD_ATTRIBUTE_JOB_LIST를 쓰는 부분)이고, 코드는 C++(Win32 API)와 C#(.NET 6 이상)으로 제시합니다. 난이도는 중급입니다.

이 글은 ‘응답 없음’, 종료, 절전 복귀, 이름 있는 파이프 각 글의 연장선에서 프로세스 바깥쪽의 수명을 다룹니다.

1. 먼저 결론

설계의 출발점은 ‘어떻게 종료시킬 것인가’보다 먼저 ‘무엇을 남겨서는 안 되고 무엇을 남기고 싶은가’를 정하는 것입니다.

Job Object는 프로세스 트리를 하나의 단위로 만드는 구조입니다. 다만 부모의 소멸과 동시에 자손을 강제 종료시키는 것과, 그 자손의 덤프나 마지막 상태를 남기는 것은 그대로는 양립하지 않습니다. 장비의 점유를 회수할 것인지, 진단 재료를 먼저 남길 것인지를 정합니다.

  • 수명을 관리하는 단위는 부모-자식 계보가 아니라 Job입니다.프로세스 그룹에 제한, 알림, 일괄 종료를 붙입니다. 한 번 소속시킨 프로세스는 종료할 때까지 빠져나올 수 없고, Windows 8 이후에는 중첩할 수 있습니다.12
  • 자식을 달리게 하기 전에 Job 소속을 확정시킵니다.기동한 뒤에 Assign하면 그 사이에 태어난 손자를 놓칩니다. CREATE_SUSPENDED와 생성 시점의 JOB_LIST는 닫을 수 있는 경쟁 창도 다릅니다(4장).
  • 자동 회수와 진단 정보의 보존은 종료 방침으로서 선택합니다.KillOnJobClose의 발동 조건은 ‘마지막 Job 핸들이 닫히는 것’입니다. 부모의 크래시에는 강한 반면, 함께 강제 종료되는 자손의 사후 분석에는 약하므로 둘 다 필요하다면 감시 쪽이 먼저 채취한 뒤에 종료시킵니다(5장).3

계측 애플리케이션이 원하는 것은 죽이는 것 자체가 아닙니다. 장비를 점유한 채로 남기지 않는 것과, 비정상 종료를 관측할 수 있는 것입니다.

알고 싶은 것 읽을 장
왜 부모의 종료 처리만으로는 부족한가 2~3장: 부모-자식 관계와 Job의 역할
자손을 놓치지 않고 관리하려면 4~6장: 생성, 종료 방침, 감시
SDK나 서비스에서 무엇이 문제가 되는가 7~9장: 사고 사례, 자원 제한, 중첩과 breakaway
현장에서 무엇을 조사하고 어떻게 고를 것인가 10~11장: 조사 절차와 판단표

아래의 지식 맵은 각 요소의 관계를 되짚어 보기 위한 것입니다. 구조부터 읽고 싶다면 2장으로 진행하십시오.

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 21건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

2. 왜 WaitForExit만으로는 부족한가

종료를 ‘기다리는’ 것과 수명을 묶는 것은 별개

Process.WaitForExit()는 ‘부모가 자식의 종료를 기다리는’ API입니다. 이 글에서 생각하는 것은 그 반대 방향, 부모가 먼저 죽었을 때 자식을 어떻게 할 것인가입니다. Windows의 부모-자식 관계만으로는 부모의 종료가 자식에게 전달되지 않습니다.

Process.Kill()이나 CloseMainWindow()도 그 자체로 손자 프로세스와 손자가 쥐고 있는 장비 핸들까지 돌봐 주지는 않습니다. 종료된 프로세스 자신의 핸들은 해제되지만, 문제는 살아남은 자손이 쥐고 있는 핸들입니다.

.NETKill(entireProcessTree: true)는 자손을 따라가며 종료시키지만, 열거 도중의 생성이나 부모가 먼저 죽은 경우의 누락이 있고, 부모 자신이 크래시한 뒤에는 호출되지 않습니다. 부모의 뒷정리 코드에만 의존하지 않는 구조가 필요합니다.

부모가 죽는 주요 이유별로 현장에 무엇이 남는지를 늘어놓습니다.

부모가 죽는 이유 자식에게 일어나는 일 현장에 남는 것
UI를 x로 닫았는데 종료 처리가 불완전 아무 일도 일어나지 않음(Process 객체가 폐기될 뿐) 헬퍼 프로세스, 카메라의 잠금
작업 관리자에서 부모만 강제 종료 자식은 계속 살아 있음 COM 포트, USB, 공유 메모리
처리되지 않은 예외로 크래시 부모의 finally가 돌아간다는 보장이 없음 임시 파일, 배타 잠금
서비스 정지의 타임아웃 SCM이 돌보는 것은 부모뿐 세션 0에 자식이 잔류
부모의 수명과 장비 점유 수명의 어긋남부모 프로세스의 종료로 부모의 대기와 Process 객체는 사라지지만 자식과 손자 프로세스는 계속 살아 있고, 장비 핸들과 이름 있는 파이프, 잠금 파일의 점유도 계속 남는다부모 프로세스의 종료사라지는 것: 부모의 대기, Process 객체남는 것: 자식, 손자 프로세스장비 핸들의 점유이름 있는 파이프의 서버 쪽잠금 파일, 공유 메모리

그림 1: 부모의 수명과 장비 점유의 수명은 어긋나 있다. 부모 쪽 뒷정리 코드는 부모가 비정상적으로 죽었을 때일수록 돌아가지 않는다.

대상은 ‘OS는 살아 있고 부모만 종료된’ 경우

종료나 절전으로 OS가 전체를 멈추는 흐름은 종료 글절전 복귀 글의 영역입니다. 이 글은 OS는 멀쩡한 채로 부모만 종료되는 경우를 다룹니다. 계측 현장에서는 이쪽의 빈도가 더 높고, 잔류도 알아차리기 어렵습니다.

3. Job Object란 무엇인가

기본 조작은 만들고, 소속시키고, 설정하고, 조회하는 것

Job Object는 프로세스 그룹을 하나의 단위로 관리하는 커널 객체입니다. 기본 조작을 역할로 나누면 다음 네 가지가 됩니다.14

API 역할
CreateJobObject 아직 프로세스가 소속되지 않은 Job을 만든다
AssignProcessToJobObject 프로세스를 Job에 소속시킨다
SetInformationJobObject 제한 등을 설정한다
QueryInformationJobObject CPU 시간, 페이지 폴트, 프로세스 수 등의 회계 정보를 읽는다

소속은 되돌릴 수 없고, 프로세스가 종료할 때까지 빠져나올 수 없습니다. 또한 회계 정보에는 종료한 프로세스의 몫도 포함됩니다.

소속된 프로세스가 CreateProcess로 만드는 자식은 기본적으로 같은 Job에 소속됩니다. 즉 손자도 증손자도 자동으로 들어온다는 것이 Job의 가치의 핵심입니다.1 다만 breakaway나 WMI에 의한 대리 기동 등 소속에서 벗어나는 경로는 9장에서 따로 확인합니다.

Job Object의 기본 구조부모 프로세스가 만든 Job Object에 자식과 손자가 소속되고, Job은 프로세스 트리 단위로 제한의 강제, 완료 포트로의 알림, 일괄 종료를 수행한다부모 프로세스Job Object자식(장비 SDK 호스트)손자(제조사 헬퍼)제한(메모리, CPU)알림(완료 포트)일괄 종료

그림 2: Job은 ‘제한’, ‘알림’, ‘일괄 종료’ 세 가지를 프로세스 트리 단위로 제공하는 그릇이다.

OS의 세대 차이, 그리고 샌드박스와의 차이

Windows 7 이전은 한 프로세스에 하나의 잡, Windows 8부터 중첩(복수 소속)이 가능해졌습니다.5 본문은 Windows 10/11을 전제로 하고, Windows 7 이전에서 주의할 부분은 9장과 FAQ에서 다룹니다.

Job에 넣는 것만으로 컨테이너나 샌드박스가 되지는 않습니다. 네트워크 접근은 제한할 수 없고, 액세스 토큰(권한)은 다른 구조입니다. UI 제한만으로 보안 경계를 만들 수도 없습니다. 이 글에서의 역할은 어디까지나 프로세스 트리의 수명과 자원을 하나의 단위로 만드는 것입니다.

4. 올바르게 넣는 법 — 생성과 소속의 경쟁

기동한 뒤에 Assign하면 그 사이에 손자가 태어난다

자식 프로세스는 달리기 시작한 처음 몇 밀리초 안에 손자를 낳는 경우가 있습니다. SDK의 헬퍼 기동이 전형입니다. Process.Start() 뒤에 PID를 얻어 Assign하면 Assign보다 먼저 태어난 손자가 Job 밖으로 나갑니다.

달리기 시작한 뒤에 넣으면 손자를 놓친다Process.Start 직후부터 자식은 달리고 있고, AssignProcessToJobObject를 호출할 때까지의 경쟁 창 사이에 자식이 낳은 손자는 Job 밖으로 나가 버린다Process.Start로 자식이 달리기 시작Assign까지의 경쟁 창이 사이에 태어난 손자Job 밖에서 계속 달린다AssignProcessToJobObject이후에 태어나는 손자만 들어온다

그림 3: 경쟁 창이 몇 밀리초라 해도, SDK 헬퍼의 기동은 바로 거기서 일어난다.

절차 A: 정지 상태로 생성하고 소속시킨 뒤에 달리게 한다

호환성이 넓은 고전적인 방법은 CREATE_SUSPENDED를 쓰는 절차입니다. 자식이 먼저 움직여 손자를 낳는 창을 다음 순서로 닫습니다.67

  1. CreateJobObject로 Job을 만든다
  2. SetInformationJobObject로 제한을 먼저 설정한다
  3. CREATE_SUSPENDED를 붙여 CreateProcess한다(초기 스레드는 달리지 않는다)
  4. AssignProcessToJobObject로 넣는다
  5. 실패하면 Resume하지 말고 그 자리에서 TerminateProcess(Job 밖에서 한 명령도 실행시키지 않는다)
  6. ResumeThread로 달리게 한다

절차 A가 닫는 것은 손자 생성과의 경쟁 창입니다. 부모 자신의 크래시에 대한 창은 남습니다.절차 3과 4 사이에 부모가 크래시하면 아직 Job에 들어가지 않은 정지 상태의 자식이 남습니다. 달리지는 않지만 자동으로 사라지지도 않습니다.

부모의 크래시 내성까지 포함해 이 창을 닫고 싶다면 다음의 절차 B를 사용합니다.

SUSPENDED로 기동한 뒤 Job에 넣는 절차Job을 만들고 제한을 설정한 뒤 CREATE_SUSPENDED로 자식을 기동해 AssignProcessToJobObject로 소속시키고, 실패하면 Resume하지 않고 TerminateProcess로 멈추며 성공하면 ResumeThread로 달리게 한다성공실패CreateJobObject로 Job을 만든다SetInformationJobObject로 제한CREATE_SUSPENDED로 자식을 기동AssignProcessToJobObjectResumeThread로 달리게 한다Resume하지 않고 즉시 Terminate

그림 4: 절차 A의 뼈대. ‘실패하면 달리게 하지 않는다’는 분기를 생략한 구현이, 사고가 났을 때만 떠돌이 프로세스를 낳는다.

// C++: 절차 A의 최소 핵심(오류 처리는 뼈대만)
HANDLE job = CreateJobObjectW(nullptr, nullptr);   // 이름 없이도 된다. 상속시키지 않는다
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);                   // 제한 없는 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);                   // 기동 재시도에서 Job 핸들을 새지 않게 한다
    return HRESULT_FROM_WIN32(err);
}

if (!AssignProcessToJobObject(job, pi.hProcess)) {
    DWORD err = GetLastError();         // Terminate로 덮어쓰기 전에 대피
    TerminateProcess(pi.hProcess, 1);   // Job 밖에서는 달리게 하지 않는다
    // 핸들을 닫고 err를 오류로 올린다
} else if (ResumeThread(pi.hThread) == (DWORD)-1) {
    DWORD err = GetLastError();
    TerminateProcess(pi.hProcess, 1);   // 정지 상태로 방치하지 않는다
    // 핸들을 닫고 err를 오류로 올린다
}
CloseHandle(pi.hThread);
// 성공하면 job과 pi.hProcess의 소유권은 호출자의 수명 관리 객체
// (4장의 C# 래퍼에 해당)로 옮긴다. job을 닫는 것 = KillOnJobClose의 발동이며,
// pi.hProcess는 6장에서 '누가 죽었는지'를 확정하는 데 쓴다

절차 B: Windows 10 이후에서는 Job에 들어간 상태로 생성한다

STARTUPINFOEX의 속성 목록에 PROC_THREAD_ATTRIBUTE_JOB_LIST로 Job 핸들을 싣습니다. 그리고 EXTENDED_STARTUPINFO_PRESENT 플래그와 함께 CreateProcess에 넘깁니다. 플래그가 없으면 확장 구조체로 해석되지 않아 속성이 무시됩니다.89

이 방법에서는 초기 스레드가 달리기 전에 프로세스가 Job에 소속됩니다. ‘생성했지만 아직 소속되지 않았다’는 경쟁 창 자체가 없어지므로, SUSPENDED와 생성 후에 Assign해서 실패를 처리하는 분기가 필요 없습니다.

방법 소속을 확정시키는 시점 남는 주의 사항
절차 A: SUSPENDED → Assign → Resume 자식의 생성 후, 초기 스레드를 움직이기 전 Assign 전에 부모가 크래시하면 정지한 자식이 남는다
절차 B: JOB_LIST로 생성 프로세스의 생성 시점 Windows 10 이후가 필요. 속성 목록과 확장 기동 플래그를 지정한다
절차 A와 절차 B의 경쟁 창 차이절차 A는 정지 상태로 생성한 뒤 Assign과 Resume를 하므로 실패 시 종료시키는 분기가 필요하지만, 절차 B는 속성 목록에 Job을 실어 생성하므로 태어난 시점에 이미 소속되어 있고 경쟁 창도 실패 분기도 존재하지 않는다절차 A: 정지 상태로 생성Assign으로 소속Resume로 시동실패 분기가 필수절차 B: 속성 목록으로 생성태어난 시점에 이미 소속경쟁 창도 실패 분기도 없다

그림 5: 절차 A는 ‘넣고 나서 달리게 한다’, 절차 B는 ‘들어간 상태로 태어난다’. Windows 10 이후를 전제로 할 수 있다면, 고르는 이유는 경쟁 창과 실패 분기의 유무 그 자체다.

어느 절차에서든 피하고 싶은 세 가지 구현

  • Process.Start()한 뒤에 PID를 얻어 넣는다(손자가 먼저 나간다)
  • Job 핸들을 자식에 상속시킨다(부모가 죽어도 자식이 핸들을 계속 쥐고 있어 KillOnJobClose가 발동하지 않는다 — 5장)
  • Assign의 실패를 삼키고 운전을 계속한다(Job 밖의 장비 프로세스는 다음 사고의 주역이 된다)

.NET에서는 Job 핸들의 소유자를 코드로 표현한다

System.Diagnostics.Process에는 Job이라는 개념이 없고 공식 래퍼도 없습니다. P/Invoke나 CsWin32로 얇은 래퍼를 씁니다.

핵심은 Job 핸들을 SafeHandle로 감싸고 IDisposable로 만드는 것입니다. 마지막 Job 핸들을 Dispose()로 닫으면 KillOnJobClose가 발동합니다. ‘래퍼의 수명이 자식 트리의 수명’이라는 설계 의도를 소유권으로 표현할 수 있습니다.

아래는 핸들 소유를 나타내는 뼈대입니다. 생성 처리는 절차 A/B의 P/Invoke 쪽에서 하고, 실제 프로젝트에서는 CsWin32의 설정이나 unsafe 지정 등도 필요합니다.

// C#: Job 핸들의 소유만 담당하는 얇은 래퍼(생성은 절차 A/B의 P/Invoke를 거쳐서)
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();              // KillOnJobClose가 없는 Job을 넘기지 않는다
            throw new Win32Exception(err);
        }
    }

    public void Dispose() => _job.Dispose();  // 여기서 산하의 트리가 종료된다
}

반대로 닫을 생각이 없는데 핸들을 닫으면 자식 트리를 종료시켜 버립니다. 래퍼에 대한 참조는 부모의 생존 기간 내내 보유하십시오. 보유하지 않으면 GC가 SafeHandle을 회수하는 순간 자식 트리가 이유 없이 전멸합니다.

5. KillOnJobClose — ‘기른다’와 ‘함께 죽는다’

발동 조건은 ‘부모의 죽음’이 아니라 ‘마지막 핸들의 닫힘’

JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE마지막 Job 핸들이 닫혔을 때 산하의 모든 프로세스를 종료시키는 제한 플래그입니다.3

부모가 예외로 종료하든, 작업 관리자에서 강제 종료되든, 서비스가 강제 정지되든, 커널은 그 프로세스의 핸들을 모두 닫습니다. 그것이 마지막 Job 핸들이라면 자식도 손자도 종료됩니다. 부모의 종료 처리가 돌아가는 것을 전제하지 않는다는 점이 이 구조의 강함입니다.10

다만 자식에게 Job 핸들을 상속시키면 부모의 종료 후에도 핸들이 남습니다. 그 경우 ‘마지막 핸들’이 되지 않아 자식 트리는 종료되지 않습니다.

KillOnJobClose의 타임라인부모가 정상 종료든 크래시든 강제 종료든 커널이 부모의 모든 핸들을 닫고, 그것이 마지막 Job 핸들이면 Job이 닫혀 산하의 프로세스 트리가 일괄 종료된다아니오(상속 있음)부모의 소멸(크래시 포함)커널이 모든 핸들을 닫는다마지막 Job 핸들?산하의 트리를 일괄 종료자식은 계속 살아남는다

그림 6: 발동 조건은 ‘부모의 죽음’이 아니라 ‘마지막 핸들이 닫힌 것’. 그래서 핸들을 자식에게 상속시켜서는 안 된다.

바로 회수하는 방침과 진단 재료를 남기는 방침을 고른다

강제 종료로 잃는 것은 장비의 점유만이 아닙니다. 함께 종료당하는 자식과 손자의 크래시 덤프를 얻을 기회, 마지막 유효 프레임, 쓰다 만 계측 파일을 플러시할 기회도 잃습니다.

부모 자신의 크래시 덤프는 별개입니다. WER는 부모가 살아 있는 동안 처리되지 않은 예외를 처리하므로, 부모의 덤프는 핸들이 닫히기 전에 쓸 수 있습니다. 여기서 문제 삼는 것은 강제 종료되는 쪽인 자식과 손자의 사후 분석 재료입니다.

방침 어울리는 현장 잃는 것
KillOnJobClose 있음 장비의 이중 오픈이 최악인 현장 사후 분석의 재료, 마지막 샘플
없음(감시만) 덤프와 로그가 자산인 현장 방치하면 고아 프로세스, 포트 점유
없음 + 감시 프로세스가 TerminateJobObject 제어용 서비스가 따로 있을 때 구현이 이중이 된다

판단의 재료로서 ‘남겨서는 안 되는 것’과 ‘남기고 싶은 것’을 먼저 열거하십시오.

남겨서는 안 되는 것: 카메라 / 디지타이저의 오픈, 시리얼과 USB의 배타, 이름 있는 파이프의 서버 쪽, 라이선스 동글의 세션, 공유 메모리와 잠금 파일.

남기고 싶은 것: 크래시 덤프, 마지막 유효 프레임 / 카운터, 장비를 안전한 쪽으로 되돌리는 명령의 전송 기회(가능하다면 죽이기 전에 보낸다).

죽일 것인가, 감시만 할 것인가장비의 이중 오픈이 최악이라면 KillOnJobClose를 붙이고, 크래시 덤프나 마지막 프레임이 자산이라면 붙이지 않고 감시하며, 별도의 제어 서비스가 있다면 그쪽에서 TerminateJobObject를 호출한다장비의 해제가 최우선덤프와 마지막 상태가 자산제어 서비스가 따로 있음죽은 순간에 무엇을 지킬 것인가?KillOnJobClose 있음감시만(죽이지 않음)감시 쪽이 TerminateJobObject

그림 7: ‘죽일 것인가’가 아니라 ‘죽은 순간에 무엇을 지킬 것인가’로 고른다. 둘 다 원한다면, 감시 쪽이 먼저 덤프를 채취한 뒤에 접는 셋째 줄의 구성이 된다.

감시 쪽에는 부모가 살아 있는 동안에 Job 핸들을 넘긴다

표의 셋째 줄, 감시 프로세스가 TerminateJobObject로 종료시키는 구성에는 준비가 필요합니다. 감시 쪽은 부모가 죽기 전에 Job 핸들을 손에 넣어 두어야 합니다.

이 글의 절차로 만드는 Job은 이름이 없으므로, 부모가 사라진 뒤에 밖에서 찾아갈 방법은 없습니다. 넘기는 방법은 다음 두 가지입니다.

방법 부모가 살아 있는 동안에 할 일
이름 없는 Job의 핸들을 복제한다 DuplicateHandle로 감시 프로세스에 핸들을 넘긴다
이름 있는 Job을 쓴다 처음부터 이름을 붙여 만들고, 감시 쪽이 OpenJobObject로 열어 보유한다

이름은 전역에서 충돌할 수 있으므로 고유한 GUID 등을 포함시킵니다. 이 준비를 잊으면 감시 쪽은 부모의 이상을 발견해도 트리를 종료시킬 수단을 가질 수 없습니다.

덤프 채취와 장비의 안전화는 강제 종료보다 먼저 한다

KillOnJobClose로 종료당하는 자식에게는 TerminateProcess와 마찬가지로 예고가 없습니다. 처리되지 않은 예외가 발생하지 않으므로, 자식에 WER(LocalDumps)를 설정해 두어도 그 강제 종료 시의 덤프는 남지 않습니다. WER가 주울 수 있는 것은 자식이 자기 자신의 크래시로 종료하는 경우입니다.

‘자동 회수도 덤프도’가 요건이라면, 종료시키는 주체를 감시 쪽으로 옮깁니다. 대상이 살아 있는 동안에 덤프를 채취하고, 필요하면 장비를 안전한 쪽으로 되돌리고, 마지막에 TerminateJobObject로 종료시키는 순서입니다.

자동 회수와 덤프를 양립시키는 접는 방법감시 프로세스가 이상을 감지하면 먼저 덤프를 채취하고, 필요하면 장비를 안전한 쪽으로 되돌리는 명령을 보내고, 마지막에 TerminateJobObject로 트리를 접음으로써 자동 회수와 사후 분석을 양립시킨다감시 쪽이 이상을 감지먼저 덤프를 채취장비를 안전한 쪽으로 되돌린다TerminateJobObject로 접는다

그림 8: ‘자동 회수도 덤프도’의 유일한 답. 순서가 반대면 채취할 대상이 이미 존재하지 않는다.

KillOnJobClose로 부모의 소멸과 동시에 자식을 강제 종료시키는 구성에서는, 자식이 ‘장비를 안전한 쪽으로 되돌리는 명령’을 보낼 기회도 없습니다. 그것이 필요한 장비에서는 표의 셋째 줄 구성을 골라, 감시 쪽이 먼저 안전화 명령을 보낸 뒤에 TerminateJobObject를 호출합니다.11

6. 완료 포트로 ‘비었다’를 기다린다

Job 핸들의 대기만으로는 트리의 종료를 확인할 수 없다

Job 핸들은 산하의 프로세스가 전부 종료해도 시그널 상태가 되지 않습니다. 시그널되는 것은 잡 시간의 상한 초과로 모든 프로세스가 종료당했을 때뿐입니다.12

‘자식 트리가 비면 다음으로 진행한다’를 위해서는 I/O 완료 포트(IOCP)를 Job에 연결합니다.1314 연결은 Job이 비어 있는 동안, 프로세스를 넣기 전에 끝냅니다. 도중에 연결하면 그 와중에 상태가 바뀐 프로세스의 알림을 놓칠 가능성이 있습니다.15

네 가지 메시지로 발생, 종료, 이상, 영으로 떨어짐을 관측한다

메시지 알 수 있는 것
JOB_OBJECT_MSG_NEW_PROCESS Job에 프로세스가 추가된 것. 손자의 발생도 감지한다
JOB_OBJECT_MSG_EXIT_PROCESS 프로세스가 종료한 것
JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS 액세스 위반 등 비정상 종료 코드로 종료한 것. 계측 애플리케이션에서는 특히 중요13
JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO 활성 프로세스 수가 0이 된 것

NEW_PROCESS의 패킷에 들어 있는 것은 새 PID뿐입니다. 누가 낳았는가 하는 부모-자식 관계는 알 수 없습니다. 계보가 필요하다면 ETW 등 다른 수단을 더합니다.

완료 포트 알림의 흐름Job이 프로세스의 발생, 종료, 비정상 종료, 영으로 떨어짐 메시지를 완료 포트로 보내고, 전용 감시 스레드가 GetQueuedCompletionStatus로 받아 UI 스레드에는 결과만 넘긴다UI 스레드감시 스레드완료 포트Job ObjectUI 스레드감시 스레드완료 포트Job ObjectNEW / EXIT_PROCESSABNORMAL_EXIT / ZERO완료 패킷을 기다린다메시지와 PID결과의 통지만 넘긴다

그림 9: GetQueuedCompletionStatus는 전용 스레드에서 돌린다. UI 스레드에서 기다리면 자식의 이상이 있을 때마다 UI가 ‘응답 없음’이 된다.

전용 스레드, 전용 포트에서 기다리고 타임아웃 때는 회계를 조회한다

이 감시 루프는 Job의 알림 전용으로 만든 완료 포트에서 돌립니다. 기존 I/O와 같은 포트에 얹으면 GetQueuedCompletionStatus가 돌아온 시점에 패킷은 이미 꺼내진 상태입니다. 키가 다르다고 continue로 버리면 그 I/O의 주인은 완료를 영원히 기다리게 됩니다. 실패 패킷의 분기도 마찬가지입니다.

공유한다면 키별로 주인에게 배달하는 구조가 별도로 필요합니다. 이 글에서는 포트를 나누고, UI 스레드에는 결과만 넘깁니다.

또한 1 Job은 1 기동 세대로, 기동할 때마다 다시 만든다는 전제입니다. 재시도로 돌려 쓰면 TotalProcesses에 이전 세대가 포함되어, 기동 전의 빈 Job을 구분하는 가드가 듣지 않게 됩니다.

// C++: 감시 스레드의 뼈대(알림 누락에 대비해 타임아웃과 회계로 보험을 든다)
DWORD msg; ULONG_PTR key; LPOVERLAPPED info;
bool treeEmpty = false;
while (!treeEmpty) {
    if (!GetQueuedCompletionStatus(iocp, &msg, &key, &info, 5000)) {
        if (info != nullptr) continue;                // 실패 I/O의 완료 패킷. 감시는 계속한다
        if (GetLastError() != WAIT_TIMEOUT) break;    // 포트 파기 등은 종료
        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 기동 세대). 재시도로
            // Job을 돌려 쓰면 TotalProcesses는 이전 세대를 센 채로 남고,
            // 이 가드로는 세대를 구분할 수 없다
        continue;                        // 조회 실패 시에는 비었다고 단정하지 않는다
    }
    if ((HANDLE)key != job) continue;    // 연결 시의 CompletionKey와 대조한다.
                                         // 이 포트는 Job 감시 전용으로 만든다는 전제
                                         // (아래 본문 참조. 공유 포트에서 이렇게 버리면
                                         //  다른 I/O의 주인이 영원히 기다린다)
    DWORD pid = (DWORD)(UINT_PTR)info;   // 메시지에 따라서는 PID가 들어 있다
    switch (msg) {
    case JOB_OBJECT_MSG_NEW_PROCESS:          /* 손자의 발생을 로그 */ break;
    case JOB_OBJECT_MSG_EXIT_PROCESS:         /* 종료를 로그 */ break;
    case JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS:/* 비정상 종료: 덤프 확인으로 */ break;
    case JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO:  treeEmpty = true; break;
    }                                    // switch의 break만으로는 대기가 끝나지 않는다
}

알림, 회계, 핸들은 확정할 수 있는 정보가 다르다

알림은 원칙적으로 도착 보장이 없습니다.보장되는 것은 JobObjectNotificationLimitInformation으로 설정한 상한의 알림뿐입니다. ‘알림이 오지 않았다 = 일어나지 않았다’라고 판단할 수 없습니다.15

‘비었는가’ 같은 집계 상태는 회계 정보의 폴링을 함께 써서 확인합니다. 다만 회계는 집계 카운터이므로, 누락된 EXIT / ABNORMAL_EXIT의 PID, 종료 코드, 이상 여부까지는 복원할 수 없습니다. 그것은 프로세스 핸들 보유와 ETW의 영역입니다.

ACTIVE_PROCESS_ZERO도 정상 종료의 증거가 아닙니다.강제 종료로 영이 된 것일 수도 있고, 메시지 자체는 구분하지 않습니다. 종료의 질은 EXIT / ABNORMAL_EXIT와 종료 코드로 판정합니다.

PID만으로 같은 프로세스라고 단정하지 않는다

완료 패킷의 PID는 재사용됩니다. 프로세스 핸들을 보유하고 있지 않은 한, 그 PID가 아직 같은 프로세스를 가리킨다는 보장은 없습니다.13

4장에서 보유한 pi.hProcess로 고정할 수 있는 것은 직접 기동한 자식의 PID뿐입니다. SDK가 낳은 손자에 대해서는 NEW_PROCESS를 받은 시점에 OpenProcess해서 핸들을 얻고, 이후에는 그 핸들을 기준으로 대조합니다.

그래도 알림에서 Open까지의 짧은 창은 남습니다. 그 사이에 PID가 재사용되면 다른 프로세스를 쥐게 됩니다. 개체의 동정까지 엄밀하게 필요한 경우에는 ETW의 프로세스 시작 이벤트 등 생성 시각을 가진 텔레메트리로도 뒷받침을 얻습니다.

알림에만 의존하지 않는 감시완료 포트의 메시지는 알림 목적이라 도착 보장이 없으므로, 회계 정보의 폴링과 프로세스 핸들의 보유를 함께 써서 누락과 PID 재사용에 대비한다완료 포트의 알림도착 보장 없음(알림 목적)회계 정보의 폴링 병용PID는 재사용된다프로세스 핸들을 보유

그림 10: 알림은 주 경로, 회계와 핸들은 보험. 둘이 갖춰져야 비로소 ‘관측하고 있다’고 말할 수 있다.

덧붙이면 예전에는 ‘Job이 비기를 기다리는 스레드를 TerminateThread로 죽인다’ 같은 구현이 돌아다녔지만, 완료 포트가 있으면 대기 스레드의 강제 종료는 필요 없습니다. Raymond Chen도 이 오래된 패턴을 완료 포트 방식으로 다시 쓰는 글을 내놓았습니다.16

7. 계측과 장비 연동에서 실제로 일어나는 사고

여기까지의 구조를 장비 현장에서 일어나는 여섯 가지 사고에 대입합니다. 각 예에서는 원인과 대처에 더해 ‘무엇이 남았는가’를 확인합니다.

사고 1: 부모만 죽고 카메라가 열린 채로 남는다

제조사 SDK가 프레임 전송용 헬퍼 프로세스를 갖는 구성에서, 부모 UI를 작업 관리자로 내리면 헬퍼만 남습니다. 다시 띄운 부모는 SDK 초기화에서 device busy. 장비의 전원을 다시 넣기 전까지 복구되지 않는 현장도 있습니다.

남은 것: 헬퍼 프로세스와 카메라의 배타 오픈.

부모만 죽고 카메라가 열린 채로 남는다UI의 강제 종료로 부모는 사라지지만 SDK의 헬퍼 프로세스는 남아 카메라의 핸들을 쥔 채로 있기 때문에, 다시 띄운 부모는 device busy로 다시 열기에 실패한다UI를 작업 관리자로 종료부모는 사라진다SDK 헬퍼는 남는다카메라를 쥔 채로 있다재기동 후에 device busy

그림 11: ‘프로세스는 사라졌는데 장비를 열 수 없다’의 정체. 범인은 작업 관리자에 표시된 이름의 프로세스가 아닌 경우가 많다.

사고 2: 손자가 Job 밖으로 나간다

경로는 둘이 있고, 대처를 나눌 필요가 있습니다.

(a) SDK가 자기 Job을 쓰고 있는 경우.상대가 이미 다른 Job에 들어가 있는 사례입니다. Windows 7에서는 한 프로세스에 하나의 잡이므로 이쪽의 Assign이 실패합니다. Windows 8 이후에서는 중첩으로 주울 수 있지만, 이쪽 Job에 UI 제한이 있으면 중첩 자체가 되지 않습니다(9장).

(b) SDK가 CREATE_BREAKAWAY_FROM_JOB으로 손자를 낳는 경우.이쪽 Job이 BREAKAWAY_OK를 허용한 경우에만 성립하며, 손자는 처음부터 트리 밖에서 태어납니다. 중첩으로도 주울 수 없습니다. 허용하지 않으면 SDK 쪽의 생성이 실패하므로, 감시를 우선한다면 허용하지 않고 실패로 감지하는 것이 기본입니다.

남은 것: 감시 밖에서 달리는 손자와, 실패를 삼킨 Assign.

손자가 Job 밖으로 나가는 두 경로SDK가 자기 Job을 쓰는 경로는 Windows 8 이후라면 중첩으로 주울 여지가 있지만 UI 제한을 붙이고 있으면 실패하고, SDK가 브레이크어웨이로 손자를 낳는 경로는 이쪽이 허용한 경우에만 성립해 손자는 처음부터 트리 밖이 된다손자가 Job 밖으로 나간다(a) SDK가 자기 Job(b) breakaway로 생성Win8 이후는 중첩으로 줍는다UI 제한이 있으면 실패허용한 경우에만 성립손자는 처음부터 트리 밖

그림 12: 같은 ‘밖으로 나간다’라도 (a)는 중첩으로 주울 여지가 남고, (b)는 허용한 시점에 주울 수 없음이 확정된다. 대처는 경로의 특정에서 시작한다.

사고 3: 서비스에서 기동한 장비 프로세스

서비스 정지 시에 SCM이 기다리는 것은 부모뿐이고, 세션 0에서 낳은 자식과 손자는 정지 처리의 관할 밖입니다. Job과 KillOnJobClose는 이 관할 밖까지 돌봅니다. 서비스와 대화형 세션을 넘나드는 구성 자체의 설계는 사용자 경계 글에 맡깁니다.

남은 것: 세션 0의 잔류 프로세스와, 다음 기동 시 이중 기동 판정의 오폭.

사고 4: 한 달 뒤에 죽는 자식

Job의 회계(PeakJobMemoryUsed, I/O 카운터, 총 프로세스 수)를 정기적으로 기록해 두지 않으면, ‘어느 세대의 헬퍼가 언제부터 부풀었는가’를 사후에 추적할 수 없습니다.17 핸들 누수로 한 달 뒤에 죽는 구조는 산업용 카메라의 장기 장애 글에서 해부한 그대로이고, Job의 회계는 그 조사의 입구가 됩니다.

남은 것: 원인 확정에 부족한 로그.

사고 5: 부모의 종료 처리가 자식의 종료를 기다린다

UI 스레드에서 WaitForExit나 트리의 종료 대기를 하면, 자식이 멈춘 날에 부모가 ‘응답 없음’이 됩니다. 종료 대기는 IOCP 스레드에 맡기고, UI에는 진행 상황과 중단 버튼만 둡니다. 구조는 ‘응답 없음’ 글에 있는 그대로입니다.

남은 것: 함께 멈춘 부모.

종료 대기를 부모의 UI 스레드에서 하지 않는다UI 스레드에서 자식의 종료를 기다리면 자식의 멈춤이 부모의 응답 없음으로 전파되므로, 종료 대기는 IOCP의 감시 스레드에 맡기고 UI 스레드에는 진행 표시와 중단 버튼만 둔다UI 스레드에서 자식의 종료를 기다린다자식이 멈춘 날부모도 응답 없음(함께 멈춤)IOCP 스레드에서 기다린다UI는 진행과 중단만

그림 13: 자식의 이상을 관측하는 쪽이 자식의 이상으로 멈춰서는 안 된다. 기다리는 장소를 나누기만 해도 함께 멈추는 일은 사라진다.

사고 6: 디버거 아래에서만 실패한다

개발 도구나 런처가 당신의 부모 프로세스를 이미 어딘가의 Job에 넣어 둔 경우가 있습니다. Windows 8 이후는 중첩으로 대개 구제되지만, 7 이전의 장비 PC에서는 Assign이 ERROR_ACCESS_DENIED가 되어 ‘개발기에서만 안 된다’, ‘운영에서만 안 된다’는 차이가 생깁니다. 먼저 IsProcessInJob으로 자신의 소속을 확인하는 것이 정석입니다.18

남은 것: 환경 차이의 원인을 특정하지 못한 검증 시간.

8. 제한은 무엇을 붙일 것인가

제한에는 목적과 부작용이 있다

계측 애플리케이션에서 의미가 있는 제한으로 좁혀, 쓰는 이유와 부작용을 늘어놓습니다.319

제한 쓰는 이유 지나치면
KILL_ON_JOB_CLOSE 부모 소멸로 장비를 해제 사후 분석의 재료가 사라진다
ACTIVE_PROCESS SDK의 폭주하는 자식 증식을 멈춘다 정상적인 헬퍼까지 생성 거부
JOB_MEMORY / PROCESS_MEMORY 장기 가동 누수의 상한 거대한 이미지 버퍼의 확보가 실패하기 시작한다
DIE_ON_UNHANDLED_EXCEPTION 무인기에서 오류 대화 상자를 띄우지 않는다 대화형 디버깅이 괴로워진다
CPU 레이트 제어 이미지 처리 자식이 UI를 굶기지 않게 한다 프레임 기한을 맞추지 못한다
BREAKAWAY_OK 다른 잡이 필요한 SDK에 탈출로를 남긴다 감시 대상에서 사라진다
UI 제한 샌드박스적인 조임 중첩이 망가진다(9장)

알림 상한은 관측용, 강제 상한은 거부와 종료용

‘알림용의 느슨한 상한’과 ‘넘으면 멈추는 상한’은 나눕니다. JobObjectNotificationLimitInformation은 초과를 알릴 뿐이고 프로세스는 계속 달립니다.15

Extended Limit의 상한은 강제되지만, 강제의 형태는 상한마다 다릅니다.3

상한 초과 시에 일어나는 일
메모리 상한 초과하는 커밋 조작이 실패한다. 프로세스 자체는 살아 있다
ACTIVE_PROCESS 초과하는 생성과 소속이 실패한다. 소속으로 초과한 그 프로세스는 종료당한다
프로세스 시간(PROCESS_TIME) 초과한 그 프로세스만 종료당한다
잡 시간(JOB_TIME) 집계값에 대한 상한이며, 기본적으로 Job 산하의 모든 프로세스가 종료당한다

이 차이를 모르고 설정하면 ‘생성 직후에 사라지는 헬퍼’를 다른 장애로 오진합니다. 장기 가동에서는 먼저 알림 상한으로 관측하고, 경향을 파악한 뒤에 강제 상한을 정하는 순서가 안전합니다.

알림용 상한과 강제 상한JobObjectNotificationLimitInformation의 상한은 초과를 알릴 뿐이고 프로세스는 계속 달리며, Extended Limit의 상한은 강제로서 메모리 상한은 조작의 실패, ACTIVE_PROCESS는 생성과 소속의 실패, 시간 상한은 프로세스의 종료로 작동한다관측하고 싶다멈추고 싶다상한의 목적은?알림 상한: 넘어도 달린다강제 상한: 거부 또는 종료회계 로그로 세대를 특정커밋 실패, 생성 거부, 종료

그림 14: 같은 ‘상한’이라도 알림과 강제는 별개이고, 강제가 듣는 방식도 상한마다 다르다. 관측 없이 곧바로 강제 상한을 치면 정상 동작의 봉우리에서 오폭한다.

CPU 레이트 제어와 주기 처리의 관계는 소프트 리얼타임 글에 맡기고, 여기서는 ‘장비 프로세스가 UI를 잡아먹는다’에 대한 대책 정도로 그칩니다.

9. 중첩, Breakaway, 이미 Job에 들어가 있는 상대

중첩은 ‘프로세스 집합의 포함’으로 생각한다

Windows 8 이후의 중첩 규칙은 네 가지로 정리할 수 있습니다.5

  • 부모 잡이 넓은 집합, 자식 잡은 그 부분집합(이 포함이 성립하지 않는 순서로 Assign하면 실패한다)
  • 주요한 자원 제한은 체인 위에서 가장 엄격한 것이 실효가 된다
  • UI 제한이 붙은 잡은 중첩할 수 없다
  • 알림은 체인 위의 모든 부모 잡의 완료 포트에도 도착한다(자식 잡 쪽에 포트가 없어도 된다)
중첩 잡의 계층과 실효 제한부모 잡은 넓은 집합이고 자식 잡은 그 부분집합이 되며, 주요한 자원 제한은 체인 위에서 가장 엄격한 값이 실효가 된다. UI 제한이 붙은 잡은 중첩할 수 없다부모 잡(넓은 집합)자식 잡(부분집합)소속 프로세스실효 제한 = 가장 엄격한 값UI 제한이 붙은 잡중첩할 수 없다

그림 15: 중첩은 ‘집합의 포함’으로 생각한다. UI 제한은 중첩을 망가뜨리므로 수명 관리용 Job에는 붙이지 않는 것이 무난하다.

Breakaway는 처음부터 Job 밖에서 생성하는 경로

breakaway는 CreateProcess로 태어나는 자손이 트리에서 빠지는 정규 경로입니다.3

Job 쪽의 설정 자식이 Job 밖에서 태어나는 조건
JOB_OBJECT_LIMIT_BREAKAWAY_OK CREATE_BREAKAWAY_FROM_JOB을 지정해 생성한다
SILENT_BREAKAWAY_OK 플래그 지정이 필요 없다. 모든 자식이 밖에서 태어난다

SDK가 스스로 Job을 쓰기 위해 필요한 경우도 있습니다. 다만 그 경로로 태어난 프로세스는 일괄 종료에서도 감시에서도 벗어납니다. 허용한다면 빠져나간 곳을 누가 관리할지까지 정합니다.

Breakaway로 트리에서 빠지는 경로Job에 BREAKAWAY_OK가 붙어 있으면 CREATE_BREAKAWAY_FROM_JOB으로 생성된 손자는 Job 밖에서 태어나 일괄 종료와 감시의 대상에서 사라진다통상의 생성BREAKAWAY 지정의 생성Job(BREAKAWAY_OK 있음)자식 프로세스손자도 Job 안손자는 Job 밖으로감시와 일괄 종료의 대상 밖

그림 16: breakaway는 ‘필요한 SDK를 위한 탈출로’와 ‘감시의 구멍’ 두 얼굴을 함께 가진다. 붙인다면 빠져나간 곳을 누가 지켜볼지까지 정한다.

WMI의 대리 기동은 breakaway 금지로도 막을 수 없다

WMI의 Win32_Process.Create처럼 제삼자의 프로세스가 대리로 기동하는 경로는 별개의 문제입니다. 실제 부모가 WMI 공급자이므로, 태어난 프로세스는 처음부터 Job 밖에 있습니다. breakaway를 금지해도 이 구멍은 막을 수 없습니다.1

SDK가 이 경로를 쓰고 있지 않은지는 NEW_PROCESS의 로그가 아니라 Process Explorer의 부모-자식 관계로 확인합니다.

기존 Job으로의 소속과 Windows 7 이전의 제약을 먼저 확인한다

상대가 이미 Job에 들어가 있을 때(사고 6)의 절차는 IsProcessInJob으로 확인 → 중첩을 짤 수 있으면 그대로 Assign → 짤 수 없으면(Windows 7, 또는 UI 제한) 설계를 바꾼다, 의 순서입니다.18

Windows 7 이전에서는 이미 다른 Job에 소속된 상대에게 이중으로 Assign할 수 없습니다. BREAKAWAY_OK는 이미 소속된 프로세스를 나중에 빼내는 플래그가 아닙니다. SDK 쪽이 자식의 생성 시점에 breakaway를 요구해 주는 경우에만 그 탈출로가 성립합니다. 그것을 기대할 수 없다면 기동 전에 ‘한 프로세스에 하나의 잡’을 전제로 설계를 바꿉니다.12

부모 자신을 Job에 넣을지는 마지막에 판단한다

마지막으로 ‘자기 프로세스를 자기 Job에 넣는’ 설계에도 짚고 넘어갑니다. 부모 자신도 산하에 넣으면 부모의 크래시 때 KillOnJobClose의 대상에 자신도 포함되어, 트리 전체의 수명이 완전히 일치합니다. 다만 Job 핸들을 쥐는 방식을 잘못하면 의도하지 않은 일제 종료가 되는 양날의 검이므로, 우선은 ‘부모는 밖, 자식 트리만 안’에서 시작하는 것이 안전합니다.

10. 조사하는 방법

조사는 소속 → 회계와 알림 로그 → 장비의 점유의 순으로 진행합니다. 프로세스 이름을 눈으로 보는 것만으로 잔류가 없다고 결론짓지 않기 위한 확인 절차입니다.

  • Process Explorer: 프로세스의 속성에 Job 탭이 있어 소속 Job과 제한이 보입니다. ‘이 헬퍼는 어느 Job에 있는가’를 가장 빠르게 확인할 수 있습니다
  • IsProcessInJob: 자신이나 상대의 소속을 코드에서 확인하는 입구18
  • QueryInformationJobObject: Basic Accounting(총 프로세스 수, CPU 시간)과 Extended Limit(PeakJobMemoryUsed 등)을 정기적으로 기록한다417
  • 완료 포트의 로그를 파일로 남긴다: NEW_PROCESS / EXIT / ABNORMAL_EXIT의 시계열은 한 달 뒤의 조사에서 유일한 증거가 된다
  • 잔류 확인은 PID로 하지 않는다: 볼 것은 장비 핸들과 파이프 이름과 잠금 파일. ‘작업 관리자에 프로세스가 보이지 않는다’는 ‘장비가 해제되었다’를 뜻하지 않습니다
잔류 조사의 절차먼저 IsProcessInJob과 Process Explorer의 Job 탭으로 소속을 확인하고, QueryInformationJobObject로 회계를 읽고, 마지막으로 프로세스의 유무가 아니라 장비 핸들과 파이프 이름과 잠금 파일로 잔류를 판정한다IsProcessInJob으로 소속 확인Process Explorer의 Job 탭QueryInformationJobObject로 회계장비 핸들, 파이프 이름으로 잔류 판정

그림 17: 조사는 ‘소속 → 회계 → 점유’의 순. 프로세스 이름을 눈으로 보는 것만으로 ‘남아 있지 않다’고 결론짓지 않는다.

11. 대략적인 구분(판단표)

여기까지의 선택을 상황별로 정리합니다. 종료 방침은 5장, 감시의 전제는 6장, 기존 Job과의 관계는 9장으로 돌아가 확인할 수 있습니다.

상황 권장
UI 본체와 장비 SDK를 별도 프로세스로 뺐다 Job에 넣고 완료 포트로 감시한다
부모 소멸로 장비를 쥔 채로 두는 것이 최악 KillOnJobClose를 붙인다
죽은 순간의 프레임이나 덤프가 자산 KillOnJobClose는 붙이지 않고, 감시 쪽이 안전화한 뒤에 TerminateJobObject
제조사 SDK가 헬퍼를 낳는다 생성을 SUSPENDED나 JOB_LIST로 하고 NEW_PROCESS를 로그한다
Assign이 ERROR_ACCESS_DENIED 기존 잡과 중첩 가능 여부를 먼저 본다. UI 제한을 의심한다
서비스에서 대화형 세션의 자식을 낸다 UI 제한을 붙이지 않는다. 사용자 경계 글의 설계로 돌아간다
손자의 종료를 UI 스레드에서 기다리고 있다 그만둔다. IOCP 스레드로 내보낸다

12. 정리

자식의 수명은 부모의 Process 객체의 수명이 아닙니다. 부모의 종료도 자식에게 자동으로 전달되지 않습니다.Job Object는 이 프로세스 트리를 하나의 단위로 만들어 제한, 알림, 일괄 종료를 다루는 구조입니다.

손자까지 관리하려면 자식을 달리게 하기 전에 소속을 확정시킵니다. 절차 A는 SUSPENDED와 Assign, Windows 10 이후의 절차 B는 JOB_LIST입니다. KillOnJobClose는 부모의 종료 이유와 관계없이 마지막 Job 핸들이 닫히는 것으로 작동하지만, 함께 종료시키는 자손의 사후 분석 재료도 잃게 합니다.

그렇기 때문에 ‘죽은 뒤에 남겨서는 안 되는 것’과 ‘남기고 싶은 것’을 먼저 열거하고, 즉시 회수인지 감시 쪽에서 채취와 안전화를 한 뒤의 종료인지를 고른다. 이것이 이 글에서 전하고 싶은 설계 절차입니다.

다음에 쓴다면 세션 0과 대화형 세션을 넘나드는 프로세스 기동의 각론이나, overlapped I/O로 장비를 기다리는 이야기입니다. 프로세스의 ‘바깥쪽 수명’을 잡고 나면 다음은 I/O의 수명이 기다리고 있습니다.

관련 글

관련 상담 영역

고무라 소프트 합동회사에서는 카메라, 계측기, 시리얼 / USB 장비와 연동하는 Windows 앱의 프로세스 분리 설계, SDK 헬퍼의 잔류나 device busy 같은 장비 점유 문제의 조사, 장기 가동 앱의 감시와 자동 복구 구조 만들기를 다루고 있습니다. ‘부모를 다시 띄우면 장비를 열 수 없다’는 한 건에서부터라도 상담해 주십시오.

참고 링크

  1. Microsoft Learn, Job Objects. Job Object가 프로세스 그룹을 하나의 단위로 관리하는 커널 객체라는 것, 소속 프로세스가 만드는 자식 프로세스가 기본적으로 같은 Job에 연결된다는 것(Win32_Process.Create를 거친 것은 제외), breakaway용 두 제한 플래그, TerminateJobObject에 의한 일괄 종료, 중첩을 쓸 수 없는 환경에서 프로세스 트리를 관리하는 방법에 대해서.  2 3 4 5

  2. Microsoft Learn, AssignProcessToJobObject function (jobapi2.h). 프로세스와 Job의 연결을 해제할 수 없다는 것, Windows 7 이전은 한 프로세스에 하나의 잡이고 Windows 8부터 복수 소속(중첩)이 가능해졌다는 것, 중첩에서의 실효 제한과 breakaway의 전파에 대해서.  2

  3. Microsoft Learn, JOBOBJECT_BASIC_LIMIT_INFORMATION structure (winnt.h). JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE(마지막 Job 핸들이 닫혔을 때 모든 프로세스를 종료), ACTIVE_PROCESS(동시 활성 프로세스 수의 상한), JOB_MEMORY(잡 전체의 커밋 상한), DIE_ON_UNHANDLED_EXCEPTION, BREAKAWAY_OK / SILENT_BREAKAWAY_OK의 각 제한 플래그에 대해서.  2 3 4 5

  4. Microsoft Learn, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION structure (winnt.h). Job이 종료한 프로세스의 몫까지 포함해 총 프로세스 수, CPU 시간, 페이지 폴트 수 등의 회계 정보를 보유하고, QueryInformationJobObject로 취득할 수 있다는 것에 대해서.  2

  5. Microsoft Learn, Nested Jobs. 중첩 잡이 부모-자식 계층(자식 잡은 부모 잡 프로세스의 부분집합)을 만든다는 것, UI 제한을 설정한 잡은 중첩할 수 없다는 것, 실효 제한이 체인 위에서 가장 엄격한 값이 된다는 것, 알림이 부모 잡 체인의 모든 완료 포트로 보내진다는 것, 계층의 종료가 최하층부터 이루어진다는 것에 대해서.  2

  6. Microsoft Learn, Process Creation Flags. CREATE_SUSPENDED(초기 스레드를 정지 상태로 생성하고 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로 생성한 뒤 Job에 넣는 고전적 절차와, 그 경쟁 창을 닫는 방법에 대해서. 

  8. Microsoft Learn, UpdateProcThreadAttribute function (processthreadsapi.h). PROC_THREAD_ATTRIBUTE_JOB_LIST에 의해 생성되는 자식 프로세스에 지정한 순서로 Job 핸들을 할당할 수 있다는 것, 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를 써서 생성 시점부터 프로세스를 Job에 소속시키는 방법에 대해서. 

  10. Raymond Chen, Destroying all child processes (and grandchildren) when the parent exits (The Old New Thing). KILL_ON_JOB_CLOSE가 붙은 Job으로 부모의 소멸 시에 자손을 한꺼번에 종료시키는 구성과, Job 핸들을 상속시키지 않는 것의 중요성에 대해서. 

  11. Microsoft Learn, TerminateJobObject function (jobapi2.h). Job에 연결된 모든 프로세스를, TerminateProcess를 개별적으로 호출한 것과 마찬가지로 강제 종료시키는 것에 대해서. 

  12. Microsoft Learn, Job Objects - Managing Job Objects. Job 객체가 시그널 상태가 되는 것은 잡 시간 상한의 초과로 모든 프로세스가 종료했을 때라는 것, 마지막 핸들이 닫혔을 때의 Job 파기와, KILL_ON_JOB_CLOSE 지정 시에는 그 닫힘이 모든 소속 프로세스의 종료를 일으킨다는 것에 대해서. 

  13. Microsoft Learn, JOBOBJECT_ASSOCIATE_COMPLETION_PORT structure (winnt.h). JOB_OBJECT_MSG_NEW_PROCESS / EXIT_PROCESS / ABNORMAL_EXIT_PROCESS / ACTIVE_PROCESS_ZERO 등 완료 포트로 보내지는 메시지의 목록, 비정상 종료로 판정되는 종료 코드, PID를 반환하는 메시지에서는 프로세스 핸들을 보유하고 있지 않은 한 PID의 재사용을 부정할 수 없다는 것, 알림의 전달이 보장되지 않는다는 것에 대해서.  2 3

  14. Raymond Chen, How do I wait until all processes in a job have exited? (The Old New Thing). Job 핸들의 대기로는 ‘비었다’를 감지할 수 없고, 완료 포트의 JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO로 기다려야 한다는 것에 대해서. 

  15. Microsoft Learn, Job Objects - Job Limits and Notifications. 완료 포트의 연결은 Job이 비활성인 동안에 하는 것이 좋다는 것(연결 중에 상태가 바뀐 프로세스의 알림을 놓칠 가능성의 저감), JobObjectNotificationLimitInformation으로 설정한 상한을 제외하면 메시지 전달이 보장되지 않는다는 것, 알림 상한에서는 초과 후에도 프로세스가 계속 달린다는 것에 대해서.  2 3

  16. Raymond Chen, Removing the TerminateThread from code that waits for a job object to empty (The Old New Thing). 대기 스레드를 TerminateThread로 죽이는 오래된 패턴을, 완료 포트 기반의 대기로 다시 쓰는 방법에 대해서. 

  17. Microsoft Learn, JOBOBJECT_EXTENDED_LIMIT_INFORMATION structure (winnt.h). 프로세스 단위와 잡 단위의 메모리 상한 설정, 그리고 PeakProcessMemoryUsed / PeakJobMemoryUsed에 의한 피크 메모리 취득에 대해서.  2

  18. Microsoft Learn, IsProcessInJob function (jobapi.h). 프로세스가 지정한 Job(또는 어느 하나의 Job)에서 실행되고 있는지를 판정할 수 있다는 것에 대해서.  2 3

  19. Microsoft Learn, JOBOBJECT_CPU_RATE_CONTROL_INFORMATION structure (winnt.h). Job 단위로 CPU 레이트(사이클의 비율이나 가중치)를 제어할 수 있다는 것에 대해서. 

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

Job Object는 컨테이너나 샌드박스인가요?
아닙니다. Job Object는 프로세스 그룹에 '제한', '알림', '일괄 종료'를 붙이는 커널 객체입니다. 메모리, CPU, 프로세스 개수의 상한은 걸 수 있지만 네트워크 접근은 제한할 수 없고, 액세스 토큰(권한)도 바뀌지 않습니다. UI 제한이 있기는 하지만 그것만으로 보안 경계가 되지는 않습니다. 격리나 보안이 목적이라면 AppContainer나 컨테이너 같은 다른 구조와 조합해야 합니다. 이 글에서 다루는 것은 '프로세스 트리의 수명과 자원을 하나의 단위로 다룬다'는 용도입니다.
Process.Kill()로는 안 되나요?
Process.Kill()이 종료시키는 것은 그 프로세스 하나뿐이고 손자 프로세스까지는 닿지 않습니다. .NET Core 3.0 이후의 Kill(entireProcessTree: true)는 자손을 따라가며 종료시키지만, 그 시점의 부모-자식 관계로 프로세스 트리를 열거하는 방식이라 열거 도중에 태어난 프로세스나, 부모가 먼저 죽어 계보가 끊긴 프로세스를 놓칠 여지가 있습니다. 게다가 두 방법 모두 '부모 자신이 크래시했을 때'에는 호출되지 않습니다. 부모의 생사와 상관없이 자손을 회수하고 싶다면, 수명을 커널에 맡기는 Job Object와 KillOnJobClose가 확실합니다.
부모가 Job에 들어 있으면 자식도 자동으로 Job에 들어가나요?
기본적으로 들어갑니다. Job에 속한 프로세스가 CreateProcess로 만든 자식 프로세스는 자동으로 같은 Job에 속합니다. 예외는 breakaway입니다. Job에 JOB_OBJECT_LIMIT_BREAKAWAY_OK가 설정되어 있고 자식이 CREATE_BREAKAWAY_FROM_JOB 플래그와 함께 생성된 경우나, JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK가 설정된 경우 자식은 Job 밖에서 태어납니다. 또한 WMI의 Win32_Process.Create를 거쳐 만들어진 프로세스는 Job에 연결되지 않습니다.
한 번 넣은 프로세스를 Job에서 뺄 수 있나요?
뺄 수 없습니다. AssignProcessToJobObject에 의한 연결은 되돌릴 수 없고, 프로세스가 끝날 때까지 소속이 이어집니다. 따라서 설계상의 선택지는 '넣지 않는다', 'breakaway로 처음부터 밖에서 낳는다', '다른 Job을 중첩한다'의 세 가지이며 '나중에 뺀다'는 없습니다. 이 되돌릴 수 없음이야말로 생성 전에 Job을 준비해 두어야 하는 이유이기도 합니다.
KillOnJobClose는 부모의 크래시에도 동작하나요?
동작합니다. JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE는 '마지막 Job 핸들이 닫혔을 때' 산하를 종료시키는 구조라서, 부모가 정상 종료하든 처리되지 않은 예외로 죽든 작업 관리자에서 강제 종료되든, 커널이 프로세스 뒷정리로 핸들을 닫기 때문에 발동합니다. 다만 Job 핸들을 자식 프로세스에 상속시키면 부모가 죽어도 자식이 가진 핸들이 남기 때문에 '마지막 핸들'이 되지 않아 발동하지 않습니다. Job 핸들은 상속시키지 마십시오.
완료 포트의 알림은 반드시 도착하나요?
도착하지 않는 경우가 있습니다. 공식 문서는 JobObjectNotificationLimitInformation으로 설정한 상한의 알림을 제외하면 완료 포트로의 메시지 전달이 보장되지 않는다고 명시하고 있습니다. 알림이 오지 않았다는 것이 사건이 일어나지 않았다는 뜻은 아닙니다. 확실성이 필요한 감시에서는 QueryInformationJobObject를 통한 회계 정보 폴링을 함께 쓰고, 프로세스 핸들을 직접 보유해 생사를 확정하십시오.
.NET에 Job Object의 공식 API가 있나요?
없습니다. System.Diagnostics.Process에는 Job이라는 개념이 없고 BCL에도 래퍼가 없습니다. P/Invoke로 CreateJobObject / SetInformationJobObject / AssignProcessToJobObject를 호출하거나, 마이크로소프트의 소스 생성기 CsWin32로 시그니처를 생성해 얇은 래퍼를 쓰는 것이 실무적인 답입니다. Job 핸들을 SafeHandle로 감싸고 IDisposable의 Dispose에서 닫는 설계로 하면, '래퍼의 수명 = 자식 프로세스 트리의 수명'이라는 KillOnJobClose의 의미가 코드에 그대로 드러납니다.
Windows 7 장비 PC에서는 어떻게 설계해야 하나요?
Windows 7 이전에서는 한 프로세스가 하나의 Job에만 들어갈 수 있고 중첩도 되지 않습니다. 상대의 SDK가 스스로 Job을 쓰고 있으면 이쪽의 AssignProcessToJobObject는 실패합니다. JOB_OBJECT_LIMIT_BREAKAWAY_OK는 '이미 자기 Job에 들어와 있는 프로세스가 CREATE_BREAKAWAY_FROM_JOB 지정으로 자식을 Job 밖에 만드는 것'을 허용하는 플래그이지, 이미 들어온 뒤에 두 번째 Assign을 통과시키는 마법이 아닙니다. 즉 탈출로는 SDK 쪽이 생성 시점에 breakaway를 요구해 주는 경우에만 성립합니다. 그것을 기대할 수 없다면, 기동 전에 '내 Job은 하나뿐'이라는 전제로 설계를 바꾸는 수밖에 없습니다. 마이크로소프트 문서도 중첩을 쓸 수 없는 환경에서는 breakaway의 두 제한 플래그로 트리를 관리하는 방법을 제시하고 있습니다. 그렇더라도 지원이 끝난 OS이므로 가능하다면 이전이 먼저입니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기