Win32 스레드 풀 API ── CreateThreadpoolWork로 「스레드를 만들지 않는」 병렬 처리

· 업데이트: · · Windows, 멀티스레드, C++, Windows 개발, Win32 API, 성능 개선

수정 이력(1건, 최종 수정 2026년 09월 03일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

한국어 전면 재작성에 맞춰 본문 표현을 바로잡았습니다. 기술적인 주장은 일본어판과 같습니다.
최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.22176814)

아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.

Go Komura (2026). 「Win32 스레드 풀 API ── CreateThreadpoolWork로 「스레드를 만들지 않는」 병렬 처리」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/win32-thread-pool-api/

DOI(등록된 아카이브)
10.5281/zenodo.22176814
DOI(마지막 등록 버전)
10.5281/zenodo.22176815

「클라이언트마다 CreateThread」 「타이머를 위해 한 개」 「이벤트 대기를 위해 한 개」. 네이티브 Windows 코드에서는 작은 작업을 위해 스레드를 늘려 버리기 쉽습니다. 스레드는 한 개마다 스택과 커널 객체를 소비하고, 생성과 파괴에도 비용이 듭니다.

이 어긋남을 흡수해 주는 것이 OS 표준의 Win32 스레드 풀 API입니다. 앱은 실행하고 싶은 처리를 콜백으로 넘기고, 워커 스레드의 관리는 OS에 맡깁니다. 「스레드를 만들지 않는다」는 것은 스레드가 필요 없어진다는 뜻이 아니라, 앱이 작업할 때마다 직접 생성하고 파괴하지 않는다는 뜻입니다.1

이 글은 C/C++로 Windows 앱과 서비스, DLL을 쓰는 개발자를 대상으로, 선택 기준 → 객체 고르기 → work 구현 → 콜백과 종료 처리에서 주의할 점의 순서로 설명합니다. 대상은 Windows Vista에서 새로 설계된 CreateThreadpoolWork 계열 API입니다. 코드는 사용법의 뼈대를 보여 주는 발췌이며, 큐 같은 앱 고유의 부분은 생략했습니다.

1. 먼저 결론

수명이 짧은 작업을 대량으로 처리한다면 스레드가 아니라 작업을 관리합니다. 다만 작업을 언제 멈추고 언제 해제할 수 있는지는 앱 쪽에서 설계합니다.

판단의 축은 다음 세 가지입니다.

  • 도구를 고른다. 표준 C++나 .NET의 도구로 충분하다면 그쪽을 사용합니다. Win32에서 타이머와 대기, I/O 완료를 통합하고 싶거나 풀을 나누고 싶을 때가 이 API의 차례입니다.
  • 작업 단위로 넘긴다. 새 API의 work, timer, wait, io를 사용하고, 스레드 우선순위나 COM의 STA처럼 스레드 고유의 상태가 필요한 작업은 전용 스레드에 남깁니다.
  • 종료까지 한 묶음으로 만든다. 기본은 「투입을 멈춘다 → 완료를 기다린다 → 닫는다」입니다. 콜백에서는 오래 블로킹하지 않고, 같은 풀의 작업을 동기적으로 기다리지 않으며, 스레드 상태를 원래대로 되돌립니다.

먼저 움직여 보고 싶다면 4장의 work 예제부터 읽고, 5장의 콜백 규율을 확인하십시오. 여러 객체의 일괄 종료는 6장, DLL의 언로드는 7장에서 다룹니다.

2. 선택 기준 ── 풀, 전용 스레드, 표준 라이브러리

2.1 풀에 맞는 것은 수명이 짧고 양이 많으며 대기 중심인 작업

스레드 풀은 OS가 관리하는 워커 스레드의 모음입니다. 워커가 콜백을 차례로 실행하고, OS가 부하에 따라 개수를 조정합니다. 직접 스레드를 만들었다 부수는 처리를 맡김으로써 관리 코드와 생성, 파괴의 부담을 줄일 수 있습니다.1

공식 문서가 적용 대상으로 드는 것은, 작은 작업 항목을 대량으로 병렬 발행하는 앱, 수명이 짧은 스레드를 자주 생성하고 파괴하는 앱, 독립된 작업을 백그라운드에서 병렬 처리하는 앱, 커널 객체나 이벤트를 기다리는 전용 스레드를 가진 앱입니다. 검색이나 네트워크 I/O, 대기 전용 스레드의 정리가 전형적입니다.1

한편 스레드 우선순위 변경이 필요하다, COM의 STA가 필요하다, 프로세스가 살아 있는 동안 계속 동작한다 같은 작업은 전용 스레드로 둡니다. 판단 기준은 처리를 실행할 수만 있으면 되는지, 스레드 자체에 「개성」이 필요한지입니다.

전용 스레드와 풀의 선택 기준우선순위나 STA 같은 스레드의 개성이 필요한지, 오랫동안 계속 동작하는지를 먼저 확인하고, 둘 다 해당하지 않는 짧고 많으며 대기 중심인 작업만 스레드 풀에 올린다예아니오예아니오우선순위, STA 등 개성이 필요한가?전용 스레드로 둔다오랫동안 계속 동작하는가?스레드 풀에 올린다짧은 작업, 대기, 타이머, I/O 완료

