본문 바로가기

컴퓨터 시스템 이야기

시그널 핸들러 작성하기

안전한 시그널의 처리

안전한 핸들러를 작성하는 보수적인 지침들을 알아보자

  • 핸들러는 가능한 간단하게 유지하라
  • Call only async-signal-safe functions in your handlers. 안전한 함수는 재진입 가능하거나, 어떤 시그널 핸들러에 의해 중단될 수 없기 때문에 어떤 시그널 핸들러로부터 안전하게 호출될 수 있는 특성을 가짐
  • errno를 저장하고 복원하라, errno를 핸들러에 진입할 때 지역변수에 저장하고, 핸들러가 리턴하기 전에 복원함
  • 전역 변수들은 volatile로 선언하라. 컴파일러는 레지스터에 캐시 되어 있는 g값을 사용해도 안전할 것처럼 생각해서 갱신 값이 안 보일 수가 있다.(자세한 이유는 여기로 --> https://kspsd.tistory.com/40)

정확한 시그널 처리

우선 펜딩 시그널에 대해 간단하게 정리해보자. 보내졌지만 아직 수신되지 않은 시그널을 pending signal이라고 하는데 시간상 어떤 시점에서, 특정 타입에 대해 최대 한 개의 펜딩 시그널이 존재할 수 있다. 만일 어떤 프로세스가 타입 k의 펜딩 시그널을 가지고 있다면 이 프로세스로 다음에 발생하는 k타입의 시그널을 큐에 들어가지 못하고 버려진다. 어떤 시그널이 블록 될 때 전달은 될 수 있지만, 펜딩 시그널은 이 프로세스가 시그널의 블록을 풀 때까지는 수신되지 않음. 펜딩 시그널은 최대 한 번만 수신되고, 커널은 펜딩 비트 내에 펜딩 하고 있는 시그널의 집합을 관리하며 수신될 때마다 그 타입의 비트를 0으로 만듦.

이러한 점 때문에 생기는 문제를 보면서 한 번 더 명확하게 정리를 해보자. 목적지 프로세스가 현재 시그널 k에 대한 핸들러를 실행하고 있기 때문에 시그널 k가 블록 되어 있는 동안 유형 k의 두 개의 시그널이 목적지 프로세스에 보내진 다면, 두 번째 시그널은 큐에 들어가지 못한다.  쉘과 웹서버 같은 실제 프로그램과 근본적으로 유사한 간단한 응용을 살펴보자 기본적인 구조는 부모가 독립적으로 동작하는 몇 개의 자식을 생성하고 종료하는 것이다. 부모님 좀비 프로세스를 만들지 않기 위해 무조건 소거를 해줘야 한다. 또한 부모가 다른 작업을 하면서 기다릴 수 있게 SIGCHLD핸들러로 자식들을 소거한다.  다음 코드와 결과를 보자.

buggy code
마지막 child 지워지지 않음

위 결과의 과정은 다음과 같다. 첫 번째 SIGCHLD 시그널이 수신되었고 핸들러가 처리하게 된다.  처리하는 동안 두 번째 시그널이 배달되었고, 대기 시그널 집합에 추가되었다. SIGCHLD 시그널은 핸들러에 의해 블록 되어있는 상태이기 때문에 수신되지 않는다. 그 뒤 세 번째 시그널이 도착한다. 하지만 여전히 핸들러는 첫 번째를 처리하고 있기 때문에 세 번째 시그널은 그냥 버려진다. 시간이 지난 뒤 핸들러가 리턴된 다음에 커널은 대기하는 SIGCHLD 시그널이 존재하고 있는 것을 알리고 수신하게 만든다. 여기서 알 수 있는 점은 시그널들이 다른 프로세스에서 이벤트의 발생을 세기 위해 사용될 수는 없다. 이를 해결할 방법은 최대한 핸들러에서 많은 프로세스를 처리하게 해줘야 한다.

핸들러 함수 수정

호환성 있는 시그널 핸들링

서로 다른 시스템들이 다른 시그널 처리 방식을 갖는다. 예를 들면

  • signal함수의 의미가 다르다 일부 오래된 유닉스 시스템들은 시그널 k에 대한 동작을 시그널 k가 핸들러에 의해 붙잡힌 이후에 기본 동작으로 복원됨. 이런 시스템에서는 핸들러는 signal을 매 실행 때마다 명시적으로 자신을 재설치해야 함
  • 시스템 콜들은 중단될 수 있다. read, wait 같은 프로세스를 오랜 기간 동안 블록 할 가능성이 있는 시스템 콜들은 느린 시스템 콜이라고 하는데, 이런 시스템 콜들이 중단되면 시그널 핸들러가 리턴할 때 다시 시작하지 않고, 에러 조건과 errno를 EINTR로 설정해서 사용자에게 즉시 리턴함. 프로그래머는 수동으로 중단된 시스템 콜들을 재시작하는 코드를 포함해야 함.

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

같은 저장장치의 위치에서 읽고 쓰는 동시성 흐름을 프로그래밍하는 방법의 문제는 여러 세기에 걸쳐서 컴퓨터과학자들에게 도전적인 문제였다. 근본적인 문제는 어쨌든 동시성 흐름을 동기화해서 각각의 가능한 interleavings들이 정확한 답을 만들게 되는 가장 큰 가능한 interleavings들의 집합을 갖도록 하는 것이다.  자세한 것은 12장에서 다루고 여기서는 간단한 예제를 보자. 부모가 새로운 자식 프로세스를 생성한 후에 그 자식을 작업 리스트에 추가한다. 부모가 SIGCHLD 시그널 핸들러에서 종료한 자식(좀비)을 청소할 때, 자식을 작업 리스트에서 삭제한다. 다음은 이 과정을 나타낸 코드이다.

#include "csapp.h"
void handler(int sig)
{
    int olderrno = errno;
    sigset_t mask_all, prev_all;
    pid_t pid;

    Sigfillset(&mask_all);
    while ((pid = waitoid(-1, NULL, 0)) > 0)
    {
        Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
        deletejob(pid);
        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();
    while (1)
    {
        if ((pid = Fork()) == 0)
        {
            Execve("/bin/data", argv, NULL);
        }
        Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
        addjob(pid);
        Sigprocmask(SIG_SETMASK, &prev_all, NULL);
    }

    exit(0);
}

위 코드를 다음과 같은 순서로 진행하면 문제가 생긴다.

  1. 부모가 fork함수를 실행하고, 커널이 새롭게 생성된 자식을 부모 대신 실행하도록 스케줄함
  2. 부모가 다시 실행할 수 있기 전에 자식이 종료되고, 좀비가 되어서 커널이 SIGCHLD  시그널을 부모에게 배달.
  3. 나중에 붐 모가 다시 실행 가능하게 되었지만, 커널이 펜딩 하고 있는 SIGCHLD를 발견하고 부모의 시그널 핸들러를 실행해서 이 시그널이 수신되도록 함.
  4. 시그널 핸들러는 종료된 자식을 청소하게 deletejob을 호출하지만, 부모가 자식을 리스트에 아직 추가하지 않았기 때문에 아무 일도 하지 않음
  5. 핸들러가 끝난 후 부모가 addjob으로 존재하지 않는 자식을 리스트에 추가.

당연히 부모가 먼저 실행되고 자식이 순서대로 죽을 수도 있다. 이런 상황이 race condition이라고 알려진 고전적인 동기화 에러이다.  이것을 해결하는 방법은 위의 코드에서 fork를 호출하기 전에 SIGCHLD시그널을 블록하고 addjob을 호출한 후에 블록을 해제하면 자식이 작업 리스트에 추가된 후에 청소되는 것을 보장하게 된다. 자식에서는 execve를 호출하기 전에 SIGCHLD를 언블록 해야 한다. 

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

메인 프로세스는 시그널 핸들러가 동작하기를 명시적으로 기다려야 할 필요가 잇다. 이전에 설명한 것과 같이 부모 자식 간에 race상태를 피하도록 SIGCHLD를 블록 한다. 그리고  자식을 생성 후 pid를 0으로 리셋하고 SIGCHLD 블록을 풀어주고 pid가 0이 아닌 다른 값이 될 때까지 스핀 루프에서 기다린다. 하지만 스핀루프는 프로세서 자원을 낭비하는 것이니. 스핀루프에 pause()를 끼워 넣어서 사용할 수 있다. 하지만 여기서 다시 문제가 발생한다.

while(!pid)
	pause();

위와 같은 상황에서 pause가 한 개 이상의 SIGINT를 수신할 수 있으니 루프를 사용한다. 하지만 이 코드에서 이미 while문 내부로 들어온 뒤 pause전에 SIGCHLD가 실행되어 버리면, 메인 프로세스는 영원히 멈춰 있게 된다. 물론 sleep(1)을 줘도 된다 pause대신에, 하지만 이것 또한 너무 느리다. 결론적으로 해결 방법은 sigsuspend를 사용해서 원자성을 가진 동작을 실행하는 것이다. pause() 하고 블록을 해제하는 과정까지 한 번에 이뤄지는 것이니 시그널 핸들러가 먼저 발생할 수 없다. (구체적인 코드는 여기에: https://kspsd.tistory.com/40 )

sigprocmask(SIG_SETMASK, &mask, &prev);
pause();
sigprocmask(SIG_SETMASK, &prev, NULL);

 sigsuspend는 위의 흐름을 원자성을 가지고 동작한다. 

'컴퓨터 시스템 이야기' 카테고리의 다른 글

Exceptional control flow  (0) 2021.09.22
네트워크 프로그래밍 (수정중)  (0) 2021.09.17
copy on write  (0) 2021.09.16
Memory system  (0) 2021.09.15
Chapter 1 컴퓨터 시스템으로의 여행(1.8 ~ 끝)  (2) 2021.08.22