Proven C BookEnglish GitHub

76 신호 — <signal.h>

먼저 알아야 할 것

75장 진단과 제어 · errno와 처리기의 제약
53장 main의 세 얼굴 · 프로그램의 시작과 끝
3장 프로그램과 프로세스 · 프로세스라는 그릇

돌아보기

75장에서 신호 처리기 안에서는 printfmalloc도 부를 수 없고, 할 수 있는 것은 volatile sig_atomic_t에 값을 대입하는 것 정도라고 했다. 그런데 신호는 결국 내 프로그램 안에서 도는 함수를 부르는 것 아닌가 — 왜 그렇게까지 제한이 심한가?

답. 언제 끼어들지 모르기 때문이다. 신호는 함수의 경계를 지켜 주지 않는다. malloc이 자유 목록을 반쯤 고쳐 놓은 순간에, printf가 버퍼에 절반쯤 써 놓은 순간에 끼어들 수 있다. 그 상태에서 처리기가 같은 함수를 다시 부르면 자료구조가 깨진다.

즉 신호는 스레드도 아니고 함수 호출도 아닌 제3의 것이다 — 지금 흐름을 잠시 멈추고 끼어들었다가 돌아가는 것이라, “지금 무엇이 중간 상태인가”를 알 수 없다. 이 장의 모든 규칙이 이 한 문장에서 나온다.

이 장의 필요성과 맥락

75장이 네 헤더를 훑었다면, 그중 둘은 한 장씩 따로 정독할 값어치가 있다. 신호가 먼저인 것은 그것이 바깥에서 들어오는 흐름이고, 다음 장의 setjmp안에서 뛰는 흐름이라 대조가 되기 때문이다. 두 장이 「흐름을 끊는 두 방향」으로 짝을 이룬다.

이 장이 끝나면

<signal.h> 하나를 정독한다. 어디서 왔는지(유닉스의 유산), 함수 두 개의 정확한 모양과 인자·반환값, sig_atomic_t의 정체, 처리기 안에서 허용되는 것의 목록, POSIX sigaction과 그 구조체 속, 그리고 서버·셸·터미널이 실제로 신호를 어떻게 쓰는지까지.

이 장에서 답할 질문

  1. 왜 표준은 더 나은 sigaction을 담지 않았는가?
  2. 그러면 처리기에서 무언가 기록해 두어야 할 때는 어떻게 하는가?
  3. 그러면 setjmp/longjmp도 같은 일을 하는 것 아닌가?
  4. 스레드가 여럿인 프로그램에서 신호는 어디로 가는가?

76.1 어디서 왔는가 — 유닉스의 유산

신호는 C가 발명한 것이 아니다. 1970년대 유닉스가 “프로세스에게 무언가를 알리는 가장 값싼 방법”으로 만든 장치이고, C 표준은 그중 어디서나 통할 만한 최소한만 골라 담았다.

초기 유닉스의 신호에는 유명한 결함이 있었다. 처리기가 한 번 불리면 처리 방식이 곧바로 기본값으로 되돌아갔고(그래서 처리기 첫 줄에서 자기를 다시 걸어야 했다), 그 사이에 같은 신호가 또 오면 프로그램이 죽었다. 이 틈 때문에 당시의 신호는 믿을 수 없는 신호(unreliable signals)라 불렸다.

4.2BSD가 이 문제를 고친 새 인터페이스를 내놓았고(sigvec 계열), 그 설계가 POSIX의 sigaction으로 정리됐다. 그런데 1989년의 C 표준이 담은 것은 고쳐진 쪽이 아니라 공통분모였다 — 그래서 표준 signal에는 그 옛 틈이 지금도 남아 있다.

문. 왜 표준은 더 나은 sigaction을 담지 않았는가?

답. C 표준의 오랜 원칙 때문이다 — 운영체제가 없는 곳에서도 성립하는 것만 담는다(62장). sigaction은 프로세스, 신호 마스크, 시스템 호출 재시작 같은 운영체제의 개념 위에 서 있다. 그런 것이 없는 임베디드 칩에서도 C는 돌아야 하므로, 표준은 “신호라는 것이 있다면 이 정도는 있다”는 최소만 정했다.

그래서 이 장은 두 층으로 나뉜다. 표준 층(어디서나 통하지만 빈틈이 있다)과 POSIX 층(빈틈이 없지만 유닉스 계열에서만 통한다). 실무의 유닉스 코드는 거의 예외 없이 뒤쪽을 쓴다.

76.2 함수 두 개 — 정확한 모양

표준이 정한 함수는 둘뿐이다.

선언하는 일
void (*signal(int sig, void (*func)(int)))(int);sig번 신호의 처리 방식을 func으로 바꾸고, 이전 방식을 돌려준다
int raise(int sig);자기 자신에게 sig번 신호를 보낸다

표 76.1

signal의 선언이 험한 이유는 60장에서 절차대로 읽었다 — “신호 번호와 핸들러를 받아 이전 핸들러를 돌려주는 함수”다.

76.2.1 signal의 인자와 반환값

자리무엇을 주는가
sig신호 번호표준의 여섯 개 또는 구현이 정한 값
funcSIG_DFL기본 처리로 되돌린다(대개 종료)
funcSIG_IGN무시한다 — 신호가 와도 아무 일도 없다
func함수 포인터그 함수를 처리기로 삼는다
반환이전의 처리 방식SIG_DFL·SIG_IGN·이전 처리기 중 하나
반환SIG_ERR설정 실패. 이때 errno에 이유가 담긴다

표 76.2

반환값을 버리지 않는 것이 첫 번째 규율이다. 실패했는데 성공한 줄 알고 지나가면, 정작 신호가 왔을 때 프로그램이 조용히 죽는다.

76.2.2 raise의 인자와 반환값

raise(sig)는 자기 자신에게 신호를 보낸다. 성공하면 0, 실패하면 0이 아닌 값을 돌려준다. 처리기가 있으면 raise가 돌아오기 전에 처리기가 불린다 — 즉 동기적이다.