그림 1: 풀에 올려도 되는 것은 「개성이 필요 없고 짧게 끝나는」 작업뿐. 그 밖에는 지금까지처럼 전용 스레드로.

2.2 표준 C++나 .NET으로 충분한지를 먼저 생각한다

C++의 std::async나 std::thread로 충분한 단위라면 표준 라이브러리가 첫 번째 후보입니다. 이식성이 있고, std::async와 future의 동작도 규격에 근거해 다룰 수 있습니다.2

Win32 스레드 풀을 직접 사용하는 이유는 timer, wait, io를 포함한 콜백 구조를 통합하고 싶다, 작업 종류마다 풀이나 스레드 수를 나누고 싶다, DLL이나 COM 컴포넌트에서 스레드를 직접 갖고 싶지 않다는 요구입니다. 단지 병렬화하고 싶은 것인지, Windows 고유의 제어가 필요한 것인지를 나누어 생각합니다.

어느 층의 도구로 쓸지에 대한 판단표준 C++의 async나 thread로 충분하면 그것을 사용하고, 타이머와 대기, I/O 완료의 통합이나 풀 분할, 스레드 수 제어가 필요한 경우, DLL이나 COM 컴포넌트에서 스레드를 직접 갖고 싶지 않은 경우에 Win32 스레드 풀을 직접 사용한다예아니오timer, wait, io의 통합풀 분할, 개수 제어DLL 안에서 스레드 직접 보유 회피표준 C++의 도구로 충분한가?std::async / std::thread무엇이 필요한가?Win32 스레드 풀

그림 2: 망설여지면 우선 표준 라이브러리, 그것으로 표현할 수 없는 요구가 나왔을 때가 이 API의 차례.

.NET에서는 ThreadPool이나 Task가 같은 역할을 맡고, I/O 완료는 IOCP와 이어져 있습니다. 관계에 대한 자세한 설명은 IOCP와 .NET 스레드 풀에 관한 글을 참조하십시오. 위층의 도구로 충분한 자리까지 Win32 API로 내려갈 필요는 없습니다.

2.3 새로 쓰는 코드에서는 Vista 이후의 새 API를 사용한다

Win32의 스레드 풀 API에는 두 세대가 있습니다. Windows 2000부터 이어진 QueueUserWorkItem이나 RegisterWaitForSingleObject 같은 예전 API와, Windows Vista에서 전면적으로 다시 설계된 CreateThreadpoolWork 계열의 새 API입니다.

새 API에서는 워커 스레드의 종류가 하나로 통합되었고, 단일 타이머 큐, 전용 상주 스레드, 프로세스 안의 독립된 여러 풀, 클린업 그룹이 마련되었습니다. 공식 문서도 새 API의 간결함과 신뢰성, 성능, 유연성을 장점으로 들고 있습니다.13

예전 API에는 큐에 넣은 작업을 취소할 방법이 없다는 구조적 제약도 있습니다. 새로 쓰는 코드에서는 새 API를 사용하고, 기존 코드를 다시 볼 때는 다음의 대응 관계를 출발점으로 삼습니다.3

예전 스레드 풀 API와 새 API의 대응예전 API의 QueueUserWorkItem은 새 API의 work 객체로, 타이머 큐는 timer로, 등록된 대기는 wait로, BindIoCompletionCallback은 io로 각각 대체된다QueueUserWorkItemwork타이머 큐timer등록된 대기waitBindIoCompletionCallbackio

그림 3: 예전 API에서의 이전 대상은 일대일로 정해져 있다. 기존 코드의 점검은 이 대응표에서 시작할 수 있다.

3. 구조 ── 발화 조건이 다른 네 가지 객체

3.1 무엇을 계기로 움직일지로 고른다

새 API의 중심은 다음 네 종류입니다. 「무엇을 처리할지」만이 아니라 무엇을 계기로 콜백을 실행하고 싶은지로 고릅니다.4

객체 생성 함수 콜백의 발화 조건
work CreateThreadpoolWork SubmitThreadpoolWork로 투입되었을 때
timer CreateThreadpoolTimer 지정한 시각이나 주기가 되었을 때
wait CreateThreadpoolWait 커널 객체가 신호 상태가 되었을 때
io CreateThreadpoolIo 연결한 핸들의 비동기 I/O가 완료되었을 때

발화 조건은 달라도 실행을 맡는 것은 같은 풀의 워커들입니다. 주기 처리, 이벤트 응답, I/O 완료 처리를 각각 전용 스레드로 쓰는 대신 공통의 콜백 구조로 맞출 수 있습니다.

발화 조건과 콜백 실행을 나누는 구조발화 조건을 가진 객체에서 실행 가능해진 콜백을 풀의 워커들이 실행하고, 처리를 마친 워커를 다음 콜백에도 쓰기 때문에 앱은 작업마다 스레드를 생성하지 않는다앱이 작업과 발화 조건을 지정객체가 조건을 기다린다콜백이 실행 가능해진다풀의 워커들이 실행처리를 마치고 워커를 반납다음 콜백에도 재사용

