Krafton Jungle/4. CSAPP

[Computer System] ⑧ 예외적인 제어흐름

munsik22 2025. 4. 19. 23:17

프로세서의 제어흐름에서 발생하는 점진적인 흐름이 아닌 갑작스러운 변화(jump나 call 리턴 등에 의해 발생)예외적인 제어흐름Exceptional Control Flow (이하 ECF)라고 부른다. 프로그래머로서 ECF를 이해하면 OS의 입출력, 프로세스, 가상 메모리 구현 등 중요한 시스템 개념을 이해하는 데 도움이 되고, 어떻게 응용 프로그램들이 OS와 상호작용하는 지 이해하는 데 도움이 될 수 있다.

8.1 예외상황 ✅

예외상황의 분석: 프로세서의 상태변화는 응용 프로그램에서 예외처리 핸들러로 급격한 제어이동(예외)를 촉발시킨다. 처리가 끝나면 핸들러는 제어를 중단되었던 프로그램으로 돌려주거나 실행을 중단시킨다.

 

예외상황Exception은 프로세서 상태의 변화에 대한 대응으로, 제어흐름의 갑작스런 변화이다. 위 그림에서 현재 어떤 명령어 Icurr을 실행하고 있을 때 프로세서 상태에 중요한 변화(이벤트)가 일어나고 있다. 프로세서가 이벤트가 발생했다는 것을 감지하면, 예외 테이블이라고 하는 점프 테이블을 통해 특정 종류의 이벤트를 처리하기 위해 OS 서브루틴(예외처리 핸들러)으로 간접 프로시저 콜을 하게 된다.

 

예외처리 핸들러가 처리를 끝마치면 예외상황을 발생시킨 이벤트의 종류에 따라 다음 세 가지 중 하나의 일이 발생한다.

  • 핸들러는 제어를 현재 인스트럭션 Icurr로 돌려준다. (이벤트가 발생했을 때 실행되고 있던 인스트럭션)
  • 핸들러는 제어를 Inext로 돌려준다. (예외상황이 발생하지 않았다면 다음에 실행되었을 인스트럭션)
  • 핸들러는 중단된 프로그램을 종료한다.

8.1.1 예외처리

한 시스템 내에서 가능한 예외 상황의 종류마다 중복되지 않는 양의 정수를 예외번호로 할당하고 있다.

  • 이 숫자의 일부는 프로세서 설계자가 부여한 것이다
    (예) divide by zero, 페이지 오류, 메모리 접근 위반, breakpoint, 산술연산 오버플로우 등
  • 나머지 숫자는 OS 커널 설계자가 할당한다.
    (예) 시스템 콜, 외부 I/O 디바이스로부터의 시그널 등

예외 테이블: 예외 테이블의 k들은 예외 k에 대한 핸들러 코드의 주소를 가지는 일종의 점프 테이블이다.

 

시스템 부팅 시 OS는 예외 테이블이라고 하는 점프 테이블을 할당하고 초기화해서 엔트리 k가 예외상황 k에 대한 핸들러의 주소를 갖는다.

예외처리기 주소의 생성: 예외번호는 예외 테이블에서의 인덱스다.

 

런타임에 프로세서는 이벤트가 발생했다는 것을 감지하고, 대응되는 예외번호 k를 결정한다. 프로세서는 그 후에 예외 테이블의 엔트리 k를 통해 간접 프로시저 콜을 하는 방법으로 예외상황을 발생시킨다. 위 그림은 프로세서가 예외 테이블을 이용해서 해당 예외 핸들러의 주소를 어떻게 만드는지를 보여준다. 예외 번호는 예외 테이블에서의 인덱스이며, 이 테이블의 시작 주소는 예외 테이블 베이스 레지스터라는 특별한 CPU 레지스터에 저장되어 있다.

 

