[Daily morning study] 리눅스 시그널(Signal) 처리 메커니즘
#daily morning study
시그널(Signal)이란
시그널은 유닉스/리눅스 운영체제에서 프로세스 간 또는 커널이 프로세스에게 비동기적으로 알림을 전달하는 소프트웨어 인터럽트다. 특정 이벤트(사용자 입력, 오류, 타이머 만료 등)가 발생했음을 프로세스에 알리고, 프로세스는 이에 반응해 기본 동작을 수행하거나 커스텀 핸들러를 실행한다.
시그널의 주요 사용 목적:
- 프로세스 종료 요청 (
SIGTERM,SIGKILL) - 프로그램 오류 통보 (
SIGSEGV,SIGFPE) - 프로세스 일시 정지 / 재개 (
SIGSTOP,SIGCONT) - 자식 프로세스 종료 통보 (
SIGCHLD) - 사용자 정의 이벤트 전달 (
SIGUSR1,SIGUSR2)
주요 시그널 종류
| 시그널 번호 | 이름 | 기본 동작 | 설명 |
|---|---|---|---|
| 1 | SIGHUP | 종료 | 터미널 연결 끊김, 데몬 재시작 관례로도 사용 |
| 2 | SIGINT | 종료 | Ctrl+C 입력 시 발생 |
| 3 | SIGQUIT | 코어 덤프 후 종료 | Ctrl+\ 입력 시 발생 |
| 9 | SIGKILL | 강제 종료 | 무시 불가, 블록 불가 |
| 11 | SIGSEGV | 코어 덤프 후 종료 | 잘못된 메모리 참조(세그멘테이션 폴트) |
| 15 | SIGTERM | 종료 | 종료 요청(기본값), 무시/처리 가능 |
| 17 | SIGCHLD | 무시 | 자식 프로세스 종료/정지/재개 시 부모에게 전달 |
| 18 | SIGCONT | 프로세스 재개 | 정지된 프로세스를 재개 |
| 19 | SIGSTOP | 프로세스 정지 | 무시 불가, 블록 불가 |
| 30/31 | SIGUSR1/SIGUSR2 | 종료 | 사용자 정의 목적 |
kill -l 명령으로 시스템에서 지원하는 전체 시그널 목록을 확인할 수 있다.
시그널 전달 과정
이벤트 발생
│
▼
커널이 시그널 생성 → 대상 프로세스의 PCB에 pending 비트 설정
│
▼
프로세스가 커널 모드 → 사용자 모드로 전환될 때 pending 시그널 확인
│
├── 시그널이 블록(마스크)되어 있으면 → 대기
│
└── 블록되지 않았으면 → 시그널 핸들러 실행
시그널은 비동기적으로 전달된다. 즉, 프로세스가 현재 어떤 코드를 실행하고 있든 상관없이, 커널 모드에서 사용자 모드로 복귀하는 시점에 pending 시그널을 처리한다.
시그널에 대한 세 가지 반응
1. 기본 동작(Default Action)
시그널별로 정해진 기본 동작을 수행한다. 대부분 프로세스 종료이며, 일부는 코어 덤프 생성, 무시, 정지 등이다.
2. 무시(Ignore)
SIG_IGN으로 설정하면 해당 시그널을 무시한다. SIGKILL과 SIGSTOP은 무시할 수 없다.
#include <signal.h>
signal(SIGTERM, SIG_IGN); // SIGTERM 무시
3. 사용자 정의 핸들러(Custom Handler)
signal() 또는 sigaction()으로 커스텀 핸들러를 등록한다. sigaction()이 더 안정적이고 POSIX 표준에 맞는 방법이다.
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
void handle_sigterm(int signum) {
printf("SIGTERM 수신: 정리 작업 중...\n");
// 파일 닫기, 메모리 해제 등 정리 로직
_exit(0);
}
int main() {
struct sigaction sa;
sa.sa_handler = handle_sigterm;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGTERM, &sa, NULL);
while (1) {
printf("실행 중...\n");
sleep(1);
}
return 0;
}
시그널 마스킹(Signal Masking)
특정 코드 구간에서 시그널이 처리되는 것을 막고 싶을 때 시그널 마스크를 사용한다. 마스크된 시그널은 블록되고, pending 상태로 대기하다가 마스크 해제 후 전달된다.
#include <signal.h>
sigset_t mask, oldmask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM); // SIGTERM을 마스크 집합에 추가
// 시그널 블록 시작
sigprocmask(SIG_BLOCK, &mask, &oldmask);
// 임계 구역 (Critical Section) — SIGTERM이 처리되지 않음
do_critical_work();
// 시그널 블록 해제 — 이후 pending된 SIGTERM 처리됨
sigprocmask(SIG_SETMASK, &oldmask, NULL);
SIGKILL과 SIGSTOP은 마스킹도 불가능하다.
signal() vs sigaction()
| 항목 | signal() | sigaction() |
|---|---|---|
| 표준 | ANSI C | POSIX |
| 이식성 | 플랫폼마다 동작 다름 | POSIX 환경에서 일관됨 |
| 핸들러 내 마스킹 | 플랫폼 의존적 | sa_mask로 세밀하게 제어 |
| 재진입 가능 여부 | 보장 안 됨 | SA_RESTART 플래그로 제어 |
| 권장 여부 | 구형 코드 | 신규 코드에서 권장 |
sigaction()을 사용하면 핸들러 실행 중 블록할 추가 시그널을 sa_mask로 지정하고, SA_RESTART 플래그로 인터럽트된 시스템 콜을 자동 재시작할 수 있다.
재진입 안전 함수(Async-Signal-Safe Functions)
시그널 핸들러는 비동기적으로 실행되기 때문에, 핸들러 안에서는 재진입 안전(async-signal-safe) 함수만 호출해야 한다.
위험한 함수 (핸들러 내 사용 금지):
printf(),malloc(),free()— 내부적으로 잠금 사용exit()— 정리 함수 호출 → 대신_exit()사용
안전한 함수:
write(),read(),open(),close()kill(),signal(),sigaction()_exit()
시그널 전달 방법
kill() 시스템 콜
#include <signal.h>
kill(pid, SIGTERM); // 특정 프로세스에 시그널 전달
kill(0, SIGTERM); // 같은 프로세스 그룹 전체에 전달
kill(-1, SIGTERM); // 전송 권한이 있는 모든 프로세스에 전달
쉘에서 시그널 전달
kill -SIGTERM <pid> # SIGTERM 전달
kill -9 <pid> # SIGKILL (강제 종료)
kill -SIGHUP <daemon_pid> # 데몬 설정 재로드 관례
pkill -SIGTERM <process_name> # 이름으로 전달
raise()
자기 자신에게 시그널을 전달한다.
raise(SIGUSR1); // 자기 자신에게 SIGUSR1 전달
SIGTERM vs SIGKILL
두 시그널의 차이를 이해하는 것이 실제 운영 환경에서 중요하다.
| 항목 | SIGTERM (15) | SIGKILL (9) |
|---|---|---|
| 처리 | 가능 (무시, 커스텀 핸들러) | 불가 |
| 정리 작업 | 가능 | 불가 (즉시 강제 종료) |
| 사용 시점 | 정상 종료 요청 시 먼저 시도 | SIGTERM 무시 시 최후 수단 |
| 좀비 프로세스 | 발생 가능 | 발생 가능 |
일반적으로 프로세스 종료 시 SIGTERM을 먼저 보내고, 일정 시간 후에도 종료되지 않으면 SIGKILL을 사용하는 패턴을 따른다.
시그널과 멀티스레드
멀티스레드 환경에서는 시그널 처리가 복잡해진다.
- 프로세스 전체에 전달된 시그널은 임의의 스레드가 처리한다.
- 특정 스레드에 시그널을 전달하려면
pthread_kill()사용 - 일반적인 패턴: 전용 시그널 처리 스레드를 두고, 나머지 스레드에서는 시그널을 모두 마스킹
// 전용 시그널 처리 스레드 패턴
void *signal_handler_thread(void *arg) {
int sig;
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGHUP);
while (1) {
sigwait(&mask, &sig); // 시그널 대기 (블로킹)
if (sig == SIGTERM) {
// 정리 작업 후 종료
break;
} else if (sig == SIGHUP) {
reload_config();
}
}
return NULL;
}
int main() {
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGHUP);
// 메인 및 이후 생성될 모든 스레드에서 이 시그널 블록
pthread_sigmask(SIG_BLOCK, &mask, NULL);
// 전용 핸들러 스레드 생성
pthread_t handler_thread;
pthread_create(&handler_thread, NULL, signal_handler_thread, NULL);
// 나머지 작업 스레드들 생성 및 실행
// ...
pthread_join(handler_thread, NULL);
return 0;
}
실전 패턴: 데몬 프로세스의 시그널 처리
리눅스 데몬에서 흔히 사용하는 시그널 처리 패턴:
#include <signal.h>
#include <syslog.h>
volatile sig_atomic_t running = 1; // sig_atomic_t: 원자적 읽기/쓰기 보장
void handle_term(int sig) {
running = 0;
}
void handle_hup(int sig) {
// SIGHUP: 설정 파일 재로드 신호로 관례상 사용
reload_configuration();
}
int main() {
struct sigaction term_action = { .sa_handler = handle_term };
struct sigaction hup_action = { .sa_handler = handle_hup };
sigaction(SIGTERM, &term_action, NULL);
sigaction(SIGINT, &term_action, NULL);
sigaction(SIGHUP, &hup_action, NULL);
openlog("mydaemon", LOG_PID, LOG_DAEMON);
while (running) {
do_work();
}
syslog(LOG_INFO, "데몬 정상 종료");
closelog();
return 0;
}
volatile sig_atomic_t를 쓰는 이유: 컴파일러 최적화로 인해 핸들러가 변경한 값을 메인 루프가 읽지 못하는 상황을 방지하기 위함이다.
정리
- 시그널은 비동기적 프로세스 간 통신 수단으로, 커널 모드→사용자 모드 전환 시 처리된다.
SIGKILL,SIGSTOP은 무시·블록 불가 — 커널이 직접 처리한다.sigaction()이signal()보다 이식성과 안정성 면에서 우수하다.- 시그널 핸들러 내에서는 async-signal-safe 함수만 사용해야 한다.
- 멀티스레드 환경에서는 전용 시그널 처리 스레드 패턴이 권장된다.
- 프로세스 종료 시 SIGTERM → 대기 → SIGKILL 순서가 모범 사례다.