SP - 1.6 Details of Signal Handling

hyeok's Log·2022년 4월 2일
4

SystemProgramming

목록 보기
6/29

Nested Signal Handlers

  시그널 핸들러는 여러 가지가 중첩될 수 있다.

  • 프로세스에는 Signal A, B에 대한 Signal Handler가 Install되어 있다.

  • 프로세스가 Context Switch 이후 다시 돌아올 때, Kernel이 해당 프로세스에게 전달된 Signal이 있는지를 Pending & ~Block 연산(pnb Value)으로 확인한다.

  • Delivered Signal이 있으면, User Code를 수행함에 앞서, User Code의 일부분인 시그널 핸들러(이 핸들러는 Delivered Signal을 Catch한다고 가정)를 불러온다.

    • 그런데, 이때, 핸들러의 수행 시간이 Time Quantam을 넘어가면, 그냥 일반적인 프로세스 User Code 수행 시와 동일하게 Context Switch를 수행한다. ★

핸들러도 User Code이므로, Time Quantam이 만료되면 Context Switch를 수행한다.

그래서 Asynchronous하다고 한다.

  • Switch가 일어나 다른 프로세스를 수행하는 동안, 우리가 주목하고 있는 프로세스에 또 다른 시그널이 도달한다. 이 시그널을 Signal B, 앞선 Received Signal을 A라고 하자.

  • 이때, 시그널 B에 대한 시그널 핸들러도 설치되어 있으므로, Context Switch가 일어나 다시 '우리 프로세스'가 수행될 때, 수행하던 시그널 A 핸들러는 잠시 멈추고, 시그널 B 핸들러를 수행한다. ★

  • Handler B를 다 수행하면, Handler A의 남은 코드 부분을 더 수행하고, 그러고 나서 기존의 User Code의 I_next로 돌아간다. (그 중간에 Context Switch는 얼마든지 일어날 수 있다.)

~> 이를 '시그널 핸들러의 중첩(Nested Signal Handlers)' 상황이라 한다.


Concurrency Issue in 'Nested Signal Handlers'

프로그래머 입장에서, Nested Signal Handlers는 조심히 다루어야한다.

  • 만약, 예를 들어 Handler A와 Handler B가 동일한 Global Variable을 접근한다고 해보자.
    • 그렇다. 얼마든지 의도적이지 않은 Rewrite, Overwrite가 일어날 수 있다.

  프로그램 Logic 상에서 핸들러 A가 전역 변수를 취급하면서 수행되고 있다가 갑자기 핸들러 B가 동작하여 전역 변수의 값을 오염시킬 수 있는 것이다.
~> 핸들러 A 입장에서는 이 '오염'을 알아챌 방법이 없다.


  • 이를 'Concurrency' 문제라고 한다. 프로그래머가 'Concurrency Handling'을 잘 해야한다.

Signal Blocking

  • Implicit Blocking Mechanism
    • Kernel이 현재 진행중인 Action의 원인 Signal에 대해, 같은 Type의 Pending Signal을 모두 Blocking하는 것을 의미한다.
      • 즉, 우리가 앞서 학습했던 'Pending & Blocking Bit Vector 개념'이 바로 이 내용이다.

~> 프로세스가 현재 SIGINT에 대한 작업을 수행 중이면, 그 프로세스에 똑같은 SIGINT가 Delivered되면 이를 Blocking한다.
~> 이때의 'Blocking'은 아예 블로킹하고 그 시그널을 무시해버리는 것이다.
=> 이것이 바로 Implicit Blocking Mechanism이다.


  • Explicit Blocking & Unblocking Mechanism

    • Kernel이 아니라, 프로그래머가 직접 명시적으로 시그널을 블로킹/언블로킹하는 방법이다.

    • sigprocmask Function을 이용한다.

      • 사실, Kernel도 Implicit Blocking Mechanism의 구현에 있어 이 함수를 사용한다. (지난 포스팅에서 다룬 내용)
    • 그 밖에, 아래와 같은 보조 함수들을 사용한다.

      • sigemptyset : 인자로 넘어온 set을 Empty Set으로 만든다.
      • sigfillset : 인자로 넘어온 set에 모든 시그널을 포함한다.
      • sigaddset : 인자로 넘어온 set에 블로킹할 시그널을 추가한다.
      • sigdelset : 인자로 넘어온 set에 언블로킹할 시그널을 알려준다.

    블로킹할 시그널들을 Set으로 취급해, 이를 Sigprocmask 함수에게 알려주는 원리이다. ★

