15 minute read

Computer Systems : A Programmer’s Perspective

8.1장부터 8.4장까지는 하드웨어와 소프트웨어가 협력해서 low-level exception을 제공하는 방식을 살펴보았다. 또한 운영체제가 context switch를 통해 exceptional control flow를 만들기 위해 exception을 사용하는 것을 살펴보았다. 이번 장에서는 좀 더 higher-level의 exception control flow인 Linux signal을 살펴본다.

8.5 Signals

Process와 kernel이 다른 process를 interrupt하게 해 주는 signal 은 일종의 작은 message로, 어떠한 종류의 event가 발생했음을 process에게 알리는 역할을 한다. 교과서 Fig 8.26에는 30종류의 Linux signal들이 나열되어 있다.
각 signal 종류는 system event의 종류에 대응된다. Low-level의 hardware exception은 kernel의 exception handler에 의해 처리되고, 보통은 user process에게는 보이지 않는다. Signal은 이런 exception의 발생을 user process에게 알리는 역할을 한다. 예를 들어 어떤 process에서 0으로 나누려고 하면 kernel이 8번 signal인 SIGFPE를 보낸다. Illegal memory reference를 하려 하면 kernel은 11번 SIGSEGV signal을 내보낸다.
하드웨어적인 event 외에도 higher-level의 software event를 알리는 signal도 있다. 예를 들어 Ctrl+C를 누르면 kernel은 foreground에서 돌아가는 process들에게 2번 SIGINT signal을 내보낸다. 다른 process를 강제로 종료시킬 수 있는 SIGKILL signal(9번)도 있고, child process가 terminate되거나 stop될 때 kernel이 parent에게 보내는 17번 SIGCHILD signal도 있다.

8.5.1 Signal Terminology

Signal의 전송은 두 단계로 구성된다.

Sending a signal
Kernel은 destination process의 context의 어떤 state를 바꾸는 것으로 signal을 전달한다. Signal이 전송되는 이유는 두 가지가 있다.

  • Kernel이 0으로 나눈 에러나 child process의 종료 같은 system event를 감지했을 때
  • Process가 kill function을 불렀을 때

process는 스스로에게 signal을 보낼 수도 있다.

receieving a signal

Destination process는 signal을 받으면 signal을 무시하거나(ignore), 프로세스를 종료하거나(terminate), signal handler라는 user-level function을 통해 catch 한다.
전송은 됐지만 received되지 않은 signal을 pending signal 이라고 한다. 이때 중요한 것은 signal은 queue되지 않기 때문에 어느 시점에서 pending 상태인 같은 type의 signal은 하나밖에 없다는 것이다. 특정 type의 signal이 pending 중이라면, 그때 또 도착한 해당 type의 signal은 버려진다.
Process는 선별적으로 특정 signal의 수신을 block 할 수 있다. Block된 signal은 전달은 되지만, pending signal이 되어서 process가 unblock하기 전까지 receive되지 않는다. 각 process마다 kernel은 pending bit vector에 pending signal들을 저장하고, blocked signal들은 blocked bit vector에 저장한다. Type k의 signal이 delivered되면 pending 의 k번째 bit를 set해주고, type k의 signal이 received되면 pending 의 k번째 bit를 clear한다.

8.5.2 Sending Signals

Unix는 process에게 signal을 보내기 위한 많은 방법들을 제공하는데, 모든 매커니즘들은 process group 이라는 개념에 의존한다.

Process Groups

모든 process는 딱 하나의 process group 에 속하고, 양의 정수인 process group ID 로 식별할 수 있다. 아래 함수는 현재 process의 process group ID 를 반환한다.

#include <unistd.h>
pid_t getpgrp(void);

기본적으로 child process는 parent와 같은 process group에 속하는데, 아래 함수를 통해 원하는 process의 process group을 바꿀 수 있다.

#include <unistd.h>
int setpgid(pid_t pid, pid_t pgid);

Process pid가 속한 process group을 pgid로 바꾼다. pid가 0이면 현재 process의 PID가 사용되고, pgid가 0이면 pid를 process의 새 process group ID 로 사용한다. 따라서 setpgid(0, 0);을 PID가 15213인 process가 불렀다면, process group ID가 15213인 새 process group을 생성하고, 15213 process를 그 group에 추가하게 된다.

Sending Signals with the /bin/kill Program

