멀티스레드 실무 베스트 프랙티스 C 언어 편 ── Win32 API의 방식으로 안전하게 작성하기

· 업데이트: · · Windows, 멀티스레드, C 언어, Win32 API, 업무 앱, 불량 조사, 설계

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

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

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

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

Go Komura (2026). 「멀티스레드 실무 베스트 프랙티스 C 언어 편 ── Win32 API의 방식으로 안전하게 작성하기」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/multithreading-best-practices-c/

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

「장치 제어용 상주 프로세스를 C로 작성하고 있다」「20년 된 C 앱에 스레드를 추가하게 되었다」「TerminateThread로 멈추고 있는데, 가끔 프로세스 전체가 굳는다」── C 언어 멀티스레드는 언어 차원의 지원이 가장 적은 세계입니다. 예외도 RAII도 템플릿도 없고, 동기화가 올바른지는 전적으로 API 선택과 호출 규율에 달려 있습니다.

이 글은 멀티스레드 실무 시리즈의 C 언어 편입니다. C×Win32 API로 작성하는 개발자를 대상으로, 멀티스레드 설계 원칙 ── 스레드를 남발하지 않는다, 공유 가변 상태를 줄인다, 락의 규율, 정지 방법을 먼저 설계한다 ── 을 Win32 도구로 풀어, 스레드 생성 방법(_beginthreadex), 동기화 객체 선택, TerminateThread를 쓰지 않는 정지 설계, DllMain의 제약까지 2026년 8월 시점의 1차 정보를 바탕으로 정리합니다. 이 글만으로도 읽을 수 있게 썼습니다. 같은 원칙을 다른 언어로 전개한 「.NET 편」「C++ 편」「Java 편」도 있습니다.

1. 먼저 결론

  • 스레드 생성은 CreateThread가 아니라 _beginthreadex입니다. CRT를 호출하는 스레드를 CreateThread로 만들면, CRT가 메모리 부족 시 프로세스를 종료시킬 수 있습니다.12
  • 프로세스 내부 락은 SRW 락이 기본값이며, 재귀 획득이 필요할 때만 CRITICAL_SECTION입니다. 프로세스 내부 상호 배제에 Mutex를 쓰는 것은 항상 커널 전이를 동반하는 「흔한 실수」입니다.3
  • 단일 변수 갱신은 Interlocked 계열 함수로 합니다. volatile은 원자성도 순서도 보장하지 않습니다. 대부분의 Interlocked 함수는 full memory barrier를 동반합니다.4
  • 대기는 조건 변수(SleepConditionVariableCS 계열)나 이벤트+대기 함수로 합니다. Sleep 폴링 루프는 CPU와 응답성을 모두 낭비합니다.5
  • TerminateThread는 사용하지 않습니다. 락·힙·DLL 상태를 깨는 위험한 함수이며, 코드 분석 경고 C6258의 대상입니다. 정지는 「정지 이벤트+WaitForMultipleObjects」의 협조적 정지로 설계합니다.67
  • 짧은 일의 병렬화는 직접 만든 스레드가 아니라 Windows 스레드 풀(CreateThreadpoolWork)에 맡깁니다. 풀 스레드를 ExitThread / TerminateThread로 끝내지 마십시오.89
  • DllMain에서는 스레드 생성·동기화·종료 대기를 하지 않습니다. 로더 락을 잡은 채로 호출되므로 데드락의 온상입니다.10
  • C11의 <threads.h>는 VS 2022 17.8 이후에서 사용할 수 있지만, <stdatomic.h>는 아직 experimental입니다. Windows 전용 코드라면 Win32 API 방식이 현실적입니다.11

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

2. 왜 멀티스레드는 어려운가 ── 경쟁 상태와 데드락

멀티스레드가 가져오는 문제는 언어를 가리지 않고 파고들면 두 종류입니다.

경쟁 상태(race condition)는 여러 스레드가 어떤 순서로 특정 코드에 도달하느냐에 따라 결과가 달라지는 버그입니다. 대표적인 예가 공유 카운터로, count++라는 하나의 식은 기계어 수준에서 「읽기 → 덧셈 → 다시 쓰기」의 3단계로 나뉩니다. 두 스레드가 이 3단계에 동시에 들어가면, 한쪽의 덧셈이 다른 쪽의 다시 쓰기에 덮여 사라집니다.4 실행할 때마다 결과가 달라지고, 어떤 결과가 될지는 예측할 수 없습니다.

스레드B공유 변수 count스레드A스레드B공유 변수 count스레드Acount = 10두 번 더했는데 count = 11스레드A의 덧셈이 사라졌다읽기(10)읽기(10)로컬에서 덧셈(11)로컬에서 덧셈(11)다시 쓰기(11)다시 쓰기(11)

그림 1: 공유 카운터에서 덧셈이 사라지는 전형적인 경쟁 상태. count++의 3단계 사이에 다른 스레드가 끼어들면, 나중에 다시 쓴 쪽이 덮어쓴다

데드락은 두 스레드가 서로 상대가 들고 있는 락을 기다리며 어느 쪽도 앞으로 나아가지 못하는 상태입니다. 스레드A가 락1을 든 채 락2를 기다리고, 스레드B가 락2를 든 채 락1을 기다린다 ── 이것만으로 둘 다 영원히 멈춥니다.

락2 해제 대기락1 해제 대기스레드A락1 보유 중스레드B락2 보유 중

그림 2: 데드락의 순환 대기. 대기 화살표가 고리를 만드는 순간, 고리 안의 모든 스레드가 영원히 정지한다

