본문 바로가기

프로세스의 개념

디스크의 프로그램과 실행 중인 프로세스를 구분하고, 프로세스의 주소 공간을 이루는 코드, 데이터, 힙, 스택 영역의 수명과 권한을 살펴봅니다. 프로세스가 생성부터 종료까지 거치는 상태와 각 전이가 일어나는 원인, Linux의 ps가 보여 주는 상태 코드를 읽는 방법을 정리합니다.

디스크에 저장된 /usr/bin/python3 파일은 프로그램(program)입니다. 이 파일은 기계어 명령과 데이터가 담긴 바이트의 나열일 뿐, 스스로는 아무 일도 하지 않습니다. 터미널에서 python3를 실행하면 운영체제는 이 파일을 메모리에 올리고, 실행에 필요한 자원을 붙여 CPU가 명령을 실행할 수 있게 만듭니다. 이렇게 실행 중인 프로그램 하나를 프로세스(process)라고 합니다. 터미널 두 개에서 python3를 각각 실행하면 프로그램은 하나지만 프로세스는 둘이 되며, 두 프로세스는 서로의 변수를 볼 수 없는 독립된 주소 공간을 가집니다.

프로그램 하나가 몇 개의 프로세스가 될지는 프로그램이 정합니다. 예를 들어 웹 브라우저는 탭과 화면 그리기, GPU 작업을 여러 프로세스로 나누기도 하고, 이미 실행 중인 프로세스를 다시 쓰기도 하므로 창의 수와 프로세스의 수가 같지 않습니다. 운영체제가 메모리와 파일 같은 자원을 나누어 주고 서로 보호하는 단위가 바로 프로세스이므로, 운영체제를 이해하려면 프로세스부터 이해해야 합니다. 이 글에서는 프로세스의 메모리가 어떻게 구성되는지, 그리고 프로세스가 생성부터 종료까지 어떤 상태를 거치는지 살펴봅니다.

프로세스의 메모리는 네 영역으로 나뉜다

프로세스는 자기만의 가상 주소 공간(virtual address space)을 가집니다. 이 공간은 쓰임새에 따라 몇 개의 영역으로 나뉘는데, C 프로그램을 기준으로 흔히 코드, 데이터, 힙, 스택의 네 영역으로 설명합니다. 영역마다 언제 만들어지고 언제 사라지는지, 그리고 읽기와 쓰기와 실행 가운데 무엇을 허락하는지가 다릅니다. 이 차이를 알면 스택 오버플로, 메모리 누수, 세그먼테이션 폴트 같은 오류가 왜 생기는지 설명할 수 있습니다.

영역담는 것만들어지고 사라지는 때권한
코드(text)실행할 기계어 명령프로그램을 적재할 때부터 끝날 때까지읽기, 실행
데이터(data, bss)전역 변수와 정적 변수프로그램을 적재할 때부터 끝날 때까지읽기, 쓰기
힙(heap)실행 중에 요청해 받은 메모리할당할 때부터 해제할 때까지읽기, 쓰기
스택(stack)함수 호출마다 생기는 지역 변수와 돌아갈 주소함수가 호출될 때부터 반환될 때까지읽기, 쓰기

코드 영역

코드 영역에는 CPU가 실행할 기계어 명령이 들어 있으며, 보통 쓰기를 허락하지 않습니다. 실행 중에 코드가 바뀌면 프로그램이 예측할 수 없게 동작하고, 공격자가 코드를 덮어써 원하는 명령을 실행시킬 수도 있기 때문입니다. 쓰기를 막아 두면 이점이 하나 더 생깁니다. 같은 프로그램을 실행한 여러 프로세스는 코드가 똑같으므로, 운영체제는 물리 메모리에 코드를 한 벌만 두고 모든 프로세스의 주소 공간에 연결해 메모리를 아낄 수 있습니다. 다만 Java나 JavaScript 엔진처럼 실행 중에 기계어를 만들어 내는 JIT 컴파일러는 운영체제에 메모리 권한을 바꾸어 달라고 요청해 새 코드를 씁니다.

데이터 영역

