Skip to content

시스템 콜 (fork · wait · exec) ​

시스템 콜(System Call)은 응용 프로그램이 운영체제(커널)에게 "이 일 좀 해 줘" 하고 공식적으로 부탁하는 창구입니다. 그중 fork(), exec(), wait()는 프로세스를 만들고 제어하는 대표적인 시스템 콜입니다.

한눈에 보기 ​

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

응용 프로그램은 커널을 통해서만 CPU, 메모리, 장치에 접근하는 구조

그림 출처: Wikimedia Commons (Bobbo, CC BY-SA 3.0)

그림에서 응용 프로그램(Applications)과 커널(Kernel) 사이의 화살표가 바로 시스템 콜입니다. 응용 프로그램은 CPU, 메모리, 장치(Devices)에 직접 닿지 못하고 반드시 커널을 거칩니다.

text
 응용 프로그램 (사용자 모드)
      │  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()는 새로운 프로세스를 만들 때 씁니다. 그런데 방식이 조금 독특합니다. 새 프로그램을 처음부터 시작하는 것이 아니라, 지금 실행 중인 자기 자신을 그대로 복제합니다.

  • 비유: 복사기로 숙제를 복사하는데, 지금 쓰고 있던 줄까지 똑같이 복사되는 것과 같습니다. 복사본도 처음부터 다시 쓰지 않고, 원본이 멈춘 바로 그 줄부터 이어서 씁니다.
python
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)

실행 결과:

text
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 스케줄러가 정하는 일입니다. 그래서 같은 코드를 다시 실행하면 아래처럼 자식이 먼저 출력될 수도 있습니다.

text
pid : 29146
child (pid : 29147)
parent of 29147 (pid : 29146)

복사본이라서 메모리는 따로 쓴다 ​

자식은 부모의 메모리를 복사한 것이라, 자식이 변수를 바꿔도 부모 쪽 값은 바뀌지 않습니다. 스레드가 메모리를 공유하는 것과 다른 점입니다. (프로세스와 스레드 참고)

python
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}")  # 부모 쪽은 그대로

실행 결과:

text
자식의 count = 11
부모의 count = 10

실제로는 fork() 순간에 메모리를 전부 복사하면 느리기 때문에, 요즘 운영체제는 일단 같은 메모리를 함께 보다가 어느 한쪽이 값을 고치는 순간에만 그 부분을 복사합니다. 이것을 Copy-on-Write(쓸 때 복사) 라고 합니다. 겉으로 보이는 결과는 위와 같습니다.

주의: 출력 버퍼까지 복사된다 ​

화면 출력은 보통 바로 나가지 않고 버퍼라는 임시 공간에 모였다가 한꺼번에 나갑니다. fork()는 이 버퍼까지 복사하기 때문에, 출력하지 못하고 버퍼에 남아 있던 내용이 두 번 찍힐 수 있습니다. 위 예제에서 flush=True(버퍼를 바로 비우기)를 붙인 이유입니다.

python
import os

print("안녕", end="")      # 줄바꿈도 flush도 없음 -> 아직 화면이 아니라 버퍼에만 있음

rc = os.fork()            # 버퍼까지 통째로 복사된다
if rc == 0:
    print(" / 자식")
else:
    os.wait()
    print(" / 부모")

실행 결과 (python3 buf.py | cat처럼 출력을 파이프로 보낸 경우):

text
안녕 / 자식
안녕 / 부모

"안녕"은 한 번만 출력했는데 두 번 찍혔습니다. C의 printf도 마찬가지입니다. 터미널에 출력할 때는 줄바꿈(\n)이 없을 때, 파이프나 파일로 출력할 때는 줄바꿈이 있어도 버퍼에 남아 있을 수 있습니다. 그래서 fork() 직전에는 fflush(stdout)으로 버퍼를 비워 두는 것이 안전합니다.

wait: 자식이 끝날 때까지 기다리기 ​

wait()는 자식 프로세스가 끝날 때까지 부모를 멈춰 세우는 시스템 콜입니다. 앞의 예제에 os.wait() 한 줄만 추가해 보겠습니다.

python
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)

실행 결과:

text
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를 씁니다.

python
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바이트짜리 파일):

text
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를 입력하면 셸은 바로 이 세 가지를 조합합니다.

text
셸(부모)
  │ fork()  → 자신을 복제해 자식을 만듦
  ├─────────────────────────┐
  │                         │ 자식
  │ wait()로 기다림          │ exec("ls") → ls 프로그램으로 변신
  │                         │ ls 실행 후 종료
  │ ◀────────────────────────┘
  ▼
다음 명령 프롬프트 표시

fork와 exec를 따로 나눠 둔 덕분에, 셸은 그 사이에 자식의 입출력 방향을 바꾸는 등(예: ls > out.txt) 준비 작업을 할 수 있습니다.

참고: 원래 C 코드 ​

시스템 콜은 원래 C 언어 함수로 제공됩니다. 위 Python 예제와 같은 동작의 C 코드입니다.

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자식이 끝날 때까지 부모가 기다리고, 끝난 자식을 정리(회수)
좀비 프로세스끝났는데 부모가 아직 회수하지 않은 프로세스
execPID는 그대로 두고 프로그램 내용만 통째로 바꿔 끼움
fork + exec + wait셸이 명령어 하나를 실행하는 기본 순서