mojo's Blog
Exceptional Control Flow: Signals and Nonlocal Jumps 본문
※ 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
핸들러는 위에서 본 것처럼 메인 프로그램과 동시에 실행되며 동일한 글로벌 데이터 구조를 공유하기 때문에 까다롭다.
(즉, 공유 데이터 구조가 손상될 수 있음)
따라서 이러한 문제를 해결하기 위해 몇가지 가이드라인을 살펴보도록 하자.
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 결과를 통해 알 수 있다.
'학교에서 들은거 정리 > 시스템프로그래밍' 카테고리의 다른 글
Concurrent Programming (0) | 2022.04.14 |
---|---|
Network Programming: Part II (0) | 2022.04.08 |
Network Programming: Part I (0) | 2022.04.01 |
System-Level I/O (0) | 2022.04.01 |
Exceptional Control Flow: Exceptions and Process (0) | 2022.03.17 |