10 프로그램과 프로세스 — 실행된다는 것
먼저 알아야 할 것
돌아보기
5장에서 프로그램의 기억이 코드·정적·스택·힙으로 나뉜다고 했다. 그런데 그 구역들은 언제, 누가 마련해 주는가?
답. 프로그램이 실행되는 순간, 대개 운영체제가 마련한다. 디스크에 놓인 실행 파일은 아직 아무 기억도 갖고 있지 않다 — 그저 “코드는 이렇고, 정적 구역은 이만큼 필요하다”는 설계도다. 그 설계도를 읽어 실제 기억을 펼치고, 작업대를 세우고, 첫 명령으로 뛰어들게 하는 일 — 그것이 실행이고, 그렇게 살아난 한 벌을 프로세스(process)라 부른다.
이 장의 필요성과 맥락
이 장이 스트림 바로 다음에 있는 데는 두 가지 이유가 있다. 하나 — 9장이 「글자가 한 줄씩 흐르는 띠」를 보여 주었으니, 그 띠를 누가 쥐어 주는가가 곧바로 다음 물음이다. 답은 운영체제이고, 표준 입력·출력·오류는 프로세스가 태어날 때 이미 열려 있는 통로다. 둘 — 5장이 「기억이 이렇게 나뉜다」고 했다면 그 구역을 누가 언제 만들어 주는가가 남는데, 그 답도 여기 있다.
그리고 이 장을 받아 두어야 16장의 첫 프로그램이 「실행된다」는 말이 빈말이 되지 않는다. 바로 다음 장(12장)이 그 격리를 하드웨어가 어떻게 구현하는지 보여 주므로, 이 자리가 두 이야기를 잇는 자리이기도 하다.
이 장이 끝나면
fork와 윈도우의 방식 — 를 짧게 본다. 여기 나오는 것은 C 표준이 아니다. 표준이 약속하는 것과 운영체제가 주는 것의 경계를 처음으로 긋는 장이기도 하다.이 장에서 답할 질문
- “자기만의 기억”이라는 말이 이상하다 — 기계에 붙은 메모리는 하나 아닌가?
- 그러면 프로세스를 지금 알아 두어야 하는 이유는 무엇인가?
- 그러면 「끝남」 상태는 왜 따로 있는가? 끝났으면 사라지면 되지 않나?
- 시스템 호출은 함수 호출과 무엇이 다른가? 코드에서는 똑같이 생겼는데.
10.1 프로그램은 명사, 프로세스는 동사#
구별은 단순하다.
- 프로그램 — 디스크에 놓인 파일. 죽어 있다. 복사할 수 있고, 옮길 수 있다.
- 프로세스 — 실행되어 살아 있는 한 벌. 기억을 갖고, 진행 중인 위치를 갖고, 열린 파일을 갖고, 언젠가 끝나며 종료 상태를 남긴다.
같은 프로그램을 세 번 실행하면 프로세스가 셋 생긴다. 셋은 같은 코드를 공유하지만 기억은 각자 따로다 — 한쪽에서 값을 바꿔도 다른 쪽은 모른다.
| 운영체제가 프로세스에게 주는 것 | 무엇인가 | 이 책에서 |
|---|---|---|
| 주소 공간 | 자기만의 기억 지도(5장의 네 구역) | 88장 |
| 실행 위치 | 지금 어느 명령을 하는 중인지 | — |
| 열린 파일 목록 | 표준 입출력을 포함한 통로 | 9장·68장 |
| 명령줄 인자·환경 | 실행할 때 건네받은 값들 | 55장 |
| 종료 상태 | 끝나며 남기는 한 숫자 | 16장·55장 |
표 10.1 — 운영체제가 프로세스에게 주는 것
이 목록에서 두 가지가 이후 장들과 곧장 이어진다. 첫째, 9장에서 볼 표준 입력·출력·오류는 운영체제가 프로세스에게 미리 열어 준 통로다 — 프로그램이 직접 여는 것이 아니다. 둘째, 16장의 헬로 월드가 끝내며 돌려주는 0이 바로 종료 상태이고, 그 숫자를 받는 쪽은 이 프로세스를 실행한 누군가다.
문. “자기만의 기억”이라는 말이 이상하다 — 기계에 붙은 메모리는 하나 아닌가?
답. 물리적으로는 하나지만, 운영체제가 프로세스마다 저마다 다른 주소 공간을 보여 준다. 프로세스가 보는 주소는 물리 주소가 아니라 그 프로세스만의 번호이고, 하드웨어가 그것을 실제 자리로 옮겨 준다(가상 메모리). 그래서 두 프로세스가 똑같은 주소를 써도 서로 다른 자리를 만지고, 한쪽이 무너져도 다른 쪽은 멀쩡하다.
이 격리가 없는 세계도 있다 — 운영체제 없이 도는 작은 칩에서는 프로세스라는 개념 자체가 없고, 코드 하나가 기계 전체를 쓴다(88장). 그래서 이 장의 이야기는 “C가 도는 모든 곳”의 이야기가 아니라 운영체제 위에서 돌 때의 이야기다.
실물로 한 번 보아 두면 「프로그램은 명사, 프로세스는 동사」가 손에 잡힌다.
examples/ch49/process.c
// 프로세스는 실행할 때마다 새로 태어난다 --- 지난번의 기억은 남지 않는다.
#include <stdio.h>
#include <stdlib.h>
// 프로그램이 통째로 가지는 값. 「지난 실행」의 값이 남아 있을 것 같지만,
// 프로그램이 끝나면 이 값이 놓였던 기억은 운영체제가 거두어 간다.
static int run_count = 0;
static void at_the_end(void)
{
puts(" the process is ending --- everything it owned goes back");
}
int main(int argc, char **argv)
{
atexit(at_the_end); // 프로세스가 끝날 때 부를 함수를 등록한다
run_count += 1;
// argv[0] 은 이 프로그램이 불린 이름이다. 자리마다 값이 다르므로 여기서는
// 「있다/없다」만 본다.
printf("the program received %d argument(s), and argv[0] is %s\n",
argc, (argc > 0 && argv[0] != NULL) ? "present" : "absent");
printf(" run_count in this process: %d\n", run_count);
puts(" run it again and it prints 1 again --- a new process starts fresh");
return 0;
}
실행 결과
the program received 1 argument(s), and argv[0] is present
run_count in this process: 1
run it again and it prints 1 again --- a new process starts fresh
the process is ending --- everything it owned goes back
찍힌 run_count 는 언제나 1 이다. 두 번 돌려도, 열 번 돌려도 1 이다 — 프로그램 파일은 그대로지만 프로세스는 매번 새로 태어나기 때문이다. 지난 실행이 쓰던 기억은 운영체제가 이미 거두어 갔다. atexit 으로 등록한 함수가 마지막에 불리는 것도 같은 이야기의 뒷면이다: 프로세스에는 시작과 끝이 있고, 그 끝에서 자기가 가졌던 것을 전부 돌려준다.
10.2 프로세스는 어떻게 태어나는가#
여기서부터는 운영체제마다 다르다. 두 갈래를 얼굴만 익힌다.
플랫폼 노트. 유닉스 계열 — fork와 exec
유닉스 계열(리눅스·macOS·BSD — 버클리 소프트웨어 배포판 Berkeley Software Distribution 에서 갈라져 나온 계보다)은 프로세스를 만드는 일을 두 걸음으로 나눈다. 이 설계가 셸과 파이프라인(9장)의 뿌리다.
fork()— 지금 프로세스를 복제한다. 복제된 쪽(자식)은 부모와 같은 코드, 같은 기억 내용, 같은 열린 파일을 갖고 시작한다. 기묘한 것은 이 함수가 두 번 돌아온다는 점이다 — 부모에게는 자식의 번호(PID)를, 자식에게는 0을 돌려준다. 그 반환값으로 자기가 누구인지 안다.exec계열 — 지금 프로세스의 내용을 다른 프로그램으로 갈아 끼운다. 기억이 통째로 새 프로그램의 것으로 바뀌고, 돌아오지 않는다(성공하면).wait계열 — 부모가 자식이 끝나기를 기다리고 종료 상태를 받는다.
pid_t pid = fork();
if (pid == 0) { /* 자식 */
execl("/bin/ls", "ls", (char *)0);
_exit(127); /* exec 이 실패했을 때만 여기 온다 */
} else if (pid > 0) { /* 부모 */
int status;
waitpid(pid, &status, 0);
}터미널에서 명령을 하나 치면 셸이 정확히 이 일을 한다 — 자기를 복제하고, 복제된 쪽을 그 명령으로 갈아 끼우고, 끝나기를 기다린다. 9장에서 본 리다이렉션과 파이프도 이 사이에서 일어난다: 복제한 뒤 갈아 끼우기 전에 자식의 입출력 통로를 바꿔 두는 것이다. 두 걸음으로 나눈 설계가 그 자리를 만들어 준 셈이다.
복제라고 해서 기억을 통째로 베끼지는 않는다. 오늘의 구현은 쓸 때까지 미룬다(copy-on-write) — 부모와 자식이 같은 자리를 공유하다가, 어느 한쪽이 쓰는 순간 그 부분만 복사한다.
플랫폼 노트. 윈도우 — CreateProcess
윈도우에는 fork가 없다. 대신 한 걸음이다 — CreateProcess가 “이 프로그램을 새 프로세스로 시작하라”고 한 번에 지시한다. 복제 단계가 없으므로 자식은 부모의 기억을 물려받지 않고, 부모가 물려주고 싶은 것(열린 통로, 환경, 작업 디렉터리)은 인자로 명시한다.
두 방식의 성격 차이가 이식에서 드러난다. 유닉스 코드의 “복제한 뒤 조금 고쳐서 갈아 끼운다”는 패턴은 윈도우에서 그대로 옮겨지지 않고, 필요한 것을 인자로 적어 넘기는 형태로 다시 써야 한다. 유닉스 계열이 공유하는 표준인 POSIX(portable operating system interface, 이식 가능 운영체제 인터페이스) 쪽에도 이 패턴을 한 걸음으로 만든 posix_spawn이 있다 — 표준 C 가 아니라 운영체제 쪽의 표준이다.
흔한 오해. “프로세스를 다루는 함수도 C 표준에 들어 있겠지”
들어 있지 않다. fork도 exec도 CreateProcess도 C 표준이 아니다. C 표준이 프로세스에 대해 말하는 것은 놀랄 만큼 적다 — 프로그램이 시작해서 main이 돌고, 끝나며 종료 상태를 남긴다는 정도다. 프로세스를 새로 만들거나, 다른 프로그램을 실행하거나, 자식을 기다리는 방법은 표준에 없다(그나마 system 하나가 “명령 문자열을 실행한다”고만 정해져 있고, 그것이 무엇을 뜻하는지는 구현에 맡긴다).
왜 이렇게 인색한가 — C가 운영체제가 없는 기계에서도 돌아야 하기 때문이다(67장의 자유 사용 구현). 프로세스가 없는 칩에도 C는 있어야 하므로, 프로세스는 표준의 밖, 운영체제의 영역에 남았다.
이 경계 감각이 이 책 전체에서 중요하다. 표준에 있는 것은 어디서나 통하고, 그 밖의 것은 그 운영체제에서만 통한다. 이 책이 운영체제에 묶인 이야기를 “플랫폼 노트” 상자에 따로 담는 이유이기도 하다.
문. 그러면 프로세스를 지금 알아 두어야 하는 이유는 무엇인가?
답. 세 가지가 곧 필요해지기 때문이다.
첫째, 9장의 스트림이 어디서 오는지 — 표준 입출력은 프로세스가 태어날 때 이미 열려 있는 통로다. 둘째, 16장의 헬로 월드가 남기는 종료 상태 0이 누구에게 가는지. 셋째, 5장에서 본 네 구역이 프로세스마다 따로라는 것 — 전역 변수가 “프로그램 전체에서 하나”라는 말은 한 프로세스 안에서 하나라는 뜻이다.
더 깊은 이야기 — 여러 갈래로 도는 프로그램(스레드), 프로세스 사이의 통신, 신호 — 는 이 책의 범위를 넘지만, 그중 C 표준이 다루는 조각들은 제자리에서 만난다(신호는 80장, 스레드와 원자적 연산(atomic operation)은 85장).
10.3 프로세스의 한살이#
프로세스는 태어나서 끝날 때까지 한 가지 상태로 있지 않다. 돌고 있는 것과 돌 수 있는 것과 돌 수 없는 것은 다르고, 그 구별이 다음 장의 바탕이 된다.
| 상태 | 무엇인가 | 무엇을 기다리나 | 여기서 나가려면 |
|---|---|---|---|
| 실행 중 | 지금 코어를 쓰고 있다 | — | 스스로 멈추거나, 시간이 다 되거나, 끝난다 |
| 준비됨 | 지금 당장 돌 수 있는데 차례가 아니다 | 코어의 차례 | 스케줄러가 고른다 |
| 기다림 | 돌 수 없다 — 무언가를 기다린다 | 디스크·입력·시각·다른 프로세스 | 기다리던 일이 일어난다 |
| 끝남 | 일은 끝났고 종료 상태만 남았다 | 부모가 그 값을 거둬 가기를 | 부모가 wait 로 거둔다 |
표 10.2 — 프로세스가 놓이는 상태
★ 셋째 줄이 실무에서 가장 자주 만나는 상태다. 프로그램이 「느리다」고 할 때 대개 코어가 모자란 것이 아니라 기다리는 중이다 — 디스크에서 오기를, 사람이 키를 누르기를, 네트워크가 답하기를. 그리고 기다리는 동안 코어를 붙잡고 있을 이유가 없으므로, 이때 운영체제는 다른 프로세스에게 코어를 넘긴다.
문. 그러면 「끝남」 상태는 왜 따로 있는가? 끝났으면 사라지면 되지 않나?
답. 끝나면서 남기는 것이 하나 있기 때문이다 — 종료 상태라는 숫자. 그 숫자를 받을 쪽(부모)이 아직 안 받았다면, 프로세스의 몸은 사라져도 그 한 줄의 기록은 남아 있어야 한다. 부모가 거둬 가면 그때 완전히 사라진다. 부모가 영영 거두지 않으면 그 기록만 남아 떠도는데, 유닉스 계열에서는 이것을 좀비라고 부른다 — 이름이 무섭지만 하는 일은 없다.
10.4 두 개의 모드 — 할 수 있는 일과 할 수 없는 일#
한 가지가 더 남았다. 프로세스가 서로의 기억을 못 건드린다면, 누가 그 규칙을 지키게 하는가? 프로그램이 스스로 지키는 것이라면 안 지키는 프로그램을 막을 길이 없다.
그래서 기계에 모드가 둘 있다.
| 사용자 모드 | 커널 모드 | |
|---|---|---|
| 누가 도는가 | 보통의 프로그램 | 운영체제 |
| 기억 | 제 주소 공간만 보인다 | 전부 보인다 |
| 장치 | 직접 못 만진다 | 만질 수 있다 |
| 할 수 없는 일을 하면 | 그 자리에서 멈춘다 — 운영체제가 개입한다 | — |
| 넘어가는 문 | 시스템 호출 — 정해진 자리로 부탁한다 | 돌아올 때 원래 모드로 |
표 10.3 — 두 개의 모드
여기서 16장의 printf 가 실제로 무엇을 하는지 조금 더 정확해진다. 글자를 화면에 쓰는 일은 장치를 만지는 일이므로 사용자 모드에서는 못 한다. printf 는 글자를 다듬어 통에 담아 두었다가, 결국 운영체제에게 부탁한다. 그 부탁이 시스템 호출이다.
문. 시스템 호출은 함수 호출과 무엇이 다른가? 코드에서는 똑같이 생겼는데.
답. 겉모습은 같다 — write(…) 라고 부르면 된다. 그러나 그 안에서 일어나는 일이 다르다. 보통의 함수 호출은 같은 모드 안에서 다른 주소로 뛰는 것이고, 시스템 호출은 모드의 경계를 넘는 일이다. 그래서 값이 더 든다. 정해진 방식으로 부탁을 전달하고, 모드가 바뀌고, 운영체제가 「이 부탁을 들어줘도 되는가」를 확인한 뒤에야 일이 진행된다.
9장에서 본 통(버퍼)이 왜 있는지가 여기서 한 겹 더 설명된다. 글자 하나마다 경계를 넘으면 그 값을 매번 치러야 하므로, 통에 모았다가 한 번에 넘긴다.
복습 정리
| 기억할 것 | 요지 |
|---|---|
| 프로그램 / 프로세스 | 파일 / 실행되어 살아 있는 한 벌 |
| 운영체제가 주는 것 | 주소 공간, 열린 통로, 종료 상태 |
| 기억은 각자 | 같은 프로그램을 여러 번 실행해도 서로 모른다 |
| 유닉스 | fork(복제) + exec(갈아 끼우기) 두 걸음 |
| 윈도우 | CreateProcess 한 걸음 |
| 표준 밖 | 프로세스는 C 표준의 영역이 아니다 — OS 가 준다 |
표 10.4 — 프로그램과 프로세스 — 기억할 것
무대가 갖춰졌다. 프로그램은 파일이고, 실행되면 프로세스가 되고, 운영체제가 기억과 통로와 종료 상태를 쥐어 준다 — 16장의 첫 프로그램이 「실행된다」고 할 때 실제로 일어나는 일이 이것이다.
이제 그 무대 위에서 벌어지는 일을 볼 차례다. 프로세스가 여럿이면 코어 하나를 어떻게 나눠 쓰는가, 그 전환을 누가 정하고 무엇이 알리는가 — 그것이 다음 장의 이야기다. 방금 본 「기다림」 상태와 「모드의 경계」가 거기서 곧바로 쓰인다. 지금까지 들고 온 단순한 기계 그림이 사실 얼마나 과감한 거짓말이었는지 — 기억이 갈라지고(12장), 실행이 겹쳐지고(13장), 컴파일러가 끼어들면서(14장), C가 왜 “추상적인 언어”일 수밖에 없는지(15장)가 이 부의 남은 이야기다.