LLM WikiAccess-protected knowledge portal
← 스터디 홈
2편 · 약 22분

프로세스와 스레드 관리

프로세스란 정확히 무엇인가

프로세스(process) 는 실행 중인 프로그램의 인스턴스다. 파일 시스템에 저장된 ELF 바이너리(정적인 데이터)와 달리, 프로세스는 CPU 시간·가상 메모리·파일 디스크립터·네트워크 소켓 같은 실제 자원을 소유한다.

Linux 커널은 각 프로세스를 task_struct 구조체로 표현한다. include/linux/sched.h에 정의된 이 구조체는 수백 개의 필드를 가지며, 커널이 프로세스를 스케줄하고 관리하는 데 필요한 모든 정보를 담는다.

// task_struct의 핵심 필드 (단순화)
struct task_struct {
    volatile long        state;      // TASK_RUNNING, TASK_INTERRUPTIBLE, ...
    pid_t                pid;        // 스레드 고유 ID
    pid_t                tgid;       // 프로세스 그룹 ID (getpid() 반환값)
    struct task_struct  *parent;     // 부모 프로세스
    struct list_head     children;   // 자식 목록
    struct mm_struct    *mm;         // 가상 메모리 맵
    struct files_struct *files;      // 열린 파일 디스크립터 테이블
    struct signal_struct *signal;    // 시그널 핸들러
    int                  prio;       // 동적 우선순위
    struct sched_entity  se;         // CFS/EEVDF 스케줄러 엔티티
};

ps aux 출력의 PID 컬럼은 실제로 tgid 값이다. 멀티스레드 프로세스에서 각 스레드는 서로 다른 pid를 갖지만, 같은 tgid를 공유한다.

프로세스 상태 전이

프로세스는 생애 주기 내내 여러 상태를 오간다.

생성 fork()
TASK_RUNNING 런큐 대기 또는
CPU에서 실행 중
TASK_INTERRUPTIBLE I/O 대기, 시그널로
깨울 수 있음
TASK_UNINTERRUPTIBLE I/O 대기, 시그널
무시 (D 상태)
TASK_STOPPED SIGSTOP/SIGTSTP
로 중단됨
EXIT_ZOMBIE 종료됐지만 부모가
wait()를 안 함
ps 출력의 STAT 컬럼: R=Running, S=Interruptible Sleep, D=Uninterruptible, T=Stopped, Z=Zombie
프로세스 상태 전이 다이어그램

D 상태(TASK_UNINTERRUPTIBLE) 는 NFS 응답 지연이나 블록 I/O 대기 시 나타난다. kill -9도 먹히지 않으며, I/O 완료 또는 서버 재시작만이 해결책이다. load average가 높은데 CPU 사용률은 낮다면 D 상태 프로세스를 의심한다.

좀비(Zombie) 는 프로세스가 종료됐지만 부모가 wait() 계열 함수를 호출하지 않은 상태다. task_struct와 종료 코드가 메모리에 남아있다. 좀비 자체는 CPU를 쓰지 않지만, PID 테이블을 차지하므로 대량 누적 시 fork() 실패로 이어진다.

fork(): 새 프로세스의 탄생

fork()는 현재 프로세스(부모)의 정확한 복사본(자식)을 만드는 시스템 콜이다.

pid_t pid = fork();

if (pid < 0) {
    // 에러
} else if (pid == 0) {
    // 자식 프로세스: fork() 반환값 = 0
    exec(...);
} else {
    // 부모 프로세스: fork() 반환값 = 자식 PID
    wait(NULL);
}

fork()두 번 반환한다. 부모에게는 자식의 PID를, 자식에게는 0을 반환한다. 이 패턴이 셸의 명령어 실행 방식의 근간이다.

Copy-on-Write(COW): 복사를 지연하라

주소 공간 전체를 즉시 복사하면 fork()는 매우 느릴 것이다. Linux는 Copy-on-Write 최적화로 이 비용을 줄인다.

fork() 직후
부모 페이지 테이블 text → 물리 페이지 A (R) stack → 물리 페이지 B (R) heap → 물리 페이지 C (R)
자식 페이지 테이블 text → 물리 페이지 A (R) stack → 물리 페이지 B (R) heap → 물리 페이지 C (R)
물리 메모리 공유
복사 없음
자식이 heap 쓰기 시도
부모 heap → 물리 페이지 C (R)
자식 heap → 물리 페이지 C' (페이지 폴트 → 새 페이지 할당)
쓰기 시점에만 복사
Copy-on-Write 메모리 공유 메커니즘

