mojo's Blog

Exceptional Control Flow: Signals and Nonlocal Jumps 본문

학교에서 들은거 정리/시스템프로그래밍

Exceptional Control Flow: Signals and Nonlocal Jumps

_mojo_ 2022. 3. 25. 01:44

※ Linux Process Hierarchy

 

 

pstree 명령어를 치면 hierarchy 를 볼 수 있다고 한다. 

명령어를 치면 다음과 같다.

 

 

※ Shell Programs

 

Shell 은 사용자를 대신하여 프로그램을 실행하는 application program 이다.

 

  • sh : Original Unix shell 
  • csh/tcsh : BSD Unix C shell
  • bash : "Bourne-Again" Shell (기본 리눅스 쉘)

 

shell 이 실행되도록 하는 코드는 다음과 같다.

 

void eval(char *cmdline)
{
	char *argv[MAXARGS]; /* Argument list execve() */
	char buf[MAXLINE];   /* Holds modified command line */ 
    int bg;              /* Should the job run in bg or fg? */ 
    pid_t pid;           /* Process id */

	strcpy(buf, cmdline);
	bg = parseline(buf, argv); 
    if (argv[0] == NULL)
		return;   /* Ignore empty lines */ 
    
    if (!builtin_command(argv)) {
		if ((pid = Fork()) == 0) {   /* Child runs user job */ 
        	if (execve(argv[0], argv, environ) < 0) {
				printf("%s: Command not found.\n", argv[0]); 
                exit(0);
			}
		}
		/* Parent waits for foreground job to terminate */ 
        if (!bg) {
			int status;
			if (waitpid(pid, &status, 0) < 0)
				unix_error("waitfg: waitpid error"); 
        }
        else
        	printf("%d %s", pid, cmdline);
	}
    return;
}

int main()
{
	char cmdline[MAXLINE]; /* command line */
    
    while (1) {
    	/* read */
        printf("> ");
        Fgets(cmdline, MAXLINE, stdin);
        if (feof(stdin))
        	exit(0);
        /* evaluate */
        eval(cmdline);
    }
}

 

main() 함수가 cmdline 버퍼에 인풋을 읽어들이고 유효한 경우 eval() 함수를 호출하여 cmdline 에 해당하는 명령어를 실행시킨다.

eval() 함수에서 parseline() 함수는 마지막에 & 가 있는지 없는지를 확인해주는 함수이며 bg 값에 따라 처리하는게 다른 것을 확인할 수 있다.

그리고 builtin_command() 명령어는 명령어가 유효한지를 체크하는 함수인 것으로 추정되며 유효할 경우 fork() 를 띄워서 자식 프로세스에게 사용자가 입력한 명령어를 처리하도록 하는 것을 짐작할 수 있다.

 

foreground process : 쉘(shell)에서 해당 프로세스 실행을 명령한 후, 해당 프로세스 수행 종료까지 사용자가 다른 입력을 하지 못하는 프로세스를 의미한다.

background process : 사용자 입력과 상관없이 실행되는 프로세스이다. (맨 뒤에 & 붙이기)

둘 다 child process 이다.

 

※ Problem with Simple Shell Example

 

위 예제의 Shell program 은 foreground 작업들이 알맞게 대기하고 회수한다.

그러나 background 작업이 종료되면 아래와 같은 문제점이 발생한다.

 

  • 좀비가 된다.
  • shell이 종료되지 않으므로 수집되지 않음
  • 커널의 메모리가 부족할 수 있는 메모리 누수가 발생할 수 있다.

 

if (!bg) {
	int status;
	if (waitpid(pid, &status, 0) < 0)
		unix_error("waitfg: waitpid error"); 
}
else
	printf("%d %s", pid, cmdline);

 

eval 함수에서 background 일 경우와 아닐 경우에 대한 처리하는 코드이다.

background 일 경우에 오직 printf() 만을 하며 wait() 을 호출하지 않으므로 zombie 가 발생하며 reaping 과정이 일어나지 않기 때문에 메모리 누수가 발생할 수 있다. 

 

 

※ ECF to the Rescue!

 

Solution : Exceptional control flow

Background process 가 완료되면 커널이 일반 처리를 중단하여 alert 를 보낸다.

이때 Unix 에서는, alert 매커니즘을 signal 이라고 부른다.

 

 

※ Signals

 

Signal 이란 시스템에서 어떤 유형의 이벤트가 발생했음을 프로세스에 알리는 작은 메시지이다.

 

  • exceptions, interrupts 둘이 유사하다.
  • 커널에서 프로세스로 전송해준다. (경우에 따라 다른 프로세스의 요청에 따라 전송하기도 함)
  • 시그널 유형은 작은 정수 ID(1~30)로 식별된다.
  • 시그널 내의 유일한 정보는 ID와 신호가 도착했다는 사실뿐이다.

 

 