예외사항은 프로시저 콜과 유사하지만 일부 중요한 차이점이 있다.

  • 프로세서는 프로시저 콜을 사용해서 핸들러로 분기하기 전에 스택에 리턴 주소를 푸시한다.
  • 프로세서는 핸들러가 리턴할 때 중단된 프로그램을 다시 시작하기 위해 필요하게 될 추가적인 프로세서 상태를 푸시한다.
  • 제어가 사용자 프로그램에서 커널로 전환하고 있을 때, 푸시되는 데이터들은 사용자 스택이 아닌 커널 스택 상에 푸시된다.
  • 예외 핸들러는 커널 모드에서 돌아가는데, 이는 예외 핸들러가 모든 시스템 자원에 완전히 접근할 수 있는 것을 의미한다.

8.1.2 예외의 종류

예외의 종류: 비동기 예외는 프로세서 외부에 있는 입출력 디바이스 내 이벤트의 결과로 발생한다.

동기형 예외는 인스트럭션을 실행한 직접적인 결과로 발생한다.

 

인터럽트

인터럽트는 프로세서 외부에 있는 입출력 디바이스로부터의 시그널의 결과로 비동기적으로 발생한다. 하드웨어 인터럽트는 비동기적이며, 즉 특정 인스트럭션을 실행해서 발생한 것이 아니라는 의미다.

인터럽트의 처리: 인터럽트 핸들러는 응용프로그램의 제어흐름에서 다음 인스트럭션으로 제어를 돌려준다.

 

나머지 예외의 종류들(트랩, 오류, 중단)은 지금의 인스트럭션을 실행한 결과로 동기적으로 일어난다. 이것을 오류 인스트럭션fault instruction이라고 부른다.

 

트랩과 시스템 콜

트랩은 의도적인 예외 상황으로, 어떤 인스트럭션을 실행한 결과로 발생한다. 인터럽트 핸들러와 마찬가지로 트랩 핸들러는 제어를 다음 인스트럭션으로 리턴한다. 트랩의 가장 중요한 사용은 시스템 콜system call이라고 알려진 사용자 프로그램과 커널 사이의 프로시저와 유사한 인터페이스를 제공하는 것이다.

 

사용자 프로그램은 파일을 읽거나(read), 새로운 프로세스를 만들거나(fork), 새 프로그램을 로드하고(execve), 현재 프로세스를 종료하는 등의 서비스를 커널에 요청할 필요가 있다. 이러한 커널 서비스의 제한된 전급을 하기 위해서 프로세서는 특별한 n 인스트럭션을 제공하며, 이들은 서비스 n을 요청하고자 할 때 사용자 프로그램이 사용할 수 있는 인스트럭션이다.

 

트랩 핸들링: 트랩 핸들러는 응용 프로그램의 제어흐름에서 제어를 다음 인스트럭션으로 돌려준다.

 

프로그래머의 관점에서 시스템 콜은 보통의 함수 호출과 동일하지만, 실제 구현은 매우 다르다.

  • 보통의 함수는 사용자 모드에서 돌아가며, 이 때문에 이들이 실행할 수 있는 인스트럭션은 제한적이며, 이들은 호출하는 함수와 동일한 스택을 사용한다.
  • 시스템 콜은 커널 모드에서 돌아가고, 커널 내에서 정의된 스택에 접근하며, 더 강력한 인스트럭션을 실행할 수 있도록 해준다.

오류

오류(여기서는 error가 아닌 fault를 의미함)는 핸들러가 정정할 수 있는 가능성이 있는 에러 조건으로부터 발생한다.

  • 오류가 발생하면 프로세서는 제어를 오류 핸들러로 이동해준다.
  • 핸들러가 오류를 정정할 수 있다면, 제어를 오류를 발생시킨 인스트럭션으로 돌려주어서 거기서부터 재실행한다.
  • 정정할 수 없다면 커널 내부의 abort 루틴으로 리턴해서 오류를 발생시킨 응용 프로그램을 종료한다.

오류의 처리: 오류가 복구될 수 있는지 여부에 따라 오류 핸들러는 오류를 발생시킨 인스트럭션을 재실행하거나 중단한다.

 

중단

