CS 로드맵 8편 — 프로세스와 스레드: OS는 실행 단위를 어떻게 추상화하는가
- CS 로드맵 (0) — AI 시대, CS 지식은 왜 더 중요해졌는가
- CS 로드맵 (1) — 배열과 연결 리스트: 메모리의 지형을 읽다
- CS 로드맵 (2) — 스택, 큐, 덱: 제한이 만드는 강력한 추상화
- CS 로드맵 (3) — 해시 테이블: O(1) 조회의 조건과 한계
- CS 로드맵 (4) — 트리: 순서와 균형, O(log n)의 보장
- CS 로드맵 (5) — 그래프: 관계의 네트워크, 경로의 과학
- CS 로드맵 (6) — 메모리 관리: 스택과 힙, GC, 그리고 프레임을 잡아먹는 것들
- CS 로드맵 (외전) — 힙과 우선순위 큐: 부분 순서의 경제학
- CS 로드맵 7편 — OS 아키텍처 입문: Unix, NT, XNU의 갈림길
- CS 로드맵 8편 — 프로세스와 스레드: OS는 실행 단위를 어떻게 추상화하는가
- CS 로드맵 9편 — 스케줄링: OS는 누구에게 CPU를 줄까
- 프로세스는 "독립된 주소 공간 + 자원의 묶음"이고, 스레드는 "프로세스 안의 실행 흐름"이다. 스레드는 코드/힙/전역 변수를 공유하지만 스택과 레지스터는 전용으로 갖는다
- Unix의 fork()는 프로세스를 복제 후 exec()로 덮어쓰는 2단계이고, Windows의 CreateProcess()는 한 번에 새로 만든다. 복제가 비싸 보이지만 Copy-on-Write로 실제로는 빠르다
- 스레드 모델은 1:1 (Linux NPTL, Windows), N:1 (그린 스레드), M:N (Go goroutine, Erlang)으로 나뉘며, 성능과 구현 복잡도의 트레이드오프가 다르다
- 컨텍스트 스위치는 레지스터 저장/복원뿐 아니라 TLB flush와 캐시 오염까지 일으키므로, 현대 게임 엔진은 "스레드 개수를 늘리기"보다 "Job/TaskGraph/Fiber로 작업을 잘게 쪼개 코어에 분배"하는 방향으로 간다
서론: 지도에서 본론으로
지난 편에서는 세 운영체제의 혈통과 뼈대를 훑었습니다. Linux는 모놀리식, Windows NT는 하이브리드, macOS XNU는 Mach + BSD 이중 구조. 이게 지도였다면, 이번 편부터는 본론입니다.
Stage 2의 핵심 질문을 다시 꺼내 보겠습니다.
“스레드 두 개가 같은 변수를 쓰면 왜 프로그램이 때때로만 죽는가?”
이 질문에 답하려면 먼저 “스레드가 뭔가”부터 정확히 알아야 합니다. 그리고 스레드를 이해하려면 그 상위 개념인 프로세스를 먼저 알아야 합니다. 프로세스와 스레드의 차이, 둘이 메모리를 어떻게 공유하고 어떻게 분리하는지, 그리고 OS가 이것을 어떻게 추상화하는지 — 이것이 동시성의 모든 문제의 출발점입니다.
이번 편에서 다루는 것:
- 프로세스: PCB와 주소 공간 레이아웃. Linux의
task_struct, Windows의EPROCESS, macOS의proc/task - 프로세스 생성: Unix의
fork()+exec()2단계 모델, Windows의CreateProcess()단일 호출, 그리고 Copy-on-Write - 스레드: 왜 프로세스만으로 부족한가, TCB, 공유 영역과 전용 영역, TLS
- 스레드 매핑 모델: 1:1, N:1, M:N — Go의 goroutine이 왜 그렇게 가벼운가
- 컨텍스트 스위칭: 레지스터·TLB·캐시 비용의 실제
- 게임 엔진의 실행 모델: Unity main thread, Unreal TaskGraph, Naughty Dog의 Fiber
게임 개발 시각을 계속 유지하면서도, 이번 편은 이론적 기초가 많습니다. 다음 편(스케줄링), 그 다음 편(동기화)이 이 위에 쌓이기 때문입니다.
Part 1: 프로세스 — OS가 보는 실행 단위
프로세스란 무엇인가
교과서적 정의부터 봅시다. 프로세스 (Process)는 실행 중인 프로그램입니다. 하드 디스크에 있는 .exe 파일이나 Mach-O 바이너리는 프로그램이고, 그것이 메모리에 적재되어 CPU에서 실행되는 인스턴스가 프로세스입니다.
프로세스가 가지는 것들:
- 고유한 주소 공간 (Address Space) — 다른 프로세스와 격리된 메모리
- 실행 상태 — CPU 레지스터 값, 프로그램 카운터
- 열린 파일 테이블 — 현재 사용 중인 파일 디스크립터 목록
- 소유자 정보 — UID, GID 등 권한
- 자식 프로세스 관계 — 누가 누구를 만들었나 (프로세스 트리)
OS는 이 모든 정보를 하나의 구조체로 관리합니다. 이것이 PCB (Process Control Block) 혹은 프로세스 디스크립터입니다.
PCB의 실체 — OS별 구조체
Linux — task_struct
Linux 커널에서 프로세스(그리고 스레드)를 나타내는 구조체는 struct task_struct입니다. include/linux/sched.h에 정의되어 있고, 수백 개의 필드를 가진 거대한 구조체입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* Linux 커널 task_struct의 일부 (kernel 6.x 기준, 극단 단순화) */
struct task_struct {
/* 상태 */
unsigned int __state; /* TASK_RUNNING 등 */
/* 식별자 */
pid_t pid; /* 프로세스 ID */
pid_t tgid; /* 스레드 그룹 ID */
struct task_struct *parent; /* 부모 프로세스 */
struct list_head children; /* 자식 목록 */
/* 메모리 */
struct mm_struct *mm; /* 주소 공간 */
/* 파일 */
struct files_struct *files; /* 열린 파일 테이블 */
/* 스케줄링 */
int prio;
struct sched_entity se; /* CFS 스케줄링 엔티티 */
/* 신호, 자원 제한 등 수백 필드... */
};
실제 구조체는 700줄이 넘습니다. Linux에서 프로세스와 스레드는 같은 구조체로 표현됩니다 — 이것이 Linux의 독특한 설계로, 뒤에서 다시 다룹니다.
Windows — EPROCESS, KPROCESS
Windows NT는 두 계층으로 나뉩니다:
KPROCESS(Kernel Process Block) — 스케줄링 관련 최소 정보EPROCESS(Executive Process Block) —KPROCESS를 감싸고 추가 정보 포함
1
2
3
4
5
6
7
8
9
/* 개념적 의사 코드 — 실제 Windows 내부는 WinDbg나 NT 소스 누출본 참조 */
typedef struct _EPROCESS {
KPROCESS Pcb; /* 커널 프로세스 블록 (상속) */
HANDLE UniqueProcessId; /* PID */
LIST_ENTRY ActiveProcessLinks; /* 전역 프로세스 리스트 */
PVOID SectionBaseAddress; /* 이미지 로드 주소 */
PVOID Token; /* 보안 토큰 */
/* ... */
} EPROCESS;
macOS — proc + task
macOS의 이중 구조가 여기서도 드러납니다. BSD 레이어에는 Unix 전통의 struct proc이 있고, Mach 레이어에는 struct task가 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* BSD 측 — bsd/sys/proc_internal.h */
struct proc {
pid_t p_pid; /* POSIX 프로세스 ID */
struct proc *p_pptr; /* 부모 */
struct task *task; /* Mach task로의 링크 */
/* ... */
};
/* Mach 측 — osfmk/kern/task.h */
struct task {
queue_head_t threads; /* 이 task에 속한 스레드들 */
vm_map_t map; /* 주소 공간 */
ipc_space_t itk_space; /* Mach port 공간 */
/* ... */
};
즉, macOS에서 fork()로 프로세스를 만들면 BSD의 proc과 Mach의 task가 쌍으로 생성됩니다. Unix 프로그램(ps, top)은 proc을 보고, Mach 기반 도구(lldb, Instruments)는 task를 봅니다.
프로세스 주소 공간 레이아웃
프로세스가 가진 메모리는 어떻게 배치되어 있을까요? 전통적인 Unix/Linux 프로세스의 32비트 주소 공간 레이아웃을 봅시다.
각 영역을 설명합니다 (낮은 주소부터):
- Text (
.text): 실행 가능한 기계어. 읽기 + 실행만 허용. 쓰기 시도는 세그멘테이션 폴트 - Read-only Data (
.rodata): 문자열 리터럴("Hello"), 상수 배열 등. 읽기 전용 - Data (
.data): 초기화된 전역/정적 변수 (int x = 42;). 파일에 초기값이 들어 있음 - BSS (Block Started by Symbol): 0으로 초기화된 전역 변수 (
int x;,static char buf[1024];). 파일에는 크기만 기록되고, 실행 시 OS가 0으로 채운다 — 실행 파일 크기를 줄이는 트릭 - Heap: 동적 할당 (
malloc,new).brk()시스템 콜로 위쪽으로 확장 - 공유 라이브러리 영역 (mmap):
libc.so,libstdc++.so등이mmap()으로 이 영역에 매핑됨 - Stack: 함수 호출 프레임, 지역 변수, 반환 주소. 아래쪽으로 자람
- Kernel Space: 커널 코드와 데이터. 유저 프로세스는 직접 접근 불가. 32비트 Linux에서는 상위 1GB, x86-64에서는 상위 절반
Windows도 PE와 다른 섹션 이름을 쓰지만 구조는 거의 동일합니다 (.text, .data, .rdata, .bss).
프로세스 상태 전이
프로세스는 여러 상태를 왔다 갔다 합니다. Silberschatz 교재의 표준 모델:
- New: 프로세스가 막 생성됨
- Ready: 실행 가능하지만 CPU를 기다리는 중
- Running: CPU에서 실제로 실행 중
- Waiting (또는 Blocked): I/O 완료나 이벤트를 기다리는 중
- Terminated: 종료됨
실제 OS들은 이보다 훨씬 더 복잡한 상태를 가집니다. Linux의 task_struct에는 TASK_RUNNING, TASK_INTERRUPTIBLE, TASK_UNINTERRUPTIBLE, TASK_STOPPED, TASK_TRACED, TASK_DEAD, TASK_WAKEKILL, TASK_WAKING, TASK_PARKED 등이 있습니다. ps에서 보이는 S, R, D, Z 같은 문자가 이것들입니다.
1
2
3
4
5
$ ps aux
USER PID %CPU %MEM COMMAND
root 1 0.0 0.1 /sbin/init <- S (sleeping)
www 1234 2.1 1.5 nginx: worker <- R (running)
root 5678 0.0 0.0 [kworker/u8:2] <- D (uninterruptible sleep)
D 상태 (uninterruptible sleep)는 게임 개발자에게도 중요합니다 — 디스크 I/O나 드라이버 요청을 기다리는 상태로, 이 상태에서는 kill -9조차 통하지 않습니다. “응답 없는 프로세스” 상당수가 D 상태입니다.
Part 2: 프로세스 생성 — fork, exec, CreateProcess
이제 프로세스를 어떻게 만드는가를 봅시다. 세 OS의 철학 차이가 가장 극명하게 드러나는 지점입니다.
Unix: fork() + exec() — 2단계 모델
Unix의 아이디어는 “부모를 복제한 다음 덮어쓴다”입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork(); /* 1단계: 자신을 복제 */
if (pid == 0) {
/* 자식 프로세스 */
execl("/bin/ls", "ls", "-l", NULL); /* 2단계: 새 프로그램으로 덮어쓰기 */
/* execl이 성공하면 여기는 실행되지 않음 */
} else if (pid > 0) {
/* 부모 프로세스 */
int status;
waitpid(pid, &status, 0); /* 자식 종료 대기 */
} else {
perror("fork failed");
}
return 0;
}
fork() 하나의 호출이 두 번 리턴합니다. 부모에게는 자식의 PID를, 자식에게는 0을 돌려줍니다. 희한한 API입니다.
fork()가 하는 일 (naive 구현):
- 새 PCB (
task_struct) 생성 - 부모의 주소 공간을 전부 복사 (text, data, heap, stack 모두)
- 열린 파일 디스크립터도 복사
- 자식에게 새 PID 할당
- 자식을 ready 큐에 넣음
2번이 문제입니다. 프로세스 주소 공간이 수백 MB일 때 매번 복사하면 엄청나게 비쌉니다. 그런데 fork() 직후 exec()를 부르면 어차피 주소 공간을 덮어쓸 텐데, 복사했다가 바로 버리는 셈입니다.
Copy-on-Write — “진짜로 쓸 때 복사하자”
해결책은 Copy-on-Write (COW)입니다. fork() 시점에는 페이지 테이블만 복사하고, 실제 메모리 페이지들은 부모와 자식이 공유합니다. 그런데 페이지들을 읽기 전용으로 표시해 둡니다.
어느 한쪽이 페이지에 쓰려고 하면 하드웨어가 page fault를 일으키고, OS가 그제야 해당 페이지만 복사해 줍니다.
COW는 하드웨어 지원이 필요합니다 — CPU의 MMU (Memory Management Unit)가 페이지 단위 보호와 page fault를 일으켜 주어야 OS가 개입할 수 있습니다. 그래서 페이지 단위 MMU는 현대 OS의 거의 모든 트릭(COW, 스왑, mmap, 공유 메모리)의 기반입니다.
Windows: CreateProcess() — 단일 호출
Windows는 다른 길을 갔습니다. 부모 복제 개념이 없고, 새 프로세스를 처음부터 만듭니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <windows.h>
int main() {
STARTUPINFO si = { sizeof(si) };
PROCESS_INFORMATION pi;
BOOL ok = CreateProcess(
"C:\\Windows\\System32\\notepad.exe", /* 실행 파일 */
NULL, /* 명령줄 */
NULL, NULL, /* 프로세스/스레드 보안 속성 */
FALSE, /* 핸들 상속 여부 */
0, /* 생성 플래그 */
NULL, NULL, /* 환경 변수, 작업 디렉토리 */
&si, &pi);
if (ok) {
WaitForSingleObject(pi.hProcess, INFINITE);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
}
return 0;
}
Unix의 fork()는 매개변수가 없는데, CreateProcess()는 10개의 매개변수를 받습니다. 이는 “프로세스 생성 시 설정 가능한 모든 옵션을 한 함수에 몰아넣는” Windows의 철학입니다.
트레이드오프:
| 측면 | Unix fork()+exec() | Windows CreateProcess() |
|---|---|---|
| API 복잡도 | 두 단계지만 각각 단순 | 한 단계지만 매개변수 많음 |
| 프로세스 생성 비용 | COW로 매우 저렴 | 상대적으로 비쌈 |
| 셸 구현 | 자연스럽 (fork → 리다이렉션 설정 → exec) | ShellExecute 같은 별도 API 필요 |
| 보안 | 부모 핸들이 자동 상속 (실수 여지 있음) | 명시적으로 상속 지정 |
| 유연성 | fork 후 exec 전에 임의 코드 실행 가능 | 생성 시점에만 설정 |
macOS — Unix 계승 + 몇 가지 트위스트
macOS는 BSD 계승이니 당연히 fork()와 exec()를 지원합니다. 하지만 XNU의 내부 구현은 약간 독특합니다.
BSD의 fork()가 Mach에 매핑될 때 실제로는:
- 현재
proc구조체를 복제 - 현재
task를 Mach 레벨에서 복제 (task_create()) - 초기 스레드 하나 만들기 (
thread_create()) - 주소 공간도 복제 (Mach의 vm_map을 COW로 복제)
즉, BSD fork() 호출 하나가 Mach 레이어의 여러 연산으로 분해됩니다. 이것이 XNU 이중 구조의 실제 모습입니다.
또 하나 흥미로운 것은 macOS의 posix_spawn()입니다. POSIX 표준인데 macOS가 적극 장려하는 API로, fork+exec를 한 번에 수행합니다.
1
posix_spawn(&pid, "/bin/ls", NULL, NULL, argv, environ);
왜 이걸 쓰라는가? iOS 때문입니다. iOS에서는 fork()가 보안상 금지됐고, posix_spawn()만 허용됩니다. 또한 내부 구현이 더 효율적인 경우도 있습니다 (COW 페이지 테이블 복제조차 건너뛸 수 있음).
잠깐, 이건 짚고 넘어가자
“iOS에서 fork()를 왜 금지했는가?”
세 가지 이유가 겹칩니다.
- 샌드박스 침해 위험: fork()된 자식 프로세스는 부모의 권한을 상속하는데, iOS의 엄격한 앱 샌드박스 모델에서는 이 경계를 깨뜨릴 수 있는 잠재적 취약점이 됩니다
- Objective-C 런타임의 상태 복제 문제: iOS 앱은 대부분 Objective-C나 Swift로 작성되며, 이들 언어의 런타임은 초기화 시 많은 상태(스레드, GCD 큐, IOKit 연결 등)를 생성합니다. fork() 이후 이들 상태가 일관성을 잃기 쉽습니다
- 메모리 효율: iOS는 메모리가 제한적이며 COW도 페이지 테이블 복제는 필요합니다. posix_spawn()은 이것조차 생략 가능
macOS에서는 fork()가 여전히 허용되지만, Apple은 “가능하면 posix_spawn()을 쓰라”고 권고합니다.
Part 3: 스레드 — 왜 프로세스만으로는 부족한가
프로세스 기반 동시성의 한계
1970~80년대 Unix는 프로세스 하나 = 실행 흐름 하나였습니다. 여러 일을 동시에 하려면 fork()로 프로세스를 여러 개 만들었습니다. 웹 서버라면 연결마다 프로세스를 하나씩 만드는 식 (고전적인 Apache prefork 모드).
이 모델의 문제:
- 프로세스 생성 비용: COW로 저렴해졌다지만, 페이지 테이블 복제, PCB 할당 등 여전히 수 마이크로초~밀리초 단위
- 컨텍스트 스위치 비용: 프로세스 간 전환 시 주소 공간도 바뀌므로 TLB flush가 필요 (뒤에서 자세히)
- 프로세스 간 통신 (IPC) 비용: 프로세스끼리는 주소 공간이 분리되어 있어, 데이터를 주고받으려면 파이프, 소켓, 공유 메모리 같은 무거운 메커니즘이 필요
- 공유 상태 표현의 어려움: 여러 실행 흐름이 같은 자료 구조를 다루고 싶을 때 복잡
1990년대 들어 해결책이 필요해졌고, 그것이 스레드 (Thread)입니다.
스레드의 정의
스레드는 프로세스 내부의 독립된 실행 흐름입니다. 한 프로세스 안에 여러 스레드가 있으면, 모두가 같은 주소 공간을 공유하면서 각자 CPU에서 동시에 실행될 수 있습니다.
스레드가 공유하는 것:
- Text (코드): 당연히 같은 코드를 실행
- Heap:
malloc으로 할당한 메모리 - Data / BSS: 전역 변수, 정적 변수
- 열린 파일 디스크립터
- 신호 핸들러
스레드가 따로 가지는 것:
- 스택 (Stack): 각 스레드마다 별도
- CPU 레지스터 상태: PC, SP, 범용 레지스터 등
- TLS (Thread-Local Storage): 스레드별 전역 변수
- 에러 상태:
errno(POSIX에서는 스레드별)
이 그림에서 중요한 점:
- 스레드 간에는 heap과 전역 변수가 그냥 공유됩니다 — “공유 메모리”가 자연스럽게 존재
- 즉 스레드 두 개가 같은
int counter를 동시에counter++하면 race condition이 생깁니다 - 반면 프로세스 두 개는 주소 공간이 분리되어 있어 자연히 격리됨
Stage 2의 핵심 질문 — “스레드 두 개가 같은 변수를 쓰면 왜 프로그램이 때때로만 죽는가?” — 의 답이 이 그림 안에 있습니다. 스레드는 의도적으로 메모리를 공유하기 때문에 동시성 문제가 생기고, 그것을 관리할 동기화 기법이 필요합니다. (다음 편 [Part 10 동기화 프리미티브]에서 본격적으로 다룹니다.)
TCB — 스레드 제어 블록
프로세스에 PCB가 있듯 스레드에는 TCB (Thread Control Block)이 있습니다. TCB가 담는 것:
- 스레드 ID
- CPU 레지스터 상태 (저장된 컨텍스트)
- 스레드 상태 (Running, Ready, Waiting)
- 스택 포인터, 스택 베이스
- 스케줄링 정보 (우선순위 등)
- 소속 프로세스 포인터
OS별 구현:
- Linux:
task_struct— 프로세스와 스레드를 같은 구조체로 표현. 어떤 필드를 공유하느냐로 구분 - Windows:
KTHREAD+ETHREAD - macOS: Mach의
struct thread
Linux의 독특한 철학 — “프로세스와 스레드는 같다”
Linus Torvalds는 1990년대에 과감한 결정을 내렸습니다. “프로세스와 스레드를 별도 개념으로 만들지 말고, 하나의 ‘실행 단위’로 통합하자.”
Linux에서는 fork() 대신 더 일반적인 clone() 시스템 콜이 있습니다. clone()은 “부모와 무엇을 공유할지”를 비트 플래그로 지정합니다.
1
2
3
4
5
6
7
8
9
10
/* Linux clone() — 개념 */
clone(fn, stack, flags, arg);
/* 플래그 예: */
CLONE_VM /* 주소 공간 공유 (true이면 스레드, false이면 프로세스) */
CLONE_FS /* 파일 시스템 상태 공유 */
CLONE_FILES /* 파일 디스크립터 공유 */
CLONE_SIGHAND /* 신호 핸들러 공유 */
CLONE_THREAD /* 같은 스레드 그룹에 소속 */
/* ... */
fork()=clone()with 모든 공유 플래그 OFFpthread_create()=clone()with 모든 공유 플래그 ON- 그 사이의 어떤 조합도 가능
이것이 Linux의 “프로세스와 스레드는 연속적”인 관점입니다. 실제로 Android 같은 환경에서는 “일부만 공유하는” 프로세스 복제를 유용하게 사용합니다 (Zygote 프로세스).
TLS — Thread-Local Storage
스레드별로 전역처럼 보이지만 실제로는 스레드마다 독립적인 변수가 필요할 때가 있습니다. 이것이 TLS입니다.
전형적 예: errno. POSIX에서 errno는 “마지막 시스템 콜의 오류 코드”인데, 스레드마다 별개여야 합니다 (스레드 A가 read()를 실패한 결과를 스레드 B가 덮어쓰면 안 됨). 그래서 errno는 TLS로 구현됩니다.
언어별 TLS 선언:
1
2
3
4
5
/* C11 */
_Thread_local int counter = 0;
/* GCC/Clang 확장 */
__thread int counter = 0;
1
2
// C++11
thread_local int counter = 0;
1
2
3
4
5
6
// C#
[ThreadStatic]
static int counter;
// 또는 더 유연한 ThreadLocal<T>
static ThreadLocal<int> counter = new ThreadLocal<int>(() => 0);
게임 개발에서의 실용 예:
- 로깅 시스템에서 각 스레드의 이름을 TLS로 저장해 로그 라인에 포함
- 렌더링에서 스레드별 command buffer 할당 후 나중에 merge
- 프로파일링에서 현재 실행 중인 스코프 스택을 스레드별로 관리
Part 4: 스레드 모델 — 1:1, N:1, M:N
이제 좀 더 깊은 질문입니다. 여러분이 pthread_create()나 new Thread()를 부를 때, OS 커널은 그 스레드를 어떻게 관리할까요?
왜 이 질문이 중요한가
CPU에서 실제로 실행 가능한 단위는 커널 스레드 (Kernel-level Thread, KLT)입니다. 커널만이 CPU를 스케줄링할 수 있기 때문입니다.
반면 프로그램이 만드는 “스레드”는 그저 유저 공간의 추상화일 수 있습니다. 이것을 유저 스레드 (User-level Thread, ULT)라고 부릅니다.
유저 스레드와 커널 스레드의 매핑 방식이 세 가지로 나뉩니다.
1:1 모델 — 현대 Linux/Windows의 선택
1:1 모델에서는 유저가 만든 스레드 하나가 곧 커널 스레드 하나입니다. pthread_create()가 내부적으로 clone() 시스템 콜을 호출해 커널이 관리하는 태스크를 직접 만듭니다.
Linux NPTL (Native POSIX Thread Library): Linux 2.6부터 glibc의 pthread 구현은 NPTL을 사용하고, NPTL은 1:1 모델입니다. 이전에는 LinuxThreads라는 비표준 1:1 구현이 있었는데, NPTL이 POSIX 준수 + 성능으로 대체했습니다.
Windows: CreateThread()는 커널의 KTHREAD를 직접 만듭니다. 역시 1:1.
장점: 스레드가 블로킹되어도 다른 스레드는 계속 돌아감. 멀티코어에서 자동 분산.
단점: 스레드 생성 비용이 비교적 크고, 수만~수십만 개가 되면 커널 메모리 압박.
N:1 모델 — 과거의 유산
N:1 모델에서는 여러 유저 스레드가 커널 스레드 하나에 매핑됩니다. 커널은 “이 프로세스에 스레드가 여럿 있다”는 걸 모릅니다 — 프로세스 하나로만 봅니다.
이 모델은 Java의 초기 “그린 스레드”, GNU Pth 같은 라이브러리에서 사용됐습니다. 1990년대 초반에는 표준이었지만, 치명적 단점 때문에 거의 사라졌습니다:
- 블로킹 시스템 콜이 전체를 멈춤: 유저 스레드 하나가
read()로 블록되면, 같은 커널 스레드를 공유하는 모든 유저 스레드가 멈춤 - 멀티코어를 못 씀: 커널 스레드 하나는 CPU 코어 하나에만 할당됨
M:N 모델 — Go의 선택
M:N 모델은 두 모델의 장점을 합칩니다. M개의 유저 스레드가 N개의 커널 스레드 풀에 동적으로 매핑됩니다 (보통 N = CPU 코어 수).
대표 구현:
- Go goroutine: Go 런타임이 M:N 스케줄러. 수백만 goroutine을 수 개의 OS 스레드로 돌림
- Erlang/Elixir: BEAM VM이 자체 스케줄러 구현
- 옛 Solaris (Solaris 2~8): 표준 POSIX pthreads를 M:N으로 구현했지만, 복잡성 때문에 Solaris 9에서 1:1로 전환
이론적 배경 — Anderson 등의 1991년 SOSP 논문 Scheduler Activations: “유저 레벨 스레드 라이브러리가 커널과 협력해 M:N을 효율적으로 구현하려면 어떤 커널 지원이 필요한가”를 다뤘습니다. 핵심은 블로킹 시스템 콜 시 커널이 유저 스케줄러를 깨워 다른 유저 스레드를 다른 커널 스레드에 할당하게 해야 한다는 것.
Go 런타임은 이와 유사한 아이디어를 구현합니다. goroutine이 blocking syscall을 부르려 하면 런타임이 그것을 감지해 그 goroutine을 다른 커널 스레드에 이식하거나, 새 커널 스레드를 만듭니다. 그래서 net.Listen이 블록되어도 다른 goroutine이 영향받지 않습니다.
게임 개발 입장에서
Unity, Unreal이 쓰는 스레드는 C++/C# 수준에서는 1:1 모델입니다. new Thread()나 std::thread가 커널 스레드를 직접 만듭니다.
그러나 엔진 내부의 Job 시스템이나 Task 그래프는 사실상 M:N 스케줄러입니다. 프로그래머가 수천 개의 “Job”을 발행해도 실제로는 엔진이 만든 수 개의 워커 스레드에서 돌아갑니다. 이건 Part 7 (Part 13 Lock-free와 구조적 해결)에서 자세히 다룰 Unity Job System 설계와 직결됩니다.
Part 5: 3-OS 스레드 API 비교
Linux — pthreads
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <pthread.h>
#include <stdio.h>
void* worker(void* arg) {
int id = *(int*)arg;
printf("Thread %d running\n", id);
return NULL;
}
int main() {
pthread_t t1, t2;
int id1 = 1, id2 = 2;
pthread_create(&t1, NULL, worker, &id1);
pthread_create(&t2, NULL, worker, &id2);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return 0;
}
POSIX 표준 API. 내부적으로 clone() 시스템 콜을 호출. 공식적 이름은 “pthread”이지만, Linux man 페이지를 보면 실제로는 NPTL (glibc 구현) 문서입니다.
Windows — CreateThread / _beginthreadex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <windows.h>
#include <process.h>
unsigned __stdcall worker(void* arg) {
int id = *(int*)arg;
printf("Thread %d running\n", id);
return 0;
}
int main() {
HANDLE t1, t2;
int id1 = 1, id2 = 2;
t1 = (HANDLE)_beginthreadex(NULL, 0, worker, &id1, 0, NULL);
t2 = (HANDLE)_beginthreadex(NULL, 0, worker, &id2, 0, NULL);
WaitForSingleObject(t1, INFINITE);
WaitForSingleObject(t2, INFINITE);
CloseHandle(t1);
CloseHandle(t2);
return 0;
}
왜 CreateThread가 아닌 _beginthreadex? CreateThread는 CRT (C Runtime Library)의 초기화 상태를 건너뜁니다 — errno, strtok 같은 스레드 별 상태가 초기화되지 않아 문제가 생깁니다. _beginthreadex는 CRT와 함께 올바르게 초기화되므로 C/C++ 코드에서는 이쪽을 써야 합니다.
macOS — pthreads + libdispatch
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* POSIX 방식 — Linux와 동일 */
#include <pthread.h>
/* ... */
/* libdispatch (GCD) 방식 — macOS 권장 */
#include <dispatch/dispatch.h>
int main() {
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
printf("Running in background\n");
dispatch_async(dispatch_get_main_queue(), ^{
printf("Back to main thread\n");
});
});
dispatch_main();
return 0;
}
macOS에서도 pthreads는 지원되지만 Apple은 GCD (Grand Central Dispatch)를 권장합니다. 이유는 Part 7에서 다뤘습니다 — 스레드 수명을 수동 관리하지 않아도 됨, QoS 클래스로 P/E 코어 자동 활용, 예측 가능한 큐 추상화 등.
C# — 언어 차원의 추상화
C#은 위 세 OS 모두에서 돕니다. .NET 런타임(CLR 또는 CoreCLR)이 OS 차이를 숨겨줍니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using System;
using System.Threading;
using System.Threading.Tasks;
// 1) 가장 원시적인 방법 — 거의 안 씀
Thread t = new Thread(() => Console.WriteLine("Hello"));
t.Start();
t.Join();
// 2) ThreadPool — 스레드 재사용
ThreadPool.QueueUserWorkItem(_ => Console.WriteLine("Hello"));
// 3) Task / async-await — 현대적 권장
await Task.Run(() => HeavyComputation());
// 4) Parallel — 데이터 병렬성
Parallel.For(0, 100, i => ProcessItem(i));
내부적으로:
- Linux: libcoreclr이
pthread_create()사용 - Windows:
CreateThread()사용 - macOS:
pthread_create()사용 (GCD는 직접 쓰지 않음)
Unity의 특수성: Unity는 Thread 사용을 제한적으로 권장합니다. 대신 Job System과 UniTask, Coroutine을 쓰라고 합니다. 이유는 Unity Engine API 대부분이 main thread 외에서 호출하면 크래시하기 때문입니다. (Part 13에서 자세히)
Part 6: 컨텍스트 스위칭 — 왜 비싼가
컨텍스트 스위칭이란
CPU 코어 하나에서 스레드 여러 개를 번갈아 실행하려면, 현재 스레드의 상태를 저장하고 다음 스레드의 상태를 복원해야 합니다. 이것이 컨텍스트 스위칭입니다.
저장해야 하는 것:
- CPU 레지스터: RAX, RBX, …, RIP (프로그램 카운터), RSP (스택 포인터), 플래그 레지스터
- 부동소수점 레지스터: XMM, YMM, ZMM (AVX 시대에는 수십 KB)
- MMU 상태: 프로세스가 바뀌면 페이지 테이블 포인터 (x86의 CR3 레지스터) 교체 필요
컨텍스트 스위칭의 “숨은 비용”
레지스터 저장/복원은 사실 빙산의 일각입니다. 진짜 비싼 건 간접 효과입니다.
TLB와 프로세스 간 스위치
TLB (Translation Lookaside Buffer)는 CPU 내부의 작은 캐시로, “가상 주소 → 물리 주소” 변환 결과를 저장합니다. L1 TLB는 보통 64~128 엔트리 정도입니다.
프로세스가 바뀌면 CR3 레지스터(페이지 테이블 베이스)가 바뀌고, TLB는 완전히 flush 됩니다 (PCID/ASID 최적화가 없다면). 그러면 이후 메모리 접근마다 페이지 테이블을 다시 거슬러 찾아야 합니다.
스레드 간 스위치는 덜 비쌉니다 — 같은 주소 공간을 공유하므로 CR3가 바뀌지 않아 TLB flush도 없습니다. 이것이 “프로세스보다 스레드가 가볍다”는 말의 구체적 근거 중 하나입니다.
측정하기
Linux에서는 perf stat로 측정할 수 있습니다:
1
2
3
4
5
6
7
$ perf stat -e context-switches,cpu-migrations,cache-misses -p <PID> sleep 10
Performance counter stats for process id '1234':
12,345 context-switches
567 cpu-migrations
10,234,567 cache-misses
macOS에서는 Instruments의 System Trace 템플릿으로 스레드 스케줄링과 컨텍스트 스위치를 마이크로초 단위로 관찰 가능합니다.
Windows에서는 Xperf 또는 Windows Performance Analyzer가 같은 역할.
LaMarca & Ladner의 관찰
캐시 친화성 측면에서 LaMarca & Ladner 1996 — “The Influence of Caches on the Performance of Heaps” 같은 연구가 다뤘듯, 알고리즘의 이론적 복잡도만으로는 실제 성능을 예측할 수 없습니다. 같은 이치로, 스레드를 많이 만들수록 빨라질 것이라는 순진한 기대는 캐시/TLB 비용 때문에 깨지기 쉽습니다.
“최적 스레드 수 = 코어 수”라는 규칙은 이 관찰에서 나옵니다. 그 이상은 컨텍스트 스위칭이 이득을 잠식합니다.
Part 7: 게임 엔진의 실행 모델
이제 이론을 게임 엔진에 연결합니다.
Unity — Main Thread의 강한 제약
Unity 개발자라면 “이 API는 메인 스레드에서만 호출할 수 있다”는 경고를 한 번쯤 봤을 겁니다. Transform.position, GameObject.Instantiate(), Renderer.sharedMaterial 등 대부분의 Unity Engine API가 메인 스레드 전용입니다.
왜인가?
Unity Engine은 C++로 작성됐고, 내부 자료 구조에 락이 없습니다. Unity 팀이 “모든 엔진 호출은 메인 스레드에서 온다”는 가정 하에 설계해서, 락 획득 오버헤드를 없앴습니다.
이것은 의도적 트레이드오프입니다:
- ✅ 엔진 호출이 매우 빠름 (락 없음)
- ❌ 멀티스레드 활용이 어려움
Unity의 해결책: Job System + Burst + Native Containers. 메인 스레드는 그대로 두고, 데이터 처리만 병렬화하는 별도 레이어를 제공합니다. (Part 13에서 상세)
Unreal Engine — Task Graph
Unreal Engine은 Task Graph 시스템을 씁니다. 게임 코드가 발행한 “태스크”들이 의존성 DAG를 이루고, 엔진이 워커 스레드 풀에 분배합니다.
Unreal의 워커 스레드 풀:
- Game Thread: 게임 로직 (Unity의 메인 스레드에 해당)
- Render Thread: 렌더링 명령 빌드
- RHI Thread: GPU 드라이버 호출
- Worker Threads: 나머지 범용 작업
태스크는 ENamedThreads로 실행될 스레드를 지정합니다. 예: ENamedThreads::GameThread, ENamedThreads::AnyBackgroundHiPriTask.
Fiber — Naughty Dog의 접근
Christian Gyrling의 GDC 2015 강연 “Parallelizing the Naughty Dog Engine Using Fibers”은 Fiber 기반 엔진 설계로 유명합니다.
Fiber는 협력적 유저 레벨 스레드입니다. OS가 관여하지 않고 애플리케이션이 스스로 스위치합니다. 커널 스레드가 한 명의 일꾼이라면, 그 일꾼이 들고 있는 여러 일거리가 Fiber입니다.
- Fiber 생성 비용: 극히 저렴 (수 나노초)
- Fiber 스위치: 레지스터만 저장/복원, 커널 개입 없음
- 수천 개 발행 가능
Naughty Dog의 Last of Us 2는 이 시스템으로 PS4의 7코어를 안정적으로 활용했습니다. Fiber는 M:N 모델의 한 형태로 볼 수 있습니다 (Fiber = 유저 스레드, 커널 스레드 = 워커).
Windows의 Fiber API: CreateFiber, SwitchToFiber. macOS/Linux에서는 ucontext.h의 makecontext/swapcontext (레거시, 권장 안 됨) 또는 Boost.Context, libco 같은 라이브러리를 써야 합니다.
엔진 실행 모델 비교
Part 8: 실전 관찰 — 내 스레드는 어떻게 돌고 있는가
이론을 알고 나서 이제 실제로 봅시다. 세 OS 모두 프로세스와 스레드를 관찰하는 풍부한 도구를 제공합니다.
Linux — /proc, ps, top
Linux에서는 모든 것이 /proc 가상 파일 시스템에 노출됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 특정 프로세스의 스레드 목록
$ ls /proc/<PID>/task/
1234 1235 1236 ...
# 각 스레드의 상태
$ cat /proc/1234/task/1234/status
Name: myapp
State: R (running)
Tgid: 1234
Pid: 1234
Threads: 8
# 주소 공간 매핑
$ cat /proc/1234/maps
00400000-00452000 r-xp 00000000 08:01 12345 /usr/bin/myapp
00651000-00652000 r--p 00051000 08:01 12345 /usr/bin/myapp
7f1234000000-7f1234021000 r-xp 00000000 08:01 54321 /lib/x86_64-linux-gnu/libc.so.6
...
top -H로 스레드 단위 CPU 사용률을 볼 수 있습니다.
macOS — Activity Monitor, ps, Instruments
Activity Monitor는 GUI 도구이지만, 더 정밀한 데이터는 CLI 도구에 있습니다.
1
2
3
4
5
# 프로세스 스레드 수 확인
$ ps -M <PID>
# 상세 정보
$ sample <PID> 5 -mayDie
Instruments의 System Trace 템플릿이 가장 강력합니다. P/E 코어별 실행 타임라인, 컨텍스트 스위치 이벤트, 블로킹 원인까지 다 보여줍니다. Apple Silicon 환경에서 특히 유용 — 어떤 스레드가 P-core에서 돌았고 어떤 스레드가 E-core로 밀렸는지 시각화됩니다.
Windows — Process Explorer, WPA
Process Explorer (Sysinternals)는 작업 관리자의 강화판:
- 프로세스 트리 시각화
- 각 프로세스의 스레드 목록 + 스택 추적
- 핸들, DLL, 메모리 상세
Windows Performance Analyzer (WPA)는 Instruments에 해당. Xperf로 수집한 ETW 이벤트를 분석합니다.
C#에서 스레드 다루기 — 코드 예시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
class ThreadInspector {
static void Main() {
Console.WriteLine($"현재 프로세스 ID: {Process.GetCurrentProcess().Id}");
Console.WriteLine($"관리 스레드 ID: {Thread.CurrentThread.ManagedThreadId}");
Console.WriteLine($"CPU 코어 수: {Environment.ProcessorCount}");
// 스레드 생성 비용 측정
var sw = Stopwatch.StartNew();
var threads = new Thread[100];
for (int i = 0; i < 100; i++) {
threads[i] = new Thread(() => Thread.Sleep(1));
threads[i].Start();
}
foreach (var t in threads) t.Join();
sw.Stop();
Console.WriteLine($"100개 스레드 생성+종료: {sw.ElapsedMilliseconds}ms");
// ThreadPool.Queue는 훨씬 빠름
sw.Restart();
var countdown = new CountdownEvent(100);
for (int i = 0; i < 100; i++) {
ThreadPool.QueueUserWorkItem(_ => {
Thread.Sleep(1);
countdown.Signal();
});
}
countdown.Wait();
sw.Stop();
Console.WriteLine($"100개 ThreadPool 작업: {sw.ElapsedMilliseconds}ms");
}
}
실행 결과 (제 머신 기준 대략):
1
2
3
4
5
현재 프로세스 ID: 12345
관리 스레드 ID: 1
CPU 코어 수: 8
100개 스레드 생성+종료: 85ms
100개 ThreadPool 작업: 8ms
10배 차이. 이것이 스레드 풀을 쓰는 실용적 이유입니다. .NET의 ThreadPool, Java의 ExecutorService, C++의 std::async 모두 같은 아이디어입니다 — 스레드를 재사용해 생성 비용을 분할 상환.
정리
이 편에서 다룬 것:
프로세스:
- PCB (
task_struct,EPROCESS,proc+task) — OS가 프로세스를 추적하는 구조 - 주소 공간 레이아웃: Text, Data, BSS, Heap, Stack, Kernel
- 프로세스 상태 전이: New, Ready, Running, Waiting, Terminated
프로세스 생성:
- Unix의
fork() + exec()— 2단계, Copy-on-Write로 실제로는 빠름 - Windows의
CreateProcess()— 1단계, 매개변수 많음 - macOS의
posix_spawn()— iOS 호환 + 더 효율적 - fork() 시 COW는 하드웨어 MMU 지원에 기반
스레드:
- 프로세스 vs 스레드: 주소 공간 공유 여부가 핵심
- 공유: Text, Data, Heap, 파일 디스크립터
- 전용: Stack, 레지스터, TLS
- Linux의 독특한 철학: 프로세스와 스레드를 같은 구조체로 표현 (
clone())
스레드 매핑 모델:
- 1:1 (Linux NPTL, Windows): 표준, 진정한 병렬
- N:1 (옛 그린 스레드): 거의 사장
- M:N (Go goroutine, Erlang): 수백만 동시 스레드, 런타임 구현 복잡
컨텍스트 스위칭:
- 직접 비용: 레지스터 저장/복원 ~1-10μs
- 숨은 비용: TLB flush, 캐시 오염, 분기 예측기 오염
- 프로세스 간 스위치가 스레드 간 스위치보다 비싸다 (CR3 교체)
- “스레드 수 = 코어 수” 원칙
게임 엔진 실행 모델:
- Unity: Main Thread 제약 + Job System (데이터 병렬화)
- Unreal: 여러 Named Thread + Task Graph
- Naughty Dog 엔진: Fiber 기반 협력적 스케줄링
다음 편은 Part 9 스케줄링 — 여러 스레드가 준비 상태일 때 OS는 누구에게 CPU를 줄까요? Linux의 CFS → EEVDF, Windows의 priority boost, macOS의 QoS 기반 스케줄링을 살펴봅니다. 게임 프레임 예산 16.67ms와 priority inversion 문제도 다룹니다.
References
교재
- Silberschatz, Galvin, Gagne — Operating System Concepts, 10th ed., Wiley, 2018 — Ch.3 (Processes), Ch.4 (Threads)
- Bovet, Cesati — Understanding the Linux Kernel, 3rd ed., O’Reilly, 2005 —
task_struct와 프로세스 관리 Ch.3 - Mauerer — Professional Linux Kernel Architecture, Wrox, 2008 — 현대 Linux 커널 내부
- Russinovich, Solomon, Ionescu — Windows Internals, 7th ed., Microsoft Press, 2017 — EPROCESS/ETHREAD 상세
- Singh — Mac OS X Internals: A Systems Approach, Addison-Wesley, 2006 — XNU의 task/proc 이중 구조
- Butenhof — Programming with POSIX Threads, Addison-Wesley, 1997 — pthreads의 고전
- Stevens, Rago — Advanced Programming in the UNIX Environment, 3rd ed., Addison-Wesley, 2013 — fork/exec 실전
- Gregory — Game Engine Architecture, 3rd ed., CRC Press, 2018 — Ch.8 멀티프로세서 엔진 설계
논문
- Anderson, Bershad, Lazowska, Levy — “Scheduler Activations: Effective Kernel Support for the User-Level Management of Parallelism”, SOSP 1991 — DOI — M:N 모델의 이론적 기초
- Mogul, Borg — “The Effect of Context Switches on Cache Performance”, ASPLOS 1991 — 컨텍스트 스위치의 숨은 비용 측정
- Engelschall — “Portable Multithreading: The Signal Stack Trick for User-Space Thread Creation”, USENIX 2000 — 유저 레벨 스레드 구현
- Kleiman, Smaalders — “The LWP Framework: Building and Debugging Mach Tasks and Threads”, Mach Workshop 1990 — Mach의 스레드 모델
공식 문서
- Linux man pages —
clone(2),fork(2),pthread_create(3),proc(5)— man7.org - Apple Developer — Threading Programming Guide — developer.apple.com
- Apple Developer — Dispatch — developer.apple.com/documentation/dispatch
- Microsoft Docs — Processes and Threads — learn.microsoft.com
- Microsoft Docs — Fibers — learn.microsoft.com
- Go Runtime — The Go Scheduler (Dmitry Vyukov) — morsmachine.dk/go-scheduler
게임 개발 / GDC 자료
- Gyrling, C. — Parallelizing the Naughty Dog Engine Using Fibers, GDC 2015 — gdcvault.com
- Unity Technologies — C# Job System manual — docs.unity3d.com
- Unreal Engine Documentation — Task Graph System — dev.epicgames.com
- Fabian Giesen — Reading List on Multithreading and Synchronization — fgiesen.wordpress.com
블로그 / 기사
- Raymond Chen — The Old New Thing — Win32 CreateProcess 내부
- Linus Torvalds — comp.os.minix 스레드 관련 초기 논의 (1992)
- Dmitry Vyukov — 1024cores.net — 무잠금 동시성 자료 (Go scheduler 내부 포함)
- Howard Oakley — The Eclectic Light Company — macOS 스레드 관찰 기법
도구
- Linux:
ps,top,htop,strace,perf,ftrace - macOS: Activity Monitor,
ps,sample, Instruments (System Trace, Time Profiler) - Windows: Task Manager, Process Explorer, WPA, PerfView
- 크로스플랫폼: Tracy Profiler — 게임에 임베드하기 좋음
