프로세스와 스레드 관리
프로세스란 정확히 무엇인가
프로세스(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를 공유한다.
프로세스 상태 전이
프로세스는 생애 주기 내내 여러 상태를 오간다.
CPU에서 실행 중
깨울 수 있음
무시 (D 상태)
로 중단됨
wait()를 안 함
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 최적화로 이 비용을 줄인다.
복사 없음
COW 덕분에 fork()는 exec()을 바로 호출하는 일반적인 패턴에서 거의 비용이 없다 — 자식이 exec() 전에 아무것도 쓰지 않으면 물리 메모리가 복사되지 않기 때문이다.
exec(): 프로그램 교체
exec() 계열 함수는 현재 프로세스의 이미지를 새 프로그램으로 완전히 교체한다. PID는 유지된다.
// execve가 성공하면 반환하지 않는다.
execve("/bin/ls", (char*[]){"/bin/ls", "-la", NULL},
(char*[]){"PATH=/usr/bin", NULL});
perror("exec"); // 여기에 도달하면 exec가 실패한 것셸이 명령어를 실행하는 방식:
fork()→ 자식 프로세스 생성- 자식에서
execve()→ 명령어 바이너리로 교체 - 부모(셸)는
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 | 스레드 고유 ID | gettid() / 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 myappCFS와 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)" — 이미 충분히 기다렸다 — 한 태스크 중 가장 이른 데드라인을 가진 것을 선택.
- 지연시간에 민감한 인터랙티브 태스크와 처리량 중심 배치 작업이 공존하는 상황에서 더 나은 반응성.
(rt_sched_class)
(fair_sched_class)
EEVDF 적용
최저 우선순위
컨텍스트 스위칭: 실행 흐름의 전환
스케줄러가 다른 태스크로 전환할 때 컨텍스트 스위치가 발생한다.
- 현재 태스크의 CPU 레지스터(rip, rsp, 범용 레지스터, FPU 상태)를
task_struct에 저장 - 다음 태스크의 레지스터를 CPU에 복구
- 가상 메모리 공간 전환 (프로세스 간 전환 시 CR3 레지스터 교체 → TLB flush)
스레드 간 전환은 같은 mm_struct를 공유하므로 TLB flush가 없어 프로세스 간 전환보다 빠르다.
컨텍스트 스위치 비용: 일반적으로 수 마이크로초. /proc/interrupts와 vmstat 1의 cs 컬럼으로 빈도를 확인한다.
시그널: 비동기 이벤트 통지
시그널은 프로세스에 비동기적으로 이벤트를 알리는 메커니즘이다. 커널이나 다른 프로세스가 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/myappinit(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 |
| USER | UID/GID 매핑 | CLONE_NEWUSER |
Docker가 컨테이너를 시작할 때 실질적으로 clone(CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWNET | ...) 를 호출하는 것이다. 컨테이너 내부의 PID 1은 호스트 관점에서 수천 번대 PID다.
References
- Linux Process Management: Fork, Exec, and Beyond — Abhik Sarkar
- Linux Internals: Process Management — InfosecBytes
- Threads vs Processes in Linux — Scaler Topics
- Linux kernel scheduler — Jinkyu Koo
- Linux Kernel 2.4 Internals: Process and Interrupt Management — TLDP
- Rethinking Thread Scheduling under Oversubscription — arXiv 2601.20435
- EEVDF Scheduler — LWN.net
- Linux man page: clone(2)
- Linux man page: fork(2)