데이터 영역에는 프로그램이 끝날 때까지 살아 있는 전역 변수와 정적(static) 변수가 들어 있으며, 다시 두 부분으로 나뉩니다. .data는 int count = 10;처럼 0이 아닌 초깃값이 있는 변수를 담으며, 이 초깃값은 실행 파일 안에 그대로 기록됩니다. .bss는 초깃값이 없거나 0인 변수를 담는데, 실행 파일에는 크기만 적어 두고 프로그램을 적재할 때 운영체제가 0으로 채운 메모리를 내줍니다. 크기가 1MB인 0 배열을 위해 실행 파일에 0을 1MB나 저장할 필요가 없도록 이렇게 나누어 둔 것입니다. 문자열 상수처럼 바뀌지 않는 데이터는 읽기 전용 영역인 .rodata에 따로 두는 경우가 많습니다.

힙 영역

힙은 프로그램이 실행 중에 필요한 만큼 요청해 받는 메모리입니다. C에서는 malloc()으로 받고 free()로 돌려줍니다. 크기를 컴파일할 때 알 수 없는 데이터나, 함수가 반환된 뒤에도 살아 있어야 하는 데이터를 힙에 둡니다. 할당한 메모리를 다 쓰고도 해제하지 않으면 프로그램이 실행되는 동안 힙이 계속 커지는데, 이를 메모리 누수(memory leak)라고 합니다.

전통적인 그림에서는 힙이 데이터 영역 바로 뒤에서 높은 주소 쪽으로 자라는 하나의 영역으로 그려지지만, 실제 할당기는 큰 블록을 mmap()으로 따로 매핑하기도 하므로 힙이 꼭 한 덩어리인 것은 아닙니다. Java, Python, Go 같은 언어는 런타임이 더 이상 쓰지 않는 객체를 찾아 회수해 주고, C++은 RAII와 스마트 포인터로 해제를 자동화합니다. 그래도 객체를 계속 참조하는 자료구조가 있으면 회수되지 않으므로, 이런 언어에서도 메모리 누수는 생길 수 있습니다.

스택 영역

스택에는 함수를 호출할 때마다 스택 프레임(stack frame)이 하나씩 쌓입니다. 프레임에는 그 함수의 지역 변수와 매개변수, 함수가 끝나면 돌아갈 주소가 들어 있습니다. 함수가 반환되면 프레임이 통째로 사라지므로 지역 변수의 메모리는 따로 해제하지 않아도 회수됩니다. 그래서 함수가 반환된 뒤에 그 함수의 지역 변수를 가리키는 포인터를 쓰면, 이미 다른 용도로 쓰이고 있을 수 있는 메모리를 건드리게 됩니다. x86을 비롯한 대부분의 아키텍처에서 스택은 낮은 주소 쪽으로 자랍니다. 컴파일러가 최적화하면 지역 변수 일부는 스택에 놓이지 않고 레지스터에만 있기도 합니다.

스택의 크기에는 한도가 있습니다. Linux에서는 ulimit -s로 주 스레드의 스택 한도를 확인할 수 있으며, 많은 배포판에서 기본값은 8MB입니다. 재귀 호출이 너무 깊어지거나 큰 배열을 지역 변수로 잡으면 스택이 한도를 넘는데, 이를 스택 오버플로(stack overflow)라고 합니다. Linux에서는 스택 끝에 접근할 수 없는 보호 페이지를 두므로, 한도를 넘으면 SIGSEGV로 프로그램이 종료됩니다.

변수가 놓인 위치 확인하기

다음 C 프로그램은 영역마다 변수를 하나씩 두고 그 주소를 출력합니다. 주석에 적은 대로 초깃값이 있는 전역 변수는 .data에, 초깃값이 없는 전역 변수는 .bss에, 지역 변수는 스택에 놓이고, malloc으로 받은 블록은 힙에 있습니다. 코드 영역의 주소는 함수 show 자체의 주소로 대신합니다.

regions.c
#include <stdio.h>
#include <stdlib.h>

int global_init = 42;   /* .data */
int global_zero;        /* .bss */

void show(void)
{
    int local = 10;                /* 스택 */
    int *block = malloc(100);      /* 블록은 힙, 포인터 변수 block은 스택 */
    if (block == NULL)
        return;

    printf("code:  %p\n", (void *) show);
    printf("data:  %p\n", (void *) &global_init);
    printf("bss:   %p\n", (void *) &global_zero);
    printf("heap:  %p\n", (void *) block);
    printf("stack: %p\n", (void *) &local);

    free(block);
}