그림 4: 발화를 기다리는 구조와 처리를 실행하는 워커를 나눔으로써 작업마다의 스레드 관리를 줄인다.

3.2 「잠들어 기다리기만 하는 스레드」도 대체할 수 있다

풀의 효과는 CPU를 쓰는 작업에만 한정되지 않습니다. 타이머는 단일 타이머 큐로, 여러 핸들의 대기는 소수의 대기 스레드로 모아집니다.1

예를 들어 「이벤트가 신호 상태가 되면 움직인다」는 목적만으로 잠들어 있는 스레드가 5개 있다면, wait 객체 5개로 바꾸는 발상이 가능합니다. 각자 잠드는 스레드를 갖는 대신, 신호가 왔을 때의 처리를 콜백으로 실행합니다.

대기 전용 스레드를 wait 객체로 대체이벤트마다 하나씩 잠들어 기다리던 대기 전용 스레드는 wait 객체로 바꾸면 풀의 대기 스레드로 모아지고, 신호가 왔을 때만 콜백이 실행되는 형태가 된다대기 전용 스레드 5개가 각각 잠든다스택과 스레드를 5개분 소비wait 객체 5개풀의 대기 스레드로 모은다신호가 왔을 때만 콜백 실행

그림 5: 「잠들어 기다리기만 하는 스레드」는 wait 객체화로 없앨 수 있다. 이것이 풀 이전의 알기 쉬운 첫걸음이 된다.

4. 구현의 기본 ── work를 만들고 투입하고 안전하게 끝낸다

4.1 먼저 생성부터 종료까지 한 바퀴 돈다

가장 기본적인 work 객체로 사용법을 한 바퀴 돌아봅니다. CreateThreadpoolWork로 콜백과 context를 묶고, SubmitThreadpoolWork로 실행을 의뢰합니다. 세 번째 인자가 NULL이면 프로세스 기본 풀을 사용합니다. 많은 용도는 이 기본 풀로 충분합니다.56

아래는 절차를 보여 주는 발췌이며, 그대로 컴파일할 수 있는 완전한 프로그램이 아닙니다. WORK_QUEUE, ITEM, Enqueue, Dequeue, ProcessItem은 앱 쪽의 처리를 나타냅니다. 큐의 배타 제어, 투입 쪽의 정지, 실패 처리는 별도로 구현하고, work 생성에 실패한 경우에는 이어지는 투입과 대기, 해제로 나아가지 않도록 합니다.

VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE instance,
                           PVOID context, PTP_WORK work)
{
    // context는 생성 시점에 고정된다. 항목별 데이터는 동기화된 큐로 넘긴다
    WORK_QUEUE* queue = (WORK_QUEUE*)context;
    ITEM* item = Dequeue(queue);        // 배타 제어를 걸고 한 건 꺼낸다
    ProcessItem(item);
}

// 1) 생성 (콜백과 공유할 문맥 = 큐를 묶는다)
PTP_WORK work = CreateThreadpoolWork(WorkCallback, &queue, NULL);
if (!work) { /* GetLastError로 실패 처리 */ }

// 2) 한 건 쌓을 때마다 한 번 투입한다 (건수와 투입 횟수를 일치시킨다)
Enqueue(&queue, item);
SubmitThreadpoolWork(work);

// 3) 투입 쪽을 멈춘 다음 완료를 기다린다 (TRUE면 미실행분의 취소도 시도한다)
WaitForThreadpoolWorkCallbacks(work, FALSE);

// 4) 닫는다
CloseThreadpoolWork(work);
work 객체의 수명 주기CreateThreadpoolWork로 생성하고 SubmitThreadpoolWork로 투입하면 콜백이 병렬로 실행된다. 종료할 때는 먼저 새 투입을 멈추고 WaitForThreadpoolWorkCallbacks로 모든 콜백의 완료를 기다린 다음 CloseThreadpoolWork로 닫는다CreateThreadpoolWork로 생성SubmitThreadpoolWork로 투입 (여러 번 가능)콜백이 병렬로 실행된다새 투입을 멈춘다WaitForThreadpoolWorkCallbacks로 완료 대기CloseThreadpoolWork로 닫는다

그림 6: 종료의 순서는 「투입을 멈춘다 → 완료를 기다린다 → 닫는다」. 어느 하나라도 건너뛰면 해제 후 접근이나 경합이 된다.

4.2 work는 재사용할 수 있지만 context는 투입마다 바뀌지 않는다

같은 work 객체는 앞의 콜백이 끝나기 전이라도 여러 번 투입할 수 있습니다. 투입할 때마다 콜백이 실행되고, 여러 번 분량이 병렬로 움직입니다. 실제로 사용하는 스레드 수는 효율을 위해 풀이 조정하는 경우가 있습니다.7

