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

· · Windows, 멀티스레드, C++, Windows 개발, Win32 API, 성능 개선

「클라이언트마다 CreateThread 하나」. 「타이머용으로 하나」. 「이벤트 대기용으로 하나」── Windows 네이티브 코드에서는 이렇게 스레드가 늘어나기 쉽습니다. 스레드마다 스택과 커널 객체를 소비하고, 만들고 파괴하는 비용도 있습니다. 작업은 잘게 나뉘어 있는데 스레드는 무겁다──그 어긋남을 흡수하려고 OS가 제공하는 것이 스레드 풀입니다.

.NET의 ThreadPoolTask.Run의 편리함은 잘 알려져 있지만, 사실 Win32 네이티브에도 잘 설계된 OS 표준 스레드 풀 API가 있습니다. Windows Vista에서 전면 개편된 이 API는 work, 타이머, 대기, 비동기 I/O를 통일된 콜백 장치로 다루는 네이티브 병렬 처리의 기반입니다. C/C++로 Windows 앱, 서비스, DLL을 쓰는 개발자를 대상으로, 이 API의 구조와 사용법, 빠지기 쉬운 함정을 1차 정보로 해설합니다.

1. 먼저 결론

  • 수명이 짧은 작업을 대량으로 발행하거나, 대기만 하는 스레드를 바꿀 때는, 자체 CreateThread보다 스레드 풀이 낫습니다. 스레드 관리는 OS에 맡기고, 스레드 수와 컨텍스트 스위치를 줄일 수 있습니다.1
  • 써야 하는 것은 새 API(CreateThreadpoolWork 계열)입니다. Vista 개편으로 예전 API(QueueUserWorkItem 계열)보다 단순하고 신뢰성 높고 고성능이 되었으며, 한 프로세스에 독립된 풀을 여러 개 만들 수도 있습니다.12
  • 객체는 네 종류입니다. 작업을 넣는 work, 시각이나 주기로 발화하는 timer, 커널 객체가 시그널되면 발화하는 wait, 비동기 I/O가 완료되면 발화하는 io. 모두 같은 콜백 장치를 탑니다.3
  • 종료는 「기다린 뒤 닫기」입니다. WaitForThreadpoolWorkCallbacks 계열로 완료를 기다리거나, 클린업 그룹으로 일괄 처리하는──돌고 있는 콜백을 남기지 않는 규율이 필요합니다.4
  • 콜백 안: 오래 블로킹하지 말 것(그럴 거면 CallbackMayRunLong); 같은 풀의 완료를 동기 대기하지 말 것; 스레드 상태를 더럽히지 말 것. 이 셋은 철칙입니다.56
  • DLL에서 쓸 때: 언로드 경합을 조심하십시오. 명시적 종료 함수에서 완료를 기다리고, FreeLibraryWhenCallbackReturns 같은 전용 API를 알아 두십시오.3

2. 왜 풀인가, 언제 풀인가

스레드 풀의 생각은 단순합니다. 작업마다 스레드를 만드는 대신, OS가 관리하는 워커 스레드 무리에 작업(콜백)을 던집니다. 워커는 작업을 차례로 실행하고, OS가 부하에 맞춰 수를 조절합니다.

공식 문서는 풀이 이득인 앱의 구체적 유형을 듭니다.1

  • 작은 작업 항목을 대량으로 병렬 발행하는 앱(검색, 네트워크 I/O 등)
  • 수명이 짧은 스레드를 자주 만들고 부수는
  • 독립된 작업을 백그라운드에서 병렬 처리하는
  • 커널 객체나 이벤트를 기다리기만 하는 전용 스레드를 들고 있는 앱

마지막은 놓치기 쉽습니다. 「이벤트가 시그널되면 돈다」고 잠자기만 하는 스레드가 다섯 개 있다면, 그건 풀의 wait 객체 다섯 개로 바꿀 수 있고, 대기는 풀의 waiter 스레드에 집약됩니다.

반대로 풀에 맞지 않는 작업도 있습니다. 스레드 우선도 변경이 필요하거나, COM STA가 필요하거나, 프로세스 수명 내내 계속 도는──스레드에 「성격」이 필요한 작업은 전용 스레드에 둡니다. 워커 스레드는 공유 자원이며, 빌리는 것입니다.

