운영체제 · 4장

스레드와 동시성 Threads & Concurrency

하나의 프로세스 안에서 여러 실행 흐름이 메모리를 공유하며 진행한다. 이 단순한 문장 뒤에는 문맥 교환의 비용, 코어 간 메모리 가시성, 스케줄링 정책, 그리고 “언제 무엇이 보이는가”라는 메모리 모델 전체가 숨어 있다.
기반: Operating System Concepts 10판 4장 · 대학원 수준으로 확장 · 이론 + 구현 코드 + 다이어그램

이 장을 마치면

  • 스레드가 프로세스와 무엇을 공유하고 무엇을 사적으로 갖는지 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()하면?
8OS 내부 — Linux · Windows커널은 스레드를 어떤 구조체로 표현하나?
9현대 런타임 — goroutine·virtual thread·async10만 개 동시성을 어떻게 싸게 얻나?
10오해 정리 · 복습

1스레드의 해부 — 무엇을 공유하고 무엇이 사적인가 📘 OSC 4.1

스레드(thread)는 CPU가 스케줄하는 가장 작은 실행 단위다. 한 스레드는 다음 네 가지를 고유하게(private) 갖는다.

반대로, 같은 프로세스에 속한 스레드들은 다음을 공유(shared)한다.

파랑 = 프로세스 전체가 공유 · 주황 = 스레드마다 사적 code data / heap files registers PC stack 단일 스레드 프로세스 code data / heap files regs · PCstackthread 1 regs · PCstackthread 2 regs · PCstackthread 3 멀티스레드 프로세스
스레드는 주소 공간(code·data·heap)과 파일을 공유하되, 레지스터·PC·스택은 각자 갖는다. “공유 메모리 + 독립 실행 흐름”이 스레드의 정의다.

1.1 스레드 제어 블록(TCB)과 PCB의 관계 ⊕ 확장

커널은 프로세스를 PCB(Process Control Block)로, 스레드를 TCB(Thread Control Block)로 표현한다. 핵심은 주소 공간·파일 테이블 같은 무거운 자원은 PCB(또는 공유 구조체)에 한 번만 두고, TCB는 그것을 가리키는 포인터 + 스레드별 휘발성 상태만 갖는다는 점이다. 그래서 스레드 생성·전환이 프로세스보다 싸다.

개념적 TCB — 무엇이 스레드별이고 무엇이 공유 포인터인가 (C)
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(가상→물리 주소 변환 캐시)를 비우지 않아도 된다 — 이것이 스레드 전환이 프로세스 전환보다 싼 결정적 이유다.

문맥 교환의 본질 — 레지스터·SP 스왑 (x86-64 의사 어셈블리)
; 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가 이어서 실행
심화 왜 콜리-세이브드(callee-saved)만?

