Devin.KR

프로세스와 시그널 - fork·exec·wait 로 명령 실행하기

개발자KR 조회 1

이 장에서 배우는 것

소프트웨어를 개발하다 보면 우리가 만든 프로그램 안에서 다른 외부 프로그램을 실행해야 할 때가 많다. 앞 장에서 파일 디스크립터와 저수준 파일 입출력을 다루며 운영체제가 자원을 관리하는 방식을 살펴보았다. 이번 장에서는 운영체제가 실행 중인 프로그램, 즉 프로세스를 어떻게 생성하고 관리하는지 알아본다. C 표준 라이브러리가 제공하는 시스템 명령어 실행 함수는 편리하지만 제어가 어렵다. 우리는 운영체제가 제공하는 시스템 호출을 직접 사용하여 외부 명령을 더 정밀하게 제어하는 방법을 배운다.

또한 프로세스 밖에서 비동기적으로 날아오는 시그널(Signal)을 안전하게 처리하는 규약을 학습한다. 시그널은 언제 발생할지 알 수 없는 소프트웨어 인터럽트이므로, 이를 다룰 때는 메모리 안전성과 교착 상태를 방지하기 위한 특별한 주의가 필요하다.

  • 운영체제가 프로세스를 복제하는 메커니즘을 이해한다.
  • 복제된 프로세스의 메모리를 다른 프로그램으로 덮어쓰는 방법을 배운다.
  • 자식 프로세스의 종료를 기다리고 상태를 회수하여 좀비 프로세스를 방지한다.
  • 외부에서 전달되는 비동기 시그널을 안전하게 수신하고 처리하는 패턴을 익힌다.

문제 상황

도서관 대출 관리 프로그램을 운영 중이다. 시스템 관리자는 매일 자정, 혹은 관리자가 요청할 때마다 현재까지의 대출 목록 데이터를 압축하여 백업 파일로 남기려 한다. C 표준 라이브러리에는 외부 압축 프로그램을 실행할 수 있는 함수가 있다.

#include <stdlib.h>

void backup_data(void) {
    int result = system("tar -czf backup.tar.gz library_data.txt");
    /* 백업이 성공했는지, 실패했는지 세부적인 이유를 알기 어렵다 */
}

위 코드는 간단하지만 치명적인 단점들이 있다. 첫째, 호출된 프로그램이 끝날 때까지 현재 프로그램이 완전히 멈춘다. 둘째, 표준 입출력을 재지정하여 프로그램의 출력을 캡처하거나 입력값을 프로그래밍 방식으로 전달하기 어렵다. 셋째, 사용자가 터미널에서 종료 단축키를 눌렀을 때 백업이 중간에 멈췄는지, 아니면 정상 종료됐는지 상태를 구별하기 까다롭다.

실무에서는 이러한 흑마술 같은 래퍼(Wrapper) 함수 대신, 운영체제의 기본 기능을 활용해 프로세스를 직접 만들고 실행 상태를 추적해야 한다.

프로세스 복제와 실행: fork와 exec

운영체제에서 새로운 프로세스를 생성하는 기본 원리는 복제다. 현재 실행 중인 프로세스의 상태를 그대로 복사하여 새로운 프로세스를 만든 뒤, 그 새로운 프로세스에 다른 프로그램의 실행 파일을 덮어씌우는 두 단계로 이루어진다.

분신술을 부리는 fork

자식 프로세스를 만드는 시스템 호출은 포크(fork)다. 이 함수를 호출하면 운영체제는 현재 프로세스의 메모리 구조(코드, 데이터, 힙, 스택)를 그대로 복사하여 자식 프로세스를 만든다. 부모 프로세스가 열어둔 파일 디스크립터까지 동일하게 상속받는다.

fork 호출 시 부모의 메모리 구조가 자식에게 복사되지만, 각각 독립적인 공간을 가짐을 보여주는 그림

두 프로세스는 분기점 이후부터 동일한 코드를 실행하지만, 서로 다른 메모리 공간을 가지므로 한쪽에서 변수를 변경해도 다른 쪽에는 영향을 주지 않는다. 그렇다면 부모와 자식은 자신이 누구인지 어떻게 알 수 있을까? 반환값을 통해 안다. 호출 성공 시 부모 프로세스에게는 새로 태어난 자식의 프로세스 식별자(PID)가 반환되고, 자식 프로세스에게는 0이 반환된다. 이를 이용해 조건문으로 각자의 역할을 나눈다.

