리눅스 커널의 이해
12

ch11. signals

linuxfilesystemkernel
SIGNALS · CH.11
LECTURE NOTE — LINUX KERNEL, CHAPTER 11

시그널, 프로세스에게 도착하는
초단문 경고장

시그널은 장황한 편지가 아닌, 즉각적인 비상벨에 가깝습니다. 단순한 번호 하나로 사건의 본질을 전달하며, 그 생성과 전달은 커널 내부에서 명확히 구분된 두 단계를 거칩니다. 이 노트에서는 그 두 단계 사이에서 벌어지는 커널의 숨가쁜 움직임을 처음부터 끝까지 추적합니다.

kernel: signal 생성 우편함에 보관 중 handler 실행 / 기본 동작
01

큰 그림: 시그널은 프로세스에게 도착하는 초단문 경고장WHY SIGNALS EXIST

프로그램 실행 중 사용자가 Ctrl+C를 누르거나, 프로세스가 허가되지 않은 메모리 영역을 건드렸을 때, 프로세스는 이 사실을 어떻게 알아챌까요? 커널이 이러한 '위기 상황'을 프로세스에게 통보하는 수단, 그것이 바로 시그널입니다.

시그널은 군더더기 없는 초단문 메시지입니다. 일반적인 편지처럼 구체적인 내용을 싣지 않고, 대개 "몇 번 시그널인가"라는 번호 하나만으로 그 의미를 전달합니다. 예컨대 SIGINT는 키보드 인터럽트를, SIGSEGV는 잘못된 메모리 접근을, SIGCHLD는 자식 프로세스의 상태 변화를 부모에게 알리는 신호로 쓰입니다.

시그널은 문장이라기보다는 '비상벨 번호'에 가깝습니다. 2번 벨이 울리면 인터럽트, 11번 벨이 울리면 메모리 접근 오류, 이렇게 약속된 규칙을 따릅니다.

이 장을 관통하는 핵심 주제는 세 가지입니다. 첫째, 시그널의 존재 이유 — 시스템 이벤트나 프로세스 간의 통신을 알리기 위함입니다. 둘째, 생성과 전달의 원리 — 커널은 우선 "해당 프로세스에 시그널이 대기 중(pending)이다"라고 기록해 두고, 이후 적절한 타이밍에 이를 전달합니다. 셋째, 프로세스의 대응 방식 — 시그널을 무시할 수도, 커널의 기본 동작에 따를 수도, 혹은 직접 작성한 signal handler를 통해 대응할 수도 있습니다.

이 과정을 우체국에 비유해 볼까요? 시그널의 '생성'은 편지가 우체국 창구에 접수되는 순간이고, '전달'은 수신자가 실제로 우편물을 뜯어보고 행동에 나서는 순간입니다. 그 사이, 우편함 속에 편지가 얌전히 놓여있는 상태가 바로 pending signal입니다. 이때 표준 시그널은 똑같은 편지가 여러 통 와도 하나만 남기지만, 실시간(real-time) 시그널은 도착한 편지들을 차곡차곡 줄 세워 보관한다는 점이 다릅니다.

지금부터 우리는 시그널을 단순히 "프로세스를 강제로 죽이는 명령"으로 치부하지 않고, 커널과 프로세스 사이를 잇는 정교한 이벤트 전달 메커니즘으로 바라볼 것입니다.

핵심 O/X 퀴즈

1. 표준 시그널은 보통 시그널 번호 외에 긴 메시지나 여러 인자를 함께 담는다.
X표준 시그널은 번호 하나만으로 의미를 전달하는 초단문 메시지 체계입니다.
2. 시그널 생성과 시그널 전달은 커널 내부에서 명확히 구분되는 단계이다.
O생성은 pending 상태를 기록하는 것이고, 전달은 실제 대응 동작을 수행하는 별개의 단계입니다.
3. real-time signal은 표준 시그널과 달리, 같은 종류가 여러 번 발생하면 큐에 여러 개 쌓일 수 있다.
O표준 시그널은 중복 발생 시 하나만 표시되지만, real-time signal은 발생한 만큼 큐에 쌓입니다(큐잉).
02

시그널의 역할과 기본 종류DEFAULT ACTIONS

시그널의 목적은 명확합니다. 프로세스에게 특정 사건의 발생을 알리고, 그에 대응하는 함수(signal handler)를 실행하도록 유도하는 것입니다.

예컨대 키보드에서 Ctrl+C를 누르면 SIGINT가 발생하고, 이 시그널의 기본 동작은 프로세스 '종료'입니다. 하지만 프로그램이 미리 핸들러를 등록해 두었다면, 무작정 종료되는 대신 "열려있던 파일을 안전하게 저장하고 닫기" 같은 사용자 맞춤형 종료 절차를 밟을 수도 있습니다.

Table 11-1 — 시그널별 기본 동작 (Linux/i386, 1~31번)
TERMINATE SIGHUP · SIGINT · SIGTERM

프로세스를 곧장 종료시킵니다. 분석용 core file은 따로 남기지 않습니다.

DUMP SIGSEGV · SIGILL · SIGFPE

프로세스를 종료하되, 사후 디버깅을 위한 core file을 남길 수 있습니다.

IGNORE SIGCHLD · SIGURG · SIGWINCH

전달은 성공적으로 이루어지지만, 기본적으로 아무런 후속 동작을 취하지 않습니다.

STOP SIGSTOP · SIGTSTP · SIGTTIN · SIGTTOU

프로세스를 종료하지 않고 그 자리에 잠시 멈춰(stop) 세웁니다.

CONTINUE SIGCONT

멈춰 있던 프로세스의 상태를 다시 TASK_RUNNING으로 되돌려 실행을 재개시킵니다.

Terminate와 Dump는 '프로세스를 죽인다'는 결론은 같지만, Dump만 core file을 남길 기회를 제공한다는 점이 다릅니다. Stop은 강제 종료가 아닌 일시 정지 상태로 만들고, Continue는 그 정지 상태를 풀어 다시 달리게 하며, Ignore는 보고는 받되 신경 쓰지 않는 조치입니다.

리눅스 환경에서 1번부터 31번까지는 일반적인 regular signal(표준 시그널)로 분류되고, 32번부터 64번까지는 real-time signal로 다뤄집니다. real-time signal의 가장 큰 무기는 바로 '큐잉(Queueing)'입니다. 만약 SIGUSR1이 10번 연속 쏟아질 때 아직 프로세스가 이를 전달받지 못한 상황이라면, 표준 시그널은 단 한 번만 온 것처럼 pending 표시를 남기지만, real-time signal은 10번의 발생 기록을 모두 고스란히 보존해 줍니다.

핵심 O/X 퀴즈

1. SIGSEGV의 기본 동작은 잘못된 메모리 접근과 관련되며, 통상적으로 dump 동작을 유발할 수 있다.
OSIGSEGV는 프로세스를 비정상 종료시키며 디버깅용 덤프(dump)를 남기는 그룹에 속합니다.
2. SIGCONT는 실행 중인 프로세스를 강제로 종료시키는 시그널이다.
XSIGCONT는 정지(stop) 상태인 프로세스를 다시 실행 상태로 복귀시키는 역할을 합니다.
3. 표준 시그널은 동일한 종류가 여러 번 발생해 pending 상태가 되더라도, 통상적으로 하나만 보존된다.
O표준 시그널은 실시간 시그널처럼 큐잉(queueing)되지 않고, 상태 비트만 켜둡니다.
03

무시, 차단, 잡기IGNORE vs BLOCK vs CATCH

프로세스가 시그널에 대처하는 방식은 크게 세 갈래로 나뉩니다. 명시적으로 무시(ignore)해버리거나, 커널이 미리 정해둔 기본 동작을 묵묵히 따르거나, 직접 시그널 핸들러를 등록해 상황을 통제(catch)하는 것입니다.

이를 다시 우체국 비유로 풀어보자면, '무시'는 편지를 받자마자 뜯지도 않고 휴지통에 던져버리는 행위입니다. 반면 '차단(블로킹)'은 당분간 내 우편함 자체를 열지 않겠다고 선언하는 것입니다. 차단된 시그널은 사라지지 않고 pending 상태로 차곡차곡 쌓여 있다가, 차단이 해제되는 순간 프로세스에게 일제히 전달됩니다.

