Threads

Kamator0·2026년 4월 14일

동기 (Motivation)

프로세스 기반 서버의 문제

전통적인 방식에서는 웹 서버가 클라이언트 요청을 처리할 때, 요청마다 새로운 프로세스(Process)를 생성했습니다. 예를 들어 웹 서버에 10명의 사용자가 동시에 접속하면, 10개의 프로세스를 만들어야 합니다.

Problems of processes

  • 큰 컨텍스트 스위칭 오버헤드 (Large context switching overhead)
    • 프로세스 간 전환 시 다음을 모두 교체해야 합니다:
    • 모든 레지스터 값 저장/복원
    • 메모리 매핑 테이블(페이지 테이블) 교체
    • TLB(Translation Lookaside Buffer) 플러시
    • CPU 캐시 무효화 가능성

이 작업은 수천~수만 CPU 사이클이 소요되며, 프로세스가 많을수록 전환 빈도가 높아져 실제 작업에 쓸 수 있는 CPU 시간이 줄어듭니다.

  • 큰 프로세스 생성 시간 (Large process creation time)
    • 새 프로세스를 만들려면 운영체제가 다음을 수행해야 합니다:
    • 독립적인 가상 메모리 공간 할당
    • 페이지 테이블 생성
    • 코드, 데이터, 힙, 스택 영역 복사 또는 설정
    • 파일 디스크립터 테이블 복사
    • PCB(Process Control Block) 생성 및 초기화

이 과정은 매우 무겁고, 요청이 올 때마다 반복하면 성능이 크게 저하됩니다.

  • 큰 동기화 오버헤드 (Large synchronization overhead)
    • 프로세스들은 서로 독립적인 메모리 공간을 가지므로, 데이터를 공유하려면 IPC(Inter-Process Communication)를 사용해야 합니다. IPC 방법에는 파이프(pipe), 소켓(socket), 공유 메모리(shared memory), 메시지 큐(message queue) 등이 있는데, 모두 커널을 경유해야 하므로 오버헤드가 큽니다.

핵심: 이 세 가지 문제를 해결하기 위해 등장한 것이 스레드(Thread)입니다.

멀티스레딩이란? (Multithreading)

정의

멀티스레딩이란 하나의 프로세스 안에 여러 개의 실행 흐름(스레드)을 두는 것입니다.

스레드(Thread)의 정의

  • 스레드는 CPU 이용의 기본 단위입니다. 프로세스가 "자원의 단위"라면, 스레드는 "실행의 단위"입니다.

스레드 컨텍스트 (Thread Context)

  • 각 스레드는 자신만의 스레드 컨텍스트를 가집니다:
구성 요소설명
Thread ID스레드를 식별하는 고유 번호
Program Counter (PC)다음에 실행할 명령어의 주소
Register SetCPU 레지스터 값들. 스레드가 계산 중이던 중간 결과 등을 저장
Stack함수 호출 시 지역 변수, 매개변수, 리턴 주소 등을 저장하는 영역

실제 예시

웹 브라우저 (하나의 프로세스):

  • 네트워킹 스레드: 서버에서 HTML, 이미지 등의 데이터를 다운로드
  • 화면 표시 스레드: 다운로드된 데이터를 화면에 렌더링
  • 디스크 I/O 스레드: 캐시 파일을 디스크에 읽고/쓰기

이 세 스레드가 동시에 실행되기 때문에, 이미지를 다운로드하는 동안에도 이미 받은 텍스트를 화면에 표시할 수 있습니다. 만약 단일 스레드라면 다운로드가 끝날 때까지 화면이 멈춰 있을 것입니다.

워드 프로세서 (하나의 프로세스):

  • 화면 표시 스레드: 문서를 화면에 그림
  • 키보드 입력 스레드: 사용자의 키 입력을 받음
  • 맞춤법/문법 검사 스레드: 백그라운드에서 오타를 검사

사용자가 글을 타이핑하는 동안에도 맞춤법 검사가 동시에 돌아갈 수 있는 이유가 바로 멀티스레딩 덕분입니다.


함수 호출 vs 멀티스레딩