중단은 대개 DRAM이라 SRAM이 고장날 때 발생하는 패리티 에러와 HW 같은복구할 수 없는 치명적인 에러에서 발생한다. 중단 핸들러는 절대로 응용프로그램으로 제어를 리턴하지 않고, 제어를 응용프로그램을 종료하는 중단 루틴으로 넘겨준다.

중단의 처리

8.1.3 Linux/x86-64 시스템에서의 예외상황

x86-64 시스템에서의 예외상황
Linux x86-64 시스템의 주요 시스템 콜


8.2 프로세스

8.2.1 논리적인 제어흐름

논리적 제어흐름: 프로세스들은 각 프로그램에 자신이 프로세서를 혼자서 사용한다는 착각을 제공한다.

 

8.2.2 동시성 흐름

  • 공동으로 실행되는 흐름의 일반적인 현상이 동시성이라고 알려져 있다.
  • 프로세스가 다른 프로세스들과 교대로 실행된다는 개념은 멀티태스킹이라고 알려져 있다.
  • 한 프로세스가 자신의 흐름 일부를 실행하는 매 시간 주기를 타임 프로세스라고 부른다. 그래서 멀티태스킹은 타임 슬라이싱이라고 부른다.
  • 동시적 흐름에 대한 개념은 흐름들이 돌아가는 프로세서 코어나 컴퓨터 개수와는 무관하다.

8.2.3 사적 주소공간

프로세스는 각 프로그램에 자신의 시스템의 주소공간을 혼자서 사용한다는 착각을 불러 일으킨다. 프로세스는 각 프로그램에 자신만의 사적 주소공간을 제공한다. 이 공간의 특정 주소에 연결된 메모리의 한 개의 바이트가 일반적으로 다른 프로세스에 의해서 읽히거나 쓰일 수 없다는 의미로 이 공간은 사적이다.

프로세스 주소공간

8.2.4 사용자 및 커널 모드

  • OS가 완벽한 프로세스 추상화를 제공하기 위해서 프로세서는 응용프로그램이 접근할 수 있는 주소공간 부분 뿐만 아니라 응용프로그램이 실행할 수 있는 인스트러션들을 제한하는 메커니즘을 제공해야 한다.
  • 프로세서는 대개 이러한 작업을 지원하기 위해서 프로세서가 현재 가지고 있는 특권을 저장하는 일부 제어 레지스터로 모드 비트를 제공한다.
  • 모드 비트가 세트되면 프로세스는 커널 모드로 동작한다. 커널 모드에서 돌고 있는 프로세스는 인스트럭션 집합의 어떤 인스트럭션에서도 실행할 수 있으며, 시스템 내의 어떤 메모리 위치도 접근할 수 있다.
  • 모드 비트가 세트되지 않을 때, 프로세스는 사용자 모드에서 돌고 있다. 사용자 프로그램은 시스템 콜을 통해서 커널 코드와 데이터에 간접적으로 접근해야 한다.

8.2.5 문맥 전환

프로세스 컨텍스트 스위칭 분석

 

OS 커널은 문맥 전환Context Switch이라고 알려진 ECF의 상위수준 형태를 사용해서 멀티태스킹을 구현하고 있다.

  • 컨텍스트 : 커널이 중단됐던 프로세스를 다시 시작하기 위해서 필요로 하는 상태
  • 스케줄링 : 프로세스가 실행되는 동안 어떤 시점에 현재 프로세스를 선점(일시적으로 정지)하고 다른 프로세스를 다시 시작할지 결정하는 것. 스케줄러라고 불리는 커널 내부의 코드에 의해 처리된다. 커널이 실행할 새 프로세스를 선택할 때 커널이 그 프로세스를 스케줄했다고 한다.
  • 문맥 전환 : 커널이 실행할 새 프로세스를 스케줄한 후에 현재 프로세스를 선점(일시적으로 정지)하는 것

문맥 전환은 커널이 사용자를 대신해서 시스템 콜을 실행하고 있을 때 일어날 수 있다. 만약 어떤 시스템 콜이 특정 이벤트의 발생을 기다리고 있다면, 커널은 현재 프로세스를 sleep시키고 다른 프로세스로 문맥 전환한다.