전용 스레드와 풀의 선택먼저 우선도나 STA 같은 스레드 성격이 필요한지, 오래 도는지 확인하고, 둘 다 해당하지 않는 짧은·대량·대기형 작업만 스레드 풀에 넣는다아니오아니오우선도·STA 같은 성격이 필요한가?전용 스레드에 둔다오래 도는가?스레드 풀에 넣는다짧은 작업, 대기, 타이머, I/O 완료

그림 1: 풀에 넣어도 되는 작업은 「성격이 필요 없고 짧게 끝나는」것뿐이다. 그 밖은 예전처럼 전용 스레드에 둔다.

역사도 하나 고정해 둡니다. 스레드 풀 API에는 두 세대가 있습니다. Windows 2000부터 이어진 예전 API(QueueUserWorkItem, RegisterWaitForSingleObject 등)와, Vista에서 전면 개편된 새 API(CreateThreadpoolWork 계열)입니다. 새 API는 워커 스레드의 종류를 통일하고, 전용 persistent 스레드, 한 프로세스의 다중 풀, 클린업 그룹 등을 제공하며, 공식 문서는 「더 단순하고, 더 신뢰성 있고, 더 성능이 좋고, 더 유연하다」고 명시합니다.1 예전 API에는 「한 번 큐에 넣은 작업을 취소할 수 없다」같은 구조적 제약도 있습니다.2 이후 이 글은 새 API만 다룹니다.

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

그림 2: 예전 API에서의 이전 대상은 1대1이다. 기존 코드의 재고는 이 대응에서 시작할 수 있다.

3. 네 객체 ── work, timer, wait, io

새 API의 중심은 콜백 발화 조건이 다른 네 종류의 객체입니다.3

객체 생성 함수 콜백이 발화하는 때
work CreateThreadpoolWork SubmitThreadpoolWork로 제출했을 때
timer CreateThreadpoolTimer 지정한 시각이나 주기가 왔을 때
wait CreateThreadpoolWait 커널 객체가 시그널이 되었을 때
io CreateThreadpoolIo 연결한 핸들의 비동기 I/O가 완료되었을 때
스레드 풀의 네 객체와 콜백 장치work는 명시적 제출, timer는 시각, wait는 커널 객체 시그널, io는 비동기 I/O 완료로 발화하며, 모두 같은 워커 스레드 무리에서 콜백으로 실행된다어느 객체?work(제출 시)timer, wait, io?timer(시각 / 주기)wait 또는 io?wait(시그널 시)io(I/O 완료)워커가 콜백을 실행

그림 3: 발화 조건은 다르지만, 넷 모두 「같은 풀의 워커가 콜백을 실행한다」는 장치로 통일되어 있다.

이 통일이 실무의 강점입니다. 주기 처리, 이벤트 응답, I/O 완료 처리를 각각 전용 스레드에 쓰지 않고, 하나의 콜백 스타일로 맞출 수 있습니다. 타이머는 풀 전체의 단일 타이머 큐에 모이고, 대기는 소수의 waiter 스레드에 집약됩니다── 「잠자기만 하는」스레드가 프로세스에서 사라집니다.1

대기 전용 스레드를 wait 객체로 바꾸기이벤트마다 하나씩 잠자던 대기 전용 스레드는 wait 객체가 되어 풀의 waiter 스레드에 집약되고, 시그널될 때만 콜백이 돈다각자 잠자는 대기 전용 스레드 5개스택 5개와 스레드 5개를 소비wait 객체 5개풀의 waiter 스레드에 집약시그널될 때만 콜백이 돈다

그림 4: 「잠자기만 하며 기다리는」스레드는 wait 객체로 바꾸면 없앨 수 있다. 풀 이전의 분명한 첫 수다.

4. 기본 패턴 ── work 객체 한 번의 왕복

가장 자주 쓰는 work 객체로 예절을 한 바퀴 돕니다.4

VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE instance,
                           PVOID context, PTP_WORK work)
{
    // 컨텍스트는 생성 시점에 고정된다. 항목별 데이터는 동기화된 큐로 넘긴다
    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 객체를 SubmitThreadpoolWork해도 됩니다. 제출마다 콜백이 (병렬로) 돕니다.7 다만 콜백에 넘어가는 컨텍스트는 생성 시점에 고정이므로, 한 work 객체로 「같은 종류의 작업 N개」를 쓸 때는 위 코드처럼 컨텍스트에 동기화된 큐를 두고 제출마다 항목 하나를 꺼냅니다(항목마다 work 객체를 만드는 설계도 됩니다). 둘째, 닫기 전에 반드시 완료를 기다립니다. 돌고 있거나 큐에 남은 콜백이 있는 채로 객체를 닫거나, 콜백이 참조하는 메모리를 해제하면 그대로 use-after-free입니다. 이 대기를 안전한 닫기로 만들려면 먼저 제출자를 멈추는 것이 전제입니다── 다른 스레드가 대기와 병렬로 아직 Submit할 수 있는 구조에서는, 대기 후의 제출이 Close와 경합합니다. WaitForThreadpoolWorkCallbacks의 두 번째 인자에 TRUE를 넘기면, 아직 시작하지 않은 제출의 취소도 시도합니다.

work 객체의 수명CreateThreadpoolWork로 만들고 SubmitThreadpoolWork로 제출하면 콜백이 병렬로 돈다. 종료 시에는 먼저 새 제출을 멈추고, WaitForThreadpoolWorkCallbacks로 모든 콜백 완료를 기다린 뒤 CloseThreadpoolWork로 닫는다CreateThreadpoolWork로 생성SubmitThreadpoolWork로 제출(반복 가능)콜백이 병렬로 돈다새 제출을 멈춘다WaitForThreadpoolWorkCallbacks로 완료 대기CloseThreadpoolWork로 닫기

그림 5: 종료 순서는 「제출 정지 → 완료 대기 → 닫기」이다. 어느 하나를 건너뛰면 use-after-free나 경합이 난다.

기본값으로 콜백은 프로세스 기본 풀에서 돕니다. 많은 용도에는 그것으로 충분합니다. 다음 장은 풀을 나누고 싶을 때입니다.

5. 사용자 정의 풀과 클린업 그룹

풀을 나눕니다. CreateThreadpool로 독립된 풀을 만들고, SetThreadpoolThreadMaximum / SetThreadpoolThreadMinimum으로 스레드 수의 상한·하한을 정할 수 있습니다.8 전형적인 용도는 격리입니다. 「느려도 되는 배치형 작업」이 「바로 응답해야 하는 작업」의 워커를 먹지 않도록, 풀을 나누고 각각에 스레드 예산을 줍니다.

콜백 환경으로 묶습니다. 작업이 어느 풀에서 도는지는 TP_CALLBACK_ENVIRON(콜백 환경)을 초기화하고, SetThreadpoolCallbackPool로 풀을 가리킨 뒤, 그것을 CreateThreadpoolWork 등의 세 번째 인자로 넘겨 지정합니다.9

클린업 그룹으로 접습니다. 객체를 많이 만드는 모듈에서는 종료 처리가 「전부 기다리고, 전부 닫기」의 나열이 되기 쉽습니다. CreateThreadpoolCleanupGroup로 그룹을 만들고 콜백 환경을 통해 각 객체를 붙이면, CloseThreadpoolCleanupGroupMembers 한 번으로 모든 멤버 객체의 완료 대기와 해제를 함께 합니다.34

콜백 환경을 통한 설정 연결콜백 환경은 사용자 정의 풀과 클린업 그룹을 가리키고, 그 환경으로 만든 work와 timer는 그 풀에서 돌며, 클린업 그룹에 대한 일괄 작업이 완료 대기와 해제를 모은다콜백 환경(TP_CALLBACK_ENVIRON)사용자 정의 풀(스레드 수 제어)클린업 그룹work / timer / wait / io 생성 시 전달한 번에 완료 대기와 해제

그림 6: 콜백 환경은 「어느 풀에서 도는지, 누가 정리하는지」를 객체 생성 시점에 주입하는 장치다.

6. 함정 ── 콜백 안의 규율

스레드 풀 버그의 거의 전부는 「빌린 스레드에서 제멋대로 하는」일에서 옵니다.

오래 블로킹하기. 풀은 콜백이 빨리 돌아온다고 가정하고 스레드 수를 조절합니다. 기본값에서 긴 작업이나 긴 대기를 하면 다른 콜백의 실행이 늦어집니다. 오래 돌 수 있는 콜백은 CallbackMayRunLong으로 「오래 돈다」고 선언하거나(풀은 스레드를 추가하는 힌트로 받습니다), 애초에 전용 스레드로 보냅니다. 주의할 점은, CallbackMayRunLong은 다른 콜백용 워커를 마련할 수 없을 때 FALSE를 반환한다는 것입니다. 반환값을 확인하지 않고 블로킹을 이어가면 풀은 여전히 막히므로, FALSE일 때는 블로킹하지 않는 쪽으로──작업을 나누거나 전용 스레드로 보내는 쪽으로──떨어집니다.5

같은 풀의 완료를 동기 대기하기. 콜백 A 안에서, 같은 풀에 제출한 작업 B의 완료를 WaitForThreadpoolWorkCallbacks 등으로 기다리는 형태는, 모든 워커가 「다른 워커를 기다리는」순간 풀 기아 교착이 됩니다. 작업 사이의 의존은 대기가 아니라 「B의 완료 콜백에서 다음을 제출한다」는 계속으로 다시 쓰십시오.

풀 기아 교착의 구조모든 워커 스레드가 같은 풀에 제출한 다른 작업의 완료를 동기 대기하면, 그 작업을 돌릴 빈 워커가 남지 않아 모두가 영원히 기다린다워커 1: 작업 X 완료를 대기실행 대기 중인 작업 X와 Y워커 2: 작업 Y 완료를 대기돌릴 빈 워커가 없다모두가 영원히 대기(기아 교착)

그림 7: 워커 안에서 워커를 동기 대기하면, 기다리던 작업을 돌릴 사람이 남지 않는다.

스레드 상태를 더럽히기. 워커 스레드는 다음 콜백에 재사용됩니다. 스레드 우선도 변경, COM 초기화 상태, TLS에 남긴 값, 해제하지 않은 락──어느 것이든 다음(무관한) 콜백의 오염이 됩니다. 「풀에 던지는 함수는 스레드의 성격에 의존해서는 안 된다」는 공식 주의는 예전 API 시대부터 있습니다.6 정리를 위한 전용 장치도 있습니다. 예를 들어 LeaveCriticalSectionWhenCallbackReturns는 「이 콜백이 돌아오면 이 락을 해제해 달라」고 풀에 맡길 수 있습니다.3

DLL 언로드와의 경합. 콜백이 도는 동안 그 코드를 담은 DLL이 언로드되면 액세스 위반이 납니다. 기본형은 DLL의 종료 함수에서 완료를 철저히 기다리는 것이고, 「이 콜백이 마지막 작업이며, 끝나면 나 자신을 포함해 DLL을 해제하고 싶다」는 상황에는 FreeLibraryWhenCallbackReturns가 있습니다. 다만 이 API는 「돌고 있는 콜백이 돌아올 때 참조 하나를 놓는다」일 뿐, 콜백이 시작되기 전의 언로드를 막지는 않습니다. 제출 전에 GetModuleHandleEx로 모듈 참조를 직접 잡고, 콜백이 이 API로 그 참조를 놓게 하는 한 세트로 씁니다.3 그리고 이 완료 대기를 DllMain 안에서 해서는 안 됩니다──「DllMain과 로더 락」에서 말한 대로, DllMain 안에서 다른 스레드를 기다리는 것은 교착 패턴입니다.

DLL 언로드와 콜백의 경합에 대비하기콜백이 도는 동안의 DLL 언로드는 액세스 위반이 되므로, 기본형은 명시적 종료 함수에서 완료를 기다린 뒤 닫는 것이고, 마지막 콜백 자신이 DLL을 해제할 때는 FreeLibraryWhenCallbackReturns를 쓴다콜백 중의 언로드액세스 위반기다린 뒤 닫기안전한 언로드종료 함수에서FreeLibraryWhenCallbackReturns마지막 콜백이 DLL을 해제DllMain 안이 아님

그림 8: 기본형은 명시적 종료 함수에서 「기다린 뒤 닫기」를 하는 것이다. DllMain 안에서의 대기는 다른 교착을 부른다.

제출한 작업 안의 예외와 크래시. 워커 스레드의 미처리 예외는 프로세스를 함께 가져갑니다. 전용 스레드의 스레드 함수와 같이, 콜백 입구에서 예외를 포괄적으로 잡아 기록하는 방침을 적용하십시오.

7. 표준 라이브러리·.NET과의 관계 ── 어느 계층에서 쓰는가

마지막으로 다른 도구와의 자리를 정리합니다.

  • C++ std::async / std::thread로 충분하면 그게 첫 후보입니다. 이식 가능하고 코드가 짧으며, future의 의미론도 표준이 정합니다.10
  • Win32 스레드 풀을 직접 쓰는 이유는 (1) timer, wait, io를 포함한 통일 콜백 장치가 필요할 때, (2) 풀 분할이나 스레드 수 제어가 필요할 때, (3) DLL이나 COM 컴포넌트 안에 자체 스레드를 두고 싶지 않을 때입니다.
  • .NET 쪽에서는 ThreadPoolTask가 같은 역할을 하고, I/O 완료는 IOCP에 묶입니다. 이 지하실 구조는 「IOCP와 .NET 스레드 풀」에서 설명합니다.
어느 계층의 도구로 쓸지 결정하기표준 C++ async나 thread로 충분하면 그것을 쓰고, 타이머·대기·I/O 완료의 통합, 풀 분할이나 스레드 수 제어, DLL이나 COM 안의 자체 스레드 회피가 필요할 때 Win32 스레드 풀을 직접 쓴다아니오timer, wait, io의 통합풀 분할 / 수 제어DLL 안의 자체 스레드 회피표준 C++ 도구로 충분한가?std::async / std::thread무엇이 필요한가?Win32 스레드 풀

그림 9: 망설이면 표준 라이브러리에서 시작하고, 이 API의 차례는 그것이 표현하지 못하는 요구가 나타났을 때다.

즉 이 API는 「네이티브로 쓰겠다」고 정한 지점의 병렬 처리 기반입니다. 현실적인 정리는 단계적입니다. 자체 CreateThread 난립의 이전 대상으로, 먼저 work 객체를 도입하고, 이어서 대기 전용 스레드는 wait로, 타이머 전용 스레드는 timer로 바꿉니다.

8. 정리

  • 수명이 짧은 작업을 대량 발행하거나, 대기 전용·타이머 전용 스레드를 정리할 때는 자체 스레드가 아니라 OS 표준 스레드 풀. 쓰는 것은 Vista 이후의 새 API입니다.
  • 중심은 work, timer, wait, io 네 객체입니다. 발화 조건은 다르고, 같은 워커 무리와 같은 콜백 스타일로 통일됩니다.
  • 예절은 「생성 → 제출 → 완료 대기 → 닫기」입니다. 여러 제출은 병렬로 돕니다. 클린업 그룹은 종료 처리를 일괄화할 수 있습니다.
  • 콜백의 세 철칙: 오래 블로킹하지 말 것(그럴 거면 CallbackMayRunLong); 같은 풀을 동기 대기하지 말 것; 스레드 상태를 더럽히지 말 것.
  • DLL에서 쓸 때: 언로드 경합을 조심하십시오. 명시적 종료 함수에서 완료를 기다리고, DllMain에서는 하지 않습니다.
  • 표준 C++이나 .NET으로 충분하면 그것을 쓰십시오. 이 API의 차례는 timer/wait/io의 통합이나 풀 제어가 필요할 때입니다.

스레드 풀 API는 Win32 API 중에서도 비교적 새롭고 잘 설계된 편입니다. 「스레드를 만든다」는 생각에서 「콜백을 던진다」는 생각으로 옮기고 나면, 네이티브 코드의 병렬 처리는 상당히 명확하게 쓸 수 있게 됩니다.

관련 기사

관련 상담 영역

합동회사 코무라소프트에서는 스레드가 난립한 네이티브 코드를 스레드 풀로 옮기는 이전 설계, C++ 앱과 DLL의 병렬 처리 설계 리뷰, 풀 기아나 콜백이 일으킨 행·크래시의 원인 조사를 다루고 있습니다. 기존 코드의 재고부터 상담해 주십시오.

참고 링크

  1. Microsoft Learn, Thread Pools. 스레드 풀이 앱을 대신해 비동기 콜백을 효율적으로 실행하는 워커 스레드의 집합이라는 점, 맞는 앱의 유형(작은 작업 항목의 대량 병렬 발행, 수명이 짧은 스레드의 빈번한 생성·파괴, 독립 작업의 병렬 처리, 커널 객체에 대한 전용 대기 등), Vista에서의 전면 개편(워커 스레드 종류의 통일, 단일 타이머 큐, 전용 persistent 스레드, 클린업 그룹, 한 프로세스의 다중 풀, 새 API)에 대해.  2 3 4 5

  2. Microsoft Learn, Thread Pooling. 예전 스레드 풀 API(QueueUserWorkItem, 타이머 큐, 등록된 대기, BindIoCompletionCallback)의 구조, 한 번 큐에 넣은 작업을 취소할 방법이 없다는 점, Vista에서 도입된 새 스레드 풀 API가 더 단순하고 신뢰성·성능·유연성에서 우수하다고 명시된 점에 대해.  2

  3. Microsoft Learn, threadpoolapiset.h header. CreateThreadpoolWork, CreateThreadpoolTimer, CreateThreadpoolWait, CreateThreadpoolIo의 네 객체 생성 함수, 클린업 그룹(CreateThreadpoolCleanupGroup), 콜백 완료에 묶인 정리(LeaveCriticalSectionWhenCallbackReturns, FreeLibraryWhenCallbackReturns 등)를 포함한 함수 목록에 대해.  2 3 4 5 6

  4. Microsoft Learn, Using the Thread Pool Functions. CreateThreadpoolWork로 생성, SubmitThreadpoolWork로 제출, WaitForThreadpoolWorkCallbacks로 완료 대기, CloseThreadpoolWork로 닫는 기본 절차, 사용자 정의 풀과 콜백 환경·클린업 그룹을 조합한 설정 예에 대해.  2 3

  5. Microsoft Learn, CallbackMayRunLong function (threadpoolapiset.h). 현재 콜백이 오래 돌 수 있음을 풀에 알려, 풀이 다른 콜백용 스레드를 확보할지 판단하는 재료로 쓴다는 점, 가능하면 오래 도는 콜백에는 전용 스레드를 검토하라는 점에 대해.  2

  6. Microsoft Learn, Thread Pooling. 스레드 풀에 제출한 작업 항목과 그것이 호출하는 함수가 스레드 풀 안전해야 한다는 점, 실행 스레드를 전용의 persistent 스레드로 가정해서는 안 된다는 점, TLS와 persistent 스레드가 필요한 비동기 호출을 피해야 한다는 점에 대해.  2

  7. Microsoft Learn, SubmitThreadpoolWork function (threadpoolapiset.h). 앞선 콜백의 완료를 기다리지 않고 같은 work 객체를 여러 번 제출할 수 있어 콜백이 병렬로 돈다는 점, 풀이 효율을 위해 스레드 수를 조절(throttle)할 수 있다는 점에 대해. 

  8. Microsoft Learn, SetThreadpoolThreadMaximum function (threadpoolapiset.h). CreateThreadpool로 만든 풀의 워커 스레드 수 상한을 정할 수 있다는 점(하한은 SetThreadpoolThreadMinimum)에 대해. 

  9. Microsoft Learn, CreateThreadpoolWork function (threadpoolapiset.h). 콜백 함수와 컨텍스트 포인터로 work 객체를 만든다는 점, 세 번째 인자 TP_CALLBACK_ENVIRON으로 콜백의 실행 환경(소속 풀 등)을 지정할 수 있고 NULL이면 기본 환경에서 돈다는 점에 대해. 

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

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

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

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

자주 묻는 질문

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

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을 해제하고 싶을 때는 전용 API인 FreeLibraryWhenCallbackReturns가 있습니다.
C++의 std::async와 .NET의 ThreadPool이 있는데, 이 API를 직접 쓸 일은 아직 있나요?
있습니다. 기준은 「그 계층의 도구로 충분한가」입니다. C++에서 필요한 병렬의 입도가 std::async나 std::thread로 커버되면, 이식성 면에서도 표준 라이브러리가 첫 후보입니다. 한편 타이머, 커널 객체 대기, 비동기 I/O 완료를 하나의 콜백 장치로 모으고 싶거나, 작업 종류마다 풀을 나누고 스레드 수를 제어하고 싶거나, DLL이나 COM 컴포넌트 안에 자체 스레드를 두고 싶지 않다면──그런 요구는 Win32 스레드 풀이 담당합니다. .NET의 ThreadPool과 IOCP의 관계는 관련 기사에서 다루며, 네이티브를 쓰는 한 그 아래 계층에 있는 이 장치를 알아 두는 일은 낭비되지 않습니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기