abort가 하는 일이 정확히 이것이다: raise(SIGABRT)를 부르고, 처리기가 없거나 돌아오면 비정상 종료한다(67장).

examples/ch76/sig_basic.c

/* <signal.h> 의 표준 부분 — 세 가지 처리 방식과 raise. */
#include <signal.h>
#include <stdio.h>
#include <string.h>

/* 표준이 허락하는 처리기의 몸통은 이만큼이다:
   volatile sig_atomic_t 에 값을 대입하는 것. 그 밖은 계약 밖이다. */
static volatile sig_atomic_t got_int;
static volatile sig_atomic_t got_term;

static void on_signal(int sig)
{
    if (sig == SIGINT)  got_int  = 1;
    if (sig == SIGTERM) got_term = 1;
    /* 여기서 printf 를 부르지 않는다 — 이유는 본문에서 */
}

static const char *disposition(void (*h)(int))
{
    if (h == SIG_DFL) return "SIG_DFL (default handling)";
    if (h == SIG_IGN) return "SIG_IGN (ignored)";
    if (h == SIG_ERR) return "SIG_ERR (installation failed)";
    return "our own handler";
}

int main(void)
{
    /* ── ① 설치 — 돌려주는 값은 *이전* 처리 방식이다 ───────────── */
    void (*prev)(int) = signal(SIGINT, on_signal);
    printf("return value of signal(SIGINT, on_signal) = %s\n", disposition(prev));

    /* ── ② raise — 자기 자신에게 신호를 보낸다 ─────────────────── */
    printf("before raise(SIGINT): got_int = %d\n", (int)got_int);
    int r = raise(SIGINT);
    printf("raise returned %d (0 means success), got_int = %d\n", r, (int)got_int);

    /* ── ③ 무시 — SIG_IGN 을 걸면 신호가 사라진다 ──────────────── */
    (void)signal(SIGTERM, SIG_IGN);
    (void)raise(SIGTERM);
    printf("raise(SIGTERM) after SIG_IGN: got_term = %d (still 0)\n",
           (int)got_term);

    /* ── ④ 처리 뒤에 처리기가 남는가? — 구현 정의다(표준 §7.14.1.1) ── */
    void (*was)(int) = signal(SIGINT, prev);
    printf("\nafter handling one signal, what is installed: %s\n", disposition(was));
    printf("  -> this implementation resets to SIG_DFL right after handling (the old System V way).\n");
    printf("  -> the standard allows either, so portable code either reinstalls inside\n");
    printf("     the handler or uses POSIX sigaction.\n");

    /* ── ⑤ 표준이 정한 신호는 여섯 개뿐 ───────────────────────── */
    struct { int num; const char *name; const char *meaning; } table[] = {
        { SIGABRT, "SIGABRT", "abnormal termination (abort)" },
        { SIGFPE,  "SIGFPE",  "arithmetic error (division by zero and such)" },
        { SIGILL,  "SIGILL",  "invalid instruction" },
        { SIGINT,  "SIGINT",  "interactive attention signal (Ctrl+C)" },
        { SIGSEGV, "SIGSEGV", "invalid memory access" },
        { SIGTERM, "SIGTERM", "termination request" },
    };
    printf("\nthe %zu signals the standard defines:\n", sizeof table / sizeof *table);
    for (size_t i = 0; i < sizeof table / sizeof *table; i++)
        printf("  %-8s = %2d   %s\n", table[i].name, table[i].num, table[i].meaning);

    printf("\nsizeof(sig_atomic_t) = %zu bytes\n", sizeof(sig_atomic_t));
    return 0;
}

실행 결과

return value of signal(SIGINT, on_signal) = SIG_DFL (default handling)
before raise(SIGINT): got_int = 0
raise returned 0 (0 means success), got_int = 1
raise(SIGTERM) after SIG_IGN: got_term = 0 (still 0)

after handling one signal, what is installed: SIG_DFL (default handling)
  -> this implementation resets to SIG_DFL right after handling (the old System V way).
  -> the standard allows either, so portable code either reinstalls inside
     the handler or uses POSIX sigaction.

the 6 signals the standard defines:
  SIGABRT  =  6   abnormal termination (abort)
  SIGFPE   =  8   arithmetic error (division by zero and such)
  SIGILL   =  4   invalid instruction
  SIGINT   =  2   interactive attention signal (Ctrl+C)
  SIGSEGV  = 11   invalid memory access
  SIGTERM  = 15   termination request

sizeof(sig_atomic_t) = 4 bytes

시연이 네 가지를 실물로 보여 준다. 이전 값이 돌아온다(첫 줄), raise는 그 자리에서 처리기를 부른다(둘째 줄), SIG_IGN을 걸면 신호가 사라진다 (셋째 줄), 그리고 마지막 것이 중요하다 — 이 구현은 처리 직후 SIG_DFL로 되돌린다.

76.2.3 표준이 정한 신호 여섯 개

이름언제 오는가기본 동작
SIGABRTabort() 호출비정상 종료
SIGFPE산술 오류(0 나눗셈, 넘침 등)비정상 종료
SIGILL잘못된 명령을 실행비정상 종료
SIGINT대화형 주의 신호(대개 Ctrl+C)종료
SIGSEGV잘못된 기억 접근비정상 종료
SIGTERM종료 요청종료

표 76.3

번호 값은 표준이 정하지 않는다 — 시연이 인쇄한 2·15 같은 값은 이 구현의 것이다. 이름으로 쓰고 번호로 쓰지 않는다가 규칙이다.

76.3 처리기 안에서 무엇을 할 수 있는가

이 장에서 가장 중요한 절이다. 표준(§7.14.1.1)은 처리기 안에서 허용되는 것을 열거로 정한다. 그 목록 밖은 정의되지 않은 동작이다.

허용되는 것조건
volatile sig_atomic_t 객체에 값을 대입하기읽는 것이 아니라 대입
락 없는 원자적 객체 다루기<stdatomic.h>, 락이 없을 때(80장)
abort 부르기
_Exit 부르기
quick_exit 부르기
signal 부르기자기를 부른 그 신호 번호로만

