Skip to content
메모장
Go back

프로세스 vs 스레드, 그리고 컨텍스트 스위칭 비용

개요

멀티태스킹 운영체제에서 두 개 이상의 작업을 동시에 실행하는 것처럼 보이려면, CPU는 실행 중인 작업을 자주 바꿔야 한다. 이를 **컨텍스트 스위칭(Context Switching)**이라 부른다.

문제는 이 전환이 무료가 아니라는 것이다.

10배 이상의 차이가 난다. 왜일까?

답은 메모리 주소 공간에 있다.


1. 프로세스와 스레드의 메모리 구조

프로세스의 메모리 격리

프로세스 A와 프로세스 B는 완전히 독립된 가상 주소 공간을 갖는다.

Process A                        Process B
┌─────────────────────┐         ┌─────────────────────┐
│  Kernel Space       │         │  Kernel Space       │
│  (공유)             │         │  (공유)             │
├─────────────────────┤         ├─────────────────────┤
│  Stack              │         │  Stack              │
│  (0x7fff...)        │         │  (0x7fff...)        │
├─────────────────────┤         ├─────────────────────┤
│  Heap               │         │  Heap               │
│  (malloc 영역)      │         │  (malloc 영역)      │
├─────────────────────┤         ├─────────────────────┤
│  Data Segment       │         │  Data Segment       │
│  (전역 변수)        │         │  (전역 변수)        │
├─────────────────────┤         ├─────────────────────┤
│  Code Segment       │         │  Code Segment       │
│  (0x400000...)      │         │  (0x400000...)      │
└─────────────────────┘         └─────────────────────┘

같은 주소(예: 0x1000)를 참조해도 실제 메모리 위치가 다르다.

스레드의 메모리 공유

같은 프로세스 내의 스레드들은 Code, Data, Heap을 모두 공유한다. 각 스레드만 자신의 Stack을 갖는다.

Process C (Single Memory Space)
┌──────────────────────────────┐
│  Kernel Space (모든 스레드 공유)  │
├──────────────────────────────┤
│  Stack (Thread 1)            │
│  ─────────────────           │
│  Stack (Thread 2)            │
│  ─────────────────           │
│  Stack (Thread 3)            │
├──────────────────────────────┤
│  Heap (모든 스레드 공유)        │
├──────────────────────────────┤
│  Data Segment (공유)          │
├──────────────────────────────┤
│  Code Segment (공유)          │
└──────────────────────────────┘

스레드 전환 시에는 Stack Pointer와 레지스터만 바꾸면 된다.


2. 컨텍스트 스위칭 비용: 프로세스 vs 스레드

프로세스 전환의 비용

프로세스 A에서 프로세스 B로 전환할 때:

  1. CPU 레지스터 저장/복원 (~100 ns)

    • Program Counter, Stack Pointer, Accumulator 등 현재 상태 저장
    • 새 프로세스의 레지스터 값 로드
  2. 메모리 관리 장치(MMU) 업데이트 (~수백 ns ~ 수 µs)

    • 프로세스 A의 페이지 테이블(Page Table) → 프로세스 B의 페이지 테이블로 교체
    • CPU의 CR3 레지스터(x86)에 새로운 페이지 테이블 기본 주소(PDBR) 설정
    • 가상 주소 → 물리 주소 매핑 캐시인 TLB(Translation Lookaside Buffer) 무효화(TLB flush)
  3. TLB Miss로 인한 후유증 (진짜 비용)

    • 프로세스 B가 실행을 시작하면 메모리에 접근할 때마다 TLB 미스 발생
    • 각 미스마다 페이지 테이블을 참조해 가상→물리 주소 변환 (수백 ns)
    • 특히 초기 워밍업 단계에서 캐시 미스 폭주 (메모리 bandwidth 저하, 1-10% 성능 저하 관측)
프로세스 A 실행 중          프로세스 B로 전환            프로세스 B 실행 시작
┌─────────────┐           ┌──────────────┐           ┌────────────────────┐
│ TLB (warm)  │ ─flush→ │ TLB (empty)  │ ─misses→ │ TLB (재 warm까지 시간)│
│ [A의 매핑]  │           │              │           │                    │
└─────────────┘           └──────────────┘           └────────────────────┘
  빠름                     비용 발생                    느림 (초반)

스레드 전환의 비용

같은 프로세스 내의 스레드 A에서 스레드 B로 전환할 때:

  1. CPU 레지스터 저장/복원 (~100 ns)
  2. 메모리 관리 장치 업데이트 — 필요 없음!
    • 페이지 테이블은 같음 (같은 프로세스)
    • TLB flush 불필요
    • 가상 주소 → 물리 주소 매핑은 여전히 유효

메모리 주소 공간이 같으므로 단순히 Stack Pointer와 레지스터 포인터만 교체하면 된다.


3. Linux 커널에서 본 프로세스와 스레드

Linux 커널은 프로세스와 스레드를 구분하지 않는다. 둘 다 task_struct라는 동일한 구조체로 관리하며, clone() 시스템콜의 플래그로 어떤 자원을 공유할지 결정할 뿐이다.

clone() 플래그

#include <sched.h>

// 프로세스 생성 (fork()의 내부 구현)
clone(fn, child_stack, 
      SIGCHLD,                    // 부모 프로세스 신호 핸들러만 사용
      NULL);

// 스레드 생성 (pthread_create()의 내부 구현)
clone(fn, child_stack,
      CLONE_VM      |             // 메모리 공간 공유
      CLONE_FS      |             // 파일 시스템 정보 공유
      CLONE_FILES   |             // 파일 디스크립터 테이블 공유
      CLONE_SIGHAND |             // 신호 핸들러 공유
      CLONE_THREAD,               // 같은 스레드 그룹
      NULL);