여기서 구분하고 싶은 것이 work와 각 작업 항목의 데이터입니다. 콜백에 넘어가는 context는 생성 시점에 고정됩니다. 하나의 work로 서로 다른 데이터를 N건 처리한다면, 위의 예처럼 동기화된 큐를 context로 삼습니다. 데이터를 한 건 쌓을 때마다 한 번 투입하고, 콜백은 큐에서 한 건 꺼냅니다. 항목마다 work 객체를 만드는 설계여도 상관없습니다.5

고정된 context에서 항목별 데이터를 받는다work를 생성할 때 context를 공유 큐로 고정하고, 투입 쪽이 항목을 한 건 쌓을 때마다 한 번 Submit하며, 병렬로 움직이는 각 콜백이 배타 제어가 걸린 큐에서 한 건씩 꺼낸다work 생성 시 context를 고정배타 제어가 걸린 공유 큐항목을 한 건 쌓는다한 건마다 한 번 Submit각 콜백이 병렬로 움직인다큐에서 한 건씩 꺼낸다꺼낸 항목을 처리

그림 7: 고정되는 것은 큐를 가리키는 context이며, 항목별 데이터는 동기화된 큐로 넘긴다.

4.3 종료는 대기보다 먼저 투입을 멈춘다

안전한 종료의 순서는 「새 투입을 멈춘다 → WaitForThreadpoolWorkCallbacks로 완료를 기다린다 → CloseThreadpoolWork로 닫는다」입니다. 콜백이 참조하는 메모리도 완료를 확인하기 전에 해제해서는 안 됩니다. 실행 중이거나 실행 대기 중인 처리를 남긴 채 참조 대상을 해제하면 해제 후 접근으로 이어집니다.6

「완료 대기를 호출했으니 안전하다」고 생각하는 것만으로는 충분하지 않습니다. 다른 스레드가 아직 SubmitThreadpoolWork를 호출할 수 있으면, 대기가 끝난 뒤의 투입과 Close가 경합합니다. 대기를 종료 처리의 구분점으로 삼으려면 먼저 투입 쪽을 멈추는 것이 전제입니다.

WaitForThreadpoolWorkCallbacks의 두 번째 인자가 FALSE면 완료를 기다리고, TRUE면 아직 시작하지 않은 콜백의 취소도 지정합니다. 실행 중인 처리를 내버려 두어도 된다는 뜻은 아닙니다. 여러 객체를 한꺼번에 종료하는 방법은 6장에서 다룹니다.

5. 콜백의 규율 ── 빌린 스레드를 점유하거나 오염시키지 않는다

5.1 오래 걸린다면 선언하고 반환값까지 확인한다

풀은 콜백이 빠르게 반환된다는 전제로 스레드 수를 조정합니다. 아무것도 알리지 않은 채 긴 처리나 긴 대기를 계속하면 다른 콜백의 실행이 늦어집니다. 오래 걸릴 수 있는 경우에는 CallbackMayRunLong으로 그 가능성을 알리거나 전용 스레드로 나눕니다.8

다만 CallbackMayRunLong을 호출하는 것만으로는 충분하지 않습니다. 다른 콜백을 위한 워커를 마련할 수 없는 경우에는 FALSE가 반환됩니다. 반환값을 무시하고 계속 블로킹하는 것이 아니라, 그런 경우에는 처리를 나누거나 전용 스레드로 옮기는 등 풀을 막지 않는 쪽으로 기울입니다.8

오래 걸리는 콜백의 처리 방식을 정한다긴 처리나 대기가 필요한 작업은 전용 스레드로 나누거나 CallbackMayRunLong으로 풀에 알리고, 다른 워커를 마련하지 못해 FALSE가 반환된 경우에는 분할이나 전용 스레드로의 이동으로 블로킹을 피한다전용으로 한다풀에서 한다TRUEFALSE긴 처리, 대기가 필요어디에서 처리할까전용 스레드로 나눈다CallbackMayRunLong으로 통지다른 워커를 확보했는가오래 걸리는 처리를 한다분할하거나 전용으로 옮긴다

그림 8: 오래 걸리는 처리의 통지는 반환값을 확인해 다음 행동을 정하는 데까지가 한 묶음이 된다.

5.2 같은 풀의 작업을 워커 안에서 동기적으로 기다리지 않는다

콜백 A 안에서 같은 풀에 작업 B를 투입하고 그 완료를 WaitForThreadpoolWorkCallbacks 등으로 기다리는 설계에는 주의가 필요합니다. 모든 워커가 「다른 워커의 작업 대기」가 되면 B를 실행할 빈 워커가 없어져 풀 고갈형 교착 상태가 됩니다.

대처는 기다리기 위한 워커를 확보하는 것이 아니라, B의 완료 콜백에서 다음 작업을 투입하는 연속 형태로 바꾸는 것입니다. 의존 관계를 워커를 막는 대기로 쓰지 않도록 합니다.

풀 고갈 교착 상태의 구조모든 워커 스레드가 같은 풀에 투입한 다른 작업의 완료를 동기적으로 기다리면, 그 작업을 실행할 수 있는 빈 워커가 존재하지 않아 모두가 영원히 계속 기다리는 교착 상태가 된다워커 1: 작업 X의 완료를 대기실행 대기 중인 작업 X, Y워커 2: 작업 Y의 완료를 대기실행할 수 있는 빈 워커가 없다모두가 영원히 기다린다 (고갈 교착 상태)