주의 : sigprocmask로 블로킹하는 것은, Kernel에서 이 함수를 이용해 Implicit Blocking을 수행할 때, 아예 무시하는 것과는 다르다. 프로그래머가 명시적으로 sigprocmask을 이용해 블로킹해놓은 시그널들은, 블로킹이 해제되는 순간 Queue에 들어오게 된다. ★★★★★


Explicit Blocking/Unblocking Mechanism

sigset_t mask, prev_mask;

Sigemptyset(&mask);				// 일단 Empty Set을 만든다.
Sigaddset(&mask, SIGINT);		// Set에 SIGINT를 추가한다. (얘를 블로킹할 예정)

Sigprocmask(SIG_BLOCK, &mask, &prev_mask);	// SIGINT를 블로킹한다.
// prev_mask에 기존 디폴트 Set을 저장하고, 임시적으로 mask를 set으로 설정한다.

// 이 부분 코드가 수행되고 있을 때는, SIGINT 시그널(Ctrl+C)을 블로킹한다.
// 즉, 이 부분 코드 수행 시 Ctrl+C가 눌려도 아무런 반응을 하지 않겠다는 의미!
// 이 부분 코드를 다 수행하고 나서, 블로킹되었던 시그널들에 반응할 것 ★

Sigprocmask(SIG_SETMASK, &prev_mask, NULL);	// 기존의 Set prev_mask로 복구한다.

~> 위와 같이 sigemptyset, sigaddset, sigprocmask 함수를 적절히 사용하여 임시적이고 명시적인 블로킹/언블로킹을 수행할 수 있다. (위의 코드에서는 Wrapper가 쌓여있다.)


Safe Signal Handling

  Signal Handler는 Concurrent Flow이기 때문에 Main Program과 Concurrent한 관계를 가진다고 했다. 이때, 만약 두 부분이 같은 전역 변수를 참조(공유)한다면, 조심히 다루어야 한다.

Shared Data Structures can become corrupted!

  • 앞서 다룬 Nested Signal Handlers 예제에서, Main과 Handler A, Handler B는 논리적인 관점에서는 Concurrent하게 동작하는 것으로 보인다. ★

Principles of 'Signal Handler'

  1. 시그널 핸들러는 최대한 간단하게 작성한다. 너무 많은 동작을 수행케 하지 않는다.

  2. Async-Signal-Safety한 함수들만 사용하도록 한다. (후술)

  3. 핸들러의 시작과 끝에서 errno를 임시 저장했다가 반환함으로써 'Errno Overwrite'를 방지하자. (errno는 Shared Data이므로!)

  4. 어떤 공유 자료구조를 접근해야한다면, 임시적이고 명시적인 시그널 블로킹 매커니즘을 적용하자.
    ~> 의도치 않은 'Corruption of Shared Data Structure'를 방지하고자 함이다. ★
    (Nested Signal Handlers 상황과 같은,,,)

  5. 전역 변수를 사용해야한다면, volatile 선언 전역 변수를 사용하도록 하자. 컴파일러는 volatile 변수의 값을 CPU Register에 저장하지 않고 메모리에서 직접 읽고 쓸 수 있게 메모리에 둔다.
    ~> 즉, 레지스터를 보호할 수 있다.

  6. 전역 변수이자 Flag 변수를 사용해야 한다면, volatile sig_atomic_t로 선언해주는 것이 좋다. 이 타입으로 선언된 변수는 데이터가 읽고 쓰여질 때 Atomic하게 레지스터를 읽고 쓸 수 있게 해준다.
    ~> 즉, 해당 레지스터를 이용 중에는 중간에 레지스터 오염이 불가능하게 막아주는 것이다.


Async-Signal-Safety

printf, sprintf, malloc, exit와 같은 함수는 Async-Signal-Safety하지 않다. 즉, 시그널 핸들러 내부에서 사용하면 안된다.

Async-Signal-Safety : "Reentrant" or "Non-Interruptable by Signals"

함수 수행 중에 어떠한 시그널을 받더라도 함수가 중간에 쪼개지지 않는 함수를 의미한다.

즉, 비동기적인 시그널이 도달해도 안전한 함수를 의미한다. ★

Reentrant의 의미는 추후 Thread 개념 학습까지 마친 후 다룰 것이다.


  • 예를 들어, printf 함수를 사용한다고 해보자. main에서는 Loop를 돌며 printf를 사용하고 있고, 시그널 핸들러 안에서도 printf를 사용하는 상황이다.

  • printf라는 함수는, 함수 내부에 Lock을 지닌다.

    • Lock이란, "나(호출된 printf)를 제외하고는 그 누구도 printf 함수 원본 코드를 사용할 수 없다."를 의미한다.
  • 즉, printf 함수는 수행될 때 Lock을 잡는다. printf가 호출되면 그 안에서 Lock을 잡고, 터미널에 결과를 출력하고 나서야 Lock을 푼다.

    • 만약, Lock을 잡아놓은 와중에 Context Switch나 Catching Signal이 일어나면, 다른 코드 부분이나 프로그램이 printf를 수행하고자 하면 이를 막는 것이다. (Lock의 존재 이유) ★

이미 Lock이 잡혀있는 상황에서, 다른 프로그램이나 코드부분에서 printf를 수행하려고 하면 Lock을 잡을 수 없어서 수행되지 않는다.

  • 따라서, 상기한 예시 상황에서, main에서 printf를 하던 도중, Context Switch 후 시그널을 receive해 시그널 핸들러가 수행되면, main에서는 시그널 핸들러의 종료를 기다리고, 시그널 핸들러에서는 main에서 printf가 끝나길 기다리는 교착 상태에 빠지게 된다.

    • 이를 'Deadlock'이라고 한다.
  • Deadlock : Concurrent Flow 관계인 main과 시그널 핸들러가 서로 서로를 기다리는, 그래서 프로그램 진행이 Hanging 상태가 되어버리는 것을 Deadlock 현상이라 한다.

    • 이러한 Deadlock이 발생할 수 있는 함수들을 'Async-Signal-Safety하지 않음'이라 하는 것이다. (엄밀한 정의는 아니다. 엄밀한 정의는 이후에 할 것)

  POSIX에서는 117개의 Async-Signal-Safety 함수를 소개하고 있다. 대표적인 친구들은 다음과 같다.
~> _exit, write, wait, waitpid, sleep, kill
~> 핸들러에서 무언가 출력하길 원한다면 write를 사용하는 것이 바람직하다.


  본인이 SP 연재에서 참고하는 교재 'Computer Systems: A Programmer's Perspective'에서는 아래와 같은 Async-Signal-Safety I/O함수들을 제공한다. 이를 Reentrant SIO(Safety I/O Library)라고 부른다.

ssize_t sio_puts(char s[]) { 						/* Put string */
	return write(STDOUT_FILENO, s, sio_strlen(s));	// 그냥 write 사용한 것
}

ssize_t sio_putl(long v) 							/* Put long */

void sio_error(char s[]) { 							/* Put msg & exit */
	sio_puts(s);
    _exit(1);										// Async-Signal-Safe
}

~> 이러한 SIO 함수들을 사용하면 Deadlock을 피할 수 있다.
~> 따라서, 지난 포스팅에서 예시로 들었던 장난스러운 SIGINT 시그널 핸들러는 다음과 같이 수정되어야하는 것이다. exit를 _exit으로, print를 Sio_puts로 바꿨음에 주목하자. (Sio_puts는 sio_puts의 Wrapper 형태이다.)

void sigint_handler(int sig) { 			// SIGINT에 대한 Signal handler
	Sio_puts("너가 Ctrl+C 누른다고 내가 종료될 줄 알았냐?\n");
	sleep(2);
	Sio_puts("흠...");
	fflush(stdout);
	sleep(1);
	Sio_puts("그래 종료해줄게ㅋ\n");
	_exit(0);
}									// This handler is async-signal-safe!

Incorrect Signal Handling Example

int ccnt = 0;

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

void incorrect_example(void) {			// main function
	pid_t pid[N];
	int i;
	ccnt = N;							// N = 5라고 하자.
	Signal(SIGCHLD, sigchld_handler);	// 핸들러를 설치했다.

	for (i = 0; i < N; i++) {
		if ((pid[i] = Fork()) == 0) {	// N개의 프로세스를 띄우고, 각 차일드는
			Sleep(1);					// 1초동안 대기한다.
			exit(0); 					// 그러고 나서, 종료하고, SIGCHLD를 보낸다.
		}
	}
	while (ccnt > 0) 		// Parent는 while을 돌면서, ccnt가 0이 될때까지 루프를 돎.
		;
}

(출력)
> ./incorrect_example
Handler reaped child 12345
Handler reaped child 12347
(Hanging...)

  • N = 5를 받고, 5개의 Child를 생성한다고 해보자.

    • 각 Child는 1초 동안 대기한 후 종료한다. 종료하면서 Parent Process에게 SIGCHLD를 보낸다.
      • 하지만, Parent는 wait하지 않고 무한루프를 돌고 있다.
  • 한편, Concurrent Flow로 흐르는 시그널 핸들러가 SIGCHLD를 캐칭하도록 설치되어 있다.

    • Child의 종료에 따른 SIGCHLD가 프로세스에 도달 시, 이 핸들러가 수행된다.
  • SIGCHLD를 보낸 죽은 Child를 Reaping 하고 전역변수 ccnt를 Decrement한다.

  • 코드를 보면, 각 Child의 종료에 대해 핸들러가 작동해, N = 5인 경우, 5개의 'Handler reaped child' 출력이 이뤄질 것으로 예상된다.

    • 그런데, 결과를 보면 2개밖에 출력하지 못했음을 알 수 있다. 핸들러가 2번 밖에 시행되지 않아서 프로세스가 계속 무한루프를 돌고 있는 상황이다.
      • 왜일까?

Async-Signal-Safety 함수도 사용하고, errno도 임시보관하고, 전역변수도 핸들러에서만 취급하는데, 그런데 왜 5개의 Child를 모두 Reaping하지 못하는 것일까?

그것은 바로, 앞서 언급했던 Implicit Signal Blocking Mechanism 때문이다.


  • 현재 Action의 원인이 되는 Signal에 대해서, Pending Signal은 큐잉되지 않는다고 했다.
    • 즉, SIGCHLD를 캐칭한 시그널 핸들러가 수행될 때에, 하필 그 시간에 도착한 SIGCHLD에 대해선 모두 무시한다는 것이다. ★★

따라서 5개의 Child 중 3개의 Child는 시그널 핸들러 수행 중에 Parent Process에 도달하여서 처리되지 않고 무시된 것이다.


Correct Signal Handling Example

  위와 같은 Pending Signal 무시 현상을 방지하기 위해 우리는 다음과 같이 코드를 변형할 수 있다.

int ccnt = 0;

void sigchld_handler(int sig) {
	int olderrno = errno;
	pid_t pid;
    
	while ((pid = wait(NULL)) > 0) {	// 시그널 핸들링 과정에서 시그널이 추가로 도달
		ccnt--; 		// 하면, 남은 좀비프로세스만큼 계속 wait을 호출해주어, wait이
		Sio_puts("Handler reaped child ");	// 비정상 반환하여 종료될 때까지 반복한다.
		Sio_putl((long)pid); 
		Sio_puts(" \n"); 
	}
	
    if (errno != ECHILD)
		Sio_error("wait error");
	errno = olderrno; 
}

void correct_example(void) {			// main function
	pid_t pid[N];
	int i;
	ccnt = N;							// N = 5라고 하자.
	Signal(SIGCHLD, sigchld_handler);	// 핸들러를 설치했다.

	for (i = 0; i < N; i++) {
		if ((pid[i] = Fork()) == 0) {
			Sleep(1);		
			exit(0); 	
		}
	}
	while (ccnt > 0)
		;
}

(출력)
> ./correct_example
Handler reaped child 12345
Handler reaped child 12346
Handler reaped child 12347
Handler reaped child 12348
Handler reaped child 12349

~> 즉, 5개의 Child가 순서대로 죽을때, 이들을 순서대로 c1, c2, c3, c4, c5라고 하면, c1에 의해 핸들러가 수행되고, 그 핸들러 수행동안 추가로 도달하는 SIGCHLD를 wait으로 모두 처리해주는 것이다.
~> 예를 들어, c1에 의해 수행된 핸들러가 c2와 c3까지 커버하고, 그 사이(순간적으로 남아 있는 SIGCHLD가 없는 시점)에 c4의 SIGCHLD를 도달하지 않아 처리하지 않았다고 하면,
~> 그 다음 Context Switch에서 남아있는 c4, c5의 SIGCHLD를 핸들러가 캐치해서 처리해줄 수 있는 것이다.


Portable Signal Handling

  과거의 UNIX 계열 시스템에서는, 시그널 핸들러를 매번 수행 시마다 등록해주었어야 했다. 한 번 시그널 핸들러가 수행되면, 그 다음 시그널에 대해서는 다시 디폴트 액션이 강제되었기 때문이다.
~> LINUX에서는 이를 개선했기 때문에, 여러번 시그널 핸들러의 호출이 가능하다. (위의 예제처럼)

  또한, 어떤 Old 시스템에서는 System Call 호출 시, System Call을 처리하다가 중간에 Interrupt Exception이 도달하면 에러를 내고 종료되는 경우도 있었다.

  이 뿐만이 아니다. Pending Signal에 대한 동일한 시그널의 도달을 막지 않는 Old System도 존재한다.


Old와 Present 구분없이 어떤 UNIX System에서든 돌아갈 수 있는 시스템 소프트웨어를 만들기 위해선 'Portable Signal Handling'이 중요하다.

sigaction이란 함수를 통해 이를 실현할 수 있다.


handler_t *Signal(int signum, handler_t *handler) {		// Wrapper 함수를 사용한다.
	struct sigaction action, old_action;				// sigaction 구조체 도입

	action.sa_handler = handler;			// 핸들러를 등록하고,
	sigemptyset(&action.sa_mask); 			// 블로킹을 위한 Empty Set을 만들고
	action.sa_flags = SA_RESTART; 			// Abort하지 않고 재시작하도록 설정

	if (sigaction(signum, &action, &old_action) < 0)
		unix_error("Signal error");		// old_action 대신 action을 수행한다!
	return (old_action.sa_handler);
}

~> 이러한 Wrapper 함수를 이용하면, 다양한 Version의 UNIX System에서 모두 정상 동작하는 시그널 핸들러를 만들 수 있게 된다. ★
~> 참고로, 위의 코드를 외우는 것은 무리이다. Portability도 고려해야한다는 것이 핵심이다.


Race - Subtle Synchronizaton Error

  아래의 예시 Simple Shell 코드는 LINUX의 jobs 명령을 구현하기 위해 Job List(중단된 프로세스, 또는 현재 계속 수행중인 백그라운드 프로세스들을 보여주는 명령)를 만들고 프로세스 생성 시 Job을 기록하고, 종료(Reaping) 시 Job을 제거하는 기능을 구현하고자 한다. 그런데 이때, 아래와 같이 코드를 작성하면, 'Race'라는 동기화 에러가 발생할 수 있다. 왜그럴까?

int main(int argc, char **argv) {
	int pid;
	sigset_t mask_all, prev_all;

	Sigfillset(&mask_all);			// 모든 시그널을 블로킹할 준비
	Signal(SIGCHLD, sigchld_handler);// SIGCHLD에 대한 핸들러 설치
	initjobs(); 					// Job List라는 자료구조를 생성

	while (1) {
		if ((pid = Fork()) == 0) { 			// Child
			Execve(argv[0], argv, NULL);
		}
        // Parent @@@@@@
		Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);	// 임시 블로킹 설정
		addjob(pid); 			// Job List에 생성한 프로세스의 PID를 Enqueue.
		Sigprocmask(SIG_SETMASK, &prev_all, NULL); 		// 임시 블로킹 해제
	}
	exit(0);
}

void sigchld_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) {// 수행중 들어오는 SIGCHLD 모두 캐칭!
		Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);	// 임시 블로킹
		deletejob(pid); 		// Job List에 들어있는 데이터를 제거한다. (Dequeue)
		Sigprocmask(SIG_SETMASK, &prev_all, NULL);
	}
    
	if (errno != ECHILD)
		Sio_error("waitpid error");
	errno = olderrno;
}

~> 도무지 아무런 문제가 없어보이지 않는가? 하지만, 미묘한 동기화 문제가 있는 코드이다.


  • main이랑 Handler가 Job List라는 자료구조를 공유하고 있다. Queue라고 하자. 내가 fork한 task에 대한 정보를 쭉 기록하는 자료구조라고 인식하면 된다.

  • Child가 할 일을 수행하고 나면, 종료되고 main에 SIGCHLD를 보낸다.

  • SIGCHLD를 받은 메인함수는 핸들러를 수행한다. 이 핸들러는 좀비 프로세스의 Reaping과 Dequeue를 수행한다.

즉, main에서 Enqueue하고, 핸들러에서 Dequeue하고 있는 상황이다.


도대체 무엇이 문제일까?

우리는 main의 Enqueue와 Handler의 Dequeue 중 무엇이 먼저 수행될지 모른다.

즉, fork 해놓고, Child가 수행되고 끝나서, SIGCHLD 시그널이 main에 도달하는데, Child가 너무 빠르게 실행됐고, 타이밍이 우연치 않게 위의 코드의 @@@@@ 시점에 SIGCHLD가 도달했다고 해보자.

즉, 임시 블로킹을 하기도 전에 SIGCHLD가 먼저 도달해서 핸들러가 수행되어버린 것이다. Child를 먼저 Reaping(Dequeue)해버리고 나서, 그 다음 Enqueue를 하려니, 이상한 결과가 나버리는 것이다.

~> 이러한 오류를 'Race'라고 부른다.


Solution

  이를 해결하기 위해선, 아래와 같이, fork 이전에 SIGCHLD를 블로킹하고, Parent가 '해야하는 일'을 한 다음에 블로킹을 해제해야한다.

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, sigchld_handler);
	initjobs();

	while (1) {
		Sigprocmask(SIG_BLOCK, &mask_one, &prev_one);	// ★★★
        
		if ((pid = Fork()) == 0) {
			Sigprocmask(SIG_SETMASK, &prev_one, NULL);	// ★★★
			Execve(argv[0], argv, NULL);
		}
        // @@@@@
		Sigprocmask(SIG_BLOCK, &mask_all, NULL);
		addjob(pid);
		Sigprocmask(SIG_SETMASK, &prev_one, NULL);
	}
	exit(0);
}

~> mask_one이라는, SIGCHLD를 Blocking할 Set을 만들어놓고, fork 이전에 Set을 mask_one으로 바꿔놓는 것이다. 즉, Child 생성부터 Parent 동작 수행까지 SIGCHLD는 모두 무시하겠다는 것이다.
=> 이렇게 하여, 중간에 SIGCHLD가 끼워드는 Race 문제를 막을 수 있다.


@@@@@ 시점에 들어온 SIGCHLD를 블로킹(대기)해놨다가 언블로킹 후에 이를 처리할 것이다! ★★★★ (sigprocmask를 일반 프로세스에서 사용할 경우의 블로킹 의미를 늘 주의하자.)


New Foreground Process Reaping Method

  Foreground 프로세스의 경우 wait이나 waitpid 함수를 호출하여 처리할 수 있음을 배웠다. 이제 시그널 핸들러도 사용할 수 있게된 우리는, 기존의 방식 말고도, Foreground 프로세스를 처리할 수 있는 새로운 루틴을 하나 더 얻게 되었다.

그것은 바로, SIGCHLD가 입력될 때마다 SIGCHLD에 대한 핸들러를 동작시키는데, 핸들러 내부를 다음과 같이 설정해주는 것이다.

volatile sig_atomic_t pid;		// Safe global variable for Handler
								// 레지스터에 저장되지 않도록!
void sigchld_handler(int s) {
	int olderrno = errno;
	pid = Waitpid(-1, NULL, 0);		// main에서 Nonzero PID를 기다리는 중
	errno = olderrno;
}

~> 즉, SIGCHLD가 발생한 경우에 Concurrent Flow로 Waitpid를 호출해 죽은 프로세스를 처리해주는 것이다. 그리고 그 Reaping된 프로세스의 PID를 전역변수에 업데이트한다.
=> 이러한 핸들러를 아래와 같이 main 함수에 설치하자.

int main(int argc, char **argv) {
	sigset_t mask, prev;
    Signal(SIGINT, sigint_handler);
	Signal(SIGCHLD, sigchld_handler);
	Sigemptyset(&mask);
	Sigaddset(&mask, SIGCHLD);

	while (1) {
		Sigprocmask(SIG_BLOCK, &mask, &prev); /* Block SIGCHLD */
        
		if (Fork() == 0) 	/* Child */
		{ .... }
            
		/* Parent */
		pid = 0;
		Sigprocmask(SIG_SETMASK, &prev, NULL); /* Unblock SIGCHLD */
		// 이순간부터 SIGCHLD를 받을 수 있게 된다.
		while (!pid)	// pid가 0이 아니면 계속 기다림. SIGCHLD에 대한
			;			// 핸들러가 동작하길 바라면서 계속 기다리는 것임.

		// 이후 동작
	}
	exit(0);
}

~> 그런데, 이렇게 마냥 기다리는 것은 너무 소모적이다.
~> 불필요하게 CPU 사이클을 잡아먹는 것이다. ★
=> 좀 더 좋은 방법이 없을까?


  리눅스에는 pause라는 시스템 함수가 있다. pause는 호출 즉시 프로그램을 재운 다음에 시그널을 받으면 깨운다. 즉, Signal 도달 전까지 재움으로써, 무한루프 방식에서의 불필요한 CPU 점유를 방지하는 것이다.

  허나, 우리가 pause를 SIGCHLD를 기다리는 용도로 쓰면, 예기치 않게 프로그램을 깨울 수도 있다. 가령, 이 프로그램 수행 중에 Ctrl+C를 눌렀고, 그것이 하필 Pause 중에 도달하는, 그런 상황이 있을 수 있다.

  또한, 앞선 Race 예시처럼, while (!pid) pause;와 같이 해놨을 경우, while과 pause 사이에 SIGCHLD가 도달해버리면, 프로그램이 그냥 Hanging하게 된다. (Race 발생 가능)

~> pause를 쓰면 CPU를 아낄 수 있는건 확실한데, 좀 더 안전하게 쓸 순 없을까?


이때, 우리는 아래와 같은 sigsuspend라는 함수를 도입한다.

int sigsuspend(const sigset_t *mask) {
	sigprocmask(SIG_BLOCK, &mask, &prev);
	pause();
	sigprocmask(SIG_SETMASK, &prev, NULL);
}	// 이 세 명령을 Atomic하게, Un-Interruptable하게 수행한다.

~> pause하는 중간에 의도치 않은 시그널이 들어와 Interrupt하는 것을 막아준다.
=> 아래와 같이 코드를 수정할 수 있다.

int main(int argc, char **argv) {
	sigset_t mask, prev;
    Signal(SIGINT, sigint_handler);
	Signal(SIGCHLD, sigchld_handler);
	Sigemptyset(&mask);
	Sigaddset(&mask, SIGCHLD);

	while (1) {
		Sigprocmask(SIG_BLOCK, &mask, &prev); /* Block SIGCHLD */
		if (Fork() == 0) 	/* Child */
		{ .... }
		
        /* Parent */
		pid = 0;
		while (!pid)
			Sigsuspend(&prev);	// 여기서 앞서 말한 문제를 방지한다.

		Sigprocmask(SIG_SETMASK, &prev, NULL);
		// 이후 동작
	}
	exit(0);
}

~> mask에다 SIGCHLD를 넣어놓고, mask를 Set으로 설정 후 fork를 띄운 것임.
~> fork 후 Parent에선, while로 들어가는데, Sigsuspend 수행 이전 시점까지는 mask에 의해 SIGCHLD를 받지 않음. (잠시 대기시켜놓는다.)
~> prev는 mask 이전에 있던 Signal에 대한 Information. 즉, 복구 Set.

sigsuspend가 수행되는 순간부터 대기시켜놓았던, 혹은 이제 곧 도착할 SIGCHLD를 받게 되는 것이다. by 복구 Set !!!

이렇게 새로운 Foreground Process Reaping 매커니즘을 고안해냈다. 그리고, 우리는 여기서 백그라운드 프로세스 Reaping까지 할 수 있다.

어떻게 할 수 있을까? 이미 답은 나와있다. 이에 대한 자세한 내용을 알고 싶은 경우, 본인의 MyShell 개발 결과물(github)에서 확인해보도록 하자.



  금일 포스팅은 여기까지이다.

0개의 댓글