함수 호출 방식 (순차 실행)

  • 실행 순서: main → Func0 실행 → Func0 종료 → Func1 실행 → Func1 종료 → main 종료

메모리 구조:

  • Text(코드), Initialized data, bss, heap은 하나
  • Stack도 하나 — Func0이 끝나면 그 스택 프레임이 정리되고, Func1의 스택 프레임이 쌓임
  • Func0과 Func1은 절대 동시에 실행되지 않음

멀티스레딩 방식 (동시 실행)

실행 방식: Thread0Func과 Thread1Func이 동시에 실행됩니다.

메모리 구조:

  • Text, Initialized data, bss, heap은 공유
  • globalVar, pMem, fd 모두 두 스레드가 함께 접근 가능
  • 각 스레드는 독립적인 Stack을 가짐 (Thread 0의 지역변수 a와 Thread 1의 지역변수 b는 서로 접근 불가)
  • 각 스레드는 Thread Control Block(TCB)을 가짐 — Thread context(PC, 레지스터)와 Thread info를 저장

공통점과 차이점 정리

항목함수 호출멀티스레딩
다른 함수/스레드의 지역변수 접근불가불가
전역 변수 접근가능 (공유)가능 (공유)
힙 영역 접근가능 (공유)가능 (공유)
스택하나의 스택 공유스레드마다 별도 스택
실행 방식순차 실행동시(concurrent) 실행

중요: 전역 변수와 힙을 공유한다는 것은 편리하지만, 동시에 접근할 때 경쟁 조건(race condition)이 발생할 수 있습니다. 예를 들어 두 스레드가 동시에 globalVar++을 실행하면, 값이 2가 아니라 1만 증가할 수 있습니다. 이것이 동기화(synchronization)가 필요한 이유입니다.

LOAD  globalVar → register   // 메모리에서 레지스터로 읽기
ADD   register, 1            // 레지스터에서 +1
STORE register → globalVar   // 다시 메모리에 저장

단일 스레드 vs 멀티스레드 프로세스

단일 스레드 프로세스 (Single-threaded Process)

  • 실행 흐름이 하나뿐
  • 레지스터 1세트, 스택 1개

멀티스레드 프로세스 (Multithreaded Process)

공유하는 자원스레드별 독립적인 자원
Code (프로그램 코드)Stack (스택)
Data (전역 변수)Registers (레지스터)
Files (열린 파일)Program Counter
Heap (동적 메모리)Thread ID

핵심: 스레드는 프로세스의 자원을 공유하면서, 실행에 필요한 최소한의 것(스택, 레지스터, PC)만 독립적으로 가집니다. 이것이 스레드가 "경량 프로세스(lightweight process)"라고 불리는 이유입니다.


스레드의 장점 (Benefits)

낮은 컨텍스트 스위칭 오버헤드

  • 프로세스 간 전환 시에는 메모리 맵(페이지 테이블), TLB, 캐시 등을 모두 교체해야 합니다. 하지만 같은 프로세스 내의 스레드 간 전환은 레지스터와 스택 포인터만 교체하면 됩니다. 메모리 공간이 같으므로 페이지 테이블 교체가 불필요합니다.

실제 측정 시, 스레드 간 컨텍스트 스위칭은 프로세스 간 전환보다 수십 배 빠릅니다.

매우 짧은 스레드 생성 시간

  • 프로세스를 생성하려면 독립적인 메모리 공간, 파일 테이블 등을 모두 새로 만들어야 합니다. 스레드 생성은 스택 영역 할당 + TCB(Thread Control Block) 생성만 하면 되므로 훨씬 빠릅니다.

일반적으로 스레드 생성은 프로세스 생성보다 10~100배 빠릅니다.

낮은 동기화 오버헤드

  • 프로세스 간 통신은 커널을 경유하는 IPC를 사용해야 합니다. 스레드들은 같은 메모리 공간을 공유하므로, 전역 변수나 힙을 통해 직접 데이터를 주고받을 수 있습니다. 물론 mutex나 세마포어 같은 동기화 메커니즘은 필요하지만, IPC보다 훨씬 가볍습니다.