int main(void)
{
    show();
    return 0;
}

함수 포인터를 void *로 바꾸어 출력하는 것은 C 표준만으로는 허락되지 않지만, POSIX 시스템에서는 동작합니다. 실행하면 주소 다섯 개가 출력되는데, 값은 실행할 때마다 달라집니다. Linux는 주소 공간 배치 무작위화(address space layout randomization, ASLR)로 코드, 힙, 스택의 시작 위치를 실행마다 바꾸기 때문입니다. 그래도 code, data, bss는 서로 가까운 주소에 모여 있고, stack은 그보다 훨씬 높은 주소에 있는 것을 볼 수 있을 것입니다. 정확한 순서와 간격은 컴파일 옵션과 할당기에 따라 다르므로 특정 값을 기대하지 않는 것이 좋습니다.

실행 중인 프로세스의 메모리 지도

Linux에서는 실행 중인 프로세스의 메모리 배치를 /proc/<PID>/maps 파일로 볼 수 있습니다. /proc/self는 이 파일을 읽는 프로세스 자신을 가리키므로, 다음 명령은 cat 프로세스 자신의 메모리 지도를 출력합니다.

cat /proc/self/maps

출력의 각 줄은 매핑된 주소 범위 하나를 나타냅니다. 아래는 proc_pid_maps(5) 매뉴얼에 실린 예에서 몇 줄을 가져온 것입니다.

00400000-00452000 r-xp 00000000 08:02 173521      /usr/bin/dbus-daemon
00652000-00655000 rw-p 00052000 08:02 173521      /usr/bin/dbus-daemon
00e03000-00e24000 rw-p 00000000 00:00 0           [heap]
7fffb2c0d000-7fffb2c2e000 rw-p 00000000 00:00 0   [stack]
7fffb2d48000-7fffb2d49000 r-xp 00000000 00:00 0   [vdso]

한 줄은 왼쪽부터 주소 범위, 권한, 파일 안의 오프셋, 장치 번호, inode 번호, 경로입니다. 권한의 r, w, x는 읽기, 쓰기, 실행이고, 마지막 글자 p는 이 프로세스만의 사본(private), s는 다른 프로세스와 공유하는 매핑을 뜻합니다. 첫 줄은 실행 파일에서 읽기와 실행만 허락된 부분이므로 코드 영역이고, 둘째 줄은 같은 파일에서 쓰기가 허락된 부분이므로 데이터 영역입니다. [heap]과 [stack]은 파일과 연결되지 않은 영역이라 inode가 0이며, [vdso]는 1장에서 본 vDSO가 매핑된 곳입니다. 직접 실행해 보면 공유 라이브러리마다 이런 줄이 여러 개 있는 것도 볼 수 있습니다.

프로세스가 거치는 다섯 상태

프로세스는 만들어진 뒤 끝날 때까지 늘 CPU를 쓰고 있는 것이 아닙니다. 디스크에서 데이터가 오기를 기다리기도 하고, CPU를 쓸 수 있는데도 다른 프로세스에게 차례가 가 있어 기다리기도 합니다. 운영체제는 프로세스마다 지금 어떤 처지에 있는지를 상태(state)로 기록하고, 이 상태를 보고 누구에게 CPU를 줄지 정합니다. 교과서에서는 보통 다음 다섯 상태로 설명합니다.

상태뜻
생성(new)운영체제가 프로세스를 만들며 관리 정보와 메모리를 준비하는 중
준비(ready)실행할 수 있고 CPU만 받으면 되는 상태. 준비 큐(ready queue)에서 차례를 기다린다
실행(running)CPU를 받아 명령어를 실행하는 중
대기(waiting)입출력 완료나 어떤 사건을 기다리는 중이라, CPU가 비어 있어도 실행할 수 없음. 블록(blocked) 상태라고도 한다
종료(terminated)실행을 마쳤거나 강제로 끝난 상태