호출 규약(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.” — 동시성은 설계이고, 병렬성은 그 설계가 멀티코어에서 얻는 보너스다.

단일 코어 — 인터리빙 (동시성 O, 병렬성 X) T1 T2 T3 T1 T2 T3 time 멀티코어 — 진짜 병렬 (동시성 O, 병렬성 O) core 1 T1 T1 core 2 T2 T3 time
같은 3개 스레드라도 단일 코어에선 시간 분할(동시성만), 멀티코어에선 서로 다른 코어에 배치되어 물리적 병렬 실행이 일어난다.

2.1 Amdahl의 법칙 — 확장성의 천장

프로그램의 직렬 비율을 S, 코어 수를 N이라 하면 가속비의 상한은:

Amdahl's Law
            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은 “고정된 시간 안에 얼마나 큰 문제를 풀 수 있나”로 관점을 바꾼다.

Gustafson's Law (scaled speedup)
speedup(N) = N − S·(N − 1)         (S = 직렬 비율, N = 코어 수)
            = S + N·(1 − S)

여기서는 가속비가 N선형으로 증가할 수 있다. 두 법칙은 모순이 아니라 다른 질문에 답한다: Amdahl=“같은 문제를 더 빨리”, Gustafson=“같은 시간에 더 크게”. 데이터센터·HPC의 확장이 가능한 이유가 Gustafson 쪽이다.

speedup (가속비) 코어 수 N 0481216 2 4 8 12 16 이상 (선형) S=.05 S=.10 S=.50
Amdahl: 직렬 비율 S가 가속비의 천장(1/S)을 만든다. S=0.5면 코어를 늘려도 2배에서 정체.

2.3 멀티코어 하드웨어의 실제 — SMT, 멀티코어, NUMA ⊕ 확장

💡 멀티코어 프로그래밍의 5대 과제 (OSC 4.2.1)

① 작업 식별(독립 병렬 단위 찾기) · ② 균형(작업량 균등) · ③ 데이터 분할 · ④ 데이터 의존성(동기화 필요 — 6절) · ⑤ 테스트·디버깅(실행 경로 폭발 → 비결정성). 마지막이 가장 어렵다: 동시성 버그는 재현되지 않는다.

2.4 병렬성의 두 종류

데이터 병렬성작업 병렬성
분배 대상데이터의 부분집합서로 다른 작업(함수)
각 코어의 연산같은 연산다른 연산
배열을 반으로 나눠 각자 합산한 데이터에 평균·분산·정렬을 동시에
전형 도구SIMD, GPU, #pragma omp for스레드 풀, 파이프라인, fork-join

둘은 배타적이지 않다. 실제 시스템은 보통 하이브리드다(예: 작업 병렬 파이프라인의 각 스테이지가 내부적으로 데이터 병렬).

3멀티스레딩 모델 — 사용자/커널 스레드와 M:N 📘 OSC 4.3

스레드 지원은 두 층위에 존재한다. 사용자 스레드는 커널 모르게 라이브러리가 사용자 공간에서 관리하고, 커널 스레드는 OS가 직접 생성·스케줄한다. 둘 사이를 어떻게 매핑하느냐가 모델을 가른다.

다대일 (M:1) user space ↑ / kernel ↓ 병렬 X · 블록 시 전체 정지 일대일 (1:1) 병렬 O · Linux/Windows 스레드 多 → 커널 부담 다대다 (M:N) 병렬 O · 유연 · 구현 난해 goroutine/virtual thread 2단계 M:N + 일부 1:1 고정(bound)
네 모델. 위=사용자 스레드, 아래=커널 스레드. 2단계는 다대다에 “특정 사용자 스레드를 커널 스레드에 못박기(bound)”를 더한 변형.
모델병렬블로킹 콜의 영향비용대표
다대일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)이었다. 이유:

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)이 이를 “커널↔런타임 협력”으로 푼다.

user-level 스레드 라이브러리(런타임) u1 u2 u3 LWP (가상 프로세서) kernel thread → 물리 코어 upcall “스레드가 블록된다 → 새 가상 프로세서 줄게, 다른 스레드 올려라”
스케줄러 액티베이션: 커널이 블로킹/완료를 업콜로 런타임에 통지해, M:N에서도 한 스레드의 블로킹이 다른 사용자 스레드를 멈추지 않게 한다.
심화 왜 사장됐다 부활했나

스케줄러 액티베이션은 우아하지만 시그널·디버깅·커널 복잡도 탓에 주류 Linux에선 채택되지 않았다(NPTL 1:1이 이김). 그러나 그 핵심 아이디어 — “블로킹 시 런타임에 제어를 돌려준다” — 는 Go 런타임의 netpoller와 Java Loom의 continuation unmount로 부활했다. 형태는 다르지만 문제와 통찰은 같다.

4스레드 라이브러리 — API부터 시스템 콜 바닥까지 📘 OSC 4.4

스레드 라이브러리는 생성·조인·동기화 API를 준다. 구현은 사용자 공간(함수 호출) 또는 커널 수준(시스템 콜)이다. 오늘날 1:1 모델에서 pthread_create는 결국 커널의 clone()으로 내려간다. 위에서 아래로 한 층씩 보자.

📝 노트 — 비동기 스레딩 vs 동기 스레딩

스레드를 만드는 두 가지 전략이 있다. 비동기 스레딩(asynchronous): 부모가 자식을 만든 뒤 곧바로 자기 일을 계속한다 — 부모·자식이 독립적으로 진행하며 데이터 공유가 적다(예: 요청마다 스레드를 띄우는 웹 서버, 반응형 UI). 동기 스레딩(synchronous, fork-join): 부모가 자식들을 만들고 모두 끝날 때까지(join) 기다린 뒤 결과를 합친다 — 데이터 공유가 많다(아래 합산 예제·5.2의 fork-join이 이 형태). 같은 API라도 이 둘 중 어느 쪽으로 쓰느냐가 동기화 부담과 설계를 가른다.

4.1 Pthreads — POSIX 표준 (명세이지 구현이 아님)

