포스트

CS 로드맵 8편 — 프로세스와 스레드: OS는 실행 단위를 어떻게 추상화하는가

CS 로드맵 8편 — 프로세스와 스레드: OS는 실행 단위를 어떻게 추상화하는가
TL;DR — 핵심 요약
  • 프로세스는 "독립된 주소 공간 + 자원의 묶음"이고, 스레드는 "프로세스 안의 실행 흐름"이다. 스레드는 코드/힙/전역 변수를 공유하지만 스택과 레지스터는 전용으로 갖는다
  • 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로 작업을 잘게 쪼개 코어에 분배"하는 방향으로 간다
Visitors

Hits

서론: 지도에서 본론으로

지난 편에서는 세 운영체제의 혈통과 뼈대를 훑었습니다. 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에서 실행되는 인스턴스가 프로세스입니다.

프로세스가 가지는 것들:

  1. 고유한 주소 공간 (Address Space) — 다른 프로세스와 격리된 메모리
  2. 실행 상태 — CPU 레지스터 값, 프로그램 카운터
  3. 열린 파일 테이블 — 현재 사용 중인 파일 디스크립터 목록
  4. 소유자 정보 — UID, GID 등 권한
  5. 자식 프로세스 관계 — 누가 누구를 만들었나 (프로세스 트리)

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비트 주소 공간 레이아웃을 봅시다.

프로세스 주소 공간 레이아웃 (개념도) Kernel Space (유저 프로세스가 직접 접근 불가) 높은 주소 0xFFFFFFFF Stack 함수 호출 프레임, 지역 변수 ↓ 아래로 자란다 미사용 영역 Stack이 자랄 공간 mmap된 공유 라이브러리가 여기 배치 (libc, libdl, 힙 확장 등) Heap malloc / new로 할당되는 메모리 ↑ 위로 자란다 (brk / sbrk) BSS (Uninitialized Data) int x; (0으로 초기화) Data (Initialized) int x = 42; Read-only Data (.rodata) Text (Code) 실행 가능 기계어 낮은 주소 0x00400000 보호 RW RW RW RW R RX

각 영역을 설명합니다 (낮은 주소부터):

  • 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 교재의 표준 모델:

프로세스 상태 전이 (Silberschatz 모델) New Ready Running Terminated Waiting admitted scheduler dispatch interrupt I/O or event wait I/O or event completion exit
  • 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 구현):

  1. 새 PCB (task_struct) 생성
  2. 부모의 주소 공간을 전부 복사 (text, data, heap, stack 모두)
  3. 열린 파일 디스크립터도 복사
  4. 자식에게 새 PID 할당
  5. 자식을 ready 큐에 넣음

2번이 문제입니다. 프로세스 주소 공간이 수백 MB일 때 매번 복사하면 엄청나게 비쌉니다. 그런데 fork() 직후 exec()를 부르면 어차피 주소 공간을 덮어쓸 텐데, 복사했다가 바로 버리는 셈입니다.

Copy-on-Write — “진짜로 쓸 때 복사하자”

해결책은 Copy-on-Write (COW)입니다. fork() 시점에는 페이지 테이블만 복사하고, 실제 메모리 페이지들은 부모와 자식이 공유합니다. 그런데 페이지들을 읽기 전용으로 표시해 둡니다.

어느 한쪽이 페이지에 쓰려고 하면 하드웨어가 page fault를 일으키고, OS가 그제야 해당 페이지만 복사해 줍니다.