그림 9: 워커 안에서 워커를 동기적으로 기다리면 기다림을 받는 작업을 실행할 사람이 없어진다.

5.3 스레드 상태를 원래대로 되돌리고 나서 반환한다

워커 스레드는 다음의 무관한 콜백에도 사용됩니다. 우선순위를 바꾼 채로 두거나 COM의 초기화 상태를 남기거나 TLS에 값을 남기거나 잠금 해제를 잊는 처리는 다음 작업까지 영향을 끌고 갑니다. 풀에 투입하는 함수와 그 함수에서 호출하는 처리까지 포함해, 전용 스레드에서 움직인다는 가정을 두지 않는 것이 필요합니다.9

뒷정리를 콜백의 종료에 연동시키는 API도 있습니다. 예를 들어 LeaveCriticalSectionWhenCallbackReturns는 콜백이 반환된 뒤의 임계 구역 해제를 풀에 의뢰합니다.4

스레드 상태를 다음 콜백으로 넘기지 않는다같은 워커를 다른 콜백이 재사용하기 때문에 우선순위와 COM, TLS, 잠금 같은 상태를 남기고 반환하면 다음 작업을 오염시키며, 뒷정리를 해서 상태를 원래대로 되돌리면 그 넘김을 막을 수 있다아니오예콜백 A가 워커를 빌린다반환 전에 뒷정리를 했는가상태를 남긴 채 재사용무관한 B에 영향을 준다원래 상태로 워커를 반납다음 콜백 B가 사용

그림 10: 스레드는 빌린 것이므로 처리 결과뿐 아니라 돌려줄 때의 스레드 상태에도 책임을 진다.

5.4 처리되지 않은 예외를 워커 밖으로 새어 나가게 하지 않는다

워커 스레드 위의 처리되지 않은 예외는 프로세스 전체를 말려들게 할 수 있습니다. 전용 스레드의 스레드 함수와 마찬가지로, 콜백의 입구에서 예외를 잡고 로그를 남기는 방침을 적용합니다. 풀에 맡긴다고 해서 작업 안에서 일어난 실패의 처리까지 필요 없어지는 것은 아닙니다.

6. 구성을 나눈다 ── 커스텀 풀과 클린업 그룹

6.1 커스텀 풀은 작업의 종류를 격리하기 위해 쓴다

기본 풀로 부족해졌을 때는 CreateThreadpool로 독립된 풀을 만들 수 있습니다. SetThreadpoolThreadMaximum과 SetThreadpoolThreadMinimum으로 워커 스레드 수의 상한과 하한을 설정합니다.10

전형적인 목적은 격리입니다. 「느려도 되는 배치 처리」가 「즉시 응답해야 하는 작업」의 워커를 다 써 버리지 않도록 풀을 나누고, 각각에 스레드 수의 예산을 줍니다. 단순히 스레드를 늘리는 이야기가 아니라 어떤 작업이 어떤 워커를 쓰는지를 나누는 이야기입니다.

6.2 콜백 환경으로 실행 위치와 뒷정리를 묶는다

어느 풀에서 실행할지를 지정하는 것이 TP_CALLBACK_ENVIRON입니다. 이 콜백 환경을 초기화하고 SetThreadpoolCallbackPool로 풀을 지정한 다음 CreateThreadpoolWork 등에 넘깁니다. 4장의 예에서 NULL이었던 세 번째 인자가 여기서는 환경을 넘기는 자리가 됩니다.5

같은 환경을 통해 클린업 그룹도 묶을 수 있습니다. 커스텀 풀은 실행 위치, 클린업 그룹은 뒷정리의 단위입니다. 이 두 역할을 나누어 보면 구성을 따라가기 쉬워집니다.

콜백 환경에 의한 구성의 연결콜백 환경이 커스텀 풀과 클린업 그룹을 가리키고, 그 환경을 넘겨 생성한 work나 timer는 그 풀에서 실행되며, 클린업 그룹의 일괄 처리로 완료 대기와 해제를 한꺼번에 할 수 있다콜백 환경 (TP_CALLBACK_ENVIRON)커스텀 풀 (스레드 수를 제어)클린업 그룹work / timer / wait / io의 생성에 넘긴다일괄로 완료 대기와 해제

그림 11: 콜백 환경은 「어느 풀에서 움직이고 누가 뒷정리하는지」를 객체 생성 시점에 주입하는 장치.

6.3 여러 객체의 완료 대기와 해제를 한데 모은다

모듈 안에 work나 timer 등이 늘어나면 종료 처리는 「각각을 기다리고 각각을 닫는다」의 나열이 됩니다. CreateThreadpoolCleanupGroup으로 그룹을 만들고 콜백 환경을 통해 생성하는 객체를 소속시켜 두면, CloseThreadpoolCleanupGroupMembers 한 번으로 소속 객체 전체의 완료 대기와 해제를 한데 모을 수 있습니다.46