둘 다 타이밍에 의존합니다. 개발 머신에서는 수만 번에 한 번만 나오는 실행 순서 조합이, 코어 수도 타이밍도 다른 고객사 머신에서는 매일 일어납니다. 「디버거를 붙이면 재현되지 않는다」는 관측 자체가 타이밍을 바꾸기 때문이며, 경쟁 버그의 전형적인 행동입니다. 따라서 이 글의 원칙은 모두 「올바르게 동기화한다」보다 먼저 「동기화가 필요한 곳을 줄인다」는 방향을 향합니다.

2.1. C만의 전제 ── 언어는 아무것도 지켜 주지 않는다

이 원칙들은 C에서는 「지키게 하는 장치」가 언어에 없는 만큼, 규율로 명확히 적어 둘 필요가 있습니다.

첫째, 해제를 구조로 보장하는 것입니다. C++의 RAII에 해당하는 것이 없으므로, 락 해제나 핸들의 CloseHandle은 함수 출구를 하나로 모으는 goto cleanup 패턴이나, 획득과 해제를 짝으로 쓰는 코딩 규약으로 지킵니다. 이른 return을 추가했다가 락이 새는 것이 C의 전형적인 사고입니다.

둘째, 데이터 경쟁의 취급은 C++과 같다는 점입니다. 적절히 정렬된 32비트 변수의 단순한 읽기·쓰기는 Windows에서 원자적이지만, 그 이상 ── 64비트 변수(32비트 Windows), 복합 연산, 여러 변수의 정합성 ── 은 아무것도 보장되지 않습니다.12 「우연히 동작하는」 코드는 컴파일러나 최적화 수준이 바뀌면 깨집니다.

셋째, 소유권을 정하는 것입니다. 「이 버퍼는 어느 스레드가 쓰고, 언제부터 누구의 것이 되는가」를 함수 주석에 적어 두는 문화가, C 멀티스레드에서는 동기화 프리미티브 선택만큼 효과가 있습니다.

3. 스레드를 만드는 방법 ── _beginthreadex 한 가지

3.1. CreateThread가 안 되는 이유

Win32의 네이티브 API는 CreateThread이지만, CRT(C 런타임) 함수를 호출하는 스레드는 _beginthreadex로 만드는 것이 공식 지침입니다. _beginthreadex는 CRT가 스레드마다 쓰는 내부 데이터를 초기화한 뒤 스레드를 시작합니다. CreateThread로 만든 스레드가 CRT 함수를 호출하면, 메모리 부족 상황에서 CRT가 프로세스를 종료시킬 수 있습니다.12 printfmallocstrtok도 CRT이므로, 실무에서는 「C로 작성하는 스레드는 항상 _beginthreadex」입니다.

_beginthread(ex 없는 쪽)도 피합니다. 만든 스레드가 일찍 끝나면 반환된 핸들이 무효가 되어 있는(다른 스레드를 가리킬 수 있는) 함정이 있어, 동기화 API에 핸들을 넘길 수 있는 _beginthreadex 쪽이 안전합니다. _beginthreadex가 반환한 핸들은 호출 측이 CloseHandle로 닫습니다.13

#include <process.h>

static unsigned __stdcall WorkerMain(void* arg)
{
    WorkerContext* ctx = (WorkerContext*)arg;
    /* ... 5장·6장의 대기 루프 ... */
    return 0;
}

HANDLE hThread = (HANDLE)_beginthreadex(
    NULL, 0, WorkerMain, &ctx, 0, NULL);
if (hThread == NULL) { /* 실패 처리 */ }
/* ...정지 요청 후... */
WaitForSingleObject(hThread, INFINITE);  /* 합류 */
CloseHandle(hThread);

3.2. 짧은 일은 Windows 스레드 풀로

「작은 일을 대량으로 던지고 싶다」「단명 스레드를 여러 번 만들었다가 부수고 있다」면, 직접 만든 스레드가 아니라 Windows 스레드 풀(Vista 이후 스레드 풀 API)을 사용합니다. CreateThreadpoolWork로 만든 작업 객체를 SubmitThreadpoolWork로 제출하면, 풀의 워커 스레드가 콜백을 병렬로 실행합니다.8 스레드 수 관리는 OS에 맡길 수 있고, 스레드 생성·폐기 비용도 사라집니다. 「스레드를 직접 만들지 않는다」는 원칙의 C에서의 답이 이것입니다.

풀을 쓸 때의 규율도 공식 문서에 적혀 있습니다. 풀 스레드를 TerminateThread / ExitThread로 끝내지 말 것, 콜백 안에서 만든 상태(TLS·스레드 우선순위 등)는 돌아가기 전에 복원할 것, 대기 핸들은 풀이 다 쓸 때까지 살려 둘 것 등입니다.9 실무에서 하나 더, 풀이 제한하는 것은 워커 스레드 수뿐이며, SubmitThreadpoolWork로 제출된 미실행 콜백은 얼마든지 쌓입니다. 투입이 처리를 계속 앞서는 상주 구성에서는, 앱 쪽에 세마포어 같은 입장 제한이나 용량 있는 큐를 두고, 가득 찼을 때 투입 측이 기다리거나 거절하게 하십시오(과부하를 메모리로 옮겨 담지 않기 위한 backpressure이며, 5장의 큐 설계와 같은 원칙입니다).

4. 공유 가변 상태를 최소화한다 ── 분할·읽기 전용·전달

경쟁은 「여러 스레드」와 「공유된 가변 데이터」가 갖춰졌을 때만 일어납니다. 동기화 프리미티브 선택(다음 장)보다 먼저, 애초에 공유를 줄일 수 없는지 생각합니다. 수단은 세 계통입니다.

분할한다. 병렬 집계에서는 공유 카운터에 각 스레드가 쓰는 것이 아니라, 스레드마다의 로컬 변수(또는 스레드마다 할당한 버퍼)에 소계를 만들고, 끝날 때 한 번만 InterlockedAdd 등으로 합류시킵니다. 공유에 대한 쓰기가 「반복할 때마다」에서 「스레드마다 한 번」으로 줄어, 동기화 비용도 경쟁의 창도 자릿수가 다르게 작아집니다. 「이 버퍼는 어느 스레드의 것인가」라는 2.1의 소유권 명기가 그대로 분할의 설계도가 됩니다.