/bin/kill -9 15213 는 process 15213에게 9번 signal(SIGKILL)을 보낸다.
/bin/kill -9 -15213 과 같이 PID를 음수로 쓰면 process group PID로 읽어서, process group 15213에 속하는 모든 process에게 SIGKILL을 보낸다.

Sending Signals from the Keyboard

Unix shell은 command line을 실행하며 생성된 process를 job 이라고 추상화해서 표현하는데, 어느 시점에서든 foreground job은 많아야 한 개가 있고 background job은 0개 이상이 있을 수 있다.
Shell은 각 job들을 서로 다른 process group으로 생성한다. 그리고 process group ID는 보통 부모의 것과 같기 때문에 아래 그림과 같이 foreground job의 parent process와 두 child process는 같은 pgid 20을 가지고, 두 background job은 각각의 pgid를 가지게 된다. image 여기에서 Ctrl+C를 입력하게 되면 kernel은 foreground process group의 모든 process에게 SIGINT signal을 보낸다. 즉 foreground job의 모든 process이 종료되는 결과가 나타날 것이다. 비슷하게, Ctrl+Z는 foreground process group의 모든 process에게 SIGSTP signal을 보낸다.

#include <unistd.h>
#include <stdio.h>

int main()
{
    while(1){
        printf("%d %d\n", getpid(), getpgrp());
        sleep(3);
    }
}

위와 같이 소스 코드 go.c를 짜고, 쉘에 ./go &를 두 번 치고 ./go 를 한 번 치면 세 개의 pid가 번갈아서 출력된다.
여기에 Ctrl+C를 누르면 마지막으로 친 foreground process만 종료되고 두 개의 background process의 pid만 번갈아서 출력되게 된다. Background도 종료하고 싶을 때는 위의 명령어 대로 /bin/kill -9 PID번호 를 입력해 주면 된다.

Sending Signals with the kill Function

#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);

위 함수는 process가 다른 process에게 signal을 보내도록 한다. pid가 0보다 크면 해당 process에게 보내는 것이고, pid가 0이면 calling process가 속한 process group의 모든 process에게 보내는 것이고, pid가 음수이면 |pid| process group의 모든 process에게 sig를 보낸다. 반환값은 정상일 때 0, 오류가 나면 -1이다.

Sending Signals with the alarm Function

Process는 스스로에게 SIGALRM signal을 보낼 수 있다.

#include <unistd.h>
unsigned int alarm(unsigned int secs);

secs초 후에 kernel이 스스로에게 SIGALRM을 보내도록 세팅하는 함수이다. secs가 0이면 아무 것도 하지 않고, 만약 pending alarm이 있는 상태에서 새 alarm call이 들어온다면 나중에 들어온 call은 pending alarm을 취소하고, pending alarm이 자신이 방해하지 않았으면 종료될 때까지 남은 시간을 반환한다. Pending alarm이 없는 상태에서 alarm 을 부르면 반환값은 0이 된다.
조금 복잡한데, 예시를 보면 이해가 된다.

#include <unistd.h>
#include <stdio.h>
int main()
{
    int a15 = alarm(15);
    int a10 = alarm(10);
    sleep(2);
    int a3 = alarm(3);
    printf("%d %d %d\n", a15, a10, a3);
    sleep(10);
}

먼저 15초짜리 알람은 pending alarm이 없는 상태에서 불렸기 때문에 a15에는 0이 반환된다. 10초짜리 alarm은 15초짜리 alarm이 있을 때 불렸으므로, alarm(10)이 불리는 즉시 alarm(15)는 취소되고 a10에는 15가 저장된다. 2초 쉬고, alarm(3)이 불리면 8초 남은 alarm(10)이 취소됐으므로 a3에는 8이 저장된다.
세 변수의 값이 출력된 후, sleep(10)이라고 걸어 줬지만 3초 뒤에 자명종이 울려서 shell에 자명종 시계라고 출력되고 프로그램이 종료된다.

8.5.3 Receiving Signals