표 76.4

그 밖의 표준 라이브러리 함수는 전부 금지다 — printf, malloc, strlen, exit까지. 그리고 정적·스레드 저장 기간의 객체를 만지는 것도, 위의 첫 줄에 해당하지 않으면 금지다.

흔한 오해. “처리기에서 printf로 로그를 남기면 편하다”

가장 흔하고 가장 오래가는 사고다. printf는 내부에 버퍼와 잠금을 가진 함수라, 중간 상태에 끼어들면 자료구조가 깨진다. 운이 좋으면 출력이 뒤섞이고, 나쁘면 교착(deadlock)이다 — printf가 잠금을 쥔 순간 신호가 와서 처리기가 다시 printf를 부르면, 자기가 쥔 잠금을 자기가 기다린다.

더 나쁜 것은 대개는 잘 돌아간다는 점이다. 그래서 이 버그는 시험을 통과하고 배포된 뒤, 부하가 높을 때만 가끔 멈추는 형태로 나타난다.

유닉스에서 굳이 처리기에서 무언가 인쇄해야 한다면 write를 쓴다 — POSIX가 비동기 신호 안전(async-signal-safe)이라고 따로 보장한 함수다. 시연의 두 번째 예제가 그렇게 한다.

76.3.1 sig_atomic_t의 정체

sig_atomic_t신호가 중간에 끼어들어도 값이 반쪽이 되지 않는 정수 타입 이다. 왜 이런 타입이 따로 있는가 — 큰 정수는 기계에 따라 두 번의 저장으로 나뉠 수 있고, 그 사이에 신호가 오면 반만 바뀐 값이 보이기 때문이다.

volatile이 함께 붙는 이유는 다른 데 있다. 컴파일러는 “이 루프 안에서 아무도 stop을 바꾸지 않는다”고 판단해 검사를 통째로 지울 수 있다(13장의 최적화). volatile매번 실제로 읽으라는 표시다. 둘의 역할이 다르므로 둘 다 필요하다 — volatile sig_atomic_t.

무엇이무엇을 막는가없으면
sig_atomic_t값이 반쪽으로 보이는 것반쯤 갱신된 값을 읽을 수 있다
volatile컴파일러가 읽기를 생략하는 것루프가 깃발을 영영 보지 못한다

표 76.5

C11 이후에는 atomic_int 같은 락 없는 원자적 타입도 처리기에서 쓸 수 있다 (80장). 여러 갈래로 도는 프로그램에서는 이쪽이 더 정확한 선택이다 — sig_atomic_t신호 대 주 흐름만 보장하지, 스레드 사이를 보장하지 않는다.

76.4 처리기와 기억 — 왜 malloc이 목록에 없는가

허용 목록에서 가장 아쉬운 자리가 기억 할당이다. 처리기 안에서 무언가 기록해 두려면 그릇이 필요한데, mallocfree도 부를 수 없다. 왜 그런지 기계 수준 에서 보면 규칙이 몸에 남는다.

할당자는 자유 목록이라는 자료구조를 관리한다(45장). malloc 한 번은 그 목록에서 조각을 떼어 내고 이웃 조각의 연결을 고쳐 쓰는 여러 단계의 갱신이다. 그 갱신이 절반쯤 진행된 순간이 반드시 존재하고, 신호는 바로 그 순간에 끼어들 수 있다.

끼어든 순간처리기가 malloc을 부르면
자유 목록의 연결을 반쯤 고쳐 놓았을 때반쯤 고쳐진 목록을 따라가 엉뚱한 조각을 내준다
블록의 크기 정보를 쓰기 직전나중에 free가 잘못된 크기로 반납한다
할당자의 잠금을 쥐고 있을 때자기가 쥔 잠금을 자기가 기다린다 — 교착

표 76.6

셋째 줄이 특히 고약하다. 현대의 할당자는 여러 갈래에서 동시에 불릴 것에 대비해 내부에 잠금을 둔다. 그 잠금을 쥔 스레드가 신호를 맞고, 처리기가 다시 malloc을 부르면, 잠금은 영원히 풀리지 않는다. 프로그램이 죽지 않고 멈춘다 — 진단하기 가장 어려운 형태의 사고다.

여기서 더 나쁜 조합이 하나 있다. 처리기에서 longjmp로 빠져나가는 경우다 (77장). 이때는 malloc을 다시 부르지 않아도 문제가 된다 — 잠금을 쥔 채로 뛰어나가기 때문이다. 그 뒤로는 주 흐름의 첫 malloc이 멈춘다. 77장에서 “신호 처리기에서 뛰는 것은 위험하다”고 말하는 근거의 절반이 이것이다.

문. 그러면 처리기에서 무언가 기록해 두어야 할 때는 어떻게 하는가?

답. 미리 잡아 둔다. 처리기를 걸기 전에 필요한 그릇을 할당해 두고, 처리기는 거기에 쓰기만 한다. 정적 배열이면 더 좋다 — 할당 자체가 없다.

static char report[4096];               /* 미리 확보 */
static volatile sig_atomic_t report_len;

기록의 양이 정해지지 않는 경우라면 애초에 처리기에서 할 일이 아니다. 깃(git)발을 세우고 주 흐름이 하도록 넘긴다. 정말로 지금 남겨야 한다면 — 충돌 보고서처럼 다음 순간에 죽을 것이 확실한 상황이라면 — 미리 잡아 둔 버퍼에 담아 write로 곧장 내보낸다. 이것이 뒤에 볼 레디스의 충돌 보고서가 하는 일이다.

76.5 신호가 저장하고 되돌리는 것 — 레지스터 문맥과 errno

처리기는 함수의 한복판에서 끼어들었다가 그 자리로 정확히 돌아간다. 계산하다 만 값들이 레지스터에 흩어져 있는데도 그렇다. 어떻게 가능한가?