※ Signal Concepts : Sending a Signal

 

Kernel 은 대상 프로세스의 context 에서 일부 상태를 업데이트하여 대상 프로세스에 시그널을 전송한다.

Kernel 이 signal 을 보내는 이유는 다음과 같다.

 

  • 커널이 0으로 나누거나 (SIGFPE) 또는 자식 프로세스의 종료 (SIGCHLD) 등의 시스템 이벤트를 검출한 경우
  • 다른 프로세스가 kill system call 을 호출하여 커널이 대상 프로세스에 시그널을 보내도록 명시적으로 요청한 경우

 

 

※ Signal Concepts : Receiving a Signal

 

Destination process 는 커널에 의해 어떤 방식으로든 시그널의 전달에 반응하도록 강제되었을 때 시그널을 받는다.

시그널을 받을때 다음과 같은 3 가지 리액션을 취한다.

 

  • 시그널을 무시(ignore)한다 (즉, 아무것도 하지 않음)
  • 프로세스를 종료(terminate)시킨다 (optional core dump 사용시)
  • Signal handler 라고 불리는 user-level function 을 실행하여 시그널을 잡는다(catch).
    • asynchronous interrupt 에 대한 응답으로 hardware exception handler 가 호출되는 것과 유사하다.

 

 

※ Signal Concepts : Pending and Blocked Signals

 

시그널이 전송되었지만, 아직 수신되지 않은 경우 이를 pending(보류중) 이라고 한다.

 

  • 특정 유형의 보류 중인 시그널은 최대 1개까지 있을 수 있다.
  • Important : signals are not queued
    • 만약 프로세스에 보류 중인 k 타입 signal 이 있는 경우, 해당 프로세스에 전송된 동일한 k 타입 signal 은 queue 에 들어가지 않고 폐기된다.

 

프로세스는 특정 시그널의 수신을 차단할 수 있다.

  • 차단된 시그널은 전달할 수 있지만, 시그널이 차단 해제될 때까지 수신되지 않는다.

 

Pending signal 은 최대 한 번 수신될 수 있다.

 

 

※ Signal Concepts : Pending/Blocked Bits

 

커널은 각 프로세스의 context 에서 pending 및 blocked 비트 벡터를 유지한다.

 

  • pending : 보류 중인 signal 들의 세트를 나타낸다.
    • k 유형의 시그널이 전달될 때, 커널이 보류 중인 비트 k를 설정한다.
    • k 유형의 시그널이 수신되면, 커널이 보류 중인 비트 k를 지운다.
  • blocked: 차단된 시그널 세트를 나타낸다.
    • sigprocmask function 을 사용하여 설정하거나 지우는게 가능하다.
    • 이를 signal mask 라고 한다.

 

※ Sending Signals : Process Groups

 

모든 프로세스는 정확히 한 프로세스 그룹에 속한다. (부모가 2 이상인 경우가 존재하지 x)

 

 

※ Sending Signals with /bin/kill Program

 

다음과 같은 함수를 실행한다고 가정해보자.

void fork16()
{
    if (fork() == 0) {
        printf("Child1: pid=%d pgrp=%d\n",
               getpid(), getpgrp());
        if (fork() == 0)
            printf("Child2: pid=%d pgrp=%d\n",
                   getpid(), getpgrp());
        while(1);
    }
    printf("Parent: pid=%d pgrp=%d\n", getpid(), getpgrp());
}

 

 

위 코드에 대해 각 프로세스들을 그룹화한다면 다음과 같이 묶일 수 있다.

 

 

ctrl + C 키를 눌러 루프를 중단시킨 후, ps 명령어를 치면 reaping 되지 못한 두 프로세스가 화면에 찍힌다.

 

 

이를 처리할 수 있는 방법은 두가지다.

 

  • /bin/kill -9 130426 130427 : ID 130426, 130427 인 두 프로세스에게 SIGKILL 을 보낸다.
  • /bin/kill -9 -130425 : ID 130425 의 그룹에 속한 모든 프로세스에게 SIGKILL 을 보낸다. 

 

Group process ID 를 이용하여 모든 프로세스에게 SIGKILL 을 보내는 것이 효율적이므로 이 방법을 시행하면 다음과 같은 결과가 나타난다.

 

 

※ Sending Signals from the Keyboard

 

