테마
Chapter 04. 동기화, IPC, 스케줄링
멀티스레드 환경에서 발생하는 **레이스 컨디션(Race Condition)**과 임계 구역(Critical Section) 문제를 살펴보고, 이를 해결하기 위한 동기화(Synchronization) 기법을 다룬다. 또한 프로세스 간 데이터를 주고받는 IPC(Inter-Process Communication) 메커니즘과, CPU를 효율적으로 배분하는 스케줄링(Scheduling) 전략까지 학습한다.
4.1 레이스 컨디션 (Race Condition)
정의
**레이스 컨디션(Race Condition)**이란 둘 이상의 스레드가 **공유 자원(Shared Resource)**에 동시에 접근하여 서로 **경쟁(Race)**하는 상태를 말한다. 이로 인해 프로그램의 실행 결과가 **비결정적(Nondeterministic)**이 되어, 실행할 때마다 다른 결과가 나올 수 있다.
냉장고 비유
냉장고에 맛있는 음료수를 넣어두었다. 나중에 마시려고 했는데, 다른 가족이 먼저 꺼내서 마셔버렸다. 내가 냉장고를 열면 음료수가 있을 수도 있고, 없을 수도 있다. 이것이 바로 레이스 컨디션이다 --- 결과를 예측할 수 없다.
전역변수 경쟁 예시
전역변수 z_data에 대해 다음과 같은 상황을 생각해보자.
| 스레드 | 동작 |
|---|---|
| 스레드 1 | z_data = 1000 대입 |
| 스레드 2 | z_data = 2000 대입 |
| 스레드 3 | z_data 값을 읽음 |
스레드 3이 읽는 값은 1000일 수도, 2000일 수도 있다. 어떤 스레드가 먼저 실행될지는 OS 스케줄러가 결정하기 때문에 아무도 모른다.
한 줄의 C 코드가 위험한 이유
예금 = 예금 + 50000; 이 한 줄의 코드는 사람 눈에는 하나의 동작처럼 보이지만, CPU 입장에서는 최소 3개의 명령어로 분해된다.
| 단계 | CPU 명령어 | 동작 |
|---|---|---|
| 1단계 | MOVE reg, [예금] | 메모리에서 예금 값을 레지스터로 읽기 |
| 2단계 | ADD reg, 50000 | 레지스터에서 50000을 더하기 |
| 3단계 | MOVE [예금], reg | 연산 결과를 다시 메모리에 쓰기 |
이 3단계 사이에 다른 스레드가 끼어들면 예상치 못한 결과가 발생한다.
스레드 A가 1단계를 마친 후 스레드 B가 끼어들어 전체 과정을 완료하면, 스레드 A는 **이미 오래된 값(100만원)**을 기반으로 계산한다. 결국 3만원이 사라진다.
링크드 리스트에서의 위험
공유 자원이 **링크드 리스트(Linked List)**인 경우 위험은 더욱 심각하다.
- 추가(Insert) 중에 다른 스레드가 **조회(Read)**하면 아직 연결되지 않은 불완전한 노드를 읽을 수 있다.
- **삭제(Delete)**는 추가보다 약 3배 복잡하다. 이전 노드의 포인터 변경, 대상 노드 해제 등 여러 단계가 필요하기 때문이다.
- 삭제 도중 다른 스레드가 해당 노드를 접근하면 **메모리 손상(Memory Corruption)**이나 **크래시(Crash)**로 이어진다.
4.2 임계 구역 (Critical Section)과 동기화
임계 구역이란?
**임계 구역(Critical Section)**이란 공유 자원에 접근하는 코드 구간으로, 동시에 두 개 이상의 스레드가 실행해서는 안 되는 영역이다. 임계 구역 내의 연산은 **원자성(Atomicity)**을 보장해야 한다 --- 즉, 중간에 끼어들기 없이 시작하면 끝까지 완전히 수행되어야 한다.
화장실 비유
화장실을 떠올려보자.
- 들어갈 때 문을 잠근다 (Lock)
- 볼일을 본다 (임계 구역에서 작업)
- 나올 때 문을 연다 (Unlock)
다른 사람은 문이 잠겨있으면 기다렸다가, 열리면 들어간다. 이것이 임계 구역 보호의 기본 원리다.
프린터 비유: 인어공주 문제
두 사람이 하나의 프린터를 공유한다고 하자.
| 사용자 | 인쇄 내용 |
|---|---|
| 사용자 A | "공주" 인쇄 중 |
| 사용자 B | "생선" 인쇄 시도 |
만약 프린터(공유 자원)에 대한 임계 구역 보호가 없다면, "공주" 인쇄 도중에 "생선"이 끼어들어 **"인어공주"**가 출력될 수 있다. 각자의 인쇄 작업은 시작부터 끝까지 방해받지 않고 완료되어야 한다.
임계 구역의 핵심 원칙
| 원칙 | 설명 |
|---|---|
| 최소화 | 임계 구역은 꼭 필요한 코드만 포함해야 한다. 길어질수록 다른 스레드의 대기 시간이 늘어난다. |
| 없앨 수 있으면 없애라 | Lock-free 자료구조나 알고리즘을 사용하면 임계 구역 자체를 없앨 수 있다. |
| 길어지면 위험 | 임계 구역이 길어질수록 효율이 저하되고, 데드락(Deadlock) 위험이 증가한다. |
Spin Lock: 가장 원시적인 동기화
Spin Lock은 while문으로 플래그(flag)를 반복 확인하며 대기하는 방식이다.
c
// 주의: 개념 설명용 의사코드
volatile int lock = 0;
void enter_critical_section() {
while (lock == 1) {
// 계속 반복 확인 (Spinning)
}
lock = 1; // 잠금
}
void leave_critical_section() {
lock = 0; // 해제
}문제점: CPU가 while문을 쉬지 않고 돌리므로 **CPU 점유율이 100%**까지 치솟는다. OS가 제공하는 Spin Lock은 이 문제를 해결한다(짧은 대기 후 스레드를 sleep 상태로 전환).
동기화 기법
모니터(Monitor)와 큐(Queue): 동기화의 결론
현대 동기화의 핵심 패턴은 **큐(Queue)**다.
"세상의 모든 스레드는 Q(큐)를 가지고 있다."
이 말의 의미는 다음과 같다.
- 각 스레드에 **작업 큐(Task Queue)**를 연결한다.
- 외부에서 해야 할 일이 있으면 해당 스레드의 큐에 **추가(Append)**한다.
- 스레드는 자신의 큐에서 하나씩 꺼내서(Dequeue) 처리한다.
- 큐에 대한 접근만 통제하면 동기화가 달성된다.
이 패턴을 사용하면 역할별 스레드 분리가 가능하다.
| 스레드 역할 | 담당 |
|---|---|
| I/O 전담 스레드 | 파일 읽기/쓰기, 네트워크 통신 |
| 검색 전담 스레드 | 데이터 검색, 인덱싱 |
| UI 전담 스레드 | 화면 렌더링, 사용자 입력 처리 |
| 연산 전담 스레드 | 복잡한 계산, 데이터 가공 |
이 패턴의 핵심은, 각 스레드가 자신의 큐에서만 꺼내어 처리하므로 공유 자원에 대한 직접적인 경쟁이 사라진다는 것이다. 큐에 항목을 추가하는 동작만 스레드 안전(Thread-safe)하게 구현하면 된다.
이벤트(Event) 객체 기반 동기화 (Windows)
Windows 운영체제에서는 **이벤트 객체(Event Object)**를 사용하여 스레드 간 실행 순서를 명확히 제어할 수 있다.
| API | 역할 |
|---|---|
CreateEvent() | 커널 오브젝트(Kernel Object)로 이벤트 생성 |
SetEvent() | 이벤트에 신호 보내기 (작업 완료 알림 등) |
WaitForSingleObject() | 이벤트가 신호 상태가 될 때까지 대기 (대기 상태로 전환) |
Unix/Linux에서는 이벤트 대신 Signal 기반으로 유사한 동기화를 수행한다.
sleep(0)의 비밀
sleep(0)은 쉬는 것이 아니다. 현재 스레드를 Ready Queue의 맨 뒤로 보내는 것이다. 즉, 다른 스레드에게 **제어권을 양보(Yield)**하는 동작이다.
| 호출 | 의미 |
|---|---|
sleep(1000) | 1초 동안 대기 상태(Blocked)로 전환 |
sleep(0) | 대기하지 않지만, Ready Queue 맨 뒤로 이동하여 다른 스레드에 실행 기회 제공 |
4.3 교착상태 (Deadlock)
정의
**교착상태(Deadlock)**란 **두 개 이상의 프로세스(또는 스레드)**가 서로 상대방이 점유한 자원을 기다리며 **무한 대기(Infinite Wait)**에 빠진 상태를 말한다. 어느 쪽도 양보하지 않으므로 시스템이 영원히 멈춘다.
교착상태 발생의 4대 조건
교착상태는 다음 네 가지 조건이 모두 충족될 때 발생한다. 하나라도 깨뜨리면 교착상태는 발생하지 않는다.
| 조건 | 영어 | 설명 | 비유 |
|---|---|---|---|
| 상호 배제 | Mutual Exclusion | 자원을 한 번에 하나의 프로세스만 사용 가능 | 화장실 칸은 한 사람만 사용 |
| 점유와 대기 | Hold and Wait | 자원을 가진 채로 다른 자원을 대기 | 젓가락 하나 잡고 다른 하나 기다림 |
| 비선점 | No Preemption | 다른 프로세스의 자원을 강제로 뺏을 수 없음 | 남의 젓가락을 빼앗을 수 없음 |
| 원형 대기 | Circular Wait | 프로세스들이 원형으로 서로의 자원을 대기 | A는 B 기다리고, B는 A 기다림 |
교착상태 디버깅
교착상태가 발생했을 때 원인을 찾는 절차는 다음과 같다.
- 덤프(Dump) 획득: 프로세스의 메모리 스냅샷을 파일로 저장
- 콜 스택(Call Stack) 분석: 각 스레드가 어디서 멈춰있는지 확인
- 원인 파악: 어떤 자원에서 서로 기다리고 있는지 순환 구조 확인
- 해결: 자원 획득 순서 통일, 타임아웃 설정, 자원 분리 등
4.4 프로세스 간 통신 (IPC - Inter-Process Communication)
IPC가 필요한 이유
Chapter 03에서 배웠듯이, 프로세스의 메모리 공간은 완전히 독립적이다. 운영체제가 외부 접근을 **차단(Protection)**하기 때문이다. 따라서 프로세스끼리 데이터를 주고받으려면 특별한 메커니즘이 필요한데, 이것이 **IPC(Inter-Process Communication)**다.
해킹(Hacking)이란, 바로 이 보호를 뚫는 것이다. iOS 커널 해킹에 성공하면 Apple로부터 버그바운티(Bug Bounty)로 고액 보상을 받을 수 있다. 그만큼 이 보호는 견고하게 설계되어 있다.
IPC 방법론 비교
| 방법 | 매체 | 특징 |
|---|---|---|
| 파이프(Pipe) | 파일 | 스트리밍 방식, 직렬화에 유리, 시작은 있지만 끝이 유동적, 크기 자동 증가 |
| 공유 메모리(Shared Memory) | RAM | 고정 크기, OS 허락 필요, 양방향, 이벤트/시그널로 동기화 |
| 소켓(Socket) | 네트워크 | 파일의 본질(everything is a file), TCP 기반 통신 |
| RPC (Remote Procedure Call) | 네트워크 | 원격 함수 호출, 현대에는 HTTP/gRPC로 부활 |
| 레지스트리(Registry) (Windows) | 파일+메모리 | in-memory 상태, 동시접근 통제, 파일보다 빠름 |
| pragma data_seg (Windows) | DLL 공유 섹션 | DLL 내 전역변수를 여러 프로세스가 공유, injection/후킹 활용 |
파일 기반 vs RAM 기반 IPC
이 두 가지 유형의 핵심 차이를 이해하는 것이 중요하다.
| 구분 | 파일 기반 (Pipe, Socket 등) | RAM 기반 (Shared Memory) |
|---|---|---|
| 데이터 형태 | 스트림(Stream) --- 연속적 흐름 | 고정 길이 블록 |
| 크기 | 유동적, 자동 증가 | 고정, 생성 시 결정 |
| 직렬화 | 필수 (바이트 스트림으로 변환) | 선택적 (메모리 직접 접근 가능) |
| OS 검사 | 비교적 느슨 | 크기와 접근 권한을 강하게 검사 |
| 속도 | 상대적으로 느림 (커널 경유) | 빠름 (직접 메모리 접근) |
공유 메모리 IPC 흐름
공유 메모리 방식은 가장 빠른 IPC이지만, 동기화를 별도로 구현해야 한다.
다양한 IPC 메커니즘 구조
4.5 CPU 스케줄링
스케줄링 계층
운영체제의 스케줄링은 크게 두 가지 수준으로 나뉜다.
| 수준 | 영어 | 역할 | 결정 사항 |
|---|---|---|---|
| 고수준 스케줄링 | High-level (Job Scheduling) | 시스템에 진입할 프로세스 수 결정 | "이 프로그램을 시스템에 들여보낼까?" |
| 저수준 스케줄링 | Low-level (CPU Scheduling) | CPU를 어떤 스레드에 할당할지 결정 | "지금 누구를 실행할까?" |
선점형 vs 비선점형
| 구분 | 선점형 (Preemptive) | 비선점형 (Non-preemptive) |
|---|---|---|
| 정의 | OS가 CPU를 강제로 빼앗아 다른 프로세스에 할당 | 프로세스가 자발적으로 반납할 때까지 CPU 독점 |
| 장점 | 하나의 프로세스가 CPU를 독점하는 것을 방지 | 구현이 단순, 문맥 교환 오버헤드 없음 |
| 단점 | 문맥 교환(Context Switch) 비용 발생 | 하나의 프로세스가 전체 시스템을 먹통으로 만들 수 있음 |
| 사용 | 대부분의 현대 OS (Windows, Linux, macOS) | 초기 OS, 일부 임베디드 시스템 |
프로세스 우선순위
운영체제는 프로세스의 종류에 따라 **우선순위(Priority)**를 부여한다.
PC 환경 vs 서버 환경
| 환경 | 우선하는 프로세스 | 이유 |
|---|---|---|
| PC | 전면(Foreground) 프로세스 | 사용자가 엑셀, 브라우저 등을 쓰려고 OS를 사용하기 때문. 사용자 체감 반응 속도가 최우선. |
| 서버 | 후면(Background) 프로세스 | 서버는 네트워크 요청, DB 쿼리 등 백그라운드 서비스가 핵심. IOCP(I/O Completion Port)가 빠른 이유도 이것. |
핵심 정리
주요 용어 정리
| 용어 | 영어 | 핵심 설명 |
|---|---|---|
| 레이스 컨디션 | Race Condition | 여러 스레드가 공유 자원에 동시 접근하여 결과가 비결정적이 되는 상태 |
| 임계 구역 | Critical Section | 동시 실행이 금지되어야 하는 공유 자원 접근 코드 구간 |
| 원자성 | Atomicity | 연산이 중간에 끊기지 않고 전부 실행되거나 전혀 실행되지 않는 성질 |
| 교착상태 | Deadlock | 프로세스들이 서로의 자원을 기다리며 무한 대기에 빠진 상태 |
| 모니터/큐 | Monitor/Queue | 스레드별 작업 큐를 통해 동기화를 달성하는 패턴 |
| IPC | Inter-Process Communication | 독립된 프로세스 간 데이터를 주고받는 메커니즘 |
| 공유 메모리 | Shared Memory | 가장 빠른 IPC. OS가 허락한 메모리 영역을 프로세스들이 공유 |
| 선점형 스케줄링 | Preemptive Scheduling | OS가 CPU를 강제 회수하여 다른 프로세스에 할당하는 방식 |
| 비선점형 스케줄링 | Non-preemptive Scheduling | 프로세스가 자발적으로 CPU를 반납할 때까지 기다리는 방식 |
| Spin Lock | Spin Lock | 반복문으로 잠금 해제를 확인하며 대기하는 원시적 동기화 기법 |
5줄 요약
- 레이스 컨디션은 C 코드 한 줄도 CPU 명령어 3개 이상으로 분해되기 때문에 발생한다 --- 중간에 다른 스레드가 끼어들면 결과를 예측할 수 없다.
- 임계 구역은 화장실처럼 Lock/Unlock으로 보호하되, 가능한 한 짧게 유지해야 하며, 없앨 수 있다면 없애라(Lock-free).
- 교착상태는 상호 배제, 점유와 대기, 비선점, 원형 대기 4가지 조건이 모두 충족될 때 발생하며, 하나만 깨뜨려도 예방된다.
- IPC는 파이프(스트림)와 공유 메모리(고정 블록) 두 축으로 나뉘며, 공유 메모리가 가장 빠르지만 동기화를 직접 구현해야 한다.
- CPU 스케줄링은 PC에서는 전면 프로세스를, 서버에서는 백그라운드 프로세스를 우선하며, 현대 OS는 대부분 선점형(Preemptive) 방식을 채택한다.