읽기 전용으로 만든다. 시작 때 만들어 두고 이후 고치지 않는 설정·테이블 종류는, 초기화가 끝난 뒤에는 몇 스레드가 읽어도 안전합니다. 「초기화는 모든 스레드를 시작하기 전에 끝낸다」거나, 지연 초기화가 필요하면 Win32의 원타임 초기화(InitOnceExecuteOnce)를 써서 「언제부터 읽기 전용이 되는가」의 경계를 코드로 분명히 합니다.3

전달한다. 스레드 사이 데이터 흐름은 공유 변수를 양쪽에서 만지는 것이 아니라, 프로듀서/컨슈머 큐로 모읍니다. C에서의 구현은 5장의 조건 변수(유한 순환 버퍼+SleepConditionVariableCS)가 그대로 공식 구현 예이며, 용량 상한이 있는 버퍼는 「생산이 소비를 앞지르면 생산 측이 기다린다」는 자연스러운 backpressure가 되기도 합니다.5

5. 동기화 객체 선택과 락의 규율

Win32 동기화 프리미티브는 종류가 많아, 선택을 잘못하면 성능도 올바름도 잃습니다. 공식 지침을 한 장으로 정리합니다.3

상호 배제동시 접근 수 제한사건 알림아니오(프로세스 내부)아니오아니오프로세스를 넘어동기화하는가?용도는?이름 있는 Mutex이름 있는 세마포어이름 있는 이벤트같은 스레드의재귀 획득이 필요한가?CRITICAL_SECTION이식성을 중시하는C++ 코드인가?std::mutex /std::shared_mutexSRW 락(기본 선택)

그림 3: Win32 동기화 프리미티브 선택. 첫 분기는 「프로세스를 넘는가」이며, 넘지 않는데 커널 객체(Mutex)를 고르지 않는 것이 요점입니다

프리미티브 범위 특징 쓰임새
SRW 락 프로세스 내부 빠름(보통 사용자 모드에서 끝남), 포인터 크기, 재귀 불가 신규 코드의 기본값. AcquireSRWLockShared로 읽기 공유도 가능
CRITICAL_SECTION 프로세스 내부 빠름(스핀 후 커널 대기), 재귀 가능 같은 스레드의 재귀 획득이 필요할 때
Mutex 프로세스 내부/프로세스 간 항상 커널 객체라 느림 프로세스를 넘는 상호 배제(이름 있음), WaitForMultipleObjects와 함께 쓸 때
세마포어 프로세스 내부/프로세스 간 커널 객체 자원 풀에 대한 동시 접근 수 제한
이벤트 프로세스 내부/프로세스 간 커널 객체 「무언가 일어났다」는 알림(데이터 보호에는 쓰지 않음)
Interlocked 함수 프로세스 내부(공유 메모리라면 프로세스 간도) 락이 필요 없는 원자 연산 카운터·플래그·포인터 교체4

표와 플로차트에 보충을 하나 답니다. 이벤트·세마포어·Mutex 같은 커널 객체는 이름 없이 만들면 프로세스 내부 동기화에도 그대로 쓸 수 있습니다(6장의 정지 이벤트가 바로 이름 없는 이벤트입니다). 커널 객체 = 프로세스 간 전용이 아닙니다. 또한 「이름 없음 = 반드시 프로세스 내부만」도 아닙니다. 핸들을 자식 프로세스에 상속하거나 DuplicateHandle로 다른 프로세스에 복제하면, 이름이 없어도 같은 커널 객체를 여러 프로세스에서 쓸 수 있습니다. 이름 붙이기는 프로세스 사이에서 같은 객체를 다시 열 수 있게 하는 대표적인 수단 중 하나라는 이해가 정확합니다. 그림 3의 분기는 「프로세스 내부 에는 커널 객체를 고르지 않는다」는 선택의 요점을 보여 준 것이며, 프로세스 내부 알림(이벤트)이나 동시 수 제한(세마포어)은 이름 없는 커널 객체가 여전히 정답입니다.

Interlocked 계열은 .NET 편의 Interlocked 클래스, C++ 편의 std::atomic에 해당합니다. InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange는 단일 변수 연산을 나눌 수 없게 수행하며, 대부분의 함수는 full memory barrier를 동반하므로 순서 보장도 얻습니다.4volatile을 붙였으니 괜찮다」는 원자성도 순서도 보장하지 않는다는 오해입니다(FAQ 참고). 또 하나의 전제가 정렬(alignment)입니다. Interlocked 함수의 대상 변수는 자연스러운 경계에 정렬되어 있어야 하며(32비트 값이면 4바이트 경계, 64비트 값이면 8바이트 경계), 정렬되지 않으면 동작은 예측할 수 없습니다.12 #pragma pack한 구조체나, 통신 포맷을 그대로 매핑한 버퍼 위 필드를 Interlocked 대상으로 삼지 마십시오. 카운터나 플래그는 보통 선언한(=컴파일러가 정렬한) 변수로 한정하십시오. 또한 InterlockedExchangePointer 등으로 포인터를 교체할 때는 고유한 주의가 있습니다. 나눌 수 없는 것은 교체 자체뿐이며, 교체한 뒤 옛 블록의 수명은 아무도 지켜 주지 않습니다. 읽는 쪽이 옛 포인터를 로드한 직후 쓰는 쪽이 교체하고 free하면, 이미 해제한 메모리에 접근하게 됩니다. 포인터 교체로 공유 데이터를 갱신하는 설계는 락·참조 카운트 같은 회수 프로토콜과 세트로만 성립합니다(망설이면 SRW 락으로 지키는 편이 무난합니다).