운영체제가 레지스터 전부를 저장했다가 되돌려 주기 때문이다. 신호를 배달할 때 커널은 스택에 신호 프레임을 쌓고 그 안에 지금의 레지스터 집합을 통째로 담는다 (리눅스에서는 ucontext_t가 그 그릇이다). 처리기가 돌아오면 sigreturn이 그 값들을 되돌려 놓는다. 그래서 어느 명령의 경계에서 끼어들었든 계산이 이어진다.

examples/ch76/sig_context.c

/* 신호는 무엇을 저장하고 무엇을 되돌리는가 — 레지스터 문맥과 errno. */
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

static volatile sig_atomic_t hits;

/* ── ① errno 를 챙기지 않는 처리기 ─────────────────────────────
   write 는 처리기에서 쓸 수 있는 몇 안 되는 함수지만, 실패하면 errno 를
   바꾼다. 주 흐름이 그 값을 보기 직전이었다면 진단이 통째로 뒤집힌다. */
static void careless(int sig)
{
    (void)sig;
    hits++;
    ssize_t n = write(-1, "", 1);   /* 반드시 실패한다 — errno = EBADF */
    (void)n;
}

/* ── ② errno 를 저장하고 복원하는 처리기 ─────────────────────── */
static void careful(int sig)
{
    int saved = errno;              /* 들어오자마자 챙긴다 */
    (void)sig;
    hits++;
    ssize_t n = write(-1, "", 1);
    (void)n;
    errno = saved;                  /* 나가기 직전에 되돌린다 */
}

/* ── 레지스터에 얹혀 도는 계산. 중간에 신호를 맞아도 결과가 같은가? ── */
static long compute(int interrupt_at)
{
    long acc = 0;
    for (int i = 1; i <= 1000; i++) {
        acc += (long)i * i % 7;
        if (i == interrupt_at) raise(SIGUSR1);
    }
    return acc;
}

static int fails(void)              /* errno 를 ENOENT 로 만들어 두는 실패 */
{
    errno = 0;
    return access("/no/such/file/here", F_OK);
}

int main(void)
{
    /* ① errno 오염 */
    signal(SIGUSR1, careless);
    (void)fails();
    int before = errno;
    raise(SIGUSR1);
    printf("a handler that does not save errno: before %d(%s) -> after %d(%s)\n",
           before, strerror(before), errno, strerror(errno));

    /* ② 저장·복원 */
    signal(SIGUSR1, careful);
    (void)fails();
    before = errno;
    raise(SIGUSR1);
    printf("a handler that does save errno:     before %d(%s) -> after %d(%s)\n",
           before, strerror(before), errno, strerror(errno));

    /* ③ 계산 도중에 끼어들어도 결과가 같다.
       이 구현은 처리 뒤 SIG_DFL 로 되돌아가므로(76장) 다시 걸어 준다. */
    signal(SIGUSR1, careful);
    hits = 0;
    long quiet = compute(0);            /* 신호 없이 */
    long hit   = compute(500);          /* 한복판에서 한 번 맞고 */
    printf("\nvalue computed undisturbed = %ld\n", quiet);
    printf("value computed while hit by a signal at iteration 500 = %ld  (handler called %d times)\n",
           hit, (int)hits);
    puts(quiet == hit ? "the same - the kernel saved every register and gave them back"
                      : "different - this should not happen");

    puts("\nthis is where it differs from setjmp/longjmp (chapter 77):");
    puts("  a signal: the kernel saves and restores the *whole* register set ->");
    puts("        you can return into the middle of an expression and the arithmetic continues.");
    puts("  longjmp: a jmp_buf holds only the *callee-saved* registers ->");
    puts("        locals that lived in the other registers snap back to their old values.");
    return 0;
}

실행 결과

a handler that does not save errno: before 2(No such file or directory) -> after 9(Bad file descriptor)
a handler that does save errno:     before 2(No such file or directory) -> after 2(No such file or directory)

value computed undisturbed = 2002
value computed while hit by a signal at iteration 500 = 2002  (handler called 1 times)
the same - the kernel saved every register and gave them back

this is where it differs from setjmp/longjmp (chapter 77):
  a signal: the kernel saves and restores the *whole* register set ->
        you can return into the middle of an expression and the arithmetic continues.
  longjmp: a jmp_buf holds only the *callee-saved* registers ->
        locals that lived in the other registers snap back to their old values.

시연의 셋째 부분이 그 사실을 확인한다. 천 번 도는 계산의 한복판(500회째)에서 신호를 맞아도 결과가 조용히 돈 것과 같다.

문. 그러면 setjmp/longjmp도 같은 일을 하는 것 아닌가?

답. 아니다. 여기가 두 장치의 결정적인 차이다.

신호longjmp(77장)
저장하는 주체커널setjmp 매크로
저장 범위레지스터 전부피호출자 보존 레지스터만(여덟 칸)
돌아가는 자리끊긴 바로 그 명령setjmp를 부른 자리
지역 변수전부 그대로volatile이면 보장 없음

표 76.7

즉 신호는 완전한 문맥 전환이고 longjmp부분적인 복원이다. 그래서 처리기는 수식 한복판으로 돌아와도 괜찮지만, longjmp는 표준이 정한 네 문맥 에서만 쓸 수 있다(77장).

76.5.1 errno는 예외다 — 손으로 챙겨야 한다

레지스터를 커널이 챙겨 준다면 무엇이 남는가. C 수준의 전역 상태다. 그중 가장 자주 물리는 것이 errno다.

처리기가 write 하나만 불러도 그것이 실패하면 errno가 바뀐다. 주 흐름이 마침 실패한 호출의 이유를 읽으려던 참이었다면, 그 값은 신호 때문에 바뀐 값이다.

시연의 첫 부분이 그것을 실물로 보여 준다 — 신호 전에 ENOENT(2)였던 errno가 처리기를 거치고 나자 EBADF(9)로 바뀌었다. 없던 파일을 열려 한 것이 잘못된 파일 서술자 오류로 둔갑한 셈이다.

처방은 두 줄이다. 들어오자마자 저장하고, 나가기 직전에 복원한다.