Kernel이 process p를 kernel mode에서 user mode로 전환할 때(예: system call로부터 돌아올 때나 context switch가 끝날 때) kernel은 unblocked pending signal(pending&~blocked)가 있었는지 확인한다.
만약 그런 signal이 없으면 p의 다음 instruction으로 control을 넘겨 주고, 그런 signal들이 있으면 그 중에서 하나 골라서 p가 receive하게 한다. Signal을 receive하면 그에 해당하는 action 을 촉발하게 되고 process가 그 action 을 끝냈으면 p의 logical flow의 next instructuin으로 control을 돌려준다.
각 signal type은 미리 정의된 default action이 있는데,

  • Process가 terminate
  • Process가 terminate 후 dumps core
  • Process가 stop(suspend) 하고 SIGCONT signal에 의해 재시작될 때까지 대기
  • Process가 signal을 무시

앞에서 signal 30개 표에 각각의 default action이 명시되어 있다. SIGKILL은 default가 terminate이고, SIGCHILD는 default가 ignore이다. 하지만 process는 SIGSTOP과 SIGKILL을 제외한 signal의 default action을 아래 함수를 이용해서 수정할 수 있다.

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

sighandler_t signal(int signum, sighandler_t handler);

signal 함수는 signum signal의 action을 세 가지 중에 하나로 바꿔 준다.

  • handler가 SIG_IGN이면 해당 signum 타입의 signal은 무시한다.
  • handler가 SIG_DFL이면 해당 signum 타입의 signal의 action을 default action으로 되돌린다.
  • handler가 user-defined function이면, process가 해당 signum 타입의 signal을 receive했을 때 signal handler 를 부른다.

세 번째 경우를 installing the handler 한다 라고 부르고, handler를 호출하는 것을 catching the signal 이라 하며, handler의 작업 수행은 handling the signal 이라고 한다.
Process가 type k의 signal을 catch 하면, signal k에 install 된 signal handler가 불리게 되는데 이때 argument로 정수 k를 들고 간다. 이 argument는 하나의 handler function 으로 여러 종류의 signal을 catch 할 수 있게 해 준다.
Handler가 일을 마치고 return을 수행하면, control은 보통 process가 interrupt됐던 instruction으로 돌아가게 된다.
아래 코드는 Ctrl+C로 만들 수 있는 SIGINT signal에 handler를 install하는 과정을 보여 준다.

#include <unistd.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
void sigint_handler(int sig) {
    printf("Caught SIGINT!\n");
    exit(0);
}

int main(){
    if(signal(SIGINT, sigint_handler)==SIG_ERR){
        printf("signal error");
        exit(0);
    }
    pause();
    
    return 0;
}

먼저 SIGINT의 action을 default(즉시 terminate) 말고 다른 걸로 바꿔 주기 위한 sigint_handler를 만들어 준다. 그리고 main 함수에서 install 해 주고 pause()를 걸어 준다. 그러면 프로그램이 쭉 대기하다가 사용자가 Ctrl+C를 입력하면 sigint_handler 함수가 실행된다.

Signal handler는 다른 handler에 의해 interrupt될 수도 있다. image 메인 프로그램에서 signal s를 catch 해서 해당 Handler인 S로 control이 넘어간다. 그런데 여기서 s와 다른 signal인 t를 catch 해서, 또다른 handler T로 control이 넘어 간다. T의 일이 끝나면 S로 돌아오고, S의 일이 끝나면 메인 프로그램으로 돌아오게 된다.

연습문제 8.7이 꽤 재미있다.

8.5.4 Blocking and Unblocking Signals

Linux는 blocking signal에 대한 implicit, explicit mechanism을 제공한다.

Implicit blocking mechanism : 기본적으로, kernel은 현재 handler가 처리 중인 타입의 pending signal을 block한다. 즉, 위의 그림에서 S가 signal s를 처리 중일 때 s 타입의 signal이 또 오면, 그 signal은 pending되지만 S가 일을 마치고 return해도 received되지 않는다.

Explicit blocking mechanism : Application은 명시적으로 특정 signal을 sigprocmask 함수로 block하고 unblock할 수 있다.

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

sigprocmask 함수는 현재 blocked된 signal의 set(즉, 8.5.1에서 얘기한 blocked bit vector)을 바꾼다. 구체적인 행동은 how에 따라 달라진다.

  • SIG_BLOCK : blocked = blocked | set 을 수행한다. 즉, set의 signal들을 blocked 에 추가한다.
  • SIG_UNBLOCK : blocked = blocked & ~set 을 수행한다. 즉, set의 signal들을 blocked 에서 제거한다.
  • SIG_SETMASK : blocked = set 을 수행한다.

oldset이 NULL이 아니면, blocked bit vector의 예전 값은 oldset에 저장된다.
이런 sigset_t *set을 구성하는 helper 함수들이 있다.