적은 메모리 점유

  • 같은 프로세스 내의 스레드들은 코드, 데이터, 힙, 파일을 공유합니다. 10개의 독립 프로세스를 만들면 코드와 데이터가 10벌 필요하지만, 1개 프로세스에 10개 스레드를 만들면 코드와 데이터는 1벌이면 됩니다.

프로세스 메모리 레이아웃 참고

높은 주소 (0xFFF00000)
┌──────────────────┐
│ per-process      │
│ kernel stack     │
├──────────────────┤
│ red zone         │
├──────────────────┤
│ user area        │
├──────────────────┤
│ ps_strings struct│
├──────────────────┤
│ signal code      │
├──────────────────┤
│ env strings      │
├──────────────────┤
│ argv strings     │
├──────────────────┤
│ env pointers     │
├──────────────────┤
│ argv pointers    │
├──────────────────┤
│ argc             │
├──────────────────┤
│ user stack    ↓  │  ← 아래로 성장
│                  │
│                  │
│ heap          ↑  │  ← 위로 성장
├──────────────────┤
│ bss              │  ← 초기화되지 않은 전역변수
├──────────────────┤
│ initialized data │  ← 초기화된 전역변수
├──────────────────┤
│ text             │  ← 프로그램 코드
└──────────────────┘
낮은 주소 (0x00000000)

스레드를 추가하면 이 구조에서 스택 영역만 추가됩니다. 나머지는 모두 공유합니다.


프로세스와 스레드의 관계

프로세스의 구성

프로세스는 두 가지 요소로 구성된 복합 엔티티(compound entity)입니다:

  1. 스레드의 집합 (A set of threads): 실제 코드를 실행하는 실행 흐름들
  2. 자원의 모음 (A collection of resources): 메모리, 열린 파일 테이블, 시그널 마스크 등

프로세스 생성 시

  • 프로세스를 처음 만들면 자동으로 Thread 0 (메인 스레드)이 하나 생깁니다:

  • Thread 0은 자신만의 signal mask와 kernel stack을 가지고, 프로세스의 메모리와 Open File table을 사용합니다.

스레드 추가 생성 시

  • Thread 1을 추가로 만들면:

  • 새로 생기는 것: Thread 1의 signal mask, kernel stack
    공유하는 것: Memory, Open File table

핵심 정리:

  • 실행의 단위는 스레드(Thread)
  • 프로세스는 스레드가 실행되는 환경(execution environment)을 제공
  • 프로세스를 "공장"이라고 하면, 스레드는 "공장 안의 노동자"에 해당

스레드 라이브러리 (Thread Library)

정의

  • 스레드 라이브러리는 프로그래머에게 스레드를 생성하고 관리하는 API를 제공합니다.

구현 방식

  • 스레드 라이브러리는 두 가지 방식으로 구현될 수 있습니다

    • User-level library (사용자 수준 라이브러리)

      • 라이브러리의 모든 코드와 데이터가 사용자 공간(user space)에 존재
      • 스레드 관련 함수 호출이 시스템 콜이 아닌 일반 함수 호출로 처리됨
      • 커널은 스레드의 존재를 전혀 모름 — 커널 입장에서는 단일 스레드 프로세스로 보임
      • 장점: 매우 빠른 스레드 전환 (커널 진입 불필요)
      • 단점: 한 스레드가 블로킹 시스템 콜을 하면 전체 프로세스가 블록됨
    • Kernel-level library (커널 수준 라이브러리)

      • 코드와 데이터 구조가 커널 공간(kernel space)에 존재
      • 스레드 관련 함수 호출이 시스템 콜로 처리됨
      • 커널이 각 스레드를 직접 인식하고 스케줄링
      • 장점: 한 스레드가 블록되어도 다른 스레드 실행 가능
      • 단점: 스레드 전환 시 커널 진입이 필요하므로 사용자 수준보다 느림

주요 스레드 라이브러리 예시

라이브러리수준비고
POSIX PThread과거 사용자 수준 → 현재 커널 수준Unix/Linux 표준
Win32 Thread커널 수준Windows API
Java Thread커널 수준JVM이 OS 스레드에 매핑