하지만 이 규칙에도 절대적인 예외가 존재합니다. 바로 SIGKILLSIGSTOP입니다. 이 두 시그널은 결코 무시할 수도, 핸들러로 잡아낼 수도, 차단할 수도 없습니다. 운영체제 입장에서 폭주하는 프로세스를 제어할 최후의 보루가 필요하기 때문입니다. 만약 악의적인 목적을 가졌거나 치명적인 버그가 있는 프로세스가 모든 시그널을 틀어막아버린다면, 시스템 관리자는 이 두 시그널을 통해서만 상황을 통제할 수 있습니다.

어떤 시그널이 전달되었을 때 그 즉시 커널이 프로세스를 죽이도록 강제하는 경우를 가리켜 fatal signal이라 부릅니다. SIGKILL은 태생부터가 fatal이며, 기본 동작이 terminate인 시그널들도 프로세스가 핸들러로 방어하지 않았다면 fatal signal로 돌변합니다. 반면 핸들러 내부에서 프로세스가 스스로 종료 절차를 밟는다면, 이는 커널에 의한 '처형'이 아니라 프로세스의 '자발적 종료'로 간주됩니다.

요약하자면, Ignore는 시그널을 받긴 했으나 무대응으로 일관하는 것, Block은 아예 수신 자체를 뒤로 미루는 것, Catch는 준비된 핸들러를 가동하는 것입니다. 그리고 SIGKILL과 SIGSTOP은 이러한 모든 방어막을 무력화하는 커널의 절대 반지와 같습니다.

핵심 O/X 퀴즈

1. 차단(블로킹)된 시그널은 즉시 버려지며, 나중에 차단이 풀려도 다시 전달되지 않는다.
X차단은 일시적인 지연을 의미하며, 해제되는 순간 다시 전달 대상이 됩니다.
2. SIGKILL과 SIGSTOP은 프로세스 단에서 무시(ignore), 잡기(catch), 차단(block)을 할 수 없도록 설계되었다.
O시스템 제어를 위한 커널의 최후 통제 수단이므로 어떤 방어도 통하지 않습니다.
3. Ignore와 block은 둘 다 시그널 처리를 지연시킨다는 점에서 본질적으로 같은 개념이다.
XIgnore는 전달 직후 무시해버리는 동작이고, block은 전달 시점 자체를 뒤로 미루는 것입니다.
04

멀티스레드 환경의 시그널THREAD GROUP SEMANTICS

POSIX 멀티스레드 환경에서 우리가 흔히 '단일 프로세스'라고 뭉뚱그려 부르는 것은, 실질적으로 여러 경량 프로세스(LWP), 즉 스레드들이 옹기종기 모인 스레드 그룹(thread group)을 의미합니다.

1
핸들러는 공유 자원. 특정 시그널에 대한 핸들러 정책은 애플리케이션 내의 모든 스레드가 똑같이 공유합니다.
2
마스크는 각자도생. 각 스레드는 자신이 어떤 시그널을 차단할지(blocked signal mask), 그리고 현재 자신에게 어떤 시그널이 대기 중인지(pending signal)를 독립적으로 관리할 수 있습니다.
3
그룹 단위 발송. kill()이나 sigqueue() 같은 함수로 보내진 일반적인 시그널은 특정 스레드를 조준하는 것이 아니라 스레드 그룹 전체를 향해 날아갑니다.
4
대표 한 명이 총대. 그룹을 향해 날아온 시그널은, 해당 시그널을 차단(block)하지 않은 스레드들 중 단 하나에게만 전달되어 처리됩니다. 누가 총대를 멜지는 커널이 임의로 결정합니다.

회사 대표 메일함으로 "긴급 협조 요청" 메일이 왔다고 상상해 보세요. 전 직원이 이 메일을 읽고 똑같은 업무를 반복하지 않습니다. 현재 시간이 남는 직원 한 명이 알아서 처리하면 그만입니다. 그러나 만약 "회사 즉각 폐쇄 조치" 같은 무시무시한 공문(fatal signal)이 날아온다면, 메일을 읽은 한 명만 퇴근하는 것이 아니라 회사 전체가 셔터를 내려야 합니다. 이것이 바로 스레드 그룹에 떨어지는 fatal signal의 위력입니다.

이러한 구조 탓에 대기 중인 시그널(pending signal)도 두 종류로 나뉩니다. 특정 스레드를 콕 집어 보낸 것은 private pending signal, 그룹 전체의 우편함에 꽂힌 것은 shared pending signal입니다. 요약하자면 멀티스레드의 시그널 규칙은 "정책은 다 같이, 마스크는 각자, 그룹 시그널 처리는 대표 한 명, 그러나 사형 선고(fatal)는 연대 책임"입니다.

핵심 O/X 퀴즈

1. POSIX 멀티스레드 애플리케이션에서 특정 시그널에 대한 핸들러 설정은 모든 스레드가 공유한다.
O핸들러 정책은 스레드 그룹 전체에 똑같이 적용됩니다.
2. 스레드 그룹으로 날아온 일반 시그널은 항상 그룹 내의 모든 스레드에게 각각 전달되어 실행된다.
X해당 시그널을 차단하지 않은 스레드 중 커널이 선택한 단 하나의 스레드만 대표로 처리합니다.
3. fatal signal이 멀티스레드 애플리케이션에 떨어지면, 처리를 담당한 스레드뿐 아니라 스레드 그룹 전체가 함께 종료된다.
Ofatal signal은 스레드 단위가 아닌 그룹 전체의 생명주기를 끝냅니다.
05

커널 자료구조 1: task_struct에서 signal, sighand까지Figure 11-1

어떤 시그널이 대기 중(pending)인지, 혹은 차단(block)되었는지 등의 상태 정보는 커널의 프로세스 디스크립터와 그 주변 자료구조에 꼼꼼히 기록됩니다. 이 네트워크의 중심에는 task_struct가 자리 잡고 있으며, 여기서 signal, sighand, pending, blocked 같은 주요 필드들이 파생됩니다.

task_struct의 시그널 핵심 필드
signal
signal_struct를 가리키는 포인터입니다. 스레드 그룹 단위로 공유되는 시그널 정보, 특히 그룹용 shared pending queue를 품고 있습니다.
sighand
sighand_struct를 가리킵니다. 1~64번 시그널 각각에 대해 어떤 행동(action)을 취할지 정리된 매뉴얼입니다.
blocked
현재 이 프로세스가 굳게 차단하고 있는 시그널의 목록을 나타내는 비트 마스크(sigset_t)입니다.
pending
이 프로세스 '개인'을 콕 집어 날아온 private pending signal들을 보관하는 우편함입니다.

이를 조직에 비유하면 signal_struct는 '우리 팀 전체가 공유하는 공용 게시판', sighand_struct는 '상황별 대응 매뉴얼 책자'입니다. 반면 task_struct에 직접 붙어 있는 pending은 '내 책상 위에만 올려진 개인 서류함'이고, blocked는 '오늘 하루 내가 절대 받지 않겠다고 선언한 업무 리스트'라고 볼 수 있습니다.

마스크를 표현하는 sigset_t는 두 개의 32비트 unsigned long 배열로 짜여 있어, 도합 64비트의 공간을 제공합니다. 리눅스의 최대 시그널 개수(_NSIG)가 64개이기 때문입니다. 재밌는 점은 시그널 번호 0번은 존재하지 않으므로, 1번 시그널이 비트 인덱스 0번에, 64번 시그널이 비트 인덱스 63번에 매칭된다는 사실입니다.

signal_struct / sighand_struct 주요 필드
count
현재 이 구조체를 참조하는 곳이 몇 군데인지 세는 카운터입니다.
live
그룹 내 살아 숨 쉬는 스레드의 총 개수입니다.
wait_chldexit
자식 프로세스가 종료되기를 목 빠지게 기다리는 wait queue입니다.
shared_pending
스레드 그룹 전체를 수신처로 삼은 shared pending signal들이 담기는 큐입니다.
action[64]
64가지 시그널 각각의 구체적 처리 방침을 담은 배열(sighand_struct 소속)입니다.
siglock
동시 다발적인 접근으로부터 자료구조를 보호하는 든든한 자물쇠입니다.