새로운 영혼을 불어넣는 exec

복제만으로는 다른 프로그램을 실행할 수 없다. 자식 프로세스 안에서 실행할 새로운 프로그램을 디스크에서 읽어와 현재 프로세스의 메모리를 완전히 덮어써야 한다. 이를 수행하는 함수군을 exec이라고 부른다.

가장 많이 쓰이는 함수는 배열 형태로 인수를 전달하고 환경 변수 경로를 검색해주는 함수다. 이 함수가 성공적으로 호출되면, 기존의 코드는 메모리에서 사라지고 새로운 프로그램의 첫 진입점부터 실행이 시작된다. 호출이 성공하면 영원히 돌아오지 않으며, 반환값을 넘겨주는 경우는 오직 실행 파일이 없거나 권한이 부족하여 덮어쓰기에 실패했을 때뿐이다.

프로세스 생성과 프로그램 실행이 분리되어 있는 이유는, 복제 직후부터 덮어쓰기 전까지의 짧은 틈새 시간에 부모로부터 상속받은 자원을 자식 프로세스에 맞게 조작할 기회를 주기 위해서다. 앞 장에서 배운 파일 디스크립터 복제 기능을 이 틈에 사용하면, 덮어써질 프로그램이 어디로 입출력을 할지 속일 수 있다.

자식의 최후와 좀비 프로세스

자식 프로세스가 작업을 마치고 종료하더라도 시스템에서 곧바로 완전히 사라지는 것은 아니다. 자식이 정상적으로 종료되었는지, 아니면 오류로 인해 강제 종료되었는지에 대한 종료 상태(Exit status) 정보를 부모 프로세스가 읽어갈 때까지 운영체제는 해당 프로세스의 관리용 데이터 구조를 최소한으로 남겨둔다.

이때 자식 프로세스는 이미 실행을 멈추고 메모리 자원도 반납했지만, 운영체제의 프로세스 목록에는 여전히 이름을 올리고 있는 상태가 된다. 이를 좀비 프로세스(Zombie process)라고 부른다.

실행 중인 프로세스가 종료되면 좀비 상태가 되고, 부모가 wait를 호출해야만 소멸함을 보여주는 생명주기 흐름도

부모 프로세스는 반드시 대기 함수를 호출하여 자식의 종료 상태를 읽어 들여야(Reaping) 한다. 시스템 호출을 통해 특정 프로세스를 기다리면, 운영체제는 자식의 남은 흔적을 지우고 부모에게 그 결과를 전달한다. 만약 부모 프로세스가 자식보다 먼저 종료되어 버리면, 고아가 된 좀비 프로세스들은 운영체제의 가장 첫 번째 프로세스(init)에 입양되어 대신 거둬들여진다.

비동기 시그널과 안전 제약

운영체제는 프로세스에 특정 사건이 발생했음을 알리기 위해 시그널을 보낸다. 사용자가 터미널에서 강제 종료 단축키를 누르거나, 프로세스가 허용되지 않은 메모리에 접근했을 때 운영체제는 실행 흐름을 강제로 멈추고 등록된 시그널 처리기(Signal handler)를 실행한다.

시그널은 프로그램이 어떤 코드를 실행하고 있든 상관없이 비동기적으로 끼어든다. 만약 프로그램이 동적 메모리를 할당하기 위해 내부적인 잠금(Lock)을 획득한 상태에서 시그널이 발생했고, 시그널 처리기 안에서도 다시 동적 메모리를 할당하려 한다면 프로그램은 영원히 멈추는 교착 상태(Deadlock)에 빠진다.

따라서 시그널 처리기 내부에서는 반드시 비동기 시그널 안전(Async-signal-safe)이 보장된 제한적인 함수만 호출해야 한다. 우리가 흔히 쓰는 화면 출력 함수나 동적 할당 함수는 내부에 버퍼와 잠금을 사용하므로 안전하지 않다. 가장 안전하고 권장되는 방법은 처리기 안에서 단순히 전역 변수 깃발(Flag)만 올리고, 프로그램의 주 반복문에서 그 깃발을 확인하여 안전하게 남은 작업을 처리하는 패턴이다.

완성 코드

다음은 도서관 대출 목록을 백업하는 외부 명령을 실행하고, 강제 종료 시그널이 들어왔을 때 자식 프로세스를 안전하게 종료하고 수거하는 완전한 프로그램이다.