사용자 수준 스레드 (User-level Threads)

  • 특징

    • 스레드 관리(생성, 스케줄링, 동기화)가 사용자 수준 라이브러리에서 수행됨
    • 커널 위에서(above the kernel) 지원되며, 커널의 도움 없이 관리됨
    • 커널은 이 스레드들의 존재를 모름
  • 동작 방식

    • 사용자 수준 스레드 라이브러리가 자체적으로:

      • 스레드 테이블을 유지 (각 스레드의 PC, 레지스터, 스택 포인터 등)
      • 스레드 간 전환을 직접 수행 (커널 개입 없음)
      • 스케줄링 알고리즘을 자체적으로 구현
  • 장점

    • 커널에 진입하지 않으므로 스레드 전환이 매우 빠름
    • OS에 관계없이 동작 가능 (이식성 높음)
  • 단점

    • 한 스레드가 블로킹 시스템 콜(예: read())을 호출하면 커널이 프로세스 전체를 블록시킴
    • 커널이 스레드를 모르므로 멀티코어 활용 불가 (하나의 프로세스 = 하나의 CPU)

커널 수준 스레드 (Kernel-level Threads)

  • 특징
    • 커널이 직접 스레드를 생성하고 소멸시킴
    • 커널의 텍스트와 전역 데이터를 공유하며, 자신만의 커널 스택을 가짐
    • 커널 스케줄러가 독립적으로 스케줄링
    • 커널의 동기화 메커니즘(sleep(), wakeup() 등)을 사용

사용하는 OS

OS
Windows XP/2000
Solaris
Linux
Tru64 UNIX
Mac OS X

커널 스레드의 용도

  • 비동기 I/O (Asynchronous I/O): 디스크나 네트워크 I/O를 기다리는 동안 다른 작업 수행
  • 인터럽트 처리 (Handling interrupt): 하드웨어 인터럽트를 전담 스레드가 처리

멀티스레딩 모델 (Multithreading Models)

사용자 스레드(user thread)와 커널 스레드(kernel thread)를 어떻게 매핑하느냐에 따라 네 가지 모델이 있습니다.

Many-to-One 모델 (다대일)

  User threads
  ↓  ↓  ↓  ↓  ↓
  └──┴──┴──┴──┘
        │
        ▼
      [ k ]        ← 커널 스레드 1개

여러 사용자 스레드 → 하나의 커널 스레드

동작 방식

  • 사용자 수준 라이브러리가 여러 스레드를 관리하지만, 커널은 단 하나의 스레드만 인식
  • 사용자 스레드 간 전환은 라이브러리가 수행 (빠름)
  • 커널 입장에서는 단일 스레드 프로세스

치명적 단점

한 스레드가 블로킹 시스템 콜(예: 디스크 읽기, 키보드 입력 대기)을 호출하면, 유일한 커널 스레드가 블록되어 프로세스 전체가 멈춤. 나머지 사용자 스레드들도 모두 실행 불가.

Thread A → Disk read (blocking) → 커널 스레드 블록!
Thread B → 실행하고 싶지만 커널 스레드가 없으므로 대기
Thread C → 마찬가지로 대기

또한 커널이 스레드를 하나만 인식하므로 멀티코어 CPU를 활용할 수 없음.

사용 예시

  • Solaris Green Threads
  • GNU Portable Threads

현재는 거의 사용되지 않는 모델입니다.


One-to-One 모델 (일대일)


각 사용자 스레드 → 각각의 커널 스레드

동작 방식

  • 사용자 스레드를 만들면 대응하는 커널 스레드도 같이 생성됨
  • 커널이 모든 스레드를 인식하고 독립적으로 스케줄링

장점

  • 한 스레드가 블로킹 시스템 콜을 해도 다른 스레드는 계속 실행 가능
  • 멀티코어 CPU 활용 가능 — 커널이 각 스레드를 다른 코어에 배치 가능
Thread A → Disk read (blocking) → 커널 스레드 A 블록
Thread B → 커널 스레드 B가 있으므로 계속 실행!
Thread C → 커널 스레드 C가 있으므로 계속 실행!

단점

  • 사용자 스레드마다 커널 스레드를 만들어야 하므로 생성 비용이 큼
  • 스레드 수가 많아지면 컨텍스트 스위칭 오버헤드 증가 -> 현재는 성능이 좋아져서 오버헤드가 거의 없는 수준
  • 시스템의 커널 스레드 수에 제한이 있을 수 있음