대기에는 조건 변수가 있습니다. InitializeConditionVariable로 만들고, 소비 측은 SleepConditionVariableCS(CRITICAL_SECTION과 짝)로 잠들며, 생산 측은 WakeConditionVariable로 깨웁니다 ── 유한 버퍼 프로듀서/컨슈머 큐의 공식 구현 예가 이 형태입니다.5 중요한 습관으로, 깨어나면 반드시 락 안에서 조건(큐가 비어 있지 않은지)을 다시 확인하고, 거짓이면 wait로 돌아가는 루프로 만드십시오. 조건 변수에는 알림 없이 깨어나는 spurious wakeup이 있고, 깨어났을 때는 다른 소비자가 요소를 먼저 가져간 뒤일 수도 있어, 「깨워졌다 = 조건 성립」이 아닙니다. .NET 편 4.3의 채널, C++ 편 4장의 BlockingQueue와 같은 구도를 C로 짜는 도구입니다. SRW 락과 짝을 이룰 때는 SleepConditionVariableSRW를 사용합니다.

5.1. 락의 규율 ── 무엇을 골라도 바뀌지 않는 세 원칙

프리미티브를 올바르게 골라도, 쓰는 규율이 없으면 경쟁은 막지 못합니다.

  • 「어느 데이터를 어느 락이 지키는가」를 1대1로 정한다. 지키고 싶은 가변 데이터 집합마다 락(SRW 락이나 CRITICAL_SECTION) 하나를 대응시키고, 그 데이터를 만지는 모든 곳에서 같은 락을 잡습니다. 헤더 주석에 「이 구조체는 g_lockFoo가 지킨다」고 적어 두는 운영이 C에서는 특히 효과가 있습니다.
  • 락을 든 채로 오래 걸리는 일·외부 일을 하지 않는다. 락 중에 해도 되는 것은 지키고 있는 데이터의 읽기·쓰기뿐입니다. 든 채로 하는 파일 I/O·네트워크·콜백 호출은 보유 시간을 늘릴 뿐 아니라, 호출된 쪽이 다른 락을 잡으려다 그림 2의 순환 대기를 만듭니다.
  • 여러 락의 획득 순서를 고정한다. 둘 이상의 락을 잡는 곳에서는 모든 스레드가 같은 순서(락 계층)로 잡도록 규칙화합니다. 순서 역전(lock order inversion)이 디버그하기 어려운 데드락을 만든다는 것과, 계층을 정해 일관되게 따라야 한다는 것은 DLL 베스트 프랙티스 문서가 분명히 적습니다.10

6. 정지 방법 설계 ── TerminateThread를 쓰지 않는다

6.1. TerminateThread가 깨뜨리는 것

TerminateThread는 대상 스레드에서 사용자 모드 코드를 전혀 실행시키지 않고 없앱니다. 공식 문서가 열거하는 결과는 심각합니다. 대상 스레드가 크리티컬 섹션을 들고 있으면 영원히 해제되지 않고, 힙 조작 중이면 힙 락이 잡힌 채로 남으며(이후 malloc하는 모든 스레드가 hang), DLL 전역 상태를 조작 중이면 DLL 상태가 깨집니다. 「가장 극단적인 경우에만 써야 하는 위험한 함수」가 공식 위치이며, 코드 분석도 경고 C6258로 검출합니다.67

「가끔 프로세스 전체가 굳는다」는 앱의 원인 조사에서 TerminateThread가 나오는 것은 실무에서 정말로 흔한 장면입니다. 찾았다면 그것은 수리 대상입니다.

6.2. 올바른 형태: 정지 이벤트 + WaitForMultipleObjects

C에서 협조적 정지의 정석은 수동 리셋 정지 이벤트를 하나 만들고, 각 워커 스레드가 「일의 신호」와 「정지의 신호」를 동시에 기다리는 형태입니다. 경고 C6258 문서 자체가 이 형태(이벤트를 만들고, 각 스레드가 WaitForSingleObject로 이벤트를 감시해 스스로 끝나는 방식)를 올바른 종료 방법으로 안내합니다.7

HANDLE hStopEvent;   /* CreateEvent(NULL, TRUE, FALSE, NULL): 수동 리셋 */
HANDLE hWorkEvent;   /* CreateEvent(NULL, FALSE, FALSE, NULL): 자동 리셋.
                        받으면 자동으로 비신호로 돌아간다(수동 리셋으로 하면
                        한 번 신호된 뒤에는 대기가 그냥 통과되어, 빈 큐를
                        계속 도는 busy loop가 된다) */

static unsigned __stdcall WorkerMain(void* arg)
{
    HANDLE waits[2] = { hStopEvent, hWorkEvent };
    for (;;) {
        DWORD r = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
        if (r == WAIT_FAILED) {          /* 핸들 무효 등. 방치하면 전력 공회전 */
            LogLastError();              /* GetLastError()를 기록하고 빠져나온다 */
            break;
        }
        if (r == WAIT_OBJECT_0)          /* 정지 요청 */
            break;
        if (r == WAIT_OBJECT_0 + 1) {    /* 일 있음 */
            /* ProcessNextItem에도 정지 이벤트를 넘긴다: 1건 내부에서 오래 기다리면,
               거기서도 정지를 관측하지 못하면 셧다운이 그 1건에 인질로 잡힌다 */
            while (ProcessNextItem(hStopEvent)) {  /* 큐에서 1건 처리. 비면 FALSE */
                /* 배출 중에도 정지 요청을 확인한다. 이것을 빼먹으면, 일이
                   계속 쌓이는 한 정지할 수 없다(정지 기아)      */
                if (WaitForSingleObject(hStopEvent, 0) == WAIT_OBJECT_0)
                    break;
            }
        }
    }
    Cleanup();                           /* 자신의 뒷정리는 스스로 하고 */
    return 0;                            /* 스스로 끝낸다 */
}