#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);

int sigismember(const sigset_t *set, int signum);

sigemptyset 함수는 set을 텅 빈 set으로 초기화시켜 주고, sigfill은 모든 signal을 set에 넣어 준다. sigaddset은 해당 signum의 signal을 set에 추가해 주고, sigdelset은 해당 signum의 signal을 set에서 지운다. sigismember 함수는 해당 signum이 set에 속하면 1, 아니면 0, 에러면 -1을 반환한다.

8.5.5 Writing Signal Handlers

Signal handler는 Linux의 system-level 프로그래밍의 가장 복잡하고 어려운 부분이다. Handler는

  1. 메인 프로그램과 동시에(concurrently) 돌아가고, 각종 전역 변수를 공유하기 때문에 서로 간섭할 수 있다. Handler끼리도 간섭할 수 있다.
  2. Signal이 언제 어떻게 수신되는지의 규칙은 때때로 비직관적이다.
  3. 여러 시스템마다 서로 다른 signal 처리 방법을 가진다.

이러한 이유로 인해 무척 어렵다. 이 장에서는 안전하고 올바르며 portable한 signal handler를 짜는 기본적인 가이드라인을 제공한다.

Safe Signal handling

Signal handler는 메인 프로그램 및 서로와 동시에 실행되기 때문에 예측하기 어렵고 치명적이며 재현도 힘든 에러들이 발생할 수 있다.

G0. handler는 가능한 한 단순하게 하라

혹시 발생할 문제를 예방하는 최선의 방법은 handler를 가능한 한 작고 단순하게 만드는 것이다.

G1. Handler에서는 오직 async-signal-safe 한 함수만 사용하라.

async-signal-safe 한 함수, 단순하게 안전한 함수는 reentrant 하거나 signal handler에 의해 interrutp될 수 없어서 signal handler signal handler에서도 안전하게 사용될 수 있다. reentrant 란 오직 지역 변수에만 접근하는 것을 의미한다. 교과서의 Fig 8.33에는 안전하게 사용될 수 있는 system-level 함수들의 목록이 나와 있다. printf나 malloc, exit과 같은 유명한 함수들이 포함되어 있지 않음을 확인할 수 있다. Signal handler에서 출력을 확인할 수 있는 유일한 방법은 write 함수이다. 이런 제한된 환경에서 작업하기 위한 Safe I/O 패키지도 존재한다.

G2. errno를 저장하고 복구하라.

많은 리눅스의 async-signal-safe한 함수들은 에러를 반환할 때 errno 변수의 값을 설정한다. Handler에서 이러한 함수를 막 부르면 errno에 의존하는 다른 프로그램의 작동에 영향을 미칠 수 있다. 그러니 지역 변수에 errno를 저장하고, handler가 return할 때 복구해 주는 것이 좋다. Return으로 끝나지 않고 _exit()로 종료된다면 필요하지는 않다.

G3. 모든 signal을 block해서 공유되는 전역 자료구조로의 접근을 보호하라.

메인 프로그램과 여러 handler들이 어떤 자료 구조를 공유한다면, 메인 프로그램의 여러 instruction을 거쳐서 그 자료구조를 접근(쓰기 및 읽기)하고 있을 때 handler가 가로채버린다면 해당 자료 구조는 불완전한 상태일 것이고 예측 불가능한 결과를 만들 수 있다. 그래서 그러한 자료 구조에 접근할 때는 잠시 signal을 모두 block해야 한다.

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

main 함수와 handler가 전역 변수 g를 공유하고 있을 때 handler가 g의 값을 바꾸고, main 함수가 g를 읽는 상황에서는 컴파일러가 g의 값을 메모리가 아닌 캐시에서 가져오게 된다. 따라서 컴파일러에게 이 변수는 캐시하지 말라고 하기 위해 volatile 으로 변수를 선언해 주면 코드에서 해당 변수가 불릴 때 항상 메모리에서 가져오게 된다.

G5. flag는 sig_atomia_t 로 선언하라.

일반적인 handler의 디자인에서 handler는 전역 변수 flag 에 signal의 수신을 기록한다. 메인 프로그램인 이 flag를 주기적으로 읽고 signal에 응답하고 초기화한다. 이런 식으로 사용되는 flag를 위해 C는 sig_atomic_t 라는 자료형을 제공해 준다. 이 자료형은 atomic하게, 즉 uninterruptable하게 읽고 쓸 수 있기 때문에 굳이 signal을 잠시 block하지 않아도 방해받지 않고 안전하게 읽고 쓸 수 있다.