사용 예시

  • Windows NT/XP/2000 (현재 Windows 전체)
  • Linux
  • Solaris 9 이후

현재 가장 널리 사용되는 모델입니다.


Many-to-Many 모델 (다대다)

  User threads
  ↓  ↓  ↓  ↓  ↓
  └──┴──┼──┴──┘
     ╱   │   ╲
    ▼    ▼    ▼
   [k]  [k]  [k]   ← 커널 스레드 3개 (사용자 스레드보다 적음)

여러 사용자 스레드 → 여러(더 적은 수의) 커널 스레드

동작 방식

  • M개의 사용자 스레드를 N개의 커널 스레드에 매핑 (M ≥ N)
  • 사용자 수준 라이브러리가 사용자 스레드를 커널 스레드에 동적으로 배정

장점

  • 제한된 수의 커널 스레드로 좋은 수준의 동시성(concurrency) 제공
  • 커널 스레드 수를 제한하여 시스템 자원 절약

단점

  • 진정한 동시성이 아님
  • 예: 4:3 모델(사용자 4개, 커널 3개)에서 3개의 사용자 스레드가 동시에 블로킹 시스템 콜을 하면, 3개의 커널 스레드가 모두 블록되어 나머지 1개의 사용자 스레드도 실행 불가
Thread A → Sock read (blocking) → 커널 스레드 1 블록
Thread B → Sock read (blocking) → 커널 스레드 2 블록
Thread C → Disk read (blocking) → 커널 스레드 3 블록
Thread D → 실행하고 싶지만 가용 커널 스레드 없음 → 대기!

사용 예시

  • Solaris 9 이전 버전
  • Windows NT/2000의 ThreadFiber 패키지

Two-level 모델 (이중 레벨)


Many-to-Many + 특정 스레드의 1:1 바인딩

동작 방식

  • 기본적으로 M:N 매핑을 사용하되, 중요한 스레드는 전용 커널 스레드에 직접 바인딩 가능
  • 실시간 처리가 필요하거나 블로킹이 잦은 스레드를 1:1로 매핑하여 보장

장점

  • M:N 모델의 유연성 + 1:1 모델의 확실한 성능 보장을 결합

사용 예시

  • IRIX
  • HP-UX
  • Tru64 UNIX
  • Solaris 8 이전

모델 비교 총정리

모델매핑블로킹 시멀티코어 활용생성 비용대표 OS
Many-to-OneM:1전체 프로세스 블록불가매우 낮음(과거) Solaris Green
One-to-One1:1다른 스레드 계속 실행가능높음Linux, Windows
Many-to-ManyM:N커널 스레드 수에 의존제한적중간(과거) Solaris
Two-levelM:N + 1:1바인딩된 스레드는 보장가능중간(과거) IRIX, HP-UX

스레드 취소 (Thread Cancellation)

정의

스레드 취소란 스레드가 작업을 완료하기 전에 강제로 종료시키는 것입니다.

예를 들어, 웹 브라우저에서 여러 스레드가 이미지를 동시에 다운로드하는 중에 사용자가 "중지" 버튼을 누르면, 남은 다운로드 스레드들을 취소해야 합니다.

두 가지 방식

Asynchronous Cancellation (비동기 취소)

대상 스레드를 즉시 종료합니다.

위험성: 스레드가 어떤 작업 중이었는지에 관계없이 즉시 죽이므로:

  • 메모리를 할당했지만 해제하지 못할 수 있음 (메모리 누수)
  • 파일을 열었지만 닫지 못할 수 있음
  • Lock(뮤텍스)을 잡은 상태에서 죽을 수 있음 ← 가장 심각

Deferred Cancellation (지연 취소)

대상 스레드가 주기적으로 "나를 취소해야 하나?"를 확인(cancellation point)하고, 안전한 시점에서 스스로 종료합니다.

장점: 스레드가 정리 작업(lock 해제, 메모리 반환 등)을 수행한 후 안전하게 종료할 수 있음

Lock 문제 시나리오 (비동기 취소의 위험)