fork() + Copy-on-Write의 실제 동작 1) fork() 호출 직후 부모 페이지 테이블 자식 페이지 테이블 (복사) 물리 페이지 (읽기 전용) 페이지 테이블만 복사 — 빠르다 실제 메모리는 공유 2) 자식이 페이지에 쓰기 시도 부모 페이지 테이블 자식 쓰기 시도 ✍️ 물리 페이지 (읽기 전용) ⚡ Page Fault 발생 CPU → OS에게 처리 요청 3) OS가 페이지를 복사 부모 자식 원본 (RW 복원) 복사본 (자식 전용) 실제로 쓴 페이지만 복사 나머지는 계속 공유 결과: fork()는 "복사"가 아니라 "공유 + 지연 복사" • fork() 자체는 페이지 테이블 크기만큼만 작업 — 수 마이크로초 • 자식이 대부분의 페이지를 쓰지 않고 바로 exec()를 부르면 복사 비용이 0에 가깝다 • 페이지 수준 granularity (보통 4KB 또는 16KB) — 바이트 하나 쓰면 페이지 전체 복사 • Linux는 이걸 태스크 생성의 기본으로 삼아 프로세스 생성이 극히 빠르다

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에 매핑될 때 실제로는:

  1. 현재 proc 구조체를 복제
  2. 현재 task를 Mach 레벨에서 복제 (task_create())
  3. 초기 스레드 하나 만들기 (thread_create())
  4. 주소 공간도 복제 (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()를 왜 금지했는가?”

세 가지 이유가 겹칩니다.

  1. 샌드박스 침해 위험: fork()된 자식 프로세스는 부모의 권한을 상속하는데, iOS의 엄격한 앱 샌드박스 모델에서는 이 경계를 깨뜨릴 수 있는 잠재적 취약점이 됩니다
  2. Objective-C 런타임의 상태 복제 문제: iOS 앱은 대부분 Objective-C나 Swift로 작성되며, 이들 언어의 런타임은 초기화 시 많은 상태(스레드, GCD 큐, IOKit 연결 등)를 생성합니다. fork() 이후 이들 상태가 일관성을 잃기 쉽습니다
  3. 메모리 효율: iOS는 메모리가 제한적이며 COW도 페이지 테이블 복제는 필요합니다. posix_spawn()은 이것조차 생략 가능

macOS에서는 fork()가 여전히 허용되지만, Apple은 “가능하면 posix_spawn()을 쓰라”고 권고합니다.


Part 3: 스레드 — 왜 프로세스만으로는 부족한가

프로세스 기반 동시성의 한계

1970~80년대 Unix는 프로세스 하나 = 실행 흐름 하나였습니다. 여러 일을 동시에 하려면 fork()로 프로세스를 여러 개 만들었습니다. 웹 서버라면 연결마다 프로세스를 하나씩 만드는 식 (고전적인 Apache prefork 모드).

이 모델의 문제:

  1. 프로세스 생성 비용: COW로 저렴해졌다지만, 페이지 테이블 복제, PCB 할당 등 여전히 수 마이크로초~밀리초 단위
  2. 컨텍스트 스위치 비용: 프로세스 간 전환 시 주소 공간도 바뀌므로 TLB flush가 필요 (뒤에서 자세히)
  3. 프로세스 간 통신 (IPC) 비용: 프로세스끼리는 주소 공간이 분리되어 있어, 데이터를 주고받으려면 파이프, 소켓, 공유 메모리 같은 무거운 메커니즘이 필요
  4. 공유 상태 표현의 어려움: 여러 실행 흐름이 같은 자료 구조를 다루고 싶을 때 복잡

1990년대 들어 해결책이 필요해졌고, 그것이 스레드 (Thread)입니다.

스레드의 정의

스레드프로세스 내부의 독립된 실행 흐름입니다. 한 프로세스 안에 여러 스레드가 있으면, 모두가 같은 주소 공간을 공유하면서 각자 CPU에서 동시에 실행될 수 있습니다.

스레드가 공유하는 것:

  • Text (코드): 당연히 같은 코드를 실행
  • Heap: malloc으로 할당한 메모리
  • Data / BSS: 전역 변수, 정적 변수
  • 열린 파일 디스크립터
  • 신호 핸들러

스레드가 따로 가지는 것:

  • 스택 (Stack): 각 스레드마다 별도
  • CPU 레지스터 상태: PC, SP, 범용 레지스터 등
  • TLS (Thread-Local Storage): 스레드별 전역 변수
  • 에러 상태: errno (POSIX에서는 스레드별)
프로세스 간 vs 스레드 간 메모리 공유 여러 프로세스 — 완전 분리 프로세스 A Text (코드) Data / BSS Heap Stack (실행 흐름 1) Registers, PC File descriptors 프로세스 B Text (별개) Data / BSS Heap Stack (실행 흐름 1) Registers, PC File descriptors IPC (파이프/소켓/공유메모리) 없이는 소통 불가 한 프로세스 내 여러 스레드 — 대부분 공유 프로세스 C (스레드 3개) Text (공유) Data / BSS (공유) Heap (공유) Stack T1 전용 Stack T2 전용 Stack T3 전용 Regs T1 Regs T2 Regs T3 File descriptors (공유) TLS T1 TLS T2 TLS T3 같은 heap/data를 그냥 읽고 쓴다 — 경합 조건의 근원

이 그림에서 중요한 점:

  1. 스레드 간에는 heap과 전역 변수가 그냥 공유됩니다 — “공유 메모리”가 자연스럽게 존재
  2. 즉 스레드 두 개가 같은 int counter를 동시에 counter++ 하면 race condition이 생깁니다
  3. 반면 프로세스 두 개는 주소 공간이 분리되어 있어 자연히 격리됨

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 모든 공유 플래그 OFF
  • pthread_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 NPTL, Windows ULT 1 ULT 2 ULT 3 KLT 1 KLT 2 KLT 3 장점 • 구현 단순 • 진정한 병렬성 (멀티코어) • 커널 스케줄러 활용 단점 • 스레드 생성 비용 높음 • 수천 개면 커널 자원 고갈 • 컨텍스트 스위치 무거움 N:1 (다대일) 옛 그린 스레드, GNU Pth ULT 1 ULT 2 ULT 3 ULT 4 KLT 1개 장점 • 스레드 생성 극히 저렴 • 수십만 개 가능 • 사용자 스케줄러 자유 단점 • 병렬성 없음 (코어 1개만) • 블로킹 syscall = 전체 멈춤 • 현재는 거의 쓰이지 않음 M:N (다대다) Go, Erlang, 옛 Solaris U1 U2 U3 U4 U5 KLT 1 KLT 2 KLT 3 장점 • 스레드 저렴 + 병렬성 • 둘의 장점 결합 • 수백만 개 goroutine 가능 단점 • 런타임 구현 복잡 • 스케줄링 공정성 이슈 • 디버깅 까다로움

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 레지스터) 교체 필요