Correct Signal Handling

Signal에서 가장 비직관적인 부분은 queue되지 않는다는 것이다. pending bit vector는 한 종류당 1비트씩만 존재하기 때문에 특정 타입의 signal에는 한 개의 pending만 존재할 수 있다.
Parent가 child process를 여럿 만들면 반드시 reap해야 한다. 하지만 하염없기 기다리기보다는 parent 자신의 일을 하기 위해서, SIGCHILD signal을 받을 때 reap하려고 한다.

void handler1(int sig){
    if(waitpid(-1, NULL, 0)<0){
        sio_error("waitpit error");
    }
    Sio_puts("Handler reaped child\n");
    sleep(1);
}

int main()
{
    if(signal(SIGCHILD, handler1) == SIG_ERR){
        unix_error("signal error");
    }
    for(int i=0 ; i<3 ; i++){
        if(fork()==0){
            printf("Hello from child %d\n", (int)getpid());
            exit(0);
        }
    }
    
    if((n=read(STDIN_FILENO, buf, sizeof(buf)) < 0) unix_error("read");
    
    printf("Parent processing input\n");
    while(1);
    
    exit(0);
}

위 프로그램은 parent가 3개의 child를 만든 후 사용자의 입력을 기다린다. SIGCHILD에 signal handler를 심어 주고, waitpid()를 통해 child를 reap하는 형태이다.
하지만 위 프로그램은 문제가 있다. 첫 번째 SIGCHILD가 왔을 때 handler1이 실행된다. 그 사이에 두 번째 SIGCHILD는 SIGCHILD에 해당하는 bit vector를 set하고, 세 번째 SIGCHILD는 무시된다. 따라서 reap는 두 개의 child에 대해서만 이루어지게 된다. ps t 명령어를 통해 zombie 상태인 child를 확인할 수 있다. <defunct>로 표시된다.
이런 상황은 절대 바람직하지 않다. 따라서 handler에서는 while문을 이용하여 모든 child에 대해 waitpid()를 받아야 한다.

void handler2(int sig){
    pid_t p;
    while((p=waitpid(-1, NULL, 0))>0){
        printf("Handler reaped child%d\n", (int)p);
    }
    sleep(1);
}

이렇게 바꾸면 SIGCHILD를 열 번 다 받지 못해도, waitpid()의 반환값이 0이 아닌 이상 계속 기다리게 된다. waitpid()의 반환값이 0이 되었다는 것은 모든 child가 reap되었다는 뜻이므로 handler2는 모든 child가 reap됨을 보장할 수 있다.

Portable Signal Handling

Unix signal handling의 또다른 좋지 않은 특성은 서로 다른 시스템마다 다른 signal-handing semantic을 갖는다는 것이다. 예를 들어,

  • 초기 Unix 시스템은 signal k가 handler에 의해 catch되고 나면 action을 기본값으로 되돌린다. 그래서 이런 시스템에서는 항상 reinstall해줘야 한다.
  • System call이 interrupt될 수 있다. read, write 같은 시스템 콜은 아주 오랜 시간동안 block하게 된다. 초기 Unix에서는 이런 slow system call은 signal을 catch하고 나서 재개되지 않고 errno에 EINTER을 넣은 후 그냥 종료된다. 그래서 알아서 재시작하도록 직접 코드를 짜야 한다.

이런 문제에 대응하기 위해 Posix standard에는 sigaction 이라는 함수가 정의되어 있다. 사용자가 signal-handling semantic을 명시할 수 있게 해 준다.

#include <signal.h>
int sigaction(int signum, struct sigaction *act, struct sigaction *oldact);

복잡한 구조체를 다뤄야 하기 때문에 잘 사용되지 않는다. 대신 wrapper function Signal을 사용할 수 있다. 현재 처리 중인 signal만 block하고, queue되지 않으며 중단됐던 시스템 콜은 언제든지 가능하면 자동으로 재시작되고 한번 install한 handler는 SIG_IGN이나 SIG_DFL로 바꾸기 전까지 쭉 유지된 상태로 남는다.

8.5.6 Synchronizing Flows to Avoid Nasty Concurrency Bugs

아래 코드의 동작은 다음과 같다.
Parent가 fork()로 child를 생성한 후, addjob()을 통해 job list에 해당 pid를 추가한다. 생성된 child가 /bin/date 로 날짜를 출력한 후 종료하면 SIGCHILD signal을 kernel이 보내고, 이를 받은 parent는 install된 handler를 실행해서 reap하고, job list에서 해당 child를 제거한다.
하지만 실제 동작은 다르게 될 수 있다. OS의 스케줄링에 따라, fork() 후 main으로 돌아오지 않고 child가 먼저 실행되어서 child의 종료, SIGCHILD 발생, handler의 catch가 일어나고 결과적으로 addjob() 전에 deletejob()이 일어날 수도 있다. 그러면 main으로 돌아왔을 때, addjob()은 없는 child에 대해서 일어나기 때문에 오작동하게 되는 것이다.
이처럼 parent process의 addjob()과 handler()의 deletejob()의 실행 순서에 따라 결과가 달라지는 것을 race 가 일어난다 라고 표현한다. 이를 막기 위해서는 fork() 전에 parent에서 SIGCHILD를 block해 주고, addjob() 후에 다시 풀어 줘야 한다.

8.5.7 Explicitly Waiting for Signals

때때로 메인 프로그램이 특정 signal handler를 명시적으로 기다리도록 해야 할 때가 있다. 예를 들어 Linux shell이 foreground job을 생성하면 끝날 때까지 기다렸다가 SIGCHILD로 reap하고 다음 명령어를 받아야 한다.
이를 비슷하게 구현한 것이 위 코드이다. Parent는 SIGCHILD를 block하고 fork()를 해서 child를 만들고, pid=0으로 초기화한 후 SIGCHILD를 해제한다. 그러면 그 사이에 도착해 있던 SIGCHILD가 received되어 handler에서 reap 후 pid에 값이 할당되고, while()로 기다리고 있던 parent가 기다림을 끝내고 다음 일(printf)를 하게 된다.
이 코드는 맞게 행동하지만 while loop가 너무나 시간 낭비가 된다. 그냥 while loop 말고 가운데 pause()를 넣어서

while(!pid)
    pause();

signal이 올 때까지 기다릴 수도 있지만, SIGCHILD와 pause 간에 race가 일어나서 pause()보다 SIGCHILD가 먼저 와서 handler가 동작하게 되면, while문 안의 pause는 영원히 깨어나지 못하게 된다.

while(!pid)
    sleep(1);

그렇다고 이렇게 하기에는 1초는 너무 긴 시간이다. 1초 말고 0.1초, 0.001초로 한다고 좋은 것도 아닌 게, 얼마나 걸릴 지 절대 모르기 때문이다. 너무 짧으면 loop에서 낭비가 심하게 되고, 너무 길면 프로그램이 느려진다.
해결책은 sigsuspend 함수이다.

#include <signal.h>
int sigsuspend(const sigset_t *mask);

이 함수는 mask로 잠시 blocked set을 대체하고 signal을 기다린다. 해당 signal에 대한 action이 종료면 반환값 없이 종료하고 handler를 부른다면 handler의 return 후에 blocked set을 복구한 뒤 -1을 반환한다.
이 함수는 다음과 같은 atomic(=uninterruptible) 코드와 동일하다.

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

첫 번째 sigprocmask와 두 번째 줄 pause()는 interrupt될 일 없이 함께 일어나므로 race를 방지하게 된다.

while(!pid)
    sigsuspend(&prev);

위와 같이 변경하면 sigsuspend() 전에는 SIGCHILD는 block된 상태이므로 pause()에서 일어났던 문제는 일어나지 않는다. 만약 SIGINT 같은 다른 signal이 들어온다면 그냥 while loop를 한번 돌아서 다시 SIGCHILD를 기다리는 상태가 된다.

8.6 Nonlocal Jumps

C는 user-level의 exceptional control flow인 nonlocal jump 를 제공한다. 일반적인 call-and-return 과정 없이 한 함수에서 실행 중인 다른 함수로 control을 바꿀 수 있다.

#include <setjmp.h>
int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int retval);