static void on_signal(int sig) {
    int saved = errno;          /* 첫 줄 */
    /* … 깃발 세우기, write 등 … */
    errno = saved;              /* 마지막 줄 */
}

이것을 빠뜨린 코드는 대개 잘 돌아간다 — 신호가 하필 그 순간에 와야 드러나기 때문이다. 그래서 규율로 삼아 두는 편이 낫다.

플랫폼 노트. 스택은 어디에 쌓이는가 — 붉은 구역과 대체 스택

커널은 신호 프레임을 지금 스택 위에 쌓는다. 여기서 두 가지 실무 지식이 나온다.

첫째, x86-64 SysV 규약에는 스택 포인터 아래 128바이트의 붉은 구역(red zone)이 있어 함수가 스택을 늘리지 않고도 쓸 수 있다. 커널은 신호 프레임을 쌓을 때 이 구역을 건너뛴다 — 그러지 않으면 중단된 함수의 임시값을 덮어쓴다. 커널 코드를 -mno-red-zone으로 빌드하는 관행이 여기서 나온다.

둘째, 스택이 넘쳐서 SIGSEGV가 났다면 처리기를 쌓을 스택도 없다. 그래서 POSIX는 sigaltstack으로 처리기 전용 스택을 따로 등록하고 SA_ONSTACK을 주는 길을 마련해 두었다. 스택 넘침을 진단해 주는 도구들이 이 장치 위에 선다.

76.6 실무의 무늬 — 깃발을 세우고 즉시 돌아간다

허용 목록이 저렇게 짧으므로, 실무의 처리기는 사실상 하나의 모양으로 수렴한다.

examples/ch76/sig_flag.c

/* 처리기가 해야 할 일은 하나뿐 — 깃발을 세우고 즉시 돌아간다. */
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>          /* write — 처리기 안에서 쓸 수 있는 몇 안 되는 것 */

static volatile sig_atomic_t stop_requested;
static volatile sig_atomic_t reload_requested;
static volatile sig_atomic_t last_signal;

static void on_signal(int sig)
{
    last_signal = sig;
    if (sig == SIGTERM || sig == SIGINT) stop_requested = 1;
    if (sig == SIGUSR1)                  reload_requested = 1;

    /* 표준이 허락하는 것은 여기까지다. 아래 write 는 POSIX 가 따로 허락한
       비동기-신호-안전 함수라서 예외적으로 쓸 수 있다(본문 참조). */
    static const char note[] = "  [handler] flag set\n";
    ssize_t n = write(STDOUT_FILENO, note, sizeof note - 1);
    (void)n;
}

/* 서버의 뼈대 — 일하다가, 깃발을 보고, 결정한다 */
static void serve(void)
{
    int served = 0;
    for (;;) {
        if (reload_requested) {
            reload_requested = 0;
            printf("  re-reading the configuration (SIGUSR1)\n");
            fflush(stdout);
        }
        if (stop_requested) {
            printf("  cleaning up and going down (after handling %d)\n", served);
            return;
        }
        served++;
        if (served == 2) (void)raise(SIGUSR1);   /* 재적재 요청을 흉내 낸다 */
        if (served == 4) (void)raise(SIGTERM);   /* 종료 요청을 흉내 낸다 */
    }
}

int main(void)
{
    /* 이 구현은 처리 뒤 SIG_DFL 로 되돌리므로, 매번 다시 건다.
       (그래서 POSIX 코드는 sigaction 을 쓴다 — 다음 예제) */
    if (signal(SIGUSR1, on_signal) == SIG_ERR) return 1;
    if (signal(SIGTERM, on_signal) == SIG_ERR) return 1;

    puts("server loop starting");
    fflush(stdout);   /* 처리기의 write 는 버퍼를 거치지 않는다 — 순서를 맞춘다 */
    serve();
    printf("number of the last signal received = %d\n", (int)last_signal);

    /* 처리기가 남기고 간 것은 '깃발' 하나다 — 그 값으로 밖에서 일한다.
       인쇄도, 파일 닫기도, free 도 전부 여기서 한다. */
    puts("cleanup finished outside the loop");
    return 0;
}

실행 결과

server loop starting
  [handler] flag set
  re-reading the configuration (SIGUSR1)
  [handler] flag set
  cleaning up and going down (after handling 4)
number of the last signal received = 15
cleanup finished outside the loop

처리기는 깃발만 세운다. 판단과 정리는 주 흐름이 한다. 시연의 serve가 그 구조다 — 루프를 돌다가 깃발을 보고, 설정을 다시 읽거나 정리하고 내려간다. 인쇄도, 파일 닫기도, free도 전부 루프 안에서 한다.

이 무늬가 좋은 이유는 셋이다. 안전하다 — 처리기가 하는 일이 대입 하나뿐이라 허용 목록을 벗어날 일이 없다. 시점을 고를 수 있다 — 지금 처리 중인 요청을 끝내고 내려갈지, 즉시 내려갈지를 주 흐름이 정한다. 시험할 수 있다 — 깃발을 직접 세워 보면 신호 없이도 같은 경로를 시험할 수 있다.

실제 사례. 출력 순서가 뒤집혀 보이는 이유

시연을 처음 돌렸을 때 처리기의 출력이 puts보다 먼저 나왔다. 버그가 아니라 버퍼 때문이다 — printf·puts는 stdout 버퍼에 쌓아 두었다가 나중에 내보내는데, 처리기의 write는 버퍼를 거치지 않고 곧바로 나간다(64장의 버퍼 이야기).

이 사소한 관찰이 앞 절의 오개념을 다시 설명해 준다. 처리기와 주 흐름은 같은 버퍼를 공유하지만 같은 규칙으로 쓰지 않는다. 그래서 시연은 fflush로 순서를 맞췄고, 실무에서는 아예 처리기에서 인쇄하지 않는 쪽을 택한다.

76.7 POSIX의 sigaction — 표준이 남긴 빈틈을 메운다

유닉스 계열에서 실제로 쓰는 것은 sigaction이다. 표준 signal의 세 가지 빈틈을 정확히 메운다.