8.4 프로세스의 제어

8.4.1 프로세스 ID(PID) 가져오기

#include <sys/types.h>
#include <unistd.h>

pid_t getpid(void);
pid_t getppid(void);
  • getpid()는 현재 프로세스의 PID를 반환한다.
  • getppid()는 부모 프로세스의 PID를 반환한다.

8.4.2 프로세스의 생성과 종료

#include <stdlib.h>

void exit(int status);
  • 종료 : exit 함수는 종료 상태 status로 프로세스를 종료한다.
#include <sys/types.h>
#include <unistd.h>

pid_t fork(void);
  • 생성 : 부모 프로세스는 fork 함수를 불러서 자식 프로세스를 생성한다.
    • 새롭게 생성된 자식 프로세스는 부모와 거의 동일하다. 자식은 코드, 데이터 세그먼트, 힙, 공유된 라이브러리, 사용자 스택을 포함하는 부모의 사용자수준 가상 주소공간과 동일한 (그러나 분리된) 복사복은 가진다.
    • 하지만 부모와 자식의 PID는 서로 다르다.
  • fork 함수는 1번 호출되면 2번 return한다.
    • 부모에서 fork는 자식의 PID를 리턴한다.
    • 자식에서 fork는 0을 리턴한다.
int main() {
    pid_t pid;
    int x = 1;
    
    pid = Fork();
    if (pid == 0) { // Child
        printf("child : x=%d\n", ++x);
        exit(0);
    } else { // Parent
        printf("parent : x=%d\n", --x);
        exit(0);
    }
}

위 코드는 fork를 사용해서 새로운 프로세스를 만드는 예제로, linux에서의 실행 결과는 다음과 같다.

parent : x=0
child : x=2

위 예제 프로그램에 대한 프로세스 그래프
중첩된 fork에 대한 프로세스 그래프

8.4.3 자식 프로세스의 청소

프로세스가 어떤 이유로 종료할 때, 커널은 시스템에서 즉시 제거하지 않는다. 대신 부모가 청소할 때까지 종료된 상태로 남아 있는다.

  • 부모가 종료된 자식을 청소할 때 커널은 자식의 exit 상태를 부모에게 전달 후 종료된 프로세스를 없앤다. 이 때 프로세스가 사라진다.
  • 종료되었지만 아직 청소되지 않은 프로세스를 좀비Zombie라고 한다.
  • 부모 프로세스가 종료될 때, 커널은 init 프로세서(PID = 1)로 하여금 모든 고아가 된 자식들의 입양된 부모가 되도록 한다.

프로세스는 waitpid 함수를 호출해서 자신의 자식들이 종료되거나 정지되기를 기다린다.

#include<sys/types.h>
#include<sys/wait.h>

pid_t waitpid(pid_t pid, int *statusp, int options)

다음은 좀비 자식들을 특정 순서없이 청소하기 위해서 waitpid 함수를 사용하는 예제이다.

#define N 2

int main() {
    int status;
    pid_t pid;
    
    // Parent creates N children
    for (int i=0; i<N; i++)
        if ((pid = Fort()) == 0) // Child
            exit(100+i);
    
    // Parent reaps N children in no particular order
    while ((pid = waitpid(-1, &status, 0)) > 0) {
        if (WIFEXITED(status))
            printf("child %d terminated normally with exit status=%d\n", pid, WEXITSTATUS(status));
        else printf("child %d terminated abnormally\n", pid);
    }
    
    // The only normal termination is if there are no more children
    if (errno != ECHILD)
        unix_error("waitpid error");
    
    exit(0);
}

8.4.4 프로세스 재우기

#include <unistd.h>

unsigned int sleep(unsigned int secs);
  • sleep 함수는 일정 기간 동안 프로세스를 정지시킨다.
#include <unistd.h>

int pause(void)
  • pause 함수는 호출하는 함수를 시그널이 프로세스에 의해 수신될 때까지 잠재우는 함수다.

8.4.5 프로그램의 로딩과 실행

#include <unistd.h>