멀티스레드 애플리케이션에서는 같은 스레드 그룹에 속한 녀석들끼리 signal_structsighand_struct를 공유합니다. 대응 매뉴얼(정책)은 공유하되, 각 스레드의 blockedpending은 독립적으로 가져가는 것—이 절묘한 균형 감각이 바로 POSIX 표준을 완벽히 소화해 내는 리눅스 커널의 핵심 설계 철학입니다.

핵심 O/X 퀴즈

1. task_struct의 blocked 필드는 현재 프로세스가 수신을 거부하고 차단한 시그널들의 집합을 나타낸다.
Oblocked는 sigset_t 자료형으로, 차단할 시그널을 비트로 마스킹해 둡니다.
2. sighand_struct는 shared pending signal queue를 관리할 뿐, 구체적인 핸들러 정보는 알지 못한다.
Xshared_pending 큐는 signal_struct가 관리하며, sighand_struct는 64개의 핸들러 정보(action 배열)를 전담합니다.
3. 리눅스의 sigset_t는 시그널 번호를 비트 위치로 변환하여 관리하는 마스크 구조체로 이해할 수 있다.
O총 64비트 공간을 통해 시그널들의 온/오프 상태를 효율적으로 관리합니다.
06

커널 자료구조 2: sigaction, sigpending, sigqueue, siginfo_tDELIVERY METADATA

k_sigaction은 사용자 모드에 노출되는 sigaction 구조체에 커널의 은밀한 제어 정보가 덧붙여진 구조입니다. 이 구조체를 지탱하는 3대 기둥은 sa_handler, sa_flags, 그리고 sa_mask입니다.

sa_handler가 가질 수 있는 세 가지 얼굴
handler 주소
개발자가 직접 작성한 특정 함수를 실행하라는 뜻입니다.
SIG_DFL
커널이 마련해 둔 기본(Default) 동작을 얌전히 수행하라는 뜻입니다.
SIG_IGN
이 시그널은 완전히 무시(Ignore)하라는 뜻입니다.
sa_flags 핵심 플래그 분석
SA_SIGINFO
핸들러에게 번호뿐 아니라 siginfo_t 구조체를 통해 풍부한 맥락 정보를 함께 던져줍니다.
SA_ONSTACK
핸들러 실행 시 일반 스택이 아닌 미리 지정된 대체 스택(alternative stack) 위에서 뛰놀게 합니다.
SA_RESTART
시그널 때문에 중간에 뚝 끊겨버린 시스템 콜을 부드럽게 자동 재시작할 수 있게 돕습니다.
SA_NODEFER
(SA_NOMASK)
핸들러가 한창 실행되는 중에도 자기 자신과 똑같은 시그널이 또 들어오는 것을 막지(block) 않습니다.
SA_RESETHAND
(SA_ONESHOT)
핸들러를 딱 한 번만 써먹은 뒤, 쿨하게 기본 동작(SIG_DFL)으로 초기화시켜 버립니다.

대기 중인 시그널이 머무는 큐(pending signal queue) 역시 두 곳입니다. 그룹용 shared pending queue와 개인용 private pending queue죠. 이 둘은 모두 sigpending이라는 구조체로 표현되는데, 여기에는 "어떤 번호의 시그널이 대기 중인가?"를 단번에 알려주는 비트 마스크인 signal과, 시그널이 발생한 구체적 정황들을 줄줄이 엮어놓은 sigqueue 연결 리스트(list)가 공존합니다.

그 연결 리스트의 핵심 요소인 sigqueue 내부에는 siginfo_t info가 똬리를 틀고 있습니다. siginfo_t는 특정 시그널 발생 사건을 파헤치는 128바이트짜리 조서와 같습니다. 여기에는 si_signo(어떤 시그널인가), si_errno(연관된 오류 코드), si_code(도대체 누가, 왜 이 시그널을 유발했는가)가 상세히 적힙니다.

si_code의 의미 — "발신자는 누구인가?"
SI_USER
사용자가 kill()이나 raise() 함수를 통해 명시적으로 날린 경우입니다.
SI_KERNEL
커널이 내부적으로 시스템의 이벤트를 감지해 자체 발생시킨 경우입니다.
SI_QUEUE
sigqueue() 함수를 통해 부가 데이터와 함께 정성스럽게 날아온 경우입니다.
SI_TIMER
설정해 둔 POSIX 타이머가 만료되어 울린 알람입니다.
SI_ASYNCIO
비동기 I/O 작업이 성공적으로 마무리되었음을 알리는 신호입니다.
SI_TKILL
tkill() 또는 tgkill()을 통해 특정 스레드를 정확히 저격해 날린 시그널입니다.

_sifields 영역은 시그널의 성격에 따라 카멜레온처럼 모습을 바꿉니다. SIGKILL이라면 발신자의 PID와 UID를 적어두고, SIGSEGV라면 프로세스가 무단 침입하려 했던 사고 현장(메모리 주소)을 꼼꼼히 기록합니다. 다만 일반적인 표준 시그널은 이런 복잡한 정보 없이 쿨하게 번호 하나만으로 소통하는 것이 기본 모델입니다.

핵심 O/X 퀴즈

1. sa_handler는 사용자 정의 함수 주소, SIG_DFL(기본), SIG_IGN(무시) 중 하나의 의미를 취할 수 있다.
O상황에 맞게 세 가지 처리 방향 중 하나를 명시합니다.
2. sigpending 구조체는 비트 마스크 없이 오로지 연결 리스트(linked list)만으로 대기 시그널을 관리한다.
X빠른 조회를 위한 비트 마스크(signal)와 상세 내역을 담은 연결 리스트(list)의 하이브리드 구조입니다.
3. siginfo_t는 단순한 시그널 번호를 넘어, 누가 왜 보냈는지에 대한 구체적인 메타데이터를 품고 있다.
Osi_code, _sifields 등을 통해 사건의 구체적 맥락을 핸들러에 전달합니다.
07

시그널 자료구조 연산: 핵심은 가볍고 빠른 비트 조작BITMASK OPERATIONS

시그널의 존재 여부를 따지는 작업은 대부분 비트 마스크(bit mask)를 통해 눈부신 속도로 처리됩니다. 시그널 번호를 특정 비트 위치로 치환한 뒤, 그 비트의 전원을 켜면(1) "포함", 끄면(0) "제외"가 되는 아주 명쾌한 논리입니다.

시그널 집합 연산의 주인공들
sigemptyset(set)
모든 비트를 0으로 밀어버립니다. 아무것도 없는 텅 빈 상태를 만듭니다.
sigfillset(set)
모든 비트를 1로 켭니다. 가능한 모든 시그널을 방어막에 올리는 셈입니다.
sigaddset(set, nsig)
특정 nsig 번호에 해당하는 비트 전원만 콕 집어 켭니다.
sigdelset(set, nsig)
특정 nsig 번호에 해당하는 비트 전원만 슬쩍 끕니다.
sigismember(set, nsig)
이 시그널이 지금 집합에 포함되어 비트가 켜져 있는지 확인합니다.
sigandsets / sigorsets /
signandsets
두 시그널 집합을 맞대고 AND / OR / NAND 논리 연산을 수행합니다.

내부적인 비트 매핑 공식을 들여다보면, (nsig - 1) / 32 연산을 통해 두 개의 배열 중 어느 쪽에 속하는지 찾고, (nsig - 1) % 32 연산으로 그 안에서 몇 번째 자리인지 정확히 타격합니다. 시그널 번호가 1번부터 시작하기 때문에 nsig - 1로 영점 조정을 하는 것이 포인트입니다.

단순한 연산을 넘어 시그널 전달의 방아쇠 역할을 하는 함수들도 있습니다. signal_pending(p) 함수는 특정 프로세스에게 '차단되지 않은(nonblocked) 대기 시그널'이 단 하나라도 있는지를 감식해 냅니다. 이 함수는 곧 TIF_SIGPENDING 플래그를 검사하는 것인데, 이 플래그가 켜져 있다는 것은 "사용자 모드로 돌아가기 전에 무조건 시그널 처리부터 마무리하라!"는 커널의 엄명과 같습니다.

recalc_sigpending_tsk(t) 함수의 역할은 지대합니다. 개인용(private) 큐와 공용(shared) 큐를 샅샅이 뒤져 차단되지 않은 대기 시그널이 있는지 확인한 후, 그 결과에 따라 TIF_SIGPENDING 플래그를 새로고침합니다. 시그널 마스크를 변경하거나 큐에서 시그널을 빼낼 때마다 상황이 달라지므로, 이 함수를 제때 호출하는 것은 필수입니다.