먼저 setjmp 함수는 현재의 calling enviornment 를 env 버퍼에 저장한다. PC, 스택 포인터, general-purpose register 등이 들어 있다.
longjmp 함수는 setjmp로 저장했던 calling enviornment 를 복구하고 setjmp를 했던 곳으로 return한다. 그러면 setjmpretval을 반환한다.
처음 보면 좀 이상하다. setjmp는 한 번 불렸는데 반환은 여러 번 된다. setjmp가 처음 불렸을 때 한 번 반환하고 longjmp에 의해 한번 더 반환한다. 반면 longjmp는 아무것도 반환하지 않는다.
nonlocal jump는 깊게 nested된 function call로부터 즉시 반환하도록 해 준다. 에러가 아주 깊은 곳에서 발생하면 call stack을 일일히 복구해 가며 돌아오지 않고 그냥 바로 만들어 놓은 error handler로 이동할 수 있게 해 준다.
위 프로그램에서, main->foo->bar로 깊이 들어가서 error2가 발동하게 된다. 그러면 longjmp에서 정해진 값으로 setjump가 return하게 되고, 이는 main의 switch문에서 바로 에러 문구가 출력되도록 한다.
모든 중간 과정을 뛰어넘기 때문에, 예를 들어 어떤 자료 구조가 할당된 후 원래는 마지막에 할당 해제되야 하는 상황에서 longjmp로 뛰어넘어 버리면 그대로 메모리 누수가 된다.
nonlocal jump의 또다른 사용처는 signal handler의 반환으로 interrupt됐던 instruction으로 돌아가지 않고 원하는 code의 위치로 이동할 수 있게 해 주는 것이다. 위 코드에서, sigsetjmp로 env에 이 시점을 저장해 놓고 while문에서 대기한다. 사용자가 Ctrl+C로 SIGINT를 보내면 handler에서 siglongjmp로 인해 앞의 sigsetjmp가 1을 반환하며 거기로 되돌아가게 된다.
이때 주의해야 할 두 가지 점이 있다. 먼저 sigsetjmp를 반드시 handler install보다 먼저 해야 한다. Handler가 sigsetjmp 전에 작동해 버리면 siglongjmp는 이상한 곳으로 가 버릴 것이다. 두 번째로 setjmp와 longjmp는 안전한 함수가 아니라는 것이다. 왜냐하면 siglongjmp가 임의의 위치로 가기 때문에 거기서 async-safe하지 않은 동작이 일어날 수 있기 때문이다. 따라서 항상 longjmp의 도착지에서 안전한 함수만 사용되도록 해야 한다.