COW 덕분에 fork()exec()을 바로 호출하는 일반적인 패턴에서 거의 비용이 없다 — 자식이 exec() 전에 아무것도 쓰지 않으면 물리 메모리가 복사되지 않기 때문이다.

exec(): 프로그램 교체

exec() 계열 함수는 현재 프로세스의 이미지를 새 프로그램으로 완전히 교체한다. PID는 유지된다.

// execve가 성공하면 반환하지 않는다.
execve("/bin/ls", (char*[]){"/bin/ls", "-la", NULL},
                  (char*[]){"PATH=/usr/bin", NULL});
perror("exec"); // 여기에 도달하면 exec가 실패한 것

셸이 명령어를 실행하는 방식:

  1. fork() → 자식 프로세스 생성
  2. 자식에서 execve() → 명령어 바이너리로 교체
  3. 부모(셸)는 wait() → 자식 종료 대기

execve(path, argv, envp)는 커널이 ELF 헤더를 파싱하고, 동적 링커(ld-linux.so)를 로드하고, _start에서 main()을 호출하는 과정을 시작한다.

스레드: 같은 메모리를 공유하는 실행 흐름

스레드는 같은 프로세스 내에서 가상 주소 공간, 파일 디스크립터, 시그널 핸들러를 공유하는 별도의 실행 흐름이다.

Linux에서 스레드와 프로세스의 구현 차이는 clone() 시스템 콜의 플래그 뿐이다.

// fork() 내부는 실질적으로 이렇게 동작
clone(child_fn, stack, SIGCHLD, arg);

// pthread_create() 내부는 이렇게 동작
clone(child_fn, stack,
      CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | ...,
      arg);
// CLONE_VM: 가상 메모리 공유
// CLONE_FILES: 파일 디스크립터 테이블 공유
// CLONE_SIGHAND: 시그널 핸들러 공유

PID vs TID vs TGID

식별자설명확인 방법
PID (in kernel)task_struct 고유 번호, 실제로는 TID/proc/<pid>/
TGID스레드 그룹 ID = 유저가 아는 "프로세스 ID"getpid() 반환값
TID스레드 고유 IDgettid() / SYS_gettid

멀티스레드 프로세스에서 ps -L로 스레드를 확인할 수 있다.

$ ps -L -p 1234
  PID    LID TTY          TIME CMD
 1234   1234 pts/0    00:00:01 myapp       ← 메인 스레드 (PID=TGID=TID)
 1234   1235 pts/0    00:00:00 myapp       ← 워커 스레드 (TGID=1234, TID=1235)
 1234   1236 pts/0    00:00:00 myapp

CFS와 EEVDF: Linux 스케줄러의 진화

CFS (Completely Fair Scheduler) — 2007~2023

Linux 2.6.23에서 도입된 CFS는 가상 런타임(vruntime) 개념으로 공정성을 구현했다.

  • 각 태스크는 CPU를 사용할 때마다 vruntime이 증가한다 (nice 값에 따라 가중치 조절).
  • 레드-블랙 트리로 정렬: 가장 작은 vruntime을 가진 태스크가 다음에 선택된다.
  • 새 태스크는 min_vruntime으로 초기화되어 오래된 태스크와 공평하게 경쟁한다.

EEVDF (Earliest Eligible Virtual Deadline First) — Linux 6.6+

2023년 Linux 6.6부터 EEVDF가 기본 스케줄러로 CFS를 대체했다. EEVDF는 최소 지연시간(latency)에 더 집중한다.

  • 각 태스크에 가상 데드라인(virtual deadline) 을 부여.
  • "적격(eligible)" — 이미 충분히 기다렸다 — 한 태스크 중 가장 이른 데드라인을 가진 것을 선택.
  • 지연시간에 민감한 인터랙티브 태스크와 처리량 중심 배치 작업이 공존하는 상황에서 더 나은 반응성.
SCHED_FIFO / SCHED_RR 실시간 태스크
(rt_sched_class)
우선순위 높음 ↓
SCHED_NORMAL 일반 프로세스
(fair_sched_class)
EEVDF 적용
SCHED_IDLE 백그라운드
최저 우선순위
nice 값과 우선순위 nice -20 (높은 우선순위) ←→ nice +19 (낮은 우선순위), 기본값 0 $ nice -n 10 myapp # CPU 사용 우선순위를 낮춰서 실행 $ renice -n -5 -p 1234 # 실행 중인 프로세스 우선순위 조정 (root 필요)
스케줄러 선택 과정 (EEVDF 기반)

컨텍스트 스위칭: 실행 흐름의 전환

