[Daily morning study] fork()와 exec() 시스템 콜 - 프로세스 생성 메커니즘
#daily morning study
프로세스 생성의 두 축: fork()와 exec()
리눅스에서 새 프로세스를 만드는 방법은 크게 두 가지 시스템 콜이 담당한다.
fork()— 현재 프로세스를 복제해서 자식 프로세스를 만든다.exec()— 현재 프로세스의 이미지를 새 프로그램으로 교체한다.
이 두 콜을 조합하면 “새 프로그램을 실행하는 새 프로세스”를 만들 수 있다. 쉘이 명령어를 실행할 때 바로 이 패턴을 쓴다.
fork() 동작 방식
#include <unistd.h>
pid_t pid = fork();
if (pid == 0) {
// 자식 프로세스: fork() 반환값이 0
} else if (pid > 0) {
// 부모 프로세스: fork() 반환값이 자식의 PID
} else {
// 에러: -1 반환
}
fork()를 호출하면 커널은 현재 프로세스의 복사본을 만든다. 복사 범위는 다음과 같다.
| 복사되는 것 | 복사되지 않는 것 |
|---|---|
| 가상 주소 공간 (코드/데이터/스택/힙) | 파일 락 |
| 열려 있는 파일 디스크립터 | 자식의 pending 시그널 |
| 시그널 핸들러 설정 | 비동기 I/O 작업 |
| 환경 변수, 프로세스 그룹 | 스레드 (주 스레드만 복사) |
fork() 이후 부모와 자식은 같은 코드를 공유하지만, 별도의 가상 주소 공간을 갖는다. 한쪽이 변수를 수정해도 다른 쪽에는 영향이 없다.
Copy-on-Write (COW)
단순 복사라면 수십~수백 MB의 메모리를 모두 복사해야 해서 매우 비싸다. 실제로는 Copy-on-Write 기법을 사용한다.
fork()직후 부모와 자식은 같은 물리 페이지를 공유한다.- 페이지는 읽기 전용으로 표시된다.
- 어느 한쪽이 해당 페이지에 쓰기를 시도하면 그때 복사가 일어난다.
fork() 직후:
부모 VA → 물리 페이지 P (읽기 전용)
자식 VA → 물리 페이지 P (읽기 전용)
자식이 쓰기 시도:
→ Page Fault 발생
→ 커널이 P를 복사해서 P' 생성
자식 VA → 물리 페이지 P' (읽기/쓰기)
부모 VA → 물리 페이지 P (읽기 전용 → 읽기/쓰기로 복원)
exec()를 바로 호출하면 어차피 메모리를 교체하므로 COW 덕분에 불필요한 복사가 전혀 일어나지 않는다.
exec() 동작 방식
exec() 계열 함수는 하나의 시스템 콜(execve)을 감싸는 라이브러리 함수들이다.
#include <unistd.h>
// 가장 기본적인 형태
execve("/bin/ls", argv, envp);
// 편의 래퍼들
execl("/bin/ls", "ls", "-l", NULL);
execvp("ls", argv); // PATH에서 탐색
exec()가 호출되면:
- 커널이 실행 파일(ELF 등)을 읽는다.
- 현재 프로세스의 가상 주소 공간을 새 프로그램으로 완전히 교체한다.
- 코드, 데이터, 스택, 힙이 모두 새 프로그램의 것으로 대체된다.
- PID는 변하지 않는다.
- 성공하면 반환하지 않는다. 실패 시에만 -1을 반환한다.
exec() 이후에도 유지되는 것:
- PID, PPID
- 열려 있는 파일 디스크립터 (단,
FD_CLOEXEC플래그가 설정된 FD는 닫힘) - 환경 변수 (새로 지정하지 않으면)
- 시그널 마스크
fork-exec 패턴: 쉘의 동작 원리
쉘이 ls -l을 실행할 때 내부적으로 이렇게 동작한다.
// 쉘 내부 의사 코드
pid_t pid = fork();
if (pid == 0) {
// 자식 프로세스
// 입출력 리다이렉션이 필요하면 여기서 FD를 조작
execvp("ls", args);
// exec 성공 시 여기까지 오지 않음
exit(1); // exec 실패
} else {
// 부모(쉘)는 자식을 기다림
waitpid(pid, &status, 0);
}
fork와 exec 사이의 짧은 구간에서 자식 프로세스는 자신의 환경을 설정할 수 있다. 파일 디스크립터를 열거나 닫고, 시그널 핸들러를 초기화하고, 리다이렉션을 위해 dup2()를 호출하는 등의 작업이 여기서 일어난다.
vfork()
vfork()는 fork()의 최적화 버전이다.
- 자식이 exec() 또는 exit()를 호출할 때까지 부모가 블록된다.
- 자식은 부모의 주소 공간을 그대로 공유한다 (복사 없음).
주소 공간을 공유하므로 자식이 exec() 전에 부모의 변수를 건드리면 매우 위험하다. 현대 리눅스에서는 fork() + COW가 효율적이어서 vfork()의 실제 사용 사례는 드물다.
posix_spawn()
fork() + exec() 패턴의 POSIX 표준 대안이다.
#include <spawn.h>
posix_spawn(&pid, "/bin/ls", NULL, NULL, argv, envp);
임베디드 환경처럼 fork()가 비싸거나 불가능한 곳에서 사용한다. 내부적으로는 fork() + exec()를 쓰거나, 플랫폼이 제공하는 더 효율적인 메커니즘을 사용할 수 있다.
정리
| 항목 | fork() | exec() |
|---|---|---|
| 역할 | 프로세스 복제 | 프로세스 이미지 교체 |
| PID | 새 PID 생성 | 변경 없음 |
| 메모리 | COW로 복사 최적화 | 완전히 교체 |
| 반환 | 부모: 자식 PID, 자식: 0 | 성공 시 반환 없음 |
| 주 용도 | 자식 프로세스 생성 | 새 프로그램 실행 |
프로세스 생성에서 fork()와 exec()를 분리한 이유는 그 사이에 자식 프로세스가 자신의 환경(FD, 시그널 등)을 자유롭게 설정할 수 있도록 하기 위해서다. 이 설계는 파이프, 리다이렉션, 데몬화 등을 매우 자연스럽게 구현할 수 있게 해준다.