Pthreads는 IEEE 1003.1c가 정의한 명세다. Linux·macOS·BSD가 각자 구현한다. 합산 예제와 핵심 패턴:

Pthreads — 생성·조인·속성·디태치 (C, 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_createclone() 시스템 콜로 새 task를 만들되, 주소 공간·파일·시그널을 공유하도록 플래그를 준다. 동기화의 바닥에는 futex(fast userspace mutex)가 있다: 경합이 없으면 사용자 공간 원자 연산만으로 끝내고, 경합할 때만 커널로 내려가 잠들고 깨운다. (아래 코드의 cmpxchg는 compare-and-swap, xchg는 atomic exchange — 하드웨어 원자 명령이며, CAS의 함정은 6.5에서 다룬다.)

스레드 라이브러리가 하는 일의 골격 — clone() (C, Linux)
#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);
}
futex 기반 뮤텍스의 핵심 — 경합 없으면 syscall 없음 (C, 의사)
/* 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 스레드

Windows API (C)
#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, &param, 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로).

Java — Executor + Callable/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();
    }
}
Java — Fork-Join (분할 정복 + work-stealing)
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 ⊕ 확장

C++ 표준 스레딩 (C++11 ~ C++20)
#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장에서 상세히 다룬다.)

스레드 풀 직접 구현 — condition_variable 기반 (C++)
#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가 표준 구현이다.

각 워커의 deque — 자기 작업은 bottom에서 push/pop(LIFO), 도둑은 top에서 steal(FIFO) Worker A t4 t5 t6 ↑ A: pop (bottom) steal: top ↑ Worker B (놀고 있음) B의 deque 비었음 steal: A의 top에서 t4를 훔쳐옴
work-stealing: 지역성(자기 LIFO)과 부하 분산(남의 FIFO steal)을 동시에 얻는다. Go·Java FJ·TBB·Rust rayon·.NET TPL이 모두 이 구조.
Chase-Lev work-stealing 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 task

5.3 OpenMP — 지시문 기반 데이터 병렬

OpenMP — 컴파일러 지시문으로 루프 병렬화 (C, 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) 직렬 큐를 추가로 만들 수 있다.

GCD (Swift) — 큐에 클로저를 비동기 제출
// 동시 큐(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가 반복을 “청크”로 쪼개 작업으로 만들고 스레드에 분배한다 — 코어 수에 맞춰 코드를 재작성할 필요가 없다.

Intel TBB — parallel_for 템플릿 (C++)
#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 경쟁 조건과 데이터 레이스 — 정확한 정의

두 용어는 자주 혼용되지만 다르다.

고전적 데이터 레이스 — counter++ 는 원자적이지 않다
// 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).
고치는 법 — 원자 연산 또는 락 (C++)
#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
store buffer 재배열 — x86에서도 깨지는 고전 리트머스 테스트
// 초기: 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에 보인다. 동기화 연산이 이 관계를 만든다.

acquire/release로 만드는 안전한 발행(publish) (C++)
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종 분류

하드웨어 메모리 배리어는 “어떤 종류의 메모리 연산이 서로 넘나들지 못하게 하는가”로 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

MESI는 각 캐시 라인을 Modified·Exclusive·Shared·Invalid 상태로 관리한다. 한 코어가 라인을 쓰려면 다른 코어들의 사본을 무효화(Invalidate)해 자기 것을 Modified로 만든다. 이 무효화 트래픽이 동시성 성능의 숨은 비용이다. (자세한 전이는 메모리 코스 10강 참조.)

⚠️ False Sharing — 논리적으로 독립인데 물리적으로 충돌

서로 다른 스레드가 다른 변수를 쓰는데 그 변수들이 같은 64B 캐시 라인(캐시 전송의 단위 — 토대는 메모리 코스 4강)에 있으면, 코히어런스 프로토콜이 매번 라인을 핑퐁시켜 성능이 급락한다. 변수가 논리적으로 독립이어도 하드웨어는 라인 단위로만 보기 때문이다.

false sharing과 패딩 해법 (C++)
// 나쁨: 두 카운터가 같은 라인 → 코어 간 라인 핑퐁
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)에 따라 계층이 나뉜다.

⚠️ ABA 문제 — CAS의 고전적 함정

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장(동기화)에서 다룬다.

📝 데드락은 어디서 — 8장 예고

위에서 “lock-free가 데드락을 없앤다”고 했는데, 데드락(교착)은 네 조건이 동시에 성립할 때만 일어난다: ① 상호 배제(자원을 한 번에 하나만) ② 점유와 대기(자원을 쥔 채 다른 자원을 기다림) ③ 비선점(강제로 뺏을 수 없음) ④ 순환 대기(대기 사이클). 이 중 하나만 깨도 데드락은 불가능하다 — 4조건의 상세와 예방·회피·탐지·회복은 8장(교착 상태)에서 다룬다. (7.1의 fork+락 데드락이 구체 사례다.)

7스레딩 이슈 — fork·시그널·취소·TLS 📘 OSC 4.6

7.1 멀티스레드에서 fork()와 exec()

fork()가 호출되면 새 프로세스는 호출한 스레드 하나만 복제한다(POSIX). 다른 스레드들은 자식에 존재하지 않는다. 이것이 악명 높은 함정을 만든다.

⚠️ fork() + 락 = 데드락

어떤 스레드가 malloc 내부 락을 쥔 순간 다른 스레드가 fork()하면, 자식은 “잠긴 채 영원히 풀어줄 주인이 없는 락”을 상속한다. 자식이 malloc을 호출하면 그대로 데드락. 그래서 fork 후에는 exec()만 호출하거나(주소 공간을 갈아엎으므로 안전), pthread_atfork() 핸들러로 fork 전후 락을 정돈해야 한다. async-signal-safe 함수만 써야 하는 이유와 같은 맥락.

pthread_atfork — fork 경계에서 락 정돈 (C)
// prepare: 부모가 fork 직전에 모든 락 획득
// parent : fork 직후 부모가 락 해제
// child  : fork 직후 자식이 락 (재)초기화
pthread_atfork(prepare_acquire_locks,
               parent_release_locks,
               child_reinit_locks);

exec()는 그대로다: 호출 프로세스의 모든 스레드를 포함한 전체 이미지를 새 프로그램으로 교체한다.

7.2 시그널 전달 — 어느 스레드가 받나

UNIX 시그널은 단일 스레드에선 단순하지만 멀티스레드에선 “누구에게?”가 문제다.

각 스레드는 자기 시그널 마스크를 가진다(pthread_sigmask). 흔한 패턴: 모든 스레드가 시그널을 블록하고 전담 스레드 하나가 sigwait()로 동기 수신 — 비동기 핸들러의 async-signal-safe 제약을 피한다. pthread_kill(tid, sig)로 특정 스레드를 지정할 수도 있다.

전담 시그널 스레드 패턴 (C)
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) — 기본·권장
동작대상을 즉시 강제 종료대상이 취소 지점에서 스스로 정리 후 종료
위험락 보유·자원 할당 중 끊기면 누수·불변식 깨짐안전한 지점에서만 종료
APIPTHREAD_CANCEL_ASYNCHRONOUSpthread_testcancel(), 블로킹 콜이 취소 지점
지연 취소 + cleanup handler (C)
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는 “전역처럼 보이지만 스레드마다 다른 인스턴스”다. 지역 변수와 달리 함수 호출을 가로질러 유지되고, 스레드 풀처럼 생성 과정을 제어 못 할 때 특히 유용하다.

TLS — 언어/라이브러리별 (C, C11, gcc, Java, C#)
_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)이라는 스케줄링 병리가 생긴다.

H(높음)이 L(낮음)이 쥔 락을 기다리는데, M(중간)이 L을 선점 → H가 M에 간접적으로 막힘 H 실행 락 대기(블록) — L의 락을 기다림 실행 M 실행(락과 무관) — L을 선점 L 락 보유·실행 M에 선점됨(락 쥔 채 정지) 락 해제
우선순위 역전: 낮은 L이 락을 쥔 채 중간 M에게 선점되면, 정작 높은 H가 M보다 늦게 실행된다 — 우선순위가 사실상 뒤집힌다.

시나리오: 낮은 우선순위 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_struct가 같은 자원 구조체를 가리킴 task_struct (T1) pid/tid, state, prio kernel stack (사적) *mm *files *sighand task_struct (T2) *mm *files *sighand mm_struct (주소 공간) files_struct sighand_struct
Linux: task_struct는 무거운 자원을 직접 담지 않고 포인터로 가리킨다. fork는 자원을 복사, clone(CLONE_VM…)은 포인터를 공유 → 같은 메커니즘으로 프로세스·스레드·컨테이너를 모두 표현.

스케줄링 단위도 task다. CFS(Completely Fair Scheduler)(및 6.6+의 EEVDF)는 프로세스가 아니라 각 task(스레드)를 스케줄하므로, 스레드가 많을수록 그 프로세스가 더 많은 CPU를 받는다(cgroup으로 그룹 단위 공정성 보정). 같은 clone()에 네임스페이스 플래그(CLONE_NEWPID 등)를 더하면 컨테이너가 된다 — 스레드·프로세스·컨테이너가 한 메커니즘의 연속선상에 있다.

8.2 Windows — ETHREAD / KTHREAD / TEB

Windows는 1:1 모델이며 스레드를 세 구조체로 표현한다. 앞 둘은 커널 공간, 마지막은 사용자 공간에 있다.

kernel space user space ETHREAD executive 정보 → 소속 프로세스 → 시작 루틴 주소 KTHREAD 스케줄링·동기화 정보 우선순위·상태 kernel stack TEB thread id user stack TLS 배열
ETHREAD(executive)·KTHREAD(kernel, 스케줄링)는 커널만 접근. TEB(Thread Environment Block)는 사용자 모드에서 접근하며 TID·사용자 스택·TLS 배열을 담는다. 레지스터·스택·사적영역을 통틀어 스레드의 context.

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 스케줄링한다.

각 P는 자기 런 큐를 가지고 work-stealing(5.2)으로 부하를 분산한다. goroutine이 블로킹 시스템 콜을 하면 그 M은 커널에서 막히지만, 런타임이 P를 떼어 다른 M에 붙여 나머지 goroutine을 계속 돌린다(= 스케줄러 액티베이션의 정신). 네트워크 I/O는 netpoller(epoll·kqueue — 커널이 수많은 소켓의 I/O 준비 상태를 한 번에 통지하는 메커니즘; 현대 Linux는 io_uring)로 비동기 처리해 M을 막지 않는다.

수많은 G(goroutine)가 P(런 큐)에 쌓이고, M(OS 스레드)이 P를 잡고 실행 G G G G G P0 (run queue) P1 (run queue) M0 (OS thread) M1 (OS thread) work-stealing P가 비면 다른 P의 G를 훔침 블로킹 syscall 시 P를 떼어 다른 M에 붙여 계속 실행
GMP: G(작업)·M(OS 스레드)·P(스케줄링 컨텍스트). P 수가 병렬도(GOMAXPROCS), work-stealing으로 균형, netpoller로 I/O 비차단.
Go — goroutine + channel (공유하지 말고 통신하라)
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 스레드는 막지 않는다” — 동기식 코드의 가독성 + 비동기의 확장성.

virtual thread carrier (OS) thread mounted ─ I/O 블로킹 → 스택을 heap에 저장 (unmount) carrier는 다른 VT 실행 I/O 완료 → 아무 carrier에 remount
가상 스레드: 블로킹 시 continuation을 힙에 저장(unmount)하고 캐리어를 해방, 완료 시 remount. 수백만 개를 만들 수 있다.
Java — 가상 스레드 (JDK 21)
// 요청마다 가상 스레드 하나 — 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 가능 — 가 생긴다.

Start await read()→ Pending 반환 await write()→ Pending 반환 Done 런타임(executor)이 future를 poll → await에서 멈췄다 재개. 스택 없이 enum 상태로 표현.
stackless async: 컴파일러가 async 함수를 “poll할 때마다 다음 await까지 전진하는 상태 기계”로 바꾼다. 메모리는 상태 enum 크기뿐.
Rust (tokio) · Python (asyncio) — 같은 stackless async
// 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()
📝 직교하는 축 — 공유 메모리 vs 메시지 전달 (CSP · 액터)

지금까지의 비교는 주로 공유 메모리 축이다(스레드·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::threadGo, Java Loom, ErlangRust, JS, Python, C#
📌 한 줄 요약

셋 다 “블로킹의 비용을 줄이는” 같은 문제를 푼다. 경량 스레드는 런타임이 스택을 관리해 동기 코드를 유지하고, async는 컴파일러가 스택을 없애 메모리를 최소화한다. OS 1:1 스레드는 여전히 그 모든 것의 바닥(carrier/M)에 있다.

10오해 정리 · 한 장 요약 · 복습 📘 OSC 4.8

10.1 흔한 오해 바로잡기

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 레퍼런스.