Search
Duplicate
📶

[Unix] Signal Safety

간단소개
시그널 핸들러란 무엇인지, ‘안전한’ 시그널 핸들러를 만들기 위해서는 어떻게 해야 하는지 알아보았습니다.
팔만코딩경 컨트리뷰터
ContributorNotionAccount
주제 / 분류
unix
Scrap
태그
POSIX
9 more properties

[Unix] Signal Safety

last update 23.12.16

1. Unix Signal

리눅스의 시그널은 가장 단순한 IPC 기법 중 하나이다. 운영체제는 프로세스의 상태를 변화시켜야 할 때 시그널을 보내 프로세스의 상태를 변화시킨다. 시그널이 전달되면 운영체제는 프로세스의 동작을 멈추고(interrupt process's flow of execution 한다고 표현; 하드웨어의 interrupt와는 다른 의미이다), 시그널의 의미에 맞는 특정 동작들을 시행하도록 한다.
시그널은 Unix 시스템의 다양한 부분에서 사용되고 있다. 예를 들어, 터미널에서 사용하는 다양한 단축키들도 시그널을 이용한다.
Ctrl+C를 누르면 SIGINT 시그널이 전달되어 프로세스를 중단할 수 있다.
Ctrl+Z는 SIGTSTP 시그널을 전달하여 터미널을 재시작 할 때 까지 (보통 fg 명령을 사용) 프로세스를 일시적으로 멈출 수 있다.
창 크기가 변경되었을 떄에는, SIGWINCH 시그널을 전달하여 바뀐 크기에 맞춰 화면 내용을 다시 출력하도록 만든다.
C언어와 같은 저수준 언어를 다룰 때 볼 수 있는 다양한 에러들도 시그널로 전달된다.
프로세스가 잘못된 메모리 영역을 접근(ex. 할당된 메모리 이상으로 힙 메모리에 접근)해 Segmentation Fault가 발생하면 운영체제는 SIGSEGV 시그널을 전달해 오류가 발생했음을 알린다.
Bus Error 또한 SIGBUS 시그널로 처리된다.
0으로 나누는 등의 잘못된 연산을 할 때에는 하드웨어에서 운영체제로 interrupt를 발생시키고 운영체제에서 프로세스로 SIGFPE 시그널을 전달해 오류를 알린다.
시그널은 서비스를 다룰 때에도 유용하게 사용된다.
fork 등의 방법으로 생긴 자식 프로세스가 종료되면 부모 프로세스에게 SIGCHLD 시그널을 전달해 종료를 알린다.
이미 닫힌 pipe를 통해 통신하려고 하면 운영체제가 SIGPIPE를 전달해 문제가 발생했음을 알린다.
서비스의 종료가 필요할 때, service 매니저는 서비스 데몬에 SIGTERM을 전달해 차례로 자식 프로세스를 종료하고 정상적으로 종료하도록 알린다.(graceful termination)
만약 정상종료가 실패하면, SIGKILL을 전달해 데몬을 강제 종료한다.
이외에도 많은 시그널이 있다.
주의할 점은, minitalk 과제 설명에도 나와있듯 리눅스 시스템은 signal queue를 지원하지 않는다는 점이다.
If a subsequent occurrence of a pending signal is generated, it is implementation-defined as to whether the signal is delivered or accepted more than once in circumstances other than those in which queuing is required.
이는 POSIX 표준에서 (signal mask를 이용하면) 한 개의 시그널까지는 pending 상태로 받아두지만, 그보다 많은 시그널의 처리는 implementation-defined로 정의했기 때문이다.

2. Signal Handling

시그널을 받았을 떄 기본적으로 정해진 동작이 있지만, 프로세스는 시그널 핸들러를 등록해 특정 시그널을 처리할 수 있다. (SIGKILL, SIGSTOP 등 제외) 시그널 핸들러가 등록되면 운영체제는 비동기적으로 시그널 핸들러를 호출한다. 시그널 핸들러의 동작이 끝나고 나면 다시 프로세스의 동작을 이어간다.
시그널 핸들러는 signal(2) 또는 sigaction(2) 시스템 콜을 이용하여 등록할 수 있다. 몇몇 자료들은 signal 함수를 이용해 핸들러를 등록하는 방법을 설명하고 있지만, signal 함수의 man page를 보면 호환성 문제 때문에 sigaction 함수를 사용하라고 권장하고 있다.
호환성 이외에도 sigaction(2) 함수를 이용하면 siginfo_t를 인자로 받아 다양한 추가 정보를 얻을 수 있다는 장점도 있다. 가급적이면 sigaction 함수를 이용하자.

3. man 2 sigaction

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
C
복사
signum: 처리하고자 하는 시그널의 고유 번호이다. (signal.h의 매크로를 이용하면 9 대신 SIGKILL처럼 시그널 이름을 사용할 수 있다)
act: 등록하고자 하는 시그널 핸들러에 대한 정보를 담은 구조체이다.
oldact: 이전에 등록된 시그널 핸들러에 대한 정보를 담을 구조체이다.
sigaction 구조체는 다음과 같은 형태를 가지고 있다:
struct sigaction { void (*sa_handler)(int); void (*sa_sigaction)(int, siginfo_t *, void *); sigset_t sa_mask; int sa_flags; };
C
복사
sa_handler: 인자로 시그널 번호만을 받는 기본적인 핸들러의 포인터. SIG_DFL 또는 SIG_IGN 매크로를 사용하여 기본 시그널 동작을 사용하거나 단순히 시그널을 무시하도록 할 수도 있다.
sa_sigaction: 인자로 siginfo_t, ucontext_t를 추가로 받는 핸들러의 포인터
sa_mask: sigemptyset(3), sigaddset(3) 등으로 설정한 시그널 마스크
sa_flags: 시그널 핸들러의 작동을 구체적으로 설정하기 위한 플래그. 플래그들은 비트마스킹이 되어 있으므로, bitwise OR 연산을 이용해 interrupt된 스레드의 재시작 시 중단된 시스템 콜을 재시작하도록 설정하는 SA_RESTART 등의 플래그를 중첩해서 적용할 수 있다.
주의) POSIX 표준에 따르면 sa_handler와 sa_sigaction은 같은 영역을 가리키고 있을 수 있으므로 반드시 둘 중 하나만을 할당해야 한다.
POSIX 표준에는 위에 적은 네 개의 멤버 변수만을 필수로 정의하고, 나머지는 implementation-defined로 놔두었다.
siginfo_t 구조체는 시그널의 발생 원인, 해당 쓰레드 정보, 시스템 콜/스택 정보 등을 담는다.
si_signo: signal number
si_pid, si_uid: (kill(2) 등을 통해 전달되었을 때) 시그널을 발생한 프로세스에 대한 정보
si_errno, si_addr: (SIGFPE 등의 하드웨어 인터럽트일 때) 오류에 대한 정보
si_code: 왜 시그널이 발생했는지에 대한 정보. FPE_INTOVF, ILL_ILLOPC등 code는 signal.h에 매크로로 정의되어 있다.
이외에도 si_band등 특정 시그널에만 사용되는 멤버 변수도 있고, 운영체제에 따라 si_syscall등의 멤버 변수를 정의하는 경우도 있다.
기본 동작이 프로세스 종료인 SIGINT에 대해 핸들러를 등록해보자. SA_RESETHAND플래그를 적용하지 않는 한 sigaction으로 핸들러를 등록하면 기본 동작은 무시된다. 따라서 아래의 코드를 실행하면 Ctrl+C를 눌러도 프로세스는 종료되지 않고 계속 작동한다:
// idontdie.c #include <stdio.h> #include <signal.h> #include <unistd.h> void handler(int signo, siginfo_t *info, void *ucontext) { printf("i don't die\\n"); return ; } int main(void) { sigset_t mask; sigemptyset(&mask); struct sigaction action; action.sa_sigaction = &handler; action.sa_mask = mask; action.sa_flags = 0; sigaction(SIGINT, &action, NULL); while (1) { printf("loop\\n"); sleep(1); } return 0; }
C
복사
음.. 그런데 이 프로세스를 어떻게 종료시키지?? SIGKILL은 핸들러를 등록할 수 없으므로, kill(1) 명령을 이용해 종료하면 된다: kill -SIGKILL {PID}