빈틈표준 signalsigaction
처리 후 처리기가 남는가구현 정의(시연에서 사라졌다)남는다(SA_RESETHAND를 주지 않는 한)
처리 중 같은 신호가 또 오면구현 정의기본으로 막힌다. sa_mask로 더 막을 수 있다
끊긴 시스템 호출정해 주지 않는다SA_RESTART로 자동 재시작

표 76.8

examples/ch76/sig_action.c

/* POSIX sigaction — 표준 signal 이 남긴 빈틈을 메운다. */
#define _POSIX_C_SOURCE 200809L
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

static volatile sig_atomic_t hits;
static volatile sig_atomic_t last_code;
static volatile sig_atomic_t from_self;

/* SA_SIGINFO 를 주면 처리기가 세 인자를 받는다 — 누가 왜 보냈는지가 들어온다 */
static void on_signal(int sig, siginfo_t *info, void *ctx)
{
    (void)sig; (void)ctx;
    hits++;
    last_code = info->si_code;
    from_self = (info->si_pid == getpid());   /* 값 자체는 인쇄하지 않는다 */
}

static const char *code_name(int code)
{
    switch (code) {
    case SI_USER:   return "SI_USER (sent by a person, via kill/raise)";
    case SI_QUEUE:  return "SI_QUEUE(sigqueue)";
    case SI_TIMER:  return "SI_TIMER (a timer)";
    case SI_KERNEL: return "SI_KERNEL (sent by the kernel)";
    case -6:        return "SI_TKILL (raise from the same thread, Linux)";
    default:        return "some other implementation-defined value";
    }
}

int main(void)
{
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);          /* 구조체는 통째로 0 으로 시작한다 */
    sa.sa_sigaction = on_signal;        /* SA_SIGINFO 를 쓰면 이쪽을 채운다 */
    sigemptyset(&sa.sa_mask);           /* 처리 중에 추가로 막을 신호들 */
    sigaddset(&sa.sa_mask, SIGUSR2);    /* USR1 처리 중에는 USR2 를 미룬다 */
    sa.sa_flags = SA_SIGINFO | SA_RESTART;  /* 정보 받기 + 시스템 호출 재시작 */

    struct sigaction old;
    if (sigaction(SIGUSR1, &sa, &old) != 0) return 1;

    printf("SIGUSR1 installed with sigaction. Previous handler: %s\n",
           old.sa_handler == SIG_DFL ? "SIG_DFL" : "there was one");

    /* 세 번 보낸다 — signal() 과 달리 처리기가 *남아 있다* */
    for (int i = 0; i < 3; i++) (void)raise(SIGUSR1);
    printf("after raising three times, handled = %d (the handler stays installed)\n", (int)hits);
    printf("  was the sender ourselves? %s\n", from_self ? "yes" : "no");
    printf("  si_code = %d%s\n", (int)last_code, code_name((int)last_code));

    /* ── 신호를 잠시 막아 두기(블록) ─────────────────────────── */
    sigset_t block, prev;
    sigemptyset(&block);
    sigaddset(&block, SIGUSR1);
    if (sigprocmask(SIG_BLOCK, &block, &prev) != 0) return 1;

    int before = (int)hits;
    (void)raise(SIGUSR1);               /* 막혀 있으므로 대기 상태가 된다 */
    printf("\nraise while blocked: handled %d -> %d (not delivered yet)\n",
           before, (int)hits);

    sigset_t pending;
    sigpending(&pending);
    printf("  is it pending? %s\n",
           sigismember(&pending, SIGUSR1) ? "yes" : "no");

    sigprocmask(SIG_SETMASK, &prev, nullptr);   /* 풀면 그 자리에서 배달된다 */
    printf("  right after unblocking, handled = %d\n", (int)hits);

    /* ── 구조체 안을 들여다보기 ──────────────────────────────── */
    printf("\nsizeof(struct sigaction) = %zu bytes, sigset_t = %zu bytes\n",
           sizeof(struct sigaction), sizeof(sigset_t));
    printf("SA_RESTART=0x%x, SA_SIGINFO=0x%x, SA_NOCLDWAIT=0x%x\n",
           (unsigned)SA_RESTART, (unsigned)SA_SIGINFO, (unsigned)SA_NOCLDWAIT);
    return 0;
}

실행 결과

SIGUSR1 installed with sigaction. Previous handler: SIG_DFL
after raising three times, handled = 3 (the handler stays installed)
  was the sender ourselves? yes
  si_code = -6 → SI_TKILL (raise from the same thread, Linux)

raise while blocked: handled 3 -> 3 (not delivered yet)
  is it pending? yes
  right after unblocking, handled = 4

sizeof(struct sigaction) = 152 bytes, sigset_t = 128 bytes
SA_RESTART=0x10000000, SA_SIGINFO=0x4, SA_NOCLDWAIT=0x2

76.7.1 struct sigaction의 속

이 구조체가 이 장의 자료형 이야기다. POSIX가 정한 멤버는 넷이고, 순서는 정하지 않는다(그래서 지정 초기화memset으로 시작해야 한다).

멤버타입무엇인가
sa_handlervoid (*)(int)평범한 처리기. 표준 signal과 같은 모양
sa_sigactionvoid (*)(int, siginfo_t *, void *)SA_SIGINFO를 줄 때 쓰는 쪽. 정보가 더 온다
sa_masksigset_t이 처리기가 도는 동안 추가로 막을 신호들
sa_flagsint동작을 고르는 깃발들(아래)

표 76.9

sa_handlersa_sigaction은 대개 공용체로 겹쳐 있다(47장). 그래서 둘 중 하나만 채워야 하고, 어느 쪽인지는 SA_SIGINFO 깃발이 말한다.

주요 깃발은 넷이다.

깃발
SA_SIGINFO세 인자짜리 처리기를 쓴다 — 누가·왜 보냈는지가 들어온다
SA_RESTART신호에 끊긴 시스템 호출을 자동으로 다시 시작한다
SA_NOCLDWAIT자식이 끝나도 좀비를 남기지 않는다(SIGCHLD)
SA_RESETHAND옛 방식대로 한 번 처리하면 기본으로 되돌린다