BOOL StopWorkers(HANDLE* threads, DWORD count)
{
    BOOL ok = TRUE;
    if (!SetEvent(hStopEvent)) {             /* 정지 요청이 닿지 않았다면, */
        LogLastError();                      /* 무한 합류에 들어가면 안 된다 */
        return FALSE;
    }
    /* 전원에게 한꺼번에 정지 요청이 섰다 */
    for (DWORD i = 0; i < count; i++) {
        if (WaitForSingleObject(threads[i], INFINITE) == WAIT_OBJECT_0) {
            CloseHandle(threads[i]);         /* 합류를 확인한 것만 닫는다 */
        } else {
            LogLastError();                  /* WAIT_FAILED: 핸들 무효 등 */
            ok = FALSE;                      /* 「전원 멈췄다」고는 보고하지 않는다 */
        }
    }
    return ok;   /* FALSE면 공유 자원 해제에 들어가면 안 된다 */
}

정지하는 쪽의 합류를 하나씩 WaitForSingleObject로 하는 데는 이유가 있습니다. WaitForMultipleObjects가 한 번에 기다릴 수 있는 핸들은 MAXIMUM_WAIT_OBJECTS(64개)까지이며, 이를 넘는 배열을 넘기면 대기 자체가 WAIT_FAILED로 실패해, 「전원 기다린 줄 알고 아무도 기다리지 않은」 채로 핸들을 닫게 됩니다. 전원의 종료를 그저 기다리기만 한다면, 상한 없는 하나씩 루프가 안전합니다.

SetEvent(hStopEvent)정지하는 쪽정지 이벤트(수동 리셋: 전원에게 보임)워커1:WaitForMultipleObjects로정지와 일을 동시에 대기워커2:WaitForMultipleObjects로정지와 일을 동시에 대기뒷정리하고 스스로 return뒷정리하고 스스로 return정지하는 쪽이 스레드 핸들을 기다려 합류여기서 비로소 「멈췄다」고 말할 수 있다

그림 4: 정지 이벤트 패턴. 정지용으로 수동 리셋 이벤트를 쓰면, 한 번의 SetEvent로 대기 중인 모든 워커가 한꺼번에 깨어난다. 끝나는 방식은 각 스레드가 스스로 정하고, 합류가 끝난 시점을 정지로 본다

요점은 세 가지입니다. 정지 이벤트는 수동 리셋으로 한다(한 번의 SetEvent로 모든 워커에게 보인다), 대기 배열의 맨 앞에 정지 이벤트를 둔다(동시에 시그널되면 정지가 우선된다), 그리고 정지하는 쪽은 반드시 스레드 핸들 합류를 기다린 뒤에 핸들을 닫는다.

적용 범위에 주의를 두 가지 답니다. 첫째, 이 「이벤트+전건 배출」 형태는 워커 1개 구성용입니다. 자동 리셋 이벤트는 몇 번 SetEvent해도 「시그널 상태가 하나 있다」는 것만 나타내며(연속 신호는 합쳐집니다), 워커가 여러 개면 하나만 깨어나 버스트를 직렬로 처리하게 됩니다. 여러 워커가 큐를 나누려면, 일의 신호는 세마포어로 바꾸고, 1건을 쌓을 때마다 ReleaseSemaphore(hSem, 1, NULL)로 카운트를 늘립니다. 세마포어 대기 성공은 카운트를 1 소비하므로, 「쌓인 건수만큼, 기다리는 워커가 하나씩 깨어난다」는 올바른 대응이 됩니다(이 용도는 그림 3 표의 「자원 풀 동시 접근 수 제한」과 같이 세마포어의 영역입니다). 다만 세마포어로 바꿀 때는 소비 측도 「대기 성공 1회 = 큐에서 1건만 처리」로 고치십시오. 위 샘플의 전건 배출 루프를 그대로 두면, 한 번 대기로 허가를 하나만 소비했는데 큐를 비워 버려, 남은 허가로 다른 워커가 빈 큐에 대해 깨어나거나 생산 측 ReleaseSemaphore가 상한 초과로 실패하는 식으로 셈이 무너집니다. 「허가 하나 = 일 1건」 대응을 지키는 것이 세마포어 방식의 전제입니다. 둘째, 1건 처리 자체에도 정지 경로를 통하게 하는 것입니다. ProcessNextItem 내부에서 긴 블로킹 대기를 한다면, 거기에도 정지 이벤트를 넘겨 겹쳐 기다리거나 유한 타임아웃을 붙입니다. 항목 사이 확인만으로는 「1건이 끝나지 않아 셧다운이 영원히 기다린다」는 구멍이 남습니다. .NET 편의 StopAsync, C++ 편의 jthread + join과 말하는 바는 같습니다.

블로킹 I/O(파이프·소켓·시리얼 포트)로 기다리는 스레드는 이벤트를 보러 오지 못하므로, I/O 쪽도 OVERLAPPED+이벤트의 겹침 대기로 만들거나, CancelIoEx로 I/O를 깨워 주는 설계가 필요합니다(시리얼 통신의 구체 예는 「시리얼 통신 앱의 함정」 참고).

7. DllMain과 로더 락 ── DLL을 쓸 때의 지뢰밭

C로 작성하는 공유 부품은 DLL이 되는 경우가 많고, 거기에는 로더 락이라는 고유 제약이 있습니다. DllMain은 OS 로더가 로더 락을 잡은 채로 호출하므로, 그 안에서 다음을 하면 데드락 또는 크래시의 원인이 됩니다.10

  • 다른 스레드와의 동기화(락 획득·스레드 종료 대기)
  • LoadLibrary / FreeLibrary 호출(직접·간접을 가리지 않음)
  • 스레드 생성(동기화가 따르면 위험)이나 ExitThread