여기서도 노림수는 실행 중인 콜백을 내버려 두지 않는 것입니다. 4장의 work를 개별적으로 종료하는 형태와 그룹 단위로 종료하는 형태를, 관리하는 객체의 수에 따라 나누어 사용합니다.

7. DLL에서 사용할 때 ── 콜백보다 먼저 언로드하지 않는다

7.1 완료 대기는 DllMain이 아니라 명시적인 종료 함수에서 한다

DLL에서 가장 위험한 것은 콜백의 코드가 아직 실행될 수 있는데 그 DLL이 언로드되는 것입니다. 언로드된 코드가 실행되면 접근 위반이 됩니다.

기본은 DLL의 명시적인 종료 함수에서 투입을 멈추고, 콜백의 완료를 기다리고, 객체를 닫고 나서 언로드하는 것입니다. 개별 대기 함수를 쓰든 6장의 클린업 그룹을 쓰든, 이 완료 확인을 생략해서는 안 됩니다.6

이 대기를 DllMain 안에서 해서는 안 됩니다. 로더 락과의 관계로 또 다른 교착 상태를 일으키기 때문입니다. 자세한 내용은 DllMain과 로더 락에 관한 글에서 설명하고 있습니다.

7.2 FreeLibraryWhenCallbackReturns는 투입 전의 참조 확보와 짝을 이룬다

「이 콜백이 마지막 작업이므로 반환된 뒤에 DLL의 참조를 놓고 싶다」는 상황에는 FreeLibraryWhenCallbackReturns가 있습니다.4

다만 이 API만으로는 콜백이 시작하기 전의 언로드를 막을 수 없습니다. 이 API가 하는 일은 실행 중인 콜백이 반환될 때 모듈 참조를 하나 놓는 것입니다. 투입 전에 GetModuleHandleEx로 그 처리용 모듈 참조를 확보하고, 콜백에서 이 API로 놓는 짝으로 사용합니다.

DLL을 종료 함수에서 닫는 경우와 콜백에서 참조를 돌려주는 경우기본은 DllMain이 아닌 종료 함수에서 투입을 멈추고 완료 대기와 해제를 한 다음 DLL을 언로드하고, 마지막 콜백에서 참조를 돌려주는 설계에서는 투입 전에 GetModuleHandleEx로 참조를 확보해 FreeLibraryWhenCallbackReturns에 의한 종료 후의 해제와 짝을 이룬다명시적인 종료 함수투입 정지, 완료 대기, 해제그 뒤에 DLL을 언로드DllMain에서는 기다리지 않는다투입 전에 모듈 참조를 확보콜백을 투입콜백 안에서 해제를 예약FreeLibraryWhenCallbackReturns반환된 뒤에 참조를 하나 해제

그림 12: 종료 함수에서의 완료 대기와 콜백용으로 확보한 참조의 해제를 혼동하지 말고, 어느 쪽이든 DLL의 수명을 먼저 설계한다.

8. 정리 ── 먼저 work부터, 종료 처리와 함께 바꾼다

Win32 스레드 풀은 네이티브 코드의 병렬 처리를 「스레드를 만든다」에서 「작업을 콜백으로 넘긴다」로 바꾸는 토대입니다. 수명이 짧은 작업이나 대기 전용 스레드를 정리하고, 스레드 관리를 OS에 맡길 수 있습니다.

도입은 단계적이어도 괜찮습니다. 먼저 work로 생성, 투입, 투입 정지, 완료 대기, 해제까지를 한 묶음으로 만든다. 다음으로 대기 전용 스레드를 wait로, 타이머 전용 스레드를 timer로 바꾼다. 필요해진 지점에서 io의 통합이나 풀 분할을 검토합니다.

기존 코드를 단계적으로 스레드 풀로 옮긴다기존에 직접 만든 스레드를 다시 살펴보고, 표준 라이브러리나 전용 스레드가 적합한 작업은 남긴 다음, 풀에 맞는 작업을 work의 투입 정지와 완료 대기를 포함해 옮기고, 대기는 wait로 타이머는 timer로 단계적으로 바꾼다아니오예기존에 직접 만든 스레드를 다시 본다풀에 맞는 작업인가표준 라이브러리, 전용을 고른다work와 종료 처리를 한 묶음으로 옮긴다대기는 wait, 타이머는 timer로필요에 따라 I/O 통합, 풀 분할

그림 13: 스레드를 줄이는 것뿐 아니라 각 단계에서 종료 처리까지 갖추는 것이 단계적 이전의 기본이 된다.

끝까지 지킬 것은 오래 블로킹하지 않는다, 같은 풀을 동기적으로 기다리지 않는다, 스레드의 상태를 더럽히지 않는다는 세 가지 규율입니다. DLL이라면 여기에 더해 언로드와의 경합을 막습니다. 표준 C++나 .NET의 도구로 충분한 곳은 그쪽에 맡기고, Windows 고유의 통합이나 제어가 필요한 곳에서 이 API를 사용하십시오.

관련 글

관련 상담 영역

