컴퓨터 시스템 8장(8.5)(예외 상황, 시그널)

Devkty·2025년 4월 26일

8.1 예외 상황

프로세서에 전원을 처음 공급한 시점부터 전원을 끌 때까지 프로그램 카운터(실행할 기계어 코드 위치 지정)는 연속된 값들을 가정한다.


제어흐름 케이스

보통의 경우
→ 점진적인 순서로 두개의 메모리가 서로 나란히 있는 경우이다.

변화가 생기는 경우
→ jump, call 리턴 같은 프로그램 인스트럭션에 의해 발생된다. 이러한 명령어들은 프로그램 변수에 의해 상태 변화에 반응하도록 하기 위해 필요하다.


예외적 변화흐름 처리의 예시

시스템들은 내부 프로그램 변수에 의해 표시되지 않음. 실행과 관련되어 있지 않은 시스템 상태에도 반응할 수 있어야함.

[ex]

  • H / W timer: 인터럽트를 발생시켜 규칙적인 간격으로 꺼짐(너무 오래실행되는 걸 방지), 이걸 시스템이 정기적으로 처리해줘야함.
  • Network: 패킷이 어댑터에 도착하고 메모리에 저장되야한다.
  • Program: 디스크로부터 데이터를 요청하고 데이터가 준비되었다는 통지를 받을 때까지 잠든 상태에 들어간다.
  • Process: 자식 프로세서를 생성하는 부모 프로세스는 자신의 자식이 종료할 때 통지를 받아야한다.

→ 운영체제가 단순히 프로그램 실행 흐름만 따르게 아닌 하드웨어 이벤트나 시스템 상태 변화 등의 외부적인 요인에도 반응할 수 있어야한다.(인터럽트, 시그널, 블로킹/논블로킹 I/O)


ECF(Exceptional Control Flow)

예외적인 제어 흐름은 컴퓨터 모든 수준에서 발생한다.

[ex]
H / W: 하드웨어에서 발생되는 이벤트들은 예외 핸들러로 갑작스런 제어 이동을 발생.

OS 커널: 문맥전환을 통해서 사용자 프로세스에서 다른 프로세스로 제어가 이동.

응용: 프로세스는 시그널 핸들러(시그널을 수신하는 곳)로 제어를 급격히 이동하는 다른 프로세스로 시그널 보내기 가능.

개별 program: 일반적인 스택 운영 회피, 다른 함수 내 임의의 위치로 비지역성 점프로 에러 대응.


ECF를 배워야하는 이유

  • 중요한 시스템 개념을 이해하는데 도움을 준다.
    운영체제의 입출력, 프로세스, 가상메모리를 구현하기 위해 사용하는 기본 메커니즘이므로 필요
  • 응용 계층이 운영체제와 상호작용하는지 이해하는지 도움을 준다.
    응용은 Trap, system call 이라고 알려진 ECF의 한 가지 형태를 사용해 운영체제로 서비스를 요청함.
    ex) 디스크 데이터 write, Network 데이터 write, 새로운 프로세스 만들고 종료하기 등
    모두 응용프로그램이 system call 통해 이루어짐.
    → system call 매커니즘을 이해하려면 응용 프로그램에 어떻게 서비스가 제공되는지 알아야 도움.
  • 새로운 응용프로그램 작성에 도움을 준다.
    운영체제는 새로운 프로세스를 만들기, 종료 기다리기, 다른 프로세서에게 시스템 내의 예외 이벤트 알리기 등 다양한 이벤트를 반응하기 위해 ECF를 응용프로그램에게 제공한다. ECF를 이해하면 이것을 활용하여 효과적인 프로그래밍을 할 수 있다.
  • 동시성을 이해하는데 도움이 된다.
    ECF자체가 동시성을 구현하는 기본 매커니즘이다.
    ex) 실행 시간이 중첩되는 프로세스, 쓰레드, 응용프로그램의 실행 가로채는 예외처리 핸들러, 응용 프로그램의 실행을 가로채는 시그널 핸들러 등
    위 예시가 ECF를 통한 동시성을 구현한 것이다.
  • 소프트웨어적인 예외상황이 어떻게 작동하는지 이해하는데 도움이 된다.
    C++ 이나 Java는 try, catch, throw 문장을 통해 예외적 상황(소프트웨어적)에 대응을 한다. 이 상황은 본래의 call/return 스택 방식에 위배되는 nonlocal 점프를 하도록 해준다.
    nonlocal 점프는 응용수준 ECF이고, C에서는 setjump와 longjump로 구현된다. 이 함수들의 하위 함수들을 이해하면 상위 소프트웨어 예외가 어떻게 구현되는지 이해 가능하다.

글의 흐름

앞으로 응용 프로그램과 운영체제가 어떻게 상호작용하는지 알아볼 것이다. 이러한 상호 작용은 모두 ECF를 중심으로 돌아간다.
글의 흐름은

  1. 하드웨어와 운영체제의 교차점에 있는 예외
  2. 응용 프로그램에게 운영체제 내부로 엔트리 포인트를 제공하는 system call
  3. 추상화 단계를 올라 응용과 운영체제의 교차점에 위치한 프로세스와 시그널
  4. ECF 응용 수준의 형태인 비지역성 점프에 대해 설명한다.