준비와 대기의 차이를 정확히 구분해야 합니다. 둘 다 지금 실행되고 있지 않다는 점은 같지만, 준비 상태의 프로세스는 CPU를 주면 바로 실행할 수 있고, 대기 상태의 프로세스는 CPU를 주어도 할 일이 없습니다. 기다리는 데이터가 아직 오지 않았기 때문입니다.

실행 상태에 있을 수 있는 프로세스의 수는 CPU의 수로 정해집니다. 정확히는 운영체제가 보는 논리 CPU 하나가 한 시점에 하나의 실행 흐름을 실행합니다. 동시 멀티스레딩(simultaneous multithreading, SMT)을 지원하는 물리 코어 하나는 논리 CPU 두 개로 보이기도 하므로, 물리 코어의 수와 동시에 실행될 수 있는 수가 같지 않을 수 있습니다. 또 오늘날의 운영체제에서 CPU를 배정받는 단위는 정확히는 프로세스 안의 스레드인데, 스레드는 4장에서 다루고 이 장에서는 스레드가 하나뿐인 프로세스를 생각합니다.

상태가 바뀌는 원인

상태 사이의 이동을 전이(transition)라고 합니다. 각 전이에는 그것을 일으키는 사건이 있습니다.

프로세스 상태와 전이 원인

A가 CPU를 사용하고 B는 기다린다. B가 선점되거나 양보한 뒤 스케줄러가 A를 선택한다. 각 단계의 값과 전환 이유를 비교합니다.

프로세스 · 실행 가능성과 실제 실행

Waiting에서 깨어나도 먼저 Ready로 돌아갑니다

생성·배정·선점·대기·종료 원인을 화살표로 구분합니다. 여기서는 교과서의 다섯 상태를 쓰며 Linux STAT 문자와 일대일 대응시키지 않습니다.

실행·대기·종료와 복귀 원인New에서 Ready, Running으로 이동한다. Running에서 선점은 Ready, 대기는 Waiting, 종료는 Terminated로 이동한다. Waiting 조건 충족은 Ready로 이동한다.NewReadyRunningTerminatedWaiting생성 완료CPU 배정선점대기 필요조건 충족종료
실행·대기·종료의 갈림길생성 뒤 Ready, CPU 배정 뒤 Running. 선점되면 Ready, 대기가 필요하면 Waiting, 조건 충족 후 Ready. 실행 종료 시 Terminated.NewReadyRunningWaitingTerminated생성 완료CPU 배정선점대기 필요대기 조건충족종료

실행 가능해지는 Ready와 실제 CPU를 받는 Running을 구분합니다. New·Terminated는 생성·종료 단계이며 Linux STAT 문자와 일대일 대응하는 목록은 아닙니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 A 실행부터 A 재개까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목프로세스 상태와 전이 원인
관계 설명New에서 Ready, Running으로 이동한다. Running에서 선점되면 Ready로, 대기하면 Waiting으로, 종료하면 Terminated로 이동한다. Waiting의 대기 조건이 충족되면 Ready로 이동한다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
A 실행

A가 CPU를 사용하고 B는 기다린다.

  • A·B 상태 1: A Running
  • A·B 상태 2: B Ready
  • A 블로킹 읽기
A 대기 B 실행

읽기가 즉시 완료되지 않는다.

  • A·B 상태 1: A Waiting
  • A·B 상태 2: B Running
  • 스케줄러가 B 선택
A 깨우기

읽기가 끝나도 B는 계속 실행할 수 있다.

  • A·B 상태 1: A Ready
  • A·B 상태 2: B Running
  • A 실행 가능
A 재개

B가 선점되거나 양보한 뒤 스케줄러가 A를 선택한다.

  • A·B 상태 1: A Running
  • A·B 상태 2: B Ready
  • 디스패치 후 A 재개

프로세스 상태와 전이 원인

A가 CPU를 사용하고 B는 기다린다. B가 선점되거나 양보한 뒤 스케줄러가 A를 선택한다. 각 단계의 값과 전환 이유를 비교합니다.

프로세스 · 실행 가능성과 실제 실행

Waiting에서 깨어나도 먼저 Ready로 돌아갑니다

생성·배정·선점·대기·종료 원인을 화살표로 구분합니다. 여기서는 교과서의 다섯 상태를 쓰며 Linux STAT 문자와 일대일 대응시키지 않습니다.

실행·대기·종료와 복귀 원인New에서 Ready, Running으로 이동한다. Running에서 선점은 Ready, 대기는 Waiting, 종료는 Terminated로 이동한다. Waiting 조건 충족은 Ready로 이동한다.NewReadyRunningTerminatedWaiting생성 완료CPU 배정선점대기 필요조건 충족종료
실행·대기·종료의 갈림길생성 뒤 Ready, CPU 배정 뒤 Running. 선점되면 Ready, 대기가 필요하면 Waiting, 조건 충족 후 Ready. 실행 종료 시 Terminated.NewReadyRunningWaitingTerminated생성 완료CPU 배정선점대기 필요대기 조건충족종료

실행 가능해지는 Ready와 실제 CPU를 받는 Running을 구분합니다. New·Terminated는 생성·종료 단계이며 Linux STAT 문자와 일대일 대응하는 목록은 아닙니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 A 실행부터 A 재개까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목프로세스 상태와 전이 원인
관계 설명New에서 Ready, Running으로 이동한다. Running에서 선점되면 Ready로, 대기하면 Waiting으로, 종료하면 Terminated로 이동한다. Waiting의 대기 조건이 충족되면 Ready로 이동한다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
A 실행

A가 CPU를 사용하고 B는 기다린다.

  • A·B 상태 1: A Running
  • A·B 상태 2: B Ready
  • A 블로킹 읽기
A 대기 B 실행

읽기가 즉시 완료되지 않는다.

  • A·B 상태 1: A Waiting
  • A·B 상태 2: B Running
  • 스케줄러가 B 선택
A 깨우기

읽기가 끝나도 B는 계속 실행할 수 있다.

  • A·B 상태 1: A Ready
  • A·B 상태 2: B Running
  • A 실행 가능
A 재개

B가 선점되거나 양보한 뒤 스케줄러가 A를 선택한다.

  • A·B 상태 1: A Running
  • A·B 상태 2: B Ready
  • 디스패치 후 A 재개

생성에서 준비로 가는 것은 운영체제가 프로세스를 만드는 일을 마쳤을 때입니다. Linux에서는 fork()가 새 프로세스를 준비 큐에 넣는 시점에 해당합니다. 준비에서 실행으로 가는 것은 스케줄러(scheduler)가 이 프로세스를 골라 CPU를 넘겨주었을 때이며, CPU를 실제로 넘기는 이 동작을 디스패치(dispatch)라고 합니다. 스케줄러가 어떤 기준으로 고르는지는 5장에서 다룹니다.

실행 중인 프로세스는 세 방향으로 나갈 수 있습니다. 블로킹 read()처럼 지금 바로 결과를 줄 수 없는 요청을 하면 대기 상태로 갑니다. 읽으려는 데이터가 이미 페이지 캐시에 있다면 기다리지 않고 바로 결과를 받으므로, 모든 read()가 대기로 이어지는 것은 아닙니다. 타이머 인터럽트로 주어진 실행 시간이 다 되었거나 더 급한 프로세스가 준비 상태가 되면, 운영체제는 실행 중인 프로세스를 강제로 준비 상태로 돌려보냅니다. 이를 선점(preemption)이라고 하며, 2장에서 본 타이머 인터럽트가 이 전이를 일으킵니다. 마지막으로 exit()를 부르거나, main()에서 반환하거나, 처리하지 않은 SIGSEGV 같은 신호를 받으면 종료 상태로 갑니다.

대기 상태의 프로세스는 기다리던 사건이 일어나면 준비 상태로 갑니다. 여기서 실행 상태로 바로 가지 않는다는 점이 중요합니다. 디스크 읽기를 마쳤다는 인터럽트가 오면 커널은 기다리던 프로세스를 깨워 준비 큐에 넣을 뿐이고, 그 순간 CPU에서는 다른 프로세스가 실행되고 있을 수 있습니다. 깨어난 프로세스는 스케줄러가 자신을 고를 때까지 다시 기다려야 합니다. "입출력이 끝났다"는 사건과 "CPU를 받았다"는 사건은 서로 다른 사건입니다.

규칙을 지키는 상태 기계 만들어 보기

위의 전이 규칙을 Python으로 옮겨 보면 무엇이 허락되고 무엇이 허락되지 않는지가 분명해집니다. 다음 코드는 CPU가 하나뿐인 컴퓨터에서 프로세스 A와 B가 상태를 오가는 과정을 흉내 냅니다. 허락된 전이만 표 ALLOWED에 넣었고, CPU가 이미 사용 중이면 다른 프로세스가 실행 상태로 갈 수 없게 했습니다.

ALLOWED = {
    ("New", "Ready"), ("Ready", "Running"), ("Running", "Ready"),
    ("Running", "Waiting"), ("Waiting", "Ready"), ("Running", "Terminated"),
}
cpu = None  # 지금 CPU에서 실행 중인 프로세스

class Process:
    def __init__(self, name):
        self.name, self.state = name, "New"

    def move(self, new, why):
        global cpu
        if (self.state, new) not in ALLOWED:
            print(f"  거부: {self.name}는 {self.state} 상태에서 {new} 상태로 갈 수 없음")
            return
        if new == "Running" and cpu is not None:
            print(f"  거부: CPU는 이미 {cpu.name}가 쓰는 중")
            return
        if self.state == "Running":
            cpu = None
        if new == "Running":
            cpu = self
        print(f"{self.name}: {self.state} -> {new} ({why})")
        self.state = new

a, b = Process("A"), Process("B")
a.move("Ready", "생성 완료")
b.move("Ready", "생성 완료")
a.move("Running", "스케줄러가 A를 고름")
a.move("Waiting", "블로킹 읽기를 요청")
b.move("Running", "스케줄러가 B를 고름")
a.move("Running", "읽기가 끝났으니 바로 실행?")
a.move("Ready", "읽기 완료 인터럽트")
a.move("Running", "B가 아직 실행 중인데?")
b.move("Ready", "B의 실행 시간이 다 됨")
a.move("Running", "스케줄러가 A를 고름")
A: New -> Ready (생성 완료)
B: New -> Ready (생성 완료)
A: Ready -> Running (스케줄러가 A를 고름)
A: Running -> Waiting (블로킹 읽기를 요청)
B: Ready -> Running (스케줄러가 B를 고름)
  거부: A는 Waiting 상태에서 Running 상태로 갈 수 없음
A: Waiting -> Ready (읽기 완료 인터럽트)
  거부: CPU는 이미 B가 쓰는 중
B: Running -> Ready (B의 실행 시간이 다 됨)
A: Ready -> Running (스케줄러가 A를 고름)

출력을 위에서부터 읽어 보겠습니다. A가 블로킹 읽기를 요청하면 대기 상태로 가면서 CPU를 내놓으므로, 스케줄러는 B를 실행할 수 있습니다. 읽기가 끝났다고 A를 곧바로 실행 상태로 옮기려는 시도는 거부됩니다. 대기에서 실행으로 가는 전이는 규칙에 없기 때문입니다. A가 준비 상태가 된 뒤에도 CPU를 B가 쓰고 있으므로 다시 거부되고, B가 선점되어 준비 상태로 돌아간 다음에야 A가 실행됩니다. 실제 운영체제의 스케줄러는 이보다 훨씬 복잡하지만, 깨어난 프로세스가 준비 큐를 거쳐야 한다는 규칙은 같습니다.

Linux의 ps가 보여 주는 상태

교과서의 다섯 상태는 개념을 설명하기 위한 모델이고, 실제 운영체제는 상태를 더 잘게 나눕니다. Linux의 ps 명령은 프로세스의 상태를 한 글자로 보여 주며, 주요 코드는 다음과 같습니다. 전체 목록은 ps(1) 매뉴얼의 상태 코드 설명에 있습니다.

코드뜻교과서 상태와의 관계
R실행 중이거나 실행할 수 있음실행과 준비를 함께 나타낸다
S신호로 깨울 수 있는 대기대기
D신호로 깨울 수 없는 대기. 주로 입출력대기
T, t작업 제어로 멈춤, 디버거가 추적하며 멈춤다섯 상태에는 없음
Z종료했지만 부모가 종료 상태를 아직 거두지 않음종료

여기서 두 가지를 주의해야 합니다. 첫째, R은 실행 중과 실행할 수 있음을 구분하지 않습니다. ps에서 어떤 프로세스가 R로 보인다고 해서 그 순간 CPU를 쓰고 있다고 단정할 수 없고, 준비 큐에서 기다리는 중일 수도 있습니다. 둘째, 대기가 S와 D 두 가지로 나뉩니다. S는 신호가 오면 대기를 멈추고 신호를 처리할 수 있는 대기이고, D는 신호가 와도 기다리던 일이 끝날 때까지 깨어나지 않는 대기입니다.

T는 프로세스가 멈춘 상태입니다. 터미널에서 실행 중인 프로그램에 Ctrl+Z를 누르면 그 작업에 SIGTSTP 신호가 가서 프로세스가 멈추고, 셸에서 fg를 입력하면 다시 이어서 실행됩니다. Z는 좀비(zombie) 프로세스로, 실행은 이미 끝나 메모리 같은 자원은 반납했지만 부모 프로세스가 종료 상태를 거두어 가지 않아 프로세스 표의 항목 하나가 남아 있는 상태입니다. 좀비는 프로세스가 끝난 직후 잠깐 보이는 것이 정상이며, 계속 남아 있거나 늘어날 때가 문제입니다. 이 내용은 3장의 프로세스 생성과 종료에서 자세히 다룹니다.

실행 중인 시스템에서 상태별 프로세스 수는 top으로 볼 수 있습니다. top의 윗부분 Tasks: 줄은 전체 프로세스 수와 함께 running, sleeping, stopped, zombie의 개수를 보여 줍니다. 여기서 running은 R 상태의 수이므로, CPU 수보다 클 수 있다는 점도 위와 같은 이유로 이해할 수 있습니다.

더 깊이: D 상태가 오래 머무를 때

D 상태의 프로세스는 SIGKILL을 보내도 바로 끝나지 않습니다. 커널이 디스크나 네트워크 파일 시스템의 응답처럼 중간에 끊으면 커널 자료구조가 어긋날 수 있는 일을 기다리는 동안에는, 신호를 처리하러 대기를 빠져나오지 않기 때문입니다. 대개는 아주 짧게 지나가지만, 응답하지 않는 NFS 서버에 접근하거나 고장 난 디스크를 읽으려는 프로세스는 D 상태에 오래 머물 수 있습니다. Linux에는 치명적인 신호에만 깨어나는 TASK_KILLABLE이라는 대기 방식도 있어 이런 상황을 줄이고 있으며, 이 대기도 ps에서는 D로 보입니다.

순간적으로 D 상태가 보이는 것만으로 장애라고 판단할 수는 없습니다. 같은 프로세스가 계속 D에 머물거나 D 상태의 프로세스가 늘어난다면, 그 프로세스가 커널의 어느 지점에서 기다리는지(ps의 wchan 열이나 /proc/<PID>/stack)와 관련된 장치나 파일 시스템의 상태를 조사합니다. D 상태의 프로세스는 Linux의 부하 평균(load average)에도 포함되므로, CPU는 한가한데 부하 평균이 높다면 이 상태의 프로세스가 많은지도 확인해 볼 만합니다.

정리

  • 프로그램은 디스크에 저장된 실행 파일이고, 프로세스는 그 프로그램을 실행한 것입니다. 운영체제는 프로세스 단위로 주소 공간과 자원을 나누어 주고 보호합니다.
  • 프로세스의 주소 공간은 코드, 데이터(.data, .bss), 힙, 스택으로 나뉘며, 영역마다 수명과 권한이 다릅니다. 실제 배치는 /proc/<PID>/maps로 확인할 수 있습니다.
  • 프로세스는 생성, 준비, 실행, 대기, 종료 상태를 오갑니다. 준비 상태는 CPU만 받으면 실행할 수 있고, 대기 상태는 CPU를 받아도 실행할 수 없습니다.
  • 대기 상태에서 기다리던 일이 끝나면 준비 상태로 가며, 실행 상태가 되려면 스케줄러가 골라 주어야 합니다.
  • Linux의 ps에서 R은 실행 중과 실행 가능을 함께 나타내고, 대기는 신호로 깨울 수 있는 S와 깨울 수 없는 D로 나뉩니다.