큐에서 시그널을 덜어내는 rm_from_queue(mask, q), 큐 전체를 깔끔하게 비우는 flush_sigqueue(q), 나아가 개인/공용 큐를 모조리 청소하고 TIF_SIGPENDING까지 초기화하는 flush_signals(t)까지. 복잡해 보이지만 본질은 하나입니다. 빠른 유무 판단은 비트 마스크에게 맡기고, 디테일한 정보는 연결 리스트에 위임하는 분업의 미학입니다.

핵심 O/X 퀴즈

1. 시그널 번호 1은 비트 인덱스 0번과 매칭되므로, 내부 연산에서는 항상 nsig - 1 공식을 사용한다.
O번호 체계와 비트 배열 인덱스 간의 1 오프셋 차이를 보정하기 위함입니다.
2. recalc_sigpending()은 시그널 대기 상태를 재계산하여 TIF_SIGPENDING 플래그를 최신 상태로 갱신하는 중책을 맡는다.
O마스크가 변경되거나 시그널이 소비된 직후 반드시 불려야 하는 함수입니다.
3. flush_sigqueue()는 특정 시그널 번호에 대한 새로운 핸들러 함수를 커널에 등록할 때 사용된다.
X새로운 핸들러를 등록하는 것이 아니라, 쌓여있는 시그널 큐 자체를 깨끗이 비우는 함수입니다.
08

시그널 생성 1: specific_send_sig_info와 send_signalSINGLE-PROCESS DELIVERY PATH

단일 프로세스를 향한 시그널 전송 흐름은 궁극적으로 specific_send_sig_info(sig, info, t) 함수로 수렴합니다. 이때 info 인자가 재미있는데, 값이 0이면 일반 사용자가 보낸 것이고, 1이면 커널이 보낸 것이며, 2라면 커널이 작정하고 보낸 SIGSTOP/SIGKILL 류의 통제 불능 시그널임을 뜻합니다.

1
무시 가능성 검토. 대상이 디버깅(tracing) 상태도 아니고, 시그널이 차단(block)되지도 않았으며, 핸들러가 SIG_IGN이거나 아예 태생부터 무시가 기본인 시그널이라면 — 애초에 큐에 넣을 필요도 없이 쿨하게 전송 프로세스를 접습니다.
2
중복 컷. 보내려는 것이 일반 표준 시그널인데, 이미 큐에 똑같은 녀석이 자리 잡고 있다면 굳이 또 쑤셔 넣지 않습니다.
3
send_signal() 투입. 메모리 여유가 있다면 sigqueue 공간을 할당받아 siginfo_t 조서를 작성해 넣고, 해당 시그널 비트에 불을 켭니다.
4
signal_wake_up() 기상 나팔. 이 시그널이 차단된 상태가 아니라면 TIF_SIGPENDING 플래그를 세팅하고, 깊은 잠(TASK_INTERRUPTIBLE)에 빠져 있던 프로세스의 뺨을 때려 깨웁니다. SIGKILL이라면 멈춰있던(TASK_STOPPED) 녀석도 강제로 기상시킵니다.

시스템에 메모리가 바닥나 sigqueue조차 할당받지 못하는 초유의 사태가 벌어지면 어떨까요? 커널은 구구절절한 정보 기록은 포기하더라도 시그널 비트만큼은 악착같이 켜둡니다. 메모리가 꽉 차 시스템이 다운되기 일보 직전의 벼랑 끝 상황에서도 kill() 명령만큼은 먹혀야 관리자가 폭주하는 프로세스를 처단할 수 있기 때문입니다. 비트 마스크의 위력이 여기서 다시 한번 빛을 발합니다.

결론적으로 '생성' 단계의 역할은 "대기(pending) 상태를 장부에 적고, 잠든 수신자를 흔들어 깨우는 것"까지입니다. 아직 핸들러가 출동하기 전입니다.

핵심 O/X 퀴즈

1. specific_send_sig_info() 함수는 오직 특정 단일 프로세스에게 시그널을 생성해 보낼 때 작동하는 경로이다.
O단일 스레드 타겟팅 전송 경로의 핵심 중추입니다.
2. 표준 시그널은 이미 큐에 대기 중이더라도, 새로 도착할 때마다 sigqueue 리스트에 끝없이 추가된다.
X표준 시그널은 중복을 허용하지 않습니다. 이미 pending 상태라면 쿨하게 무시됩니다.
3. 시스템 메모리가 고갈되어 sigqueue 할당에 실패하더라도, 커널은 시그널 비트를 세팅하여 최소한의 전송 기회를 살려둔다.
O메모리 부족이라는 극한의 상황에서도 시스템 제어권(kill)을 유지하기 위한 커널의 묘수입니다.
09

시그널 생성 2: 그룹의 우편함에 편지 꽂기STOP / CONTINUE 상쇄

스레드 그룹 전체를 대상으로 하는 시그널 전송은 group_send_sig_info(sig, info, p) 함수가 담당합니다. 함수가 호출되면 가장 먼저 시그널 번호가 합당한지 따져보고, 유저 모드에서 날아온 시그널이라면 깐깐한 권한 심사를 거칩니다. 발신자가 충분한 capability를 쥐고 있는지, SIGCONT를 같은 세션 내에서 보냈는지, 혹은 발신자와 수신자의 계정이 일치하는지 확인합니다. 자격 미달이라면 가차 없이 -EPERM 에러를 뱉어냅니다.

여기서 흥미로운 관전 포인트는 시그널 번호(sig)가 0일 때입니다. 0번 시그널은 실제 통신용이 아니라, "내가 이 대상에게 시그널을 보낼 수 있는 권한이 있나?"를 조용히 찔러보는(probe) 용도로 쓰입니다.

"얼음!"과 "땡!" 명령이 우편함에 동시에 도착하면 앞뒤가 맞지 않습니다. 그래서 커널은 상쇄 논리를 폅니다. handle_stop_signal() 과정에서 "얼음!"(SIGSTOP 류) 시그널이 오면 대기 중이던 "땡!"(SIGCONT) 시그널을 즉각 파기해버리고, 반대로 "땡!"이 오면 멈춰있던 녀석들을 깨움과 동시에 대기 중이던 "얼음!" 명령들을 큐에서 싹 지워버립니다. 모순을 원천 차단하는 것이죠.

심사를 무사히 통과하면 해당 시그널은 무시 대상이 아닌지, 표준 시그널이 중복 수신된 것은 아닌지 한 번 더 확인한 후 send_signal()을 통해 그룹 공용 큐(shared pending queue)에 사뿐히 안착합니다. 그다음, __group_complete_signal() 함수가 출동해 그룹 내 여러 스레드 중 누가 총대를 멜지 적임자를 물색합니다.

__group_complete_signal()의 총대 스레드 선발 기준
차단(Block) 여부
당연히 이 시그널을 귀막고 차단(block)한 스레드는 후보에서 탈락합니다.
생존 상태
좀비(zombie), 이미 죽은 상태(dead), 디버깅 중이거나 멈춘 스레드도 제외됩니다. (단, SIGKILL 같은 사형 선고는 예외적으로 멈춘 녀석도 잡아냅니다.)
퇴근(Exit) 여부
이미 프로세스 종료 절차를 밟고 있는 스레드는 짐을 싸게 둡니다.
탐색 순서
함수에 전달받은 프로세스를 최우선으로 찔러보고, 여의치 않으면 이전에 그룹 시그널을 처리했던 스레드를 기점으로 그룹을 한 바퀴 순회하며 적임자를 찾습니다.

만약 도착한 시그널이 fatal signal이라면 적임자를 고를 여유도 없이 그룹 전체에 SIGKILL을 뿌려 다 같이 동반 종료시킵니다. fatal이 아니라면 마침내 선택받은 단 한 명의 스레드에게 signal_wake_up() 기상 나팔을 울려 처리할 업무가 생겼음을 알립니다.

핵심 O/X 퀴즈