backup_manager.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#include <errno.h>

/* 시그널 처리기에서 변경되는 변수는 반드시 volatile sig_atomic_t 타입이어야 한다. */
volatile sig_atomic_t cancel_requested = 0;

/* 시그널 처리기: 오직 깃발만 설정하고 즉시 반환한다. */
void handle_sigint(int sig) {
    (void)sig; /* 사용하지 않는 매개변수 경고 무시 */
    cancel_requested = 1;
}

int main(void) {
    /* 예제를 위한 더미 데이터 파일 생성 */
    FILE *f = fopen("library_data.txt", "w");
    if (f != NULL) {
        fputs("책 번호, 대출자, 반납일\n1, 김철수, 2023-12-01\n", f);
        fclose(f);
    }

    struct sigaction sa;
    sa.sa_handler = handle_sigint;
    sigemptyset(&sa.sa_mask);
    /* SA_RESTART를 비워두어 대기 함수가 시스템 호출 도중 시그널을 받으면 중단되게 한다. */
    sa.sa_flags = 0; 

    if (sigaction(SIGINT, &sa, NULL) == -1) {
        perror("시그널 처리기 등록 실패");
        return 1;
    }

    printf("도서관 대출 목록 백업을 시작한다.\n");

    pid_t pid = fork();

    if (pid < 0) {
        perror("프로세스 복제 실패");
        return 1;
    } else if (pid == 0) {
        /* 자식 프로세스 공간 */
        char *args[] = {"tar", "-czf", "backup.tar.gz", "library_data.txt", NULL};
        execvp(args[0], args);
        
        /* execvp는 성공하면 돌아오지 않으므로, 이 코드가 실행된다는 것은 덮어쓰기 실패를 의미한다. */
        perror("백업 프로그램 실행 실패");
        exit(1);
    } else {
        /* 부모 프로세스 공간 */
        printf("백업 프로세스(PID: %d)를 생성했다.\n", pid);

        int status;
        pid_t wpid;

        while (1) {
            wpid = waitpid(pid, &status, 0);

            if (wpid == -1) {
                if (errno == EINTR) {
                    /* 시그널에 의해 waitpid가 중단되었을 때 깃발을 확인한다. */
                    if (cancel_requested) {
                        printf("\n사용자 취소 요청을 받았다. 백업 프로세스를 강제 종료한다.\n");
                        kill(pid, SIGTERM);
                        cancel_requested = 0; /* 시그널 중복 전송 방지 */
                        /* 자식에게 종료를 요청했으니, 자식이 완전히 죽을 때까지 다시 기다려야 한다. */
                        continue; 
                    }
                } else {
                    perror("자식 프로세스 대기 중 오류 발생");
                    break;
                }
            } else if (wpid == pid) {
                /* 자식 프로세스 상태 수거 완료 */
                if (WIFEXITED(status)) {
                    printf("백업 완료. 종료 코드: %d\n", WEXITSTATUS(status));
                } else if (WIFSIGNALED(status)) {
                    printf("백업 프로세스가 비정상 종료됐다. 시그널 번호: %d\n", WTERMSIG(status));
                }
                break;
            }
        }
    }

    return 0;
}

줄별 해설

이 코드는 안정적인 시스템 프로그래밍의 핵심 패턴을 보여준다.

9번 줄: 시그널 처리기 안에서 수정하고 주 흐름에서 읽는 변수다. 최적화 컴파일러가 값을 레지스터에 캐싱하지 않고 매번 메모리에서 읽어오도록 volatile 예약어를 붙였다. sig_atomic_t는 한 번의 기계어 명령으로 읽고 쓰기가 보장되는 타입으로, 데이터를 읽는 도중 시그널이 끼어들어 값이 깨지는 현상을 막는다.

12-15번 줄: 시그널 처리기다. 여기서 화면 출력이나 파일 입출력을 해서는 안 된다. 단순히 취소를 요청받았다는 깃발만 세운다.

25-29번 줄: 구조체를 이용해 SIGINT(터미널에서 전달되는 인터럽트 시그널) 발생 시 우리가 만든 함수가 호출되도록 등록한다. 운영체제 동작 방식을 제어하는 플래그를 0으로 설정했다. 이렇게 하면 waitpid 같은 차단(Blocking) 시스템 호출 내부에서 시그널을 받았을 때, 운영체제가 대기를 재시작하지 않고 함수를 즉시 오류로 반환시킨다. 이는 메인 반복문에서 시그널 깃발을 재빨리 확인할 수 있게 해준다.