Thread 0이 lock을 잡은 상태에서 비동기 취소로 강제 종료되면, unlock()이 실행되지 않습니다.

이때 Thread 1은 어떻게 되는가?

OSThread 1의 상태
Linux영원히 잠듦 (deadlock) — lock이 영영 해제되지 않으므로 Thread 1은 무한 대기
Windows깨어나서 lock을 획득 — OS가 소유자가 죽은 lock을 자동으로 해제해줌

교훈: 비동기 취소는 매우 위험하며, 거의 항상 지연 취소(deferred cancellation)를 사용합니다.


스레드 풀 (Thread Pools)

문제 상황

웹 서버가 요청마다 새 스레드를 생성하면:

  • 스레드 생성/소멸의 오버헤드가 누적됨
  • 동시 요청이 폭증하면 스레드가 무한히 늘어나 시스템이 다운될 수 있음

해결: 스레드 풀

미리 일정 수의 스레드를 만들어 놓고(pool), 요청이 오면 풀에서 스레드를 꺼내 할당하는 방식입니다.

동작 방식

main :

// 서버 시작할 때 딱 한 번 실행
int main() {
    
    // 스레드 10개를 미리 생성해서 pool에 넣어둠
    for (int i = 0; i < 10; i++) {
        CreateThread(workThread);  // 10개 다 만들어놓음
    }

    // 이후 요청이 와도 스레드 새로 안 만듦
    while(1) {
        request = waitForRequest();  // 요청 대기
        assignToPool(request);       // pool에서 놀고있는 스레드한테 넘김
    }
}

각 워커 스레드의 구조:

int workThread(int param) {
    while (1) {                        // 무한 루프
        wait for a request to arrive;  // 요청 대기
        switch (request) {             // 요청 종류에 따라 처리
            case DB_query:
    		lock();    // 나 쓰는 동안 다른 스레드 접근 금지
    		count++;
    		unlock();
            case DB_insert: ...
            ...
        }
    }
}

장점

장점설명
빠른 응답이미 만들어진 스레드를 사용하므로, 스레드 생성 시간이 절약됨
자원 제한스레드 수를 풀 크기로 제한하여 시스템 과부하 방지
안정성동시 접속자가 폭증해도 스레드 수가 풀 크기를 넘지 않음

실무 적용

스레드 풀은 현대 서버 프로그래밍에서 매우 널리 사용됩니다:

  • Java의 ExecutorService / ThreadPoolExecutor
  • Python의 concurrent.futures.ThreadPoolExecutor
  • Node.js의 libuv 내부 스레드 풀
  • Nginx, Apache 등 웹 서버의 워커 스레드

플랫폼별 스레드 구현

Pthreads (POSIX Threads)

POSIX 표준 (IEEE 1003.1c)으로 정의된 스레드 API입니다.

  • API는 스레드 라이브러리의 동작(behavior)을 명세
  • 실제 구현(implementation)은 라이브러리 개발자에게 맡김
  • Unix 계열 OS에서 널리 사용: Solaris, Linux, Mac OS X

주요 함수:

함수설명
pthread_create()새 스레드 생성
pthread_join()특정 스레드의 종료를 기다림
pthread_exit()현재 스레드 종료
pthread_cancel()다른 스레드 취소 요청
pthread_mutex_lock()뮤텍스 잠금
pthread_mutex_unlock()뮤텍스 해제

Windows XP Threads

One-to-One 매핑을 사용합니다.

각 스레드의 구성 요소

구성 요소설명
Thread ID고유 식별자
Register SetCPU 레지스터 값
User Stack사용자 모드 스택
Kernel Stack커널 모드 스택
Private Data Storage Area스레드 전용 데이터

이것들을 통칭하여 스레드의 컨텍스트(context)라고 합니다.

주요 데이터 구조

구조체위치설명
ETHREAD (Executive Thread Block)커널 공간스레드가 속한 프로세스의 포인터, KTHREAD에 대한 포인터 포함
KTHREAD (Kernel Thread Block)커널 공간스케줄링/동기화 정보, 커널 스택, TEB에 대한 포인터
TEB (Thread Environment Block)사용자 공간Thread ID, 사용자 모드 스택, TLS(Thread-Local Storage)