int execve(const char *filename, const char *argv[], const char *envp[]);
  • execve 함수는 현재 프로그램의 컨텍스트 내에서 새로운 프로그램을 로드하고 실행한다.

Linux는 환경 배열을 조작하기 위한 몇 개의 함수를 제공한다.

#include <stdlib.h>

char *getenv(const char *name);
  • getenv 함수는 환경 배열에서 "name=value" 스트링을 검색한다. 찾게 되면 해당하는 값에 대한 포인터를 리턴하고, 그 외의 경우에는 NULL을 리턴한다.
#include <stdlib.h>

int setenv(const char *name, const char *newvalue, int overwrite);
void unsetenv(const char *name);
  • 환경 배열이 "name=oldvalue" 형태의 스트링을 포함하면, unsetenv는 이를 삭제하고 setenv는 oldvalue를 newvalue로 교체한다. name이 없다면 setenv는 "name=newvalue"를 배열에 추가한다.

8.5 시그널 ✅

시그널은 작은 메시지 형태로, 프로세스에게 시스템 내에 어떤 종류의 이벤트가 일어났다는 것을 알려준다. 리눅스 시그널은 상위수준의 SW 형태의 ECF로, 프로세스와 커널이 다른 프로세스를 중단하도록 한다.

리눅스 시그널

8.5.1 시그널 용어

시그널을 목적지 프로세스로 전달하는 것은 두 단계로 이루어진다.

  1. 시그널 보내기 : 커널은 목적지 프로세스의 컨텍스트 내에 있는 일부 상태를 갱신해서 시그널을 목적지 프로세스로 보낸다(배달한다). 시그널은 다음 두 가지 이유 중 하나로 배달된다.
    • 커널이 0으로 나누기나 자식 프로세스의 종료 같은 시스템 이벤트를 감지했다.
    • 어떤 프로세스가 커널에 명시적으로 시그널을 목적지 프로세스에 보낼 것을 요구하기 위해 kill 함수를 호출했다.
  2. 시그널 받기 : 목적지 프로세스는 배달된 시그널에 대해서 커널이 어떤 방식으로 반응해야 할 때 시그널을 받는다. 프로세스는 시그널 핸들러라고 부르는 사용자수준 함수를 실행해서 시그널을 무시하거나, 종료하거나, 획득할 수 있다.

시그널 핸들링

보내졌지만 아직 받지 않은 시그널은 펜딩pending 시그널이라 부른다. 시간 상으로 어떤 시점에서 특정 타입에 대해 최대 한 개의 펜딩 시그널이 존재할 수 있으며, 펜딩 시그널은 최대 한 번만 수신된다.

8.5.2 시그널 보내기

  • UNIX 시스템의 시그널을 보내는 메커니즘은 모두 프로세스 그룹 개념을 사용한다.
    • 모든 프로세스는 정확히 한 개의 프로세스 그룹에 속하며, 어떤 그룹에 속하는지는 양수 process group ID로 식별한다.
    • getpgrp 함수는 현재 프로세스의 프로세스 그룹 ID를 리턴한다.
    • 기본적으로 자식 프로세스는 자신의 부모와 동일한 프로세스 그룹에 속한다.
    • 프로세스는 자신의 프로세스 그룹 또는 다른 프로세스의 그룹을 setpgid 함수를 사용해 변경할 수 있다.
  • 시그널을 /bin/kill 프로그램을 사용해서 보내기
/bin/kill -9 15213 // 시그널 9번(SIGKILL)을 프로세스 15213에 보냄
/bin/kill -9 -15213 // SIGKILL을 프로세스 그룹 15213 내의 모든 프로세스들에 보냄
  • 키보드에서 시그널 보내기
ls | sort
  • kill 함수로 시그널 보내기
#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);
PID 시그널을 받는 대상
pid > 0 해당 PID 하나의 프로세스
pid == 0 같은 그룹의 모든 프로세스
pid < 0| |pid| 내의 모든 프로세스
  • alarm 함수로 시그널 보내기
#include <unistd.h>

unsigned int alarm(unsigned int secs);

