시스템 콜 (fork · wait · exec)
시스템 콜(System Call)은 응용 프로그램이 운영체제(커널)에게 "이 일 좀 해 줘" 하고 공식적으로 부탁하는 창구입니다. 그중 fork(), exec(), wait()는 프로세스를 만들고 제어하는 대표적인 시스템 콜입니다.
한눈에 보기
- 응용 프로그램은 보안과 안정성 때문에 디스크, 메모리, 다른 프로세스를 직접 건드릴 수 없습니다. 파일 읽기, 새 프로세스 만들기 같은 일은 반드시 운영체제를 거쳐야 합니다.
- 비유: 은행 창구와 같습니다. 손님(응용 프로그램)은 금고(하드웨어)에 직접 들어갈 수 없고, 창구 직원(커널)에게 신청서(시스템 콜)를 내면 직원이 대신 처리해 줍니다.
- 시스템 콜이 호출되면 CPU는 권한이 제한된 사용자 모드에서 모든 권한을 가진 커널 모드로 바뀌어 운영체제 코드를 실행하고, 끝나면 다시 사용자 모드로 돌아옵니다. 이 전환은 인터럽트 글의 소프트웨어 인터럽트 방식으로 일어납니다.

그림 출처: Wikimedia Commons (Bobbo, CC BY-SA 3.0)
그림에서 응용 프로그램(Applications)과 커널(Kernel) 사이의 화살표가 바로 시스템 콜입니다. 응용 프로그램은 CPU, 메모리, 장치(Devices)에 직접 닿지 못하고 반드시 커널을 거칩니다.
응용 프로그램 (사용자 모드)
│ fork(), read(), write() ... 호출
▼
─────────────── 경계 (시스템 콜) ───────────────
▼
커널 (커널 모드): 실제 작업 수행 후 결과를 돌려줌이 글에서는 프로세스와 관련된 세 가지를 다룹니다.
| 시스템 콜 | 하는 일 |
|---|---|
fork() | 지금 프로세스를 복제해서 새 프로세스(자식)를 만듦 |
exec() | 지금 프로세스의 내용을 다른 프로그램으로 통째로 바꿈 |
wait() | 부모가 자기가 만든 자식 프로세스가 끝날 때까지 기다림 |
fork와 exec는 새 프로세스를 만드는 일과 관련 있고, wait는 부모(Parent) 프로세스가 만든 자식(Child) 프로세스가 끝날 때까지 기다리는 명령입니다. 예제는 Python의 os 모듈로 작성했습니다. Python의 os.fork(), os.wait(), os.execvp()는 같은 이름의 운영체제 시스템 콜을 그대로 부르는 함수입니다. (macOS, Linux에서 동작하고 Windows에는 fork가 없습니다.)
fork: 프로세스 복제하기
fork()는 새로운 프로세스를 만들 때 씁니다. 그런데 방식이 조금 독특합니다. 새 프로그램을 처음부터 시작하는 것이 아니라, 지금 실행 중인 자기 자신을 그대로 복제합니다.
- 비유: 복사기로 숙제를 복사하는데, 지금 쓰고 있던 줄까지 똑같이 복사되는 것과 같습니다. 복사본도 처음부터 다시 쓰지 않고, 원본이 멈춘 바로 그 줄부터 이어서 씁니다.
import os
print(f"pid : {os.getpid()}", flush=True)
rc = os.fork() # 여기서 프로세스가 둘로 갈라진다
if rc == 0: # (1) 자식: fork()가 0을 돌려줌
print(f"child (pid : {os.getpid()})", flush=True)
os._exit(0)
else: # (2) 부모: fork()가 자식의 PID를 돌려줌
print(f"parent of {rc} (pid : {os.getpid()})", flush=True)실행 결과:
pid : 33045
parent of 33049 (pid : 33045)
child (pid : 33049)- PID(Process ID)는 프로세스 식별 번호입니다. UNIX 계열 시스템에서는 프로세스에게 명령을 내릴 때 이 번호를 씁니다. (PID 값은 실행할 때마다 달라집니다.)
fork()가 실행되는 순간 프로세스가 하나 더 생깁니다. 새로 생긴 프로세스(자식)는fork를 호출한 프로세스(부모)와 거의 똑같은 복사본을 갖습니다.- 운영체제는 똑같은 프로그램 2개가 돌고 있고, 둘 다 "이제
fork()가 값을 돌려줄 차례"라고 봅니다. 그래서 자식은 프로그램 처음부터가 아니라fork()가 끝난 바로 다음 줄(if 문)부터 실행합니다. 첫 줄의pid :가 한 번만 찍힌 이유입니다.
부모와 자식을 구분하는 방법: 돌려받는 값
완전히 똑같은 복사본이라면 둘이 할 일을 나눌 수 없겠죠. 차이점은 fork()가 돌려주는 값입니다.
| 누가 | fork()가 돌려주는 값 |
|---|---|
| 부모 | 새로 만든 자식의 PID (양수) |
| 자식 | 0 |
| 실패한 경우 | C에서는 -1 (Python에서는 OSError 예외가 발생) |
이 차이 덕분에 if rc == 0:처럼 부모와 자식이 서로 다른 일을 하도록 나눌 수 있습니다. 아주 유용한 방식입니다.
실행 순서는 보장되지 않는다
부모와 자식 중 누가 먼저 실행될지는 확신할 수 없습니다(non-deterministic). 이것은 CPU 스케줄러가 정하는 일입니다. 그래서 같은 코드를 다시 실행하면 아래처럼 자식이 먼저 출력될 수도 있습니다.
pid : 29146
child (pid : 29147)
parent of 29147 (pid : 29146)복사본이라서 메모리는 따로 쓴다
자식은 부모의 메모리를 복사한 것이라, 자식이 변수를 바꿔도 부모 쪽 값은 바뀌지 않습니다. 스레드가 메모리를 공유하는 것과 다른 점입니다. (프로세스와 스레드 참고)
import os
count = 10
rc = os.fork()
if rc == 0:
count += 1 # 자식이 자기 복사본을 바꿈
print(f"자식의 count = {count}", flush=True)
os._exit(0)
else:
os.wait()
print(f"부모의 count = {count}") # 부모 쪽은 그대로실행 결과:
자식의 count = 11
부모의 count = 10실제로는
fork()순간에 메모리를 전부 복사하면 느리기 때문에, 요즘 운영체제는 일단 같은 메모리를 함께 보다가 어느 한쪽이 값을 고치는 순간에만 그 부분을 복사합니다. 이것을 Copy-on-Write(쓸 때 복사) 라고 합니다. 겉으로 보이는 결과는 위와 같습니다.
주의: 출력 버퍼까지 복사된다
화면 출력은 보통 바로 나가지 않고 버퍼라는 임시 공간에 모였다가 한꺼번에 나갑니다. fork()는 이 버퍼까지 복사하기 때문에, 출력하지 못하고 버퍼에 남아 있던 내용이 두 번 찍힐 수 있습니다. 위 예제에서 flush=True(버퍼를 바로 비우기)를 붙인 이유입니다.
import os
print("안녕", end="") # 줄바꿈도 flush도 없음 -> 아직 화면이 아니라 버퍼에만 있음
rc = os.fork() # 버퍼까지 통째로 복사된다
if rc == 0:
print(" / 자식")
else:
os.wait()
print(" / 부모")실행 결과 (python3 buf.py | cat처럼 출력을 파이프로 보낸 경우):
안녕 / 자식
안녕 / 부모"안녕"은 한 번만 출력했는데 두 번 찍혔습니다. C의 printf도 마찬가지입니다. 터미널에 출력할 때는 줄바꿈(\n)이 없을 때, 파이프나 파일로 출력할 때는 줄바꿈이 있어도 버퍼에 남아 있을 수 있습니다. 그래서 fork() 직전에는 fflush(stdout)으로 버퍼를 비워 두는 것이 안전합니다.
wait: 자식이 끝날 때까지 기다리기
wait()는 자식 프로세스가 끝날 때까지 부모를 멈춰 세우는 시스템 콜입니다. 앞의 예제에 os.wait() 한 줄만 추가해 보겠습니다.
import os
print(f"pid : {os.getpid()}", flush=True)
rc = os.fork()
if rc == 0:
print(f"child (pid : {os.getpid()})", flush=True)
os._exit(0)
else:
wc, status = os.wait() # 자식이 끝날 때까지 여기서 멈춤
print(f"parent of {rc} (wc : {wc} / pid : {os.getpid()})", flush=True)실행 결과:
pid : 33050
child (pid : 33054)
parent of 33054 (wc : 33054 / pid : 33050)wait()는 끝난 자식의 PID(wc)와 종료 상태(status)를 돌려줍니다. 그래서wc와rc가 같은 값입니다.- 부모가 먼저 CPU를 받더라도,
wait()는 자식이 끝나기 전에는 돌아오지 않습니다. 그래서 자식의 출력이 항상 부모의 마지막 출력보다 먼저 나옵니다. 실행 순서가 들쭉날쭉하던 문제를wait로 정리할 수 있습니다. - 비유: 부모가 아이를 심부름 보내고 현관에서 기다리는 것과 같습니다. 아이가 돌아와 "다녀왔어요(종료 상태)"라고 말해야 부모가 다음 일을 합니다.
wait에는 또 하나 중요한 역할이 있습니다. 자식이 끝나도 운영체제는 부모가 종료 상태를 받아 갈 때까지 자식의 정보(PCB)를 지우지 않고 남겨 둡니다. 이렇게 끝났지만 아직 회수되지 않은 프로세스를 좀비 프로세스라고 합니다. 부모가wait로 회수해야 깨끗이 정리됩니다. PCB는 PCB와 컨텍스트 스위칭에서 다룹니다.
exec: 다른 프로그램으로 갈아타기
fork()만 쓰면 부모와 똑같은 프로그램을 여러 번 돌릴 때만 쓸 수 있습니다. 자식이 부모와 전혀 다른 프로그램을 실행하게 하고 싶을 때 exec를 씁니다.
import os
print(f"pid : {os.getpid()}", flush=True)
rc = os.fork()
if rc == 0:
print(f"child (pid : {os.getpid()})", flush=True)
# wc 명령으로 memo.txt의 줄 수 / 단어 수 / 글자 수를 센다
os.execvp("wc", ["wc", "memo.txt"])
print("this shouldn't print out") # exec가 성공하면 절대 실행되지 않음
else:
wc, status = os.wait()
print(f"parent of {rc} (wc : {wc} / pid : {os.getpid()})", flush=True)실행 결과 (memo.txt는 3줄, 6단어, 35바이트짜리 파일):
pid : 33055
child (pid : 33059)
3 6 35 memo.txt
parent of 33059 (wc : 33059 / pid : 33055)execvp(실행할 프로그램, 인자 목록)은 실행 파일을 찾아서 지금 프로세스의 내용을 그 프로그램으로 통째로 덮어씁니다.- 코드 영역에 새 프로그램의 코드를 읽어 오고, 데이터·힙·스택 영역도 새 프로그램에 맞게 새로 만들어집니다. (메모리 구조 참고)
- 인자 목록의 첫 번째 값은 관례상 프로그램 이름(
"wc")이고, 그 뒤가 실제로 넘겨줄 인자("memo.txt")입니다.
- 새 프로세스를 만드는 것이 아닙니다. PID는 그대로(위에서 33059)이고, 그 프로세스가 하던 일만
wc프로그램으로 바뀝니다. - 원래 코드가 사라졌으니
execvp()다음 줄은 실행되지 않습니다."this shouldn't print out"이 출력되지 않은 이유입니다. (exec가 실패했을 때만 다음 줄로 넘어갑니다.) - 비유: 같은 배우(PID)가 무대 위에서 대본(프로그램)만 완전히 다른 작품으로 바꿔 드는 것과 같습니다.
fork + exec + wait = 셸이 명령을 실행하는 방법
터미널에서 ls를 입력하면 셸은 바로 이 세 가지를 조합합니다.
셸(부모)
│ fork() → 자신을 복제해 자식을 만듦
├─────────────────────────┐
│ │ 자식
│ wait()로 기다림 │ exec("ls") → ls 프로그램으로 변신
│ │ ls 실행 후 종료
│ ◀────────────────────────┘
▼
다음 명령 프롬프트 표시fork와 exec를 따로 나눠 둔 덕분에, 셸은 그 사이에 자식의 입출력 방향을 바꾸는 등(예: ls > out.txt) 준비 작업을 할 수 있습니다.
참고: 원래 C 코드
시스템 콜은 원래 C 언어 함수로 제공됩니다. 위 Python 예제와 같은 동작의 C 코드입니다.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
printf("pid : %d\n", (int) getpid());
fflush(stdout); // fork 전에 출력 버퍼 비우기
int rc = fork();
if (rc < 0) { // fork 실패
exit(1);
} else if (rc == 0) { // 자식
printf("child (pid : %d)\n", (int) getpid());
fflush(stdout); // exec 전에도 비우기 (안 그러면 위 출력이 사라질 수 있음)
char *myargs[] = {"wc", "memo.txt", NULL}; // 마지막은 NULL로 끝을 표시
execvp(myargs[0], myargs);
printf("this shouldn't print out\n"); // exec 성공 시 실행 안 됨
} else { // 부모
int wc = wait(NULL);
printf("parent of %d (wc : %d / pid : %d)\n", rc, wc, (int) getpid());
}
return 0;
}정리
| 개념 | 비전공자 버전 요약 |
|---|---|
| 시스템 콜 | 프로그램이 운영체제 창구에 내는 공식 신청서 |
| 사용자 모드 / 커널 모드 | 손님 구역과 직원 전용 구역. 시스템 콜 때만 직원 구역으로 넘어감 |
| fork | 지금 프로세스를 쓰던 줄까지 그대로 복사. 부모는 자식 PID, 자식은 0을 받음 |
| 실행 순서 | 부모와 자식 중 누가 먼저 돌지는 스케줄러 마음 |
| Copy-on-Write | 일단 같이 보다가 고칠 때만 복사해서 빠르게 fork |
| wait | 자식이 끝날 때까지 부모가 기다리고, 끝난 자식을 정리(회수) |
| 좀비 프로세스 | 끝났는데 부모가 아직 회수하지 않은 프로세스 |
| exec | PID는 그대로 두고 프로그램 내용만 통째로 바꿔 끼움 |
| fork + exec + wait | 셸이 명령어 하나를 실행하는 기본 순서 |