1. kill() 함수에서 시그널 번호로 0을 넘기면, 아무런 시그널도 전송하지 않으면서 권한 유무만 체크할 수 있다.
O프로세스의 생존 여부나 전송 권한을 확인할 때 아주 유용하게 쓰이는 기법입니다.
2. SIGCONT 시그널이 들어오면, 큐에서 대기하고 있던 정지(stop) 계열 시그널들은 모두 상쇄되어 사라진다.
O상호 모순되는 명령을 처리하는 커널의 스마트한 교통정리 방식입니다.
3. 스레드 그룹으로 발송된 시그널은 예외 없이 그룹 내의 모든 스레드가 일제히 핸들러를 가동하여 처리한다.
X그룹으로 온 일반 시그널은 선발 기준을 통과한 단 하나의 스레드만 대표로 처리합니다.
10

시그널 전달 1: TIF_SIGPENDING과 do_signal의 관문DELIVERY ENTRY POINT

커널은 시스템 콜이나 인터럽트 처리를 마치고 사용자 모드로 복귀하기 직전, 항상 현재 프로세스의 TIF_SIGPENDING 플래그를 점검합니다. 이 깃발이 펄럭이고 있다면 주저 없이 do_signal() 함수를 호출합니다. 시그널 핸들러는 철저히 사용자 모드에서 도는 코드이므로, 커널 입장에선 "유저 모드로 돌아갈 때 슬쩍 실행 흐름을 꺾어버리는" 이 타이밍이 기가 막히게 적절한 것입니다.

관문 역할을 하는 do_signal(regs, oldset)은 두 개의 티켓을 검사합니다. regs는 유저 모드에서 쓰던 레지스터 값들이 커널 스택에 곱게 보관된 주소이고, oldset은 원래 프로세스가 쓰던 차단 마스크(blocked signal mask) 주소입니다. 만약 시스템이 인터럽트를 처리하다가 커널 모드로 돌아가는 중이었다면(유저 모드 복귀가 아니라면), 시그널 처리를 나중으로 미루고 재빨리 함수를 빠져나갑니다.

1
dequeue_signal() 출동. 먼저 개인용(private) 큐를 뒤져보고, 그다음 공용(shared) 큐를 훑어봅니다. 두 큐 모두 낮은 번호의 시그널부터 우선적으로 뽑아냅니다.
2
비트 정리 및 재계산. 큐에서 시그널을 꺼냈으니 더 이상 대기(pending) 상태가 아닙니다. 비트 마스크에서 해당 불을 끄고, recalc_sigpending()을 돌려 TIF_SIGPENDING 깃발을 내릴지 말지 결정합니다.
3
운명의 세 갈래 길. 핸들러 정책이 SIG_IGN이면 쿨하게 넘어가고, SIG_DFL이면 커널이 정한 기본 형벌(또는 조치)을 내리며, 개발자가 만든 핸들러가 있다면 본격적인 실행 준비 태세에 돌입합니다.

만약 프로세스가 디버깅(tracing)을 당하고 있는 중이라면, 시그널을 전달하는 이 찰나의 순간에 커널은 디버거 프로세스에게 귓속말(알림)을 보내고 스케줄러를 호출해 제어권을 넘깁니다. 우리가 GDB 같은 툴로 프로세스의 숨통을 쥐었다 폈다 하며 변수를 뜯어볼 수 있는 마법의 비밀이 바로 여기에 숨어 있습니다.

시그널 '전달'이란 단순히 큐에서 메시지를 빼내는 좀스러운 작업이 아닙니다. 프로세스가 사용자 모드로 돌아갔을 때 실행될 코드의 궤적 자체를 통째로 비틀어버리는 거대한 문맥 전환(context switch)의 마술입니다. 핸들러를 띄우려면 커널 스택에 잠들어있는 프로세스의 레지스터 상태를 정교하게 깎고 다듬어야 합니다.

핵심 O/X 퀴즈

1. 커널은 사용자 모드로 돌아가기 직전에 TIF_SIGPENDING 깃발을 확인하여 시그널 전달 절차를 밟기 시작한다.
O유저 모드로의 복귀 시점이 do_signal() 호출의 가장 완벽한 진입점입니다.
2. dequeue_signal() 함수는 스레드 그룹 공용 큐를 먼저 털어보고 나서 개인용 큐를 확인한다.
X나를 콕 집어 날아온 개인용(private) 큐를 먼저 확인하는 것이 원칙입니다.
3. do_signal()은 sa_handler의 값에 따라 시그널 무시, 기본 동작 수행, 혹은 사용자 핸들러 실행 준비라는 세 갈래 분기를 탄다.
O정책(action)에 따른 정확한 3단 분기 구조를 따릅니다.
11

기본 동작 실행: init 예외, 정지, 덤프, 그리고 죽음SIG_DFL PATH

시그널 핸들러가 SIG_DFL로 설정된 경우, do_signal()은 커널에 굳건히 정의된 기본 동작을 수행합니다. 여기서 가장 흥미로운 예외 대상은 다름 아닌 PID 1번, 바로 init 프로세스입니다. 시스템의 뿌리인 init 프로세스에게 날아간 시그널은 핸들러로 낚아채지 않는 이상 커널이 조용히 씹어버립니다(버려집니다).

태생 자체가 무시(ignore)가 기본인 시그널들(SIGCONT, SIGCHLD, SIGWINCH, SIGURG)은 말 그대로 가볍게 무시된 채 루프는 다음 대기 시그널을 향해 넘어갑니다.

반면 멈춤(stop)이 기본 동작인 시그널(SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU)은 스레드 그룹 전체의 시간을 멈춰 세웁니다. 단, 무적의 SIGSTOP을 제외한 나머지 정지 시그널들은 orphaned process group 상태일 때 POSIX 규칙에 따라 종종 무시되기도 합니다. 여기서 고아 프로세스 그룹이란 단순히 부모가 죽어버렸다는 뜻이 아니라, 같은 세션 내의 다른 프로세스 그룹에 속해 있으면서 든든한 뒷배가 되어줄 부모 프로세스가 더 이상 존재하지 않는 고립된 상태를 뜻합니다.

만약 현재 프로세스가 스레드 그룹 내에서 가장 처음으로 멈추는 녀석이라면, do_signal_stop()이 즉각 그룹 정지 절차를 밟기 시작합니다. group_stop_count를 양수로 올리고 다른 스레드들을 마구 깨웁니다.
기상한 스레드들은 분위기를 파악하고 그룹 정지에 동참하여 상태를 TASK_STOPPED로 바꾼 뒤, schedule()을 호출해 깊은 잠에 빠져듭니다.
부모가 SA_NOCLDSTOP 플래그를 세워두지 않았다면, 스레드 그룹 리더의 부모에게 "당신 자식이 멈췄소!"라는 통지문(SIGCHLD)을 날려줍니다.

기본 동작이 덤프(dump)인 시그널은 프로그램의 뼈와 살이 분리되는 순간을 찰칵 찍어 디버깅용 core file로 남겨주는 친절함을 발휘합니다. 이 코어 파일 안에는 프로세스의 메모리 상태와 CPU 레지스터 값들이 고스란히 얼어붙어 있습니다. 유언장을 남긴 프로세스는 곧바로 죽음을 맞이합니다. 나머지 무자비한 강제 종료(terminate) 시그널들은 do_group_exit()을 호출해 스레드 그룹 전체의 숨통을 일거에 끊어버립니다. 멀티스레드 환경에서는 나 하나 잘못해서 프로그램 전체가 셧다운되는 연대 책임의 무서움을 엿볼 수 있습니다.

핵심 O/X 퀴즈

1. SIGSTOP의 기본 동작은 스레드 그룹 전체의 실행을 일시적으로 동결시키는 것이다.
OSIGSTOP은 묻지도 따지지도 않고 스레드 그룹을 정지(stop)시킵니다.
2. 덤프(dump) 동작은 코어 파일을 생성하며, 파일 생성 후에도 프로세스는 정상적으로 다음 코드를 실행한다.
X코어 파일은 프로세스의 유언장입니다. 파일 생성 후 프로세스는 무조건 종료됩니다.
3. SA_NOCLDSTOP 플래그를 설정하면, 자식 프로세스가 정지(stop)되었을 때 부모에게 SIGCHLD 알림이 가지 않도록 막을 수 있다.
O불필요한 알림 노이즈를 줄이기 위한 플래그입니다.
12

시그널 잡기: 핸들러 실행을 위한 유저 스택 조작 마술Figure 11-2 / 11-3