ctrl + c(ctrl + z)를 입력하면 커널은 SIGINT(SIGTSTP)를 foreground process 그룹의 모든 작업에 보낸다.

 

  • SIGINT : 디폴트 액션은 각 프로세스를 종료하는 것이다. (ctrl + c)
  • SIGTSTP : 디폴트 액션은 각 프로세스를 정지하는 것이다. (ctrl + z)

 

ctrl + c 그리고 ctrl + z 를 연습하기 위해 다음 코드를 분석해보도록 하자.

void fork17()
{
    if (fork() == 0) {
        printf("Child: pid=%d pgrp=%d\n",
               getpid(), getpgrp());
    }
    else {
        printf("Parent: pid=%d pgrp=%d\n",
               getpid(), getpgrp());
    }
    while(1);
}

 

 

ctrl + z 키를 누르면 프로세스가 죽지 않고 중지된 상태가 된다.

ps 명령어를 통해서 프로세스의 상태가 'T' 인것으로 보아 stop 상태임을 알 수 있다.

이때 ps 프로세스는 R+ 인 것으로 보아 running 상태임을 알 수 있으며, 현재 진행중인 foreground process 임을 짐작할 수 있다.

그렇다면 위에 stop 된 프로세스들을 종료시키는 방법은 어떻게 해야할까?

 

 

fg 명령어는 blocked 된 프로세스들을 resume 하는 명령어이다.

프로세스를 재개하여 ctrl + c 키를 누르면 커널은 SIGINT 시그널을 받게 되며 현재 foreground process 그룹의 모든 작업에게 종료하도록 명령을 내린다.

따라서 ps 명령어를 입력하면 stop 된 프로세스들이 사라진 것을 확인할 수 있다.

 

 

※ Sending Signals with kill Function

 

자식 프로세스가 지속적으로 loop 를 돈다고 할 때, 부모 프로세스가 자식 프로세스를 kill 하도록 하는 코드를 보도록 하자.

void fork12()
{
    pid_t pid[N];
    int i;
    int child_status;

    for (i = 0; i < N; i++)
        if ((pid[i] = fork()) == 0) {
            /* Child: Infinite Loop */
            while(1)
                ;
        }
    for (i = 0; i < N; i++) {
        printf("Killing process %d\n", pid[i]);
        kill(pid[i], SIGINT);
    }

    for (i = 0; i < N; i++) {
        pid_t wpid = wait(&child_status);
        if (WIFEXITED(child_status))
            printf("Child %d terminated with exit status %d\n",
                   wpid, WEXITSTATUS(child_status));
        else
            printf("Child %d terminated abnormally\n", wpid);
    }
}

 

 

N = 5 인 경우 위와 같이 문구가 출력된다.

kill() 함수를 통해 자식 프로세스를 kill 한 후, 자식 프로세스가 exit() 을 호출하여 정상적으로 terminate 된게 아니라 kill 된 것 이므로 WIFEXITED() 함수에서 전부 exit() 된 상태가 아니므로 "Child ~ terminated abnormally" 가 출력 된 것이다.

 

 

※ Receiving Signals

 

커널이 exception handler 에서 반환되어 프로세스 p 에 제어를 전달할 준비가 되었다고 가정해본다.

 

 

Kernel 은 pnb = pending & ~blocked 을 계산한다.

  • 프로세스 p에 대한 보류 중(pending)인 비차단(~blocked) 시그널 세트를 의미

If (pnb == 0)

  • p의 논리 흐름에서 다음 명령으로 제어를 전달한다.

else

  • 0이 아닌 최소 비트 k(pnb)를 선택하고 프로세스 p가 시그널 k를 수신하도록 강제화 한다.
  • 시그널을 수신하면 p에 의해 몇 가지 액션이 유발된다.
  • 0이 아닌 모든 k(pnb)에 대해 반복한다.
  • p의 논리 흐름에서 다음 명령으로 제어를 전달한다.

 

※ Default Actions

 

각 signal 유형에는 다음 중 하나의 기본 액션이 미리 정의되어 있다.

  • 프로세스가 종료
  • 프로세스는 SIGCONT 시그널에 의해 재시작될 때까지 정지
  • 프로세스가 시그널을 무시

 

※ Installing Signal Handlers

 

시그널 함수는 시그널 signum 의 수신과 관련된 디폴트 액션을 변경한다.

  • handler_t *handler(int signum, handler_t *handler)

 

handler 의 다른 값:

  • SIG_IGN: signum 유형의 시그널을 무시한다.
  • SIG_DFL: signum 유형의 시그널을 수신하면 기본 액션으로 되돌린다.
  • 그 이외의 경우 핸들러는 사용자 레벨의 신호 핸들러의 주소이다.
    • 프로세스가 signum 형식의 신호를 수신하면 호출된다.
    • 핸들러의 「installing」라고 불린다.
    • Executing handler 은 신호 "catching" 또는 "handling" 라고 불린다.
    • 핸들러가 return 문을 실행하면, 제어는 신호 수신으로 중단된 프로세스의 제어 흐름에서 명령으로 돌아간다.

 

※ Signal Handling Example

 

signal 에 대한 수신을 기다리고 ctrl + c 키를 누르면 문구가 뜨면서 종료되는 코드를 보도록 하자.

void sigint_handler(int sig) /* SIGINT handler */
{
    printf("So you think you can stop the bomb with ctrl-c, do you?\n");
    sleep(2);
    printf("Well...");
    fflush(stdout);
    sleep(1);
    printf("OK. :-)\n");
    exit(0);
}

int main()
{
    /* Install the SIGINT handler */
    if (signal(SIGINT, sigint_handler) == SIG_ERR)
        unix_error("signal error");

    /* Wait for the receipt of a signal */
    pause();

    return 0;
}

 

 

ctrl + c 키를 눌렀더니 위와 같이 프린트가 찍히면서 종료되었다.

여기서 핵심은 sigint_handler() 함수를 user level 에서 작성했다는 것이다. 

signal 함수를 통해 SIGINT 라는 시그널과 직접 작성한 sigint_handler 함수를 통해 해당하는 시그널 SIGINT 를 만나게 될 경우 sigint_handler 함수가 실행되면서 exit() 을 호출하여 프로그램이 종료된다.

 

그렇다면 pause() 를 호출하지 않으면 어떠한 일이 발생할까?

 

 

시그널을 기다리지 않고 바로 프로그램이 종료된다.

 

 

※ Another View of Signal Handlers as Concurrent Flows

 

 

Process A 에게 signal 을 보낸 후에 context switch 가 일어나서 Process B 가 실행되다가 다시 context switch 가 일어나서 Process A 로 돌아왔다.

그러나 Process A 에서 다음 Instruction 이 바로 실행되는 것이 아니라 이전에 보냈던 signal 을 받게 되었으므로 Kernel 측에서 이를 반응하여 handler 가 호출되고 작업이 종료되면 Kernel 에서 작업 종료에 대한 처리 후에야 다음 Instruction 이 실행되는 것을 알 수 있다.

 

 

※ Nested Signal Handlers

 

Handlers can be interrupted by other handlers

 

 

시그널을 여러번 catch 하여 handler 가 중첩으로 호출되는 경우가 생길 수 있다고 한다.

확실한 것은 Handler S, T 는 user-level 에서 구현된 핸들러이다. (즉, 내가 직접 작성한 코드)

그렇다는 것은 Handler S, T 내에서 전역변수를 공유하는 것은 확실하다.

다음과 같은 상황을 예로 들어보도록 하자.

 

전역 변수 : int x = 0;
Handler S : x 를 1로 변경한 후 x 가 1일 경우 "yes" 출력하고 그 외에 "no" 를 출력
Handler T : x 를 2로 변경한 후 "bye" 를 출력

 

main 이 진행되는 도중에 시그널 s 를 catch 하여 핸들러 S 가 실행된다고 가정해보자.

그러면 x = 1 로 변경되면서 if (x == 1) 에 대한 조건을 보려하던 찰나에 시그널 t 를 catch 하여 핸들러 T 가 실행된다고 해보자.

x = 2 로 변경되면서 "bye" 가 출력되고 다시 핸들러 S 로 돌아가면서 if (x == 1) 에 대한 조건을 보게 되고 이때 x = 2 이므로 의도치 않게 "no" 가 출력되는 현상이 일어난다.

그리고 핸들러 S 가 종료되고 다시 main 으로 돌아가서 그 다음 instruction 을 실행하게 된다.

중요한 것은 global variable 가 공유되기 때문에 이로 인한 Concurrency 문제가 발생한다. (즉, 의도치 않은 버그가 발생)

 

 

※ Blocking and Unblocking Signals

 

Implicit blocking mechanism

  • 커널은 현재 처리 중인 유형의 pending signals 을 차단한다.
  • 예를 들어 SIGINT 핸들러는 다른 SIGINT에 의해 중단(blocking)될 수 없다.

Explicit blocking and unblocking mechanism

  • sigprocmask 함수

Supporting functions

  • sigemptyset: 빈 세트를 만든다.
  • sigfillset : 설정할 모든 시그널 번호들을 추가한다.
  • sigaddset : 설정할 시그널 번호 추가한다. (sigfillset 과 약간의 차이점이 있군)
  • sigdelset : 설정에서 시그널 번호를 삭제한다.

 

sigprocmask 를 통해서 분명하게 시그널에 대한 blocking, unblocking 을 할 수 있다고 한다.

일시적으로 시그널들을 blocking 하는 코드 예시를 보도록 하자.

 

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);

 

우선 sigset_t 타입의 mask, prev_mask 를 생성하였다.

그리고 mask 에 빈 세트를 만들고 SIGINT 시그널을 추가하여 해당 시그널만 blocking 하도록 한다.

Sigprocmask( ) 함수 사이의 코드는 SIGINT 시그널에 대해서 무시할 수 있게 된다. (즉, not interrupt, blocking)

Sigprocmask(SIG_BLOCK, &mask, &prev_mask) 는 이 함수를 호출하기 이전에 시그널을 blocking 하는 정보들이 prev_mask 에 저장되면서 동시에 위에서 설정하였던 mask 에 대해서 blocking 한 시그널들이 차단되게 된다.

Sigprocmask(SIG_SETMASK, &prev_mask, NULL) 는 이전에 보존해뒀던 prev_mask 에 대해서 적용함으로써 mask 가 적용되기 그 이전 상태로 돌아가는 것을 알 수 있다.

 

 

※ Safe Signal Handling

 

핸들러는 위에서 본 것처럼 메인 프로그램과 동시에 실행되며 동일한 글로벌 데이터 구조를 공유하기 때문에 까다롭다.

(즉, 공유 데이터 구조가 손상될 수 있음)

따라서 이러한 문제를 해결하기 위해 몇가지 가이드라인을 살펴보도록 하자.

 

Async-signal-safe란, 인터럽트된 코드를 간섭(side-effect)하지 않는 것을 말한다. 정확하게는 앞서 말한 바와 같이 현재 인터럽트된 시스템 진행을 간섭하지 않으면 되는 것이지만, 인터럽트란 언제나 일어날 수 있기 때문에 특정 인터럽트된 진행을 간섭하지 않게끔 하는 것은 의미가 없고, 모든 경우에 안전해야 한다. 대표적으로 re-entrant한 함수는 safe하다.

 

G0: 핸들러를 최대한 심플하게 유지하자.

  • 예: 글로벌 flag 를 설정 및 반환하기

G1: 핸들러의 async-signal-safe 함수만을 호출하자.

  • printf, sprintf, malloc 및 exit은 안전하지 않음

G2: entry 그리고 exit 때 errno 를 저장 및 복원하자.

  • 다른 핸들러가 errno 값을 덮어쓰지 않도록 함

G3: 모든 신호를 일시적으로 차단하여 공유 데이터 구조에 대한 접근을 보호하자.

  • 손상을 방지하기 위함

G4: 글로벌 변수를 volatile 으로 선언하자.

  • 컴파일러가 레지스터에 저장하지 않도록 하기 위함

G5: 글로벌 flag 를 volatile sig_atomic_t 로 선언하자.

  • flag : 읽기 또는 쓰기 전용 변수(예: flag = 1, flag++ 아님)
  • 이렇게 선언된 플래그는 다른 글로벌처럼 보호될 필요가 없음

 

volatile 을 잘 모르겠어서 코드를 통해서 volatile 이 어떻게 쓰이는지를 확인해보도록 하자.

int i = 0;
while (i < 10000000) i++;
printf("%d", i);

 

위 코드는 volatile 이 붙지 않은 코드이다.

이때, 컴파일러는 이 코드를 최적화하여 while 반복문을 없애버리고 i 에 그냥 10000000 을 할당해버린다.

즉 최적화 작업을 통해 아래와 같은 코드로 변경된다.

int i = 10000000;
printf("%d", i);

 

이번엔 volatile 이 붙은 코드를 살펴보도록 하자.

volatile int i = 0;
while (i < 10000000) i++;
printf("%d", i);

 

volatile 을 붙이면 컴파일러는 해당 변수를 최적화에서 제외하여 항상 메모리에 접근하도록 만듭니다.

따라서 항상 i의 메모리 접근이 일어나야 하므로 컴파일러가 while 문을 없애지 않는다.

 

 

※ Async-Signal-Safety

 

시그널에 의해 재진입하거나 인터럽트할 수 없는 경우, 이러한 기능은 async-signal-safe 이다.

Posix는 117개의 함수가 async-signal-safe 임을 보증한다.

  • list 에 있는 자주 사용되는 기능:
    • _exit, write, wait, waitpid, sleep, kill
  • list 에 없는 자주 사용되는 기능:
    • printf, sprintf, malloc, exit
    • 그러나 write는 유일한 async-signal-safe output function 이다.

 

printf 함수는 async-signal-safe 를 보장해주지 못한다. (printf 함수 내에서 lock 을 잡기 때문)

따라서 write 함수를 사용해야 async-signal-safe 를 보장해주는데 다음과 같은 함수를 통해 write를 할 수 있다.

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

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

 

매개변수인 문자열 s 를 받아와서 write 함수를 호출하거나 _exit() 호출을 하여 async-signal-safe 를 보장해주는 것을 확인하였다.

 

 

  Correct Signal Handling

 

다음 코드를 살펴보도록 하자.

int ccount = 0;
void child_handler(int sig)
{
    int olderrno = errno;
    pid_t pid;
    if ((pid = wait(NULL)) < 0)
        Sio_error("wait error");
    ccount--;
    Sio_puts("Handler reaped child ");
    Sio_putl((long)pid);
    Sio_puts(" \n");
    sleep(1);
    errno = olderrno;
}

void fork14()
{
    pid_t pid[N];
    int i;
    ccount = N;
    Signal(SIGCHLD, child_handler);

    for (i = 0; i < N; i++) {
        if ((pid[i] = Fork()) == 0) {
            Sleep(1);
            exit(0);  /* Child exits */
        }
    }
    while (ccount > 0) /* Parent spins */
        ;
}

 

Signal(SIGCHLD, child_handler) 는 SIGCHLD 시그널을 받을 경우 child_handler 를 실행하는 것이다.

우선 실행 결과를 살펴보도록 하자.

 

 

N = 5 라고 할 때, main 함수에서 자식 프로세스일 경우 sleep(1) 을 호출하고 exit(0) 을 호출한다.

즉, 자식 프로세스가 종료되었으므로 부모 프로세스에게 SIGCHILD 를 보낸다는 것을 확인할 수 있다.

부모 프로세스(main)는 SIGCHILD 시그널을 받았으므로 child_handler() 을 호출하는것 또한 알 수 있다.

그렇다면 wait() 함수를 통해 자식 프로세스 N 개가 정상적으로 reaping 처리가 되면서 ccount 값이 N 만큼 감소하여 main 함수의 가장 하단에 있는 while - loop 를 빠져나오고 정상적으로 종료가 되어야 하는데 지속적으로 loop 를 도는 이유가 무엇일까?

 

보류 중인 시그널(pending signal)이 큐잉되지 않으므로 이러한 현상이 나타나게 된 것이다.

  • 각 시그널 유형에 대해 1비트는 신호가 보류 중인지 여부를 나타낸다.
  • 그러므로 특정 유형의 보류 신호를 최대 1개까지 수신할 수 있다.

자식의 종료와 같은 이벤트는 시그널을 사용하여 카운팅할 수 없다.

 

따라서 이를 해결하기 위해 핸들러를 수정해야 할 필요가 있다.

보류 중인 시그널이 큐잉되지 않기 때문에 핸들러 함수를 모든 자식 프로세스들을 reaping 하도록 하여 프로그램이 종료되도록 구현해줘야 한다.

즉, wait () 함수를 while - loop 를 통해 여러번 호출시켜서 모든 자식프로세스들을 reaping 하고 모든 reaping 이 끝나면 -1 값이 반환되면 자동으로 빠져나오도록 자연스럽게 구현해주면 된다.

 

핸들러를 수정한 코드는 다음과 같다.

void child_handler2(int sig)
{
    int olderrno = errno;
    pid_t pid;
    while ((pid = wait(NULL)) > 0) {
        ccount--;
        Sio_puts("Handler reaped child ");
        Sio_putl((long)pid);
        Sio_puts(" \n");
    }
    if (errno != ECHILD)
        Sio_error("wait error");
    errno = olderrno;
}

 

 

※ Portable Signal Handling

 

Unix의 버전에 따라 시그널 처리 시멘틱이 다를 수 있다고 한다.

  • 일부 오래된 시스템에서는 시그널을 수신한 후 작업을 기본값으로 복원한다.
  • 중단된 일부 시스템 호출이 errno == EINTR로 반환될 수 있다.
  • 일부 시스템은 처리 중인 유형의 시그널을 차단하지 않는다.

 

이를 해결하기 위해 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);
}

 

우선, sigaction 구조체 타입의 변수 action, old_action 이 선언되었다.

action 의 function 타입인 sa_handler 에 매개변수로 받은 handler 를 할당해주는 것으로 보인다.  (이때 매개변수로 받은 handler 는 user-level 에서 작성된 handler 함수)

action 의 sigset_t 타입의 sa_maks 를 sigemptyset 을 호출함으로써 빈 세트로 만들어준다.

그리고 sigaction 이 호출되어 반환값이 음수면 에러 문구를 띄우도록 하고 old_action 의 sa_handler 를 반환해준다. 

 

... 사실 잘 모르겠다.

다양한 환경에서 위에서 언급된 portable 문제를 해결할 수 있다라는 정도로만 이해하고 넘어가도록 해야겠다.

 

 

※ Synchronizing Flows to Avoid Races

 

부모가 자식보다 먼저 실행한다고 가정하기 때문에 미묘한 동기화 오류가 있는 단순 Shell 이다.

코드를 보도록 하자.

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(SIGCHLD, 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);
}

 

main 에서 Signal(SIGCHLD, handler) 를 호출함으로써 SIGCHLD 시그널을 수신할 경우 handler 함수를 수행하도록 하였다.

그리고 job 을 초기화하고 while - loop 을 돈다.

이때 초기화한 job 은 다음과 같은 링크드 리스트 형태라고 가정해보자.

 

 

while 문이 실행하자 마자 fork() 를 띄움으로써 자식 프로세스가 실행될 경우 Execve("/bin/date", argv, NULL) 을 호출함으로써 날짜를 출력하고 종료하여 SIGCHLD 시그널을 main 프로세스에게 보낸다.

여기서 context switch 를 통해 여러가지 상황이 일어날 수 있음을 짐작할 수 있다.

 

case ① : fork() 를 띄우자 마자 context switch 가 일어나는 경우

자식 프로세스는 Execve("/bin/date", argv, NULL) 를 호출하여 날짜를 출력한 후 exit 한다.

즉, SIGCHLD 시그널을 받은 상태이므로 다시 main 프로세스로 넘어와서 handler 가 호출되는 것을 알 수 있다.

이때 handler 함수에서 while 루프를 통해 자식 프로세스를 reaping 하는데 deletejob(pid) 이 호출된다.

 

 

즉 위와 같은 텅 빈 상태에서 delete 할게 없어서 handler 를 빠져나오고 addjob(pid) 을 호출한다.

이미 해당 pid 를 handler 함수에서 delete 를 해버렸는데 reaping 한 프로세스에 대한 pid 를 add 를 해버리니 문제가 발생한다.

 

case ② : fork() 를 띄우고 Sigprocmask(SIG_BLOCK, ... ) 호출 이전 라인에서 context switch 가 일어나는 경우

자식 프로세스로 이동하고 Execve(~~~) 를 호출한 다음에 exit 한다.

그러면 SIGCHLD 시그널을 main 프로세스에서 받고 Sigprocmask(SIG_BLOCK, ...) 을 호출하기 전 상태이므로 이 또한 handler 가 먼저 실행되는 것을 짐작할 수 있다.

 

 

 

즉 case ① 과 같은 상황이 되버린 것이다.

 

case ③ : fork() 를 띄우고 Sigprocmask(SIG_BLOCK, ... ) 를 호출한 다음 라인에서 context switch 가 일어나는 경우

자식 프로세스가 SIGCHLD 시그널을 보내게 되면 이번엔 다행스럽게 Sigprocmask(SIG_BLOCK, ... ) 를 호출한 다음 라인이기 때문에 SIGCHLD 시그널을 blocking 한다.

즉, addjob(pid) 가 수행되게 된다.

 

 

우연히 signal 을 blocking 하는 코드 영역으로 들어와서 정상적으로 add 된 것이다.

이 경우는 정상적으로 add 되었지만, 정상적인 코드가 아니라는 것은 ①, ② 와 같은 케이스를 통해 확인하였다.

 

그렇다면 이를 정상적으로 수행시키기 위해 어떻게 수정해야 할까?

 

위 코드를 자세히 보면 SIGCHLD 를 받고 main 프로세스에 돌아올 때 바로 handler 가 호출되어 deletejob() 이 호출되는 문제점이 있었다.

우선 수정한 코드를 분석해보도록 하자.

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, SIGCHLD);
    Signal(SIGCHLD, handler);
    initjobs(); /* Initialize the job list */

    while (1) {
        Sigprocmask(SIG_BLOCK, &mask_one, &prev_one); /* Block SIGCHLD */
        if ((pid = Fork()) == 0) { /* Child process */
            Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
            Execve("/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 SIGCHLD */
    }
    exit(0);
}

 

sigset_t 타입의 mask_one 을 추가한 것으로 보인다.

mask_one 을 통해서 fork() 를 띄우고 바로 handler 를 호출하여 delete 를 해주는 것을 방지해주는 것을 알 수 있다.

 

 

※ Explicitly Waiting for Signals

 

SIGCHLD 시그널의 도착을 분명하게 대기하는 프로그램 핸들러이다.

이에 대한 코드를 살펴보도록 하자.

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);

        /* 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);
}

 

우선, 전역변수로 volatile sig_atomic_t 타입의 pid 를 선언하여 signal safe 를 보장하도록 하였다.

그리고 SIGINT 시그널에 대해서 sigint_handler 함수를 호출하도록 했다면 아무리 ctrl + c 키를 눌러도 프로그램이 종료되지 않음을 알 수 있다.

sigchld_handler() 함수를 보면 SIGCHLD 시그널을 받게 될 경우에 Waitpid() 를 호출함으로써 자식 프로세스의 프로세스 아이디를 pid 에 할당해준다.

 

그렇다면 main 문에서 fork() 를 띄우고 pid 를 0으로 초기화 한 다음에 while 루프를 지속적으로 도는 것을 상상해보자.

context switch 가 일어나서 자식 프로세스가 exit() 하고 SIGCHLD 시그널을 받게 된다면 main 함수에서 Sigpromask 내에 SIGCHLD 시그널을 무시하는 코드 영역이 아니므로 sigchld_handler 함수를 호출하여 pid 값을 죽은 자식 프로세스의 pid 값을 reaping 하여 while 루프를 빠져나오고 "." 이 찍히면서 이러한 행위를 반복한다.

 

실제로 프로그램을 돌려보면 context switch 마다 프린트 되므로 약간의 딜레이가 발생하는 것을 알 수 있다.

그렇다면 문제는 main 프로세스가 쓸데없이 while 루프를 계속 돌면서 cpu cycle 을 잡아먹는 것이다.

 

이를 해결하기 위해 어떻게 처리해야 할까?

 

이를 해결하기 위해 여러 방법들이 도입된다.

 

① puase()

while (!pid)
	pause();

 

pause() 함수는 우선 sleep 상태가 되며 시그널을 받게 되면 깨어나는 그러한 매커니즘을 갖는 함수이다.

그렇다면 sleep 상태가 된다고 할 때, 우연히 ctrl + c 키를 눌러서 의도하지 않은 SIGINT 시그널을 받게 되어 깨어난다고 해보자.

즉, pause() 함수는 의도하는 것과 다르게 실행된다는 문제점이 있다.

더 큰 문제점은 pause() 를 실행하기 전에 우연히 자식 프로세스가 실행이 되면서 SIGCHLD 시그널을 받아서 handler 가 호출되어 pid 값을 0으로 변경하게 된다면, 그 다음 pause() 를 호출하게 되면 더 이상 SIGCHLD 시그널을 받지 못하는 무한 loop 상태가 되버리는 경우도 존재한다. (확률적으로 굉장히 적은 일이지만...)

 

② sleep()

while (!pid)
	sleep(1);

 

sleep() 함수에 인자값을 넣어서 얼마나 sleep 할지를 결정할 수 있다.

즉, 이러한 인자값을 통해서 얼마나 느려질 지에 대한 issue 를 고려해볼 수 있다.

 

Solution : sigsuspend 

 

 

※ Waiting for Signals with sigsuspend

 

int sigsuspend(const sigset_t *mask)

 

Equivalent to atomic(uninterruptable) version of : 

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

 

pause() 함수에 signal 을 받지 않고 masking 하는 이러한 매커니즘을 구현한 것이 sigsuspend 함수이다.

즉, 3 차례에 걸친 함수들을 atomic 하게 실행할 수 있게 한 함수이다. 

 

그렇다면 main 프로세스에서 CPU cycle 을 잡아먹는 문제를 해결할 수 있도록 하는 코드를 살펴보면 다음과 같다.

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() 호출 이전과 이후에 printf() 를 따로 설정하여 실행해보았다.

 

 while (!pid){
    printf("getpid = %d / pid = %d, hi\n", getpid(), pid);
    Sigsuspend(&prev);
    printf("getpid = %d / pid = %d, bye\n", getpid(), pid);
}
printf("real byebye\n");

 

 

 

확실한 것은 Sigsuspend(&prev) 를 호출하기 이전에는 SIGCHLD 시그널을 blocking 한 상태이므로 main 프로세스임을 알 수 있다.

그러면서 Sigsuspend(&prev) 를 호출하고도 main 프로세스인 것도 확실한데 pid 값이 변경된 것으로 보아하니 Sigsuspend(&prev) 호출 이후에 자동으로 SIGCHLD 시그널을 인식하여 자식 프로세스를 reaping 하여 pid 값이 들어온 것을 볼 수 있다.

그리고 자식 프로세스가 정상적으로 reaping 되는 것을 위 print 결과를 통해 알 수 있다.

 

Comments