8.5.3 시그널의 수신

커널이 프로세스를 커널 모드에서 사용자 모드로 전환할 때(시스템 콜에서 리턴하거나 문맥 전환을 끝마치는 것 등), 커널은 프로세스의 블록되지 않은 펜딩 시그널의 집합을 체크한다.

  • 집합이 비어 있다면(대개의 경우), 다음 명령으로 제어를 전달한다.
  • 집합이 비어 있지 않으면 커널은 집합 내에서 어떤 시그널을 선택해(대개 가장 작은 시그널) 시그널을 강제로 받게 한다. 시그널을 수신하면 프로세스는 특정한 동작을 수행한 뒤 다음 명령어로 진행한다.

각 시그널 타입은 사전에 정의된 기본 동작을 가지며, 이들은 다음 동작 중의 하나이다.

  • 프로세스가 종료한다.
  • 프로세스를 종료하고 코어를 덤프한다.
  • 프로세스가 SIGCONT 시그널에 의해 재시작될 때까지 정지(지연)한다.
  • 프로세스가 시그널을 무시한다.
#include <signal.h>
typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

signal 함수는 시그널 signum과 연결된 동작을 다음 세 가지 방법 중 하나로 바꿀 수 있다.

  • SIG_IGN : 해당 시그널은 무시된다.
  • SIG_DFL : 기본 동작으로 돌아간다.
  • 그 외 : 사용자 함수의 시그널 핸들러 주소 → 해당 시그널이 오면 호출할 시그널 핸들러로 등록된다.

다음은 사용자가 Ctrl + C를 키보드에서 입력할 때마다 보내지는 SIGINT를 잡는 프로그램이다. SIGINT에 대한 기본 동작은 즉시 이 프로세스를 종료하는 것이다.

void handler(int sig) { // SIGINT HANDLER
    printf("Caught signal %d\n", sig);
    exit(0);
}

int main() {
	// Install the SIGINT HANDLER
    if (signal(SIGINT, handler) == SIG_ERR)
    	unix_error("signal error");
    pause(); // wait for the receipt of a signal
    return 0;
}

핸들러는 다른 핸들러에 의해 중단될 수 있다.

8.5.4 시그널 블록하기와 해제하기

Linux는 시그널을 블록하기 위해 묵시적/명시적인 방법을 제공한다.

  • 묵시적 블록 방법 : 기본적으로 커널은 핸들러에 의해 처리되고 있는 유형의 모든 대기 시그널들의 처리를 막는다.
  • 명시적 블록 방법 : 응용 프로그램들은 sigprocmask 함수와 이들의 도움함수를 이용해서 시그널들을 명시적으로 블록하거나 해제할 수 있다.
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);

int sigismember(const sigset_t *set, int signum);

8.5.5 시그널 핸들러 작성하기

시그널 처리는 linux 시스템 수준 프로그래밍에서 가장 까다로운 부분으로, 핸들러는 이해하기 어렵게 만드는 특성을 가진다.

  • 핸들러는 메인 프로그램과 동시적으로 돌아가고, 같은 전역변수를 공유하며, 그래서 메인 프로그램가 다른 핸들러들과 뒤섞일 수 있다.
  • 어떻게, 그리고 언제 시그널들이 수신될 수 있을지 종종 직관적이지 않다.
  • 시스템마다 서로 다른 시그널 처리 방식을 가진다.

동시성 오류를 없애고 안전하게 시그널 핸들러를 작성하는 방법은 다음과 같다.

  • 핸들러의 기능을 가능한 간단하게 작성하라.
  • 핸들러에서 비동기성-시그널-안전한async-signal-safe 함수만을 호출하라.
  • errno를 저장하고 복원하라.
  • 모든 시그널을 블록시켜서 공유된 전역 자료구조들로의 접근을 보호하라.
  • 전역변수들은 volatile로 선언하라.
  • sig_atomic_t로 플래그를 선언하라.

우리가 자주 사용하는 printf, sprintf, malloc, exit 등의 함수는 비동기성-시그널-안전한 함수에 해당하지 않는다. 따라서 시그널 핸들러를 위한 SIO(Safe I/O) 패키지를 사용해야 한다.