4. Signal Safety

시그널 핸들러는 프로세스/스레드의 진행 상황과는 관계없이 비동기적으로 실행되고, 프로세스/스레드의 다른 작업과 관계없이 호출된다. 진행되고 있던 시스템 콜은 interrupt되며, 심지어는 시그널 핸들러가 실행 중일 때 다른 시그널에 의해 실행중이던 핸들러는 interrupt되고 핸들러가 다시 호출될 수도 있다. 멀티스레드 프로세스에 시그널이 전달된 경우, 다른 스레드는 동작을 계속하고 하나의 스레드에서만 스레드 핸들러가 실행될 수도 있다.
그렇다면 시그널 핸들러가 올바르게 처리되기 위해서는 어떻게 해야 할까? 이 문서는 시그널 핸들러가 가져야 할 async-signal-safe에 대해 정의한다.
A function is asynchronous-safe, or asynchronous-signal safe, if it can be called safely and without side effects from within a signal handler context. That is, it must be able to be interrupted at any point to run linearly out of sequence without causing an inconsistent state. It must also function properly when global data might itself be in an inconsistent state.
요악하면, 핸들러의 실행 과정에서 interrupt 되더라도 side effect가 없어야 한다. 여기서 말하는 side effect는 파일/전역 변수/버퍼 등에 접근하여 상태를 변화시키는 것을 말한다.
async-signal-safe한 함수는 그 자체로도 side effect가 없어야 하지만, signal-safe한 함수만 사용해야 한다. signal-safety(7) 문서는 예시로 printf 함수를 든다. 시그널 핸들러 안에서 printf를 사용하는 것은 매우 위험한 작업이다.
표준 라이브러리 stdio.h의 함수들은 buffer management를 이용한다. printf 함수도 버퍼가 다 차거나 / (개행문자 출력 등의 이유로) 버퍼가 flush되면 그때 버퍼에 있던 내용들을 한 번에 출력한다. stdio.h에 있는 모든 함수는 대표적으로 async-unsafe한 함수들이다. 만약 시그널 핸들러에서 printf를 이용해 어떤 문자열을 출력한다고 하자. 버퍼에 출력할 문자열을 복사한 후, 버퍼 크기에 대한 변수를 변경하기 직전에 다시 시그널이 발생하여 핸들러가 interrupt되면, 출력 버퍼는 알 수 없는 상태에 놓이게 된다.
malloc 등의 함수도 async-unsafe하다. 메모리를 할당받기 위해 이용하는 시스템 콜들이 interrupt될 수 있으며, 스레드의 본 작업 중간에 핸들러가 힙 메모리를 할당/해제하면 dangling pointer 등 심각한 문제가 생길 수 있다. 또한 glibc와 같이 thread-safety를 위해 lock를 도입한 경우 핸들러가 호출한 malloc 과정에서 콜이 interrupt되면서 deadlock 상태에 놓이게 될 수도 있다.
signal-safety(7)은 이 async-signal-safe 특성을 다음 두 가지로 나누어 설명한다. GPT 형님의 도움을 받아 설명하면...
재진입성(Reentrancy):
정의: 리엔트란트 함수는 한 번에 하나의 스레드에서만 실행되는 함수를 의미합니다. 이 함수는 안전하게 인터럽트될 수 있고, 중간에 중단되더라도 예상대로 작동할 수 있어야 합니다.
주요 포인트:
1.
로컬 변수 사용:
리엔트 함수는 가능한 한 로컬 변수를 사용해야 합니다. 로컬 변수는 스택에 저장되며 각 함수 호출에 대해 고유한 변수 집합이 있게 됩니다.
2.
전역 변수 및 정적 변수 피하기:
정적이거나 전역 변수를 사용하는 함수는 리엔트하지 않을 수 있습니다. 이러한 변수들은 여러 호출 간에 공유되므로, 사용 시에는 뮤텍스와 같은 동기화 메커니즘을 사용하여 스레드 안전성을 보장해야 합니다.
3.
블록되는 연산 피하기:
리엔트 함수는 블록되는 연산(예: 외부 이벤트를 기다리는 입출력 연산)을 피해야 합니다. 블록되는 연산은 예기치 않은 지연을 유발할 수 있습니다.

