[OS] 가상화의 세계 - 프로세스 API
아주 쉬운 세가지 이야기 - 가상화의 세계
프로세스 API 정리: fork(), wait(), exec()
운영체제에서 프로세스는 단순히 실행 중인 프로그램을 의미하는 것이 아니라, 운영체제가 관리하는 실행 단위다. 프로세스를 실행하려면 CPU 시간, 메모리 공간, 입출력 장치와 같은 자원이 필요하고, 운영체제는 이러한 자원을 관리하면서 여러 프로세스가 동시에 실행되는 것처럼 보이게 만든다.
그렇다면 사용자가 새로운 프로그램을 실행하고 싶을 때 운영체제는 어떤 기능을 제공해야 할까? 해당 장에서 이 질문을 중심으로 Unix 시스템에서 프로세스를 생성하고 제어하는 방법을 설명한다. Unix는 프로세스를 만들기 위해 fork()를 사용하고, 다른 프로그램을 실행하기 위해 exec()을 사용하며, 부모 프로세스가 자식 프로세스의 종료를 기다릴 때 wait()를 사용한다.
fork() 시스템 콜
fork()는 현재 실행 중인 프로세스를 복사하여 새로운 자식 프로세스를 만드는 시스템 콜이다. 즉, 자식 프로세스가 완전히 새로운 코드에서 시작하는 것이 아니라, 부모 프로세스가 fork()를 호출한 그 지점 이후부터 실행을 이어간다는 것이다.
부모 프로세스 실행 중
→ fork() 호출
→ 자식 프로세스 생성
→ 부모와 자식이 fork() 이후 코드부터 실행
자식 프로세스는 부모 프로세스의 복사본처럼 만들어지지만, 부모와 완전히 같은 프로세스는 아니다. 자식 프로세스는 자신의 주소 공간, 자신의 레지스터, 자신의 프로그램 카운터 값을 가진다. 그리고 fork() 의 반환 값이 부모와 자식에게 다르게 나타난다. 부모 프로세스는 생성된 자식 프로세스의 PID를 반환하고, 자식 프로세스는 0을 반환한다.
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main() {
pid_t pid;
pid = fork();
if (pid == 0) { // 자식 프로세스
printf("이것은 자식 프로세스입니다.\n");
} else if (pid > 0) { // 부모 프로세스
printf("이것은 부모 프로세스입니다.\n");
} else { // fork 실패
printf("fork() 실패\n");
}
return 0;
}
fork() 반환 값 < 0 → 프로세스 생성 실패
fork() 반환 값 == 0 → 자식 프로세스
fork() 반환 값 > 0 → 부모 프로세스
이 반환 값의 차이를 이용하면 같은 코드 안에서도 부모와 자식이 서로 다른 작업을 수행하도록 만들 수 있다.
위 프로그램의 출력 결과가 항상 동일하지는 않다. fork()를 호출하여 부모 프로세스와 자식 프로세스가 생겼을 경우 누가 먼저 실행될지는 정해져있지 않기 때문이다.
운영체제의 CPU 스케줄러가 어떤 프로세스를 먼저 실행할지 결정하기 때문이다.
이처럼 실행 결과가 매번 같지 않을 수 있는 성질을 비결정성(nondeterminism) 이라고 한다. 운영체제가 여러 프로세스를 번갈아 실행하기 때문에, 프로세스의 실행 순서는 상황에 따라 달라질 수 있다.
용어 설명
프로세스: 실행 중인 프로그램의 인스턴스로, 자체적인 메모리 공간과 시스템 자원을 가진다.
시스템 콜: 운영체제에게 특정 작업을 요청하기 위해 프로그램이 사용하는 인터페이스
PID(프로세스 식별자): 각 프로세스를 고유하게 식별하기 위해 운영체제가 할당하는 번호
wait() 시스템 콜
fork()만 사용하면 부모와 자식의 실행 순서를 보장하기 어렵다. 부모가 먼저 실행될 수도 있고, 자식이 먼저 실행될 수 있다. 이 문제를 해결하기 위해 사용하는 시스템 콜이 wait() 이다.
wait()는 부모 프로세스가 자식 프로세스의 종료를 기다리기 위해 사용하는 시스템 콜이다. 부모 프로세스가 wait()를 호출하면, 자식 프로세스가 종료될 때까지 부모 프로세스는 실행을 멈춘다. 이후 자식 프로세스가 종료되면 wait()가 반환되고, 부모 프로세스는 다음 코드를 실행한다.
부모 프로세스가 fork() 호출
→ 자식 프로세스 생성
→ 부모 프로세스가 wait() 호출
→ 부모는 자식이 끝날 때까지 대기
→ 자식 프로세스 종료
→ 부모 프로세스 다시 실행
wait()를 사용하면 부모 프로세스가 자식 프로세스보다 먼저 끝나거나, 부모가 자식의 결과를 기다리지 않고 바로 다음 작업을 수행하는 상황을 제어할 수 있다.
즉, wait()는 프로세스의 실행 순서를 직접 완전히 통제하는 기능이라기보다는, 부모가 자식의 종료를 기다리게 만드는 기능이다.
exec() 시스템 콜
fork()는 현재 프로세스를 복사해서 자식 프로세스를 만든다. 하지만 실제로 우리가 쉘에서 명령어를 실행할 때는 부모 프로세스의 복사본을 실행하고 싶은 것이 아니다. 예를 들어 터미널에서 ls, cat, wc 같은 명령어를 입력하면, 쉘의 복사본이 실행되는 것이 아니라 해당 명령어 프로그램이 실행되어야 한다.
이때 사용하는 것이 exec() 시스템 콜이다.
exec()는 현재 실행 중인 프로세스의 실행 이미지를 새로운 프로그램으로 교체한다. 실행 파일의 코드와 정적 데이터를 읽어 현재 프로세스의 코드 영역과 데이터 영역을 덮어 쓰고, 힙과 스택 등도 새 프로그램 실행에 맞게 다시 초기화한다. 중요한 점은 exec()가 새로운 프로세스를 만드는 것이 아니다. 기존 프로세스가 다른 프로그램으로 바뀌어 실행되는 것이다.
자식 프로세스 생성
→ exec() 호출
→ 자식 프로세스의 실행 내용이 새 프로그램으로 교체
→ 새 프로그램 실행
예를 들어 자식 프로세스가 exec()를 통해 wc 프로그램을 실행하면, 자식 프로세스는 더 이상 원래 프로그램의 코드를 실행하지 않고 wc 프로그램으로 바뀌어 실행된다. exec()가 성공하면 기존 프로그램으로 다시 돌아오지 않는다.
왜 fork()와 exec()를 분리했을까?
처음 보면 이런 의문이 들 수 있다. 새로운 프로그램을 실행하고 싶다면 그냥 “새 프로세스를 만들고 바로 실행”하는 하나의 API를 제공하면 될 것 같은데, Unix는 왜 fork()와 exec()를 분리했을까?
그 이유는 쉘을 구현할 때 이 분리 구조가 매우 유용하기 때문이다. 쉘은 먼저 fork()로 자식 프로세스를 만든 뒤, 자식 프로세스에서 exec()를 호출하기 전에 여러 설정 작업을 할 수 있다.
사용자가 명령어 입력
→ 쉘이 fork()로 자식 프로세스 생성
→ 자식 프로세스가 exec()로 명령어 프로그램 실행
→ 부모 프로세스인 쉘은 wait()로 자식 종료 대기
→ 자식 프로세스 종료
→ 쉘이 다시 프롬프트 출력
예를 들어 사용자가 wc p3.c 명령어를 입력했다고 가정하면 쉘은 먼저 자기 자신을 복사해서 자식 프로세스를 만들고, 그 자식 프로세스가 exec()를 통해 wc 프로그램으로 바뀌어 실행되도록 한다.
부모 프로세스인 쉘은 wait()를 호출해 wc 명령어가 끝날 때까지 기다린다. 명령어 실행이 끝나면 쉘은 다시 프롬프트를 출력하고 다음 명령어를 기다린다.
이 구조의 핵심은 fork()와 exec() 사이에 여유 공간이 생긴다는 점이다. 자식 프로세스가 만들어진 뒤 실제 프로그램으로 바뀌기 전에 입출력 재지정 같은 준비 작업을 할 수 있다.
정리
프로세스 API의 핵심은 운영체제가 프로세스를 어떻게 생성하고 제어하는지 이해하는 것이다. Unix에서는 프로세스를 생성할 때 fork()를 사용하고, 자식 프로세스가 다른 프로그램을 실행해야 할 때 exec()를 사용한다. 그리고 부모 프로세스가 자식 프로세스의 종료를 기다려야 할 때 wait()를 사용한다.
fork() → 자식 프로세스 생성
exec() → 자식 프로세스가 새로운 프로그램 실행
wait() → 부모 프로세스가 자식 프로세스 종료 대기
이 구조 덕분에 쉘은 명령어 실행, 입출력 재지정, 파이프와 같은 기능을 유연하게 구현할 수 있다.