ETHREAD와 KTHREAD는 커널 공간에 있으므로 커널만 접근 가능하고, TEB는 사용자 공간에 있으므로 애플리케이션 코드에서도 접근할 수 있습니다.

Linux Threads

Linux는 스레드를 "task"라고 부릅니다. Linux에서는 프로세스와 스레드의 구분이 다른 OS만큼 명확하지 않습니다.

clone() 시스템 콜

스레드 생성은 clone() 시스템 콜을 통해 이루어집니다.

clone()fork()와 비슷하지만, 자식 task가 부모의 주소 공간을 공유할지 말지를 플래그로 제어할 수 있습니다.

플래그의미
CLONE_VM부모와 메모리 공간 공유 (이것이 스레드의 핵심!)
CLONE_FS파일 시스템 정보 공유
CLONE_FILES열린 파일 테이블 공유
CLONE_SIGHAND시그널 핸들러 공유

fork() = clone(플래그 없음) → 완전히 독립적인 프로세스 생성
pthread_create() = clone(CLONE_VM | CLONE_FILES | ...) → 메모리를 공유하는 스레드 생성

Linux의 철학: 프로세스든 스레드든 모두 "task"이고, 자원 공유 정도만 다를 뿐입니다. 커널 스케줄러는 프로세스와 스레드를 구분하지 않고 모두 동일하게 스케줄링합니다.


Threads-Appendix

pthread와 리눅스 시스템 콜의 관계

  • pthread는 사용자 수준 API이고, 실제로는 리눅스 커널의 시스템 콜을 호출합니다.

pthread_create() → clone()

  • pthread_create()로 스레드를 만들면 내부적으로 clone() 시스템 콜이 호출됩니다. 이전에 정리했듯이 clone()은 CLONE_VM | CLONE_FILES | CLONE_SIGHAND 같은 플래그를 설정하여 부모와 메모리, 파일 테이블, 시그널 핸들러를 공유하는 새 task를 만듭니다.
  • 만약 이 플래그들을 빼면 fork()와 같은 효과가 되어 독립적인 프로세스가 됩니다. 결국 리눅스에서 스레드와 프로세스의 차이는 clone()에 어떤 플래그를 넘기느냐의 차이일 뿐입니다.

pthread_exit() → exit()

  • 스레드가 종료할 때 exit() 시스템 콜을 호출합니다. 정확히는 exit()이 아니라 exit_group()이냐 개별 스레드의 exit()이냐의 차이가 있는데, pthread_exit()는 해당 스레드만 종료시키고 프로세스의 다른 스레드들은 계속 실행됩니다.
  • 반면 main에서 exit()을 호출하면 프로세스 전체(모든 스레드)가 종료됩니다.

pthread_join() → futex(FUTEX_WAIT)

  • pthread_join()은 특정 스레드가 끝날 때까지 기다리는 함수입니다. 내부적으로 futex 시스템 콜을 사용합니다.

Futex (Fast Userspace Mutex)란?

  • Futex는 "Fast Userspace Mutex"의 약자로, 뮤텍스, 조건 변수, 세마포어 같은 동기화 기본 요소(locking primitives)를 효율적으로 구현하기 위한 시스템 콜입니다.
    핵심 아이디어는 이렇습니다. 전통적인 방식에서는 lock/unlock 할 때마다 매번 커널에 진입(시스템 콜)해야 했습니다. 하지만 실제로 lock 경쟁이 발생하는 경우는 드뭅니다. 대부분의 경우 lock을 시도하면 바로 잡을 수 있습니다.

Futex는 이 점을 이용합니다:

  • 경쟁이 없는 경우 (fast path): 사용자 공간에서 원자적 연산(atomic operation)만으로 lock을 잡습니다. 커널에 전혀 진입하지 않으므로 매우 빠릅니다.

  • 경쟁이 있는 경우 (slow path): 다른 스레드가 이미 lock을 잡고 있으면, 그때서야 futex(FUTEX_WAIT) 시스템 콜로 커널에 진입하여 대기(sleep)합니다. lock을 잡고 있던 스레드가 해제할 때 futex(FUTEX_WAKE)로 대기 중인 스레드를 깨웁니다