원자성 (Atomicity):

정의: 원자성은 하나의 연산이 원자적으로 수행되어 중간에 다른 연산이 간섭하지 않음을 보장하는 특성을 나타냅니다.
주요 포인트:
1.
원자적 데이터 유형 사용:
멀티스레드 또는 시그널 핸들링 환경에서는 일부 데이터 유형이 원자적으로 동작해야 합니다. 예를 들어, sig_atomic_t는 시그널 핸들러에서 안전하게 사용할 수 있는 원자적인 데이터 유형입니다.
2.
원자적 연산 사용:
원자적 연산은 중간에 다른 연산이 간섭하지 않도록 보장합니다. compare-and-swap 등의 원자적 연산을 사용하여 임계 영역을 보호하거나, 원자적인 동기화 기법을 사용하여 스레드 안전성을 확보합니다.
3.
메모리 배리어:
메모리 배리어는 메모리 연산의 순서를 제어하고 다른 스레드 또는 프로세스에서 해당 연산을 볼 수 있도록 하는 데 사용됩니다.
2023.12.16 수정

5. 안전한 핸들러를 만들기 위해서

위에서 언급한 리눅스 메뉴얼은 시그널 핸들러에서 사용 가능한 라이브러리 함수들의 목록을 나열한다. 사용하고자 하는 함수가 async-signal-safe한지 확인하고 사용하면 안전한 핸들러를 만들 수 있다.
원자성을 고려해 스레드의 본 작업에 영향을 미칠 수 있는 변수 접근을 피하고, 재진입성을 고려해 전역 변수 대신 지역 변수를 사용하고 lock을 사용하지 않는 등의 설계를 하는 것도 중요하다.
그러나 최대한 재진입성과 원자성을 고려해 핸들러를 설계하더라도, 모든 경우에 side-effect를 완전히 없애기는 힘들다.
이때 사용할 수 있는 방법이 volatile 키워드와 sig_atomic_t 자료형을 사용하는 것이다. sig_atomic_t로 선언하면 쓰기 작업을 한 opcode로 만들어 값의 일부만 메모리에 들어가고 interrupt되어 side-effect를 남기는 것을 방지한다.
sigmask를 사용하여 핸들러가 실행중일 때 시그널을 block하여 핸들러의 재호출을 막을 수도 있다.