본문 바로가기

[CSAPP] 8장 예외적인 제어 흐름 (4/4) Signal, Nonlocal Jump

출처
Randal E. Bryant & David R. O'Hallaron. (2015). Computer Systems: A Programmer's Perspective (3rd ed.). Pearson.

5. 시그널

고수준 소프트웨어 형태의 예외적인 제어 흐름인 Linux 시그널에 대해 알아보겠다. Linux의 시그널은 프로세스와 커널이 다른 프로세스를 interrupt할 수 있게 해준다. 

시그널이란 프로세스에게 시스템에 어떤 유형의 이벤트가 발생했는지를 알려주는 작은 메시지이다. 다음은 Linux 시스템에서 지원되는 30가지 유형의 시그널이다.

 

각각의 시그널 유형은 특정 시스템 이벤트에 대응된다. 저수준 하드웨어 예외는 커널의 exception handler에 의해 처리되고 사용자 프로세스에게는 가시적으로 보이지 않았다. 시그널은 이러한 예외의 발생을 사용자 프로세스에게 노출시켜주는 메커니즘을 제공한다. 예를 들어, 프로세스가 divide by zero를 시도했다면 커널은 SIGFPE 시그널 (8번)을 전송한다. 만약 프로세스가 허용되지 않은 명령을 실행한다면 커널은 SIGILL 시그널 (4번)을 전송한다. 만약 프로세스가 허용되지 않은 메모리를 참조하려고 한다면 커널은 SIGSEGV 시그널 (11번)을 전송한다.

다른 시그널들은 커널 또는 다른 사용자 프로세스에서의 고수준 소프트웨어 이벤트에 대응된다. 예를 들어, 프로세스가 foreground에서 실행되고 있을 때 Ctrl + C를 입력하면 커널은 foreground 프로세스 그룹에 있는 모든 프로세스에게 SIGINT 시그널(2번)을 전송한다. 한 프로세스는 다른 프로세스에게 SIGKILL 시그널 (9번)을 전송함으로써 다른 프로세스를 강제로 종료시킬 수 있다. 자식 프로세스가 종료되거나 멈췄을 때 커널은 부모 프로세스에게 SIGCHILD 시그널 (17번)을 전송한다.

 

(1) 시그널

목적지 프로세스에게 시그널을 전달하는 것은 두 가지 단계로 일어난다.

 

시그널 보내기

커널은 목적지 프로세스의 컨텍스트에 있는 어떤 state를 업데이트함으로써 목적지 프로세스에 시그널을 전달한다. 시그널은 하나 또는 두 가지 이유로 전달된다. (1) 커널이 divide-by-zero 에러나 자식 프로세스의 종료와 같은 시스템 이벤트를 감지했다. (2) 프로세스가 커널이 목적지 프로세스에게 시그널을 전송하도록 명시적으로 요청하기 위해 kill 함수를 호출했다. 프로세스는 시그널을 자기 자신에게 전송할 수도 있다.

 

시그널 받기

커널이 목적지 프로세스에 전달된 시그널에 대하여 목적지 프로세스가 어떤 방식으로든 반응하도록 강요할 때 목적지 프로세스는 시그널을 받는다. 프로세스는 signal handler라고 불리는 사용자 레벨의 함수를 실행함으로써 시그널을 무시할 수도 있고, 종료시킬 수도 있고 시그널을 catch할 수도 있다. 

 

전송되었지만 아직 수신되지 않은 시그널을 pending signal이라고 한다. 어떤 시점에서나 특정 유형의 pending 시그널은 최대 한 개만 있을 수 있다. 만약 프로세스가 k 유형의 pending 시그널을 가지고 있다면 이후에 그 프로세스에 대한 k 유형의 시그널은 queue되지 않는다.이들은 그냥 삭제된다. 프로세스는 특정 시그널의 수신을 선택적으로 block할 수 있다. 시그널이 block되면 시그널은 전송될 수는 있지만 프로세스가 시그널을 unblock할 때까지 수신될 수는 없다 (pending 시그널).

pending 시그널은 최대 한 번만 수신된다. 각각의 프로세스에 대해 커널은 pending 비트 벡터에 pending 시그널의 집합을 유지한다. block된 시그널의 집합은 blocked 비트 벡터에 유지된다. 커널은 k 유형의 시그널을 전달받을 때마다 비트 k를 pending으로 설정하고, k 유형의 시그널을 수신받을 때마다 pending에 있는 비트 k를 clear한다. 

 

(2) 시그널 보내기

유닉스 시스템은 프로세스에게 시그널을 보낼 수 있는 다양한 메커니즘을 제공한다. 모든 메커니즘은 프로세스 그룹이라는 개념에 의존한다.

 

프로세스 그룹

모든 프로세스는 하나의 프로세스 그룹에 속한다. 프로세스 그룹은 양수 정수의 프로세스 그룹 ID로 식별된다. getpgrp 함수는 현재 프로세스의 프로세스 그룹 ID를 return한다.

#include <unistd.h>

/* Returns: process group ID of calling process */
pid_t getpgrp (void);

 

기본적으로 자식 프로세스는 부모 프로세스와 같은 프로세스 그룹에 속한다. 프로세스는 setpgid 함수를 통해 자신 또는 다른 프로세스의 프로세스 그룹을 변경할 수 있다.

#include <unistd.h>

/* Returns: 0 on success, -1 on error */
int setpgid (pid_t pid, pid_t pgid);

 

setpgid 함수는 프로세스 pid의 프로세스 그룹을 pgid로 변경한다. 만약 pid가 0이라면 현재 프로세스의 PID가 사용된다. 만약 pgid가 0이면 pid에 의해 특정된 프로세스의 PID가 프로세스 그룹 ID로 사용된다. 예를 들어, 15213 프로세스가 setpgid (0, 0)을 호출하면 이는 프로세스 그룹 아이디가 15213인 프로세스 그룹을 생성하고 이 그룹에 프로세스 15213을 추가한다.

 

/bin/kill 프로그램으로 시그널 전송하기

/bin/kill 프로그램은 다르 프로세스에게 임의의 시그널을 전송한다. 예를 들어, 명령어 /bin/kill -9 15213은 프로세스 15213 프로세스에게 시그널 9번 (SIGKILL)을 전송한다. 음수의 PID는 PID 프로세스 그룹에 있는 모든 프로세스에게 시그널이 전송되게 만든다. /bin/kill -9 -15213은 프로세스 그룹 15213에 속한 모든 프로세스에게 시그널을 보낸다. 이 교재에서 완전한 경로 /bin/kill을 사용한 이유는 몇몇 Unix shell이 내장된 kill 명령어를 가지고 있기 때문이다.

 

키보드로 시그널 전송하기

Unix shell은 커맨드 라인을 평가한 결과 생성된 프로세스를 표현하기 위해 job의 추상화를 이용한다. 어느 시점에나 foreground에는 최대 하나의 job이 있고 background에는 0개 또는 1개 이상의 job이 있다. 예를 들어, ls | sort 라는 명령어를 타이핑하면, Unix pipe에 의해 연결된 두 개의 프로세스로 이루어진 하나의 foreground job이 생성된다. 하나는 ls 프로그램을 실행시키는 프로세스이고 다른 하나는 sort 프로그램을 실행시키는 프로세스이다. shell은 각 job에 대해 별개의 프로세스 그룹을 생성한다. 프로세스 그룹 ID로는 일반적으로 job에 있는 부모 프로세스 중 하나로부터 구해진다.

 