8.5 시그널

시그널은 상위 수준의 소프트웨어 형태의 ECF이다. 프로세스와 커널이 다른 프로세스를 중단하도록할 수 있다.

각 시그널 타입은 특정 종류의 시스템 이벤트에 대응된다.
하위 수준 하드웨어 예외는 커널의 예외 핸들러에 의해 처리되고 정상적으로 사용자 프로세스에선 볼 수 없다.
시그널은 이러한 예외들을 사용자 프로세스에 노출해주는 메커니즘을 제공한다.

[ex]
어떤 프로세스가 0으로 나누려고함 → 커널이 SIGFPE 신호(8번)을 프로세스에 보냄
잘못된 인스트럭션을 실행 → SIGILL(4번) 보냄
잘못된 메모리를 참조 → SIGSEGV(11번) 보냄

다른 시그널들은 커널 내 or 다른 사용자 프로세스 내부의 상위수준 소프트웨어 이벤트에 대응.
[ex]
어떤 프로세스가 포그라운드에 돌아가고 있을 때, Ctral + C 입력 → 커널은 SUGINT(2번)을 포그라운드 프로세스 그룹에 속한 각 프로세서들에게 보냄.
어떤 프로세스는 다른 프로세스에 SIGKILL(9번)을 보내서 강제 종료도 가능.
자식 프로세스가 종료하거나 정지시 → 커널이 SIGCHILD(17번)을 부모에게 보냄.

밑에 사진은 시그널 종류이다.


8.5.1 시그널 용어

시그널을 목적지 프로세스로 전달하는 것은 두 단계이다.

시그널 보내기

커널은 목적지 프로세스의 컨텍스트 내에 있는 일부 상태를 갱신해서 시그널을 목적지로 보낸다.
시그널이 두가지 이유 중의 하나로 배달된다.
1. 커널이 0으로 나누거나 자식 프로세스의 종료 같은 시스템 이벤트 감지.
2. 어떤 프로세스가 커널에 명시적으로 시그널을 목적지 프로세스에 보낼 것을 요구하기 위해서 kill 함수를 호출.(프로세스는 시그널을 자기 자신에게 보내기 가능)

시그널 받기

커널이 시그널을 전달하면, 대상 프로세스는 그 시그널에 반응하도록 강제로 요청받게 됩니다.
프로세스는 해당 시그널에 대해 3가지 처리가 있다.
1. 시그널을 무시.
2. 시그널 때문에 종료.
3. 시그널 핸들러(signal handler)라고 불리는 사용자 정의 함수를 실행해서 시그널을 직접 처리.

위 사진이 그 과정을 그림으로 표현한 것이다.

보내졌으나 아직 받지 않은 시그널은 Pending Signal이라고 한다. 시간상 어느 시점에 특정 타입에 대해 최대 한 개의 펜딩 시그널이 존재할 수 있다.
타입 k의 펜딩 시그널을 가지고 있으면 다음 프로세스로 다음에 발생하는 k 타입의 시그널은 큐에 들어가지 않는다. 즉, 버려진다.

커널은 펜딩 내에 비트 k를 타입 k의 시그널이 배달될 때 마다 설정하며, 시그널 타입 k가 수신될 때마다 펜딩의 비트 k를 0으로 만든다.

펜딩 시그널: 최대 한 번만 수신된다. 각 프로세스에 대해, 커널은 펜딩 비트 벡터 내에 펜딩하고 있는 시그널의 집합을 관리하며, 블록 비트 벡터 내에서 블록된 시그널 집합을 관리한다.

Block: 프로세스는 선택적으로 어떤 시그널의 수신을 Block할 수 있다. 블록되면 시그널이 배달은 되지만 프로세스가 블록을 풀때까지 수신되지 않는다.


8.5.2 시그널 보내기

Unix 시스템은 시그널을 프로세스로 보내는 여러 가지 메커니즘을 제공한다. 모든 메커니즘은 프로세스 그룹 개념을 사용한다.

프로세스 그룹

모든 프로세스는 한 개의 프로세스 그룹에 속한다. 양수 process group ID로 식별한다. getgrp함수는 현재 프로세스의 프로세스 그룹 ID를 리턴한다.

기본적으로, 자식 프로세스는 자신의 부모와 동일한 프로세스 그룹에 속한다.
프로세스는 자신의 프로세스 그룹 or 다른 프로세스 그룹을 setgrid 함수를 사용해서 변경 가능하다.

#include <unistd.h>

pid_t getpgrp(void);

setgrid 함수는 프로세스 pid의 프로세스 그룹을 pgrid(프로세스 묶음)로 변경한다. 만일 pid가 0이면 현재 프로세스의 PID가 사용된다. 만일 pgid가 0이면 pid 명시된 프로세스의 PID가 프로세스 그룹 ID로 사용된다.

#include <unistd.h>

int setpgid(pid_t pid, pid_t pgid);

시그널을 /bin/kill 프로그램을 사용해서 보내기

linux> /bin/kill -9 15213

/bin/kill 프로그램은 다른 프로세스로 임의의 시그널을 보낸다.
SIGKILL(9번) 시그널을 다른 프로세스 15213에 보내면 음수 PID는 시그널이 프로세스 그룹 PID 내의 모든 프로세스(15213 내 모든 프로세스)로 보내지도록 한다.
위의 예시의 /bin/kill 처럼 완전한 경로를 사용하는 이유는 일부 Unix 쉘이 자신만의 내장 kill 명령어를 가지고 있기 때문이다.