표 76.10

76.7.2 siginfo_t — 누가, 왜 보냈는가

SA_SIGINFO를 주면 처리기가 siginfo_t *를 받는다. 자주 쓰는 멤버는 이렇다.

멤버무엇인가
si_signo신호 번호
si_code 왔는가 — SI_USER(kill), SI_KERNEL, SI_TIMER
si_pid보낸 프로세스의 번호
si_uid보낸 사용자의 번호
si_addrSIGSEGV·SIGBUS일 때 문제가 난 주소

표 76.11

시연이 si_code를 인쇄한다. raise로 자기에게 보냈더니 리눅스는 SI_TKILL (−6)을 넣었다 — “같은 스레드가 보냈다”는 뜻이다. 값의 이름과 의미는 구현마다 다르므로, 분기의 근거로 삼기 전에 그 플랫폼의 문서를 확인해야 한다.

si_addr은 디버깅에서 값을 한다. SIGSEGV 처리기에서 이 값을 기록해 두면 “어느 주소를 만지다 죽었는가”가 남는다 — 다만 그 처리기 안에서는 인쇄할 수 없으므로(허용 목록), 대개 write로 원시 바이트를 남기고 _Exit로 끝낸다.

76.7.3 신호를 잠시 막아 두기 — 마스크

sigprocmask는 “지금은 이 신호를 받지 않겠다”고 선언한다. 막힌 동안 온 신호는 사라지지 않고 대기(pending)하다가, 막기를 푸는 순간 배달된다. 시연의 마지막 묶음이 그 장면이다 — 막아 둔 채 raise하니 처리 횟수가 그대로였고, 풀자마자 올라갔다.

이것이 필요한 자리는 분명하다. 자료구조를 고치는 동안 신호가 끼어들면 안 될 때, 그 구간만 막는다. 임계 구역을 신호에 대해서도 만드는 셈이다.

76.8 실제 사용 사례

이제 실무에서 신호가 실제로 어떤 일을 하는지 본다.

신호쓰임대표 사례
SIGTERM정상 종료 요청 — 정리할 시간을 준다kill의 기본값, 도커·쿠버네티스의 컨테이너 종료
SIGINT사용자의 중단(Ctrl+C)명령줄 도구가 진행 중인 일을 접는다
SIGKILL즉시 죽인다 — 잡을 수 없다정상 종료가 실패했을 때의 마지막 수단
SIGHUP설정 다시 읽기(관행)nginx·아파치의 무중단 설정 반영
SIGCHLD자식이 끝났다셸과 서버가 좀비를 거둔다
SIGPIPE읽는 쪽이 사라진 파이프에 썼다서버가 대개 무시하고 EPIPE로 처리한다
SIGWINCH터미널 창 크기가 바뀌었다vim·top이 화면을 다시 그린다
SIGUSR1·SIGUSR2응용이 뜻을 정한다nginx의 무중단 실행 파일 교체, 로그 파일 다시 열기

표 76.12

76.8.1 정상 종료 — 가장 널리 쓰이는 무늬

컨테이너 세계에서 특히 중요해졌다. 오케스트레이터가 컨테이너를 내릴 때 먼저 SIGTERM을 보내고, 유예 시간(대개 30초) 안에 끝나지 않으면 SIGKILL로 죽인다. 그래서 서버는 SIGTERM을 받으면 새 요청은 받지 않고, 진행 중인 요청을 마치고, 연결을 닫고 내려가야 한다.

앞 절의 깃발 무늬가 정확히 이 일을 한다. 처리기는 stop = 1만 하고, 주 루프가 그 값을 보고 정리 절차를 밟는다.

76.8.2 SIGPIPE — 무시하는 것이 정답인 신호

파이프나 소켓에서 읽는 쪽이 먼저 사라졌는데 쓰면 SIGPIPE가 온다. 기본 동작은 프로세스 종료다 — 웹 서버라면 클라이언트가 창을 닫았다는 이유로 서버가 죽는 셈이다.

그래서 네트워크 프로그램은 거의 예외 없이 이렇게 시작한다.

signal(SIGPIPE, SIG_IGN);   /* 신호 대신 write 가 -1/EPIPE 를 돌려주게 한다 */

무시로 돌려놓으면 write가 실패를 값으로 돌려준다(errno == EPIPE). 75장의 “실패는 값으로”가 여기서도 낫다는 판단이다.

76.8.3 시스템 호출이 끊기는 문제 — EINTR

신호가 오면 read·write 같은 느린 시스템 호출이 중간에 끊겨 −1을 돌려주고 errnoEINTR가 담긴다. 이것을 모르면 “가끔 읽기가 실패한다”는 유령 버그가 된다.

처방은 둘이다. SA_RESTART를 주어 커널이 자동으로 다시 시작하게 하거나, 손으로 다시 시도하는 것이다.

ssize_t n;
do { n = read(fd, buf, len); } while (n < 0 && errno == EINTR);

SA_RESTART가 만능은 아니라는 점도 알아 두어야 한다 — 재시작되지 않는 호출이 있고(대표적으로 시간 제한이 걸린 것들), 그래서 견고한 코드는 위의 재시도 고리를 함께 쓴다.

실제 사례. 자기 파이프 요령과 그 후예들

신호와 이벤트 루프를 섞는 것은 오래된 골칫거리였다. select·poll로 기다리는 중에 신호가 오면 루프가 깨지고, 처리기 안에서는 거의 아무것도 할 수 없다.

그래서 1990년대에 자기 파이프 요령(self-pipe trick)이 나왔다. 프로그램이 자기에게 파이프를 하나 만들어 두고, 처리기는 거기에 바이트 하나를 write한다 (write는 안전 목록에 있다). 그러면 이벤트 루프가 그 파이프를 보통의 읽기 가능 사건으로 받아 처리한다 — 신호가 파일 서술자로 바뀌는 셈이다.