다음은 하나의 foreground job과 두 개의 background job으로 이루어진 shell을 보여준다.

foreground job에 있는 부모 프로세스는 PID 20을 가지고 프로세스 그룹 ID 20을 가진다. 부모 프로세스는 프로세스 그룹 20의 구성원인 두 개의 자식 프로세스를 생성한다.

Ctrl+C 타이핑은 커널이 foreground 프로세스 그룹에 있는 모든 프로세스에게 SIGINT 시그널을 보내게 만든다.  일반적으로 이는 foreground job을 종료시킨다. 비슷하게 Ctrl+Z 타이핑은 커널이 foreground에 있는 모든 프로세스에게 SIGSTP 시그널을 보내게 만든다. 일반적으로 이는 foreground job을 중단시킨다.

 

kill 함수로 시그널 전송하기

프로세스는 kill 함수를 호출함으로써 자신을 포함하여 다른 프로세스에게 시그널을 보낸다.

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

/* Returns: 0 if OK, -1 on error */
int kill (int pid_t pid, int sig);

 

pid가 0보다 크다면 kill 함수는 pid 프로세스에게 시그널 넘버 sig를 전송한다. 만약 pid가 0이라면 kill은 kill을 호출한 프로세스의 프로세스 그룹에 있는 모든 프로세스(kill을 호출한 프로세스 자기 자신을 포함)에게 시그널 sig를 전송한다. 만약 pid가 0보다 작으면 kill은 시그널 sig를 pid 절댓값에 해당하는 프로세스 그룹에 있는 모든 프로세스에게 시그널 sig를 전송한다.

다음은 자식 프로세스에게 SIGKILL을 전송하기 위해 kill 함수를 사용하는 부모 프로세스의 예시를 보여준다.

#include "csapp.h"

int main ()
{
    pid_t pid;
    
    /* Child sleeps until SIGKILL signal received, then dies */
    if ((pid = Fork()) == 0) {
    	Pause ();	/* Wait for a signal to arrive */
        printf ("control should never reach here!\n");
        exit (0);
    }
    
    /* Parent sends a SIGKILL signal to a child */
    Kill (pid, SIGKILL);
    exit (0);
}

 

alarm 함수로 시그널 전송하기

프로세스는 alarm 함수를 호출함으로써 자기 자신에게 SIGALRM을 전송할 수 있다.

#include <unistd.h>

/* Returns: remaining seconds of previous alarm, or 0 if no previous alarm */
unsigned int alarm (unsigned int secs);

 

alarm 함수는 커널이 secs 초 안에 호출 프로세스에게 SIGALRM을 전송하게 만든다. 만약 sec이 0이라면, 새로운 알람은 스케줄링되지 않는다. 어떤 이벤트에서나 alarm 호출은 모든 대기 중인 pending 알람을 취소시키고 (alarm 호출이 알람을 취소하지 않았다면) 전송되었을 대기 중인 pending alarm이 전달되기까지 남은 초를 return한다. 만약 pending alarm이 없으면 0을 return한다.

 

(3) 시그널 받기

커널이 프로세스 p를 커널 모드에서 사용자 모드로 전환하면 (즉, 시스템 콜로부터 return하거나 컨텍스트 스위칭을 마치면) 커널은 프로세스 p에 대해 unblocked된 pending 시그널들 (pending & ~blocked)의 set을 확인한다. 만약 set이 비어 있으면 (일반적인 경우이다.) 커널은 다음 명령에게 컨트롤을 넘긴다. 하지만 set이 비어 있지 않으면 커널은 set에서 특정 시그널 k를 선택해서 (일반적으로 가장 작은 k이다.) 프로세스 p가 k의 수신을 받도록 강제한다. 시그널의 수신은 프로세스가 어떤 행동을 하게 만든다. 프로세스가 이 행동을 끝내고 나면 다음 명령에게 컨트롤이 넘어간다. 모든 시그널 유형은 사전에 정의된 기본 행동이 있다. 이는 다음 중 하나이다.

  • 프로세스 종료
  • 프로세스를 종료하고 core를 덤프하기 (dumps core)
  • SIGCONT 시그널을 받아 재시작하기 전까지 프로세스 중단
  • 프로세스가 시그널 무시

예를 들어, SIGKILL 시그널을 수신했을 때 기본 행동은 수신 프로세스를 종료시키는 것이다. 반면 SIGCHILD 시그널 수신의 기본 행동은 시그널을 무시하는 것이다. 프로세스는 signal 함수를 이용하여 시그널에 관련된 기본 행동을 변경할 수 있다. 하지만 SIGSTOP과 SIGKILL 시그널의 경우 기본 행동이 변경될 수 없다.

#include <signal.h>
typedef void (*sighandler_t)(int);

/* Returns: pointer to previous handler if OK, SIG_ERR on error (does not set errno) */
sighandler_t signal(int signum, sighandler_t handler);

 

signal 함수는 시그널 signum에 관련된 행동을 다음 세 가지 방식으로 변경할 수 있다.

  • handler가 SIG_IGN이라면 signum 유형의 시그널은 무시된다.
  • handler가 SIG_DFL이라면 signum 유형의 시그널에 대한 기본 행동은 기본 행동으로 되돌아간다.
  • handler가 signal handler라고 불리는 사용자 정의 함수의 주소라면 이 함수는 프로세스가 signum 유형의 시그널을 수신했을 때 호출될 것이다. signal 함수에게 핸들러의 주소를 전달하여 기본 행동을 바꾸는 것을 핸들러를 설치한다고 말한다. 이 핸들러를 호출하는 것은 cating the signal이라고 말한다. 핸들러를 실행하는 것은 handling the signal이라고 말한다.

프로세스가 k 유형의 시그널을 catch하면 시그널 k를 위해 설치된 핸들러는 k로 설정된 단일 정수 인자와 함께 호출된다. 이 인자는 하나의 핸들러 함수가 여러 유형의 시그널을 catch할 수 있도록 해준다. 

핸들러가 return 문을 실행했을 때 제어는 일반적으로 시그널의 수신으로 중단되었던 제어 흐름에 있는 명령으로 돌아온다. "일반적으로"라고 말한 이유는 어떤 시스템에서는 방해받은 시스템이 에러와 함께 즉시 return하기 때문이다.

다음은 사용자가 Ctrl+C를 입력했을 때 보내지는 SIGINT 시그널을 catch하는 프로그램을 보여준다.

#include "csapp.h"

void sigint_handler (int sig) /* SIGINT handler */
{
    printf ("Caught SIGINT!\n");
    exit (0);
}

int main ()
{
    /* Install the SIGINT handler */
    if (signal (SIGINT, sigint_handler) == SIG_ERR)
    	unix_error ("signal error");
    
    pause ();	/* Wait for the receipt of a signal */
    
    return 0;
}

 

SIGINT의 기본 행동은 즉시 프로세스를 종료시키는 것이다. 예시에서는 시그널을 catch하기 위해 기본 행동을 바꾸고, 메시지를 출력하고 그 다음에 프로세스를 종료시켰다.

시그널 핸들러는 다른 핸들러에 의해 방해받을 수 있다.

