“장치 제어용 상주 프로세스를 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 함수는 완전한 메모리 배리어를 동반합니다.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
2. 왜 멀티스레드는 어려운가 ── 경쟁 상태와 데드락
멀티스레드가 가져오는 문제는 언어를 불문하고 파고들면 두 가지로 귀결됩니다.
경쟁 상태(race condition)는 여러 스레드가 특정 코드에 도달하는 순서에 따라 결과가 달라지는 버그입니다. 대표적인 예가 공유 카운터로, count++라는 하나의 식은 기계어 수준에서 “읽기 → 가산 → 다시쓰기”라는 3단계로 나뉩니다. 두 스레드가 이 3단계에 동시에 들어가면, 한쪽의 가산이 다른 쪽의 다시쓰기에 덮어써져 사라집니다.4 실행할 때마다 결과가 달라지며, 어떤 결과가 나올지는 예측할 수 없습니다.
sequenceDiagram
participant A as 스레드 A
participant M as 공유 변수 count
participant B as 스레드 B
Note over M: count = 10
A->>M: 읽기(10)
B->>M: 읽기(10)
A->>A: 로컬에서 가산(11)
B->>B: 로컬에서 가산(11)
A->>M: 다시쓰기(11)
B->>M: 다시쓰기(11)
Note over M: 두 번 가산했는데 count = 11<br/>스레드 A의 가산이 사라졌다
그림 1: 공유 카운터에서 가산이 사라지는 전형적인 경쟁 상태. count++의 3단계 사이에 다른 스레드가 끼어들면 나중에 다시 쓴 쪽이 덮어쓴다
데드락은 두 스레드가 서로 상대가 가진 잠금을 기다리며 어느 쪽도 앞으로 나아가지 못하는 상태입니다. 스레드 A가 잠금 1을 가진 채 잠금 2를 기다리고, 스레드 B가 잠금 2를 가진 채 잠금 1을 기다린다 ── 이것만으로 양쪽 모두 영원히 멈춥니다.
flowchart LR
A["스레드 A<br/>락1 보유 중"] -->|"락2 해제 대기"| B["스레드 B<br/>락2 보유 중"]
B -->|"락1 해제 대기"| A
그림 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 printf도 malloc도 strtok도 모두 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로 제출된 미실행 콜백은 얼마든지 쌓일 수 있습니다. 투입이 처리를 계속 앞지르는 상주형 구성에서는, 애플리케이션 쪽에 세마포어 등의 입장 제한이나 용량이 있는 큐를 두어, 가득 찼을 때 투입 쪽이 기다리거나 거부하는 형태로 만드십시오(과부하를 메모리로 옮기지 않기 위한 백프레셔로, 5장의 큐 설계와 같은 원칙입니다).
4. 공유 가변 상태를 최소화한다 ── 분할·읽기 전용·전달
경쟁은 “여러 스레드”와 “공유된 가변 데이터”가 갖추어졌을 때만 발생합니다. 동기화 프리미티브의 선택(다음 장)보다 먼저, 애초에 공유를 줄일 수 없는지를 생각합니다. 방법은 세 가지 계통입니다.
분할한다. 병렬 집계에서는 공유 카운터에 각 스레드가 직접 쓰는 대신, 스레드별 로컬 변수(또는 스레드별로 할당한 버퍼)에 소계를 만들고, 종료 시 한 번만 InterlockedAdd 등으로 합류시킵니다. 공유에 대한 쓰기가 “반복할 때마다”에서 “스레드당 한 번”으로 줄어, 동기화 비용도 경쟁의 창(window)도 자릿수가 다르게 작아집니다. “이 버퍼는 어느 스레드의 소유인가”라는 2.1의 소유권 명문화가 그대로 분할의 설계도가 됩니다.
읽기 전용으로 만든다. 시작 시 구성한 뒤 다시 쓰지 않는 설정·테이블류는, 초기화 완료 후에는 어느 스레드에서 읽어도 안전합니다. “초기화는 전체 스레드 시작 전에 끝낸다”거나, 지연 초기화가 필요하다면 Win32의 원타임 초기화(InitOnceExecuteOnce)를 사용해 “언제부터 읽기 전용이 되는가”의 경계를 코드로 명확히 합니다.3
전달한다. 스레드 간 데이터의 흐름은 공유 변수를 양쪽에서 건드리는 대신 프로듀서/컨슈머 큐로 몰아줍니다. C에서의 구현은 5장의 조건 변수(유한 순환 버퍼 + SleepConditionVariableCS)가 그대로 공식 구현 예이며, 용량 상한이 있는 버퍼는 “생산이 소비를 앞지르면 생산 쪽이 기다린다”는 자연스러운 백프레셔가 되기도 합니다.5
5. 동기화 객체 선택법과 잠금의 규율
Win32의 동기화 프리미티브는 종류가 많아, 선택을 잘못하면 성능도 정확성도 잃습니다. 공식 지침을 한 장으로 정리합니다.3
flowchart TB
S{"프로세스를 넘어<br/>동기화하는가?"} -->|"예"| Q2{"용도는?"}
Q2 -->|"배타"| MTX["이름 있는 Mutex"]
Q2 -->|"동시 접근 수 제한"| SEM["이름 있는 세마포어"]
Q2 -->|"이벤트 통지"| EVT["이름 있는 이벤트"]
S -->|"아니오 - 프로세스 내"| Q3{"같은 스레드의<br/>재귀 획득이 필요한가?"}
Q3 -->|"예"| CS["CRITICAL_SECTION"]
Q3 -->|"아니오"| Q4{"이식성을 중시하는<br/>C++ 코드인가?"}
Q4 -->|"예"| STD["std::mutex /<br/>std::shared_mutex"]
Q4 -->|"아니오"| SRW["SRW 잠금 - 기본 선택지"]
그림 3: Win32 동기화 프리미티브 선택법. 첫 번째 분기는 “프로세스를 넘나드는가”이며, 넘나들지 않는데 커널 객체(Mutex)를 고르지 않는 것이 핵심
| 프리미티브 | 범위 | 특징 | 쓰임새 |
|---|---|---|---|
| SRW 잠금 | 프로세스 내 | 고속(대개 사용자 모드에서 완결), 포인터 크기, 재귀 불가 | 신규 코드의 기본값. AcquireSRWLockShared로 읽기 공유도 가능 |
| CRITICAL_SECTION | 프로세스 내 | 고속(스핀 후 커널 대기), 재귀 가능 | 같은 스레드의 재귀 획득이 필요한 경우 |
| Mutex | 프로세스 내/프로세스 간 | 항상 커널 객체라 저속 | 프로세스를 넘나드는 배타 제어(이름 있음), WaitForMultipleObjects와의 병용 |
| 세마포어 | 프로세스 내/프로세스 간 | 커널 객체 | 자원 풀에 대한 동시 접근 수 제한 |
| 이벤트 | 프로세스 내/프로세스 간 | 커널 객체 | “무언가 일어났다”는 통지(데이터 보호에는 사용하지 않음) |
| Interlocked 함수 | 프로세스 내(공유 메모리라면 프로세스 간도 가능) | 잠금이 필요 없는 원자적 연산 | 카운터·플래그·포인터 교체4 |
표와 플로우차트에 보충 설명을 하나 덧붙입니다. 이벤트·세마포어·Mutex 같은 커널 객체는 이름 없이 만들면 프로세스 내 동기화에도 얼마든지 사용할 수 있습니다(6장의 정지 이벤트가 바로 이름 없는 이벤트입니다). 커널 객체 = 프로세스 간 전용이 아닙니다. 덧붙여 “이름 없음 = 반드시 프로세스 내로 한정”인 것도 아니어서, 핸들을 자식 프로세스에 상속시키거나 DuplicateHandle로 다른 프로세스에 복제하면, 이름이 없어도 같은 커널 객체를 여러 프로세스에서 사용할 수 있습니다. 이름 부여는 프로세스 간에 같은 객체를 다시 열 수 있게 하기 위한 대표적인 수단 중 하나라는 것이 정확한 이해입니다. 그림 3의 분기는 “프로세스 내 잠금에는 커널 객체를 고르지 않는다”는 선택의 요점을 보여준 것으로, 프로세스 내 통지(이벤트)나 동시 수 제한(세마포어)은 이름 없는 커널 객체가 여전히 정답입니다.
Interlocked 계열은 .NET 편의 Interlocked 클래스, C++ 편의 std::atomic에 대응합니다. InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange는 단일 변수에 대한 연산을 불가분하게 수행하며, 대부분의 함수는 완전한 메모리 배리어를 동반하므로 순서 보장도 함께 얻을 수 있습니다.4 “volatile을 붙였으니 괜찮다”는 원자성도 순서도 보장하지 않는다는 점에서 오해입니다(FAQ 참고). 또 하나의 전제 조건이 정렬(얼라인먼트)입니다. 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을 호출하는 모든 스레드가 행), 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로 실패하여, “전원을 기다렸다고 생각했지만 아무도 기다리지 않은” 상태로 핸들을 닫게 됩니다. 전원의 종료를 그저 기다리기만 한다면, 상한이 없는 1개씩의 루프가 안전합니다.
flowchart TB
OWNER["정지시키는 쪽"] -->|"SetEvent(hStopEvent)"| SE["정지 이벤트<br/>수동 리셋 - 모든 워커에 보임"]
SE --> W1["워커 1 - WaitForMultipleObjects로<br/>정지와 작업을 동시에 대기"]
SE --> W2["워커 2 - WaitForMultipleObjects로<br/>정지와 작업을 동시에 대기"]
W1 --> C1["뒷정리 후 스스로 return"]
W2 --> C2["뒷정리 후 스스로 return"]
C1 --> J["정지시키는 쪽이 스레드 핸들을 기다려 합류<br/>이 시점에 비로소 정지했다고 말할 수 있다"]
C2 --> J
그림 4: 정지 이벤트 패턴. 정지용으로 수동 리셋 이벤트를 사용하면, 한 번의 SetEvent로 대기 중인 모든 워커가 일제히 깨어난다. 종료 방식은 각 스레드가 스스로 결정하며, 합류 완료를 정지로 간주한다
핵심은 세 가지입니다. 정지 이벤트는 수동 리셋으로 한다(한 번의 SetEvent로 모든 워커에 보임), 대기 배열의 맨 앞에 정지 이벤트를 둔다(동시에 시그널되었을 때 정지가 우선됨), 그리고 정지시키는 쪽은 반드시 스레드 핸들의 합류를 기다린 뒤에 핸들을 닫는다.
두 가지, 적용 범위에 관한 주의를 덧붙입니다. 첫째, 이 “이벤트 + 전건 배출” 형태는 워커 1개 구성을 위한 것입니다. 자동 리셋 이벤트는 몇 번 SetEvent를 호출해도 “시그널 상태가 하나 있다”는 것만 표현할 수 있어서(연속된 신호는 합쳐집니다), 워커가 여러 개 있으면 하나만 깨어나 버스트를 직렬로 처리하게 됩니다. 여러 워커로 큐를 분담한다면, 작업 신호는 세마포어로 바꾸어, 1건이 쌓일 때마다 ReleaseSemaphore(hSem, 1, NULL)로 카운트를 늘립니다. 세마포어의 대기 성공은 카운트를 1 소비하므로, “쌓인 건수만큼 대기 중인 워커가 하나씩 깨어난다”는 올바른 대응이 됩니다(이 용도는 그림 3 표의 “자원 풀의 동시 접근 수 제한”과 마찬가지로 세마포어의 담당 영역입니다). 다만 세마포어로 바꿀 때는 소비 측도 “대기 성공 1회 = 큐에서 1건만 처리”로 고쳐야 합니다. 위 예시의 전건 배출 루프를 그대로 남겨두면, 한 번의 대기로 허가를 하나만 소비했는데도 큐를 비워버려서, 남은 허가로 다른 워커가 빈 큐에 대해 깨어나거나 생산 측의 ReleaseSemaphore가 상한 초과로 실패하는 식으로 셈이 무너집니다. “허가 1개 = 작업 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가 아니라 요소요소에 타임아웃을 붙여 시간 초과를 로그에 남기면, 영원한 행을 검출 가능한 실패로 바꿀 수 있습니다. 행이 발생한 현장에서는 덤프를 채취하여 모든 스레드의 스택을 확인하고, 서로의 잠금 대기가 순환하고 있지 않은지 살펴봅니다. DLL 관련 오류에는 Application Verifier를 이용한 검사가 공식적으로 권장됩니다.10 덤프와 로그의 정비는 「Windows 앱 크래시 시 로그와 덤프를 남기는 설계」에서 다루고 있습니다.
셋째, 부하로 흔듭니다. 코어 수보다 많은 스레드로 장시간 돌리기·처리 순서를 무작위화하기·인위적인 지연을 삽입하기와 같은 스트레스 테스트는, 개발 머신에서 경쟁의 “당첨”을 뽑기 쉽게 만드는 현실적인 수단입니다. 최적화된 릴리스 빌드 + 고부하에서의 재현 시험도 잊지 말고 수행합니다.
10. 정리 ── C 언어판 체크리스트
- 스레드 생성은 모두
_beginthreadex인가(CreateThread/_beginthread가 섞여 있지 않은가) - 스레드 핸들은 합류(
WaitForSingleObject)한 뒤에CloseHandle하고 있는가 - 단명 작업에 직접 만든 스레드를 남발하고 있지 않은가(스레드 풀 API에 맡길 수 없는가)
- 프로세스 내 배타 제어가 SRW 잠금/CRITICAL_SECTION으로 되어 있는가(Mutex를 잘못 사용하고 있지 않은가)
- 공유 카운터·플래그는
volatile에 의존하지 않고 Interlocked 계열로 되어 있는가 Sleep폴링이 남아 있지 않은가(조건 변수·이벤트 대기로 교체했는가)TerminateThread(다른 스레드의 강제 종료)가 한 곳도 없는가. 워커는ExitThread호출이 아니라 스레드 함수의return으로 끝나는가(CRT의 뒷정리가_endthreadex를 통해 올바르게 실행됨)- 정지 이벤트 +
WaitForMultipleObjects의 정지 경로가 모든 워커에 있는가, 블로킹 I/O 중인 스레드도 깨울 수 있는가 - 잠금·핸들의 해제가 모든 반환 경로에서 보장되는가(
goto cleanup규율) DllMain에서 스레드 생성·동기화·종료 대기를 하고 있지 않은가
언어의 지원이 없는 대신, C의 멀티스레드는 API 선택과 규율이 그대로 품질이 됩니다. _beginthreadex·SRW 잠금·Interlocked·정지 이벤트 ── 이 네 가지 세트를 기본값으로 삼으면, C에서도 “가끔 멈춘다”에서 거리를 둔 설계를 할 수 있습니다.
관련 글
- 멀티스레드 실무 베스트 프랙티스 .NET 편 ── 스레드를 늘리기 전에 정해둘 것
- 멀티스레드 실무 베스트 프랙티스 C++ 편 ── RAII와 jthread로 사고를 구조적으로 없애기
- 멀티스레드 실무 베스트 프랙티스 Java 편 ── 가상 스레드 시대의 정석
- 공유 메모리를 사용할 때의 함정과 베스트 프랙티스 - 동기, 가시성, 수명, ABI, 보안을 먼저 정리
- Windows에서 타이머 대기보다 이벤트 대기를 우선하는 이유 - 약 15.6ms 입자의 폴링을 피한다
- 시리얼 통신 앱의 함정 - 1 byte 단위, 타임아웃, 플로우 컨트롤, 재접속, USB 변환, UI 프리즈를 먼저 정리
- Windows 앱이 프로그램 실수에 의한 예외로 떨어져도 확실히 로그를 남기려면 - in-process에 걸지 않는 설계와 WER / 최종 로그 / 감시 프로세스의 베스트 프랙티스
관련 상담 영역
합동회사 코무라소프트에서는 C로 작성된 상주 프로세스·장치 제어 애플리케이션·DLL의 멀티스레드 설계 리뷰, TerminateThread나 잠금 누락에 기인하는 행(hang)·크래시의 원인 조사(덤프 분석), 레거시 C 코드에 스레드를 추가하는 기술 상담을 다루고 있습니다.
참고 링크
-
Microsoft Learn, CreateThread function. CRT를 호출하는 실행 파일 내의 스레드는 CreateThread / ExitThread가 아니라 _beginthreadex / _endthreadex로 관리해야 한다는 것, CreateThread로 만들어진 스레드가 CRT를 호출하면 저메모리 상태에서 CRT가 프로세스를 종료시킬 수 있다는 것에 대해. ↩ ↩2
-
Microsoft Learn, Multithreading with C and Win32. CRT 라이브러리를 호출하는 프로그램에서는 스레드를 _beginthread / _beginthreadex로 시작해야 하며 Win32의 CreateThread / ExitThread를 사용해서는 안 된다는 것, _beginthread 계열이 CRT의 스레드별 변수를 초기화한다는 것, SuspendThread가 CRT 내부 데이터 구조에 접근 중인 스레드를 멈춰 데드락을 초래할 수 있다는 것에 대해. ↩ ↩2
-
Microsoft Learn, About Synchronization. Win32 동기화 프리미티브 선택 지침으로, SRW 잠금이 신규 코드의 기본값이며 포인터 크기이고 대개 사용자 모드에서 완결된다는 것, CRITICAL_SECTION은 재귀 획득이 필요한 경우에 사용한다는 것, Mutex는 항상 커널 객체이며 프로세스 간의 이름 있는 동기화 및 WaitForMultipleObjects와의 병용에 사용한다는 것, 프로세스 내 동기화에 Mutex를 사용하는 것은 고빈도 연산에서 크게 느려지는 “흔한 실수”라는 것, 세마포어는 자원 풀의 동시 접근 수 제한에·이벤트는 통지에 사용된다는 것에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Interlocked Variable Access. Interlocked 함수가 여러 스레드에서 공유되는 변수에 대한 접근을 동기화하고 연산을 불가분하게 수행한다는 것, InterlockedIncrement / Decrement가 읽기·가산·다시쓰기를 하나의 원자적 연산으로 묶으며 동기화 없이는 두 스레드의 동시 증가가 한 번분 사라질 수 있다는 것, InterlockedExchange / InterlockedCompareExchange 등의 함수군, 공유 메모리상의 변수라면 서로 다른 프로세스의 스레드 간에도 사용할 수 있다는 것, 대부분의 Interlocked 함수가 완전한 메모리 배리어를 제공하며 Acquire / Release 버전으로 순서 시맨틱을 선택할 수 있다는 것에 대해. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Using Condition Variables. CRITICAL_SECTION으로 보호한 유한 순환 버퍼에 의한 프로듀서/컨슈머 큐의 구현 예, InitializeConditionVariable로 조건 변수를 만들고 컨슈머가 SleepConditionVariableCS로 대기하며 WakeConditionVariable로 상대를 깨우는 구조, 조건 변수가 Windows Vista 이후에서 지원된다는 것에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, TerminateThread function. TerminateThread가 대상 스레드에 사용자 모드 코드를 일절 실행시키지 않고 종료시킨다는 것, 대상이 크리티컬 섹션을 보유하고 있으면 해제되지 않는다는 것, 힙에서 메모리를 확보하는 중이면 힙 잠금이 해제되지 않는다는 것, kernel32의 상태나 DLL의 전역 상태가 파괴될 수 있다는 것, “가장 극단적인 경우에만 사용해야 하는 위험한 함수”이며 대상 스레드가 실행할 수 있는 코드를 완전히 파악·제어하고 있는 경우가 아니라면 호출해서는 안 된다는 것에 대해. ↩ ↩2
-
Microsoft Learn, Warning C6258. 코드 분석 경고 C6258이 TerminateThread의 사용을 검출한다는 것, TerminateThread로는 적절한 스레드 정리를 할 수 없다는 것, 올바른 종료 방법으로 CreateEvent로 이벤트를 만들고 각 스레드가 WaitForSingleObject로 이벤트 상태를 감시하다가 시그널 상태가 되면 스스로 실행을 끝내는 절차가 제시되어 있다는 것에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, CreateThreadpoolWork function. CreateThreadpoolWork로 작업 객체를 생성하고, SubmitThreadpoolWork를 호출할 때마다 풀의 워커 스레드가 콜백을 실행한다는 것, 콜백 환경(TP_CALLBACK_ENVIRON)으로 실행 환경을 지정할 수 있다는 것, Windows Vista 이후에서 사용할 수 있다는 것에 대해. ↩ ↩2
-
Microsoft Learn, Thread Pools. 스레드 풀이 짧은 작업을 대량으로 비동기 실행하는 애플리케이션이나 단명 스레드를 자주 만드는 애플리케이션에 적합하다는 것, Vista에서 재설계된 새로운 스레드 풀 API의 구성 요소, 모범 사례로서 풀의 스레드를 TerminateThread로 종료시키거나 콜백에서 ExitThread를 호출하지 말아야 한다는 것, 콜백에서 변경한 상태를 돌아가기 전에 정리해야 한다는 것, 대기 핸들을 풀이 다 쓸 때까지 살려두어야 한다는 것에 대해. ↩ ↩2
-
Microsoft Learn, Dynamic-Link Library Best Practices. DllMain이 로더 락을 보유한 상태에서 호출되기 때문에 호출할 수 있는 API에 중대한 제약이 있다는 것, DllMain 내에서 다른 스레드와 동기화하면 데드락을 초래한다는 것, LoadLibrary 호출이 금지 사항이라는 것, DLL 언로드 시 DllMain 내에서 스레드 종료를 기다리면 종료하는 스레드의 DLL_THREAD_DETACH 전달과 서로 기다리며 데드락에 빠지는 구도, 이상적인 DllMain은 거의 비어 있는 스텁이며 초기화는 가능한 한 지연시켜야 한다는 것, 잠금 계층을 정의하여 로더 락을 최상위에 두어야 한다는 것에 대해. ↩ ↩2 ↩3 ↩4 ↩5
-
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
-
Microsoft Learn, Interlocked Variable Access. 적절히 정렬된 32비트 변수의 단순한 읽기·쓰기는 원자적이지만 접근의 동기화(순서)는 보장되지 않는다는 것, 64비트 변수의 단순한 읽기·쓰기는 64비트 Windows에서는 원자적이지만 32비트 Windows에서는 보장되지 않는다는 것, 그 밖의 크기의 변수는 어떤 플랫폼에서도 원자성이 보장되지 않는다는 것에 대해. ↩ ↩2
-
Microsoft Learn, _beginthread, _beginthreadex. _beginthreadex가 _beginthread보다 안전한 이유로, _beginthread로 만든 스레드가 일찍 종료되면 반환된 핸들이 무효가 되어 다른 스레드를 가리킬 수 있다는 점, _beginthreadex의 핸들은 호출한 쪽이 CloseHandle로 닫아야 하며 유효성이 보장된다는 점, _beginthreadex라면 동기화 API에 핸들을 넘길 수 있다는 점, 스레드 함수가 __stdcall 규약으로 스레드 종료 코드를 반환한다는 점, 멀티스레드판 CRT로의 링크가 필요하다는 점에 대해. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
멀티스레드 실무 베스트 프랙티스 C++ 편 ── RAII와 jthread로 사고를 구조적으로 없애기
C++의 멀티스레드는 데이터 경쟁이 미정의 동작이 되는 세계입니다. std::thread 소멸자의 함정, jthread와 stop_token에 의한 정지 설계, scoped_lock의 데드락 회피, atomic의 올바른 위치, Win32 동기화 ...
멀티스레드 실무 베스트 프랙티스 .NET 편 ── 스레드를 늘리기 전에 정해둘 것
「스레드를 만들면 가끔 죽거나 멈춘다」를 막는 설계의 정석을 .NET/C# 대상으로 정리합니다. 스레드를 직접 만들지 않고 Task에 올라타기, 공유 가변 상태 줄이기, 락의 규율, CancellationToken을 통한 정지 설계, UI 스레드...
멀티스레드 실무 베스트 프랙티스 Java 편 ── 가상 스레드 시대의 정석
Java의 멀티스레드는 스레드를 직접 만들지 않고 ExecutorService와 가상 스레드에 올라타는 것이 정석입니다. synchronized와 ReentrantLock의 구분, 인터럽트에 의한 협조적 정지, ConcurrentHashMap의 ...
Windows 앱 외주·수탁 개발을 의뢰하기 전에 정리해야 할 것
Windows 앱의 외주·수탁 개발을 의뢰하기 전에, 기존 소프트웨어 개수, 장치 연계, COM/ActiveX, 배포·업데이트, 보수와 관련해 정리해야 할 포인트를 설명합니다.
볼륨 섀도 복사본(VSS)의 구조와 실무 ── 사용 중 파일의 백업은 왜 가능한가
사용 중인 파일은 공유 위반으로 복사할 수 없는데, 백업 소프트웨어는 왜 복사할 수 있는가. 볼륨 섀도 복사본(VSS)의 요청자·라이터·공급자 역할 분담, 카피 온 라이트의 구조, vssadmin 실무와 차분 영역의 함정을 해설합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기술 상담 & 설계 리뷰
설계 방향, 아키텍처 경계, 수명 관리, 기존 Windows 자산 처리 방법을 정리하는 데 도움을 드립니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 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 함수는 완전한 메모리 배리어를 동반하므로 순서 보장도 동시에 얻을 수 있습니다. 여러 변수를 한꺼번에 지키는 경우에는 SRW 잠금 또는 CRITICAL_SECTION입니다.