플래그의미프로세스스레드
CLONE_VM메모리 공간 공유✗✓
CLONE_FS현재 디렉토리 공유✗✓
CLONE_FILES파일 디스크립터 공유✗✓
CLONE_SIGHAND신호 핸들러 공유✗✓
CLONE_THREAD스레드 그룹✗✓

4. 메모리 계산: C10K 문제

C10K 문제: 동시에 10,000개 클라이언트 연결을 처리할 수 있는가?

스레드 기반 접근의 한계

한 스레드가 얼마나 메모리를 먹는가?

Stack 크기: 8 MB (x86-64 리눅스 기본값)
× 10,000개 스레드
──────────────────
= 80 GB

80GB는 단일 서버의 메모리로는 불가능하다. 실제로는 14GB의 서버에서 수십수백 개 스레드만 만들 수 있다.

메모리 10 GB (실제 서버)
÷ 스레드당 8 MB Stack
────────────────────
≈ 1,250 스레드 (이론적 최대)

실제로는 추가 메모리(Heap, TLS)를 고려하면 더 적음

이벤트 루프 기반 접근

대안은 컨텍스트 스위칭 자체를 최소화하는 것이다.

[Single Thread / Event Loop]
┌──────────────────────────────────┐
│  while true {                    │
│    events = epoll_wait()         │ ← 10,000개 FD 모니터링
│    for event in events {         │
│      process(event)              │
│    }                             │
│  }                               │
└──────────────────────────────────┘

epoll()은 커널이 관심 있는 10,000개의 파일 디스크립터를 추적하고, 실제로 이벤트가 발생한 것만 돌려준다.


5. 스케줄러의 역할: CFS (Completely Fair Scheduler)

Linux의 기본 스케줄러 CFS는 “모든 태스크에게 공정한 CPU 시간을 준다”는 원칙에 따라 동작한다.

런큐 (Run Queue)

CPU 0의 런큐
┌─────────────────────────────┐
│ [Task A] [Task B] [Task C]  │  ← ready 상태의 모든 태스크
└─────────────────────────────┘
           ↓
        CFS 선택
           ↓
    현재 Task A 실행 (time slice: 몇 ms)
           ↓
   컨텍스트 스위칭 → Task B

스케줄링 비용


6. 실무에서의 선택 기준

멀티프로세스 (Process-based)

장점:

단점:

사용 사례:

# nginx 설정
worker_processes auto;  # CPU 코어 수와 동일하게 설정

# 각 worker가 epoll로 수천 개 연결 처리
# (프로세스 수는 적지만, 연결 수는 많음)

멀티스레드 (Thread-based)

장점:

단점:

사용 사례:

// Spring에서의 스레드풀 기본값 (Tomcat)
ThreadPool size = CPU 코어 수 (또는 수십~수백)
요청당 1 스레드 할당

이벤트 루프 (Event-driven)

장점:

단점:

사용 사례:

// Node.js: 단일 이벤트 루프
const server = http.createServer((req, res) => {
  // 이 콜백이 블로킹되면 다른 요청 처리 불가
  res.end('OK');
});

// CPU 바운드 작업은 Worker Threads로 분리
const { Worker } = require('worker_threads');
const worker = new Worker('./cpu-intensive-task.js');

7. 성능 측정: 실제 컨텍스트 스위칭 비용

측정 도구

Linux에서 프로세스/스레드 간 컨텍스트 스위칭 비용을 측정하는 방법:

# 1. vmstat로 컨텍스트 스위칭 횟수 모니터링
vmstat 1  # 1초마다 갱신

# 출력:
# cs = context switches (초당 컨텍스트 스위칭 횟수)
# in = interrupts (초당 인터럽트 횟수)

# 2. perf로 캐시 미스 측정
perf stat -e cache-references,cache-misses ./program

# 3. 직접 측정 (C 코드)

벤치마크 예제

#include <time.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

// 프로세스/스레드 생성 + 컨텍스트 스위칭 시간 측정

int main() {
    struct timespec start, end;
    double elapsed_ns;

    // 프로세스 생성 + fork()
    clock_gettime(CLOCK_REALTIME, &start);
    
    for (int i = 0; i < 1000; i++) {
        pid_t pid = fork();
        if (pid == 0) {
            // 자식 프로세스
            exit(0);
        } else {
            // 부모 프로세스
            wait(NULL);
        }
    }
    
    clock_gettime(CLOCK_REALTIME, &end);
    elapsed_ns = (end.tv_sec - start.tv_sec) * 1e9 + 
                 (end.tv_nsec - start.tv_nsec);
    
    printf("1000 × fork() + wait: %.2f ns/operation\n", 
           elapsed_ns / 1000);
    
    return 0;
}

전형적인 결과 (x86-64, 4GHz CPU, 무부하 상황):


8. 트레이드오프 정리

측면멀티프로세스멀티스레드이벤트루프
컨텍스트 스위칭높음 (µs)중간 (ns-µs)최소
메모리/연결크다 (MB/프로세스)중간 (KB/스레드)최소 (KB)
동시 연결 수수십~수백수백~수천수만~수십만
격리 (안정성)완벽취약중간
개발 복잡도단순복잡 (동기화)복잡 (비동기)
CPU 활용좋음 (멀티코어)좋음 (멀티코어)CPU-bound에 부적합

결론

어떤 방식을 선택할지는 동시 연결 수, 격리 요구사항, CPU 바운드 vs I/O 바운드 특성에 따라 결정된다.


Share this post:

Previous Post
Kafka 파티셔닝 전략과 순서 보장
Next Post
Redis 캐싱 전략: String Blob vs Hash 자료구조 선택