[Daily morning study] 리눅스 시그널(Signal) 처리 메커니즘

#daily morning study

Image


시그널(Signal)이란

시그널은 유닉스/리눅스 운영체제에서 프로세스 간 또는 커널이 프로세스에게 비동기적으로 알림을 전달하는 소프트웨어 인터럽트다. 특정 이벤트(사용자 입력, 오류, 타이머 만료 등)가 발생했음을 프로세스에 알리고, 프로세스는 이에 반응해 기본 동작을 수행하거나 커스텀 핸들러를 실행한다.

시그널의 주요 사용 목적:

  • 프로세스 종료 요청 (SIGTERM, SIGKILL)
  • 프로그램 오류 통보 (SIGSEGV, SIGFPE)
  • 프로세스 일시 정지 / 재개 (SIGSTOP, SIGCONT)
  • 자식 프로세스 종료 통보 (SIGCHLD)
  • 사용자 정의 이벤트 전달 (SIGUSR1, SIGUSR2)

주요 시그널 종류

시그널 번호이름기본 동작설명
1SIGHUP종료터미널 연결 끊김, 데몬 재시작 관례로도 사용
2SIGINT종료Ctrl+C 입력 시 발생
3SIGQUIT코어 덤프 후 종료Ctrl+\ 입력 시 발생
9SIGKILL강제 종료무시 불가, 블록 불가
11SIGSEGV코어 덤프 후 종료잘못된 메모리 참조(세그멘테이션 폴트)
15SIGTERM종료종료 요청(기본값), 무시/처리 가능
17SIGCHLD무시자식 프로세스 종료/정지/재개 시 부모에게 전달
18SIGCONT프로세스 재개정지된 프로세스를 재개
19SIGSTOP프로세스 정지무시 불가, 블록 불가
30/31SIGUSR1/SIGUSR2종료사용자 정의 목적

kill -l 명령으로 시스템에서 지원하는 전체 시그널 목록을 확인할 수 있다.


시그널 전달 과정

이벤트 발생
    │
    ▼
커널이 시그널 생성 → 대상 프로세스의 PCB에 pending 비트 설정
    │
    ▼
프로세스가 커널 모드 → 사용자 모드로 전환될 때 pending 시그널 확인
    │
    ├── 시그널이 블록(마스크)되어 있으면 → 대기
    │
    └── 블록되지 않았으면 → 시그널 핸들러 실행

시그널은 비동기적으로 전달된다. 즉, 프로세스가 현재 어떤 코드를 실행하고 있든 상관없이, 커널 모드에서 사용자 모드로 복귀하는 시점에 pending 시그널을 처리한다.


시그널에 대한 세 가지 반응

1. 기본 동작(Default Action)

시그널별로 정해진 기본 동작을 수행한다. 대부분 프로세스 종료이며, 일부는 코어 덤프 생성, 무시, 정지 등이다.

2. 무시(Ignore)

SIG_IGN으로 설정하면 해당 시그널을 무시한다. SIGKILLSIGSTOP은 무시할 수 없다.

#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);

SIGKILLSIGSTOP은 마스킹도 불가능하다.


signal() vs sigaction()

항목signal()sigaction()
표준ANSI CPOSIX
이식성플랫폼마다 동작 다름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 순서가 모범 사례다.