키보드에서 시그널 보내기

유닉스 쉘은 job의 추상화를 통해 한 개의 명령줄을 해석한 결과로 만들어진 프로세스에 반영한다.
어떤 시점에서도 최대 한 개의 포그라운드 작업과 0 또는 그 이상의 백그라운드 작업이 존재한다.

linux> ls | sort

다음과 같이 작성하면 두 개의 프로세스가 Unix 파이프로 연결된 포그라운드를 만든다. 하나는 ls 프로그램을 실행하고, 다른 하나는 sort 프로그램을 실행한다.

쉘은 각 작업마다 별도의 프로세스 그룹을 만든다. 일반적으로 프로세스 그룹 ID는 작업 내의 부모 프로세스들 중 하나에서 가져온다.

ex) 위 그림은 하나의 포그라운드 작업과 두 개의 백 그라운드 작업을 갖는 쉘이다.
포그라운드 작업의 부모 프로세스는 PID 20이고 프로세스 그룹 ID 20을 가진다. 부모 프로세스는 두개의 자식 프로세스를 만들수 있다.(동일한 그룹이므로 pgid는 20으로 같다)

키보드에서 Ctrl + C를 입력하면, SIGINT 시그널을 포그라운드 프로세스 그룹에 모두 보낸다.
→ 포그라운드를 종료시킨다.

비슷하게 Ctrl + Z 를 입력하면 커널은 포그라운드 프로세스 그룹에 속한 모든 프로세스에게 SIGSTP를 보낸다.
→ 포그라운드 작업을 정지시킨다.


kill 함수로 시그널 보내기

#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);

프로세스는 kill 함수를 호출해서 시그널을 다른 프로세스로 보낸다.(자신 포함)

→ PID가 0보다 크면, kill 함수는 시그널 번호 sig → 프로세스 PID로 보낸다.

  • PID가 0이면 kill은 호출하는 프로세스인 자신을 포함해서 프로세스 그룹 내 프로세스에 sig를 보낸다.

자기자신을 포함해서 자기 자신의 pgid 그룹을 모두 죽이겠다.

  • PID가 0보다 작으면, sig를 프로세스 그룹 | pid | 내의 모든 프로세스로 보낸다.

alarm 함수로 시그널 보내기

SIGALRM 로 alam 함수를 호출하여 자기 자신에게 보낼 수 있다.

#include <unistd.h>

unsigned int alarm(unsigned int secs);

위 함수는 커널이 초마다 프로세스로 SIGALRM을 보내도록 한다.
위의 secs 가 0 이면 새로운 알람이 스케줄되지 않는다.
alarm으로의 호출은 어떤 이벤트가 발생해도 대기하는 모든 알람을 취소하고 대기하던 알람이 전달되었어야 할 때까지 (취소 아닐시) 남은 시간을 초 단위 숫자로 반환하거나, 대기하고 있으면 0을 리턴한다.


8.5.3 시그널의 수신

커널이 프로세스 p를 커널 모드에서 사용자 모드로 전환 시, 커널은 프로세스 p에 대한 블록되지 않은 pending & ~ blocked의 집합을 체크한다.

만일 집합이 비었있으면 커널은 제어를 p의 논리 제어 흐름 내의 다음 인스트럭션(I)으로 전달한다.

→ 비어 있지 않다면, 커널은 집합 내 어떤 시그널 k를 선택해서 p가 시그널 k를 수신토록한다.

시그널을 받으면 프로세스는 동작을 개시하고 완료했다면, 제어는 p의 논리 제어 흐름 내의 다음 인스트럭션(I)으로 돌아간다.

시그널 타입은 다음과 같은사전에 정의된 기본동작을 가진다.

  • 프로세스가 종료
  • 종료하고 코어를 덤프
  • SIGCONT 시그널에 의해 재시작될 때까지 정지
  • 시그널을 무시

위의 사진처럼 여러 시그널이 있다.

SIGKILL: 수신한 프로세스 종료

SIGCHLD: 시그널 무시

프로세스는 위의 시그널과 연관된 기본 동작을 signal 함수를 사용해서 수정할 수 있다.

그러나! SIGSTOP과 SIGKILL은 예외적으로 기본 동작을 변경할 수 없다.

signal 함수는 signum과 연결된 동작을 밑의 3가지 방법 중 하나로 바꿀 수 있다.

  • 핸들러가 SIG_IGN이면, signum 타입의 시그널 무시
  • 핸들러가 SIG_DFL 이면, sugnum 타입의 시그널 동작은 기본으로 작동
  • 그외, 핸들러는 사용자가 정의한 함수의 시그널 핸들러라고 부르는 주소가 된다. 이것은 프로세스가 signum 타입의 시그널을 수신할 때마다 호출된다.

핸들러 행위

핸들러 주소를 signal 함수로 넘겨주는 방법은 기본 동작을 변경하는 것: handling installing

핸들러 호출: catching the signal
핸들러 실행: handling the signal

타입 k의 시그널 잡을 시, 시그널 k를 위해 설치된 핸들러는 k에 설정된 한 개의 정수 인자를 사용해서 호출.
→ 정수 인자는 동일한 핸들러 함수가 서로 다른 종류의 시그널을 잡을 수 있게 해준다.

핸들러가 return 문장을 실행시, 제어는 보통 프로세스가 중단되었던 제어흐름 내 인스트럭션으로 다시 전달된다.(보통이라고 한 이유는 일부 시스템에서 중단된 콜들이 에러가 발생하면 즉시 리턴하기 때문이다)

사용자가 Ctrl + C를 예시로 보면, 입력마다 SIGINT를 잡고 프로세서를 종료 시킨다. 그러나 밑의 경우에는 기본 동작을 수정하여 시그널을 잡기 → 메시지 출력 → 프로세스 종료 순으로 진행되게 바뀐 것이다.

// 기본 동작 수정 예시 
#include "csapp.h"
2
3 void sigint_handler(int sig) /* SIGINT handler **/
4 {
5 printf("Caught SIGINT!\n");
6 exit(0);
7 }
8
9 int main()
10 {
11 /* Install the SIGINT handler */
12 if (signal(SIGINT, sigint_handler) == SIG_ERR)
13 unix_error("signal error");
14
15 pause(); /** Wait for the receipt of a signal */
16
17 return 0;
18 }

시그널 핸들러는 다음 그림과 같이 다른 핸들러에 의해 중단 가능하다.
밑의 예제는 메인 프로그램은 메인 프로그램을 중단하고, 제어를 핸들러 S로 전송하는 시그널 s를 붙잡는다. S가 돌고 있는 동안 프로그램은 시그널 t를 붙잡는다. S를 중단하고, 제어를 핸들러 T로 전송한다.

T가 리턴할 때, S는 중단한 위치에서 이어서 실행한다.
→ S는 리턴하고, 제어를 다시 메인 프로그램으로 전달하며, 처음에 떠난 자리에서 실행을 다시한다.


8.5.3 시그널 블록하기와 블록 해제하기

리눅스는 시그널을 블록하기 위해 묵시적이고 명시적인 방법을 제공한다.

묵시적 블록 방법

기본적으로 커널은 핸들러에 의해 처리되고 있는 유형의 모든 대기 시그널들의 처리를 막는다.

ex) 바로 위의 그림에서 메인 프로그램에서 시그널 s를 잡아 핸들러 S를 실행하고 있다. 다른 시그널 s가 보내졌다면 대기하게 되지만, 핸들러 S가 리턴되기 전까진 수신 불가하다.

명시적 블록 방법

응용 프로그램들은 sigprocmask 함수와 이들의 도움 함수를 이용해서 시그널들을 명시적으로 블록하거나 해제할 수 있다. 해당 함수는 현재 블록된 시그널의 집합을 변경한다. 특정 동작은 how값에 따라 달라진다.

SIG_BLOCK: set에 있는 시그널들을 blocked에 추가

SIG_UNBLOCK: set에 있는 시그널들을 blocked에서 제거

SIG_SETMASK: blocked = set

oldset이 널이 아니면, blocked 비트 벡터의 이전 값이 oldset에 저장된다. set같은 시그널 집합들은 여러 함수를 사용해서 조작한다. (자세한건 책)


8.5.5 시그널 핸들러 작성하기

시그널 처리가 리눅스 수준 프로그래밍에서 가장 까다로운 부분이다.

시그널의 특성은 다음과 같다.
1. 핸들러는 메인 프로그램과 동시적으로 돌아가고, 같은 전역변수를 공유한다. 그래서 메인의 다른 핸들러들과 섞일 수 있다.
2. 어떻게 언제 시그널을 수신될 수 있는지 직관적이지 않다.
3. 다른 시스템들은 다른 시그널 처리 방식을 갖는다.

중요! 안전한 시그널의 처리

시그널 핸들러는 다른 핸들러들과 함께 돌아갈 수 있기 때문에 처리하기 어렵다. 만약, 핸들러와 메인 프로그램이 동시에 같은 전역 데이터 구조에 접근한다면, 결과는 예측할 수 없게 되고 치명적인 결과를 낼 수 있다.

그래서 안전한 핸들러를 작성하는 보수적인 지침을 알아보겠다. 지침을 무시하면, 동시성 에러를 낳아 위험해 질 수 있다. 이러한 에러가 있으면 대부분 정확하게 동작하지만, 실패한다면 예측할 수 없고 디버그하기 어려운 재현할 수 없는 방식으로 실패한다.

3번까지는 내용을 작성하였으나 그외의 자세한 내용은 책을 참고하자.

  1. 핸들러는 가능한 간단하게 유지해라.
    문제를 피하는 최상의 방법은 핸들러를 작고 단순하게 유지하는 것이다.
    ex) 핸들러가 전역 플래그 한 개를 설정하고 즉시 리턴할 수도 있다. 시그널의 수신과 연관된 모든 처리는 메인 프로그램에서 수행하고 메인 프로그램은 주기적으로 플래그를 체크한다.
  2. 핸들러에서 비동기성-시그널-안전한 함수만 호출해라.
    이 안전한 함수는 재진입 가능하거나, 어떤 시그널 핸들러에 의해 중단될 수 없기 때문에 어떤 핸들러부터 안전하게 호출될 수 있는 특성을 갖는다.