위 그림에서 main 프로그램은 프로그램을 방해하고 핸들러 S에게 제어를 넘기는 시그널 s를 catch한다. S가 실행될 때 프로그램은 S를 중단시키고 핸들러 T에게 제어를 이동시키는 시그널 t (s와 다른) catch한다. T가 return할 때 S는 중단된 곳에서부터 재시작한다. 마침내 S가 main 프로그램으로 제어를 다시 돌려놓으면서 return한다.

 

(4) 시그널 block, unblock하기

Linux는 blocking 시그널을 위한 암묵적, 명시적 메커니즘을 제공한다.

 

암묵적 blocking 메커니즘

기본적으로 커널은 핸들러에 의해 현재 처리되고 있는 모든 pending 시그널들을 block한다. 예를 들어서 프로그램이 시그널 s를 catch했고 현재 핸들러 S를 실행 중이라고 해 보자. 만약 다른 시그널 s가 프로세스에게 보내졌다면 s는 핸들러 S가 returng할 때까지 수신되지 않고 pending이 될 것이다. 

 

명시적 blocking 메커니즘

애플리케이션은 sigprocmask 함수와 헬퍼들을 이용하여 선택된 시그널을 명시적으로 block하거나 unblock할 수 있다.

#include <signal.h>

/* Returns: 0 if OK, -1 on error */
int sigprocmask (int how, const sigset_t *set, sigset_t *oldset);
int segemptyset (sigset_t *set);
int sigfillset (sigset_t *set);
int sigaddset (sigset_t *set, int signum);
int sigdelset (sigset_t *Set, int signum);

/* Returns: 1 if member, 0 if not, -1 on error */
int sigismember (const sigset_t *set, int signum);

 

sigprocmask 함수는 현재 block된 시그널의 set (blocked된 비트 벡터)를 변경한다. 구체적인 행동은 how의 값에 따라 달라진다.

 

SIG_BLOCK

blocked에 set에 있는 시그널을 추가한다. (blocked = blocked | set)

 

SIG_UNBLOCK

blocked에서 set에 있는 시그널을 제거한다. (blocked = blocked & set)

 

SIG_SETMASK

blocked = set

 

만약 oldset가 NULL이 아니라면 blocked 비트의 예전 값은 oldset에 저장된다.

 

set와 같은 시그널 집합은 다음 함수를 이용하여 조작된다.

 

sigemptyset

set을 빈 집합으로 초기화한다.

 

sigfillset

set에 모든 시그널을 추가한다.

 

sigaddset

set에 signum을 추가한다.

 

sigdelset

set에서 signum을 삭제한다.

 

sigismember

signum이 set의 member이면 1을 return하고 그렇지 않으면 0을 반환한다.

 

다음은 sigprocmask를 이용하여 어떻게 SIGINT 시그널의 수신을 일시적으로 block할 수 있는지를 보여준다.

sigset_t mask, prev_mask;

Sigemptyset (&mask);
Sigaddset (&mask, SIGINT);

/* Block SIGINT and save previous blocked set */
Sigprocmask (SIG_BLOCK, &mask, &prev_mask);

// Code region that will not be interrupted by SIGINT

/* Restore previous blocked set, unblocking SIGINT */
Sigprocmask (SIG_SETMASK, &prev_mask, NULL);

 

(5) 시그널 핸들러 작성하기

시그널 핸들러는 Linux의 시스템 레벨 프로그래밍에 있어서 가장 골치 아픈 부분 중 하나이다. 핸들러를 만드는 것을 어렵게 만드는 이유는 다음과 같다. (1) 핸들러는 main 프로그램과 동시에 실행되며 같은 전역 변수를 공유하기 때문에 main 프로그램과 다른 핸들러를 방해할 수 있다. (2) 어떻게 그리고 언제 시그널을 수신되는지에 대한 규칙이 직관적이지 않은 경우가 많다. (3) 시스템마다 다른 시그널 핸들링 문법을 가질 수 있다.

이번 절에서 우리는 이러한 이슈들을 다룰 것이다. 그리고 안전하고, 정확하고, portable한 시그널 핸들러를 만들기 위한 기본적인 가이드라인을 제공할 것이다.

 

안전한 시그널 핸들링

시그널 핸들러는 메인 프로그램과 그리고 시그널 핸들러끼리 동시에 실행될 수 있어서 까다롭다. 만약 핸들러와 메인 프로그램이 동시에 같은 전역 자료 구조에 접근한다면 예상치 못한, 치명적인 결과가 나올 수 있다.

이번 절에서는 동시에 실행되기에 안전한 핸들러를 작성하기 위한 보수적인 가이드라인을 제공할 것이다. 만약 이러한 가이드라인을 무시한다면 당신은 미묘한 동시성 에러를 유발할 수 있는 위험을 감수하게 되는 것이다. 이러한 에러가 있어도 당신의 프로그램은 대부분의 시간 동안 올바르게 수행될 수 있다. 하지만 프로그램이 실패한 경우에는 디버깅하기 매우 어려운 방법으로 실패하게 될 것이다. 미리 대비하는 것이 좋다.

 

0. 핸들러를 가능한 간단하게 유지하라

문제를 피하는 가장 좋은 방법은 핸들러를 가능한 작고 간단하게 유지하는 것이다. 예를 들어, 핸들러는 단순히 전역 플래그를 설정한 다음 즉시 return할 수 있다. 시그널의 수신과 관련된 모든 처리는 주기적으로 플래그를 확인하고 재설정하는 main 프로그램에 의해 수행된다.

 

1. 당신의 핸들러에서 async-signal-safe 함수만 호출하라

async-signal-safe, 또는 간단히 safe 함수는 시그널 핸들러에서 안전하게 호출될 수 있는 속성을 가지고 있다. 이는 이 함수가 reentrant하기 때문일 수도 있고 (지역 변수에만 접근하는 경우) 또는 시그널 핸들러에 의해 중단될 수 없기 때문이기도 하다. 다음은 Linux가 안전하다고 보장하는 시스템 레벨 함수들이다. printf, sprintf, malloc, exit와 같이 인기 있는 함수들이 이 목록에 없다는 것을 기억하길 바란다.

시그널 함수에서 안전한 결과를 만들 수 있는 유일한 안전한 방법은 write 함수를 사용하는 것이다. 특히, printf나 sprintf를 사용하는 것은 안전하지 않다. 이러한 제약 아래에서 작업하기 위해 SIO (Safe I/O) 패키지라고 불리는 안전한 함수들을 개발했다. 이를 이용하면 시그널 핸들러에서 간단한 메시지들을 출력할 수 있다.

#include "csapp.h"

/* Returns: number of bytes transferred if OK, -1 on error */
ssize_t sio_putl (long v);
ssize_t sio_puts (char s[]);

/* Returns: nothing */
void sio_error (char s[]);

 

sio_putl과 sio_puts 함수는 long과 string을 표준 출력에 내보낸다. sio_error 함수는 에러 메시지를 출력하고 종료시킨다.

 

다음은 두 개의 사적 reentrant 함수를 이용하는 SIO package의 구현 내용을 보여준다.

ssize_t sio_puts (char s[]) /* Put string */
{
	return write (STDOUT_FILENO, s, sio_strlen (s));
}