시그널을 '잡는다(catch)'는 것은 단순히 등록된 함수 포인터를 띡 하고 호출하는 수준의 간단한 작업이 아닙니다. 시그널 핸들러는 철저히 사용자 모드 영역의 코드인데, 지금 이를 통제하고 있는 커널은 커널 모드에 머물러 있습니다. 따라서 커널은 "프로세스가 유저 모드로 눈을 떴을 때, 원래 실행하려던 코드 대신 핸들러 함수 첫 줄에 서 있게끔" 실행 문맥(context)을 정교하게 속여야 합니다.

1
커널 모드의 심장부에서 do_signal()handle_signal()을 호출하며 마술이 시작됩니다.
2
setup_frame() 또는 setup_rt_frame()이 출동해 사용자 모드 스택(user stack) 위에 시그널 프레임(signal frame)이라는 무대를 세웁니다.
3
프로세스가 사용자 모드로 복귀하는 순간, 프로그램 카운터(PC)는 자연스럽게 핸들러의 주소를 가리키고 있어 핸들러 코드가 맹렬히 돌아가기 시작합니다.
4
핸들러가 끝을 맺으면 프레임에 심어둔 복귀 코드(return code)가 발동하여 sigreturn() / rt_sigreturn() 시스템 콜을 부르고, 커널이 다시 등판해 시그널을 맞기 전의 평화롭던 원래 문맥으로 복구시킵니다.

왜 굳이 사용자 모드 스택에 프레임을 쌓아야 할까요? 핸들러 실행이 끝난 후 프로세스가 원래 돌던 곳으로 돌아가려면, 사고가 터지기 직전의 레지스터 상태를 어딘가에 잘 보관해 두어야 합니다. 그런데 커널 스택은 유저-커널을 오갈 때마다 포맷되고 재사용되는 험악한 동네라, 커널은 이 귀중한 백업 데이터를 안전한 사용자 스택 영역에 고스란히 복사해 두는 것입니다.

handle_signal()SA_SIGINFO 플래그의 유무를 보고 무대를 고릅니다. 복잡한 사건 조서가 필요 없으면 setup_frame()으로 기본 프레임을 깔고, 상세한 siginfo_t가 필요하면 덩치가 큰 확장 프레임을 짜는 setup_rt_frame()을 호출합니다.

Figure 11-3 — sigframe 구조 (setup_frame)
pretcode
핸들러가 임무를 마친 뒤 발을 디딜 복귀 주소입니다. 보통 vsyscall 페이지에 있는 __kernel_sigreturn 코드로 점프하게 만듭니다.
sig
핸들러에게 "네가 처리해야 할 시그널 번호야"라고 쥐여주는 인자입니다.
sc
시그널이 터지기 직전, 프로세스의 생생한 하드웨어 문맥(레지스터 상태 등)을 박제해 둔 sigcontext입니다.
fpstate
부동소수점 연산 레지스터 상태를 백업할 때 쓰이는 공간입니다.
extramask
차단된 실시간(real-time) 시그널들에 대한 마스크 정보입니다.
retcode
과거엔 실제 실행될 복귀 코드였으나, 최신 리눅스에서는 디버거가 "아, 이거 시그널 프레임이네" 하고 알아볼 수 있게 남겨둔 서명(signature) 역할을 합니다.

프레임을 완성한 커널은 마지막으로 커널 스택에 잠들어있는 프로세스의 레지스터 복사본(regs)을 손봅니다. 스택 포인터(esp)는 방금 만든 프레임을 가리키게 꺾고, 명령어 포인터(eip)는 핸들러의 첫 주소를 정조준하며, eax 레지스터엔 시그널 번호를 살포시 얹습니다. 이로써 프로세스가 눈을 뜨는 순간(유저 모드 복귀), 완전히 새로운 연극이 시작되는 것입니다.

결코 오해해선 안 될 사실: 커널은 사용자 모드의 시그널 핸들러를 직접 호출(call)하지 않습니다. 커널은 그저 프로세스가 유저 모드로 돌아갈 때의 나침반(레지스터)을 슬쩍 조작해, 마치 프로세스 스스로 핸들러를 호출한 것처럼 완벽한 환상을 만들어 낼 뿐입니다.

핵심 O/X 퀴즈

1. 시그널 핸들러는 본래 커널 모드 함수이므로, 커널이 직접 자신의 스택 위에서 호출한다.
X핸들러는 사용자 모드 함수입니다. 커널은 직접 호출하지 않고 복귀 문맥을 조작하여 실행을 유도합니다.
2. setup_frame() 함수는 유저 스택 위에 시그널 프레임을 올리고, 나중에 원래 궤도로 돌아갈 문맥 정보(sigcontext)를 백업한다.
O과거의 나(원래 문맥)를 유저 스택에 안전하게 박제해 두는 핵심 과정입니다.
3. 커널 스택의 eip(명령어 포인터)를 조작하여 핸들러 주소를 가리키게 만들면, 유저 모드 복귀 시 자연스레 핸들러가 가동된다.
O이것이 바로 커널이 실행 궤적을 비틀어 핸들러를 띄우는 핵심 마술(eip 조작)입니다.
13

핸들러 실행 중의 마스크와 sigreturn 복귀전RETURNING TO NORMAL FLOW

시그널 핸들러가 한창 실행 중일 때, 얄궂게도 동일한 시그널이 또 발생하면 어떻게 될까요? 기본적으로 커널은 현재 처리 중인 시그널이 핸들러 실행 동안 다시 끼어들지 못하도록 꽉 틀어막습니다(block). 이렇게 해주지 않으면 핸들러 코드는 재진입(re-entrant) 상황을 완벽하게 대비해야 하고, 개발자들의 디버깅 지옥 문이 열리게 됩니다.

handle_signal()은 프레임을 세팅한 직후 sa_flags를 쓱 훑어봅니다. 만약 SA_NODEFER 깃발이 없다면, 커널은 sa_mask에 명시된 시그널들을 차단 목록(blocked)에 올리고 방금 터진 시그널 자신도 차단 명단에 얹은 뒤 recalc_sigpending()을 호출해 상황을 갱신합니다. 반대로 SA_NODEFER가 펄럭인다면, 커널은 "그래, 방해받든 말든 네 마음대로 해라"라며 자동 차단막을 내려주지 않습니다.

열심히 일한 핸들러가 return을 외치는 순간, 유저 스택에 심어져 있던 pretcode가 프로세스를 vsyscall 페이지의 __kernel_sigreturn 코드로 텔레포트시킵니다.
이 작은 코드는 스택에서 시그널 번호를 치우고, eax 레지스터에 sigreturn() 시스템 콜 번호를 장전한 뒤 int $0x80을 쏴서 커널을 다시 불러냅니다.
소환된 sys_sigreturn()은 유저 스택의 프레임 주소가 정상적인지 의심의 눈초리로 검증합니다(주소가 수상하면 얄짤없이 SIGSEGV 철퇴를 내립니다). 이상이 없다면 sigcontext를 열어 예전의 차단 마스크(blocked)를 원상 복구합니다.
마지막으로 restore_sigcontext()가 박제해 뒀던 레지스터 문맥을 커널 스택으로 고스란히 옮겨놓고, 유저 스택에 지었던 시그널 프레임 무대를 말끔히 철거합니다.

만약 SA_SIGINFO 플래그를 달아 확장 프레임으로 놀았다면, 복귀 경로도 __kernel_rt_sigreturn을 거쳐 rt_sigreturn() 시스템 콜을 타게 되지만 전체적인 그림은 다르지 않습니다.

여기서 빛을 발하는 또 하나의 플래그가 바로 SA_ONESHOT(SA_RESETHAND)입니다. 이 녀석이 켜져 있으면, 핸들러를 일회용품처럼 딱 한 번만 쓰고 버립니다. 처리가 끝나는 순간 시그널 정책을 SIG_DFL(기본 동작)로 롤백시켜버리죠. 다음번에 같은 시그널이 온다면 핸들러가 아닌 시스템 기본 동작이 발동될 것입니다.

명심할 점: do_signal()은 수많은 대기 시그널이 밀려있어도, 한 번 호출될 때 단 하나의 핸들러 무대만 세팅하고 곧바로 사용자 모드로 돌려보냅니다. 한 큐에 핸들러를 줄줄이 소시지처럼 세팅하지 않는다는 뜻이죠. 이는 실시간(real-time) 시그널의 엄격한 순서를 지켜내기 위한 커널의 뚝심 있는 설계입니다.