8.7 Tools for Manipulating Process

Linux에는 process들을 모니터링할 수 있는 좋은 도구들을 제공한다.

  • STRACE : 실행 중인 프로그램과 child가 부르는 system call을 추적한다.
  • PS : 현재 시스템의 process들(zombie 포함)을 쭉 출력한다.
  • TOP : 현재 프로세스들의 resource usage를 출력한다.
  • PMAP : process의 memory map을 표시한다.
  • /proc : kernel의 자료구조들을 ASCII 문자로 읽을 수 있게 export해주는 virtual filesystem이다.

8.8 Summary

Exceptional Control Flow는 컴퓨터 시스템의 모든 단계에서 일어날 수 있고, concurrency를 제공하는 기본적인 메커니즘이다.

  • 하드웨어 레벨의 exception은 processor의 event에 의해 촉발되는 control flow의 급격한 변화이고, 소프트웨어인 handler에 의해 처리된다. exception에는 interrupt, fault, abort, trap의 네 종류가 있다. Interrupt는 processor chip의 interrupt pin에 의해 비동기적으로 발생하고, 처리 후 다음 instruction으로 돌아간다. Fault와 abort는 instruction의 실행으로 인해 비동기적으로 발생하며, fault는 처리 후 exception을 냈던 instruction을 다시 수행하고 abort는 바로 종료한다. Trap은 의도적으로 system call의 발동을 위해 일으키는 exception이다.
  • 운영체제 단계에서 kernel은 process라는 개념을 제공하기 위해 ECF를 사용한다. Process는 모든 프로그램이 processor과 memory를 독점적으로 사용 중이라는 illusion을 준다.
  • 운영체제와 application 간의 인터페이스에서는 application이 child process를 만들고, 새 프로그램을 거기서 실행시키고, process끼리 signal을 주고받는다.
  • Application 단계에서 C는 nonlocal jump를 통해 일반적인 call/return에서의 stack 관리 과정을 뛰어넘어서 control flow를 조정할 수 있다.

Tags:

Categories:

Updated: