운영체제 3편

프로세스 — 상태, PCB, 문맥 교환, fork와 exec

suhyun·2026년 9월 23일·읽는 데 약 11분
한 줄로: 프로세스는 메모리에 올라가 돌고 있는 프로그램의 실행 하나이고, 프로세서와 메모리를 혼자 쓴다는 착각 위에서 돕니다.

1. 프로그램, 프로세서, 프로세스

2편 끝에서 프로세스를 실행 중인 프로그램이라고만 하고 넘어갔습니다. 이 글은 그 말을 제대로 풉니다. 먼저 헷갈리기 쉬운 이름 셋을 가릅니다.

  • 프로그램 — 디스크에 놓인 실행 파일입니다. 가만히 있습니다.
  • 프로세서 — 명령어를 실행하는 하드웨어입니다.
  • 프로세스 — 그 프로그램이 메모리에 올라가 실제로 돌고 있는 한 번의 실행입니다.

같은 프로그램을 두 번 띄우면 프로그램은 하나지만 프로세스는 둘이 됩니다.

2. 두 가지 착각과 동시 실행

프로세스는 각 프로그램에게 두 가지 착각을 줍니다. 하나는 프로세서를 혼자 쓰고 있다는 것입니다. 명령어가 한 줄씩 끊기지 않고 이어지는 것처럼 보입니다. 이 흐름을 제어 흐름이라고 합니다. 다른 하나는 메모리를 혼자 쓰고 있다는 것입니다. 다른 프로세스가 건드리지 못하는 자기만의 주소 공간이 있는 것처럼 보입니다. 실제로는 프로세서 하나를 여럿이 번갈아 쓰고 메모리도 나눠 씁니다. 앞의 것은 시간을 잘게 나눠 주어 만들고, 뒤의 것은 나중에 볼 가상 메모리가 만듭니다.

두 프로세스의 실행 기간이 시간 위에서 겹치면 둘이 동시에 돈다고 말합니다. 겹치지 않고 하나가 끝난 뒤 다른 하나가 시작되면 차례로 돈다고 합니다. 그런데 프로세서가 하나라면 겹친다는 것은 사실 잘게 나눠 번갈아 돈다는 뜻입니다. 사용자 눈에는 여러 프로세스가 나란히 흐르는 것처럼 보이지만, 시스템 쪽에서 보면 프로세서 하나를 번갈아 채우고 있습니다. 한 프로세스에서 다른 프로세스로 넘어갈 때마다 그 사이에 커널이 잠깐 끼어듭니다.

3. 메모리에 올라간 프로세스의 모양

1편에서 본 로딩의 결과로 메모리에 생기는 모양입니다. 프로세스의 주소 공간은 네 구역으로 나뉩니다.

  1. 코드 — 맨 아래. 실행할 명령어가 들어 있습니다.
  2. 데이터 — 프로그램 내내 살아 있는 전역 변수가 들어 있습니다.
  3. — 프로그램이 실행 도중에 필요한 만큼 달라고 해서 받아 쓰는 공간이고, 위쪽을 향해 커집니다.
  4. 스택 — 맨 위. 함수가 잠시 쓰고 버리는 값을 쌓는 곳이고 아래쪽으로 자랍니다.

힙과 스택은 서로를 향해 자라고, 그 사이의 빈 곳을 나눠 씁니다.

4. 다섯 가지 상태

프로세스는 실행되는 동안 상태가 바뀝니다. 막 만들어지는 중인 생성, 프로세서를 받기만 기다리는 준비, 지금 명령어가 실행되고 있는 실행, 입출력 같은 어떤 일이 끝나기를 기다리는 대기, 그리고 끝난 종료입니다.

생성이 끝나면 준비로 갑니다. 운영체제가 고르면 실행으로 갑니다. 실행 중에 디스크를 읽으러 가면 대기로 빠집니다. 읽기가 끝나면 곧바로 실행이 아니라 준비로 돌아갑니다. 프로세서는 이미 다른 프로세스가 쓰고 있기 때문입니다. 실행 중에 주어진 시간이 다 되어도 준비로 돌아갑니다. 마지막 명령어까지 끝나면 종료입니다.

5. PCB와 두 가지 큐

프로세스 제어 블록

운영체제는 프로세스마다 기록장을 하나씩 둡니다. 이것을 프로세스 제어 블록, 줄여서 PCB라고 부릅니다. 지금 상태, 프로세스 번호, 레지스터들에 들어 있던 값을 적습니다. 레지스터 중에 특히 중요한 것이 프로그램 카운터입니다. 다음에 실행할 명령어의 주소를 담고 있는 레지스터입니다. 그 밖에 누구를 먼저 실행할지 정할 때 쓰는 우선순위, 메모리를 어디서 어디까지 쓰는지, 프로세서를 얼마나 썼는지, 어떤 파일을 열어 두었는지도 적습니다.

준비 큐와 대기 큐

프로세서 하나로는 한 번에 프로세스 하나만 돌릴 수 있고, 나머지는 줄을 섭니다. 준비 큐는 메모리에 올라와 있고 프로세서만 받으면 바로 돌 수 있는 프로세스들의 줄입니다. 대기 큐는 입출력처럼 어떤 일이 끝나기를 기다리는 프로세스들의 줄입니다. 실제로 줄에 서는 것은 PCB입니다. 실행 중이던 프로세스가 디스크를 읽으러 가면 그 PCB가 대기 큐 끝에 붙고, 읽기가 끝나면 준비 큐로 옮겨 갑니다. 운영체제는 준비 큐 맨 앞에서 다음 프로세스를 꺼내 실행시킵니다. 이 고르는 일을 스케줄링이라고 합니다. 목적은 2편에서 본 둘, 프로세서를 놀리지 않는 것과 사용자가 기다리지 않게 하는 것입니다.

6. 문맥 교환

프로세스 A가 돌다가 멈추면 운영체제는 지금 레지스터 값을 프로그램 카운터까지 전부 A의 PCB에 적습니다. 그다음 B의 PCB에서 예전에 적어 둔 값을 꺼내 레지스터에 다시 채웁니다. 프로그램 카운터도 B의 값으로 바뀝니다. 그러면 B는 멈췄던 바로 그 자리에서 다시 이어 갑니다. 이 전체를 문맥 교환이라고 합니다. 문맥은 레지스터 값처럼 프로세스가 이어서 돌기 위해 필요한 상태를 말합니다.

이렇게 적고 꺼내는 일은 커널 코드가 합니다. 프로세스 사이에 잠깐 끼어든다고 한 커널이 바로 이 일을 합니다. 이 일에도 시간이 들기 때문에, 교환에 걸리는 시간을 줄이려고 레지스터 묶음을 여러 벌 갖춘 하드웨어도 있습니다.

문제 1

프로세스 A에서 B로 넘어갈 때, 운영체제가 실수로 A의 프로그램 카운터를 PCB에 적지 않았다고 해 봅니다. 나중에 A가 다시 프로세서를 받으면 어떤 일이 생깁니까. 그리고 문맥 교환이 진행되는 동안, A와 B 중 누구의 명령어가 실행됩니까.

풀이

프로그램 카운터는 다음에 실행할 명령어의 주소입니다. 이것이 PCB에 없으면 A를 다시 이어 돌릴 때 어디서부터 해야 하는지 알 수 없습니다. 레지스터에 남아 있는 것은 B가 마지막으로 쓰던 주소이기 때문에, A는 엉뚱한 자리에서 실행을 시작합니다. 멈췄던 자리로 돌아오지 못하는 것입니다.

두 번째 물음의 답은 둘 다 아닙니다. 교환하는 동안 도는 것은 적고 꺼내는 커널 코드뿐입니다. A의 명령어도 B의 명령어도 한 줄도 실행되지 않습니다. 사용자 프로그램의 일은 하나도 나아가지 않고 시간만 듭니다. 그래서 문맥 교환을 순수한 비용이라고 부릅니다.

7. 부모와 자식, 프로세스 트리

이번에는 프로세스가 처음 어떻게 생기는지 봅니다. 프로세스는 다른 프로세스가 만듭니다. 만드는 쪽을 부모, 만들어진 쪽을 자식이라고 합니다. 자식도 다시 자식을 만들 수 있으니 전체는 나무 모양이 되고, 이것을 프로세스 트리라고 합니다. 운영체제는 프로세스마다 겹치지 않는 번호를 하나씩 붙입니다. 프로세스 식별자, 줄여서 pid라고 부르고, 이 번호로 프로세스를 찾고 관리합니다. 리눅스에서는 ps 명령으로 지금 도는 프로세스와 그 번호를, pstree 명령으로 나무 모양 전체를 볼 수 있습니다.

8. fork — 자기 자신을 복사한다

리눅스에서 새 프로세스를 만드는 시스템 콜은 fork입니다. fork를 부르면 운영체제는 부른 프로세스를 통째로 복사해 자식을 만듭니다. 코드도, 변수 값도, 지금 몇 번째 줄을 실행하고 있었는지도 같습니다. 그래서 fork가 끝나면 똑같은 두 프로세스가 fork 바로 다음 줄에서부터 각자 돌기 시작합니다. 둘을 구별하는 방법은 fork가 돌려주는 값 하나입니다. 부모에게는 자식의 pid를, 자식에게는 0을 돌려줍니다. 실패하면 -1입니다. 코드는 이 값을 보고 부모가 할 일과 자식이 할 일을 가릅니다.

복사된 뒤에는 따로 산다