「DLL 언로드 때 워커 스레드 종료를 DllMain에서 기다린다」는 옳아 보여도 전형적인 데드락입니다(끝나는 스레드가 DLL_THREAD_DETACH 전달을 위해 로더 락을 잡으려다 서로 기다립니다). 스레드를 가진 DLL은 MyLib_Init / MyLib_Shutdown 같은 명시적 초기화·종료 함수를 공개하고, 스레드 시작과 합류는 거기서 하는 설계로 하십시오. DllMain의 이상은 거의 빈 스텁입니다.10

8. C11 스레드라는 선택지 ── 현황 정리

「Win32에 의존하지 않는 이식 가능한 C로 쓰고 싶다」는 경우의 선택지가 C11의 <threads.h>(thrd_create / mtx_lock / cnd_wait)와 <stdatomic.h>입니다. MSVC 지원 현황은 공식 적합 표에 따르면, <threads.h>는 Visual Studio 2022 17.8에서 지원되며(/std:c11과 대응 SDK 필요), 반면 <stdatomic.h>는 experimental이라 /experimental:c11atomics 옵션이 필요한 단계입니다.11

Linux와 코드를 공유해야 한다면 C11 스레드(또는 pthread 래퍼)에 가치가 있지만, Windows 전용 코드베이스라면 이 글의 Win32 방식이 정보량·실적·디버그 용이성에서 유리합니다. 어느 쪽을 골라도, 여기까지의 설계 원칙(공유를 줄인다, 락과 데이터의 대응, 협조적 정지)은 바뀌지 않습니다.

9. 검증과 디버그 ── 「재현되지 않는다」는 전제로 대비한다

경쟁 버그는 테스트에서 나오기를 기대할 수 없습니다. 보통 테스트는 「우연히 경쟁하지 않은」 실행을 성공으로 세기 때문입니다. 대비는 3층으로 생각합니다.

첫 방어선은 설계입니다. 리뷰에서는 「공유 중인 가변 데이터는 무엇인가」「각각 어느 락이 지키는가(5.1의 대응표)」「락 획득 순서는 유일한가」「정지 이벤트는 모든 워커에게 닿는가」를 표로 확인합니다. 이 표를 쓸 수 없는 설계는, 동작하고 있어도 아직 완성되지 않았습니다.

둘째, 이상을 관측 가능하게 만듭니다. 대기에는 무조건 INFINITE가 아니라, 요지에 타임아웃을 붙여 시간 초과를 로그에 남기면, 영원한 hang을 검출 가능한 실패로 바꿀 수 있습니다. hang 현장에서는 덤프를 채취해 모든 스레드의 스택을 확인하고, 서로의 락 대기가 순환하지 않는지 봅니다. DLL 쪽 오류에는 Application Verifier 검사가 공식적으로 권장됩니다.10 덤프와 로그 정비는 「Windows 앱 크래시 때 로그와 덤프를 남기는 설계」에서 다룹니다.

셋째, 부하로 흔듭니다. 코어 수보다 많은 스레드로 오래 돌리기·처리 순서를 무작위화하기·인공 지연을 넣기와 같은 스트레스 테스트는, 개발 머신에서 경쟁의 「당첨」을 뽑기 쉽게 하는 현실적인 수단입니다. 최적화된 릴리스 빌드+고부하 재현 시험도 빼놓지 마십시오.

10. 정리 ── C 언어판 체크리스트

  1. 스레드 생성은 모두 _beginthreadex인가(CreateThread / _beginthread가 섞이지 않았는가)
  2. 스레드 핸들은 합류(WaitForSingleObject)한 뒤에 CloseHandle하는가
  3. 단명 일에 직접 만든 스레드를 남발하지 않는가(스레드 풀 API에 맡길 수 없는가)
  4. 프로세스 내부 상호 배제가 SRW 락/CRITICAL_SECTION인가(Mutex를 잘못 쓰지 않았는가)
  5. 공유 카운터·플래그는 volatile 의존이 아니라 Interlocked 계열인가
  6. Sleep 폴링이 남아 있지 않은가(조건 변수·이벤트 대기로 바꿨는가)
  7. TerminateThread(다른 스레드 강제 종료)가 한 곳도 없는가. 워커는 ExitThread 호출이 아니라 스레드 함수에서의 return으로 끝나는가(CRT 뒷정리가 _endthreadex를 통해 올바르게 돈다)
  8. 정지 이벤트+WaitForMultipleObjects의 정지 경로가 모든 워커에 있는가, 블로킹 I/O 중인 스레드도 깨울 수 있는가
  9. 락·핸들 해제가 모든 반환 경로에서 보장되는가(goto cleanup 규율)
  10. DllMain에서 스레드 생성·동기화·종료 대기를 하지 않는가

언어 지원이 없는 대신, C 멀티스레드는 API 선택과 규율이 그대로 품질이 됩니다. _beginthreadex·SRW 락·Interlocked·정지 이벤트 ── 이 네 가지를 기본값으로 두면, C에서도 「가끔 굳는다」에서 거리를 둔 설계를 할 수 있습니다.

관련 글

관련 상담 영역

합동회사 코무라소프트에서는 C로 만든 상주 프로세스·장치 제어 앱·DLL의 멀티스레드 설계 리뷰, TerminateThread나 락 누수에 기인하는 hang·크래시의 원인 조사(덤프 분석), 레거시 C 코드에 스레드를 추가하는 기술 상담을 다룹니다.

참고 링크

  1. Microsoft Learn, CreateThread function. CRT를 호출하는 실행 파일 안의 스레드는 CreateThread / ExitThread가 아니라 _beginthreadex / _endthreadex로 관리해야 한다는 것, CreateThread로 만든 스레드가 CRT를 호출하면 저메모리 상태에서 CRT가 프로세스를 종료시킬 수 있다는 것에 대해.  2

  2. Microsoft Learn, Multithreading with C and Win32. CRT 라이브러리를 호출하는 프로그램에서는 스레드를 _beginthread / _beginthreadex로 시작해야 하며 Win32의 CreateThread / ExitThread를 쓰지 말 것, _beginthread 계열이 CRT의 스레드별 변수를 초기화한다는 것, SuspendThread가 CRT 내부 데이터 구조에 접근 중인 스레드를 멈춰 데드락을 부를 수 있다는 것에 대해.  2

  3. Microsoft Learn, About Synchronization. Win32 동기화 프리미티브 선택 지침으로, SRW 락이 신규 코드의 기본값이며 포인터 크기이고 보통 사용자 모드에서 끝난다는 것, CRITICAL_SECTION은 재귀 획득이 필요할 때 쓴다는 것, Mutex는 항상 커널 객체이며 프로세스 간 이름 있는 동기화와 WaitForMultipleObjects와의 병용에 쓴다는 것, 프로세스 내부 동기화에 Mutex를 쓰는 것은 고빈도 연산에서 크게 느려지는 「흔한 실수」라는 것, 세마포어는 자원 풀의 동시 접근 수 제한에·이벤트는 알림에 쓰인다는 것에 대해.  2 3

  4. Microsoft Learn, Interlocked Variable Access. Interlocked 함수가 여러 스레드가 공유하는 변수 접근을 동기화하고 연산을 나눌 수 없게 수행한다는 것, InterlockedIncrement / Decrement가 읽기·덧셈·다시 쓰기를 하나의 원자 연산으로 묶으며 동기화 없이는 두 스레드의 동시 증가가 한 번분 사라질 수 있다는 것, InterlockedExchange / InterlockedCompareExchange 등의 함수군, 공유 메모리 위 변수라면 다른 프로세스의 스레드 사이에서도 쓸 수 있다는 것, 대부분의 Interlocked 함수가 full memory barrier를 제공하며 Acquire / Release 판으로 순서 의미를 고를 수 있다는 것에 대해.  2 3 4

  5. Microsoft Learn, Using Condition Variables. CRITICAL_SECTION으로 보호한 유한 순환 버퍼에 의한 프로듀서/컨슈머 큐의 구현 예, InitializeConditionVariable로 조건 변수를 만들고 컨슈머가 SleepConditionVariableCS로 대기하며 WakeConditionVariable로 상대를 깨우는 구조, 조건 변수가 Windows Vista 이후에서 지원된다는 것에 대해.  2 3

  6. Microsoft Learn, TerminateThread function. TerminateThread가 대상 스레드에서 사용자 모드 코드를 전혀 실행시키지 않고 종료시킨다는 것, 대상이 크리티컬 섹션을 들고 있으면 해제되지 않는다는 것, 힙에서 메모리를 할당하는 중이면 힙 락이 해제되지 않는다는 것, kernel32 상태나 DLL 전역 상태가 깨질 수 있다는 것, 「가장 극단적인 경우에만 써야 하는 위험한 함수」이며 대상 스레드가 실행할 수 있는 코드를 완전히 파악·제어하는 경우가 아니면 호출해서는 안 된다는 것에 대해.  2

  7. Microsoft Learn, Warning C6258. 코드 분석 경고 C6258이 TerminateThread 사용을 검출한다는 것, TerminateThread로는 적절한 스레드 정리를 할 수 없다는 것, 올바른 종료 방법으로 CreateEvent로 이벤트를 만들고 각 스레드가 WaitForSingleObject로 이벤트 상태를 감시하다 시그널 상태가 되면 스스로 실행을 끝내는 절차가 나와 있다는 것에 대해.  2 3

  8. Microsoft Learn, CreateThreadpoolWork function. CreateThreadpoolWork로 작업 객체를 만들고, SubmitThreadpoolWork를 호출할 때마다 풀의 워커 스레드가 콜백을 실행한다는 것, 콜백 환경(TP_CALLBACK_ENVIRON)으로 실행 환경을 지정할 수 있다는 것, Windows Vista 이후에서 사용할 수 있다는 것에 대해.  2

  9. Microsoft Learn, Thread Pools. 스레드 풀이 짧은 일을 대량으로 비동기 실행하는 앱이나 단명 스레드를 자주 만드는 앱에 맞다는 것, Vista에서 재설계된 새 스레드 풀 API의 구성 요소, 베스트 프랙티스로 풀 스레드를 TerminateThread로 끝내거나 콜백에서 ExitThread를 호출하지 말 것, 콜백에서 만든 상태를 돌아가기 전에 뒷정리할 것, 대기 핸들을 풀이 다 쓸 때까지 살려 둘 것에 대해.  2

  10. Microsoft Learn, Dynamic-Link Library Best Practices. DllMain이 로더 락을 잡은 채로 호출되므로 호출할 수 있는 API에 중대한 제약이 있다는 것, DllMain 안에서 다른 스레드와 동기화하면 데드락을 부른다는 것, LoadLibrary 호출이 금지라는 것, DLL 언로드 때 DllMain 안에서 스레드 종료를 기다리면 종료 스레드의 DLL_THREAD_DETACH 전달과 서로 기다려 데드락이 되는 구도, 이상적인 DllMain은 거의 빈 스텁이며 초기화는 가능한 한 늦춰야 한다는 것, 락 계층을 정해 로더 락을 최상위에 두어야 한다는 것에 대해.  2 3 4 5

  11. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. C 표준 라이브러리 기능 대응표에서, C11 스레드(threads.h)가 Visual Studio 2022 17.8에서 지원되었다는 것, stdatomic.h가 experimental 취급이라는 것(/experimental:c11atomics 옵션), C11 / C17 컴파일러 지원에는 Visual Studio 2019 16.8 이후와 대응하는 Windows SDK가 필요하다는 것에 대해.  2

  12. Microsoft Learn, Interlocked Variable Access. 적절히 정렬된 32비트 변수의 단순한 읽기·쓰기는 원자적이지만 접근의 동기화(순서)는 보장되지 않는다는 것, 64비트 변수의 단순한 읽기·쓰기는 64비트 Windows에서는 원자적이지만 32비트 Windows에서는 보장되지 않는다는 것, 그 밖의 크기 변수는 어떤 플랫폼에서도 원자성이 보장되지 않는다는 것에 대해.  2

  13. Microsoft Learn, _beginthread, _beginthreadex. _beginthreadex가 _beginthread보다 안전한 이유로, _beginthread로 만든 스레드가 일찍 끝나면 반환된 핸들이 무효가 되어 다른 스레드를 가리킬 수 있다는 점, _beginthreadex의 핸들은 호출 측이 CloseHandle로 닫아야 하며 유효성이 보장된다는 점, _beginthreadex라면 동기화 API에 핸들을 넘길 수 있다는 점, 스레드 함수가 __stdcall 규약으로 스레드 종료 코드를 반환한다는 점, 멀티스레드판 CRT에 링크해야 한다는 점에 대해. 

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

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

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

