동기화란 여러 스레드가 공유 자원에 접근하거나 특정 순서에 맞게 실행될 때 발생하는 문제를 제어하는 메커니즘이다.
동기화의 두 가지 관점
실행 순서의 동기화
스레드의 실행 순서를 명시적으로 정의하고, 그 순서를 강제하는 것이다. A 스레드의 결과를 B 스레드가 받아서 이어서 처리하는 경우가 대표적이다. 순서 자체가 핵심이다.
메모리 접근에 대한 동기화
공유 메모리에 여러 스레드가 동시에 접근하는 것을 막는 것이다. 순서를 강제하는 것이 목적이 아니라, 동시 접근을 막다 보니 결과적으로 순차적 접근이 이루어지는 것이다.
두 방식의 핵심 차이는 “순서가 목적인가, 배타성이 목적인가” 이다.
사전 지식 - 임계 영역 (Critical Section)
임계 영역이란 여러 스레드가 동시에 접근하면 문제가 생길 수 있는 공유 자원 접근 코드 구간을 말한다.
중요한 것은 임계 영역이 특정 메모리 영역(스택, 힙, 코드 영역 등)을 의미하는 것이 아니라는 점이다. 임계 영역은 물리적 메모리 구분이 아닌, 개념적인 코드 구간이다. 공유 자원에 접근하기 때문에 보호가 필요한 코드의 범위라고 이해하면 된다.
Race Condition (경쟁 조건)
임계 영역을 보호하지 않을 때 발생하는 문제다. 예를 들어 count++는 소스 코드 한 줄이지만, CPU가 실제로 처리하는 명령어는 세 단계로 분해된다.
1. 메모리에서 count 값을 레지스터로 읽기 (LOAD)
2. 레지스터 값에 1을 더하기 (ADD)
3. 결과를 메모리에 쓰기 (STORE)
두 스레드가 이 세 단계 사이에서 컨텍스트 스위칭되면, 둘 다 같은 초기값을 읽고 각각 +1을 수행해 최종 결과가 +1만 반영되는 데이터 손실이 발생한다.
동기화의 두 가지 방법
유저 모드 동기화
커널 모드로의 전환 없이 동기화를 수행한다. 커널 전환 비용이 없으므로 성능이 좋다. 단, 기능적 제약이 있으며 프로세스 간 동기화는 불가능하다.
크리티컬 섹션 기반 동기화 (메모리 접근 동기화)
CRITICAL_SECTION 오브젝트를 선언하고 초기화한 뒤, 임계 영역 진입 시 EnterCriticalSection()으로 접근 권한을 획득하고, 임계 영역 탈출 시 LeaveCriticalSection()으로 반환하는 구조다.
CRITICAL_SECTION cs;
InitializeCriticalSection(&cs);
EnterCriticalSection(&cs);
// --- 임계 영역 ---
// 공유 자원 접근 코드
// -----------------
LeaveCriticalSection(&cs);
DeleteCriticalSection(&cs);Enter와 Leave 사이의 임계 영역에 대한 접근 권한은 Leave가 호출될 때까지 다른 스레드에 부여되지 않는다.
EnterCriticalSection() 자체의 동기화는 어떻게 되는가?
EnterCriticalSection() 내부도 크리티컬 섹션 객체의 상태를 변경하므로 동기화가 필요하다. 이때는 별도의 고수준 락을 사용하는 것이 아니라, CPU가 제공하는 원자적 Read-Modify-Write 명령(CAS, Compare-And-Swap 등)이나 Interlocked 계열 연산을 사용한다. 이 연산들은 읽기-비교-쓰기를 하나의 원자적 단계로 보장하므로 여러 스레드가 동시에 호출해도 오직 하나의 스레드만 락 획득에 성공한다. 멀티코어 환경에서는 atomic instruction과 캐시 일관성(Cache Coherence) 보장이 함께 작동한다.
CPU가 아예 “읽기 + 비교 + 쓰기를 하드웨어 수준에서 쪼갤 수 없는 1개의 명령어” 로 제공한다는 뜻
인터락 함수 기반 동기화 (메모리 접근 동기화)
특정 변수 하나를 원자적으로 제어할 때 사용한다. count++처럼 여러 명령어로 분해되는 연산을 CPU 수준에서 하나의 명령어로 처리하도록 보장한다. 이것이 **원자성(Atomicity)**이다.
LONG count = 0;
InterlockedIncrement(&count); // count++의 원자적 버전
InterlockedDecrement(&count); // count--의 원자적 버전
InterlockedExchange(&count, newValue); // 값 교환의 원자적 버전volatile 키워드는 컴파일러 최적화를 막고 캐시가 아닌 메모리에 직접 연산을 수행하도록 강제한다. 컴파일 단계에서 코드 수행 순서를 변경하거나 레지스터/캐시에 저장하는 것을 금지하는 지시어다. 단, volatile만으로는 원자성이 보장되지 않는다.
커널 모드 동기화
커널이 제공하는 기능을 활용하므로 보다 강력하고 안정적이다. 커널 모드 전환 비용이 발생하지만, 프로세스 간 동기화가 가능하고 타임아웃 등 다양한 기능을 활용할 수 있다.
커널 모드 동기화 객체들은 커널 오브젝트로 존재하며 Signaled / Non-Signaled 상태를 가진다.
뮤텍스 기반 동기화 (메모리 접근 동기화)
크리티컬 섹션과 동작 방식은 유사하지만, 커널 오브젝트라는 점이 핵심 차이다. 뮤텍스는 소유권(Ownership) 개념이 명확하다. 소유권을 획득한 스레드가 있을 때 Non-Signaled 상태가 되고, 소유권을 반환하면 Signaled 상태로 돌아간다. WaitForSingleObject()로 소유권을 획득하고, ReleaseMutex()로 반환한다.
HANDLE hMutex = CreateMutex(NULL, FALSE, NULL);
WaitForSingleObject(hMutex, INFINITE);
// --- 임계 영역 ---
ReleaseMutex(hMutex);
CloseHandle(hMutex);크리티컬 섹션과의 차이점을 정리하면 다음과 같다.
| 항목 | 크리티컬 섹션 | 뮤텍스 |
|---|---|---|
| 동작 모드 | 유저 모드 | 커널 모드 |
| 성능 | 빠름 | 느림 (커널 전환 비용) |
| 프로세스 간 사용 | 불가 | 가능 (이름 지정 시) |
| 소유권 | 없음 | 있음 |
세마포어 기반 동기화 (메모리 접근 동기화)
뮤텍스가 “한 번에 하나”를 허용한다면, 세마포어는 “한 번에 N개”를 허용한다. 접근 가능한 최대 스레드 수(카운트)를 지정하고, 현재 접근 중인 스레드 수를 카운팅하여 제어한다.
- 카운트가 0이면 Non-Signaled 상태로 추가 접근이 차단된다.
- 카운트가 1 이상이면 Signaled 상태로 소유권을 획득할 수 있다.
// 최대 3개의 스레드가 동시 접근 가능한 세마포어
HANDLE hSemaphore = CreateSemaphore(NULL, 3, 3, NULL);
WaitForSingleObject(hSemaphore, INFINITE); // 카운트 -1
// --- 임계 영역 ---
ReleaseSemaphore(hSemaphore, 1, NULL); // 카운트 +1
CloseHandle(hSemaphore);뮤텍스는 세마포어의 카운트가 1인 특수한 경우로 볼 수 있다. 단, 뮤텍스는 소유권 개념이 있어 획득한 스레드만 해제할 수 있지만, 세마포어는 획득한 스레드가 아닌 다른 스레드도 카운트를 증가시킬 수 있다.
이름있는 뮤텍스 기반 프로세스 동기화 (프로세스 간 동기화)
뮤텍스는 커널 오브젝트이므로 프로세스가 아닌 운영체제(커널)의 소유다. 그러나 각 프로세스는 자신만의 핸들 테이블을 갖기 때문에, 다른 프로세스의 핸들을 그대로 참조하기 어렵다.
이를 해결하기 위해 뮤텍스에 이름을 부여한다. 이름이 있는 뮤텍스는 어느 프로세스에서든 동일한 이름으로 OpenMutex()나 CreateMutex()를 호출하면 같은 커널 오브젝트의 핸들을 획득할 수 있다.
// 프로세스 A
HANDLE hMutex = CreateMutex(NULL, FALSE, L"MyAppMutex");
// 프로세스 B - 같은 커널 오브젝트를 이름으로 참조
HANDLE hMutex = OpenMutex(MUTEX_ALL_ACCESS, FALSE, L"MyAppMutex");이벤트 기반 동기화 (Event-Based Synchronization)
핵심 개념: 실행 _순서_를 동기화한다. 한 스레드가 특정 작업을 완료했음을 다른 스레드에게 신호로 알리는 메커니즘.
SetEvent()→ 이벤트를 Signaled 상태로 전환WaitForSingleObject()→ Signaled 상태가 될 때까지 대기 (블로킹)CloseHandle()→ 커널 오브젝트 해제 (반드시 호출)
| 종류 | CreateEvent 2번째 인자 | 동작 방식 |
|---|---|---|
| 자동 리셋 (Auto-Reset) | FALSE | SetEvent() 후, 대기 중인 스레드 하나만 깨우고 자동으로 Non-Signaled로 복귀 |
| 수동 리셋 (Manual-Reset) | TRUE | SetEvent() 후, 대기 중인 모든 스레드가 깨어남. ResetEvent() 호출 전까지 Signaled 유지 |
선택 기준
- 소비자가 1개라면 → Auto-Reset
- 소비자가 여러 개이고 모두 동시에 깨워야 한다면 → Manual-Reset
기본 코드 패턴
// 이벤트 생성: Auto-Reset, 초기 상태 Non-Signaled
HANDLE hEvent = CreateEvent(
NULL, // 보안 속성
FALSE, // FALSE = Auto-Reset, TRUE = Manual-Reset
FALSE, // 초기 상태: Non-Signaled
NULL // 이름 없음
);
// 스레드 A: 작업 완료 후 신호 발생
SetEvent(hEvent);
// 스레드 B: 신호 대기 (무한정)
WaitForSingleObject(hEvent, INFINITE);
// → Signaled가 되면 이 줄부터 실행 재개
CloseHandle(hEvent);생산자-소비자 모델 (Producer-Consumer Pattern)
이벤트 기반 동기화가 가장 자주 활용되는 설계 패턴
- 버퍼를 사이에 두어 생산 속도 ≠ 소비 속도인 상황에서도 독립적 실행 보장
- 이벤트로 “데이터 준비됨”을 소비자에게 알림
주의: 동시 접근 문제 생산자 1 : 소비자 N 구조에서 이벤트가 Signaled로 바뀌는 순간 여러 소비자가 동시에 버퍼에 접근할 수 있다.
이벤트 Signaled
│
├── 소비자 스레드 1 ──► 버퍼 접근 ⚠️
├── 소비자 스레드 2 ──► 버퍼 접근 ⚠️ ← Race Condition 발생 가능
└── 소비자 스레드 3 ──► 버퍼 접근 ⚠️
해결책: 뮤텍스(Mutex)와 혼용
// 소비자 스레드 내부
WaitForSingleObject(hEvent, INFINITE); // 신호 대기
WaitForSingleObject(hMutex, INFINITE); // 버퍼 잠금
// → 임계 구역: 버퍼 읽기/쓰기
ReleaseMutex(hMutex); // 잠금 해제이벤트 vs 뮤텍스 역할 구분
- 이벤트 → 실행 순서 제어 (“이제 시작해도 돼”)
- 뮤텍스 → 공유 자원 접근 제어 (“내가 쓰는 동안 너는 기다려”)
타이머 기반 동기화 (Timer-Based Synchronization)
핵심 개념: 정해진 시간이 경과하면 자동으로 Signaled 상태가 되는 커널 오브젝트.
WaitForSingleObject()와 함께 사용.
타이머 종류
| 종류 | 동작 방식 | 사용 사례 |
|---|---|---|
| 수동 리셋 타이머 (일반 타이머) | 지정 시간 후 1회 Signaled | 딜레이 후 1회 실행 |
| 주기적 타이머 (Periodic Timer) | 최초 지연 후, 일정 간격으로 반복 Signaled | 주기적 폴링, 하트비트 |
타이머 생성 패턴 (참고)
// Waitable Timer 생성
HANDLE hTimer = CreateWaitableTimer(NULL, TRUE, NULL);
LARGE_INTEGER liDueTime;
liDueTime.QuadPart = -10000000LL; // 1초 후 (100ns 단위, 음수 = 상대 시간)
// 수동 리셋 타이머: lPeriod = 0
// 주기적 타이머: lPeriod = 주기(ms)
SetWaitableTimer(hTimer, &liDueTime, 1000, NULL, NULL, FALSE);
// ^^^^ 1000ms 주기 → 주기적 타이머
WaitForSingleObject(hTimer, INFINITE); // 타이머 만료 대기
CancelWaitableTimer(hTimer);
CloseHandle(hTimer);데드락 (Deadlock)
동기화를 잘못 사용할 경우 데드락이 발생할 수 있다. 두 스레드가 서로 상대방이 점유한 자원을 기다리며 무한 대기에 빠지는 상태다.
데드락의 4가지 필요 조건(Coffman Conditions)은 다음과 같으며, 네 가지가 모두 충족될 때 발생한다.
- 상호 배제(Mutual Exclusion): 한 번에 하나의 스레드만 자원을 사용할 수 있다.
- 점유 대기(Hold and Wait): 자원을 점유한 채로 다른 자원을 기다린다.
- 비선점(Non-Preemption): 다른 스레드가 강제로 자원을 빼앗을 수 없다.
- 순환 대기(Circular Wait): 스레드들이 원형으로 서로의 자원을 기다린다.
참고 - std::mutex (C++ 표준 라이브러리)
cpp
#include <mutex>
std::mutex mtx;
mtx.lock();
// 임계 영역
mtx.unlock();
// 또는 RAII 방식
std::lock_guard<std::mutex> lock(mtx);- C++ 언어 레벨에서 제공
- 내부적으로 플랫폼에 맞게 구현됨 (Windows면 크리티컬 섹션 또는 뮤텍스, Linux면 pthread_mutex)
- 같은 프로세스 내 스레드 간에서만 사용
- 이름이 없음, 커널 오브젝트가 아님
면접 예상 질문
기본 난이도
Q1. 유저 모드 동기화와 커널 모드 동기화의 차이점은 무엇인가?
유저 모드 동기화는 커널 모드 전환 없이 수행되므로 성능이 빠르다. 반면 커널 모드 동기화는 커널 전환 비용이 발생하지만, 프로세스 간 동기화가 가능하고 타임아웃 설정 등 다양한 기능을 제공한다. 대표적으로 크리티컬 섹션은 유저 모드, 뮤텍스와 세마포어는 커널 모드 동기화 기법이다.
Q2. 뮤텍스와 세마포어의 차이점은 무엇인가?
뮤텍스는 소유권 개념이 있어 한 번에 하나의 스레드만 접근을 허용하며, 획득한 스레드만 해제할 수 있다. 세마포어는 카운트 기반으로 N개의 스레드가 동시에 접근할 수 있도록 허용하며, 소유권 개념이 없어 다른 스레드도 카운트를 증가시킬 수 있다. 뮤텍스는 세마포어의 카운트가 1인 특수한 경우로 이해할 수 있다.
Q3. 크리티컬 섹션과 뮤텍스 중 어떤 상황에서 무엇을 선택해야 하는가?
같은 프로세스 내의 스레드 간 동기화라면 크리티컬 섹션이 적합하다. 커널 전환 비용이 없어 성능이 우수하기 때문이다. 반면 다른 프로세스 간 동기화가 필요하거나, 타임아웃을 사용한 대기가 필요하다면 뮤텍스를 사용해야 한다. 크리티컬 섹션은 프로세스 내부에서만 유효하다.
Q4. 실행 순서 동기화와 메모리 접근 동기화는 어떻게 다른가?
실행 순서 동기화는 스레드 간의 실행 흐름 자체를 제어한다. A 스레드가 완료된 후 B 스레드가 시작되어야 하는 경우처럼 순서가 핵심이다. 주로 이벤트 오브젝트로 구현한다. 메모리 접근 동기화는 순서보다 배타성이 목적이다. 공유 자원에 동시에 접근하는 것을 막아 데이터 무결성을 보장한다. 뮤텍스, 세마포어, 크리티컬 섹션이 이에 해당한다.
Q5. volatile 키워드가 멀티스레딩 환경에서 충분한 동기화 수단인가?
그렇지 않다. volatile은 컴파일러가 해당 변수를 캐시나 레지스터에 저장하지 않고 매번 메모리에서 직접 읽도록 강제한다. 즉, 컴파일러 최적화로 인한 가시성 문제는 해결할 수 있다. 그러나 volatile은 원자성을 보장하지 않는다. count++처럼 여러 CPU 명령어로 분해되는 연산은 volatile을 붙여도 Race Condition이 발생한다. 원자성이 필요하다면 인터락 함수나 별도의 동기화 기법을 사용해야 한다.
고난이도
Q6. EnterCriticalSection() 함수 내부에서 크리티컬 섹션 객체의 상태를 변경할 때, 그 변경 자체는 어떻게 동기화되는가?
별도의 고수준 락을 재귀적으로 사용하는 것이 아니라, CPU 아키텍처 수준의 원자적 명령어를 활용한다. 대표적으로 CAS(Compare-And-Swap)나 XCHG 같은 명령어가 있으며, Windows에서는 Interlocked 계열 API가 이를 래핑한다. 이 명령어들은 하드웨어 수준에서 읽기-비교-쓰기를 단일 버스 트랜잭션으로 보장하므로, 멀티코어 환경에서도 여러 스레드가 동시에 시도해도 오직 하나만 성공한다. 멀티코어에서는 추가로 캐시 일관성 프로토콜(MESI 등)이 각 코어의 캐시 간 데이터 일관성을 보장한다.
Q7. 스핀락(Spinlock)은 무엇이며, 언제 크리티컬 섹션보다 유리한가?
스핀락은 락을 획득하지 못한 스레드가 블로킹되어 대기 큐에 들어가는 대신, CPU를 점유한 채 루프를 돌며 락이 해제되기를 기다리는 방식이다. 컨텍스트 스위칭 비용이 전혀 없다는 것이 장점이다. 임계 영역이 매우 짧아서 락 경쟁 시간이 컨텍스트 스위칭 비용보다 확실히 짧을 때 유리하다. 반면 임계 영역이 길거나 CPU 코어가 하나뿐인 환경에서는 CPU를 낭비하므로 오히려 성능이 떨 어진다. Windows의 크리티컬 섹션은 내부적으로 스핀 카운트를 설정할 수 있어 짧은 대기는 스핀으로, 긴 대기는 커널 대기로 전환하는 하이브리드 방식을 지원한다.
Q8. 데드락의 4가지 필요 조건 중 하나를 제거하여 데드락을 예방하는 방법을 각각 설명하라.
상호 배제 조건은 자원의 특성상 제거가 어려운 경우가 많다. 점유 대기 조건은 모든 필요한 자원을 한 번에 획득하거나, 자원을 전혀 점유하지 않은 상태에서만 새 자원을 요청하도록 강제하여 제거할 수 있다. 비선점 조건은 자원을 요청했을 때 즉시 획득하지 못하면 현재 보유한 자원을 모두 반납하도록 하여 제거할 수 있다. 순환 대기 조건은 모든 자원에 전역적인 순서를 부여하고, 스레드가 항상 낮은 번호부터 높은 번호 순서로만 자원을 획득하도록 강제하는 것이 가장 실용적인 방법이다.
Q9. volatile 키워드만으로 멀티스레딩 동기화가 충분한가?
충분하지 않다. volatile은 컴파일러가 해당 변수를 캐시나 레지스터에 저장하지 않고 매번 메모리에서 직접 읽도록 강제하는 것으로, 컴파일러 최적화로 인한 가시성 문제만 해결한다. 원자성은 보장하지 않는다. count++처럼 LOAD-ADD-STORE 세 단계로 분해되는 연산은 volatile을 붙여도 Race Condition이 발생한다. 원자성이 필요하면 인터락 함수나 별도의 동기화 기법을 함께 사용해야 한다.
Q10. ABA 문제란 무엇이며 Lock-Free 알고리즘에서 왜 중요한가?
CAS 기반 Lock-Free 알고리즘에서 발생하는 문제다. 스레드 A가 값을 읽어 A임을 확인한 뒤 작업을 준비하는 사이에, 스레드 B가 값을 A → B → A로 바꿔놓으면 스레드 A의 CAS는 값이 여전히 A이므로 성공한다. 그러나 중간에 상태가 바뀌었다가 돌아온 것이므로 값의 동일성이 불변성을 보장하지 않는다. Lock-Free 스택에서 팝 연산 중 노드가 해제되었다가 동일한 주소에 재할당되면 포인터 값은 같아도 실제 의미는 다르다. 해결책으로는 값에 버전 태그를 함께 포함시키는 Tagged Pointer 기법을 사용한다.
스레드 1: head = A 읽음
스레드 1: CAS 준비 ("A이면 B로 바꿔라")
↑ 여기서 스위칭
스레드 2: A→B→A로 다 바꿔놓음
스레드 1: 복귀
스레드 1: CAS 실행 ← 이건 원자적으로 완벽하게 실행됨
근데 이미 늦음
CAS 명령 자체는 원자적으로 아무 문제 없이 실행된다. 문제는 “읽은 시점”과 “CAS 실행 시점” 사이의 간격이다. 노드 기반 자료구조에서 가장 치명적이다.
Q1. Auto-Reset
이벤트를 사용하는 상황에서 소비자 스레드가 3개라면, SetEvent()를 한 번 호출했을 때 몇 개의 스레드가 대기에서 풀리는가? (오토와 매뉴얼의 차이로 비교)
Q2. 이벤트만으로 생산자-소비자 문제를 구현했을 때 발생할 수 있는 Race Condition 시나리오를 구체적으로 서술하고, 뮤텍스를 함께 사용했을 때 어떻게 해결되는지 설명하라.
Q4. 이벤트 객체란 무엇이며 뮤텍스와 어떻게 다른가요.
Q5. 이벤트 기반 대기와 스핀락의 차이는 무엇인가요?