오늘날 리눅스에는 이 발상을 커널이 직접 제공하는 signalfd가 있고, BSD 계열 에는 kqueueEVFILT_SIGNAL이 있다. 이름은 달라도 발상은 같다 — 신호를 비동기 방해가 아니라 줄 서는 사건으로 바꾼다.

문. 스레드가 여럿인 프로그램에서 신호는 어디로 가는가?

답. 까다로운 자리이고, 규칙을 알아 두지 않으면 재현이 어려운 버그가 된다.

프로세스 전체로 보낸 신호(kill)는 그 신호를 막지 않은 스레드 중 아무 하나 에게 배달된다 — 어느 스레드인지는 정해져 있지 않다. 반면 pthread_kill은 지정한 스레드로 간다. 그리고 처리기는 프로세스 전체가 공유하지만, 마스크는 스레드마다 따로다.

그래서 실무의 정석은 이렇다 — 모든 스레드에서 신호를 막아 두고, 전담 스레드 하나만 sigwait로 기다린다. 그러면 신호가 비동기 방해가 아니라 평범한 함수 반환으로 바뀌고, 그 스레드 안에서는 printfmalloc이든 마음대로 쓸 수 있다. 앞의 signalfd와 같은 발상이다.

76.8.4 어떤 소프트웨어가, 왜 신호를 쓰는가

신호를 쓰는 이유는 소프트웨어마다 다르고, 그 이유를 보면 이 장치의 자리가 분명해진다.

무엇무엇에 쓰는가왜 신호여야 했는가
nginx·아파치SIGHUP 설정 재적재, SIGUSR2 실행 파일 교체운영자가 밖에서 안으로 지시할 수 있는 가장 값싼 통로. 포트도 소켓도 필요 없다
PostgreSQL질의 취소·종료 요청(처리기는 깃발만)연결마다 프로세스가 하나씩인 구조라, 프로세스 사이 신호가 곧 통신 수단
레디스SIGSEGV·SIGBUS 처리기로 충돌 보고서죽는 순간의 상태를 남길 마지막 기회. 미리 잡아 둔 버퍼에 담아 write로 내보낸다
HotSpot JVMSIGSEGV로 널 검사와 세이프포인트정상 경로에서 검사 명령을 아예 없애고, 드문 경우만 하드웨어 트랩에 맡긴다
웹어셈블리 런타임SIGSEGV·SIGBUS 트랩 처리기로 경계 밖 접근경계 검사를 가드 페이지에 맡겨 매 접근의 비교 명령을 없앤다
보엠 GCmprotect+SIGSEGV로 쓰기 장벽, 신호로 스레드 정지언어의 도움 없이 C 프로그램에 수집기를 얹는 유일한 길
libuv·Node.js전담 스레드와 파이프로 신호를 사건으로 바꾼다이벤트 루프에 섞으려면 신호가 파일 서술자가 되어야 한다
CPython처리기는 깃발만, 바이트코드 루프가 검사인터프리터 상태를 처리기에서 만질 수 없다 — 허용 목록 때문
libcurlSIGPIPE 무시, 옛 판은 SIGALRM으로 이름 풀이 시간 제한시간 제한을 걸 다른 수단이 없던 시절의 유산. 위험해서 끄는 스위치(CURLOPT_NOSIGNAL)를 따로 두었다

표 76.13

세 갈래로 정리된다. 바깥에서 들어오는 지시(nginx·PostgreSQL — 운영의 통로), 하드웨어 트랩을 사용자 코드로 올리기(JVM·웹어셈블리·GC·레디스 — 정상 경로를 비우고 예외만 신호로), 그리고 신호를 사건으로 바꾸기(libuv·CPython — 허용 목록의 제약을 우회하는 현대적 처방).

가운데 갈래가 특히 흥미롭다. 「널 검사를 없애고 SIGSEGV로 잡는다」는 발상은 드문 일에 비용을 몰아주는 최적화의 전형이다 — 정상 경로에서 명령 하나를 지우는 대가로, 사고가 났을 때 커널·처리기를 거치는 긴 길을 감수한다. 11장의 “드문 분기는 비싸도 좋다”와 같은 계산이다.

반례. 처리기에서 곧바로 정리하기

static void on_term(int sig) {
    (void)sig;
    fclose(logfile);        /* 표준 라이브러리 — 금지 */
    free(buffer);           /* 금지 */
    printf("bye\\n");        /* 금지 */
    exit(0);                /* exit 도 금지(_Exit 는 허용) */
}

넷 다 허용 목록 밖이다. 게다가 이 코드는 대개 잘 돌아가서 더 위험하다 — 문제가 드러나는 것은 부하가 높거나 신호가 몰릴 때뿐이다.

올바른 형태는 깃발 하나다.

static volatile sig_atomic_t stop;
static void on_term(int sig) { (void)sig; stop = 1; }

복습 정리

기억할 것요점
정체스레드도 함수 호출도 아닌 제3의 것 — 언제 끼어들지 모른다
표준의 범위함수 둘(signal·raise), 신호 여섯 개뿐
signal의 반환값이전 처리 방식. SIG_ERR이면 실패
처리기 안허용 목록이 전부 — 사실상 깃발 대입 하나
volatile sig_atomic_t둘 다 필요하다. 하나는 반쪽 값을, 하나는 최적화를 막는다
기억 할당malloc은 자유 목록과 잠금 때문에 금지 — 그릇은 미리 잡아 둔다
문맥 복원레지스터는 커널이 전부 되돌린다. 단 errno는 손으로 저장·복원
POSIXsigaction이 세 빈틈(재설정·중복·EINTR)을 메운다
실무처리기는 깃발, 정리는 주 흐름. SIGPIPE는 무시
현대적 대안signalfd·kqueue·전담 스레드 — 신호를 사건으로 바꾼다

표 76.14

바깥에서 날아오는 방해를 다뤘다. 다음 장은 그 반대편 — 프로그램이 스스로 흐름을 끊고 위층으로 뛰어오르는 장치, setjmplongjmp다.