OSTEP Process API 학습노트: fork·exec·wait와 pipe가 이어지는 방식

반응형

OSTEP의 「Interlude: Process API」를 읽으며 fork(), exec(), wait()을 각각 외우기보다 shell이 한 명령을 실행하는 흐름으로 다시 묶었다.

부모가 fork로 자식을 만든다
  → 자식이 file descriptor를 정리한다
  → 자식이 exec로 새 program을 실행한다
  → 부모가 waitpid로 종료 상태를 회수한다

fork: 현재 실행 흐름을 둘로 나눈다

fork()가 성공하면 parent에는 child PID를, child에는 0을 반환한다. 두 process가 같은 다음 instruction부터 독립적으로 계속 실행되므로 반환값으로 역할을 나눈다.

pid_t child = fork();

if (child == -1) {
    perror("fork");
    return 1;
}

if (child == 0) {
    /* child */
} else {
    /* parent: child contains the child PID */
}

“주소 공간 전체를 즉시 물리 복사한다”는 설명은 현대 Unix-like OS의 일반적인 구현과 다르다. Linux의 fork()는 page table을 복제하고 memory page는 copy-on-write로 공유하다가 어느 쪽이 쓸 때 분리한다. 이 구현 세부와 POSIX API 의미는 구분해야 한다.

열려 있던 file descriptor도 child에 복제되며, parent와 child의 descriptor는 같은 open file description을 가리킬 수 있다. 그래서 file offset과 status flag를 공유하는 경우가 생긴다.

exec: 새 process를 더 만드는 호출이 아니다

execve() 계열은 현재 process image를 새 program으로 교체한다. 성공하면 원래 코드의 다음 줄로 돌아오지 않으며 PID도 그대로다.

char *argv[] = {"/usr/bin/grep", "-n", "error", "app.log", NULL};
char *envp[] = {"PATH=/usr/bin:/bin", NULL};

execve(argv[0], argv, envp);
perror("execve");
_exit(127);

실패 뒤에는 exit()보다 _exit()을 쓰는 편이 안전하다. fork() 이전에 parent가 쌓아 둔 stdio buffer를 child가 다시 flush하는 문제를 피할 수 있기 때문이다.

execvp()처럼 PATH를 검색하는 변형은 편하지만, privileged context나 통제된 runner에서는 의도하지 않은 binary를 실행할 수 있다. absolute path와 정제한 environment를 사용하고, child에 넘기지 않을 descriptor에는 O_CLOEXEC 또는 FD_CLOEXEC을 설정한다.

waitpid: 종료한 child를 회수한다

child가 종료해도 parent가 상태를 읽을 때까지 kernel에는 최소한의 process entry가 남는다. 이것이 zombie다.

int status;
pid_t done;

do {
    done = waitpid(child, &status, 0);
} while (done == -1 && errno == EINTR);

if (done == -1) {
    perror("waitpid");
} else if (WIFEXITED(status)) {
    printf("exit=%d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
    printf("signal=%d\n", WTERMSIG(status));
}

parent가 먼저 종료한 orphan은 단순히 “systemd가 항상 입양한다”라고 외우기보다, Linux에서는 가장 가까운 subreaper나 PID 1로 reparent될 수 있다고 이해하는 편이 정확하다.

dup2와 pipe로 shell pipeline 만들기

process가 시작할 때 일반적으로 0, 1, 2는 stdin, stdout, stderr다. dup2()로 이 번호가 가리키는 대상을 바꾸고 exec()하면 새 program은 자신이 pipe에 연결되었다는 사실을 몰라도 된다.

다음은 cat app.log | grep error의 핵심 구조다.

int fds[2];
if (pipe(fds) == -1) {
    perror("pipe");
    return 1;
}

pid_t producer = fork();
if (producer == 0) {
    dup2(fds[1], STDOUT_FILENO);
    close(fds[0]);
    close(fds[1]);
    execl("/usr/bin/cat", "cat", "app.log", NULL);
    _exit(127);
}

pid_t consumer = fork();
if (consumer == 0) {
    dup2(fds[0], STDIN_FILENO);
    close(fds[0]);
    close(fds[1]);
    execl("/usr/bin/grep", "grep", "error", NULL);
    _exit(127);
}

close(fds[0]);
close(fds[1]);
waitpid(producer, NULL, 0);
waitpid(consumer, NULL, 0);

사용하지 않는 pipe end를 모든 process가 닫아야 한다. 쓰기 end가 하나라도 열린 채 남아 있으면 reader가 EOF를 받지 못해 pipeline이 끝나지 않을 수 있다. 실제 코드에서는 각 fork, dup2, close, exec, waitpid의 실패도 모두 처리해야 한다.

Signal handler는 최소한으로 둔다

SIGCHLD handler 안에서 logging, allocation, lock 획득 같은 일반 작업을 하면 async-signal-safety 문제가 생긴다. handler에서는 sig_atomic_t flag 설정이나 self-pipe write처럼 안전한 신호만 남기고, 실제 회수는 main event loop에서 수행하는 설계가 이해하기 쉽다.

Linux 전용 프로그램이라면 signalfd로 signal을 file descriptor event로 다루는 선택지도 있다. 어느 방식을 쓰든 signal 도착과 상태 확인 사이의 race를 고려하고 waitpid(-1, ..., WNOHANG)을 더 이상 회수할 child가 없을 때까지 반복한다.

Runner와 worker 운영에 연결되는 부분

  • command 실행은 fork → fd 정리 → exec → wait의 실패 단계를 각각 기록한다.
  • stdout·stderr pipe를 소비하지 않으면 child가 pipe buffer에서 막힐 수 있다.
  • timeout은 SIGTERM으로 정상 종료 기회를 주고, 정한 유예가 지난 뒤에만 SIGKILL을 검토한다.
  • process group을 사용하지 않으면 child가 다시 만든 grandchild가 남을 수 있다.
  • PID 수, zombie 수, open FD, child 실행 시간과 exit status를 함께 관찰한다.

이 API들의 장점은 단순한 조합성이다. fork()가 실행 주체를 만들고, exec()가 program을 교체하며, file descriptor가 입출력을 연결하고, waitpid()가 생애주기를 닫는다.

공식 문서

반응형
KEEP READING
카테고리 전체 보기 →

댓글