2025. 11. 3. 16:04ㆍ개념 공부
스레드(Threads) & 동시성(Concurrency) 완전 해부
한 개의 프로세스 안에 여러 실행 지점(PC)을 두는 스레드라는 추상은 병렬성과 I/O 중첩을 가능하게 합니다. 하지만 그 대가로 공유 데이터, 스케줄링, 원자성, 상호배제라는 개념적 난제를 가져오죠.
1) 스레드란 무엇인가: 프로세스와 같은 듯 다른 두 점
- 주소 공간 프로세스와 공유 (코드/데이터/힙 동일) — 다른 점은 스택: 스레드마다 자기 스택이 따로 존재합니다.
- 문맥 각 스레드는 PC와 레지스터 집합을 가집니다. 스레드 간 전환 시 TCB(Thread Control Block)에 레지스터 상태를 저장/복원합니다. 프로세스 전환과 유사하되 페이지 테이블 스위치가 없기 때문에 더 가볍습니다.
단일 스레드 주소공간
- 하나의 스택: 호출 프레임/지역변수/리턴주소
- 힙은 위로, 스택은 아래로 자라나는 전형적 레이아웃
다중 스레드 주소공간
- 스택이 여러 개: 각 스레드의 지역 상태가 서로 격리
- 힙과 스택들의 성장 충돌 위험—대체로 스택을 작게 가져가 실무에선 크게 문제 없음
→ “코드는 공유하고, 스택은 나눠 가진다”가 핵심. 스레드마다 thread-local 스택이 곧 thread-local storage의 실체입니다.
2) 왜 스레드를 쓰는가: 병렬성과 비차단화
- 병렬화(Parallelism): 다코어에서 데이터 병렬/태스크 병렬을 실현해 속도 향상.
- I/O 중첩(Overlap): 한 스레드가 블록되면 다른 스레드로 곧장 전환 → 서버/DBMS가 즐겨 쓰는 패턴.
프로세스로도 가능하지만, 공유 메모리를 자연스럽게 쓰려면 스레드가 적합합니다.
3) 맛보기: 스레드 생성/조인과 실행 순서의 비결정성
// 두 스레드를 만들어 각각 "A", "B"를 출력하는 예시 (POSIX pthreads)
Pthread_create(&p1, NULL, mythread, "A");
Pthread_create(&p2, NULL, mythread, "B");
Pthread_join(p1, NULL);
Pthread_join(p2, NULL);
가능한 실행 순서는 다양합니다. 생성 순서가 실행 순서를 보장하지 않습니다. 스케줄러가 선택하는 시점에 따라 “A→B”, “B→A”, 혹은 main이 먼저/나중에 출력하는 등 다르게 나타납니다. 동시성의 본질은 비결정성입니다.
4) counter++가 틀리는 이유: 레이스와 임계구역
아래 코드는 전역 counter를 두 스레드가 1e7번씩 증가시켜 최종 2e7을 기대합니다.
for (int i = 0; i < 1e7; i++) {
counter = counter + 1; // 기대: 원자적 증가
}
하지만 실측 결과는 매번 다른 잘못된 값을 뱉기도 합니다. 왜일까요?
어셈블리 관점(3단계 시퀀스)
mov counter, %eax ; 메모리→레지스터
add $1, %eax ; 레지스터에서 +1
mov %eax, counter ; 레지스터→메모리
두 스레드가 위 3단계를 교차 실행하면, “두 번 증가했는데 결과는 한 번 증가” 같은 일이 벌어집니다. 스케줄러가 언제 인터럽트/컨텍스트 스위치를 거는지(즉, 어떤 지점에서 시퀀스가 끊기는지)에 따라 결과가 달라져요. 이 코드는 임계구역(critical section)이며, 보호 없이 동시 실행되면 데이터 레이스가 발생합니다.
• Critical Section: 공유 자원을 다루는 코드 블록(동시에 1개 스레드만 들어가야 안전)
• Race Condition: 실행 타이밍에 따라 결과가 달라지는(잘못될 수 있는) 상황
• Indeterminate: 실행마다 출력이 달라지는 비결정적 프로그램
• Mutual Exclusion: 임계구역에 1개 스레드만 들어가도록 보장하는 성질(락 등으로 달성)
스케줄러가 “악당”이 되는 순간: 상세 트레이스
상세 예시에서 T1이 mov; add까지 실행하고, 인터럽트! → T2가 mov; add; mov을 끝낸 뒤 다시 T1이 복귀해 마지막 mov을 덮어써 증가 손실이 발생합니다. 이 미세한 타이밍을 통제하지 못하면 정답 보장은 없습니다.
5) 원자성(Atomicity)과 동기화 프리미티브의 필요
이상적으로는 memory-add counter, 1 같은 단일 원자 명령이 있으면 끝입니다. 현실은 일반적 코드(예: B-tree 갱신)를 단일 명령으로 만들 수 없죠. 그래서 하드웨어가 제공하는 소수의 원자적 연산(CAS, XCHG 등)과 OS 지원을 이용해 락/세마포어/조건변수 같은 동기화 프리미티브를 구축, 임계구역을 원자적 블록으로 감쌉니다. 이것이 동시성의 “제어”입니다.
6) 프로그래밍 관점의 체크리스트
- 스택은 스레드별, 힙·전역은 공유 — 공유 자료구조를 건드릴 땐 반드시 동기화.
- 스케줄러 타이밍을 의도로 가정하지 말 것 — “여기선 절대 안 끊기겠지?”는 금물.
- 임계구역 최소화 — 잠금 구간을 짧게, 데이터 경합을 줄이는 설계(분할, 샤딩, 락 프리 구조) 고려.
- 도구 사용 — 디스어셈블러/디버거/레이스 디텍터(ThreadSanitizer 등)로 가시화하고 가정 검증.
7) 연구자로서 더 깊이 보기
- 메모리 모델: 하드웨어/컴파일러 재주문(리오더링)이 데이터 레이스와 어떤 상호작용을 만드는가?
- 동기화 비용: 락 경합, 캐시 코히어런스, 페어런스 vs 스루풋의 균형 설계.
- 대안 패러다임: Actor, CSP, STM, RCU, Lock-free/Wait-free 알고리즘.
- OS 내부: 커널 데이터 구조(프로세스 리스트, 페이지 테이블, 파일시스템 메타데이터) 갱신 시 동기화 패턴.
8) 미니 퀴즈(자기 점검)
- 스레드 컨텍스트 스위치가 프로세스 컨텍스트 스위치보다 가벼운 이유는? (힌트: 주소 공간 전환)
- counter++ 가 세 단계로 분해될 때, 어디서 인터럽트가 걸리면 증분 손실이 발생하는가?
- “임계구역”과 “상호배제”를 어셈블리 수준 시퀀스 관점에서 정의해보라.
- “원자성”을 보장하기 위해 하드웨어와 운영체제가 각각 제공하는 역할은 무엇인가?
정답 보기 / 숨기기
- 스레드 컨텍스트 스위치가 더 가벼운 이유: 스레드는 같은
주소 공간(page table)을 공유하기 때문에, 프로세스처럼 MMU의 페이지 테이블을 교체할 필요가 없습니다. 즉, TSS, CR3 레지스터, TLB flush가 발생하지 않아 훨씬 빠릅니다. - counter++에서 손실이 생기는 지점:
mov counter, %eax이후add $1, %eax사이 혹은mov %eax, counter직전. 즉, 메모리→레지스터→메모리로 가는 3단계 중 레지스터 단계에서 문맥 전환이 일어나면 이전 값이 덮어쓰여집니다. - 임계구역 & 상호배제: 임계구역은 공유 데이터를 갱신하는 코드 블록. 상호배제는 해당 블록을 동시에 실행하는 스레드가 1개 이하임을 보장하는 성질. 어셈블리 수준에서는
mov/add/mov와 같은 시퀀스를 하나의 원자 단위로 보장해야 함을 의미합니다. - 원자성 보장 역할 분담:
- 하드웨어:Test-and-Set,Compare-and-Swap,XCHG등의 원자적 명령어 제공.
- 운영체제: 이러한 명령을 이용해 락(lock), 세마포어, 모니터 등의 동기화 추상화 제공.
스레드는 주소 공간을 공유해 협업을 쉽게 하지만, 그만큼 공유 데이터 동기화를 올바르게 설계해야 합니다. 문제의 본질은 “스케줄러가 언제든 끊을 수 있다”는 사실. 따라서 임계구역을 식별하고, 원자성을 부여하고, 상호배제를 지켜야 비로소 결과가 결정적이 됩니다.
'개념 공부' 카테고리의 다른 글
| 운영체제 아주 쉬운 세가지 이야기 10주차(락) (1) | 2025.11.11 |
|---|---|
| 운영체제 아주 쉬운 세가지 이야기 9주차(쓰레드 API) (0) | 2025.11.03 |
| Thrashing과 OOM Killer — 메모리 관리의 핵심 메커니즘 (0) | 2025.10.29 |
| 운영체제 아주 쉬운 세가지 이야기 8주차(물리 메모리 크기의 극복: 정책) (0) | 2025.10.29 |
| 운영체제 아주 쉬운 세가지 이야기 8주차(물리 메로리 크키의 극복: 메커니즘) (0) | 2025.10.29 |