스레드와 동시성 Threads & Concurrency
이 장을 마치면
- 스레드가 프로세스와 무엇을 공유하고 무엇을 사적으로 갖는지 TCB 수준에서 설명하고, 문맥 교환이 실제로 저장/복원하는 것을 말할 수 있다.
- 동시성과 병렬성을 정확히 구분하고, Amdahl·Gustafson 법칙으로 확장성의 상한과 그 반론을 계산할 수 있다.
- 다대일·일대일·다대다·2단계 모델과 스케줄러 액티베이션을 이해하고, 1:1이 왜 이겼고 M:N이 왜 돌아오는지 논증할 수 있다.
- Pthreads·Windows·Java·C++ 스레딩을 구현 코드로 다루고,
clone()·futex바닥까지 내려갈 수 있다. - 스레드 풀·work-stealing·OpenMP·GCD를 구현 관점에서 설명한다.
- 데이터 레이스의 정확한 정의, 메모리 일관성 모델(SC·TSO·weak), happens-before, false sharing을 이해하고 atomics로 고칠 수 있다.
- goroutine·virtual thread·async/await가 같은 문제를 어떻게 다르게 푸는지 비교한다.
이 자료는 교재(Operating System Concepts 10판 4장) 원문에 대학원 수준 확장을 더한 것입니다. 절·소절 제목 옆 배지로 출처를 구분했습니다.
📘 OSC 4.x 교재 4장 핵심 내용 ⊕ 교재 외 확장 교재 범위 밖 심화(현대 런타임·메모리 모델·구현 코드 등)
※ 6절(메모리 모델)·9절(현대 런타임)은 절 전체가 확장입니다. 배지 없는 소절은 교재 본문에 해당합니다(10절 복습엔 확장 주제 문항도 포함).
3장의 프로세스 모델은 “프로세스 = 단일 제어 흐름을 가진 실행 중 프로그램”을 가정했다. 이 장에서 그 가정을 깬다. 스레드는 CPU 활용의 기본 단위이며, 같은 프로세스의 스레드들은 주소 공간을 공유한다 — 이것이 강력함의 원천이자, 동시성 버그의 근원이다.
0학습 지도
| 절 | 주제 | 핵심 질문 |
|---|---|---|
| 1 | 스레드의 해부 — 공유와 사적 | 문맥 교환은 실제로 무엇을 저장하나? |
| 2 | 동시성 vs 병렬성 · 멀티코어 HW | 코어를 4배로 늘리면 4배 빨라지나? |
| 3 | 멀티스레딩 모델 · 스케줄러 액티베이션 | 왜 1:1이 이겼고, 왜 M:N이 돌아오나? |
| 4 | 스레드 라이브러리 — API부터 syscall까지 | pthread_create 아래에는 무엇이 있나? |
| 5 | 암시적 스레딩 — 작업 기반 병렬성 | 스레드 대신 “작업”을 다룬다는 게 뭔가? |
| 6 | 동시성의 진짜 위험 — 메모리 모델 | 내 쓰기는 다른 코어에 언제 보이나? |
| 7 | 스레딩 이슈 — fork·시그널·취소·TLS | 멀티스레드에서 fork()하면? |
| 8 | OS 내부 — Linux · Windows | 커널은 스레드를 어떤 구조체로 표현하나? |
| 9 | 현대 런타임 — goroutine·virtual thread·async | 10만 개 동시성을 어떻게 싸게 얻나? |
| 10 | 오해 정리 · 복습 | — |
1스레드의 해부 — 무엇을 공유하고 무엇이 사적인가 📘 OSC 4.1
스레드(thread)는 CPU가 스케줄하는 가장 작은 실행 단위다. 한 스레드는 다음 네 가지를 고유하게(private) 갖는다.
- 스레드 ID — 식별자(TID).
- 프로그램 카운터(PC) — 지금 실행 중인 명령의 주소.
- 레지스터 집합 — 범용 레지스터, 상태 플래그, 스택 포인터(SP) 등 CPU의 휘발성 상태.
- 스택 — 함수 호출 프레임(지역 변수·반환 주소·인자). 스레드마다 별도 스택을 가지므로 각자 독립적인 호출 경로를 갖는다.
반대로, 같은 프로세스에 속한 스레드들은 다음을 공유(shared)한다.
- 코드 섹션(text), 데이터 섹션(전역/정적 변수), 힙.
- 열린 파일 디스크립터, 시그널 핸들러, 현재 작업 디렉터리, 사용자/그룹 ID 같은 프로세스 자원.
1.1 스레드 제어 블록(TCB)과 PCB의 관계 ⊕ 확장
커널은 프로세스를 PCB(Process Control Block)로, 스레드를 TCB(Thread Control Block)로 표현한다. 핵심은 주소 공간·파일 테이블 같은 무거운 자원은 PCB(또는 공유 구조체)에 한 번만 두고, TCB는 그것을 가리키는 포인터 + 스레드별 휘발성 상태만 갖는다는 점이다. 그래서 스레드 생성·전환이 프로세스보다 싸다.
struct tcb {
tid_t tid; /* 스레드별 */
void *kstack; /* 스레드별: 커널 스택 */
cpu_context ctx; /* 스레드별: 저장된 레지스터·SP·PC */
int state; /* RUNNING / READY / BLOCKED ... */
int prio; /* 스케줄링 우선순위 */
void *tls; /* 스레드 지역 저장소 베이스 */
struct mm *mm; /* 공유: 주소 공간(페이지 테이블) — 같은 프로세스면 동일 포인터 */
struct files *files; /* 공유: 열린 파일 테이블 */
struct signal *sig; /* 공유: 시그널 핸들러 */
struct tcb *next_in_proc; /* 같은 프로세스의 스레드 리스트 */
};Linux는 이 구분을 극단까지 밀어붙인다. 프로세스든 스레드든 모두 하나의 struct task_struct로 표현하고, “스레드”란 단지 mm·files·signal 포인터를 부모와 공유하는 task일 뿐이다(8절에서 상세).
1.2 문맥 교환은 실제로 무엇을 저장하는가 ⊕ 확장
스케줄러가 스레드 A→B로 전환할 때 하는 일은 명확하다: A의 휘발성 CPU 상태를 A의 TCB에 저장하고, B의 TCB에서 복원한다. 같은 프로세스 내 스레드 전환이면 주소 공간을 가리키는 페이지 테이블 베이스 레지스터(x86의 CR3)를 바꾸지 않으므로 TLB(가상→물리 주소 변환 캐시)를 비우지 않아도 된다 — 이것이 스레드 전환이 프로세스 전환보다 싼 결정적 이유다.
; void switch_to(cpu_context *old, cpu_context *new)
switch_to:
; --- 현재(old) 스레드 상태 저장 ---
mov [rdi + CTX_RSP], rsp ; 스택 포인터 저장
mov [rdi + CTX_RBX], rbx ; 콜리-세이브드(callee-saved) 레지스터들 저장
mov [rdi + CTX_RBP], rbp
; ... r12~r15 등 ...
; --- 다음(new) 스레드 상태 복원 ---
mov rsp, [rsi + CTX_RSP] ; 스택을 B의 것으로 교체 (= 실행 흐름이 바뀜)
mov rbx, [rsi + CTX_RBX]
mov rbp, [rsi + CTX_RBP]
; ... 복원 ...
ret ; B의 스택에 쌓인 반환 주소로 점프 → B가 이어서 실행호출 규약(calling convention)상 콜러-세이브드(caller-saved) 레지스터는 어차피 호출자가 보존할 책임이 있으므로 switch_to가 일반 함수 호출처럼 보이는 한 저장할 필요가 없다. 실제 커널은 인터럽트/시스템 콜 진입 시 전체 레지스터를 커널 스택의 trap frame(진입 순간의 레지스터 전체를 저장해 두는 영역)에 이미 저장하므로, 스케줄러의 switch_to는 콜리-세이브드(callee-saved)만 다루면 된다. PC는 명시적으로 저장하지 않는다 — ret이 스택의 반환 주소로 점프하는 것이 곧 “B의 PC로 복귀”다.
1.3 스레드의 네 가지 이점 (그리고 비용)
| 이점 | 메커니즘 | 숨은 비용 |
|---|---|---|
| 응답성 | 긴 작업을 별도 스레드로 → UI 스레드가 블록되지 않음 | 공유 상태 동기화 필요 |
| 자원 공유 | 주소 공간을 공유 → 공유 메모리 설정 불필요 | 데이터 레이스 위험(6절) |
| 경제성 | 생성·문맥 교환이 프로세스보다 저렴(자원을 공유하므로) | 스택·TCB는 여전히 메모리 소비(스택 예약 보통 수 MB — Linux 기본 8MB·Windows 1MB) |
| 확장성 | 코어마다 스레드 배치 → 병렬 실행 | Amdahl 한계·경합·false sharing(2·6절) |
2동시성 vs 병렬성, 그리고 멀티코어 하드웨어 📘 OSC 4.2
동시성(concurrency)은 구조의 속성이다: 여러 작업이 겹치는 수명을 가지고 진행한다(한 순간에는 하나만 실행될 수도 있다). 병렬성(parallelism)은 실행의 속성이다: 여러 작업이 물리적으로 같은 순간에 실행된다(코어가 여러 개여야 가능).
Rob Pike의 정리: “Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.” — 동시성은 설계이고, 병렬성은 그 설계가 멀티코어에서 얻는 보너스다.
2.1 Amdahl의 법칙 — 확장성의 천장
프로그램의 직렬 비율을 S, 코어 수를 N이라 하면 가속비의 상한은:
1 1
speedup ≤ ───────────── lim speedup = ─── (N → ∞)
S + (1−S)/N N→∞ S직관: 병렬화할 수 없는 직렬 부분 S가 천장을 만든다. S=0.05면 코어를 무한히 늘려도 최대 20배. 병렬 코드를 잘 쓰는 것보다 직렬 구간을 없애는 것이 먼저라는 강력한 함의가 여기 있다(락 경합·순차 I/O가 흔한 직렬 구간).
2.2 Gustafson의 법칙 — Amdahl에 대한 반론 ⊕ 확장
Amdahl은 문제 크기 고정을 가정한다. 그러나 실무에서는 코어가 많아지면 더 큰 문제를 푼다(고해상도, 더 많은 데이터). Gustafson은 “고정된 시간 안에 얼마나 큰 문제를 풀 수 있나”로 관점을 바꾼다.
speedup(N) = N − S·(N − 1) (S = 직렬 비율, N = 코어 수)
= S + N·(1 − S)여기서는 가속비가 N에 선형으로 증가할 수 있다. 두 법칙은 모순이 아니라 다른 질문에 답한다: Amdahl=“같은 문제를 더 빨리”, Gustafson=“같은 시간에 더 크게”. 데이터센터·HPC의 확장이 가능한 이유가 Gustafson 쪽이다.
2.3 멀티코어 하드웨어의 실제 — SMT, 멀티코어, NUMA ⊕ 확장
- 칩 멀티프로세서(CMP, 멀티코어): 한 칩에 독립 코어 여러 개. 각 코어가 OS에는 별개 CPU로 보인다. 코어마다 L1/L2 사유, L3는 공유가 흔하다.
- 동시 멀티스레딩(SMT, Intel Hyper-Threading): 한 물리 코어가 2개(이상)의 하드웨어 스레드를 노출. 레지스터 파일은 복제하되 실행 유닛·캐시는 공유. 한 스레드가 캐시 미스로 멈춘 사이 다른 스레드가 실행 유닛을 채워 처리량을 높인다. 단, 둘이 같은 자원을 다투면 1+1 < 2일 수 있다.
- NUMA(Non-Uniform Memory Access): 멀티소켓에서 각 CPU가 자기 로컬 메모리에 빠르게, 원격 노드 메모리에 느리게 접근. 스레드와 그 데이터를 같은 노드에 배치(NUMA affinity)하지 않으면 병렬화 이득이 원격 접근 지연에 잠식된다.
① 작업 식별(독립 병렬 단위 찾기) · ② 균형(작업량 균등) · ③ 데이터 분할 · ④ 데이터 의존성(동기화 필요 — 6절) · ⑤ 테스트·디버깅(실행 경로 폭발 → 비결정성). 마지막이 가장 어렵다: 동시성 버그는 재현되지 않는다.
2.4 병렬성의 두 종류
| 데이터 병렬성 | 작업 병렬성 | |
|---|---|---|
| 분배 대상 | 데이터의 부분집합 | 서로 다른 작업(함수) |
| 각 코어의 연산 | 같은 연산 | 다른 연산 |
| 예 | 배열을 반으로 나눠 각자 합산 | 한 데이터에 평균·분산·정렬을 동시에 |
| 전형 도구 | SIMD, GPU, #pragma omp for | 스레드 풀, 파이프라인, fork-join |
둘은 배타적이지 않다. 실제 시스템은 보통 하이브리드다(예: 작업 병렬 파이프라인의 각 스테이지가 내부적으로 데이터 병렬).
3멀티스레딩 모델 — 사용자/커널 스레드와 M:N 📘 OSC 4.3
스레드 지원은 두 층위에 존재한다. 사용자 스레드는 커널 모르게 라이브러리가 사용자 공간에서 관리하고, 커널 스레드는 OS가 직접 생성·스케줄한다. 둘 사이를 어떻게 매핑하느냐가 모델을 가른다.
| 모델 | 병렬 | 블로킹 콜의 영향 | 비용 | 대표 |
|---|---|---|---|---|
| 다대일 | X | 전체 프로세스 블록 | 최소(사용자 공간) | green threads(초기 Solaris·Java) |
| 일대일 | O | 해당 스레드만 | 커널 스레드 1:1 부담 | Linux NPTL · Windows |
| 다대다 | O | 런타임이 다른 스레드 스케줄 | 중간(런타임 복잡) | Go·Loom·Erlang |
| 2단계 | O | 유연 | 중간 | 구 Solaris·IRIX |
3.1 왜 1:1이 이겼나 — NPTL의 교훈 ⊕ 확장
2000년대 초까지 Linux는 M:N(NGPT)과 1:1(NPTL)을 두고 경쟁했다. 승자는 1:1 모델의 NPTL(Native POSIX Thread Library)이었다. 이유:
- 커널 스케줄러가 직접 스레드를 보므로 SMP에서 진짜 병렬이 자연스럽고, 블로킹 시스템 콜이 한 스레드만 멈춘다.
- M:N은 사용자 스케줄러와 커널 스케줄러가 이중으로 존재해 우선순위 역전·시그널 전달·CPU affinity가 악몽이 된다.
- 당시 커널 스레드 생성 비용을
clone()·futex최적화로 충분히 낮출 수 있었다 — “싼 커널 스레드”가 “복잡한 사용자 스케줄러”를 이겼다.
3.2 왜 M:N이 돌아오나 ⊕ 확장
그런데 1:1에는 천장이 있다: 커널 스레드 하나당 스택(보통 수 MB 예약) + 커널 메모리가 들어 수십만 개를 만들 수 없고, 문맥 교환이 커널 진입을 동반한다. C10K(한 서버가 동시 연결 1만 개를 감당하는 고전 확장성 문제)를 넘어 C10M(천만 동시 연결)을 노리면서 “블로킹처럼 쓰되 비용은 사용자 공간”인 M:N이 부활했다 — Go의 goroutine, Java 21의 Virtual Threads가 그 결과다(9절).
3.3 스케줄러 액티베이션 — M:N의 고전적 해법
M:N의 근본 난제: 커널 스레드가 블록되면 그 위에 얹힌 여러 사용자 스레드가 함께 멈춘다. 1991년 Anderson 등의 스케줄러 액티베이션(scheduler activations)이 이를 “커널↔런타임 협력”으로 푼다.
- 커널은 런타임에 가상 프로세서(=LWP, lightweight process) 집합을 제공한다. 런타임은 그 위에 사용자 스레드를 스케줄한다.
- 스레드가 블록될 상황이면 커널이 런타임에 업콜(upcall)한다: “네 스레드가 곧 막힌다. 새 가상 프로세서를 줄 테니 다른 스레드를 올려라.”
- 블로킹이 풀리면 커널이 다시 업콜해 “그 스레드 깨어났다”고 알린다. 런타임이 재스케줄한다.
스케줄러 액티베이션은 우아하지만 시그널·디버깅·커널 복잡도 탓에 주류 Linux에선 채택되지 않았다(NPTL 1:1이 이김). 그러나 그 핵심 아이디어 — “블로킹 시 런타임에 제어를 돌려준다” — 는 Go 런타임의 netpoller와 Java Loom의 continuation unmount로 부활했다. 형태는 다르지만 문제와 통찰은 같다.
4스레드 라이브러리 — API부터 시스템 콜 바닥까지 📘 OSC 4.4
스레드 라이브러리는 생성·조인·동기화 API를 준다. 구현은 사용자 공간(함수 호출) 또는 커널 수준(시스템 콜)이다. 오늘날 1:1 모델에서 pthread_create는 결국 커널의 clone()으로 내려간다. 위에서 아래로 한 층씩 보자.
스레드를 만드는 두 가지 전략이 있다. 비동기 스레딩(asynchronous): 부모가 자식을 만든 뒤 곧바로 자기 일을 계속한다 — 부모·자식이 독립적으로 진행하며 데이터 공유가 적다(예: 요청마다 스레드를 띄우는 웹 서버, 반응형 UI). 동기 스레딩(synchronous, fork-join): 부모가 자식들을 만들고 모두 끝날 때까지(join) 기다린 뒤 결과를 합친다 — 데이터 공유가 많다(아래 합산 예제·5.2의 fork-join이 이 형태). 같은 API라도 이 둘 중 어느 쪽으로 쓰느냐가 동기화 부담과 설계를 가른다.
4.1 Pthreads — POSIX 표준 (명세이지 구현이 아님)
Pthreads는 IEEE 1003.1c가 정의한 명세다. Linux·macOS·BSD가 각자 구현한다. 합산 예제와 핵심 패턴:
cc x.c -lpthread)#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
long sum; /* 전역 → 모든 스레드가 공유 */
void *runner(void *arg) {
long upper = atol(arg);
sum = 0;
for (long i = 1; i <= upper; i++) sum += i;
pthread_exit(NULL); /* return 으로도 종료 가능 */
}
int main(int argc, char *argv[]) {
pthread_t tid;
pthread_attr_t attr;
pthread_attr_init(&attr); /* 기본 속성: 스택 크기·스케줄링 등 */
/* pthread_attr_setstacksize(&attr, 1<<20); // 스택 1MB로 지정 가능 */
pthread_create(&tid, &attr, runner, argv[1]);
pthread_join(tid, NULL); /* 종료까지 블록, 자원 회수 */
printf("sum = %ld\n", sum);
pthread_attr_destroy(&attr);
}여러 스레드는 배열 + for 루프로 join한다. 조인하지 않을 백그라운드 스레드는 pthread_detach()로 분리해 종료 시 자원이 자동 회수되게 한다(조인 안 한 joinable 스레드는 좀비처럼 자원 누수).
4.2 한 층 아래 — clone()과 futex ⊕ 확장
Linux에서 pthread_create는 clone() 시스템 콜로 새 task를 만들되, 주소 공간·파일·시그널을 공유하도록 플래그를 준다. 동기화의 바닥에는 futex(fast userspace mutex)가 있다: 경합이 없으면 사용자 공간 원자 연산만으로 끝내고, 경합할 때만 커널로 내려가 잠들고 깨운다. (아래 코드의 cmpxchg는 compare-and-swap, xchg는 atomic exchange — 하드웨어 원자 명령이며, CAS의 함정은 6.5에서 다룬다.)
#define _GNU_SOURCE
#include <sched.h>
#include <sys/mman.h>
#include <unistd.h>
#include <stdio.h>
static int worker(void *arg) {
/* CLONE_THREAD이므로 getpid()는 부모와 같은 TGID(=PID) 반환. 개별 TID는 gettid() */
printf("child task: 주소공간 공유, pid(TGID)=%d\n", (int)getpid());
return 0;
}
int main(void) {
const int STACK = 1 << 20;
/* 자식 스택은 호출자가 마련한다 (스택은 아래로 자라므로 top을 넘김) */
char *stack = mmap(NULL, STACK, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0);
/* pthread_create가 내부적으로 켜는 플래그들: */
int flags = CLONE_VM /* 메모리 공간 공유 → "스레드"의 핵심 */
| CLONE_FS /* 파일시스템 정보 공유 */
| CLONE_FILES /* 열린 파일 테이블 공유 */
| CLONE_SIGHAND /* 시그널 핸들러 공유 */
| CLONE_THREAD; /* 같은 스레드 그룹(TGID) → 같은 PID로 보임 */
clone(worker, stack + STACK, flags, NULL);
/* (실제로는 여기서 futex 등으로 자식 종료를 기다린다) */
sleep(1);
}/* 0=unlocked, 1=locked, 2=locked&waiters */
void lock(atomic_int *m) {
int c;
if ((c = cmpxchg(m, 0, 1)) != 0) { /* fast path: 경합 없으면 즉시 획득 */
if (c != 2) c = xchg(m, 2); /* 대기자 있음 표시 */
while (c != 0) {
futex(m, FUTEX_WAIT, 2, NULL); /* slow path: 커널에서 잠듦 */
c = xchg(m, 2);
}
}
}
void unlock(atomic_int *m) {
/* fetch_sub는 '감소 전' 값을 반환. 1이었다면(대기자 없음) 이미 0이 되어 끝 */
if (atomic_fetch_sub(m, 1) != 1) { /* 1이 아니었음 = 2였음 → 대기자 있음 */
store(m, 0); /* 0으로 만들고 */
futex(m, FUTEX_WAKE, 1, NULL); /* 대기자 하나 깨움 */
}
}“스레드는 메모리를 공유하는 task”이고, “뮤텍스는 경합할 때만 커널로 내려가는 futex”다. 이 두 문장이 현대 Linux 스레딩의 비용 구조를 거의 설명한다 — 경합이 없으면 거의 공짜, 경합하면 비싸다(컨텍스트 스위치).
4.3 Windows 스레드
#include <windows.h>
DWORD Sum;
DWORD WINAPI Summation(LPVOID p) {
DWORD upper = *(DWORD*)p;
for (DWORD i = 1; i <= upper; i++) Sum += i;
return 0;
}
int main(int argc, char *argv[]) {
DWORD id, param = atoi(argv[1]);
HANDLE h = CreateThread(NULL, 0, Summation, ¶m, 0, &id);
WaitForSingleObject(h, INFINITE); /* 여러 개면 WaitForMultipleObjects */
CloseHandle(h);
}4.4 Java — Thread, Executor, Callable/Future, Fork-Join
Java는 스레드가 1급 시민이다. 저수준 Thread/Runnable부터 고수준 java.util.concurrent까지 있다. 실무에서는 거의 항상 Executor 프레임워크를 쓴다(생성과 실행을 분리, 결과는 Future로).
import java.util.concurrent.*;
class Summation implements Callable<Long> {
private final long upper;
Summation(long upper) { this.upper = upper; }
public Long call() { // 별도 스레드에서 실행
long s = 0;
for (long i = 1; i <= upper; i++) s += i;
return s; // Runnable과 달리 결과 반환 가능
}
}
public class Driver {
public static void main(String[] a) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Long> f = pool.submit(new Summation(Long.parseLong(a[0])));
System.out.println("sum = " + f.get()); // 결과 준비될 때까지 블록
pool.shutdown();
}
}import java.util.concurrent.*;
class SumTask extends RecursiveTask<Long> {
static final int THRESHOLD = 10_000;
final int[] a; final int lo, hi;
SumTask(int[] a, int lo, int hi){ this.a=a; this.lo=lo; this.hi=hi; }
protected Long compute() {
if (hi - lo <= THRESHOLD) { // 충분히 작으면 직접
long s = 0; for (int i=lo;i<hi;i++) s += a[i]; return s;
}
int mid = (lo + hi) >>> 1; // 아니면 분할 후 fork
SumTask L = new SumTask(a, lo, mid);
SumTask R = new SumTask(a, mid, hi);
L.fork(); // 비동기 제출 (다른 워커가 훔쳐갈 수 있음)
long r = R.compute(); // 현재 스레드가 직접 (work-first)
return r + L.join(); // L 결과 합류
}
}
// ForkJoinPool.commonPool().invoke(new SumTask(arr, 0, arr.length));4.5 C++ — std::thread / std::async / std::jthread ⊕ 확장
#include <thread>
#include <future>
#include <numeric>
#include <vector>
#include <print> // C++23
long partial_sum(long lo, long hi) {
long s = 0; for (long i = lo; i <= hi; ++i) s += i; return s;
}
int main() {
// (1) std::async: 결과를 future로 받는 가장 간단한 작업 병렬
std::future<long> f = std::async(std::launch::async, partial_sum, 1, 500'000);
long here = partial_sum(500'001, 1'000'000);
std::println("sum = {}", here + f.get());
// (2) std::jthread (C++20): 소멸 시 자동 join + 협력적 취소 토큰
std::jthread t([](std::stop_token st){
while (!st.stop_requested()) { /* work */ } // 깔끔한 취소
});
// t 소멸 시 request_stop() + join() 자동 호출 → RAII 안전
}C/C++·Pthreads·Windows는 전역/공유 메모리가 기본(편하지만 위험). Java는 전역이 없어 객체로 명시 전달. Go·Rust는 “공유하지 말고 통신하라” — Go는 채널, Rust는 소유권/Send+Sync로 컴파일 타임에 데이터 레이스를 막는다(6절과 직결).
5암시적 스레딩 — 작업 기반 병렬성 📘 OSC 4.5
스레드가 수천 개로 늘면 “스레드를 직접 만들고 관리”하는 모델은 무너진다. 암시적 스레딩은 생성·관리를 컴파일러·런타임에 넘긴다. 개발자는 스레드가 아니라 “작업(task)”을 식별하고, 런타임이 보통 M:N으로 스레드에 매핑한다.
5.1 스레드 풀 — 그리고 직접 구현
요청마다 스레드를 만들면 ① 생성 지연 ② 스레드 수 무한 증가(자원 고갈)의 문제가 있다. 스레드 풀은 워커 N개를 미리 만들어 두고, 작업을 큐에 넣으면 놀고 있는 워커가 집어 처리한다. (아래 코드가 쓰는 조건 변수(condition variable)는 락과 짝지어 “조건이 참이 될 때까지 대기하고, 바뀌면 통지”하는 동기화 프리미티브다 — 세마포어·모니터와 함께 6장에서 상세히 다룬다.)
#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>
class ThreadPool {
std::vector<std::thread> workers;
std::queue<std::function<void()>> tasks;
std::mutex m;
std::condition_variable cv;
bool stop = false;
public:
explicit ThreadPool(size_t n) {
for (size_t i = 0; i < n; ++i)
workers.emplace_back([this]{
for (;;) {
std::function<void()> job;
{
std::unique_lock lk(m);
cv.wait(lk, [this]{ return stop || !tasks.empty(); });
if (stop && tasks.empty()) return; // 종료
job = std::move(tasks.front()); tasks.pop();
}
job(); // 락 밖에서 실행 (중요)
}
});
}
void submit(std::function<void()> job) {
{ std::lock_guard lk(m); tasks.push(std::move(job)); }
cv.notify_one(); // 워커 하나 깨움
}
~ThreadPool() {
{ std::lock_guard lk(m); stop = true; }
cv.notify_all();
for (auto &w : workers) w.join();
}
};풀 크기는 보통 CPU 코어 수 근처(CPU 바운드) 또는 그 이상(I/O 바운드, 대기 중인 워커가 많아야 처리량↑)으로 잡는다. 정교한 풀은 부하에 따라 동적으로 크기를 조절한다(Java newCachedThreadPool, Windows 스레드 풀 API).
cv.wait(lk, predicate) 술어 형태가 필수인가위 코드가 cv.wait(lk, [&]{ return stop || !tasks.empty(); })로 술어를 넘기는 데는 두 이유가 있다. ① 가짜 깨어남(spurious wakeup): POSIX/C++ 표준은 notify 없이도 wait가 깨어날 수 있음을 허용한다. 술어 없이 단순 wait 후 진행하면 큐가 빈 채로 pop해 UB가 된다 — 깨어난 뒤 조건을 반드시 재확인(while 루프)해야 한다(술어 형태가 이를 내장). ② 잃어버린 깨어남(lost wakeup): 락을 쥔 채 조건을 검사·대기해야 notify와 검사 사이의 경쟁을 막는다. notify_one은 워커 하나만, notify_all은 전부 깨워 경쟁시킨다(불필요한 전체 기상 = thundering herd 주의).
5.2 Fork-Join과 work-stealing deque
Fork-join은 분할 정복을 위한 풀이다. 핵심 메커니즘은 work-stealing: 각 워커는 자기 작업을 양끝 큐(deque)에 쌓고, 자기 것은 한쪽 끝(LIFO, 캐시 친화적)에서 꺼내며, 놀게 된 워커는 남의 큐 반대쪽 끝(FIFO, 큰 작업)에서 훔쳐온다. Chase-Lev deque가 표준 구현이다.
// 소유자 전용 (bottom 끝, 단일 스레드) — 락 없음
push(task): buf[bottom] = task; bottom++ // (메모리 배리어 필요)
pop(): bottom--;
if (bottom < top) { bottom = top; return EMPTY }
t = buf[bottom];
if (bottom == top) // 마지막 1개: 도둑과 경쟁
if (!CAS(&top, top, top+1)) t = EMPTY
return t
// 도둑들 (top 끝, 여러 스레드) — CAS로 경쟁
steal(): t = top;
if (top >= bottom) return EMPTY
task = buf[t];
if (!CAS(&top, t, t+1)) return ABORT // 다른 도둑이 이김 → 재시도
return task5.3 OpenMP — 지시문 기반 데이터 병렬
cc -fopenmp)#include <omp.h>
double dot(const double *a, const double *b, int n) {
double sum = 0.0;
/* 루프를 스레드들에 분할 + 부분합을 안전하게 reduction */
#pragma omp parallel for reduction(+:sum) schedule(static)
for (int i = 0; i < n; i++)
sum += a[i] * b[i];
return sum;
}
/* #pragma omp parallel → 코어 수만큼 스레드 생성
reduction(+:sum) → 스레드별 사적 sum을 마지막에 합산(레이스 없음)
schedule(static|dynamic|guided) → 반복 분배 정책 */OpenMP의 강점은 점진적 병렬화다: 직렬 코드에 지시문 한 줄을 더해 병렬로 만든다. reduction은 6절의 데이터 레이스를 자동으로 피하는 패턴(스레드별 사적 누산 → 최종 병합)의 좋은 예다.
5.4 Grand Central Dispatch (Apple) & Intel TBB
GCD는 작업을 디스패치 큐에 올리면 시스템이 관리하는 스레드 풀이 실행한다. 큐는 직렬(serial)(한 번에 하나, 순서 보장)과 동시(concurrent)(여러 개 병렬)로 나뉘고, 전역 동시 큐는 4개의 QoS 클래스(userInteractive·userInitiated·utility·background)로 우선순위를 표현한다. 각 프로세스에는 고유한 직렬 큐인 main queue가 있어(UI 갱신은 반드시 이 큐에서) 메인 스레드 안전을 보장하고, 개발자는 상태 보호용 사설(private) 직렬 큐를 추가로 만들 수 있다.
// 동시 큐(userInitiated)에 작업을 던지고, 끝나면 메인 큐에서 UI 갱신
DispatchQueue.global(qos: .userInitiated).async {
let result = heavyComputation()
DispatchQueue.main.async { // 직렬(메인) 큐 → UI 스레드 안전
updateUI(result)
}
}
// 직렬 큐: 순차 실행 보장 (락 없이 상태 보호 용도로도 씀)
let serial = DispatchQueue(label: "com.app.state")
serial.async { mutate(&sharedState) }Intel TBB는 C++ 템플릿 라이브러리로 parallel_for·parallel_reduce 등을 제공하고, 내부적으로 work-stealing + 캐시 인식 스케줄링을 한다. 개발자는 반복 공간(range)과 본문(body)만 주면, TBB가 반복을 “청크”로 쪼개 작업으로 만들고 스레드에 분배한다 — 코어 수에 맞춰 코드를 재작성할 필요가 없다.
#include <tbb/parallel_for.h>
// 직렬: for (size_t i = 0; i < n; ++i) apply(v[i]);
// 병렬: range(0..n) × body(람다) 만 선언 — 청크 분할·스레드 매핑은 TBB가
tbb::parallel_for(size_t(0), n, [=](size_t i) { apply(v[i]); });
// 합산처럼 결합이 필요하면 parallel_reduce
double sum = tbb::parallel_reduce(
tbb::blocked_range<size_t>(0, n), 0.0,
[&](auto r, double acc){ for (size_t i=r.begin(); i<r.end(); ++i) acc += v[i]; return acc; },
std::plus<double>());셋(OpenMP·GCD·TBB)의 공통점: “무엇을 병렬화할지”만 선언하면 “어떻게 스레드에 매핑할지”는 런타임이 결정한다.
6동시성의 진짜 위험 — 메모리 모델·가시성·재배열 ⊕ 교재 외 확장
“락을 걸면 된다”는 절반의 진실이다. 멀티코어에서 한 코어의 쓰기가 다른 코어에 언제·어떤 순서로 보이는가는 CPU의 메모리 일관성 모델과 컴파일러의 재배열이 결정한다. 이걸 모르면 “락 없이도 될 것 같은” 코드가 특정 하드웨어에서만 가끔 깨진다.
6.1 경쟁 조건과 데이터 레이스 — 정확한 정의
두 용어는 자주 혼용되지만 다르다.
- 경쟁 조건(race condition): 결과가 스레드들의 실행 타이밍/인터리빙에 따라 달라지는 설계 결함. (반드시 메모리 접근만의 문제는 아님 — 논리적 순서 문제도 포함.)
- 데이터 레이스(data race): 동기화 없이 두 스레드가 같은 메모리 위치에 접근하고, 그중 적어도 하나가 쓰기인 상황. C/C++·Java·Go의 메모리 모델에서 데이터 레이스는 정의되지 않은 동작(UB)이다.
// counter++ 는 사실 세 단계: load → add → store
//
// Thread A Thread B
// r = load(counter)
// r = load(counter) // 둘 다 같은 값을 읽음
// store(counter, r+1)
// store(counter, r+1) // B가 A의 증가를 덮어씀 → 하나 유실
//
// 두 스레드가 1,000,000번씩 ++ 해도 결과는 2,000,000이 아니다(lost update).#include <atomic>
std::atomic<long> counter{0};
counter.fetch_add(1, std::memory_order_relaxed); // 원자적 RMW, 카운터엔 relaxed로 충분
// 또는 뮤텍스
std::mutex m;
{ std::lock_guard lk(m); ++plain_counter; } // 임계 구역(critical section): 한 번에 한 스레드만 진입6.2 메모리 일관성 모델 — SC, TSO, weak
여러 코어가 메모리를 공유할 때, 한 코어의 store가 다른 코어의 load에 보이는 순서를 규정하는 것이 메모리 일관성 모델이다.
| 모델 | 허용되는 재배열 | 실제 하드웨어 |
|---|---|---|
| Sequential Consistency (SC) | 없음 — 모든 코어가 하나의 전역 순서를 본다(직관적) | 이상적 모델(하드웨어엔 거의 없음, 너무 느림) |
| TSO (Total Store Order) | Store→Load 재배열만 허용(store buffer 때문) | x86 (비교적 강함) |
| Weak / Relaxed | 거의 모든 재배열 허용 — 명시적 배리어 필요 | ARM, POWER, RISC-V |
// 초기: x = 0, y = 0
//
// Thread 1 Thread 2
// x = 1; y = 1;
// r1 = y; r2 = x;
//
// SC라면 r1==0 && r2==0 은 불가능.
// 그러나 x86(TSO)에서도 store buffer 때문에 Store→Load가 재배열되어
// r1==0 && r2==0 이 실제로 관측된다! (각 코어가 자기 store를
// 버퍼에 담아두고, 상대의 store가 아직 안 보이는 채로 load)
//
// 해결: 두 store 뒤에 메모리 배리어(MFENCE / atomic_thread_fence(seq_cst))6.3 happens-before와 acquire/release
고수준 언어는 하드웨어 차이를 happens-before 관계로 추상화한다: A가 B보다 happens-before면 A의 모든 쓰기가 B에 보인다. 동기화 연산이 이 관계를 만든다.
- release store: 이 store 이전의 모든 메모리 연산이 이 store보다 먼저 보이도록 보장(아래로 못 내려감).
- acquire load: 이 load 이후의 모든 메모리 연산이 이 load보다 나중에 보이도록 보장(위로 못 올라감).
- 한 스레드의 release store를 다른 스레드가 acquire load로 관측하면, 그 사이에 happens-before가 성립 → release 이전의 쓰기들이 acquire 이후에 보인다.
std::atomic<bool> ready{false};
int data = 0; // 일반 변수
// Producer
data = 42; // (1) 평범한 쓰기
ready.store(true, std::memory_order_release); // (2) release: (1)이 (2)보다 먼저 보이도록 못박음
// Consumer
while (!ready.load(std::memory_order_acquire)) // (3) acquire
;
assert(data == 42); // 보장됨! (2)→(3) 동기화로 (1)이 보임memory_order_seq_cst(기본값)는 가장 강하고 직관적이지만 배리어 비용이 크다. acquire/release는 더 싸고, relaxed는 순서 보장 없이 원자성만 준다(카운터처럼 순서가 무관할 때).
하드웨어 메모리 배리어는 “어떤 종류의 메모리 연산이 서로 넘나들지 못하게 하는가”로 4가지로 분해된다: LoadLoad(이전 load들이 이후 load보다 먼저)·StoreStore(이전 store들이 이후 store보다 먼저)·LoadStore·StoreLoad. 이 중 StoreLoad가 가장 비싸고(store buffer를 비워야 함), x86이 유일하게 명시적으로 요구하는 것도 이것(MFENCE)이다 — 6.2의 store-buffer 리트머스가 바로 StoreLoad 펜스를 빠뜨려 깨진 사례다. 고수준에서는 std::atomic_thread_fence(memory_order_acquire/release/seq_cst)가 독립 펜스(특정 변수에 묶이지 않음)를, atomic 연산에 붙는 memory_order는 그 연산에 결합된 펜스를 만든다. ARM/POWER는 weak 모델이라 dmb/lwsync 같은 펜스를 acquire/release 지점마다 컴파일러가 삽입한다.
6.4 캐시 일관성(coherence) vs 일관성 모델(consistency), 그리고 false sharing
둘은 다른 층위다. 코히어런스(coherence)는 “하나의 메모리 위치”에 대한 여러 코어의 시점을 일치시키는 하드웨어 프로토콜(MESI 등)이고, 컨시스턴시(consistency)는 “여러 위치들 사이의 순서”에 대한 규약이다(6.2).
MESI는 각 캐시 라인을 Modified·Exclusive·Shared·Invalid 상태로 관리한다. 한 코어가 라인을 쓰려면 다른 코어들의 사본을 무효화(Invalidate)해 자기 것을 Modified로 만든다. 이 무효화 트래픽이 동시성 성능의 숨은 비용이다. (자세한 전이는 메모리 코스 10강 참조.)
서로 다른 스레드가 다른 변수를 쓰는데 그 변수들이 같은 64B 캐시 라인(캐시 전송의 단위 — 토대는 메모리 코스 4강)에 있으면, 코히어런스 프로토콜이 매번 라인을 핑퐁시켜 성능이 급락한다. 변수가 논리적으로 독립이어도 하드웨어는 라인 단위로만 보기 때문이다.
// 나쁨: 두 카운터가 같은 라인 → 코어 간 라인 핑퐁
struct Bad { std::atomic<long> a; std::atomic<long> b; }; // 16B, 한 라인
// 좋음: 각 카운터를 캐시 라인 경계로 정렬/패딩 → 독립
struct Good {
alignas(64) std::atomic<long> a;
alignas(64) std::atomic<long> b; // 다른 라인 → 핑퐁 없음
};
// C++17: std::hardware_destructive_interference_size 로 라인 크기 질의 가능6.5 lock-free·wait-free, 그리고 ABA 문제
락 대신 원자 연산(주로 CAS, compare-and-swap)만으로 자료구조를 만들면 데드락·우선순위 역전·convoy(느린 락 보유자 뒤로 스레드들이 줄지어 밀려 처리량이 급락하는 현상)가 사라진다. 진행 보장(progress guarantee)에 따라 계층이 나뉜다.
- obstruction-free: 다른 스레드가 멈춰 있으면 한 스레드는 유한 단계에 끝난다(가장 약함).
- lock-free: 어떤 스레드는 항상 진행한다(시스템 전체가 멈추지 않음). 개별 스레드는 굶을 수 있다.
- wait-free: 모든 스레드가 유한 단계에 끝난다(가장 강함, 구현 난해).
CAS는 “값이 여전히 A면 바꿔라”인데, 그 사이 다른 스레드가 A→B→A로 되돌려 놓으면 CAS는 “안 변했다”고 착각해 성공한다 — 그러나 그 A는 다른 의미(예: free되었다 재할당된 노드)일 수 있다. 5.2의 work-stealing deque top CAS, lock-free 스택의 pop이 대표적 취약 지점이다. 해법: 태그된 포인터(포인터+버전 카운터를 함께 CAS, double-width CAS), 위험 포인터(hazard pointer — 스레드가 “지금 참조 중”으로 등록한 포인터는 회수 금지), 또는 RCU(Read-Copy-Update: 갱신 시 새 복사본을 쓰고, 옛 버전을 읽던 독자가 모두 떠난 뒤 회수)로 “아직 누가 보는 메모리는 재사용하지 않기”를 보장한다.
이런 기법은 강력하지만 6.2의 메모리 일관성·펜스를 정확히 다뤄야 해 작성·검증이 매우 어렵다. 실무 원칙: “먼저 락으로 올바르게 만들고, 프로파일링으로 경합이 증명된 핫스팟만 lock-free로”. 자세한 락·세마포어·모니터는 6장(동기화)에서 다룬다.
위에서 “lock-free가 데드락을 없앤다”고 했는데, 데드락(교착)은 네 조건이 동시에 성립할 때만 일어난다: ① 상호 배제(자원을 한 번에 하나만) ② 점유와 대기(자원을 쥔 채 다른 자원을 기다림) ③ 비선점(강제로 뺏을 수 없음) ④ 순환 대기(대기 사이클). 이 중 하나만 깨도 데드락은 불가능하다 — 4조건의 상세와 예방·회피·탐지·회복은 8장(교착 상태)에서 다룬다. (7.1의 fork+락 데드락이 구체 사례다.)
7스레딩 이슈 — fork·시그널·취소·TLS 📘 OSC 4.6
7.1 멀티스레드에서 fork()와 exec()
fork()가 호출되면 새 프로세스는 호출한 스레드 하나만 복제한다(POSIX). 다른 스레드들은 자식에 존재하지 않는다. 이것이 악명 높은 함정을 만든다.
어떤 스레드가 malloc 내부 락을 쥔 순간 다른 스레드가 fork()하면, 자식은 “잠긴 채 영원히 풀어줄 주인이 없는 락”을 상속한다. 자식이 malloc을 호출하면 그대로 데드락. 그래서 fork 후에는 exec()만 호출하거나(주소 공간을 갈아엎으므로 안전), pthread_atfork() 핸들러로 fork 전후 락을 정돈해야 한다. async-signal-safe 함수만 써야 하는 이유와 같은 맥락.
// prepare: 부모가 fork 직전에 모든 락 획득
// parent : fork 직후 부모가 락 해제
// child : fork 직후 자식이 락 (재)초기화
pthread_atfork(prepare_acquire_locks,
parent_release_locks,
child_reinit_locks);exec()는 그대로다: 호출 프로세스의 모든 스레드를 포함한 전체 이미지를 새 프로그램으로 교체한다.
7.2 시그널 전달 — 어느 스레드가 받나
UNIX 시그널은 단일 스레드에선 단순하지만 멀티스레드에선 “누구에게?”가 문제다.
- 동기 시그널(SIGSEGV, SIGFPE 등 — 잘못된 연산이 원인): 그 연산을 한 스레드에게 전달.
- 비동기 시그널(SIGINT, SIGTERM 등 외부 발생): 프로세스 내 그 시그널을 블록하지 않은 임의의 한 스레드에게 전달. 시그널은 한 번만 처리되면 되므로 보통 첫 번째 후보에게.
각 스레드는 자기 시그널 마스크를 가진다(pthread_sigmask). 흔한 패턴: 모든 스레드가 시그널을 블록하고 전담 스레드 하나가 sigwait()로 동기 수신 — 비동기 핸들러의 async-signal-safe 제약을 피한다. pthread_kill(tid, sig)로 특정 스레드를 지정할 수도 있다.
sigset_t set;
sigemptyset(&set); sigaddset(&set, SIGINT); sigaddset(&set, SIGTERM);
pthread_sigmask(SIG_BLOCK, &set, NULL); // main과 모든 자식이 블록(상속됨)
void *sig_thread(void *arg) {
int sig;
for (;;) {
sigwait(&set, &sig); // 동기 수신 — 핸들러 제약 없음
printf("received signal %d, shutting down\n", sig);
graceful_shutdown();
}
}Windows에는 UNIX식 시그널이 없고 APC(Asynchronous Procedure Call)로 유사 기능을 제공 — APC는 프로세스가 아니라 특정 스레드에 큐잉되므로 “누구에게?” 문제가 더 단순하다.
7.3 스레드 취소 — 비동기 vs 지연
| 비동기 취소(asynchronous) | 지연 취소(deferred) — 기본·권장 | |
|---|---|---|
| 동작 | 대상을 즉시 강제 종료 | 대상이 취소 지점에서 스스로 정리 후 종료 |
| 위험 | 락 보유·자원 할당 중 끊기면 누수·불변식 깨짐 | 안전한 지점에서만 종료 |
| API | PTHREAD_CANCEL_ASYNCHRONOUS | pthread_testcancel(), 블로킹 콜이 취소 지점 |
void *worker(void *arg) {
pthread_cleanup_push(free, buf); // 취소 시 자원 해제 보장
while (running) {
do_chunk();
pthread_testcancel(); // 취소 요청 확인 = 취소 지점
}
pthread_cleanup_pop(1); // 정상 종료 시에도 정리
return NULL;
}
// 취소 요청: pthread_cancel(tid); (요청일 뿐, 실제 종료는 대상이 결정)Java·C++20은 협력적 취소만 제공한다(강제 종료는 위험해 폐기됨): Java Thread.interrupt() + isInterrupted(), C++20 std::stop_token. 모두 “대상이 플래그를 보고 스스로 멈춘다”는 지연 취소 철학이다.
7.4 스레드 지역 저장소(TLS)
스레드들은 데이터를 공유하지만, 때로 각자 사본이 필요하다(트랜잭션 ID, errno, 난수 상태 등). TLS는 “전역처럼 보이지만 스레드마다 다른 인스턴스”다. 지역 변수와 달리 함수 호출을 가로질러 유지되고, 스레드 풀처럼 생성 과정을 제어 못 할 때 특히 유용하다.
_Thread_local int tx_id; // C11 표준 키워드
__thread int tx_id; // gcc/clang 확장 (동일)
// Pthreads 동적 TLS (라이브러리 코드에서 키 생성)
pthread_key_t key;
pthread_key_create(&key, destructor); // 스레드 종료 시 destructor 호출
pthread_setspecific(key, ptr);
void *p = pthread_getspecific(key);
// Java: static final ThreadLocal<Integer> txId = ThreadLocal.withInitial(() -> 0);
// C#: [ThreadStatic] static int txId; / ThreadLocal<int>구현상 TLS는 보통 각 스레드의 TCB가 가리키는 TLS 블록에 산다. x86-64 Linux에서는 %fs 세그먼트 레지스터가 현재 스레드의 TLS 베이스를 가리켜, %fs:offset 한 번에 접근한다(errno가 스레드 안전한 비결).
7.5 우선순위 역전과 우선순위 상속 ⊕ 확장
스레드에 우선순위가 있고(실시간 스레드 포함) 공유 자원을 락으로 보호하면, 우선순위 역전(priority inversion)이라는 스케줄링 병리가 생긴다.
시나리오: 낮은 우선순위 L이 락을 잡고 임계 구역에 있다 → 높은 H가 같은 락을 원해 블록된다 → 그런데 중간 M(락과 무관)이 깨어나 L을 선점(preemption — 실행 중인 스레드를 강제로 멈추고 CPU를 빼앗는 것)한다. 이제 L은 못 돌아 락을 못 놓고, H는 그 락을 기다리며, 결국 H가 M보다 늦게 실행된다(우선순위 뒤집힘). 1997년 화성 탐사선 Mars Pathfinder가 이 버그로 반복 리셋된 사례가 유명하다.
우선순위 상속(priority inheritance): H가 L이 쥔 락을 기다리면, L의 우선순위를 일시적으로 H만큼 끌어올려 M에게 선점되지 않게 한다 → L이 빨리 락을 놓는다(놓으면 원래대로 복귀). POSIX는 pthread_mutexattr_setprotocol(&attr, PTHREAD_PRIO_INHERIT)로 활성화한다. 우선순위 상한(priority ceiling): 락마다 “이 락을 잡는 동안 가질 최고 우선순위”를 미리 정해 두는 변형. 실시간 시스템(원문 연습문제 4.7의 real-time thread)과 RTOS(real-time OS, 실시간 운영체제)에서 필수다.
8OS 내부 — Linux와 Windows의 스레드 📘 OSC 4.7
8.1 Linux — “스레드는 없다, task가 있을 뿐”
Linux는 프로세스와 스레드를 구분하지 않는다. 둘 다 struct task_struct(task)이고, “스레드”란 부모와 자원 포인터를 공유하는 task다. 공유 정도는 clone() 플래그가 결정한다(4.2).
| clone 플래그 | 공유되는 것 |
|---|---|
CLONE_VM | 주소 공간(mm_struct) — 켜면 “스레드”, 끄면 별도 메모리 |
CLONE_FILES | 열린 파일 디스크립터 테이블 |
CLONE_FS | 파일시스템 정보(cwd, root, umask) |
CLONE_SIGHAND | 시그널 핸들러 테이블 |
CLONE_THREAD | 같은 스레드 그룹(TGID) → getpid()가 동일하게 보임 |
스케줄링 단위도 task다. CFS(Completely Fair Scheduler)(및 6.6+의 EEVDF)는 프로세스가 아니라 각 task(스레드)를 스케줄하므로, 스레드가 많을수록 그 프로세스가 더 많은 CPU를 받는다(cgroup으로 그룹 단위 공정성 보정). 같은 clone()에 네임스페이스 플래그(CLONE_NEWPID 등)를 더하면 컨테이너가 된다 — 스레드·프로세스·컨테이너가 한 메커니즘의 연속선상에 있다.
8.2 Windows — ETHREAD / KTHREAD / TEB
Windows는 1:1 모델이며 스레드를 세 구조체로 표현한다. 앞 둘은 커널 공간, 마지막은 사용자 공간에 있다.
9현대 런타임 — goroutine · virtual thread · async/await ⊕ 교재 외 확장
“10만 개 동시 연결을 어떻게 싸게?”라는 질문에 대한 2020년대의 답들이다. 모두 “OS 스레드는 비싸다(스택 수 MB + 커널 문맥 교환)”에서 출발해, 사용자 공간에서 값싼 동시성을 만든다 — 3.2절의 M:N 부활.
9.1 Go — goroutine과 GMP 스케줄러
goroutine은 시작 스택 ~2KB(필요 시 성장)의 사용자 공간 스레드다. Go 런타임이 G·M·P로 M:N 스케줄링한다.
- G(goroutine): 실행 단위. 수십만 개 가능.
- M(machine): 실제 OS 스레드.
- P(processor): 스케줄링 컨텍스트(런 큐 보유).
GOMAXPROCS개수만큼 존재 = 동시에 Go 코드를 실행하는 병렬도.
각 P는 자기 런 큐를 가지고 work-stealing(5.2)으로 부하를 분산한다. goroutine이 블로킹 시스템 콜을 하면 그 M은 커널에서 막히지만, 런타임이 P를 떼어 다른 M에 붙여 나머지 goroutine을 계속 돌린다(= 스케줄러 액티베이션의 정신). 네트워크 I/O는 netpoller(epoll·kqueue — 커널이 수많은 소켓의 I/O 준비 상태를 한 번에 통지하는 메커니즘; 현대 Linux는 io_uring)로 비동기 처리해 M을 막지 않는다.
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs { // 채널이 닫힐 때까지
results <- j * j // 결과를 채널로 통신 (락 불필요)
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
for w := 1; w <= 4; w++ { go worker(w, jobs, results) } // 4 goroutine
for j := 1; j <= 100; j++ { jobs <- j }
close(jobs)
for a := 1; a <= 100; a++ { <-results }
}9.2 Java Virtual Threads (Project Loom, JDK 21+)
가상 스레드는 JVM이 관리하는 경량 스레드다. 핵심은 continuation: 가상 스레드가 블로킹 연산(예: 소켓 read)에 닿으면, JVM이 그 스택을 힙에 저장하고(unmount) 캐리어(플랫폼) 스레드를 풀어준다. I/O가 완료되면 아무 캐리어 스레드에 다시 올린다(mount). “블로킹 코드를 그대로 쓰되 OS 스레드는 막지 않는다” — 동기식 코드의 가독성 + 비동기의 확장성.
// 요청마다 가상 스레드 하나 — 100만 개도 OK (각 ~수백 바이트)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
var data = socket.read(); // 블로킹처럼 보이지만 OS 스레드는 안 막음
return process(data);
});
}
} // 동기 코드의 모습, 비동기의 확장성9.3 async/await — stackless coroutine과 상태 기계
Rust·JavaScript·Python·C#의 async는 다른 접근이다. async fn은 컴파일러가 상태 기계(state machine)로 변환한다 — 각 await 지점이 상태가 되고, 함수는 “poll하면 다음 상태로 전진하다 Pending을 반환하는 객체”가 된다. 스택을 따로 두지 않는 stackless 방식이라 메모리가 극도로 작다(스택이 없으니). 대신 함수 색칠(function coloring) 문제 — async 함수는 async에서만 await 가능 — 가 생긴다.
// Rust — async fn은 Future(상태 기계)를 반환, .await로 합성
async fn handle(sock: TcpStream) -> io::Result<()> {
let req = sock.read().await?; // 여기서 yield 가능
let resp = process(req).await?;
sock.write(resp).await
}
#[tokio::main] // 멀티스레드 work-stealing executor
async fn main() { /* tokio::spawn(handle(...)) ... */ }
# Python — asyncio: 단일 스레드 이벤트 루프 + 코루틴
async def handle(reader, writer):
data = await reader.read(1024) # 이벤트 루프에 제어 양보
writer.write(process(data))
await writer.drain()지금까지의 비교는 주로 공유 메모리 축이다(스레드·goroutine·async가 같은 메모리를 락/atomic으로 조율). 이와 직교하는 다른 축이 메시지 전달이다: 상태를 한 곳에 가두고 메시지로만 소통해 데이터 레이스를 설계로 차단한다. 두 계보가 유명하다 — CSP(Communicating Sequential Processes; Go 채널의 이론적 뿌리, “메모리를 공유해 통신하지 말고, 통신해서 메모리를 공유하라”)와 액터 모델(Actor; 각 액터가 사유 상태 + 우편함을 갖고 비동기 메시지를 직렬 처리 — Erlang/BEAM(Erlang 가상 머신), Akka, 그리고 Swift의 actor). 9절의 런타임이 “어떻게 실행하나”를 답한다면, 이 두 계보는 “어떻게 소통하나”를 답한다 — 둘은 함께 쓰인다(예: Go = M:N 런타임 + CSP 채널).
9.4 세 접근의 비교
| 1:1 OS 스레드 | M:N 경량 스레드 (goroutine·virtual thread) | stackless async (async/await) | |
|---|---|---|---|
| 스케줄 | 커널(선점) | 런타임(주로 협력+선점 혼합) | 런타임 이벤트 루프(협력) |
| 스택 | 고정·큼(MB) | 작고 성장(KB~) | 없음(상태 enum) |
| 개수 한계 | 수천~수만 | 수십만~수백만 | 수백만 |
| 코드 모습 | 동기(블로킹) | 동기(블로킹처럼) | async/await 색칠 |
| 대표 | pthread, std::thread | Go, Java Loom, Erlang | Rust, JS, Python, C# |
셋 다 “블로킹의 비용을 줄이는” 같은 문제를 푼다. 경량 스레드는 런타임이 스택을 관리해 동기 코드를 유지하고, async는 컴파일러가 스택을 없애 메모리를 최소화한다. OS 1:1 스레드는 여전히 그 모든 것의 바닥(carrier/M)에 있다.
10오해 정리 · 한 장 요약 · 복습 📘 OSC 4.8
10.1 흔한 오해 바로잡기
- ❌ “스레드는 프로세스의 모든 것을 공유한다.” → 코드·데이터·힙·파일은 공유하지만 레지스터·PC·스택은 사적이다.
- ❌ “
counter++는 한 줄이니 원자적이다.” → load·add·store 3단계라 데이터 레이스. atomic이나 락 필요. - ❌ “락만 잘 걸면 메모리 순서는 신경 안 써도 된다.” → 락 안에서는 맞지만, lock-free를 쓰는 순간 메모리 일관성 모델·배리어가 핵심이 된다.
- ❌ “x86은 강한 메모리 모델이라 재배열이 없다.” → TSO라도 Store→Load 재배열이 있어 store buffer 리트머스가 깨진다.
- ❌ “스레드를 많이 만들수록 빠르다.” → 코어 수를 넘으면 문맥 교환·경합·false sharing으로 느려진다. Amdahl의 천장도 있다.
- ❌ “비동기 취소로 깔끔하게 죽이면 된다.” → 락·자원을 쥔 채 끊기면 누수·교착. 지연(협력) 취소가 표준.
- ❌ “goroutine/가상 스레드는 OS 스레드보다 항상 빠르다.” → 생성·전환은 싸지만, CPU 바운드 병렬도는 결국 코어 수(P/캐리어)에 묶인다.
10.2 한 장 정리
🎯 4장 핵심
- 스레드 = 공유 주소 공간 + 사적 레지스터·PC·스택. 문맥 교환은 휘발성 CPU 상태를 TCB에 저장/복원하며, 같은 프로세스 내라면 TLB를 안 비워 싸다.
- 동시성(구조) ≠ 병렬성(실행). Amdahl이 직렬 비율로 천장(1/S)을 긋고, Gustafson이 “더 큰 문제”로 반론한다. HW는 멀티코어·SMT·NUMA.
- 매핑 모델 M:1 / 1:1 / M:N / 2단계. 1:1(NPTL)이 단순함으로 이겼고, C10M 시대에 M:N이 부활(스케줄러 액티베이션의 정신).
- 라이브러리는 Pthreads·Windows·Java·C++. 바닥엔
clone()과 경합 시에만 커널로 가는 futex. - 암시적 스레딩 = “스레드가 아니라 작업”: 스레드 풀, work-stealing fork-join, OpenMP, GCD/TBB.
- 동시성의 진짜 위험은 데이터 레이스(UB) + 메모리 일관성(SC/TSO/weak) + 재배열. happens-before와 acquire/release로 다스리고, false sharing은 패딩으로 피한다.
- 이슈: fork는 호출 스레드만 복제(+락 함정), 시그널은 전담 스레드로, 취소는 지연, 데이터는 TLS.
- Linux는 모든 걸
task_struct로(clone 플래그가 공유 결정), Windows는 ETHREAD/KTHREAD/TEB. - 현대: goroutine(GMP)·virtual thread(continuation)·async(stackless 상태기계) — 모두 OS 1:1 스레드 위에서 값싼 동시성을 만든다.
10.3 복습 — 답을 가리고
Q1. 같은 프로세스의 두 스레드 간 문맥 교환이 두 프로세스 간 전환보다 싼 이유는?
주소 공간(페이지 테이블 베이스, x86의 CR3)을 바꾸지 않으므로 TLB를 비울 필요가 없다. 저장/복원할 상태도 (공유 자원 제외) 레지스터·SP 정도로 적다.
Q2. S=0.2일 때 코어를 무한히 늘리면 최대 가속비는? 그리고 그 한계를 어떻게 깨나?
1/S = 1/0.2 = 5배가 천장(Amdahl). 깨는 길은 ① 직렬 구간(락 경합·순차 I/O)을 줄이거나 ② Gustafson 관점으로 문제 크기를 키워 직렬 비율을 상대적으로 낮추는 것.
Q3. 1:1 모델이 M:N을 한때 이긴 이유와, M:N이 돌아온 이유를 각각 한 문장.
이긴 이유: 커널이 스레드를 직접 봐 SMP 병렬·블로킹·시그널이 단순하고, futex로 충분히 쌌다(NPTL). 돌아온 이유: 1:1은 스레드당 MB 스택+커널 비용이라 수십만 개가 불가능 → C10M에 M:N(goroutine·Loom)이 필요.
Q4. counter++를 두 스레드가 100만 번씩 해도 200만이 안 나오는 이유와 두 가지 해법.
load→add→store가 인터리빙되어 lost update가 난다(데이터 레이스). 해법: ① 원자 연산(
fetch_add) ② 뮤텍스로 임계 구역 보호. 카운터는 순서가 무관하니memory_order_relaxed로 충분.
Q5. x86(TSO)에서도 r1==0 && r2==0이 관측되는 store-buffer 리트머스를 설명하고 고치는 법.
각 코어가 자기 store를 store buffer에 담아두고 아직 상대에게 안 보이는 채로 상대 변수를 load하면, 둘 다 0을 읽을 수 있다(Store→Load 재배열). 두 store 뒤에 seq_cst 펜스/MFENCE를 넣어 store가 가시화된 뒤 load하게 한다.
Q6. false sharing이란 무엇이며 왜 “논리적으로 독립인데” 느려지나? 해법은?
서로 다른 스레드가 다른 변수를 쓰지만 그 변수들이 같은 64B 캐시 라인에 있어, 코히어런스가 매 쓰기마다 라인을 무효화·핑퐁한다. 해법: 변수를
alignas(64)로 라인 경계에 패딩해 다른 라인에 두기.
Q7. 멀티스레드 프로그램에서 fork() 직후 malloc()을 부르면 위험한 이유는?
다른 스레드가 allocator 락을 쥔 순간 fork되면 자식은 “주인 없는 잠긴 락”을 상속한다. 자식의 malloc이 그 락을 기다리며 영원히 데드락. 그래서 fork 후엔 exec만 하거나
pthread_atfork로 락을 정돈한다.
Q8. goroutine과 async/await가 “스택”을 다루는 방식의 근본 차이는?
goroutine(과 가상 스레드)은 작고 성장하는 실제 스택을 런타임이 관리(stackful) → 동기 코드 유지. async는 컴파일러가 스택을 없애고 상태 기계로 변환(stackless) → 메모리 최소·함수 색칠 문제.
10.4 연관 자료 · 더 깊이
본문은 같은 OS 코스의 3·5·6·8·18장과 “6절/8절”을 자주 참조한다. 둘은 가리키는 곳이 다르다. “N절” 참조(예: 6절·8절)는 이 페이지 안의 앵커(#s6·#s8 등)로 바로 이동한다. “N장” 참조 — 3장 프로세스·5장 CPU 스케줄링·6장 동기화 도구·8장 교착 상태·18장 가상 머신/컨테이너 — 는 이 운영체제 코스에 순차적으로 추가될 페이지를 가리키며, 현재는 4장만 공개되어 있다. 그 페이지들이 준비되기 전까지, 동기화·메모리 일관성의 하드웨어 측면은 위의 메모리 코스 링크(4강 캐시·10강 MESI/메모리 순서)로 보강하라.
Silberschatz·Galvin·Gagne, Operating System Concepts 10e, Ch.4 · Anderson et al., “Scheduler Activations” (1991) · Herlihy & Shavit, The Art of Multiprocessor Programming (메모리 모델·lock-free) · McKenney, Is Parallel Programming Hard… (무료, 메모리 배리어) · Go runtime scheduler 설계 문서 · JEP 444 (Virtual Threads) · C++ <atomic> memory_order 레퍼런스.