ssize_t sio_puts(char s[]) /* Put string */
{
    return write(STDOUT_FILENO, s, sio_strlen(s));
}

ssize_t sio_putl(long v) /* Put long */
{
    char s[128];
    sio_ltoa(v, s, 10); /* Based on K&R itoa() */
    return sio_puts(s);
}

void sio_error(char s[]) /* Put error message and exit */
{
    sio_puts(s);
    _exit(1);
}

다음은 SIGINT의 안전한 버전이다.

void sigint_handler(int sig) /* Safe SIGINT handler */
{
    Sio_puts("Caught SIGINT!\n"); /* Safe output */
    _exit(0); /* Safe exit */
}

정확한 시그널 처리를 위해서는, 부모 프로세스는 자식 프로세스를 생성한 후 독립적으로 작업을 수행하고 있어야 하기 때문에, 자식 프로세스가 종료될 때까지 무작정 기다릴 수는 없기 때문에, 자식 프로세스는 SIGCHLD 시그널을 보내고 부모 프로세스는 SIGCHLD 핸들러를 통해 자식 프로세스를 소거할 수 있게 해야 한다.

void handler(int sig)
{
	int olderrno = errno;

	while (waitpid(-1, NULL, 0) > 0) {
		Sio_puts("Handler reaped child\n");
	}
	if (errno != ECHILD)
		Sio_error("waitpid error");
	Sleep(1);
	errno = olderrno;
}

int main()
{
    int i, n;
    char buf[MAXBUF];

    if (signal(SIGCHLD, handler1) == SIG_ERR)
        unix_error("signal error");

    /* Parent creates children */
    for (i = 0; i < 3; i++) {
        if (fork() == 0) {
            printf("Hello from child %d\n", (int)getpid());
            exit(0);
        }
    }

    /* Parent waits for terminal input and then processes it */
    if ((n = read(STDIN_FILENO, buf, sizeof(buf))) < 0)
        unix_error("read");

    printf("Parent processing input\n");
    while (1)
        ;
    exit(0);
}

UNIX 시그널 핸들링의 또 다른 어려운 점은 서로 다른 시스템들이 서로 다른 시그널 처리 방식을 가진다는 점이다. 이를 해결하기 위해 Posix 표준은 sigaction 함수를 정의하고 있으며, 이는 사용자들이 핸들러를 설치할 때 사용자들이 원하는 시그널 처리 개념을 명확히 명시하도록 한다.

#include <signal.h>
int sigaction(int signum, struct sigaction *act, struct sigaction *oldact);

8.5.6 치명적인 동시성 버그를 피하기 위해서 흐름을 동기화하기

부모가 새로운 자식 프로세스를 생성한 후에 그 자식을 작업 리스트에 추가한다. 부모가 SIGCHILD 시그널 핸들러에서 종료한 자식(좀비)를 청소할 때, 자식을 작업 리스트에서 삭제한다. 이 코드는 정확한 것처럼 보이지만, 다음과 같은 순서에서 문제가 발생한다.

  1. 부모가 fork 함수를 실행하고, 자식이 부모 대신 실행되도록 스케쥴된다.
  2. 부모가 깨어나기 전에 자식이 종료되고 SIGCHLD 시그널을 부모에게 보낸다.
  3. 커널이 SIGCHLD를 발견하고 부모의 시그널 핸들러를 실행해서 이 시그널이 수신되도록 한다.
  4. 시그널 핸들러는 종료된 자식을 청소해야 하지만, 부모가 자식을 리스트에 아직 추가를 안 해서 아무 일도 하지 않는다.
  5. 핸들러가 완료된 후 부모가 깨어나서 addjob을 호출하여 (존재하지도 않는) 자식을 작업 리스트에 추가한다.

위와 같은 동기화 에러를 Race라고 한다.