위 함수들은 리눅스가 안전하다고 보장하는 시스템 수준 함수들이다. 자주 사용하는 printf, sprintf, malloc, exit 등은 리스트에 없다.

시그널 핸들러에서 출력을 생성하는 유일한 안전한 방법은 write 함수를 사용하는 것이다. 특히나 printf나 sprintf 호출하면 안전하지 않다. 이런 제한사항을 피해가기 위해 우리는 Safe I/O 패키지라고 부르는 안전 함수를 개발했다. 이걸 시그널 핸들러에서 간단한 메시지를 출력하기 위해 이용 가능하다. (예시는 책을 확인해보자.)

  1. errno를 조정하고 복원하라.
    많은 리눅스 비동기-시그널-안전한 함수들은 이들이 에러를 가지고 리턴할 때 errno를 설정한다. 이러한 함수들을 핸들러 내에서 호출하는 것은 errno에 의존하는 프로그램 내의 다른 부분들과 혼선이 생길 수 있다.
    해결방법은 errno를 핸들러에 진입할 때 지역변수에 저장하고 핸들러가 리턴하기 전에 복원하는 것이다. (리턴시 필요)핸들러가 _exit을 호출해서 프로세스를 종료한다면 불필요하다.
  2. 모든 시그널을 블록시켜서 공유된 전역 자료 구조들로의 접근을 보호해라.
    인스트럭션이 어떤 핸들러에 의해 중단되면, 예측 불가능한 값을 가질 수 있다. 그러니 접근하는 동안 시그널들을 일시적으로 블록시키면 어떤 핸들러가 중단시키지 않을 것을 보장한다.
  3. 전역변수들을 volatile로 선언해라
    volatile 형 지시자를 사용하면 해당 변수를 캐시에 넣지 않아 코드에서 매번 참조할 때 메모리에서 g값을 읽어오도록 강요한다.
  4. sig_atomic_t 로 플래그들을 선언하라.
    보통 핸들러는 수신을 전역 플래그에 써서 기록하는데, 위 방법으로 플래그를 선언하면 중단되지 않아 시그널을 블록하지 않고 변수 읽고 쓰기를 안전하게 수행 가능하다.(원자성에 대한 보장은 개별적일때만 가능)

우리가 제시한 지침들은 항상 반드시 필요한 것은 아니라는 의미에서 보수적인 것이다. 위 사항을 지키지 않고 유효할 때도 있다. 그러나 그러한 케이스는 찾기 어렵다. 그러므로 위의 접근법을 취하는 것이 좋다. (자세한 내용은 책)


정확한 시그널 처리

시그널에 대한 직관적이지 않은 측면은 대기하는 시그널들이 큐에 들어가지 않는다는 점이다.
→ pending 비트 벡터가 각 시그널 유형에 대해 정확히 한 개의 비트만을 포함하기 때문이다.

만약 같은 유형 k의 두 개의 시그널이 목적지 프로세스에 보내지면, 두 번째 시그널은 버려진다.(큐에 안들어간다)

기본적인 구조는 부모 프로세스가 한동안 독립적으로 동작하는 몇 개의 자식들을 생성하고 종료한다. 부모는 좀비를 없애기 위해 자식들을 소거한다. 그러나 자식들이 돌고 있는 동안 부모가 다른 작업을 하길 원하므로 SIGCHLD 핸들러로 자식을 소거한다.(자식 종료 시 커널이 부모에게 SIGCHLD 보냄)

(예시는 책의 741 ~ 743쪽을 확인해보자.)

호환성 있는 시그널 핸들링

유닉스 시그널 핸들링의 또 다른 지저분한 측면은 서로 다른 시스템들이 서로 다른 시그널 처리 방식을 갖는다는 점이다.

  • signal 함수의 의미가 다르다.
    오래된 유닉스 시스템들은 시그널 k에 대한 동작을 핸들러에 의해 붙잡힌 이후에 기본 동작으로 복원된다. 이런 시스템에서, 핸들러는 signal을 매 실행 때마다 명시적으로 자신을 재설치함.
  • 시스템 콜들은 중단될 수 있다.
    read, wait, accept 같이 프로세스를 오랜 시간 동안 잠재적으로 블록할 가능성 있는 시스템 콜들은 느린 시스템 콜이라 하고, 오래된 유닉스에서 프로그래머는 수동으로 중단된 시스템 콜들을 재시작하는 코드를 포함해야한다.

위 이슈를 다루기 위해 Posix 표준은 sigaction함수를 정의하여 핸들러 설치시 사용자가 원하는 시그널 처리 개념을 명확히 명시하도록 한다.

#include <signal.h>

int sigaction(int signum, struct sigaction *act,
struct sigaction *oldact);

그러나 위함수는 사용자가 복잡한 구조의 항목들을 설정하도록 허용하기 때문에 불편하다. 그래서 Signal이라고 부르는 래퍼 함수를 정의하는 것이 좋다. 우리를 대신해서 sigaction을 호출한다.