스케줄러가 다른 태스크로 전환할 때 컨텍스트 스위치가 발생한다.

  1. 현재 태스크의 CPU 레지스터(rip, rsp, 범용 레지스터, FPU 상태)를 task_struct에 저장
  2. 다음 태스크의 레지스터를 CPU에 복구
  3. 가상 메모리 공간 전환 (프로세스 간 전환 시 CR3 레지스터 교체 → TLB flush)

스레드 간 전환은 같은 mm_struct를 공유하므로 TLB flush가 없어 프로세스 간 전환보다 빠르다.

컨텍스트 스위치 비용: 일반적으로 수 마이크로초. /proc/interruptsvmstat 1cs 컬럼으로 빈도를 확인한다.

시그널: 비동기 이벤트 통지

시그널은 프로세스에 비동기적으로 이벤트를 알리는 메커니즘이다. 커널이나 다른 프로세스가 kill() 시스템 콜을 통해 전달한다.

시그널기본 동작발생 상황
SIGTERM (15)프로세스 종료kill <pid> 기본, graceful shutdown 가능
SIGKILL (9)강제 종료캡처/무시 불가, 커널이 직접 처리
SIGINT (2)프로세스 종료Ctrl+C
SIGHUP (1)프로세스 종료터미널 연결 종료 (데몬 설정 재로드에 관용적으로 사용)
SIGCHLD (17)무시자식 프로세스 종료 시 부모에 전달
SIGSEGV (11)코어 덤프잘못된 메모리 접근
// 시그널 핸들러 등록
signal(SIGTERM, graceful_shutdown);
// 또는 더 안전한 sigaction() 사용
struct sigaction sa = {.sa_handler = graceful_shutdown};
sigaction(SIGTERM, &sa, NULL);

/proc 파일시스템: 커널 상태의 창

/proc는 디스크에 존재하지 않는 가상 파일시스템이다. 커널이 메모리 내 자료구조를 파일 형태로 노출한다.

/proc/<pid>/
├── status       # 프로세스 상태, PID, PPID, Uid, Threads, VmRSS 등
├── maps         # 가상 주소 공간 매핑 (mmap 목록)
├── smaps        # 각 매핑의 상세 메모리 사용량
├── fd/          # 열린 파일 디스크립터 심볼릭 링크
├── fdinfo/      # 각 fd의 상세 정보 (offset 등)
├── cmdline      # 실행 명령줄
├── environ      # 환경 변수
├── task/        # 각 스레드의 하위 디렉토리 (멀티스레드)
└── net/tcp      # 소켓 연결 목록

실용적 사용 예:

# 특정 프로세스의 메모리 사용량
cat /proc/1234/status | grep -E 'VmRSS|VmPeak|Threads'

# 어떤 파일을 열고 있나
ls -la /proc/1234/fd

# 가상 주소 공간 레이아웃
cat /proc/1234/maps
# 55a8b9c00000-55a8b9c01000 r--p 00000000 fd:01 1234567  /usr/bin/myapp

init(PID 1)과 프로세스 트리

Linux 부팅 후 커널이 최초로 생성하는 프로세스는 PID 1이다. 현대 Linux에서는 대부분 systemd다.

PID 1 (systemd)
├── PID 2 (kthreadd) ── 커널 스레드 부모
│   ├── kworker/...
│   └── ksoftirqd/...
├── PID 500 (sshd)
│   └── PID 5001 (sshd: user@pts/0)
│       └── PID 5002 (bash)
│           └── PID 5100 (ps)
└── PID 800 (docker)
    └── PID 8001 (containerd)

고아(orphan) 프로세스 — 부모가 먼저 죽은 프로세스 — 는 자동으로 PID 1에 입양된다. systemd는 이 고아 프로세스들의 wait()를 처리한다. 좀비가 쌓이지 않는 이유다.

컨테이너와 네임스페이스

컨테이너는 새로운 기술이 아니다. Linux의 네임스페이스(namespace)cgroups 위에 구축된 격리 계층이다.

네임스페이스격리 대상시스템 콜 플래그
PID프로세스 ID 공간CLONE_NEWPID
MNT파일시스템 마운트CLONE_NEWNS
NET네트워크 인터페이스CLONE_NEWNET
UTS호스트명, 도메인CLONE_NEWUTS
IPC공유 메모리, 세마포어CLONE_NEWIPC
USERUID/GID 매핑CLONE_NEWUSER

Docker가 컨테이너를 시작할 때 실질적으로 clone(CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET | ...) 를 호출하는 것이다. 컨테이너 내부의 PID 1은 호스트 관점에서 수천 번대 PID다.

References