/* WARNING: This code is buggy! */
void handler(int sig)
{
    int olderrno = errno;
    sigset_t mask_all, prev_all;
    pid_t pid;

    Sigfillset(&mask_all);
    while ((pid = waitpid(-1, NULL, 0)) > 0) { /* Reap a zombie child */
        Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
        deletejob(pid); /* Delete the child from the job list */
        Sigprocmask(SIG_SETMASK, &prev_all, NULL);
    }
    if (errno != ECHILD)
        sio_error("waitpid error");
    errno = olderrno;
}

int main(int argc, char **argv)
{
    int pid;
    sigset_t mask_all, prev_all;

    Sigfillset(&mask_all);
    Signal(SIGCHLD, handler);
    initjobs(); /* Initialize the job list */

    while (1) {
        if ((pid = Fork()) == 0) { /* Child process */
            Execve("/bin/date", argv, NULL);
        }
        Sigprocmask(SIG_BLOCK, &mask_all, &prev_all); /* Parent process */
        addjob(pid); /* Add the child to the job list */
        Sigprocmask(SIG_SETMASK, &prev_all, NULL);
    }
    exit(0);
}

위 코드는 미묘한 동기화 에러를 가지는 쉘 프로그램이다. 만약 자식이 부모가 실행할 수 있기 전에 종료한다면, addjobdeletejob은 잘못된 순서로 호출될 것이다.

void handler(int sig)
{
    int olderrno = errno;
    sigset_t mask_all, prev_all;
    pid_t pid;

    Sigfillset(&mask_all);
    while ((pid = waitpid(-1, NULL, 0)) > 0) { /* Reap a zombie child */
        Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
        deletejob(pid); /* Delete the child from the job list */
    }
    if (errno != ECHILD)
        sio_error("waitpid error");
    errno = olderrno;
}

int main(int argc, char **argv)
{
    int pid;
    sigset_t mask_all, mask_one, prev_one;

    Sigfillset(&mask_all);
    Sigemptyset(&mask_one);
    Sigaddset(&mask_one, SIGCHLD);
    Signal(SIGCHLD, handler);
    initjobs(); /* Initialize the job list */

    while (1) {
        Sigprocmask(SIG_BLOCK, &mask_one, &prev_one); /* Block SIGCHLD */
        if ((pid = Fork()) == 0) { /* Child process */
            Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
            Execve("/bin/date", argv, NULL);
        }
        Sigprocmask(SIG_BLOCK, &mask_all, NULL); /* Parent process */
        addjob(pid); /* Add the child to the job list */
        Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
    }
    exit(0);
}

위 코드는 race condition을 제거하고 프로세스 동기화를 하기 위해 sigprocmask를 사용한 코드이다. 이제 부모는 addjob이 대응하는 deletejob 이전에 실행되도록 보장한다.

8.5.7 명시적으로 시그널 대기하기

시그널 대기에 pause를 사용하면, pause가 하나 이상의 SIGINT 시그널을 수신하는 경우 중단될 수 있기 때문에 race condition을 가질 수 있다. 대신에 sleep을 사용하면 정확하지만 너무 느리다. 가장 적절한 해결책은 sigsuspend를 이용하는 것이다.

volatile sig_atomic_t pid;

void sigchld_handler(int s)
{
    int olderrno = errno;
    pid = waitpid(-1, NULL, 0);
    errno = olderrno;
}

void sigint_handler(int s)
{
}

int main(int argc, char **argv)
{
    sigset_t mask, prev;

    Signal(SIGCHLD, sigchld_handler);
    Signal(SIGINT, sigint_handler);
    Sigemptyset(&mask);
    Sigaddset(&mask, SIGCHLD);

    while (1) {
        Sigprocmask(SIG_BLOCK, &mask, &prev); /* Block SIGCHLD */
        if (Fork() == 0) /* Child */
            exit(0);

        /* Wait for SIGCHLD to be received */
        pid = 0;
        while (!pid)
            sigsuspend(&prev);

        /* Optionally unblock SIGCHLD */
        Sigprocmask(SIG_SETMASK, &prev, NULL);
        /* Do some work after receiving SIGCHLD */
        printf(".");
    }
    exit(0);
}