합동회사 코무라소프트에서는 스레드가 증식한 네이티브 코드의 스레드 풀 이전 설계, C++ 앱과 DLL의 병렬 처리 설계 리뷰, 풀 고갈이나 콜백에서 비롯된 멈춤과 크래시의 원인 조사를 다루고 있습니다. 기존 코드의 점검부터도 상담하실 수 있습니다.

참고 링크

  1. Microsoft Learn, Thread Pools. 스레드 풀이 앱을 대신해 비동기 콜백을 효율적으로 실행하는 워커 스레드의 모음이라는 점, 적합한 앱의 유형 (작은 작업 항목의 대량 병렬 발행, 수명이 짧은 스레드의 잦은 생성과 파괴, 독립 작업의 병렬 처리, 커널 객체의 배타적 대기 등), Vista에서의 전면 재설계 (워커 스레드 종류의 통합, 단일 타이머 큐, 전용 상주 스레드, 클린업 그룹, 프로세스 내 여러 풀, 새 API)에 대하여. ↩ ↩2 ↩3 ↩4 ↩5

  2. Microsoft Learn, <future>. std::async와 future에 의한 태스크 단위의 비동기 실행이 표준 라이브러리로 제공되어 스레드를 직접 관리하지 않고도 병렬 처리를 쓸 수 있다는 점에 대하여. ↩

  3. Microsoft Learn, Thread Pooling. 예전 스레드 풀 API (QueueUserWorkItem, 타이머 큐, 등록된 대기, BindIoCompletionCallback)의 구조, 큐에 넣은 작업을 취소할 방법이 없다는 점, Vista에서 도입된 새 스레드 풀 API 쪽이 더 간단하고 신뢰성과 성능, 유연성에서 뛰어나다고 명기되어 있다는 점에 대하여. ↩ ↩2

  4. Microsoft Learn, threadpoolapiset.h header. CreateThreadpoolWork, CreateThreadpoolTimer, CreateThreadpoolWait, CreateThreadpoolIo의 네 가지 객체 생성 함수, 클린업 그룹 (CreateThreadpoolCleanupGroup), 콜백 완료와 연동하는 뒷정리 (LeaveCriticalSectionWhenCallbackReturns, FreeLibraryWhenCallbackReturns 등)를 포함한 함수 목록에 대하여. ↩ ↩2 ↩3 ↩4

  5. Microsoft Learn, CreateThreadpoolWork function (threadpoolapiset.h). 콜백 함수와 문맥 포인터로 work 객체를 생성한다는 점, 세 번째 인자인 TP_CALLBACK_ENVIRON으로 콜백의 실행 환경 (소속 풀 등)을 지정할 수 있고 NULL이면 기본 환경에서 실행된다는 점에 대하여. ↩ ↩2 ↩3

  6. Microsoft Learn, Using the Thread Pool Functions. CreateThreadpoolWork로 생성하고 SubmitThreadpoolWork로 투입하며, WaitForThreadpoolWorkCallbacks로 완료를 기다리고 CloseThreadpoolWork로 닫는 기본 절차, 커스텀 풀과 콜백 환경, 클린업 그룹을 조합한 구성 예에 대하여. ↩ ↩2 ↩3 ↩4

  7. Microsoft Learn, SubmitThreadpoolWork function (threadpoolapiset.h). 같은 work 객체를 앞선 콜백의 완료를 기다리지 않고 여러 번 투입할 수 있으며 콜백이 병렬로 실행된다는 점, 효율을 위해 풀이 스레드 수를 조정 (스로틀링)할 수 있다는 점에 대하여. ↩

  8. Microsoft Learn, CallbackMayRunLong function (threadpoolapiset.h). 현재의 콜백이 오래 실행될 가능성을 풀에 통지하고, 풀이 다른 콜백을 위한 스레드 확보를 판단하는 재료로 삼는다는 점, 오래 걸리는 콜백에는 가능하면 전용 스레드의 사용도 검토해야 한다는 점에 대하여. ↩ ↩2

  9. Microsoft Learn, Thread Pooling. 스레드 풀에 투입하는 작업 항목과 거기서 호출되는 함수는 스레드 풀 안전해야 하며, 실행 스레드가 전용 상주 스레드라고 가정해서는 안 된다는 점, TLS의 사용이나 상주 스레드를 필요로 하는 비동기 호출을 피해야 한다는 점에 대하여. ↩

  10. Microsoft Learn, SetThreadpoolThreadMaximum function (threadpoolapiset.h). CreateThreadpool로 생성한 풀에 대해 워커 스레드 수의 상한을 설정할 수 있다는 점 (하한은 SetThreadpoolThreadMinimum)에 대하여. ↩

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

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

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

자주 묻는 질문

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