밑에와 같은 처리 개념으로 시그널 핸들러를 설치한다.

  • 현재 핸들러에 의해 처리되고 있는 시그널 유형들만 블록된다.
  • 모든 시그널 구현에서처럼 시그널들은 큐에 들어가지 않는다
  • 중단된 시스템 콜들은 필요할 때마다 자동으로 재시작 된다.
  • 핸들러가 설치되면, Signal 이 핸들러 인자 SIG_IGN, ISIG_DFL 중의 하나를 갖는 핸들러로 불린다.

우리의 모든 코드에서 Signal 래퍼를 이용한다.


8.5.6 치명적인 동시성 버그를 피하기 위해서 흐름을 동기화하기

같은 저장장치의 위치에서 읽고 쓰는 동시성 흐름을 프로그래밍하는 방법의 문제가 있다. 일반적으로, 다수의 잠재적인 제어의 중첩은 인스트럭션 수에 있어 기하급수적이다. 그래서 주요 문제는 동시성 흐름을 동기화해서 각각의 가능한 중첩들이 정확한 답을 만들게 되는 가장 큰 중첩들의 집합을 갖도록하는 것이다.

동시성 프로그래밍 자체가 어렵지만, 밑의 프로그램 예시를 살펴보겠다.

1 /* WARNING: This code is buggy! **/
2 void handler(int sig)
3 {
4 int olderrno = errno;
5 sigset_t mask_all, prev_all;
6 pid_t pid;
7
8 Sigfillset(&mask_all);
9 while ((pid = waitpid(-1, NULL, 0)) > 0) { /** Reap a zombie child **/
10 Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
11 deletejob(pid); /** Delete the child from the job list */
12 Sigprocmask(SIG_SETMASK, &prev_all, NULL);
13 }
14 if (errno != ECHILD)
15 Sio_error("waitpid error");
16 errno = olderrno;
17 }
18
19 int main(int argc, char **argv)
20 {
21 int pid;
22 sigset_t mask_all, prev_all;
23
24 Sigfillset(&mask_all);
25 Signal(SIGCHLD, handler);
26 initjobs(); /* Initialize the job list */
27
28 while (1) {
29 if ((pid = Fork()) == 0) { /* Child process */
30 Execve("/bin/date", argv, NULL);
31 }
32 33 Sigprocmask(SIG_BLOCK, &mask_all, &prev_all); /* Parent process */
addjob(pid); /** Add the child to the job list */
34 Sigprocmask(SIG_SETMASK, &prev_all, NULL);
35 }
36 exit(0);
37 }

위 프로그램에서 addjob과 deletejob 함수는 작업 리스트로부터 추가하고 제거된다. 부모가 새로운 자식 프로세스를 생성한 후에 그 자식을 작업 리스트에 추가한다. 부모가 SIGCHLD 시그널 핸들러에서 종료한 자식(좀비)를 청소할 때, 자식으리 작업 리스트에서 삭제한다. 대충보면 정상적인 것 같지만, 5가지 순서에서 문제가 발생한다.

  1. 부모가 fork 함수를 실행하고 커널이 새롭게 생성된 자식을 부모 대신 실행하도록 스케줄.
  2. 부모가 다시 실행할 수 있기 전에 자식이 종료하고, 좀비가 되어서 커널이 SIGCH_LD 시그널을 부모에게 배달
  3. 나중에 부모가 다시 실행 가능하게 되었으나. 실행 전 커널이 펜딩하고 있는 SIGCHLD를 발견하고 부모의 시그널 핸들러를 실행해서 시그널이 수신되도록한다.
  4. 핸들러는 종료된 자식을 청소하고 deletejob을 호출하며, 이것은 부모가 자식을 리스트에 아직 추가하지 않았기 때문에 아무일도 하지 않는다.
  5. 핸들러가 완료한 후에 커널은 부모를 실행하고, 부모는 fork를 리턴하고, addjob을 호출해서 부정확하게 존재하지 않은 자식을 작업 리스트에 추가한다.

→ 부모의 메인 루틴과 시그널 핸들링 흐름들의 일부가 겹칠 때 addjob 전에 deletejob 호출 가능성이 있다. 존재하지 않은 작업으로 잘못된 엔트리가 작업 리스트에 존해하고 삭제할 수 없다. 반면에 이벤트들이 정확한 순서로 발생 할 수도 있다.

이게 race라고 알려진 고전적인 동기화 에러 예시다. 루틴 내의 addjob 호출과 핸들러 내 deletejob으로의 호출 간에 존재한다. addjob이 경주에서 이긴하면 답은 틀린다.

→ 디버깅하기 매우어려운데, 수백번 수행하다가 한 번 중첩상황이 경쟁을 유발할 수 있기 때문이다.

그래서 SIGCHLD를 fork 호출 전 블록하고 addjob을 호출한 후만 블록을 해제하면 자식이 작업 리스트에 추가된 후에 청소되는 것을 보장하게 된다.

밑의 그림은 대략적으로 나타낸 흐름도이다.


8.5.7 명시적으로 시그널 대기하기

종종 메인 프로그램은 특정 시그널 핸들러가 동작하기를 명시적으로 기다려야할 필요가 있다. 위의 상황과 같다.
예를 들어, 어떤 리눅스 쉘이 전면작업을 생성할 때, 커널은 이 작업이 종료되고, SIGCHLD 핸들러에 의해 삭제될 때까지 기다려야한다.

스핀루프: CPU를 계속 점유하면서 아무 일도 하지 않고 짧은 루프를 반복 수행하는 것.

  1. 부모는 SIGINT와 SIGCHLD에 대한 핸들러를 설치하고 무한 루프에 진입한다.
  2. 부모는 앞에 내용인 자식간의 경쟁(race)을 피하기 위해 SIGCHLD를 블록한다.
  3. 자식을 생성 후 PID를 0으로 리셋하고, SIGCHLD 블록을 풀고, PID가 0이 아닌 다른 값이 될 때까지 스핀 루프에서 기다린다.
  4. 자식이 종료되면, 핸들러는 자식을 소거하고 자식의 PID를 전역변수 PID에 할당한다. 이것으로 스핀루프를 종료시키고 부모는 다음 반복실행을 시작하기 전에 추가적인 작업을 계속한다.

이러한 흐름으로 가는 것이 정확하나, 스핀루프는 프로세서 자원을 낭비한다. 그래서 이 이하의 글은 자원 낭비를 줄이기위한 3가지 방법에 대해 설명한다.

스핀루프 프로세서 자원 낭비를 줄이기 위한 방법들

while (!pid)   /*race*/
pause();
OR
sleep(1);
  1. 스핀루프 본체에 pause 넣기

pause가 한개 이상의 SIGINT 시그널을 수신한 경우 중단될 수 있기 때문에 아직 루프를 사용한다. 그러나 SIGCHLD가 while 시험이 끝나고, pause 실행 전에 수신되면, pause는 여전히 잠을 잔다.
pause를 sleep으로 대체하는 옵션도 있다.

  1. 스핀루프 본체에 sleep 넣기

그러나 해당 코드는 느리다. 시그널이 while 이후, sleep 이전에 수신된다면, 이 프로그램은 루프의 종료조건을 다시 체크할 수 있을 때까지 긴 시간을 기다려야 한다. nanosleep과 같이 높은 정밀도의 sleep 함수를 사용하는 것도 안된다. 잠을 자야하는 길이를 결정하는 좋은 규칙이 없기 때문이다. 너무 작으면 루프가 낭비되고, 너무 크게하면 프로그램이 너무 느려진다. 적절한 해결책은 sigsuspend를 이용하는 것이다.

  1. sigsuspend 쓰기

sigsuspend함수는 현재 블록된 집합들을 일시적으로 mask로 교체하고, 자신의 동작이 핸들러를 실행 또는 프로세스를 종료하는 시그널을 수신할 때까지 프로세스를 유예한다.
시그널의 동작이 종료하는 것이라면, sigsuspend로부터 리턴하지 않고 종료한다.
동작이 핸들러를 돌리는 것이라면, sigsuspend는 핸들러가 리턴한 후에 리턴해서 블록된 집합을 sigsuspend가 호출되었을 때의 상태로 복원한다.

→ sigprocmask와 pause로의 호출은 중단되는 일 없이 함께 일어나는 것을 보장해준다. 이건 어떤 시그널이 sigprocmask로 호출 이후, pause로의 호출 전에 수신되는 잠재적인 경쟁상태를 제거한다.

SIGCHLD는 sigsuspend로 호출하기 전에 블록된다. sigsuspend는 임시로 SIGCHLD를 언블록하고 부모가 시그널을 한 개 붙잡을 때까지 잠을 잔다. 리턴하기 전에 본래의 블록된 집합을 복원해서 SIGCHLD를 다시 블록한다.

여기서 두가지 케이스가 있다.

  • 부모가 SIGINT를 붙잡으면 루프 테스트는 성공하고, 반복 실행에서 sigsuspend를 다시 호출한다.
  • 부모가 SIGCHLD를 붙잡으면 루프 테스트는 실패하고, 우리는 루프를 탈출한다.

해당 지점에서 SIGCHLD는 블록되어 있고, 우리는 선택적으로 SIGCHLD를 언블록한다. 이것은 소거되어야하는 후면작업을 갖는 실제 쉘에서 유용할 수 있다.

sigsuspend 버전은 최초의 스핀 루프보다 덜 낭비적이어서 pause로 인해 생긴 경쟁 상태를 회피하고 sleep보다 더 효율적이다.


8.6 비지역성 점프(C언어)

C에서는 비지역성 점프라고 불리는 사용자 수준의 예외적 흐름 제어를 제공한다.

이것은 보통의 콜-리턴 순서를 통할 필요 없이 하나의 함수에서 현재 실행하고 있는 다른 함수로 제어를 이동한다.
C언어에서 비지역성 점프는 setjmp, longjmp 함수가 있다.

setjmp(env) 함수

현재의 실행 위치와 상태(=환경)를 env라는 버퍼에 저장합니다. 그리고 처음 호출될 때는 항상 0을 반환합니다.

하지만 이게 끝이 아니다. 이 함수는 longjmp와의 연계 때문에 한 번만 호출되지만, 두 번 이상 반환할 수 있습니다.
→ 현재 상태를 저장해라

longjmp(env, retval) 함수

longjmp(env, retval)는 이전에 setjmp로 저장해둔 그 위치로 프로그램의 흐름을 되돌립니다.

그렇게 되돌아가면, setjmp는 다시 돌아온 것처럼 동작하면서 이번에는 0이 아닌 retval 값을 반환하게 됩니다.이렇게 하면 멀리 떨어져 있는 코드 위치로 "점프"해서 돌아갈 수 있는 겁니다.
→ setjmp로 점프해 넘어가라


