운영체제 아주 쉬운 세가지 이야기 9주차(병행성: 개요)

2025. 11. 3. 16:04개념 공부

스레드(Threads) & 동시성(Concurrency) 완전 해부

한 개의 프로세스 안에 여러 실행 지점(PC)을 두는 스레드라는 추상은 병렬성I/O 중첩을 가능하게 합니다. 하지만 그 대가로 공유 데이터, 스케줄링, 원자성, 상호배제라는 개념적 난제를 가져오죠.

왜 이 글인가? 스레드가 “왜 필요한지”에서 시작해 “어디가 위험한지(critical section)”와 “왜 결과가 들쭉날쭉(인덱터미넌트)해지는지(레이스)”까지, 소스/어셈블리/스케줄러 관점으로 한 번에 연결합니다.

1) 스레드란 무엇인가: 프로세스와 같은 듯 다른 두 점

  • 주소 공간 프로세스와 공유 (코드/데이터/힙 동일) — 다른 점은 스택: 스레드마다 자기 스택이 따로 존재합니다.
  • 문맥 각 스레드는 PC와 레지스터 집합을 가집니다. 스레드 간 전환 시 TCB(Thread Control Block)에 레지스터 상태를 저장/복원합니다. 프로세스 전환과 유사하되 페이지 테이블 스위치가 없기 때문에 더 가볍습니다.

단일 스레드 주소공간

  • 하나의 스택: 호출 프레임/지역변수/리턴주소
  • 힙은 위로, 스택은 아래로 자라나는 전형적 레이아웃

다중 스레드 주소공간

  • 스택이 여러 개: 각 스레드의 지역 상태가 서로 격리
  • 힙과 스택들의 성장 충돌 위험—대체로 스택을 작게 가져가 실무에선 크게 문제 없음

→ “코드는 공유하고, 스택은 나눠 가진다”가 핵심. 스레드마다 thread-local 스택이 곧 thread-local storage의 실체입니다. 

 

2) 왜 스레드를 쓰는가: 병렬성과 비차단화

  1. 병렬화(Parallelism): 다코어에서 데이터 병렬/태스크 병렬을 실현해 속도 향상.
  2. 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) 미니 퀴즈(자기 점검)

  1. 스레드 컨텍스트 스위치가 프로세스 컨텍스트 스위치보다 가벼운 이유는? (힌트: 주소 공간 전환)
  2. counter++ 가 세 단계로 분해될 때, 어디서 인터럽트가 걸리면 증분 손실이 발생하는가?
  3. “임계구역”과 “상호배제”를 어셈블리 수준 시퀀스 관점에서 정의해보라.
  4. “원자성”을 보장하기 위해 하드웨어운영체제가 각각 제공하는 역할은 무엇인가?
정답 보기 / 숨기기
  1. 스레드 컨텍스트 스위치가 더 가벼운 이유: 스레드는 같은 주소 공간(page table)을 공유하기 때문에, 프로세스처럼 MMU의 페이지 테이블을 교체할 필요가 없습니다. 즉, TSS, CR3 레지스터, TLB flush가 발생하지 않아 훨씬 빠릅니다.
  2. counter++에서 손실이 생기는 지점: mov counter, %eax 이후 add $1, %eax 사이 혹은 mov %eax, counter 직전. 즉, 메모리→레지스터→메모리로 가는 3단계 중 레지스터 단계에서 문맥 전환이 일어나면 이전 값이 덮어쓰여집니다.
  3. 임계구역 & 상호배제: 임계구역은 공유 데이터를 갱신하는 코드 블록. 상호배제는 해당 블록을 동시에 실행하는 스레드가 1개 이하임을 보장하는 성질. 어셈블리 수준에서는 mov/add/mov와 같은 시퀀스를 하나의 원자 단위로 보장해야 함을 의미합니다.
  4. 원자성 보장 역할 분담:
    - 하드웨어: Test-and-Set, Compare-and-Swap, XCHG 등의 원자적 명령어 제공.
    - 운영체제: 이러한 명령을 이용해 락(lock), 세마포어, 모니터 등의 동기화 추상화 제공.
요약
스레드는 주소 공간을 공유해 협업을 쉽게 하지만, 그만큼 공유 데이터 동기화를 올바르게 설계해야 합니다. 문제의 본질은 “스케줄러가 언제든 끊을 수 있다”는 사실. 따라서 임계구역을 식별하고, 원자성을 부여하고, 상호배제를 지켜야 비로소 결과가 결정적이 됩니다.