CreateThread로 스레드를 직접 만드는 것과 비교해서 스레드 풀은 무엇이 좋습니까?
수명이 짧은 작업을 대량으로 처리하는 상황에서의 효율, 그리고 스레드 관리 코드의 감소입니다. 스레드의 생성과 파괴에는 무시할 수 없는 비용이 들기 때문에, 「작업마다 CreateThread를 하고 끝나면 파괴」를 반복하는 앱이나 이벤트를 기다리기 위해서만 잠들어 있는 스레드를 많이 안고 있는 앱은, 풀로 바꾸면 스레드 수와 컨텍스트 전환을 줄일 수 있습니다. 공식 문서도 작은 작업 항목을 대량으로 병렬 발행하는 앱, 수명이 짧은 스레드를 많이 만드는 앱, 커널 객체 대기 전용 스레드를 가진 앱 등을 풀의 적용 대상으로 들고 있습니다. 반대로 우선순위 변경이 필요하거나 COM의 STA가 필요하거나 오랫동안 계속 동작하는 전용 처리처럼 「스레드 자체에 개성이 필요한」 작업은 지금까지처럼 전용 스레드로 두어야 합니다.
QueueUserWorkItem 같은 예전부터 있던 스레드 풀 함수와는 무엇이 다릅니까?
Windows Vista에서 스레드 풀은 전면적으로 다시 설계되었고, 현재의 threadpoolapiset 계열 API(CreateThreadpoolWork 등)가 새 API, QueueUserWorkItem이나 RegisterWaitForSingleObject 등은 예전(레거시) API라는 관계입니다. 새 API는 워커 스레드의 종류를 하나로 통합했고, 프로세스 안에 독립된 여러 풀을 만들 수 있으며, 클린업 그룹을 통한 일괄 해제나 콜백 완료와 연동한 잠금 해제, DLL 언로드 같은 장치를 갖추고 있습니다. 공식 문서도 새 API 쪽이 더 간단하고 신뢰성과 성능, 유연성에서 뛰어나다고 밝히고 있습니다. 예전 API에는 「큐에 넣은 작업을 취소할 방법이 없다」 같은 구조적 제약도 있으므로, 새로 쓰는 코드에서는 새 API를 사용하십시오.
콜백 안에서 해서는 안 되는 일이 있습니까?
크게 세 가지입니다. 첫째, 아무것도 알리지 않은 채로 오래 블로킹하거나 긴 처리를 하는 것입니다. 풀은 콜백이 빠르게 끝난다는 전제로 스레드 수를 조정하므로, 오래 걸리는 처리는 CallbackMayRunLong으로 선언하거나 전용 스레드를 사용합니다. 둘째, 같은 풀에 넣은 다른 작업의 완료를 동기적으로 기다리는 것입니다. 워커가 전부 「다른 워커 대기」 상태가 되면 풀 고갈형 교착 상태가 발생합니다. 셋째, 스레드의 개성에 의존하는 것입니다. 워커 스레드는 콜백 사이에서 공유되므로, 스레드 우선순위나 COM 초기화 상태를 바꾼 채로 반환하거나 TLS에 상태를 남기는 행위는 다음 콜백에 대한 오염이 됩니다. 종료 시의 뒷정리(잠금 해제나 DLL 언로드)에는 LeaveCriticalSectionWhenCallbackReturns와 FreeLibraryWhenCallbackReturns라는 전용 장치가 마련되어 있습니다.
DLL 안에서 스레드 풀을 사용할 때 주의할 점이 있습니까?
가장 큰 위험은 「콜백이 아직 동작 중인데 DLL이 언로드되는」 것입니다. 언로드된 뒤에 콜백이 실행되면 접근 위반이 됩니다. DLL 쪽은 종료 처리에서 WaitForThreadpoolWorkCallbacks 같은 대기 함수(또는 클린업 그룹의 CloseThreadpoolCleanupGroupMembers)로 자신이 발행한 콜백의 완료를 확실히 기다린 다음 객체를 닫아야 합니다. 다만 DllMain 안에서 이 대기를 하면 로더 락과 얽혀 교착 상태가 될 수 있으므로, DllMain이 아니라 명시적인 종료 함수에서 하는 것이 원칙입니다. 콜백 자신이 「이 처리가 마지막」인 상황에서 DLL을 해제하고 싶을 때를 위해 FreeLibraryWhenCallbackReturns라는 전용 API가 마련되어 있습니다.
C++의 std::async나 .NET의 ThreadPool이 있는 지금, 이 API를 직접 사용할 자리가 있습니까?
있습니다. 판단 기준은 「그 층의 도구로 충분한가」입니다. C++에서 std::async나 std::thread로 충분한 단위의 병렬 처리라면, 이식성 측면에서도 표준 라이브러리가 첫 번째 후보입니다. 반면 타이머, 커널 객체 대기, 비동기 I/O 완료를 하나의 콜백 구조로 통합하고 싶다, 풀을 나누어 작업 종류마다 스레드 수를 제어하고 싶다, DLL이나 COM 컴포넌트 안에서 스레드를 직접 갖고 싶지 않다 같은 요구는 Win32 스레드 풀의 담당 범위입니다. .NET의 ThreadPool이나 IOCP와의 관계는 관련 글에서 다룬 그대로이며, 네이티브로 쓰는 이상 아래 층에 있는 이 구조를 알아 두는 것은 헛되지 않습니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기