81 신호 — <signal.h>
먼저 알아야 할 것
errno와 처리기의 제약main의 세 얼굴 · 프로그램의 시작과 끝돌아보기
80장에서 신호 처리기 안에서는 printf도 malloc도 부를 수 없고, 할 수 있는 것은 volatile sig_atomic_t에 값을 대입하는 것 정도라고 했다. 그런데 신호는 결국 내 프로그램 안에서 도는 함수를 부르는 것 아닌가 — 왜 그렇게까지 제한이 심한가?
답. 언제 끼어들지 모르기 때문이다. 신호는 함수의 경계를 지켜 주지 않는다. malloc이 자유 목록을 반쯤 고쳐 놓은 순간에, printf가 버퍼에 절반쯤 써 놓은 순간에 끼어들 수 있다. 그 상태에서 처리기가 같은 함수를 다시 부르면 자료구조가 깨진다.
즉 신호는 스레드도 아니고 함수 호출도 아닌 제3의 것이다 — 지금 흐름을 잠시 멈추고 끼어들었다가 돌아가는 것이라, “지금 무엇이 중간 상태인가”를 알 수 없다. 이 장의 모든 규칙이 이 한 문장에서 나온다.
이 장의 필요성과 맥락
setjmp 는 안에서 뛰는 흐름이라 대조가 되기 때문이다. 두 장이 「흐름을 끊는 두 방향」으로 짝을 이룬다.이 장이 끝나면
<signal.h> 하나를 정독한다. 어디서 왔는지(유닉스의 유산), 함수 두 개의 정확한 모양과 인자·반환값, sig_atomic_t의 정체, 처리기 안에서 허용되는 것의 목록, POSIX sigaction과 그 구조체 속, 그리고 서버·셸·터미널이 실제로 신호를 어떻게 쓰는지까지.이 장에서 답할 질문
- 왜 표준은 더 나은
sigaction을 담지 않았는가? - 그러면 처리기에서 무언가 기록해 두어야 할 때는 어떻게 하는가?
- 그러면
setjmp/longjmp도 같은 일을 하는 것 아닌가? - 스레드가 여럿인 프로그램에서 신호는 어디로 가는가?
81.1 어디서 왔는가 — 유닉스의 유산#
신호는 C가 발명한 것이 아니다. 1970년대 유닉스가 “프로세스에게 무언가를 알리는 가장 값싼 방법”으로 만든 장치이고, C 표준은 그중 어디서나 통할 만한 최소한만 골라 담았다.
초기 유닉스의 신호에는 유명한 결함이 있었다. 처리기가 한 번 불리면 처리 방식이 곧바로 기본값으로 되돌아갔고(그래서 처리기 첫 줄에서 자기를 다시 걸어야 했다), 그 사이에 같은 신호가 또 오면 프로그램이 죽었다. 이 틈 때문에 당시의 신호는 믿을 수 없는 신호(unreliable signals)라 불렸다.
4.2BSD가 이 문제를 고친 새 인터페이스를 내놓았고(sigvec 계열), 그 설계가 POSIX의 sigaction으로 정리됐다. 그런데 1989년의 C 표준이 담은 것은 고쳐진 쪽이 아니라 공통분모였다 — 그래서 표준 signal에는 그 옛 틈이 지금도 남아 있다.
문. 왜 표준은 더 나은 sigaction을 담지 않았는가?
답. C 표준의 오랜 원칙 때문이다 — 운영체제가 없는 곳에서도 성립하는 것만 담는다(67장). sigaction은 프로세스, 신호 마스크, 시스템 호출 재시작 같은 운영체제의 개념 위에 서 있다. 그런 것이 없는 임베디드 칩에서도 C는 돌아야 하므로, 표준은 “신호라는 것이 있다면 이 정도는 있다”는 최소만 정했다.
그래서 이 장은 두 층으로 나뉜다. 표준 층(어디서나 통하지만 빈틈이 있다)과 POSIX 층(빈틈이 없지만 유닉스 계열에서만 통한다). 실무의 유닉스 코드는 거의 예외 없이 뒤쪽을 쓴다.
81.2 함수 두 개 — 정확한 모양#
표준이 정한 함수는 둘뿐이다.
| 선언 | 하는 일 |
|---|---|
void (*signal(int sig, void (*func)(int)))(int); | sig번 신호의 처리 방식을 func으로 바꾸고, 이전 방식을 돌려준다 |
int raise(int sig); | 자기 자신에게 sig번 신호를 보낸다 |
표 81.1 — <signal.h> 의 선언
signal의 선언이 험한 이유는 65장에서 절차대로 읽었다 — “신호 번호와 핸들러를 받아 이전 핸들러를 돌려주는 함수”다.
81.2.1 signal의 인자와 반환값#
| 자리 | 무엇을 주는가 | 뜻 |
|---|---|---|
sig | 신호 번호 | 표준의 여섯 개 또는 구현이 정한 값 |
func | SIG_DFL | 기본 처리로 되돌린다(대개 종료) |
func | SIG_IGN | 무시한다 — 신호가 와도 아무 일도 없다 |
func | 함수 포인터 | 그 함수를 처리기로 삼는다 |
| 반환 | 이전의 처리 방식 | SIG_DFL·SIG_IGN·이전 처리기 중 하나 |
| 반환 | SIG_ERR | 설정 실패. 이때 errno에 이유가 담긴다 |
표 81.2 — 처리기가 받는 것과 그 뜻
반환값을 버리지 않는 것이 첫 번째 규율이다. 실패했는데 성공한 줄 알고 지나가면, 정작 신호가 왔을 때 프로그램이 조용히 죽는다.
81.2.2 raise의 인자와 반환값#
raise(sig)는 자기 자신에게 신호를 보낸다. 성공하면 0, 실패하면 0이 아닌 값을 돌려준다. 처리기가 있으면 raise가 돌아오기 전에 처리기가 불린다 — 즉 동기적이다.
abort가 하는 일이 정확히 이것이다: raise(SIGABRT)를 부르고, 처리기가 없거나 돌아오면 비정상 종료한다(72장).
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로 되돌린다.
81.2.3 표준이 정한 신호 여섯 개#
| 이름 | 언제 오는가 | 기본 동작 |
|---|---|---|
SIGABRT | abort() 호출 | 비정상 종료 |
SIGFPE | 산술 오류(0 나눗셈, 넘침 등) | 비정상 종료 |
SIGILL | 잘못된 명령을 실행 | 비정상 종료 |
SIGINT | 대화형 주의 신호(대개 Ctrl+C) | 종료 |
SIGSEGV | 잘못된 기억 접근 | 비정상 종료 |
SIGTERM | 종료 요청 | 종료 |
표 81.3 — 표준 신호의 이름과 기본 동작
번호 값은 표준이 정하지 않는다 — 시연이 인쇄한 2·15 같은 값은 이 구현의 것이다. 이름으로 쓰고 번호로 쓰지 않는다가 규칙이다.
81.3 처리기 안에서 무엇을 할 수 있는가#
이 장에서 가장 중요한 절이다. 표준(§7.14.1.1)은 처리기 안에서 허용되는 것을 열거로 정한다. 그 목록 밖은 정의되지 않은 동작이다.
| 허용되는 것 | 조건 |
|---|---|
volatile sig_atomic_t 객체에 값을 대입하기 | 읽는 것이 아니라 대입 |
| 락 없는 원자적 객체 다루기 | <stdatomic.h>, 락이 없을 때(85장) |
abort 부르기 | — |
_Exit 부르기 | — |
quick_exit 부르기 | — |
signal 부르기 | 자기를 부른 그 신호 번호로만 |
표 81.4 — 처리기 안에서 허용되는 것
그 밖의 표준 라이브러리 함수는 전부 금지다 — printf, malloc, strlen, exit까지. 그리고 정적·스레드 저장 기간의 객체를 만지는 것도, 위의 첫 줄에 해당하지 않으면 금지다.
흔한 오해. “처리기에서 printf로 로그를 남기면 편하다”
가장 흔하고 가장 오래가는 사고다. printf는 내부에 버퍼와 잠금을 둔 함수라, 중간 상태에 끼어들면 자료구조가 깨진다. 운이 좋으면 출력이 뒤섞이고, 나쁘면 교착(deadlock)이다 — printf가 잠금을 쥔 순간 신호가 와서 처리기가 다시 printf를 부르면, 자기가 쥔 잠금을 자기가 기다린다.
더 나쁜 것은 대개는 잘 돌아간다는 점이다. 그래서 이 버그는 시험을 통과하고 배포된 뒤, 부하가 높을 때만 가끔 멈추는 형태로 나타난다.
유닉스에서 굳이 처리기에서 무언가 인쇄해야 한다면 write를 쓴다 — POSIX가 비동기 신호 안전(async-signal-safe)이라고 따로 보장한 함수다. 시연의 두 번째 예제가 그렇게 한다.
81.3.1 sig_atomic_t의 정체#
sig_atomic_t는 신호가 중간에 끼어들어도 값이 반쪽이 되지 않는 정수 타입 이다. 표준의 정의를 그대로 옮기면 이렇다 — 「비동기 인터럽트가 있는 상황에서도 원자적 실체로 접근할 수 있는 정수 타입」(C23 §7.14). ★ 이 타입이 왜 언어에 있는지의 답이 그 한 구절에 있다. 신호는 프로그램 안의 일이지만, 그 뒤에 서 있는 것은 기계가 흐름을 가로채는 일이다. 왜 이런 타입이 따로 있는가 — 큰 정수는 기계에 따라 두 번의 저장으로 나뉠 수 있고, 그 사이에 신호가 오면 반만 바뀐 값이 보이기 때문이다.
volatile이 함께 붙는 이유는 다른 데 있다. 컴파일러는 “이 루프 안에서 아무도 stop을 바꾸지 않는다”고 판단해 검사를 통째로 지울 수 있다(14장의 최적화). volatile은 매번 실제로 읽으라는 표시다. 둘의 역할이 다르므로 둘 다 필요하다 — volatile sig_atomic_t.
| 무엇이 | 무엇을 막는가 | 없으면 |
|---|---|---|
sig_atomic_t | 값이 반쪽으로 보이는 것 | 반쯤 갱신된 값을 읽을 수 있다 |
volatile | 컴파일러가 읽기를 생략하는 것 | 루프가 깃발을 영영 보지 못한다 |
표 81.5 — 비동기 안전을 막는 것
C11 이후에는 atomic_int 같은 락 없는 원자적 타입도 처리기에서 쓸 수 있다 (85장). 여러 갈래로 도는 프로그램에서는 이쪽이 더 정확한 선택이다 — sig_atomic_t는 신호 대 주 흐름만 보장하지, 스레드 사이를 보장하지 않는다.
81.4 처리기와 기억 — 왜 malloc이 목록에 없는가#
허용 목록에서 가장 아쉬운 자리가 기억 할당이다. 처리기 안에서 무언가 기록해 두려면 그릇이 필요한데, malloc도 free도 부를 수 없다. 왜 그런지 기계 수준 에서 보면 규칙이 몸에 남는다.
할당자는 자유 목록이라는 자료구조를 관리한다(46장). malloc 한 번은 그 목록에서 조각을 떼어 내고 이웃 조각의 연결을 고쳐 쓰는 여러 단계의 갱신이다. 그 갱신이 절반쯤 진행된 순간이 반드시 존재하고, 신호는 바로 그 순간에 끼어들 수 있다.
| 끼어든 순간 | 처리기가 malloc을 부르면 |
|---|---|
| 자유 목록의 연결을 반쯤 고쳐 놓았을 때 | 반쯤 고쳐진 목록을 따라가 엉뚱한 조각을 내준다 |
| 블록의 크기 정보를 쓰기 직전 | 나중에 free가 잘못된 크기로 반납한다 |
| 할당자의 잠금을 쥐고 있을 때 | 자기가 쥔 잠금을 자기가 기다린다 — 교착 |
표 81.6 — 처리기가 malloc 을 부르면
셋째 줄이 특히 고약하다. 현대의 할당자는 여러 갈래에서 동시에 불릴 것에 대비해 내부에 잠금을 둔다. 그 잠금을 쥔 스레드가 신호를 맞고, 처리기가 다시 malloc을 부르면, 잠금은 영원히 풀리지 않는다. 프로그램이 죽지 않고 멈춘다 — 진단하기 가장 어려운 형태의 사고다.
여기서 더 나쁜 조합이 하나 있다. 처리기에서 longjmp로 빠져나가는 경우다 (82장). 이때는 malloc을 다시 부르지 않아도 문제가 된다 — 잠금을 쥔 채로 뛰어나가기 때문이다. 그 뒤로는 주 흐름의 첫 malloc이 멈춘다. 82장에서 “신호 처리기에서 뛰는 것은 위험하다”고 말하는 근거의 절반이 이것이다.
문. 그러면 처리기에서 무언가 기록해 두어야 할 때는 어떻게 하는가?
답. 미리 잡아 둔다. 처리기를 걸기 전에 필요한 그릇을 할당해 두고, 처리기는 거기에 쓰기만 한다. 정적 배열이면 더 좋다 — 할당 자체가 없다.
static char report[4096]; /* 미리 확보 */
static volatile sig_atomic_t report_len;기록의 양이 정해지지 않는 경우라면 애초에 처리기에서 할 일이 아니다. 깃(git)발을 세우고 주 흐름이 하도록 넘긴다. 정말로 지금 남겨야 한다면 — 충돌 보고서처럼 다음 순간에 죽을 것이 확실한 상황이라면 — 미리 잡아 둔 버퍼에 담아 write로 곧장 내보낸다. 이것이 뒤에 볼 레디스의 충돌 보고서가 하는 일이다.
81.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(82장) | |
|---|---|---|
| 저장하는 주체 | 커널 | setjmp 매크로 |
| 저장 범위 | 레지스터 전부 | 피호출자 보존 레지스터만(여덟 칸) |
| 돌아가는 자리 | 끊긴 바로 그 명령 | setjmp를 부른 자리 |
| 지역 변수 | 전부 그대로 | 비 volatile이면 보장 없음 |
표 81.7 — 신호와 longjmp 를 함께 쓸 때
즉 신호는 완전한 문맥 전환이고 longjmp는 부분적인 복원이다. 그래서 처리기는 수식 한복판으로 돌아와도 괜찮지만, longjmp는 표준이 정한 네 문맥 에서만 쓸 수 있다(82장).
81.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을 주는 길을 마련해 두었다. 스택 넘침을 진단해 주는 도구들이 이 장치 위에 선다.
81.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는 버퍼를 거치지 않고 곧바로 나간다(69장의 버퍼 이야기).
이 사소한 관찰이 앞 절의 오개념을 다시 설명해 준다. 처리기와 주 흐름은 같은 버퍼를 공유하지만 같은 규칙으로 쓰지 않는다. 그래서 시연은 fflush로 순서를 맞췄고, 실무에서는 아예 처리기에서 인쇄하지 않는 쪽을 택한다.
81.7 POSIX의 sigaction — 표준이 남긴 빈틈을 메운다#
유닉스 계열에서 실제로 쓰는 것은 sigaction이다. 표준 signal의 세 가지 빈틈을 정확히 메운다.
| 빈틈 | 표준 signal | sigaction |
|---|---|---|
| 처리 후 처리기가 남는가 | 구현 정의(시연에서 사라졌다) | 남는다(SA_RESETHAND를 주지 않는 한) |
| 처리 중 같은 신호가 또 오면 | 구현 정의 | 기본으로 막힌다. sa_mask로 더 막을 수 있다 |
| 끊긴 시스템 호출 | 정해 주지 않는다 | SA_RESTART로 자동 재시작 |
표 81.8 — 표준 signal 의 빈틈과 sigaction
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
81.7.1 struct sigaction의 속#
이 구조체가 이 장의 자료형 이야기다. POSIX가 정한 멤버는 넷이고, 순서는 정하지 않는다(그래서 지정 초기화나 memset으로 시작해야 한다).
| 멤버 | 타입 | 무엇인가 |
|---|---|---|
sa_handler | void (*)(int) | 평범한 처리기. 표준 signal과 같은 모양 |
sa_sigaction | void (*)(int, siginfo_t *, void *) | SA_SIGINFO를 줄 때 쓰는 쪽. 정보가 더 온다 |
sa_mask | sigset_t | 이 처리기가 도는 동안 추가로 막을 신호들 |
sa_flags | int | 동작을 고르는 깃발들(아래) |
표 81.9 — siginfo_t 의 멤버
sa_handler와 sa_sigaction은 대개 공용체로 겹쳐 있다(48장). 그래서 둘 중 하나만 채워야 하고, 어느 쪽인지는 SA_SIGINFO 깃발이 말한다.
주요 깃발은 넷이다.
| 깃발 | 뜻 |
|---|---|
SA_SIGINFO | 세 인자짜리 처리기를 쓴다 — 누가·왜 보냈는지가 들어온다 |
SA_RESTART | 신호에 끊긴 시스템 호출을 자동으로 다시 시작한다 |
SA_NOCLDWAIT | 자식이 끝나도 좀비를 남기지 않는다(SIGCHLD) |
SA_RESETHAND | 옛 방식대로 한 번 처리하면 기본으로 되돌린다 |
표 81.10 — sigaction 깃발의 뜻
81.7.2 siginfo_t — 누가, 왜 보냈는가#
SA_SIGINFO를 주면 처리기가 siginfo_t *를 받는다. 자주 쓰는 멤버는 이렇다.
| 멤버 | 무엇인가 |
|---|---|
si_signo | 신호 번호 |
si_code | 왜 왔는가 — SI_USER(kill), SI_KERNEL, SI_TIMER 등 |
si_pid | 보낸 프로세스의 번호 |
si_uid | 보낸 사용자의 번호 |
si_addr | SIGSEGV·SIGBUS일 때 문제가 난 주소 |
표 81.11 — 신호 집합을 다루는 멤버
시연이 si_code를 인쇄한다. raise로 자기에게 보냈더니 리눅스는 SI_TKILL (−6)을 넣었다 — “같은 스레드가 보냈다”는 뜻이다. 값의 이름과 의미는 구현마다 다르므로, 분기의 근거로 삼기 전에 그 플랫폼의 문서를 확인해야 한다.
si_addr은 디버깅에서 요긴하다. SIGSEGV 처리기에서 이 값을 기록해 두면 “어느 주소를 만지다 죽었는가”가 남는다 — 다만 그 처리기 안에서는 인쇄할 수 없으므로(허용 목록), 대개 write로 원시 바이트를 남기고 _Exit로 끝낸다.
81.7.3 신호를 잠시 막아 두기 — 마스크#
sigprocmask는 “지금은 이 신호를 받지 않겠다”고 선언한다. 막힌 동안 온 신호는 사라지지 않고 대기(pending)하다가, 막기를 푸는 순간 배달된다. 시연의 마지막 묶음이 그 장면이다 — 막아 둔 채 raise하니 처리 횟수가 그대로였고, 풀자마자 올라갔다.
이것이 필요한 자리는 분명하다. 자료구조를 고치는 동안 신호가 끼어들면 안 될 때, 그 구간만 막는다. 신호에도 임계 구역을 두는 셈이다.
81.8 실제 사용 사례#
이제 실무에서 신호가 실제로 어떤 일을 하는지 본다.
| 신호 | 쓰임 | 대표 사례 |
|---|---|---|
SIGTERM | 정상 종료 요청 — 정리할 시간을 준다 | kill의 기본값, 도커·쿠버네티스의 컨테이너 종료 |
SIGINT | 사용자의 중단(Ctrl+C) | 명령줄 도구가 진행 중인 일을 접는다 |
SIGKILL | 즉시 죽인다 — 잡을 수 없다 | 정상 종료가 실패했을 때의 마지막 수단 |
SIGHUP | 설정 다시 읽기(관행) | nginx·아파치의 무중단 설정 반영 |
SIGCHLD | 자식이 끝났다 | 셸과 서버가 좀비를 거둔다 |
SIGPIPE | 읽는 쪽이 사라진 파이프에 썼다 | 서버가 대개 무시하고 EPIPE로 처리한다 |
SIGWINCH | 터미널 창 크기가 바뀌었다 | vim·top이 화면을 다시 그린다 |
SIGUSR1·SIGUSR2 | 응용이 뜻을 정한다 | nginx의 무중단 실행 파일 교체, 로그 파일 다시 열기 |
표 81.12 — 신호별 쓰임과 대표 사례
81.8.1 정상 종료 — 가장 널리 쓰이는 패턴#
컨테이너 세계에서 특히 중요해졌다. 오케스트레이터가 컨테이너를 내릴 때 먼저 SIGTERM을 보내고, 유예 시간(대개 30초) 안에 끝나지 않으면 SIGKILL로 죽인다. 그래서 서버는 SIGTERM을 받으면 새 요청은 받지 않고, 진행 중인 요청을 마치고, 연결을 닫고 내려가야 한다.
앞 절의 깃발 패턴이 정확히 이 일을 한다. 처리기는 stop = 1만 하고, 주 루프가 그 값을 보고 정리 절차를 밟는다.
81.8.2 SIGPIPE — 무시하는 것이 정답인 신호#
파이프나 소켓에서 읽는 쪽이 먼저 사라졌는데 쓰면 SIGPIPE가 온다. 기본 동작은 프로세스 종료다 — 웹 서버라면 클라이언트가 창을 닫았다는 이유로 서버가 죽는 셈이다.
그래서 네트워크 프로그램은 거의 예외 없이 이렇게 시작한다.
signal(SIGPIPE, SIG_IGN); /* 신호 대신 write 가 -1/EPIPE 를 돌려주게 한다 */무시로 돌려놓으면 write가 실패를 값으로 돌려준다(errno == EPIPE). 80장의 “실패는 값으로”가 여기서도 낫다는 판단이다.
81.8.3 시스템 호출이 끊기는 문제 — EINTR#
신호가 오면 read·write 같은 느린 시스템 호출이 중간에 끊겨 −1을 돌려주고 errno에 EINTR가 담긴다. 이것을 모르면 “가끔 읽기가 실패한다”는 유령 버그가 된다.
처방은 둘이다. 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 계열 에는 kqueue의 EVFILT_SIGNAL이 있다. 이름은 달라도 발상은 같다 — 신호를 비동기 방해가 아니라 줄 서는 사건으로 바꾼다.
문. 스레드가 여럿인 프로그램에서 신호는 어디로 가는가?
답. 까다로운 자리이고, 규칙을 알아 두지 않으면 재현이 어려운 버그가 된다.
프로세스 전체로 보낸 신호(kill)는 그 신호를 막지 않은 스레드 중 아무 하나 에게 배달된다 — 어느 스레드인지는 정해져 있지 않다. 반면 pthread_kill은 지정한 스레드로 간다. 그리고 처리기는 프로세스 전체가 공유하지만, 마스크는 스레드마다 따로다.
그래서 실무의 정석은 이렇다 — 모든 스레드에서 신호를 막아 두고, 전담 스레드 하나만 sigwait로 기다린다. 그러면 신호가 비동기 방해가 아니라 평범한 함수 반환으로 바뀌고, 그 스레드 안에서는 printf든 malloc이든 마음대로 쓸 수 있다. 앞의 signalfd와 같은 발상이다.
81.8.4 어떤 소프트웨어가, 왜 신호를 쓰는가#
신호를 쓰는 이유는 소프트웨어마다 다르고, 그 이유를 보면 이 장치의 자리가 분명해진다.
| 무엇 | 무엇에 쓰는가 | 왜 신호여야 했는가 |
|---|---|---|
| nginx·아파치 | SIGHUP 설정 재적재, SIGUSR2 실행 파일 교체 | 운영자가 밖에서 안으로 지시할 수 있는 가장 값싼 통로. 포트도 소켓도 필요 없다 |
| PostgreSQL | 질의 취소·종료 요청(처리기는 깃발만) | 연결마다 프로세스가 하나씩인 구조라, 프로세스 사이 신호가 곧 통신 수단 |
| 레디스 | SIGSEGV·SIGBUS 처리기로 충돌 보고서 | 죽는 순간의 상태를 남길 마지막 기회. 미리 잡아 둔 버퍼에 담아 write로 내보낸다 |
| HotSpot JVM | SIGSEGV로 널 검사와 세이프포인트 | 정상 경로에서 검사 명령을 아예 없애고, 드문 경우만 하드웨어 트랩에 맡긴다 |
| 웹어셈블리 런타임 | SIGSEGV·SIGBUS 트랩 처리기로 경계 밖 접근 | 경계 검사를 가드 페이지에 맡겨 매 접근의 비교 명령을 없앤다 |
| 보엠 GC | mprotect+SIGSEGV로 쓰기 장벽, 신호로 스레드 정지 | 언어의 도움 없이 C 프로그램에 수집기를 얹는 유일한 길 |
| libuv·Node.js | 전담 스레드와 파이프로 신호를 사건으로 바꾼다 | 이벤트 루프에 섞으려면 신호가 파일 서술자가 되어야 한다 |
| CPython | 처리기는 깃발만, 바이트코드 루프가 검사 | 인터프리터 상태를 처리기에서 만질 수 없다 — 허용 목록 때문 |
| libcurl | SIGPIPE 무시, 옛 판은 SIGALRM으로 이름 풀이 시간 제한 | 시간 제한을 걸 다른 수단이 없던 시절의 유산. 위험해서 끄는 스위치(CURLOPT_NOSIGNAL)를 따로 두었다 |
표 81.13 — 신호여야 했던 이유
세 갈래로 정리된다. 바깥에서 들어오는 지시(nginx·PostgreSQL — 운영의 통로), 하드웨어 트랩을 사용자 코드로 올리기(JVM·웹어셈블리·GC·레디스 — 정상 경로를 비우고 예외만 신호로), 그리고 신호를 사건으로 바꾸기(libuv·CPython — 허용 목록의 제약을 우회하는 현대적 처방).
가운데 갈래가 특히 흥미롭다. 「널 검사를 없애고 SIGSEGV로 잡는다」는 발상은 드문 일에 비용을 몰아주는 최적화의 전형이다 — 정상 경로에서 명령 하나를 지우는 대가로, 사고가 났을 때 커널·처리기를 거치는 긴 길을 감수한다. 12장의 “드문 분기는 비싸도 좋다”와 같은 계산이다.
반례. 처리기에서 곧바로 정리하기
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는 손으로 저장·복원 |
| POSIX | sigaction이 세 빈틈(재설정·중복·EINTR)을 메운다 |
| 실무 | 처리기는 깃발, 정리는 주 흐름. SIGPIPE는 무시 |
| 현대적 대안 | signalfd·kqueue·전담 스레드 — 신호를 사건으로 바꾼다 |
표 81.14 — 신호 — 기억할 것
바깥에서 날아오는 방해를 다뤘다. 다음 장은 그 반대편 — 프로그램이 스스로 흐름을 끊고 위층으로 뛰어오르는 장치, setjmp와 longjmp다.