fork 직후 두 프로세스의 메모리 내용은 똑같지만, 같은 메모리를 함께 쓰는 것이 아니라 복사본을 하나씩 따로 가집니다. 예를 들어 fork 전에 변수 값이 11이라고 해 봅니다. fork 뒤 자식만 1을 더하면 자식에서는 12가 되고 부모에서는 11 그대로입니다. 예외가 하나 있습니다. fork 전에 열어 둔 파일은 부모와 자식이 함께 씁니다. 파일 안에서 지금 읽고 쓰는 위치까지 공유합니다. fork 뒤에 연 파일은 각자의 것입니다.

문제 2

다음 프로그램을 한 번 실행하면 L0, L1, L2, Bye는 각각 몇 번씩 출력됩니까.

printf("L0\n");
fork();
printf("L1\n");
fork();
printf("L2\n");
fork();
printf("Bye\n");

풀이

fork는 부른 프로세스를 복사하고, 두 프로세스는 fork 다음 줄부터 이어서 돕니다. 이것만 쓰면 됩니다. 처음에는 프로세스가 하나이니 L0은 한 번입니다. 첫 번째 fork에서 둘이 되고, 둘 다 L1을 출력하니 L1은 두 번입니다. 두 번째 fork에서 넷이 되니 L2는 네 번입니다. 세 번째 fork에서 여덟이 되니 Bye는 여덟 번입니다. fork를 한 번 지날 때마다 프로세스 수가 두 배가 됩니다. 답은 1, 2, 4, 8입니다.

9. exec — 다른 프로그램으로 바뀐다

fork만으로는 같은 프로그램이 늘어날 뿐입니다. 다른 프로그램을 돌리려면 exec 계열의 시스템 콜을 씁니다. exec를 부르면 지금 프로세스의 코드와 데이터와 스택이 새 프로그램의 것으로 통째로 바뀝니다. pid는 그대로입니다. 몸은 같고 안의 내용만 새 프로그램이 되는 것입니다. 성공하면 원래 코드로 돌아오지 않습니다. 돌아올 코드가 이미 지워졌기 때문입니다. 실패했을 때만 -1을 돌려주며 돌아옵니다.

그래서 새 프로그램을 띄우는 흔한 방법은 둘을 잇는 것입니다. fork로 자식을 만들고, 자식이 exec로 새 프로그램이 됩니다. 글자로 명령을 받아 실행해 주는 셸에서 명령을 입력할 때마다 이 일이 일어납니다.

10. exit와 wait — 끝난 자식은 부모가 거둔다

프로세스가 일을 마치면 exit를 부릅니다. 운영체제는 그 프로세스가 쓰던 메모리와 열린 파일을 거둡니다. 그런데 PCB 같은 기록 일부는 남겨 둡니다. 부모가 자식이 어떻게 끝났는지 알아야 하기 때문입니다. 부모는 wait를 불러 자식이 끝나기를 기다렸다가 그 결과를 받습니다. 이때 비로소 자식의 기록이 완전히 지워집니다. 이것을 거둔다고 합니다.

  • 좀비 — 부모가 wait를 부르지 않아 끝난 자식의 기록이 계속 남은 상태입니다. 죽었는데 기록은 살아 있습니다.
  • 고아 — 부모가 먼저 끝나 버려 남겨진 자식입니다. 시스템이 맨 처음 띄운 프로세스가 대신 맡아 나중에 거둡니다.

네 시스템 콜을 한 번에

부모가 fork를 부르면 자기와 똑같은 자식이 생깁니다. 자식은 fork가 0을 돌려준 것을 보고 exec를 불러 새 프로그램이 됩니다. 부모는 그동안 wait를 불러 기다립니다. 자식의 새 프로그램이 할 일을 마치고 exit를 부르면, 기다리던 부모에게 알림이 가고 부모가 깨어나 자식을 거둔 뒤 이어서 돕니다. 셸에서 명령 하나를 실행할 때마다 이 네 단계가 한 바퀴 돕니다.

정리

프로세스는 메모리에 올라가 돌고 있는 프로그램의 실행 하나이고, 프로세서와 메모리를 혼자 쓴다는 착각 위에서 돕니다. 메모리에서는 코드, 데이터, 힙, 스택 네 구역으로 놓입니다. 생성, 준비, 실행, 대기, 종료를 오가고, 운영체제는 그 모든 것을 PCB에 적어 두고 준비 큐와 대기 큐에 세웁니다. 한 프로세스에서 다른 프로세스로 넘어갈 때는 레지스터를 PCB에 적고 꺼내는 문맥 교환이 일어납니다. 새 프로세스는 fork로 복사해 만들고, exec로 다른 프로그램이 되고, exit로 끝나고, 부모의 wait로 거둬집니다.

그런데 부모와 자식은 복사된 뒤 메모리를 따로 씁니다. 서로 말을 주고받으려면 다른 수단이 필요합니다. 다음 글에서는 프로세스끼리 신호를 보내고 데이터를 주고받는 방법을 봅니다.

dev-news학습 노트소개개인정보 처리