연습 문제

1. 읽기는 끝났지만 B가 실행 중이라면

논리 CPU가 하나인 컴퓨터에서 프로세스 A가 파일 읽기를 기다리다가 읽기가 끝났습니다. 그런데 CPU에서는 프로세스 B가 계속 명령어를 실행하고 있습니다. A는 지금 어느 상태이며, A가 다시 실행되려면 무엇이 필요할까요? 이때 ps에서 A가 R로 보인다는 사실만으로 A가 CPU를 쓰고 있다고 말할 수 있을까요?

해설 보기

A는 기다리던 일이 끝났으므로 준비 상태입니다. 다시 실행되려면 B가 선점되거나 스스로 CPU를 내놓은 뒤, 스케줄러가 A를 골라 디스패치해야 합니다. ps의 R은 실행 중인 상태와 실행할 수 있는 상태를 함께 나타내므로, A가 R로 보여도 준비 큐에서 기다리는 중일 수 있습니다. 이 상황에서 실제로 CPU를 쓰는 것은 B입니다.

2. 변수는 어느 영역에 있을까

다음 C 코드에서 limit, calls, msg, msg가 가리키는 문자열, n, p, p가 가리키는 메모리가 각각 어느 영역에 놓이는지 답하세요. 함수 안에 선언된 변수라도 static이 붙었는지를 함께 보아야 합니다.

#include <stdlib.h>

int limit = 100;
const char *msg = "hello";

int *make(int n)
{
    static int calls;
    calls++;
    int *p = malloc(n * sizeof(int));
    return p;
}
해설 보기

limit은 0이 아닌 초깃값이 있는 전역 변수이므로 .data에 있습니다. calls는 함수 안에 선언되었지만 static이므로 프로그램이 끝날 때까지 살아 있고, 초깃값이 없어 .bss에 놓입니다. msg는 문자열의 주소를 초깃값으로 가진 전역 포인터 변수이므로 .data에 있고, msg가 가리키는 문자열 "hello" 자체는 바뀌지 않는 상수이므로 읽기 전용 영역(.rodata)에 놓이는 것이 보통입니다. 매개변수 n과 지역 변수 p는 make가 호출될 때 생기는 스택 프레임에 있으며, 최적화하면 레지스터에만 있을 수도 있습니다. p가 가리키는 메모리는 malloc으로 받은 것이므로 힙에 있고, make가 반환된 뒤에도 해제하기 전까지 살아 있습니다. 그래서 make가 반환한 포인터는 써도 되지만, p 변수의 주소를 반환했다면 반환 뒤에는 쓸 수 없습니다.

3. 준비 상태에서 바로 대기 상태로 갈 수 없는 이유

앞의 Python 상태 기계에서 B가 준비 상태일 때 b.move("Waiting", "파일 읽기")를 실행하면 거부됩니다. 준비 상태에서 대기 상태로 가는 전이가 규칙에 없는 이유를 설명하세요.

해설 보기

프로세스가 대기 상태로 가는 것은 입출력을 요청하거나 잠금을 기다리는 것처럼 프로세스가 무언가를 실행하다가 기다려야 할 때입니다. 그런데 이런 요청은 시스템 콜 같은 명령어를 실행해야 할 수 있고, 명령어를 실행하려면 CPU를 받아 실행 상태여야 합니다. 준비 상태의 프로세스는 아직 CPU를 받지 못해 아무 명령어도 실행하지 않으므로, 스스로 무언가를 요청해 대기 상태로 갈 방법이 없습니다. 그래서 대기 상태로 가는 길은 실행 상태에서만 나 있습니다. Linux에서 SIGSTOP으로 프로세스를 멈추는 것처럼 바깥에서 상태를 바꾸는 경우도 있지만, 그것은 교과서의 다섯 상태 모델에 없는 별도의 정지 상태입니다.

다음 글 PCB와 컨텍스트 스위칭에서는 운영체제가 프로세스마다 상태와 자원 정보를 어떻게 기록하는지, 그리고 실행 중인 프로세스를 바꿀 때 무엇을 저장하고 되살리는지 살펴봅니다.