숨 가쁜 핸들러 실행 사이클을 정리해 볼까요: ① 커널이 유저 스택에 프레임 무대를 짓는다 → ② 레지스터를 비틀어 핸들러로 난입시킨다 → ③ 핸들러가 도는 동안 방해꾼 시그널을 차단한다 → ④ 끝난 후 sigreturn()이 마법처럼 예전 문맥으로 되돌린다.

핵심 O/X 퀴즈

1. 기본적으로 핸들러가 실행되는 동안에는, 방금 자신을 호출한 것과 동일한 시그널이 재진입하지 못하도록 자동 차단된다.
OSA_NODEFER 플래그가 없다면 커널이 알아서 자동 차단 방어막을 쳐줍니다.
2. sigreturn()은 개발자가 새로운 시그널 핸들러를 커널에 등록하기 위해 직접 호출하는 시스템 콜이다.
X핸들러를 등록하는 것이 아니라, 핸들러 종료 후 찢어졌던 문맥을 꿰매 원래 코드로 돌아가게 해주는 커널의 수호천사 같은 시스템 콜입니다.
3. SA_ONESHOT 깃발을 꽂으면, 핸들러가 한 번 짜릿하게 실행된 후 시그널 정책이 기본 동작(SIG_DFL)으로 돌아간다.
O핸들러를 일회용으로 쓰겠다는 명확한 선언입니다.
14

시스템 콜의 자동 부활: EINTR과 SA_RESTARTTable 11-11

프로세스가 read()와 같은 시스템 콜을 던졌지만 당장 데이터를 받을 수 없어 TASK_INTERRUPTIBLE 상태로 대기(sleep)에 들어간 상황을 가정해 봅시다. 이때 누군가 시그널을 쏴서 프로세스를 흔들어 깨우면 커널은 즉각 기상 조치를 취하지만, read() 시스템 콜 입장에서는 아직 임무를 완수하지 못한 난감한 상황입니다. 이때 시스템 콜은 커널 내부적으로 EINTR, ERESTARTNOHAND, ERESTART_RESTARTBLOCK, ERESTARTSYS 등 복잡한 에러 코드를 반환하며 후퇴합니다. 그리고 이 상황을 맞닥뜨린 사용자 프로그램이 최종적으로 받아보는 성적표가 바로 그 유명한 EINTR — "시그널의 난입으로 작업이 중단됨"입니다.

Table 11-11 — 시그널 정책별 시스템 콜 재시작 운명
Default / Ignore
커널 내부 에러 코드들(ERESTARTSYS 등)은 매끄럽게 재실행 궤도에 오릅니다. 단, 이미 EINTR로 굳어진 경우는 자동 재시작 없이 그대로 에러로 종료됩니다.
Catch + SA_RESTART
시그널을 잡았을 때 ERESTARTSYS 코드는 SA_RESTART 플래그가 꽂혀 있어야만 부활(재실행)합니다. 이 깃발이 없다면 얄짤없이 -EINTR을 맞고 쓰러집니다.
Catch (기타)
ERESTARTNOHAND 류의 코드들은 시그널을 잡는 순간 보통 EINTR로 둔갑해 버립니다. 반면 ERESTARTNOINTR은 끝까지 살아남아 재실행됩니다.

커널은 지금 깨어난 프로세스가 한창 시스템 콜을 돌리던 중이었는지 orig_eax 레지스터 값을 보고 눈치챕니다. 인터럽트로 들어왔다면 IRQ 번호에서 256을 뺀 음수 값이, 일반 예외라면 -1이 박혀 있지만, 시스템 콜 통로(int $0x80 또는 sysenter)로 들어왔다면 양수의 시스템 콜 번호가 당당히 들어있기 때문입니다. 즉, orig_eax가 0 이상이라면 "아, 이 녀석 시스템 콜 하다가 시그널 맞고 깨어났구나!" 하고 알아차리는 것입니다.

이 끊어진 시스템 콜을 부활시키려면 커널은 eax 레지스터에 원래 하려던 시스템 콜 번호를 다시 쥐여주고, 명령어 포인터(eip)를 시스템 콜 진입 명령(int $0x80) 바로 직전으로 뒤로 물립니다. x86 아키텍처에서 이 명령어의 길이가 딱 2바이트이므로, eip -= 2 연산 한 번이면 타임머신을 탄 것처럼 시스템 콜 진입 직전으로 되돌아갑니다.

조금 특별하게 다뤄지는 녀석이 ERESTART_RESTARTBLOCK입니다. 이 코드는 원래 하던 시스템 콜을 무식하게 처음부터 다시 하지 않고, 스마트한 restart_syscall()을 호출하게 끔 유도합니다. 대표적인 녀석이 nanosleep()입니다. 20ms 동안 꿀잠을 자려다 10ms 만에 시그널을 맞고 깨어났는데, 무식하게 다시 20ms를 자버리면 총 30ms를 자게 되어 스케줄이 엉망이 됩니다. 그래서 이 영리한 재시작 루틴은 이미 흘러간 10ms를 계산에서 빼고, 남은 10ms만 마저 자도록 정밀하게 조정해 줍니다.

핵심 O/X 퀴즈

1. EINTR 에러 코드는 시스템 콜이 정상적으로 완료되지 못하고 시그널에 의해 중단되었음을 유저 프로그램에 알리는 역할을 한다.
O네트워크 I/O 등에서 개발자를 가장 괴롭히지만, 동시에 가장 확실하게 상황을 알려주는 신호탄입니다.
2. SA_RESTART 플래그를 설정하면, 핸들러가 실행된 이후에 끊어졌던 특정 시스템 콜들이 자동으로 부활(재시작)할 수 있는 길을 열어준다.
OERESTARTSYS 상황에서 자동 재시작을 허락하는 마법의 깃발입니다.
3. nanosleep() 같이 시간을 다루는 시스템 콜은 시그널 후 재시작될 때, 이전에 흘러간 시간을 쿨하게 무시하고 무조건 처음 설정한 시간만큼 다시 잔다.
X흘러간 시간을 정밀하게 깎아내어 남은 시간만 마저 자도록 restart_syscall()이 개입합니다.
15

관련 시스템 콜: kill, tkill, tgkill, sigactionSENDING & CONFIGURING

시스템 콜 kill(pid, sig)은 그 무시무시한 이름과 달리 프로세스를 무조건 죽이는 함수가 아닙니다. 그저 지정한 타겟에게 sig 번호의 시그널을 쏘아 보내는 배달부일 뿐이며, 그 결과 프로세스가 죽을지 살지는 전달된 시그널의 성격과 타겟의 방어 태세(핸들러)에 달려 있습니다.

kill(pid, sig) — PID에 숨겨진 타겟팅의 마술
pid > 0
해당 PID를 가진 프로세스의 스레드 그룹 전체를 정확히 조준합니다.
pid = 0
나와 한 배를 탄(같은 프로세스 그룹에 속한) 모든 형제 프로세스들에게 흩뿌립니다.
pid = -1
건드리면 안 되는 커널 프로세스(swapper, init)와 자기 자신을 제외한 시스템의 모든 프로세스에게 광역기를 시전합니다.
pid < -1
PID의 절댓값과 같은 번호를 가진 프로세스 그룹을 타겟으로 융단 폭격을 가합니다.

sys_kill()은 기본적으로 발신자 정보만 담긴 깡통 siginfo_t 조서를 작성합니다. 그다음 kill_something_info() 함수로 바통을 넘겨, 타겟의 성격에 따라 단일 타격(kill_proc_info()), 그룹 타격(kill_pg_info())을 결정지어 수행합니다.

kill() 함수로도 32~64번 대역의 실시간(real-time) 시그널을 쏠 수는 있습니다. 하지만 명심하세요. kill()은 큐에 여러 개가 안정적으로 쌓이는 '큐잉'을 100% 보장해주지 않습니다. 실시간 시그널 특유의 줄 세우기 기능을 제대로 맛보려면 반드시 rt_sigqueueinfo() 시스템 콜이나 sigqueue() 라이브러리 함수를 써야 합니다.

tkill()tgkill()은 멀티스레드 환경에서 그룹 전체가 아닌, 스레드 하나(lightweight process)의 미간을 정확히 꿰뚫기 위해 등장한 스나이퍼 시스템 콜입니다. tkill(pid, sig)는 단순히 PID(스레드 ID)만으로 조준하고, tgkill(tgid, pid, sig)는 그 스레드가 속한 스레드 그룹 ID까지 교차 검증합니다. POSIX의 pthread_kill()은 내부적으로 이 날카로운 저격수들을 활용합니다.