ssize_t sio_putl (long v) /* Put long */
{
    char s[128];
    
    sio_ltoa (v, s, 10); /* Based on K&R itoa() */
    return sio_puts (s);
}

void sio_error (char s[]) /* Put error message and exit */
{
	sio_puts (s);
    _exit (1);
}

 

sio_strlen 함수는 문자열 s의 길이를 return한다. sio_ltoa 함수는 itoa 함수에 기반을 두고 있는데 값 v를 문자열 s에 b 진수 문자열로 변환한다. _exit 함수는 exit의 async-signal-safe 버전이다. 

다음은 SIGNINT 핸들러의 안전한 버전을 보여준다.

#include "csapp.h"

void sigint_handler (int sig) /* Safe SIGINT handler */
{
    Sio_puts ("Caught SIGINT!\n");	/* Safe output */
    _exit (0);			        /* Safe exit */
}

 

2. errno를 저장하고 복원하라.

Linux의 async-signal-safe 함수 중 많은 함수는 error와 함께 return할 때 errno를 설정한다. 핸들러 안에서 이러한 함수들을 호출하는 것은 errno에 의존하는 프로그램의 다른 부분들을 방해할 수도 있다. 이를 피하여 작업할 수 있는 방법은 핸들러에 진입할 때 errno를 지역 변수에 저장해놓은 다음에 핸들러가 return하기 전에 이를 errno에 복원하는 것이다. 이 작업은 핸들러가 return할 때만 이루어지면 된다. 핸들러가 _exit을 호출함으로써 프로세스를 종료시킬 때는 이 작업이 필요 없다.

 

3. 모든 시그널을 block함으로써 공유되는 전역 자료 구조에 대한 접근을 보호하라.

핸들러는 main 프로그램 또는 다르 핸들러와 전역 자료 구조를 공유한다면 당신의 핸들러와 main 프로그램은 그러한 자료 구조에 접근하는 동안 (읽거나 쓰는 동안) 모든 시그널을 일시적으로 block해야 한다. 이러한 규칙이 필요한 이유는 main 프로그램에서 자료 구조 d에 접근하려면 일반적으로 명령 시퀀스가 요구되기 때문이다. 만약 명령의 시퀀스가 d에 접근하는 핸들러에 의해 중단되면 핸들러는 비일관적인 상태에 있는 d에 접근하게 될 수 있고 이는 예측할 수 없는 결과를 낳을 수 있다. d에 접근하는 동안 일시적으로 시그널을 block하는 것은 핸들러가 명령의 시퀀스를 중단시키지 않을 것이라는 보장할 수 있다.

 

4. 전역 변수를 volatile로 선언하라.

핸들러와 main 함수가 전역 변수 g를 공유하고 있다고 해보자. 핸들러가 g를 업데이트하고 main 함수가 주기적으로 g를 읽는다고 해보자. 최적화 컴파일러에게 이는 main 함수에서 g의 값은 절대 변하지 않는 것처럼 보일 것이다. 그래서 안전을 위해 g에 대한 모든 참조가 레지스터에 저장되어 있는 g의 복사본을 이용하게 할 것이다. 이렇게 되면 main 함수는 핸들러에 의해 업데이트된 값을 절대 볼 수 없게 된다.

컴파일러에게 변수를 캐싱하지 말라고 얘기하려면 변수를 volatile type qualifier로 선언하면 된다.

volatile int g;

 

volatile qualifier는 코드에서 g가 참조될 때마다 메모리에서 g의 값을 읽어오도록 컴파일러를 강제한다. 공유되는 자료 구조가 그렇듯이 전역 변수에 대한 모든 접근은 일반적으로 시그널을 일시적으로 block함으로써 보호되어야 한다.

 

5. 플래그를 sig_atomic_t로 선언하라.

하나의 공통 핸들러 설계에서 핸들러는 전역 flag에 write을 함으로써 시그널의 수신을 기록한다. main 프로그램은 주기적으로 이 flag를 읽고 시그널에 반응하고 flag를 clear한다. 이러한 방식으로 공유되는 flag에 대하여 C는 정수 자료형인 sig_atmoic_t를 제공한다. sig_atomic_t에 대해 read와 write는 원자성을 보장받는다. (interrupt되지 않는다.) sig_atmoic_t가 다음과 같이 구현될 수 있기 때문이다.

volatile sig_atomic_t flag;

 

이들은 방해받을 수 없기 때문에 sig_atmoic_t 변수에 대한 읽기와 쓰기 작업은 시그널을 일시적으로 block하지 않아도 안전하게 이루어질 수 있다. 원자성의 보장은 오직 개별적인 read와 write 작업에 대해서만 보장된다. flag++ 또는 flag = flag + 10 과 같이 여러 명령을 요구하는 업데이트에 대해서는 적용되지 않는다.

 

이러한 가이드라인은 보수적인 것으로서 항상 엄격하게 지켜져야 하는 것은 아니다. 예를 들어, 당신이 어떤 handler가 절대로 errno를 변경할 수 없다는 것을 안다면 errno를 저장하고 복구할 필요가 없다. 또는 핸들러에 의해 어떤 printf도 방해받지 않는다는 것을 보장할 수 있다면 핸들러에서 printf를 호출하는 것이 안전하다. 공유 전역 자료 구조에 대한 접근도 마찬가지이다. 하지만 이러한 확신을 증명한다는 것은 일반적으로 어려운 일이다. 그렇기에 보수적인 접근을 취해서 핸들러를 가능한 간단하게 유지하고, 핸들러에서 안전한 함수들을 호출하고, errno를 저장한 다음 복원하고, 공유 자료 구조에 대한 접근을 보호하고, volatile과 sig_atomic_t를 이용하라는 가이드라인을 따를 것을 권장한다.

 

올바른 시그널 핸들링

시그널과 관련하여 직관적이지 않은 부분 중 하나는 시그널이 queue되지 않는다는 점이다. 이는 pending 비트 벡터가 각 시그널 유형에 대해 오직 하나의 비트만 유지하고, 특정 시그널 유형에 대해 최대 하나의 pending signal만 가질 수 있기 때문이다. 따라서 k 유형에 대한 핸들러를 실행 중이라서 k 유형이 block되어 있는 동안 k 유형에 대한 두 개의 시그널이 목적지 프로세스에 보내진다면, 두 번째 시그널은 queue되지 않고 그냥 삭제된다. 핵심은 pending 시그널의 존재는 단지 적어도 하나의 시그널이 도착했다는 사실을 나타낸다는 점이다.

이것이 정확성에 어떻게 영향을 주는지 보기 위해 shell이나 웹 서버와 같은 실제 프로그램의 속성과 비슷한 속성을 가진 간단한 애플리케이션을 살펴보겠다.기본 구조는 부모 프로세스가 자식 프로세스를 생성하는데 이 자식 프로세스는 독립적으로 한동안 실행되다가 종료한다. 부모 프로세스는 시스템에 좀비 프로세스를 남기고 싶지 않다면 자식 프로세스를 거두어야 한다. 하지만 우리는 부모 프로세스는 자식 프로세스가 실행되는 동안 다른 일을 할 수 있도록 자유롭기를 바란다. 그래서 자식 프로세스가 종료되기를 명시적으로 기다리는 대신에 SIGCHILD 핸들러를 통해 자식 프로세스를 거두기로 결정했다.

