1. Unix 시스템의 프로세스 생성 방식: fork ()
유닉스 시스템에서는 새로운 프로세스를 생성할 때 fork () 함수를 사용한다. 즉, 유닉스 시스템은 운영체제를 부팅할 때 처음 생성되는 프로세스 (PID 가 0인 init process) 하나만 직접 만들고 그 이후에 프로세스를 생성할 때는 호출 프로세스인 부모 프로세스를 클론하여 자식 프로세스를 만드는 방식으로 새로운 프로세스를 생성한다. (윈도우에서는 모든 프로세스를 직접 만든다고 한다.)
부모 프로세스가 fork ()를 통해 생성한 자식 프로세스는 부모 프로세스의 주소 공간을 거의 그대로 물려받는다. 그래서 부모 프로세스와 자식 프로세스 또는 자식 프로세스들끼리는 복잡한 IPC 기법 (InterProcess Communication) 없이 정보를 공유할 수 있다. 이것이 fork ()를 통한 프로세스 생성 방식의 장점이다.
fork () 함수의 특징: call once, return twice
fork () 함수는 한 번 호출될 때 리턴은 두 번 되는 특이한 함수이다. 한 번의 리턴은 호출 프로세스인 부모 프로세스로의 리턴이며 에러가 발생하지 않았다면 이때의 리턴 값은 새롭게 생성된 자식 프로세스의 PID이다. 다른 한 번의 리턴은 자식 프로세스로의 리턴이며 이때의 리턴 값은 0이다.
일반적으로 fork () 를 사용하는 코드를 보면, fork () 함수의 리턴 값이 부모 프로세스에서는 0보다 큰 양수의 정수 (PID)이고, 자식 프로세스에서는 항상 0이라는 점을 이용하여 부모 프로세스와 자식 프로세스에서 실행될 코드를 구분한다.
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
int main () {
pid_t pid;
/* Parent creates child */
if ((pid = fork ()) < 0)
perror ("Fork error");
else if (pid == 0) {
printf ("This is child process\n");
exit (0); /* Child terminates and send SIGCHLD to parent */
}
printf ("This is parent process\n");
exit (0); /* Parent terminates */
}
fork ()가 두 번 리턴됨에 따라 부모 프로세스와 자식 프로세스는 서로 다른 흐름을 가지게 되는데 이 두 흐름의 실행 순서는 예측할 수 없다.
2. 부모 프로세스와 자식 프로세스의 동기화 문제
부모 프로세스와 자식 프로세스의 실행 순서는 임의적이라서 예측이 불가하다. 이로 인해 어떤 시나리오에서는 부모 프로세스와 자식 프로세스가 잘못된 순서로 전역 자료구조에 접근하여 자료구조가 이상하게 관리되는 동기화 에러가 발생할 수 있다.
Unix shell에서의 프로세스 그룹 추상화 단위: Job
하나의 프로세스는 하나의 프로세스 그룹을 가지며, 프로세스 그룹은 하나 이상의 프로세스로 구성된다. Unix shell은 프로세스 그룹을 job으로 추상화하여 job 단위로 프로세스를 조작한다. foreground에는 최대 하나의 job이 실행될 수 있고 background에는 여러 개의 job이 실행될 수 있다. 사용자는 shell 명령어를 사용하여 프로세스를 job 단위로 background 또는 foreground로 보낼 수 있다. 또한 job 단위로 프로세스의 상태를 running, stopped, terminated로 변경할 수 있다.
메인 함수의 addjob과 SIGCHLD 시그널 핸들러의 deletejob 사이의 race condition
shell 프로그램이 전역 자료 구조인 jobs 라는 배열을 두어 shell에서 실행 중인 job의 목록을 관리한다고 해 보자. 이 shell 프로그램은 fork ()를 호출하여 자식 프로세스를 생성하고, addjob을 호출하여 새롭게 생성된 자식 프로세스를 jobs에 추가한다. 자식 프로세스가 종료되면 이를 감지한 시그널 핸들러는 deletejob을 호출하여 jobs에서 해당 자식 프로세스를 삭제한다.
부모 프로세스에서 fork ()를 호출했을 때 새롭게 생성된 자식 프로세스의 흐름과 호출 프로세스인 부모 프로세스의 흐름은 어떤 순서로 실행될지 예측할 수 없다. 만약 부모 프로세스가 addjob까지 실행한 다음에 자식 프로세스가 종료된다면 프로그램은 문제 없이 동작할 것이다. 그러나 어떤 시나리오에서는 부모 프로세스가 addjob을 호출하기도 전에 자식 프로세스가 먼저 실행되고 실행이 끝나서 일찍 종료되어 버릴 수 있다. 부모 프로세스가 자식 프로세스를 addjob하기 전에 자식 프로세스가 먼저 종료해버리면 job 목록이라는 전역 자료구조에는 동기화 에러가 발생한다. 부모 프로세스가 addjob을 수행하게 될 때 jobs에 이미 종료된 자식 프로세스가 추가되기 때문이다.
이렇게 부모 프로세스와 자식 프로세스가 동시에 실행됨에 따라 발생하는 동기화 에러를 해결하기 위해서는 메인 함수의 addjob과 핸들러의 deletejob 사이의 race condition을 제거해줘야 한다. 이는 메인 함수에서 fork ()를 수행하기 직전부터 부모 프로세스가 addjob을 하기 전까지 SIGCHLD 핸들러의 수행을 차단하는 방식, 다시 말해서 SIGCHLD 시그널을 차단 (= SIGCHLD를 차단하고 pending 상태로 두기) 하는 방식으로 실현 가능하다.
3. 자식 프로세스의 종료 관리하기
자식 프로세스가 부모 프로세스보다 먼저 종료되는 상황은 자연스러운 상황이다. 자식 프로세스는 exit할 때 부모 프로세스가 자신의 종료 여부를 추적할 수 있도록 완전히 종료되지 않고 exit status 정보를 남겨놓은 채로 시스템에 남아 있는다. 이러한 상태의 자식 프로세스를 좀비 프로세스라고 한다. 부모 프로세스는 이러한 좀비 프로세스를 잘 거두어서 자식 프로세스의 종료 여부를 확인하고 좀비 프로세스가 차지하고 있는 메모리를 완전히 해제해주어야 한다.
부모 프로세스는 자식 프로세스가 모두 종료하기 전까지 종료되지 않는다. 자식 프로세스를 모두 거두어야 하기 때문이다. 그런데 예기치 못하게 부모 프로세스가 자식 프로세스보다 먼저 종료된다면 init process가 자식 프로세스들을 입양한다. init process는 PID 1을 가진, 시스템이 시작할 때 커널에 의해 생성되어서 절대로 종료되지 않는, 모든 프로세스의 조상 프로세스이다.
참고 자료
- Randal E. Bryant & David R. O'Hallaron. (2015). Chapter 8. Exceptional Control Flow. Computer Systems: A Programmer's Perspective (3rd ed.). Pearson.
- 서울대학교 홍성수 교수님. 운영체제의 기초 04-4 process creation and termination [Video]. K-MOOC. https://www.kmooc.kr/view/course/detail/13580?tm=20250201211648.
- Carnegie Mellon University (2002). Lab Assignment L5: Writing Your Own Unix Shell. https://csapp.cs.cmu.edu/3e/shlab.pdf
[관련 포스팅]
- CSAPP '8장 예외적인 제어 플로우' 번역: (3/4) Process Control / (4/4) Signal, Nonlocal Jump
- KMOOC 운영체제의 기초 '04-4. process creation and termination' 정리: https://manythreedays.tistory.com/125
- CMU UnixShell Lab 과제 안내서 번역: https://manythreedays.tistory.com/144