Thread A: lock 시도
→ 아무도 안 잡고 있음 → atomic CAS로 바로 획득 (커널 진입 없음!)

Thread B: lock 시도
→ Thread A가 잡고 있음 → futex(FUTEX_WAIT) → 커널에서 sleep

Thread A: unlock
→ futex(FUTEX_WAKE) → Thread B를 깨움
이렇게 하면 경쟁이 없는 일반적인 경우에는 시스템 콜 오버헤드가 전혀 없고, 경쟁이 발생할 때만 커널이 개입합니다. pthread_join()도 대상 스레드가 아직 실행 중이면 futex로 대기하고, 대상 스레드가 종료하면 깨어나서 메모리를 회수하는 방식으로 동작합니다.

Java 스레드와 pthread의 관계

  • Java의 스레드 API는 내부적으로 OS의 네이티브 스레드(Linux에서는 pthread)를 사용합니다.

new Thread() → JVM 내부 메모리 할당

  • new Thread()를 호출하면 아직 실제 OS 스레드가 만들어지지 않습니다. JVM 힙에 Thread 객체를 위한 메모리만 할당됩니다.
  • Java 객체의 필드(이름, 우선순위, 상태 등)를 초기화하는 단계입니다.

thread.start() → pthread_create()

  • start()를 호출해야 비로소 실제 OS 스레드가 생성됩니다. JVM이 내부적으로 pthread_create()를 호출하고, 이것이 다시 clone() 시스템 콜을 호출합니다. 생성된 OS 스레드가 Java의 run() 메서드를 실행합니다.
    그래서 start() 없이 run()을 직접 호출하면 새 스레드가 생기지 않고 현재 스레드에서 그냥 메서드만 실행됩니다. Java 초보자가 흔히 하는 실수입니다.

thread.sleep() → pthread_cond_timedwait()

  • Thread.sleep(1000)처럼 일정 시간 대기할 때, 내부적으로 pthread_cond_timedwait()을 호출합니다. 이것은 조건 변수(condition variable)에서 지정된 시간만큼 대기하는 pthread 함수이며, 내부적으로는 역시 futex를 사용합니다.

thread.join() → pthread_join()

  • 다른 스레드의 종료를 기다리는 join()은 그대로 pthread_join()에 매핑되고, 이것은 다시 futex(FUTEX_WAIT)로 구현됩니다.

전체 호출 체인 정리

Java 계층pthread 계층Linux 커널 계층
new Thread()(메모리 할당만)(없음)
thread.start()pthread_create()clone()
thread.sleep()pthread_cond_timedwait()futex(FUTEX_WAIT)
thread.join()pthread_join()futex(FUTEX_WAIT)
(스레드 종료)pthread_exit()exit()

결국 Java → pthread → 시스템 콜이라는 3단계 계층 구조입니다. Java 개발자는 new Thread().start()만 쓰지만, 그 아래에서는 pthread 라이브러리가 동작하고, 더 아래에서는 리눅스 커널의 clone()과 futex가 실제 작업을 수행합니다. Java thread를 "커널 수준 라이브러리"로 분류한 이유이기도 합니다.

핵심 요약

  1. 스레드는 프로세스 내의 실행 단위이며, 코드/데이터/힙/파일을 공유하고 스택/레지스터/PC만 독립적으로 가짐

  2. 스레드가 프로세스보다 가벼운 이유: 컨텍스트 스위칭 오버헤드 적음, 생성 시간 짧음, 메모리 공유로 효율적

  3. 멀티스레딩 모델: 현대 OS(Linux, Windows)는 One-to-One 모델을 사용하여 각 사용자 스레드에 커널 스레드를 매핑

  4. 스레드 취소는 위험: 비동기 취소는 deadlock을 유발할 수 있으므로 지연 취소를 사용해야 함

  5. 스레드 풀: 미리 스레드를 만들어 재사용하여 생성 오버헤드를 줄이고 시스템 안정성을 확보

  6. 공유 자원의 양면성: 메모리 공유는 효율적이지만 race condition을 일으킬 수 있으므로 동기화(mutex, semaphore 등)가 필수

0개의 댓글