컨텍스트 스위칭의 “숨은 비용”

레지스터 저장/복원은 사실 빙산의 일각입니다. 진짜 비싼 건 간접 효과입니다.

컨텍스트 스위칭 — 직접 비용 vs 숨은 비용 Thread A 실행 스위치 ~1-10μs Thread B 실행 (캐시 재구축 중) 스위치 ~1-10μs Thread A 실행 (캐시 재구축) 직접 비용 (시각적으로 보이는 부분) • 레지스터 저장 (~30개, ~수백 바이트) • SIMD 레지스터 저장 (AVX-512 시 수 KB) • 커널에 진입 → 스케줄러 실행 → 복귀 • MMU 포인터 교체 (프로세스 간 스위치 시) 총 대략 1~10마이크로초 (하드웨어에 따라) 숨은 비용 (보이지 않는 부분) TLB flush: 프로세스 전환 시 주소 변환 캐시 비움 → 수백~수천 사이클 재구축 CPU 캐시 오염: Thread A가 쓰던 L1/L2 데이터가 Thread B 실행에 의해 밀려남 분기 예측기 오염: 브랜치 히스토리가 뒤섞임 프리페처 상태 초기화 수십 마이크로초~수 밀리초 "이후 성능 저하"로 나타남 결론: 스레드를 너무 많이 만들면 CPU가 계속 스위칭만 하고 유용한 일은 못 한다 이를 방지하려면 (1) 스레드 수를 코어 수 근처로 유지, (2) Job/Task로 작업 단위를 쪼개 큐잉

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에서는 InstrumentsSystem 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.hmakecontext/swapcontext (레거시, 권장 안 됨) 또는 Boost.Context, libco 같은 라이브러리를 써야 합니다.

엔진 실행 모델 비교

주요 엔진의 스레드 실행 모델 Unity Main Thread (고정) 대부분의 Engine API Transform, GameObject 등 Job System (별도 레이어) W0 W1 W2 W..N IJob / IJobParallelFor Burst + Native Containers 철학: Engine 유지 + 데이터 병렬 Unreal Engine Game Thread Render Thread RHI Thread Audio Thread Worker Thread Pool W0 W1 W2 W..N Task Graph: 의존성 DAG ENamedThreads로 타겟 지정 철학: 다중 Named Thread + 범용 풀 Fiber (Naughty Dog) 워커 스레드 (코어 수만큼) W0 W1 W2 W..7 Fiber Pool (수천 개) F F F F F ... Job = Fiber로 실행 협력적 스위치, 커널 개입 없음 대기 시 Fiber 교체만 철학: 유저 레벨 협력 스케줄링

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

InstrumentsSystem 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의 스레드 모델

공식 문서

게임 개발 / GDC 자료

블로그 / 기사

  • 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 — 게임에 임베드하기 좋음
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.