왜 굳이 더 복잡한 tgkill()이 필요할까요? 내가 특정 스레드의 PID를 향해 방아쇠를 당기려는 찰나, 공교롭게도 그 스레드가 죽어버리고 커널이 그 PID를 생면부지의 다른 프로세스에게 재활용해버렸다고 칩시다. tkill()은 묻지도 따지지도 않고 그 엄한 녀석을 쏴버리겠지만, tgkill()은 "잠깐, 네가 속한 그룹 ID가 내가 아까 본 그 그룹이 맞냐?"라고 한 번 더 신분증을 확인하므로 엉뚱한 희생양(race condition)이 생기는 것을 막아줍니다. 스레드 그룹 ID는 프로세스의 요람부터 무덤까지 절대 변하지 않는 든든한 닻이기 때문입니다.

sigaction(sig, act, oact)은 특정 시그널이 날아왔을 때 어떻게 대응할지 행동 강령을 설정하는 제어의 핵심입니다. act에는 당신이 정한 새 핸들러와 플래그, 차단 마스크가 담기고, 원한다면 oact를 통해 예전 강령을 백업받을 수 있습니다. 커널은 이를 받아 do_sigaction()을 통해 자료구조 속 action[sig-1] 위치에 이 강령을 깊숙이 새겨 넣습니다.

엄격한 POSIX의 율법: 어떤 시그널의 정책을 SIG_IGN으로 바꾸거나, 원래 무시당하던 시그널을 SIG_DFL로 돌려놓는 순간, 큐에서 대기하고 있던 동종 시그널들은 모조리 소각되어야 합니다. 또한, 사용자가 sa_mask에 무슨 짓을 하든 간에 절대 권력인 SIGKILL과 SIGSTOP은 핸들러 마스크에 감히 이름을 올릴 수 없습니다.

핵심 O/X 퀴즈

1. kill() 시스템 콜은 이름과 달리 특정 시그널을 대상에게 전송하는 기능일 뿐, 대상을 무조건 죽음으로 내몰지는 않는다.
O결과는 전달하는 시그널 번호와 대상의 핸들러 정책에 따라 천차만별입니다.
2. tgkill()은 단순히 대상 스레드 ID만 보지 않고 그 스레드가 올바른 스레드 그룹에 속해 있는지 교차 검증하여, PID 재사용에 따른 레이스 컨디션을 방어한다.
O그룹 ID라는 확실한 앵커(anchor)를 추가로 확인하는 영리한 설계입니다.
3. sigaction() 설정 시, sa_mask에 SIGKILL을 살짝 끼워 넣어 차단하려 하면 커널이 이를 군말 없이 받아들인다.
X어떤 꼼수를 써도 커널은 SIGKILL과 SIGSTOP을 마스크에서 강제로 뽑아냅니다.
16

sigpending, sigprocmask, sigsuspend, 그리고 실시간 시스템 콜WAITING SAFELY

sigpending() 시스템 콜은 "현재 차단(block)되어 프로세스에 아직 전달되지 못한 대기 중인 시그널"의 목록을 확인하는 데 사용됩니다. 커널은 프로세스 개인의 큐와 그룹 큐의 비트를 OR 연산으로 합친 뒤, 현재 프로세스가 굳게 닫아걸고 있는 차단 마스크(blocked mask)와 AND 연산을 때립니다. 즉, "도착은 했는데 내가 막고 있어서 문밖에 서 있는" 녀석들만 추려서 사용자에게 보고합니다.

sigprocmask(how, set, oldset)의 세 가지 모드
SIG_BLOCK
기존 차단 목록에 새로운 시그널들을 덧붙여 벽을 더 높게 쌓습니다.
SIG_UNBLOCK
기존 차단 목록에서 특정 시그널들을 빼내어 통행을 허가합니다.
SIG_SETMASK
기존 차단 목록을 완전히 허물고, 덮어쓰기 방식으로 새로운 마스크를 씌웁니다.

어떤 모드를 선택하든, 커널은 눈에 불을 켜고 SIGKILLSIGSTOP이 마스크에 섞여 있는지 검열해 가차 없이 쳐냅니다. 마스크 공사가 끝난 뒤에는 반드시 recalc_sigpending()을 호출하여, 방금 차단이 풀린 시그널들 중 곧바로 배달해야 할 대기 시그널이 있는지 장부를 다시 맞춥니다.

sigsuspend()는 아주 오묘한 콤보 시스템 콜입니다. 그 역할은 "잠시 이 임시 마스크를 쓰고 있을 테니, 내가 차단하지 않은 시그널이 날아와서 뺨을 때려줄 때까지 푹 자게 해다오"라는 뜻입니다. 기존의 마스크를 안전한 saveset에 킵해두고 새로운 마스크를 얼굴에 쓴 뒤, 프로세스를 TASK_INTERRUPTIBLE 상태로 재워버립니다. 시그널을 맞고 번쩍 눈을 뜨면 커널은 do_signal(regs, &saveset)을 호출해 시그널을 소화하게 하고, 이 모든 과정이 끝나면 최종적으로 -EINTR 에러를 뱉으며 화려하게 퇴장합니다.

왜 굳이 sigprocmask()로 통행을 허가한 뒤 곧바로 sleep()을 때리지 않고 이런 복잡한 함수를 만들었을까요? 두 함수를 따로 부르면 그 찰나의 틈새로 지옥(race condition)이 열리기 때문입니다. 마스크를 내리는 순간과 sleep()으로 잠들기 직전, 그 눈 깜짝할 사이에 기다리던 시그널이 날아와 처리가 끝나버릴 수 있습니다. 그러면 프로세스는 영원히 오지 않을 두 번째 시그널을 기다리며 평생 잠에서 깨어나지 못하게 됩니다. sigsuspend()는 마스크 변경과 수면 진입이라는 두 동작을 도저히 파고들 틈이 없는 원자적(atomic)인 하나의 덩어리로 묶어 이 비극을 완벽히 차단합니다.

실시간(real-time) 시그널 생태계에도 이와 똑같은 쌍둥이 시스템 콜들이 rt_sigaction(), rt_sigpending(), rt_sigprocmask(), rt_sigsuspend()라는 이름으로 포진해 있습니다. 특히 실시간의 꽃인 '큐잉(줄 세우기)'을 제대로 써먹으려면 두 가지 강력한 무기가 필요합니다. 하나는 rt_sigqueueinfo()(데이터를 얹어 그룹 공용 큐에 확실히 찔러넣는 전송용 콜)이고, 다른 하나는 rt_sigtimedwait()(문밖에서 대기 중인 시그널을 굳이 맞지 않고 안전하게 손으로 꺼내서 내용물만 확인하거나, 정해진 시간 동안 타임아웃을 걸고 기다리는 수신용 콜)입니다.

핵심 O/X 퀴즈

1. sigpending()은 단순히 발생한 모든 시그널이 아니라, 발생(pending)했으면서 동시에 차단(blocked)되어 전달되지 못한 시그널들의 집합만 걸러서 보여준다.
Opending 상태와 blocked 상태의 AND 연산 교집합만을 찾아냅니다.
2. sigsuspend()는 마스크 변경(sigprocmask)과 대기(sleep)를 따로따로 호출할 때 벌어지는 레이스 컨디션의 악몽을 막기 위해 탄생했다.
O두 가지 치명적인 동작을 원자적(atomic)으로 묶어주는 든든한 방어막입니다.
3. 실시간(real-time) 시그널은 줄을 세우는 큐잉 기능이 없으므로, rt_sigqueueinfo()처럼 데이터를 얹어 보내는 특수 시스템 콜이 불필요하다.
X오히려 큐잉을 확실히 보장하고 부가 데이터(siginfo)를 전달하기 위해 이 전용 시스템 콜들이 반드시 필요합니다.

리눅스 시그널은 단순한 알림 기능이 아니라, 프로세스 상태 · 스케줄링 · 사용자 스택 · 시스템 콜 재시작 · 멀티스레드 의미론이 모두 만나는 커널의 교차로입니다.

강의노트 — Linux Kernel, Chapter 11 “Signals” 기반 재구성