38번 줄: 프로세스를 복제한다. 이 줄을 기점으로 실행 흐름이 두 갈래로 나뉜다.

43-46번 줄: 자식 프로세스의 흐름이다. execvp 함수에 실행할 명령의 이름과 인수 배열을 전달한다. 배열의 마지막 요소는 반드시 NULL 포인터여야 운영체제가 인수의 끝을 알 수 있다. 이 함수가 호출되는 순간, 자식 프로세스의 메모리는 tar 프로그램으로 교체되며, 그 아래에 있는 perror와 exit은 실행되지 않는다.

58-86번 줄: 부모 프로세스의 흐름이다. waitpid를 호출하여 자식 프로세스가 종료될 때까지 기다린다. 정상적으로 자식이 종료되면 프로세스의 식별자가 반환된다.

62-63번 줄: 만약 반환값이 -1이고, 오류 원인을 담는 전역 변수 errno가 EINTR이라면, 대기 도중에 시그널이 도착해 강제로 깨어났다는 뜻이다.

65-68번 줄: 시그널 처리기에서 세워둔 깃발을 확인한다. 사용자가 취소를 요청했다면 자식 프로세스에게 즉시 종료하라는 시그널을 보낸다. 중요한 점은 kill 함수로 종료를 요청했다고 해서 자식이 당장 수거되는 것은 아니라는 것이다. 다시 루프의 처음으로 돌아가 waitpid를 호출해야만 비로소 좀비 상태가 된 자식을 수거하고 상태를 읽을 수 있다.

77-81번 줄: 자식이 완전히 종료되어 수거에 성공한 경우다. 매크로 함수들을 사용하여 자식이 정상적으로 main 함수 끝에 도달했는지, 아니면 외부 요인으로 강제 종료되었는지 판별하여 원인을 출력한다.

실행 결과

정상적으로 백업이 끝날 때까지 기다린 경우의 출력이다.

$ gcc -std=c17 -Wall -Wextra backup_manager.c -o backup_manager
$ ./backup_manager
도서관 대출 목록 백업을 시작한다.
백업 프로세스(PID: 34567)를 생성했다.
백업 완료. 종료 코드: 0

백업 도중 사용자가 터미널에서 강제 종료(Ctrl+C)를 누른 경우의 출력이다.

$ ./backup_manager
도서관 대출 목록 백업을 시작한다.
백업 프로세스(PID: 34568)를 생성했다.
^C
사용자 취소 요청을 받았다. 백업 프로세스를 강제 종료한다.
백업 프로세스가 비정상 종료됐다. 시그널 번호: 15

실무에서 자주 틀리는 것

시그널 처리기 내부에서 복잡한 작업 수행하기

초보자가 가장 많이 하는 실수는 시그널을 받았을 때 곧바로 화면에 문자열을 출력하거나 동적 메모리를 해제하는 코드를 작성하는 것이다.

void bad_signal_handler(int sig) {
    /* 매우 위험한 코드 */
    printf("시그널 %d 수신함!\n", sig);
    free(global_buffer);
    exit(1);
}

시그널 처리기는 언제 실행될지 예측할 수 없다. 만약 주 프로그램이 화면 출력을 위해 내부 자원을 잠근 상태에서 시그널이 발생했다면, 이 처리기 안의 함수 호출은 영구적인 교착 상태를 유발한다. 시그널 처리기는 반드시 안전이 보장된 함수만 호출하거나 깃발 변수만 수정해야 한다.

인수 배열의 마지막을 비워두지 않기

새로운 프로그램을 실행할 때 전달하는 문자열 배열은 반드시 배열의 끝을 알리는 특수한 포인터로 마무리되어야 한다.

/* 틀린 코드 */
char *args[] = {"ls", "-l"};
execvp(args[0], args); /* 배열의 끝을 알 수 없어 메모리 범위를 벗어난다 */

/* 고친 코드 */
char *args[] = {"ls", "-l", NULL};
execvp(args[0], args);

운영체제는 배열의 길이를 알 수 없으므로 NULL 포인터를 만날 때까지 메모리를 계속 읽어 들인다. 이 실수는 프로그램이 즉시 비정상 종료되는 원인이 된다.

자식 프로세스를 수거하지 않고 방치하기