위의 두개의 함수가 유용한 이유가 무엇일까?
→ 예를 들어, 어떤 프로그램에서 여러 단계로 함수가 깊게 호출되다가, 깊숙한 곳에서 에러가 발생했다고 해보겠습니다. 보통은 에러 처리를 위해 하나하나 돌아가며 복귀해야 합니다.

그러나 setjmplongjmp를 사용하면, 에러 발생 즉시 미리 지정한 위치(예: 메인 함수)로 바로 점프해서 돌아올 수 있습니다. 이를 통해 복잡한 함수 호출 체인을 다 거치지 않고, 한 번에 에러 처리 코드로 돌아올 수 있다는 장점이 있습니다.


그러면 장점만 있을까요?
당연히 강력한만큼 위험성도 따릅니다. 예를 들어 함수 안에서 malloc 같은 동적 메모리 할당을 하고 나중에 free로 해제하려 했는데, longjmp로 갑자기 점프해버리면 그 해제 코드가 실행되지 않아 메모리 누수가 발생할 수 있습니다.

앞에서 설명한 것처럼 프로그램 실행 중에 신호(signal)가 도착하면, 신호 처리 함수(핸들러)가 실행되고, 그 처리가 끝나면 인터럽트되었던 위치로 되돌아갑니다.

하지만 어떤 경우에는 그렇게 되돌아가는 대신, 프로그램의 특정 지점으로 점프하여 흐름을 재설정(soft restart)하고 싶을 수 있습니다. 예를 들어, 사용자가 키보드에서 Ctrl+C (SIGINT)를 누르면, 원래 하던 일을 멈추고 처음부터 다시 시작하게 할 수 있습니다.

→ 이럴 때 사용하는 것이 바로 sigsetjmpsiglongjmp입니다.
이 함수들은 각각 setjmp, longjmp의 신호 처리 전용 버전으로, 단순한 호출 상태뿐 아니라 신호 마스크(signal mask) 등도 함께 저장하고 복원할 수 있습니다.

sigsetjmp, siglongjmp 동작순서와 주의점

  1. 프로그램이 시작되면 sigsetjmp를 호출하여 현재 상태를 저장합니다.
  2. 이후 무한 루프에 진입해 계속 작업을 처리합니다.
  3. 사용자가 Ctrl+C를 누르면 SIGINT가 발생하고, 신호 핸들러가 실행됩니다.
  4. 핸들러는 siglongjmp를 호출하여 프로그램을 다시 초기 지점으로 점프시킵니다.

이로써 프로그램은 마치 다시 시작한 것처럼 동작하게 됩니다.

위의 두개의 시그널은 2개의 주의점이 있습니다.

  1. 신호 핸들러는 sigsetjmp 이후에 설정해야 합니다
  • 만약 sigsetjmp를 호출하기 전에 신호 핸들러를 등록해버리면, 아주 드물게 Ctrl + C가 먼저 발생해서 핸들러가 실행될 수 있습니다.
  • 이 경우 siglongjmp가 참조할 저장된 환경이 아직 준비되지 않은 상태이므로, 예기치 않은 동작이 발생할 수 있습니다.
  • 따라서 항상 sigsetjmp를 먼저 호출한 다음, 신호 핸들러를 등록해야 안전합니다.
  1. 안전한 함수(async-signal-safe)만 사용해야 합니다
  • 신호 핸들러에서 호출하거나, siglongjmp로 점프한 이후 실행되는 함수들은 반드시 비동기-신호 안전 함수(async-signal-safe)여야 합니다.
    → 그 이유는 신호가 발생한 시점에서는 시스템 상태가 불안정할 수 있고, 일반적인 함수 호출은 예기치 않은 오류나 충돌을 일으킬 수 있기 때문입니다.
  • 반면, exit 같은 함수는 신호 처리 후 코드에서 호출되지 않기 때문에 도달 불가능(unreachable)하므로 안전합니다.

8.7 프로세스 조직을 위한 도구

리눅스 시스템에서는 프로세스를 관찰하고 조직하기 위해 도구를 제공한다.

도구 이름주요 기능
strace실행 중인 프로그램이 호출하는 시스템 호출(system call)을 모두 추적해서 출력. 프로그램의 동작 원리를 이해하는 데 아주 유용함. 특히 -static 옵션으로 정적 링크된 바이너리를 만들면 출력이 깔끔함.
ps현재 실행 중인 프로세스들의 목록을 보여줌. 좀비(zombie) 프로세스도 포함됨.
top실시간으로 시스템 자원(CPU, 메모리 등)을 누가 얼마나 쓰고 있는지 보여줌.
pmap특정 프로세스의 메모리 맵을 보여줌. 각 메모리 영역이 어떤 용도로 쓰이는지 확인 가능.
/proc 파일시스템가상 파일시스템으로 커널 내부의 다양한 데이터를 텍스트 형식으로 제공. (예: /proc/loadavg는 현재 시스템의 평균 부하를 보여줌.)
profile
모든걸 기록하며 성장하고 싶은 개발자입니다. 현재 크래프톤 정글 8기를 수료하고 구직활동 중입니다.

0개의 댓글