자주 묻는 질문

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

CreateThread와 _beginthreadex 중 어느 쪽을 사용해야 하나요?
C 런타임 라이브러리(CRT) 함수를 호출하는 스레드라면 _beginthreadex입니다. _beginthreadex는 CRT가 스레드마다 필요로 하는 내부 데이터를 초기화한 뒤 스레드를 시작합니다. CreateThread로 만든 스레드가 CRT 함수를 호출하면, CRT가 메모리 부족 시 프로세스를 종료시킬 수 있다고 공식 문서에 명시되어 있습니다. 실제로 C로 작성하는 앱의 스레드는 거의 확실히 어딘가에서 CRT 함수(printf, malloc, strtok 등)를 호출하므로, 「항상 _beginthreadex」로 기억해도 됩니다. 참고로 _beginthread(ex 없는 쪽)는 스레드가 일찍 끝나면 핸들이 무효가 되는 함정이 있어, 이쪽도 _beginthreadex를 고릅니다.
TerminateThread로 스레드를 멈춰도 되나요?
안 됩니다. TerminateThread는 대상 스레드에서 사용자 모드 코드를 전혀 실행시키지 않고 없애기 때문에, 그 스레드가 크리티컬 섹션을 들고 있으면 해제되지 않고, 힙에서 메모리를 할당하는 중이면 힙 락이 잡힌 채로 남으며, DLL 전역 상태를 조작 중이면 DLL 상태가 깨집니다. 공식 문서는 「가장 극단적인 경우에만 써야 하는 위험한 함수」라고 명시하며, 코드 분석에서도 경고 C6258로 검출됩니다. 올바른 정지는 정지용 이벤트를 만들고, 각 스레드가 WaitForSingleObject / WaitForMultipleObjects로 그 이벤트를 감시한 뒤 스스로 뒷정리를 하고 끝나는 협조적 정지입니다.
프로세스 내부 상호 배제에 Mutex를 쓰고 있었습니다. 무엇이 문제인가요?
동작은 하지만 성능을 크게 깎아 먹습니다. Win32 Mutex는 항상 커널 객체이므로, 획득·해제마다 커널 모드 전이가 발생합니다. 프로세스 내부 상호 배제라면 사용자 모드에서 끝나는(경합이 있을 때만 커널 대기로 떨어지는) SRW 락이나 CRITICAL_SECTION이 훨씬 빠르며, 공식 문서도 프로세스 내부 동기화에 Mutex를 쓰는 것을 「흔한 실수」라고 명시합니다. Mutex가 필요한 자리는 이름 있는 객체로 프로세스 간 상호 배제가 필요할 때와, WaitForMultipleObjects로 다른 커널 객체와 함께 기다리고 싶을 때입니다.
C11의 threads.h나 stdatomic.h는 Windows에서 사용할 수 있나요?
MSVC에서는 C11 스레드(threads.h)가 Visual Studio 2022 17.8에서 지원되었습니다(/std:c11과 대응하는 Windows SDK가 필요합니다). 반면 stdatomic.h는 experimental 취급이라 /experimental:c11atomics 옵션이 필요한 단계입니다(2026년 8월 시점의 공식 대응표 기준). 이식성을 최우선하는 선택지는 될 수 있지만, Windows 전용 코드베이스라면 실적과 정보량 면에서 Win32 API(_beginthreadex, SRW 락, 조건 변수, Interlocked)로 작성하는 편이 현실적입니다.
volatile을 붙이면 공유 플래그는 안전해지나요?
그렇지 않습니다. C의 volatile은 컴파일러 최적화(레지스터에 캐시하는 등)만 막을 뿐, 연산의 원자성도 프로세서 간 메모리 순서도 보장하지 않습니다. 적절히 정렬된 32비트 변수의 단순한 읽기·쓰기는 Windows에서 그 자체로 원자적이지만, 「읽고 더해서 다시 쓰는」 연산은 쪼개지며, 앞뒤 메모리 연산과의 순서도 규정되지 않습니다. 공유 카운터나 플래그 갱신에는 Interlocked 계열 함수를 사용하십시오. 대부분의 Interlocked 함수는 full memory barrier를 동반하므로 순서 보장도 함께 얻습니다. 여러 변수를 한꺼번에 지키려면 SRW 락 또는 CRITICAL_SECTION입니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기