부모 프로세스가 바쁘다는 이유로 대기 함수를 호출하지 않으면, 시스템에는 수거되지 않은 좀비 프로세스가 계속 누적된다.

/* 틀린 코드: 프로세스를 만들고 나몰라라 한다 */
for (int i = 0; i < 100; i++) {
    if (fork() == 0) {
        exit(0);
    }
}
/* 대기 함수를 호출하지 않고 오랜 시간 다른 작업을 수행한다 */
sleep(1000); 

비록 좀비 프로세스가 메모리나 CPU를 점유하지는 않지만, 운영체제의 프로세스 테이블의 슬롯을 차지한다. 슬롯이 꽉 차면 더 이상 시스템에서 어떤 새로운 프로그램도 실행할 수 없게 된다. 자식을 생성했다면 부모는 반드시 책임지고 상태를 회수해야 한다.

한눈에 보기

프로세스 관리 주요 시스템 호출
함수명 역할 반환값의 의미
fork 현재 프로세스를 복제하여 자식을 생성 부모에게는 자식 식별자, 자식에게는 0
execvp 현재 프로세스의 메모리를 다른 실행 파일로 교체 오직 실패했을 때만 -1 반환
waitpid 특정 자식 프로세스의 상태 변화를 대기하고 회수 상태 변화가 일어난 자식 식별자
exit 프로세스를 스스로 종료하고 자원을 반납 반환하지 않음
상태 해석 및 시그널 매크로
매크로/타입 역할 사용 조건
WIFEXITED 정상적인 종료 함수 호출로 끝났는지 확인 대기 함수가 성공적으로 반환했을 때
WEXITSTATUS 프로세스가 남긴 구체적인 종료 번호 추출 위 매크로가 참일 때만 안전함
WIFSIGNALED 외부 시그널에 의해 강제로 종료되었는지 확인 대기 함수가 성공적으로 반환했을 때
sig_atomic_t 시그널 처리기에서 안전하게 읽고 쓸 수 있는 자료형 비동기적 수정 플래그 선언 시 사용

연습 문제

  1. 다음 코드가 실행되었을 때 터미널에 출력되는 글자의 총 개수는 몇 개인지 설명하라.
    fork();
    fork();
    printf("A\n");
  2. 부모 프로세스가 자식 프로세스를 생성한 후 자식보다 먼저 exit(0)을 호출하여 종료해버렸다. 이때 시스템에 남겨진 자식 프로세스는 어떤 과정을 거쳐 관리되는지 서술하라.
  3. 프로그램이 실행되는 도중 SIGINT 시그널을 받으면, 열려 있던 모든 네트워크 소켓과 파일 디스크립터를 정리하고 종료하도록 작성하려 한다. 시그널 처리기 안에서 직접 정리 함수를 호출하는 것이 왜 위험한지 설명하고, 안전한 대안 구조를 제시하라.

정답과 해설

  1. 총 4개의 'A'가 출력된다. 첫 번째 복제 호출로 프로세스가 2개가 되고, 두 번째 호출에서 이 2개의 프로세스가 각각 다시 복제를 수행하여 총 4개의 독립적인 프로세스가 된다. 이 4개의 프로세스가 각자 화면에 글자를 출력한다.
  2. 부모를 잃은 자식 프로세스는 고아 프로세스(Orphan process)가 된다. 운영체제는 이러한 프로세스들을 찾아 시스템의 가장 최상위 부모 프로세스인 init (최근 시스템에서는 systemd 등)의 자식으로 입양시킨다. 고아 프로세스가 실행을 마치고 종료되면 입양한 프로세스가 대신 대기 함수를 호출해 주어 좀비 프로세스로 남지 않도록 자동으로 수거한다.
  3. 시그널은 비동기적이다. 주 흐름에서 소켓 구조체를 할당하거나 수정하는 찰나에 시그널이 발생할 수 있다. 이때 시그널 처리기에서 같은 구조체나 시스템 함수에 접근하면 메모리가 오염되거나 자원 잠금에 걸려 교착 상태가 발생할 수 있다. 안전한 대안은 전역 volatile sig_atomic_t 변수를 선언하고 처리기에서는 이 변수값만 1로 바꾼 뒤 즉시 반환하는 것이다. 메인 프로그램의 반복문이 이 변수를 확인하여 안전한 시점에 자원 정리 함수를 호출하도록 설계해야 한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.