다음이 첫 번째 시도의 예시이다. 부모 프로세스는 SIGCHILD 핸들러를 설치하고 세 개의 자식 프로세스를 생성한다. 그동안 부모 프로세스는 터미널로부터 입력을 기다린 다음 실행시킨다. 이러한 처리는 무한 루프로 설계되었다. 각각의 자식 프로세스가 종료할 때 커널은 SIGCHILD 시그널을 부모 프로세스에게 전송함으로써 부모 프로세스에게 그 사실을 알려준다. 부모 프로세스가 SIGCHILD를 catch하면 하나의 자식 프로세스를 거두고 추가적인 cleanup 작업 (sleep 문으로 설계된다.)을 수행한 다음 return한다.

 

다음 signal1 프로그램은 꽤 단순하다. 하지만 Linux 시스템에서 이를 실행시켜보면 다음과 같은 결과가 나온다.

 

결과를 보면 비록 3개의 SIGCHILD가 부모 프로세스에게 전송되었지만 오직 2개만 수신되었고 그래서 부모 프로세스가 2개의 자식 프로세스만 거둔 것을 알 수 있다. 만약 우리가 부모 프로세스를 중단시킨다면 자식 프로세스는 거두어지지 않고 좀비로 남아 있을 것이다. ps 명령어의 결과에서 확인할 수 있다.

무엇이 문제일까? 시그널이 queue되지 않는다는 사실을 고려하지 않은 것이 문제이다. 다음과 같은 일이 일어났다: 첫 번째 시그널은 부모 프로세스에 의해 수신되었고 caught되었다. 핸들러가 첫 번째 시그널을 처리하는 동안, 두 번째 시그널이 전달되고 pending 시그널 집합에 추가된다. 하지만 SIGCHILD 핸들러에 의해 SIGCHILD 시그널이 block되기 때문에 두 번째 시그널은 수신되지 않는다. 그다음 핸들러가 첫 번째 시그널을 여전히 처리하는 동안 세 번째 시그널이 도착한다. 이미 pending SIGCHILD이 이미 있기 때문에 세 번째 SIGCHILD 시느널이 삭제된다. 조금 이따가 핸들러가 return하고 나서 커널은 pending SIGCHILD 시그널이 있다는 것을 알리고 부모 프로세스가 시그널을 수신하도록 만든다. 부모 프로세스는 시그널을 catch하고 핸들러를 실행한다. 핸들러가 두 번째 시그널 처리를 끝내면 더 이상 pending SIGCHILD 시그널은 없다. 왜냐하면 세 번째 SIGCHILD가 왔다는 사실은 손실되기 때문이다. 여기서 배워가야 할 점은 시그널은 절대로 다른 프로세스에서 발생한 이벤트를 세는 데 사용될 수 없다는 것이다.

이 문제를 해결하기 위해 우리는 pending 시그널의 존재는 오직 프로세스가 그 유형의 시그널을 마지막으로 수신받은 이후로 최소 하나의 시그널이 전달되었다는 것을 암시한다는 점을 기억해야 한다. 따라서 호출될 때마다 가능한 많은 좀비 자식 프로세스를 거둘 수 있도록 SIGCHILD 핸들러를 수정해야 한다. 다음은 수정된 SIGCHILD 핸들러이다.

이를 리눅스 시스템에서 돌리면 이제 모든 좀비 자식 프로세스가 정확하게 거두어진다.

 

Portable Signal Handling

Unix 시그널 핸들링의 별로인 부분 중 또 다른 하나는 서로 다른 시스템이 서로 다른 시그널 핸들링 문법을 가진다는 것이다.

 

signal 함수의 문법이 다르다.

어떤 옛날 Unix 시스템은 시그널 k가 핸들러에 의해 catch된 이후에 시그널 k를 위한 행동을 기본값으로 되돌려놓는다. 이러한 시스템에서 핸들러는 매번 실행될 때마다 signal 함수를 호출함으로써 명시적으로 자기 자신을 재설치해야 한다.

 

시스템 콜이 방해받을 수 있다.

read, wait, accept와 같은 시스템 콜은 긴 시간 동안 프로세스를 block할 수 있는데 이러한 시스템 콜들을 slow 시스템 콜이라고 말한다. 어떤 Unix 옛날 버전에서는 핸들러가 signal을 catch했을 때, 방해받은 핸들러가 return할 때 slow 시스템 콜이 재시작하지 않고 대신 error 상태와 함께 사용자에게 즉시 return하고 errno를 EINTR을 설정한다. 이러한 시스템에서 개발자는 방해받은 시스템 콜을 직접 재시작하는 코드를 포함시켜줘야 한다.

 

이러한 이슈를 다루기 위해서 Posix 표준은 sigaction 함수를 정의하고 있다. sigaction 함수는 사용자가 핸들러를 설치하고 싶을 때 시그널 핸들링 문법을 명확하게 구체화할 수 있게 해준다. 

#include <signal.h>

/* Returns: 0 if OL, -1 on error */
int sigaction (int signum, struct sigaction *act, struct sigaction *oldact);

 

sigaction 함수는 다루기 어렵다. 왜냐하면 이 함수는 사용자에게 복잡한 구조의 항목을 설정하도록 요구하기 때문이다. 더 편리한 방법은 W.Richard Stevens가 제안한, Signal이라는 wrapper 함수를 정의하는 것이다. Signal은 sigaction을 호출해준다.

다음은 Signal의 정의이다. 

handler_t *Signal (int signum, handler_t *handler)
{
    struct sigaction action, old_action;
    
    action.sa_handler = handler;
    sigemptyset (&action.sa_mask);	/* Block sigs of type being handled */
    action.sa_flags = SA_RESTART;	/* Restart syscalls if possible */
    
    if (sigaction (signum, &action, &old_action) < 0)
    	unix_error ("Signal error");
    return (old_action.sa_handler);
}

 

Signal이라는 wrapper 함수는 다음과 같은 시그널 핸들링 문법으로 시그널 핸들러를 설치한다.

  • 현재 핸들러에 의해 처리되고 있는 유형의 시그널만 block된다.
  • 다른 시그널 구현처럼 시그널은 queue되지 않는다.
  • 중단된 시스템 콜은 언제든지 가능하다면 자동으로 재시작된다.
  • 시그널 핸들러가 한 번 설치되었다면 SIG_IGN 또는 SIG_DFL 라는 handler 인자와 함께 Signal이 호출되기 전까지 설치된 상태로 남아 있는다.

(6) 심각한 동시성 버그를 피하기 위해 흐름 동기화하기

같은 스토리지 위치를 읽고 쓰는 동시 흐름을 어떻게 프로그래밍할 것인가 하는 문제는 오랜 시간 동안 컴퓨터 과학자들에게 도전 과제였다. 일반적으로 가능한 흐름 교차의 수는 명령 수에 대해 지수적 exponential이다. 이러한 교차 중 일부는 정확한 답을 내겠지만 그렇지 않은 것도 있을 것이다. 가능한 흐름 교차의 경우의 수들이 모두 정확한 답을 낼 수 있도록 동시 흐름을 잘 동기화하는 것이 필요하다.

 

다음은 전형적인 Unix shell의 구조를 보여주는 프로그램이다. 부모 프로세스는 job 하나당 하나의 항목을 이용하여 전역 job 목록을 사용 중인 현재 자식 프로세스들을 추적하고 있다. addjob과 deletejob 함수는 job 목록에서 항목을 추가하고 제거한다.

/* WARNING: This code is buggy! */
void handler (int sig)
{
    int olderrno = errno;
    sigset_t mask_all, prev_all;
    pid_t pid;
    
    Sigfillset(&mask_all);
    while ((pid = waitpid (-1, NULL, 0)) > 0) {	/* Reap a zombie child */
    	Sigprocmask (SIG_BLOCK, &mask_all, &prev_all);
        deletejob (pid);	/* Delete the child from the job list */
        Sigprocmask (SIG_SETMASK, &prev_all, NULL);
    }
    if (errno != ECHILD)
    	Sio_error ("waitpid error");
    errno = olderrno;
 }
 
 int main (int argc, char **argv)
 {
    int pid;
    sigset_t mask_all, prev_all;
    
    Sigfillset (&mask_all);
    Signal (SIGCHILD, handler);
    initjobs ();	/* Initialize the job list */
    
    while (1) {
    	if ((pid = Fork ()) == 0) {	/* Child process */
            Execve ("/bin/date", argv, NULL);
    	}
        Sigprocmask (SIG_BLOCK, &mask_all, &prev_all);	/* Parent process */
        addjob (pid);	/* Add the child to the job list */
        Sigprocmask (SIG_SETMASK, &prev_all, NULL);
    }
    exit (0);
 }

 

부모 프로세스가 새로운 자식 프로세스를 생성하고 나서 부모 프로세스는 job 목록에 자식 프로세스를 추가한다. 부모 프로세스가 SIGCHILD 시그널 핸들러에서 종료된 좀비 자식 프로세스를 거둘 때 부모 프로세스는 job 목록에서 자식 프로세스를 삭제한다. 얼핏 보면 코드가 정확한 것처럼 보이지만 다음과 같은 이벤트 시퀀스들이 가능하다. (부모 프로세스가 실행 가능한 상황이 되기 전에 자식 프로세스가 먼저 종료되면 addjob과 deletejob이 잘못된 순서로 호출될 수 있다.)

 

1. 부모 프로세스가 fork 함수를 호출하고 커널이 새롭게 생성된 자식 프로세스가 부모 프로세스 대신 실행될 수 있도록 스케줄링한다.

2. 부모 프로세스가 다시 시작될 수 있는 상황이 되기 전에 자식 프로세스가 종료하고 좀비 프로세스가 되어 커널이 부모 프로세스에게 SIGCHILD 시그널을 보내게 만든다.

3. 부모 프로세스가 다시 실행 가능하게 되었지만 실행되기 전에 커널은 pending SIGCHILD가 있다는 것을 확인하고 부모 프로세스에서 실행 중인 시그널 핸들러가 이를 수신하도록 만든다.

4. 시그널 핸들러는 종료된 자식 프로세스를 거두고 deletejob을 호출한다. 이 deletejob은 아무 일도 하지 않는다. 왜냐하면 부모 프로세스가 목록에 자식 프로세스를 아직 추가하지 않았기 때문이다.

5. 핸들러가 작업을 완료하고 나서 커널은 부모 프로세스를 실행시킨다. 부모 프로세스는 fork로부터 return하고 addjob을 호출하여 존재하지 않는 자식 프로세스를 job 목록에 추가하는 잘못된 작업을 수행하게 된다.

 

따라서 부모 프로세스의 main 함수와 시그널 핸들링 흐름의 교차 중 어떤 것에서는 deletejob이 addjob보다 먼저 호출될 수 있다. 이는 더 이상 존재하지 않고 앞으로 제거될 일 없는 job에 대해 job 목록에 부정확한 항목을 만들게 된다. 한편 이벤트들이 정확한 순서로 발생하는 교차도 있다. 예를 들어 만약 커널이 fork 함수가 return했을 때 자식 프로세스 대신에 부모 프로세스를 스케줄링한다면 부모 프로세스는 자식 프로세스가 종료하고 시그널 핸들러가 job을 목록에서 제거하기 전에 정확하게 자식 프로세스를 job 목록에 추가할 것이다.

이는 race라고 불리는 전형적인 동기화 에러이다. 이 경우 race는 main 함수에서의 addjob 호출과 핸들러에서의 deletejob 사이에 race가 발생한 것이다. 만약 addjob이 race에서 이긴다면 답은 정확할 것이다. 하지만 그렇지 않다면 답은 정확하지 않을 것이다. 이러한 에러는 디버깅하기 굉장히 어렵다. 왜냐하면 모든 교차를 테스트하기란 불가능한 경우가 많기 때문이다. 수십 억 줄의 코드가 문제 없이 실행되었더라도 바로 다음 테스트에서 race를 유발하는 교차가 나타날 수 있다.

다음은 위 코드에서 race를 제거하는 하나의 방법을 보여준다. fork를 호출하기 전에 SIGCHILD 시그널을 block하고 addjob을 호출한 이후에만 SIGCHILD 시그널을 unblock함으로써 자식 프로세스가 job 목록에 추가된 이후에만 거두어질 수 있도록 보장할 수 있다. 자식 프로세스가 부모 프로세스의 blocked set을 상속받는다는 점에 주목하라. 이런 이유로 execve를 호출하기 전에 자식 프로세스에서 SIGCHILD 시그널을 unblock할 때 주의를 기울여야 한다.

void handler (int sig)
{
    int olderrno = errno;
    sigset_t mask_all, prev_all;
    pid_t pid;
    
    Sigfillset (&mask_all);
    while ((pid = waitpid (-1, NULL, 0)) > 0) { /* Reap a zombie child */
    	Sigprocmask (SIG_BLOCK, &mask_all, *prev_all);
        deletejob (pid);	/* Delete the child from the job list */
        Sigprocmask (SIG_SETMASK, &prev_all, NULL);
    }
    if (errno != ECHILD)
    	Sio_error ("waitpid error");
    errno = olderrno;
}

int main (int argc, char **argv)
{
    int pid;
    sigset_t mask_all, mask_one, prev_one;
    
    Sigfillset (&mask_all);
    Sigemptyset (&mask_one);
    Sigaddset (&mask_one, SIGCHILD);
    Signal (SIGCHILD, handler);
    initjobs ();	/* Initialize the job list */
    
    while (1) {
    	Sigprocmask (SIG_BLOCK, &mask_one, &prev_one);	/* Block SIGCHILD */
        if ((pid = Fork ()) == 0) { /* Child process */
            Sigprocmask (SIG_SETMASK, &prev_one, NULL);	/* Unblock SIGCHILD */
            Exeve ("/bin/date", argv, NULL);
        }
        Sigprocmask (SIG_BLOCK, &mask_all, NULL);	/* Parent process */
        addjob (pid);	/* Add the child to the job list */
        Sigprocmask (SIG_SETMASK, &prev_one, NULL);	/* Unblock SIGCHILD */
    }
    exit (0);
}

 

(7) 명시적으로 시그널 기다리기

main 프로그램이 특정 시그널 핸들러가 실행되기를 명시적으로 기다려야 할 때가 있다. 예를 들어, Linux shell이 foreground job을 생성했을 때 shell은 다음 사용자 명령을 받아들이기 전에 job이 종료되고 SIGCHILD 핸들러에 의해 거두어지기를 기다려야 한다.

 

다음 예시는 기본적인 아이디어를 보여준다. 부모 프로세스가 SIGINT와 SIGCHILD를 위한 핸들러를 설치하고 무한 루프에 진입한다. 이는 부모 프로세스와 자식 프로세스 사이의 race를 피하기 위해 SIGCHILD를 block한다. 자식 프로세스를 생성하고 나서 pid를 0으로 초기화하고 SIGCHILD를 unblock하고 pid가 0이 아닌 값이 되기를 기다리는 스핀 루프에 빠진다. 자식 프로세스가 종료되고 나서 핸들러는 자식 프로세스를 거두고 0이 아닌 PID를 전역 pid 변수에 할당한다. 이는 스핀 루프를 종료시킨다. 그리고 부모 프로세스는 다음 반복을 시작하기 전까지 추가적인 작업을 계속한다.

#include "csapp.h"

volatile sig_atomic_t pid;

void sigchld_handler (int s)
{
    int olderrno = errno;
    pid = waitpid (-1, NULL, 0);
    errno = olderrno;
}

void sigint_handler (int s)
{
}

int main (int argc, char **argv)
{
    sigset_t mask, prev;
    
    Signal (SIGCHILD, sigchld_handler);
    Signal (SIGINT, sigint_handler);
    Sigemptyset (&mask);
    Sigaddset (&mask, SIGCHLD);
    
    while (1) {
    	Sigprocmask (SIG_BLOCK, &mask, &prev);	/* Block SIGCHILD */
        if (Fork () == 0)	/* Child */
            exit (0);
        
        /* Parent */
        pid = 0;
        Sigprocmask (SIG_SETMASK, &prev, NULL);	/* Unblock SIGCHLD */
        
        /* Wait for SIGCHLD to be received (wasteful) */
        while (!pid)
            ;
       
       	/* Do some work after receiving SIGCHLD */
        printf(".");
    }
    exit (0);
}

 

이 코드는 정확하기는 하지만 스핀 루프는 프로세서 리소스를 너무 낭비한다. 그래서 스핀 루프의 내용에 pause를 삽입함으로써 이를 추가하고 싶을 수 있다.

while (!pid)	/* Race! */
    pause ();

 

pause가 하나 또는 그 이상의 SIGINT 시그널의 수신으로 인해 방해받을 수도 있기 때문에 루프는 여전히 필요하다. 그러나 이 코드는 심각한 race 상태를 가진다. 만약 SIGCHILD가 while 다음 하지만 pause 전에 수신되면 pause는 영원히 sleep하게 된다.

 

다른 방법은 pause를 sleep으로 대체하는 것이다.

while (!pid)	/* Too slow! */
    sleep (1);

 

이는 정확하기는 하지만 코드가 너무 느리다. 만약 시그널이 while 다음 그리고 sleep 이전에 수신된다면 프로그램은 루프 종료 조건을 다시 확인하기 전까지 상대적으로 긴 시간을 기다려야 한다. sleep 간격을 결정할 수 있는 마땅한 규칙이 없기 때문에 nanosleep과 같은 high-resolution sleep 함수를 사용하는 것도 적합하지 않다. sleep 간격을 너무 작게 만들면 루프는 너무 낭비스러워질 것이고 sleep 간격을 너무 크게 만들면 프로그램이 너무 느려질 것이다.

 

적합한 해결 방법은 sigsuspend를 사용하는 것이다.

#include <signal.h>

/* Returns: -1 */
int sigsuspend (const sigset_t *mask);

 

sigsuspend 함수는 일시적으로 현재 blocked된 set를 mask로 교체하고, 핸들러를 실행하거나 프로세스를 종료시키는 시그널을 수신할 때까지 프로세스를 중단시킨다. 만약 행동이 프로세스를 종료시키는 것이라면 프로세스는 sigsuspend에서 return하지 않고 종료된다. 만약 행동이 핸들러를 실행하는 것이라면 sigsuspend는 그것이 호출되었을 때의 상태로 blocked set를 복원하면서, handler가 return한 이후에 return한다. 

sigsuspend 함수는 다음의 원자성이 있는 버전이라고 할 수 있다.

sigprocmask (SIG_BLOCK, &mask, &prev);
pause ();
sigprocmask (SIG_BLOCK, &prev, NULL);

 

원자성은 sigprocmask와 pause 호출이 방해받지 않고 동시에 발생할 수 있도록 보장한다. 이는 sigprocmask 호출 이후 그리고 pause 호출 이전에 시그널을 수신받는 잠재적인 race 상황을 제거한다.

 

다음은 스핀 루프를 sigsuspend로 대체한 것이다.

#include "csapp.h"

volatile sig_atomic_t pid;

void sigchld_handler (int s)
{
    int olderrno = errno;
    pid = Waitpid (-1, NULL, 0);
    errno = olderrno;
}

void sigint_handler (int s)
{
}

int main (int argc, char **argv)
{
    sigset_t mask, prev;
    
    Signal (SIGCHLD, sigchld_handler);
    Signal (SIGINT, sigint_handler);
    SIGemptyset (&mask);
    Sigaddset (&mask, SIGCHLD);
    
    while (1) {
        Sigprocmask (SIG_BLOCK, &mask, &prev);	/* Block SIGCHLD */
        if (Fork () == 0)	/* Child */
            exit (0);
            
        /* Wait for SIGCHLD to be received */
        pid = 0;
        while (!pid)
            sigsuspend (&prev);
            
        /* Optionally unblock SIGCHLD */
        Sigprocmask (SIG_SETMASK, &prev, NULL);
        
        /* Do some work after receiving SIGCHLD */
        printf (".");
    }
    exit (0);
}

 

sigsuspend를 호출하기 전에 SIGCHILD는 block된다. sigsuspend는 일시적으로 SIGCHLD를 unblock하고 부모 프로세스가 시그널을 catch할 때까지 sleep한다. return하기 전에 sigsuspend는 SIGCHLD를 다시 block하는 원래의 blocked set를 복원한다. 만약 부모 프로세스가 SIGINT를 catch하면 루프는 테스트를 성공하여 다음 반복이 sigsuspend를 다시 호출한다. 만약 부모 프로세스가 SIGCHLD를 catch하면 루프 테스트는 실패하여 루프를 빠져나간다. 이때 SIGCHLD는 block되고 우리는 선택적으로 SIGCHLD를 unblock할 수 있다. 이는 거두어져야 하는 background job이 있는 실제 shell의 경우 유용할 수 있다.

sigsuspend 버전은 원래의 스핀 루프 버전보다 덜 낭비적이면서 pause에 의한 race를 피할 수 있고 sleep보다 더 효율적이다.

6. Nonlocal Jump

C는 nonlocal jump라고 하는, 사용자 레벨의 예외적인 제어 흐름의 형태를 제공한다. nonlocal jump는 하나의 함수에서 현재 실행 중인 다른 함수로, 정상적인 call-and-return 시퀀스를 거칠 필요 없이, 바로 제어를 이동시킨다. nonlocal jump는 setjmp와 longjmp 함수에 의해 제공된다.

#include <setjmp.h>

/* Returns: 0 from setjmp, nonzero from longjmps */
int setjmp (jmp_buf env);
int sigsetjmp (sigjmp_buf env, int savesigs);

 

setjmp 함수는 나중에 longjmp가 사용할 수 있도록 현재 calling environment를 env 버퍼에 저장하고 0을 return한다. calling environment는 프로그램 카운터, 스택 포인터, 일반 목적 레지스터를 포함한다. 우리의 논의를 넘어서는 어떤 이유들 때문에 setjmp가 return하는 값은 변수에 할당되어서는 안 된다.

rc = setjmp (env);	/* Wrong! */

 

하지만 switch 문 또는 조건문에서는 안전하게 사용될 수 있다.

#include <setjmp.h>

/* Never returns */
void longjmp (jmp_buf env, int retval);
void siglongjmp (sigjmp_buf env, int retval);

 

longjmp 함수는 env 버퍼로부터 calling environment를 복원하고, env를 초기화시키는 대부분의 최근 setjmp 호출로부터의 return을 유발한다. 그러면 setjmp는 0이 아닌 값인 retval과 함께 return한다.

setjmp와 longjmp 사이의 상호작용은 처음에 헷갈릴 수 있다. setjmp 함수는 한 번 호출되지만 return은 여러 번 이루어진다. setjmp가 처음 호출되고 calling environment가 env 버퍼에 저장될 때 한 번 return되고, 대응되는 longjmp 호출이 일어났을 때마다 return이 또 일어난다. 한편 longjmp 함수는 한 번 호출되고 절대 return하지 않는다.

 

nonlocal jump를 적용하는 중요한 케이스 중 하나는 깊이 중첩된 합수 호출로부터 중간 return을 허용할 때이다. 주로 어떤 에러 상태를 감지한 결과로 일어난다.만약 에러 상태가 중첩된 함수 호출 깊은 곳에서 감지된다면 nonlocal jump를 이용하여, 콜 스택을 힘들게 되감는 대신에,일반적인 localized 에러 핸들러로 바로 return할 수 있다.

다음은 예시를 보여준다. main 함수는 먼저 setjmp를 호출하여 현재 calling environment를 저장하고 bar 함수를 호출하는 foo 함수를 호출한다. setjmp의 0이 아닌 return 값은 에러 유형을 알려준다. 이 에러 유형은 코드의 한 부분에서 디코딩되고 처리될 수 있다.

#include "csapp.h"

jmp_buf buf;

int error1 = 0;
int error2 = 1;

void foo (void), bar (void);

int main ()
{
    switch (setjmp (buf)) {
    case 0:
        foo ();
        break;
    case 1:
        printf ("Detected an error1 condition in foo\n");
        break;
    case 2:
        printf ("Detected an error2 condition in foo\n");
        break;
        default:
        printf ("Unknown error condition in foo\n");
    }
    exit (0);
}

/* Deeply nested function foo */
void foo (void)
{
    if (error1)
        longjmp (buf, 1);
    bar ();
}

void bar (void)
{
    if (error2)
        longjmp (buf, 2);
}

 

longjmp는 중간 호출들을 모두 skip할 수 있는데 이는 의도하지 않은 결과를 가질 수 있다. 예를 들어, 만약 어떤 자료 구조가 중간에 있는 함수 호출에서 할당되었고 함수의 마지막에서 할당을 해제할 예정이었을 경우 할당 해제 작업이 skip되고 이는 메모리 누수로 이어진다.

 

nonlocal jump를 적용하는 중요한 케이스 중 다른 하나는 시그널 핸들러가 시그널의 도착으로 중단된 명령으로 return하는 것이 아니라 특정 코드 위치를 뻗어나가게 하는 것이다. 다음은 이러한 기본 기법을 보여준다.

#include "csapp.h"

sigjmp_buf buf;

void handler (int sig)
{
    siglongjmp (buf, 1);
}

int main ()
{
    if (!sigsetjmp (buf, 1)) {
        Signal (SIGINT, handler);
        Sio_puts ("starting\n");
    }
    else
        Sio_puts ("restarting\n");
    
    while (1) {
        Sleep (1);
        Sio_puts ("processing...\n");
    }
    exit (0);	/* Control never reaches here */
}

 

프로그램은 사용자가 Ctrl+C를 입력할 때마다 soft restart를 하기 위해 시그널과 nonlocal jump를 이용한다. sigsetjmp와 siglongjmp 함수는 시그널 핸들러가 사용할 수 있는 버전의 setjmp와 longjmp이다.

sigsetjmp 함수에 대한 초기 호출은 프로그램이 처음 시작할 때 calling environment와 signal context (pending과 blocked 시그널 벡터를 포함)를 저장한다. 사용자가 Ctrl+C를 입력할 때 커널은 SIGINT를 catch하는 프로세스에게 SIGINT를 전송한다. 시그널 핸들러로부터 return하는 대신에 (중단된 루프로 제어를 다시 되돌리는 대신에) 핸들러는 main 프로그램의 시작점으로의 nonlocal jump를 수행한다. 우리 시스템에서 프로그램을 실행시키면 다음과 같은 결과가 나온다.

여기에 두 가지 흥미로운 점이 있다.

첫 번째는 race를 피하기 위해 우리가 sigsetjmp를 호출한 이후에 핸들러를 설치해야 한다는 것이다. 만약 그렇지 않다면 sigsetjmp에 대한 초기 호출이 siglongjmp를 위한 calling environment를 세팅하기 전에 핸들러를 실행하는 위험을 감수하게 될 것이다.

두 번째로 sigsetjmp와 siglongjmp 함수가 async-signal-safe 함수가 아니라는 점이다. 그 이유는 일반적으로 siglongjmp는 임의의 코드로 점프하기 때문이다. 그래서 우리는 siglongjmp가 도달할 수 있는 모든 코드에서 safe 함수만 호출하도록 주의를 기울여야 한다. 우리의 예시에서 우리는 safe한 sio_puts와 sleep 함수를 호출했다. safe하지 않은 exit 함수의 경우 도달 가능하지 않다.

 

*C++과 Java에 의해 제공되는 예외 메커니즘은 C의 setjmp와 longjmp 함수의 고수준, 더 구조적인 버전이다. try문의 catch 문이 setjmp와 비슷하고 throw 문이 longjmp 함수와 비슷하다고 생각하면 된다.

7. 프로세스 조작을 위한 도구

Linux 시스템은 프로세스를 모니터링하고 조작할 수 있는 유용한 도구들을 제공한다.

 

STRACE

실행 중인 프로그램과 자식 프로세스에서 호출되는 모든 시스템 콜의 흔적을 출력한다. 공유 라이브러리와 관계된 산출물을 제거한 보다 간결한 결과를 보고 싶다면 프로그램을 -static으로 컴파일하라.

 

PS

현재 시스템에 있는 프로세스 (좀비 프로세스 포함)을 목록으로 보여준다.

 

TOP

현재 프로세스의 리소스 사용에 대한 정보를 출력한다.

 

PMAP

프로세스의 메모리 매핑을 보여준다.

 

/proc

사용자 프로그램이 읽을 수 있는 아스키 텍스트 형태로 되어 있는 다양한 커널 자료 구조 콘텐츠를 내보내는 가상 파일 시스템이다. 예를 들어 cat /proc/loadavg를 타이핑해보면 Linux 시스템의 현재 load 평균을 볼 수 있다.