리눅스 내부 구조 02 - 프로세스와 스레드
프로세스란 무엇인가?
프로그램과 프로세스는 다르다. 프로그램은 디스크에 저장된 실행 파일이고, 프로세스는 그 프로그램이 메모리에 올라가 실행 중인 상태를 말한다. 같은 프로그램을 두 번 실행하면 두 개의 독립적인 프로세스가 생기며, 각각 자신만의 메모리 공간과 실행 상태를 갖는다.
리눅스 커널은 각 프로세스를 task_struct라는 구조체로 관리한다. 이 구조체에는 프로세스 ID, 상태, 메모리 맵, 열린 파일 목록, 스케줄링 정보, 시그널 핸들러 등 프로세스에 관한 모든 정보가 담겨 있다. 커널 소스에서 task_struct의 크기는 수 킬로바이트에 달하는데, 이는 하나의 프로세스를 올바르게 관리하기 위해 커널이 추적해야 하는 정보가 그만큼 많다는 뜻인 것이다.
모든 프로세스는 고유한 PID(Process ID)를 부여받는다. 시스템에서 가장 먼저 생성되는 프로세스는 PID 1을 가지며, 전통적으로 init이 그 역할을 했고 현대 배포판에서는 systemd가 담당한다. PID 1은 시스템의 모든 프로세스의 조상이 되며, 부모를 잃은 고아 프로세스를 거둬들이는 역할도 수행하는 것이다.
fork와 exec: 프로세스의 탄생
리눅스에서 새 프로세스를 만드는 방식은 다소 독특하다. 무에서 프로세스를 생성하는 것이 아니라, 기존 프로세스를 복제한 뒤 그 복제본을 새로운 프로그램으로 교체하는 두 단계를 거친다.
첫 번째 단계가 fork()이다. fork()를 호출하면 현재 프로세스의 거의 완벽한 복사본이 만들어진다. 부모 프로세스와 자식 프로세스는 동일한 코드, 동일한 데이터, 동일한 열린 파일을 갖지만 PID만 다르다. fork()의 반환값으로 부모와 자식을 구분할 수 있는데, 부모에게는 자식의 PID가, 자식에게는 0이 반환된다.
pid_t pid = fork();
if (pid == 0) {
// 자식 프로세스: 새로운 프로그램 실행
execvp("ls", args);
} else if (pid > 0) {
// 부모 프로세스: 자식이 끝나길 대기
waitpid(pid, &status, 0);
}
두 번째 단계가 exec() 계열 함수이다. exec()는 현재 프로세스의 메모리를 완전히 새로운 프로그램으로 교체한다. 코드, 데이터, 힙, 스택이 모두 새 프로그램의 것으로 바뀌지만 PID는 그대로 유지된다. 셸에서 명령어를 실행할 때마다 이 fork-exec 조합이 사용되는 것이다.
과연 매번 전체 메모리를 복사하는 것이 효율적인가? 그렇지 않다. 그래서 리눅스는 Copy-on-Write(COW)라는 기법을 사용한다. fork() 시점에 메모리를 실제로 복사하지 않고 부모와 자식이 같은 물리 메모리 페이지를 공유하다가, 어느 한쪽이 페이지를 수정하려 할 때 그 페이지만 복사하는 것이다. fork() 직후에 exec()를 호출하면 자식의 메모리가 전부 새 프로그램으로 교체되므로, 복사할 필요가 없었던 셈이다.
프로세스 상태
프로세스는 생성부터 종료까지 여러 상태를 오간다. 리눅스에서 프로세스가 가질 수 있는 주요 상태는 다음과 같다.
스케줄러에 의해
┌──────────── 선택됨 ──────────┐
│ ▼
┌──┴────────┐ ┌──────────┐
│ READY │ │ RUNNING │
│ (실행 대기) │◄────────────│ (실행 중) │
└───────────┘ 타임슬라이스 └────┬─────┘
소진 │
I/O 요청 │
▼
┌──────────┐
│ SLEEPING │
│ (대기 중) │
└──────────┘
TASK_RUNNING 상태는 프로세스가 CPU에서 실행 중이거나 실행 대기열에서 CPU를 기다리고 있는 상태이다. TASK_INTERRUPTIBLE은 특정 이벤트를 기다리며 잠들어 있는 상태로, 시그널에 의해 깨어날 수 있다. 디스크 I/O나 네트워크 패킷을 기다리는 프로세스가 대부분 이 상태에 있다. TASK_UNINTERRUPTIBLE은 시그널로도 깨울 수 없는 깊은 대기 상태인데, 하드웨어 응답을 기다리는 등 중단하면 데이터 손상이 발생할 수 있는 상황에서 사용된다.
그리고 TASK_ZOMBIE 상태가 있다. 좀비 프로세스는 실행이 끝났지만 부모가 아직 종료 상태를 수거하지 않은 프로세스이다. 프로세스가 종료되면 커널은 해당 프로세스의 자원 대부분을 해제하지만, task_struct와 종료 코드는 부모가 wait()를 호출할 때까지 유지한다. 부모가 wait()을 호출하지 않으면 좀비가 계속 남게 되는데, 좀비 자체는 메모리를 거의 차지하지 않지만 PID를 점유하므로 대량으로 쌓이면 시스템에 문제가 될 수 있는 것이다.
프로세스 트리
리눅스의 모든 프로세스는 트리 구조를 형성한다. PID 1인 init(또는 systemd)이 루트가 되고, 이후 생성되는 모든 프로세스는 반드시 부모 프로세스를 갖는다. pstree 명령으로 이 구조를 확인할 수 있다.
systemd─┬─sshd───sshd───bash───vim
├─nginx─┬─nginx
│ └─nginx
├─cron
└─rsyslogd
부모 프로세스가 자식보다 먼저 종료되면 자식은 고아 프로세스가 된다. 이때 커널은 이 고아 프로세스의 부모를 PID 1로 재지정한다. PID 1은 주기적으로 wait()를 호출하여 고아가 된 자식의 종료 상태를 수거하므로, 좀비가 무한히 쌓이는 것을 방지하는 것이다. 컨테이너 환경에서 PID 1 문제가 중요한 이유가 바로 여기에 있다. 컨테이너 내에서 PID 1로 실행되는 프로세스가 이 수거 역할을 하지 않으면 좀비 프로세스가 누적될 수 있는 것이다.
스레드: 프로세스 안의 프로세스?
전통적으로 프로세스는 하나의 실행 흐름만 가졌다. 하지만 웹 서버가 수천 개의 동시 요청을 처리해야 한다면, 요청마다 새 프로세스를 만드는 것은 비용이 크다. 각 프로세스는 독립적인 주소 공간을 가지므로, 프로세스 간에 데이터를 공유하려면 IPC 같은 별도의 메커니즘이 필요하다.
스레드는 이 문제를 해결한다. 같은 프로세스 내의 스레드들은 코드, 데이터, 힙, 열린 파일을 공유하면서 각자 독립적인 스택과 레지스터 상태만 유지한다. 메모리 공유가 기본이므로 데이터 교환이 빠르고, 새 스레드를 만드는 비용이 새 프로세스를 만드는 비용보다 훨씬 작다.
그런데 리눅스에서 스레드 구현은 다른 운영체제와 다른 흥미로운 특성을 갖고 있다. 리눅스 커널은 스레드를 프로세스와 별도의 개념으로 취급하지 않는다. 커널의 관점에서 스레드는 자원을 공유하는 프로세스일 뿐이며, 둘 다 task_struct로 표현된다.
이것을 가능하게 하는 것이 clone() 시스템 콜이다. fork()가 거의 모든 자원을 복사하는 반면, clone()은 어떤 자원을 공유하고 어떤 자원을 복사할지를 플래그로 세밀하게 제어할 수 있다.
// fork와 유사: 대부분의 자원을 복사
clone(fn, stack, 0, arg);
// 스레드 생성: 메모리, 파일 디스크립터, 시그널 핸들러 등을 공유
clone(fn, stack, CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, arg);
CLONE_VM은 메모리 공간 공유, CLONE_FILES는 파일 디스크립터 테이블 공유, CLONE_SIGHAND는 시그널 핸들러 공유를 의미한다. 이 플래그들을 조합하면 완전히 독립적인 프로세스부터 모든 것을 공유하는 스레드까지 연속적인 스펙트럼 위의 어느 지점이든 만들어낼 수 있는 것이다.
POSIX 스레드
응용 프로그램에서 스레드를 사용할 때는 일반적으로 clone()을 직접 호출하지 않고 POSIX 스레드(pthread) API를 사용한다. 리눅스에서 pthread의 구현체인 NPTL(Native POSIX Threads Library)이 내부적으로 clone()을 호출하여 스레드를 생성하는 것이다.
#include <pthread.h>
void *worker(void *arg) {
// 스레드가 수행할 작업
return NULL;
}
int main() {
pthread_t thread;
pthread_create(&thread, NULL, worker, NULL);
pthread_join(thread, NULL); // 스레드 종료 대기
return 0;
}
과연 스레드가 프로세스보다 항상 나은 선택인가? 그렇지 않다. 스레드는 메모리를 공유하기 때문에 한 스레드의 버그가 같은 프로세스의 모든 스레드에 영향을 줄 수 있다. 잘못된 포인터 접근 하나가 전체 프로세스를 죽이는 셈이다. 반면 프로세스는 주소 공간이 분리되어 있으므로 하나의 프로세스가 죽어도 다른 프로세스에는 영향이 없다. Nginx가 워커 프로세스 모델을 사용하고, Chrome이 탭마다 별도의 프로세스를 사용하는 것은 이러한 격리의 이점을 취하기 위한 것이다.
/proc 파일시스템
리눅스는 실행 중인 프로세스의 정보를 /proc 파일시스템을 통해 유저 공간에 노출한다. /proc은 실제 디스크에 존재하는 파일시스템이 아니라 커널이 동적으로 생성하는 가상 파일시스템이다.
# PID 1234 프로세스의 정보
ls /proc/1234/
cmdline cwd environ exe fd maps status ...
# 프로세스의 메모리 맵 확인
cat /proc/1234/maps
# 프로세스의 상태 확인
cat /proc/1234/status
Name: nginx
State: S (sleeping)
Pid: 1234
PPid: 1
Threads: 4
VmRSS: 12340 kB
/proc/[pid]/fd 디렉토리에는 프로세스가 열어 놓은 모든 파일 디스크립터가 심볼릭 링크로 표현되어 있고, /proc/[pid]/maps에는 프로세스의 가상 메모리 영역 배치가 기록되어 있다. 시스템 관리자나 모니터링 도구가 프로세스의 상태를 파악할 때 커널 내부 자료구조에 직접 접근하는 대신 /proc을 읽는 것으로 충분한 셈이다. ps, top, htop 같은 도구들도 내부적으로는 /proc에서 정보를 읽어오는 것이다.
다음 포스트에서는 프로세스 스케줄링을 살펴본다.