Computer Science/컴퓨터 구조
[CSAPP] 8.5 Signal(시그널)
tgool
2023. 2. 22. 12:21
8.5 Signal(시그널)
- 지금부터 프로세스가 커널을 통해 다른 프로세스에게 인터럽트를 보내는 Linux signal로 알려진 ECF(Exceptional control flow)에 대해 알아 볼 것이다.
- signal은 시스템에서 특정 유형의 이벤트가 발생했음을 프로세스에 알리는 작은 메시지이다.
- 위 그림은 Linux 시스템에서 지원되는 30가지 유형의 signal이다.
- 각 signal 유형은 일종의 시스템 이벤트이다.
- low-level의 하드웨어 exception는 커널의 에외 핸들러에 의해 처리되며 일반적으로 사용자 프로세스에는 표시되지 않는다.
- 반면, signal은 예외의 발생을 사용자 프로세스에 노출하는 메커니즘을 제공한다.
- divide by zeros를 한 프로세스에서 수행할 경우, 커널이 SIGFPE signal(8)을 보낸다.
- 잘못된 명령을 실행하면, 커널이 SIGILL singal(4)을 보낸다.
- 프로세스가 잘못된 메모리 참조를 수행할 경우, SIGSEV signal(11)을 보낸다.
- 그 외 singal들은 더 high-level의 소프트웨어 이벤트에 해당.
- 프로세스가 foreground에서 실행되는 동안 Crtl+C를 누르면, 커널이 foreground 프로세스 그룹의 각 프로세스가 SIGINT(2)를 보낸다.
- 프로세스가 SIGKILL(9)를 보내서 다른 프로세스를 종료시킬 수 있다.
- 자식 프로세스가 종료되거나 중지되면 커널은 부모 프로세스에 SIGCHLD(17)을 전송한다.-
8.5.1 Signal Terminolog
- 시그널이 대상 프로세스에 전송되는 과정은 두 가지로 나뉜다
- 1. Sending a singal: 커널은 대상 프로세스의 context에서 일부 상태를 업데이트하여 대상 프로세스에 시그널을 보낸다. 이 시그널은 두 가지 유형으로 나뉜다.
- 1. 커널이 0으로 나누는 오류 또는 자식 프로세스의 종료 같은 시스템 이벤트를 탐지했을 때
- 2. 프로세스가 대상 프로세스에 신호를 전송하도록 커널에 명시적으로 요청하도록 kill 함수를 호출 했을 때.(자세한 건 다음 섹션에서 다룸)
- 2. Receving a signal: 대상 프로세스는 커널이 시그널 전달로부터 시그널을 수신한다. singal handler라는 유저 레벨의 함수를 호출하여 시그널을 무시하거나 종료하거나 시그널을 catch할 수 있다.
- 1. Sending a singal: 커널은 대상 프로세스의 context에서 일부 상태를 업데이트하여 대상 프로세스에 시그널을 보낸다. 이 시그널은 두 가지 유형으로 나뉜다.
- 위 그림은 시그널 핸들러가 시그널을 잡는 기본적인 과정을 보여준다.
- 1. 시그널이 수신되면, 2. 시그널 핸들러에게 컨트롤이 전환되고, 3. 시그널 핸들러가 작동하고, 4. 시그널 핸들러는 다음 instruction에 return한다. 그 후 시그널 처리가 완료되면 시그널 핸들러는 중단된 프로그램에 대한 제어 권한을 반환한다.
- Pending signal(보류 시그널): 시그널이 전송되었지만 아직 수신되지 않은 시그널.
- 동일한 유형의 보류 시그널은 누적되지 않는다. (이미 같은 유형의 pending 시그널이 존재한다면 새로 들어온 같은 유형의 시그널은 폐기된다)
- 프로세스는 특정 유형의 시그널의 수신을 차단할 수 도 있다.
- 시그널이 차단되면 시그널을 전송할 수는 있지만 시그널 수신 차단을 해제할 때까지 pending 중인 신호는 수신되지 않는다.
- 커널은 Pending 비트 벡터에 pending 시그널들을 저장한다.
- 예를 들어, k 유형의 시그널이 전송되면 pending 비트 벡터에서 비트 k를 설정하고, 시그널이 수신되면 pending 비트 벡터에서 비트 k를 지운다.
8.5.2 Sending Signals
- 유닉스 시스템은 프로세스에 시그널을 보내기 위한 여러 매커니즘을 제공. 이 매커니즘은 Process gruop의 개념에 의존.
Process Groups
- Process group(프로세스 그룹): 모든 프로세스는 양의 정수 PGID(Process group ID)로 식별되는 정확히 하나의 프로세스 그룹에 속하게 된다. getpgrp 함수는 현재 프로세스의 PGID를 반환하는 함수이다.
#include <unistd.h>
pid_t getpgrp(void);
/*Returns: process group ID of calling process*/
- 자식 프로세스는 부모 프로세스와 동일한 그룹의 프로세스 그룹에 속하게 된다.
- setpgid 함수를 통해 자신 또는 다른 프로세스의 프로세스 그룹을 변경할 수 있다.
#include <unistd.h>
int setpgid(pid_t pid, pid_t pgid);
/*Returns: 0 on success, −1 on error*/
- setpgid 함수는 pid 프로세스의 프로세스 그룹을 pgid로 변경한다.
- pid가 0이면 현재 프로세스의 PID가 이용된다. pgid가 0이면 pid로 지정된 프로세스의 PID가 프로세스 그룹 ID에 사용된다.
- 예를 들어, 현재 프로세스의 ID가 15213일 때, setpgid(0, 0);가 수행되면 프로세스 그룹 ID가 15213인 새 프로세스 그룹을 생성하고 이 새 그룹에 프로세스 15213을 추가한다.
Sending Signals with the /bin/kill Program
- /bin/kill 프로그램은 다른 프로세스에 임의의 시그널을 보낸다.
- 예를 들어, command인 linux> /bin/kill -9 15213의 경우 프로세스 15213에 시그널 9(SIGKILL)을 전송한다. PID가 음수이면 시그널이 프로세스 그룹 PID의 모든 프로세스로 전송된다.
- 예를 들어, linux> /bin/kill -9 -15213의 경우 프로세스 그룹 15213의 모든 프로세스에 SIGKILL 신호를 전송한다.
- 일부 유닉스 셸에는 자체 kill commnad가 내장되어 있기 때문에 여기서 전체 경로 /bin/kill을 사용합니다.
- 예를 들어, command인 linux> /bin/kill -9 15213의 경우 프로세스 15213에 시그널 9(SIGKILL)을 전송한다. PID가 음수이면 시그널이 프로세스 그룹 PID의 모든 프로세스로 전송된다.
Sending Signals from the Keyboard
- 유닉스 셀은 커맨드 라인을 평가한 결과 생성된 프로세스를 표현하기 위해 job의 추상화를 사용한다.
- 예를 들어, 커맨드 라인에 linux> ls | sort를 입력하면 Unix 파이프로 연결된 두 개의 프로세스(하나는 ls 프로그램을 실행하고 다른 하나는 정렬 프로그램을 실행)로 구성된 foreground 작업을 생성합니다. 셀은 각 job에 대한 별도의 프로세스 그룹을 생성하고 프로세스 그룹 ID는 job의 부모 프로세스 중 하나에서 가져온다.
- 예를 들어, 그림 8.28은 하나의 foreground job과 두 개의 background job이 있는 셸을 보여줍니다.
- foreground job의 부모 프로세스의 PID는 20이고 프로세스 그룹 ID는 20입니다. 부모 프로세스는 두 자식 프로세스를 생성했으며, 각 자식 프로세스는 프로세스 그룹 20의 구성원이기도 합니다.
- 키보드에 Ctrl+C를 입력하면 커널이 foreground 프로세스 그룹의 모든 프로세스에 SIGINT 신호를 전송합니다. 기본적으로 foreground job이 종료됩니다.
- 마찬가지로 Ctrl+Z를 입력하면 커널이 SIGTSTP 신호를 foreground 프로세스 그룹의 모든 프로세스에 전송합니다. 기본적인 경우 결과는 foreground job을 중지(일시 중단)합니다.
Sending Signals with the kill Function
- 프로세스는 kill 함수를 호출하여 다른 프로세스(자기 자신도 가능)로 시그널은 보낸다.
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
/*Returns: 0 if OK, −1 on error*/
- pid가 0보다 크면 kill 함수가 시그널 번호 sig를 process pid로 전송한다.
- pid가 0이면 kill은 호출 프로세스 자체를 포함하여 호출 프로세스의 프로세스 그룹에 있는 모든 프로세스에 시그널을 보낸다.
- pid가 0보다 작으면 kill은 프로세스 그룹 |pid|(pid의 절대값)의 모든 프로세스에 시그널을 보낸다.
#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);
}
- 위 코드는 차일드에게 SIGKILL 시그널을 보내기 위해 킬 함수를 사용하는 부모 프로세스의 예시이다.
- 부모 프로세스가 fork()를 통해 자식 프로세스를 생성하고(pid 변수에 자식 프로세스 pid가 담김) kill 함수를 통해 자식 프로세스에게 SIGKILL 시그널을 전달한다. 자식 프로세스는 SIGKILL 시그널을 부모 프로세스로 부터 수신하게 되면 출력문을 출력하고 종료된다. (고아 프로세스 고려 x)
Sending Signals with the alarm Function
- 프로세스는 alarm 함수를 호출하여 SIGALRM 시그널을 자신에게 전송할 수 있다.
#include <unistd.h>
unsigned int alarm(unsigned int secs);
/*Returns: remaining seconds of previous alarm, or 0 if no previous alarm*/
- 알람 함수는 커널이 호출 프로세스에 SIGALRM 신호를 초 단위로 전송하도록 설정된다.
- secs가 0이면 새 알람이 예약되지 않는다.
- 만약 이전에 설정된 알람이 존재한다면, 남아 있는 알람의 시간(초)를 반환 값으로 가지고 이전에 설정된 알람이 없다면 0을 반환한다.
8.5.3 Receiving Signal
- 시스템 함수 호출에서 복귀하는 등 컨택스트 스위치가 완료되었을 때(커널이 프로세스 p를 커널 모드에서 유저 모드로 전환할 때), 차단되지 않은 pending 중인 시그널 set(pending & ~blocked)을 확인한다.
- 만약 이 set이 비어있으면, 커널은 p의 논리적 제어 흐름에서 다음 명령(Inext)로 제어를 전달한다.
- 만약 이 set이 비어있지 않다면 커널은 이 set에서 일부 시그널 k(일반적으로 가장 작은 k)를 선택하고 p가 시그널 k를 선택하도록 명령한다.시그널을 수신하면 프로세스에 의해 일부 작업(action)이 트리거 된다.
- 프로세스가 작업(action)을 완료하면 제어는 p의 논리적 제어 흐름에서 다음 명령(Inext)로 제어를 전달한다.
- 각 시그널 유형에는 미리 정의된 기본 동작(action)이 존재한다.
- 1. 프로세스 종료
- 2. 프로세스가 종료되고 코어가 덤프된다.
- 코어 덤프(core dump), 메모리 덤프(memory dump), 또는 시스템 덤프(system dump), ABEND 덤프는 컴퓨터 프로그램이 특정 시점에 작업 중이던 메모리 상태를 기록한 것으로, 보통 프로그램이 비정상적으로 종료했을 때 만들어진다. 실제로는, 그 외에 중요한 프로그램 상태도 같이 기록되곤 하는데, 프로그램 카운터, 스택 포인터 등 CPU 레지스터나, 메모리 관리 정보, 그 외 프로세서 및 운영 체제 플래그 및 정보 등이 포함된다. 코어 덤프는 프로그램 오류 진단과 디버깅에 쓰인다. 이 명칭은 1950년대부터 1970년대 랜덤 액세스 메모리로 주로 쓰던 자기 코어 메모리에서 유래했다. 자기 코어 기술은 더 이상 쓰이지 않지만 그 명칭은 계속 쓰이고 있는 것이다. 많은 운영 체제는 프로그램에 치명적인 오류가 일어나면 자동으로 코어 덤프를 실행시키는데, 이를 "코어를 덤프한다"고 한다. 이 말의 의미가 확장되어, 많은 경우에 프로그램 메모리의 기록이 발생하는지 여부에 관계없이 생기는 치명적인 오류를 의미하게 되었다.
- https://ko.wikipedia.org/wiki/%EC%BD%94%EC%96%B4_%EB%8D%A4%ED%94%84
- 3. 프로세스는 SIGCONT 시그널에 의해 다시 시작될 때까지 중지(일시 중단)된다.
- 4. 프로세스가 시그널을 무시한다.
- 예를 들어, SIGKILL 수신에 대한 기본 작업은 수신 프로세스를 종료하는 것입니다. 반면, SIGHLD 수신에 대한 기본 동작은 시그널을 무시하는 것입니다. 프로세스는 아래와 같이 시그널 핸들러 함수를 사용하여 신호와 관련된 기본 동작을 수정할 수 있습니다. 유일한 예외는 기본 작업을 변경할 수 없는 SIGSTOP 및 SIGKILL입니다.
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
/*Returns: pointer to previous handler if OK, SIG_ERR on error (does not set errno)*/
- signal 함수는 다음 세 가지 방법 중 하나로 시그널의 동작을 변경할 수 있다.
- 핸들러가 SIG_IGN이면 특정 시그널 유형(signum)의 시그널이 무시됩니다.
- 핸들러가 SIG_DFL인 경우 특정 시그널 유형(signum)의 기본 작업으로 설정된다.
- 그렇지 않으면 핸들러는 프로세스가 특정 시그널 유형(signum)의 시그널이 수신할 때마다 호출되는 시그널 핸들러라고 하는 사용자 정의 함수의 주소이다.
- 핸들러의 주소를 signal 함수에 전달하여 기본 동작을 변경하는 것을 installing the handler라고 합니다.
- 핸들러의 호출을 catching the signal라고 합니다.
- 핸들러의 실행을 handling the signal라고 합니다.
- 핸들러가 반환 문을 실행하면 제어는 시그널 수신으로 인해 프로세스가 중단되었던 제어 흐름의 명령으로 다시 실행된다.
- 아래 코드는 사용자가 키보드에 Ctrl+C를 입력할 때마다 전송되는 SIGINT 시그널을 포착하는 프로그램을 보여준다.
#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의 기본 작업은 프로세스를 즉시 종료하는 것이다. 이 예에서는 기본 동작을 수정하여 시그널 수신하고 메시지를 출력한 다음 프로세스를 종료한다.
- 시그널 핸들러는 아래 그림 8.31과 같이 다른 핸들러에 의해 중단될 수 있다.
- 이 예에서 메인 프로그램은 시그널을 캐치하여(1) 메인 프로그램을 중단하고 핸들러 S로 제어를 이전한다(2). S가 실행되는 동안 프로그램은 시그널 t(!= s)를 캐치하여(3) S를 중단하고 핸들러 T로 제어를 전달한다(4). T가 리턴되면 (5) S는 중단되었던 위치를 다시 시작한다. 마지막으로, S가 리턴되고(6), 제어권을 메인 프로그램으로 다시 이전하며, 메인 프로그램은 중단했던 곳에서 다시 시작된다(7)
8.5.4 Blocking and Unblocking Signals
- 리눅스는 신호를 차단하기 위한 암묵적이고 명시적인 메커니즘을 제공한다.
- Implicit blocking mechanism: 기본적으로 커널은 현재 처리 중인 유형의 pending 중인 동일한 유형의 신호를 차단한다.
- Explicit blocking mechanism: 애플리케이션은 sigprocmask 함수를 사용하여 선택한 시그널을 명시적으로 차단 및 차단 해제할 수 있다.
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
/*Returns: 0 if OK, −1 on error*/
int sigismember(const sigset_t *set, int signum);
/*Returns: 1 if member, 0 if not, −1 on error*/
- sigprocmask 함수는 현재 차단된 시그널 set(the blocked bit vector)를 변경한다. 특정 동작은 다음 방법의 값에 따라 달라진다.
- SIG_BLOCK. Add the signals in set to blocked (blocked = blocked | set)
- SIG_UNBLOCK. Remove the signals in set from blocked (blocked = blocked & ~set).
- SIG_SETMASK. blocked = set
- oldset이 NULL이 아닌 경우 blocked bit vector 의 이전 값이 oldset에 저장된다.
- 시그널 set은 다음 함수를 사용하여 조작 가능:
- sigemptyset function: 시그널 set이 빈 set로 초기화된다.
- sigfillset function: 모든 signal를 추가합니다.
- sigaddset function: set에 signum을 추가하고,
- sigdelset function은 set에서 signum을 삭제하며
- sigismember function는 signum이 set의 멤버이면 1을 반환하고, set의 멤버가 아니면 0을 반환한다.
- 아래 코드는 sigprocmask 함수를 사용하여 SIGINT 시그널 수신을 일시적으로 차단하는 방법을 보여준다.
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)
8.5.5 Writing Signal Handlers(시그널 핸들러 작성)
- 시그널 핸들링은 리눅스 시스템 수준 프로그래밍에서 까다로운 측면 중 하나이다.
- 핸들러는 몇 가지 속성으로 인해 위와 같은 성격을 갖는다.
- 1. 핸들러는 메인 프로그램과 동시에 실행되고 동일한 글로벌 변수를 공유하므로 메인 프로그램 및 다른 핸들러와 간섭할 수 있다.
- 2. 시그널을 수신하는 방법과 언제 수신되는 지에 대한 규칙은 종종 직관에 어긋난다.
- 3. 시스템마다 핸들링 차이가 있을 수 있다.
- 이 섹션에서는 이러한 문제를 해결하고 safe, correct, portable signal handlers에 대해 각각 알아볼 것이다.
Safe Signal Handling
- 시그널 핸들러는 메인 프로그램과 서로 동시에 실행될 수 있기 때문에 까다롭다.
- 핸들러와 메인 프로그램이 동일한 글로벌 데이터 구조에 동시에 액세스하는 경우 결과는 예측할 수 없고 치명적일 수도 있다.
- 이러한 동시성 오류가 발생하게 되면 프로그램은 정상적으로 작동을 하지만 예측할 수 없는 결과가 발생하기 때문에 디버깅하기 매우 어렵다.
- 따라서 동시성 오류를 없애고 안전하게 시그널 핸들러를 작성하는 법에 대해 알아보자.
- G1. 핸들러의 기능을 가능한 간단하게 작성하라. ex) 핸들러는 전역 flag만 수정하고 바로 return하는 형식으로 작성하고 나머지 기능은 메인 프로그램에서 작동하게 한다.
- G2. 핸들러에서 async-signal-safe functions(비동기(동시x) 시그널 함수)만을 호출하라. ex) 아래 그림 8.33은 리눅스가 안전하다고 보장하는 시스템 함수들이다. 우리가 자주 사용하는 printf, sprintf malloc, exit는 아래 목록에 존재하지 않는다.
- 시그널 핸들러에서 출력을 안전하게 생성하는 유일한 방법은 write 함수를 사용하는 것이다.(10.1에서 다룰 것) 특히 printf 또는 sprintf를 호출하는 것은 안전하지 않다. 그래서 우리는 대신 Sio(Safe I/O) 패키지라고 불리는 몇 가지 안전한 함수를 사용한다.
#include "csapp.h"
ssize_t sio_putl(long v);
ssize_t sio_puts(char s[]);
/*Returns: number of bytes transferred if OK, −1 on error*/
void sio_error(char s[]);
/*Returns: nothing*/
- sio_putl: long 타입을 표준 출력
- sio_puts: string 타입을 표준 출력
- sio_error: 오류 메시지를 출력하고 종료
- 위 그림은 함수의 내부 구조이다. 내부에 작성된 함수에 대해 더 알아보자.
- sio_strlen 함수는 문자열의 길이를 반환
- sio_ltoa 함수는 v를 s 문자열 표현으로 변환
- _exit 함수는 exit의 비동기 시그널 안전 변형
- 아래 코드는 SIGINT 핸들러의 안전한 버전을 보여준다.
#include "csapp.h"
void sigint_handler(int sig) /* Safe SIGINT handler */
{
Sio_puts("Caught SIGINT!\n"); /* Safe output */
_exit(0); /* Safe exit */
}
- G3. errno를 저장하고 복원하자..
- 많은 리눅스 비동기 시그널 안전 함수들은 오류와 함께 반환될 때 errno를 설정한다. 핸들러 내부에서 이러한 함수를 호출하는 것은 errno에 의존하는 프로그램의 다른 부분을 방해할 수 있다.
- 해결 방법은 핸들러에 입력할 때 로컬 변수에 오류를 저장하고 핸들러가 리턴되기 전에 복원하는 것이다. 핸들러가 리턴되는 경우에만 필요하고 핸들러가 _exit를 호출하여 프로세스를 종료하는 경우에는 필요하지 않다.
- G4. 모든 시그널을 차단하여 공유 글로벌 데이터 구조에 대한 액세스를 보호하자.
- 핸들러가 기본 프로그램 또는 다른 핸들러와 글로벌 데이터 구조를 공유하는 경우 핸들러 및 메인 프로그램은 해당 글로벌 데이터 구조에 액세스(읽기 또는 쓰기)하는 동안 모든 시그널을 일시적으로 차단해야 한다.
- 이유는 일반적으로 메인 프로그램에서 구조화된 데이터에 액세스하려면 일련의 instruction이 필요하기 때문이다. 이 instruction sequence가 d에 액세스하는 핸들러에 의해 중단되면 예측할 수 없는 결과를 초래할 수 있다. 액세스하는 동안 시그널을 일시적으로 차단하면 핸들러가 instruction sequence를 중단하지 않게 된다.
- G5. Volatile로 글로벌 변수를 선언하자.
- 전역 변수 g를 공유하는 핸들러와 메인 함수를 생각해 보자. 핸들러가 g를 업데이트하고 메인 함수에서 주기적으로 g를 읽는다. 최적화 컴파일러에게 g의 값은 메인에서 절대 변하지 않는 것처럼 보일 것이고, 따라서 g에 대한 모든 참조를 만족시키기 위해 레지스터에 캐시된 g의 복사본을 사용하는 것이 효율적일 것이다. 이 경우 메인 함수는 핸들러에서 업데이트된 값을 볼 수 없게 된다. 변수를 Volatile 타입 한정자로 선언하여 컴파일러에게 캐시하지 않도록 지정할 수 있습니다.
- volatile 키워드는 변수를 Main Memory에 저장하겠다 라는 것을 명시.
- volatile의 기능적 의미는 캐시사용안함(no-cache)이다.
- 보통 프로그램이 실행될 때 속도를 위해 필요한 데이터를 메모리에서 직접 읽어오지 않고 캐시(cache)로부터 읽어온다. 하지만, 하드웨어에 의해서 변경되는 값들은 캐시에 즉각적으로 반영되지 않으므로 데이터를 캐시로부터 읽어오지 말고 주 메모리에서 직접 읽어오도록 해야한다. 이러한 특성 때문에 하드웨어가 사용하는 메모리는 volatile로 선언해야 하드웨어에 의해 변경된 값들이 프로그램에 제대로 반영된다.
- For example: volatile int g; volatile 한정자는 컴파일러가 코드에서 참조될 때마다 메모리에서 g의 값을 읽도록 강요
- 전역 변수 g를 공유하는 핸들러와 메인 함수를 생각해 보자. 핸들러가 g를 업데이트하고 메인 함수에서 주기적으로 g를 읽는다. 최적화 컴파일러에게 g의 값은 메인에서 절대 변하지 않는 것처럼 보일 것이고, 따라서 g에 대한 모든 참조를 만족시키기 위해 레지스터에 캐시된 g의 복사본을 사용하는 것이 효율적일 것이다. 이 경우 메인 함수는 핸들러에서 업데이트된 값을 볼 수 없게 된다. 변수를 Volatile 타입 한정자로 선언하여 컴파일러에게 캐시하지 않도록 지정할 수 있습니다.
- G6. sig_atomic_t로 플래그를 선언하자.
- 하나의 일반적인 핸들러 설계에서 핸들러는 글로벌 플래그에 기록함으로써 시그널의 수신을 기록한다. 메인 프로그램은 주기적으로 플래그를 읽고 시그널에 응답하며 플래그를 지운다. 이러한 방식으로 공유되는 플래그의 경우, C는 sig_atomic_t라는 정수 데이터 유형을 제공하며, 읽기와 쓰기가 하나의 명령으로 구현될 수 있기 때문에 원자적(atomic)임을 보장한다:
- Volitile sig_atomic_t flag; sig_atomic_t 변수는 interrupt될 수 없으므로 시그널을 일시적으로 차단하지 않고 안전하게 sig_atomic_t 변수에서 읽고 쓸 수 있다. 원자성 보장은 single 읽기 및 쓰기에만 적용된다. flag++ 또는 flag = flag + 10과 같은 업데이트에는 적용되지 않는다. 이 업데이트에는 multiple instruction이 필요하기 때문이다.
Correct Signal Handling
- 같은 타입의 pending signal은 한 개만 존재할 수 있다.
- 왜냐하면 the pending bit vector은 정확히 각 타입에 대해 1비트만을 포함하기 때문이다.
- 그래서 만약에 한 프로세스가 현재 시그널 k에 대해 시그널 핸들러가 실행되고 있을 때 같은 유형 k인 시그널이 두 번 전송되면, 한 시그널은 폐기된다.
- SIGCHLD 시그널을 예시로 들어보자.
- SIGCHLD 시그널은 부모 프로세스가 자식 프로세스를 fork()를 통해 생성한 후 reap하기 위해 자식 프로세스가 종료되었음을 알리는 시그널이다.
- 만약 자식 프로세스가 종료되고 reap이 되지 않는다면 좀비 프로세스로 남게 된다.
- 좀비 프로세스는 메모리를 차지하지 않고 디스크 용량, IO, CPU 시간 또한 전혀 차지 하지 않으므로 별 문제가 되어 보이지 않지만 이들은 프로세스 테이블의 용량을 차지한다.
- 자식 프로세스를 많이 만들어내고 이 프로세스들이 모두 좀비 프로세스가 되는 경우라면 프로세스 테이블의 용량이 꽉차 이후 프로세스 테이블을 사용할 수없기 때문에 좀비 프로세스는 만들지 않는 것이 좋다.
- 부모 프로세스는 자식 프로세스를 생성한 후 독립적으로 작업을 수행하고 있어야 하기 때문에, 자식 프로세스가 종료될 때까지 무작정 기다릴 수는 없다.
- 따라서 자식 프로세스는 SIGCHLD 시그널을 보내고 부모 프로세스는 SIGCHLD 핸들러를 통해 자식 프로세스를 reap할 수 있게 한다.
- 아래 그림은 구체적인 예시이다.
- 부모 프로세스는 SIGCHLD 핸들러를 설치하고 fork()를 통해 세 개의 자식 프로세스를 생성한다.
- 각 자식이 종료되면 커널은 SIGCHLD 시그널을 부모 프로세스에게 보낸다.
- 부모는 SIGCHLD 시그널을 캐치하고 자식 프로세스를 reap한다.
# include <sys/types.h>
# include <sys/wait.h>
pid_t waitpid(pid_t pd, int *status, int options);
- waitpid는 비블록 함수, 성공시 종료된 자식 프로세스 ID(경우에 따라 0), 실패시 -1 리턴
- pid : 종료 확인을 원하는 자식 프로세스 ID, 임의의 자식 프로세스인 경우 -1 대입.
- sstatus: wait 함수의 status 와 같은 역할
- options: sys/wait.h에 정의된 ‘WNOHANG’ 상수를 인자로 전달하게 되면 이미 종료한 자식 프로세스가 없는 경우 blocking 상태로 가지않고 바로 리턴함. 이때 waitpid 함수의 리턴값은 0이 된다.
- 과연 세 개의 자식 프로세스가 모두 reap 되었을까?
- 결과는 다음과 같다.
- 세 개의 SIGCHLD 시그널이 부모에게 전송되었지만, 이 시그널 중 두 개만 수신되었고, 따라서 부모는 두 명의 자식 프로세스 reap 했다.
- 한 개의 자식 프로세스는 좀비 프로세스로 남게 된다.
- 첫번째 SIGCHLD 시그널은 캐치 되었고 핸들러가 첫번째 SIGCHLD 시그널을 처리하는 동안 두번째 SIGCHLD 시그널이 pending 되었다.
- 그리고 나서 세번째 SIGCHLD 시그널이 전송되었을 때, 이미 같은 pending 시그널이 존재하기 때문에 세번째 SIGCHLD 시그널은 폐기된다.
- 이 문제를 해결하기 위해서는 SIGCHLD 핸들러를 다음과 같이 수정해야 한다.
void handler2(int sig)
{
int olderrno = errno;
while (waitpid(-1, NULL, 0) > 0) {
Sio_puts("Handler reaped child\n");
}
if (errno != ECHILD)
Sio_error("waitpid error");
Sleep(1);
errno = olderrno;
}
- while 문을 추가하였다.
- waitpid()의 리턴값은 성공적으로 리턴받은 자식의 pid(>0)값을 리턴하는데 while(waitpid()>0)구문을 통해 자식 프로세스가 동시에 리턴값을 부모에게 줘도 반복해서 받을 수 있기 때문에 모든 자식의 리턴값을 받을 수 있다.
- 추가로 waitpid에 세번째 인자로 WNOHANG을 넣으면 , 기다리는 자식의 프로세스가 종료되지 않아 리턴값을 받을 수 없는 상황일때 블록 함수인 wait()처럼 기다리지 않고 바로 0을 반환한다.
- 따라서 부모는 waitpid()가 호출될때 wait()처럼 자식의 리턴값을 받을때까지 기다리지 않아도 되므로 자신의 일을 수행할 수 있게된다. 즉 병행 작업이 훨씬 원활히 이루어지게 된다.
Portable Signal Handling
- unix 시그널 핸들링의 또 다른 단점은 시스템마다 시그널 핸들링 semantic이 다르다는 것이다.
- 따라서 시스템마다 시그널 함수의 semantic은 다양하다는 문제가 있다.
- 예시는 다음과 같다.
- 일부 오래된 유닉스 시스템은 핸들러에 의해 시그널 k가 캐치된 후 시그널 k에 대한 동작을 기본값으로 복원한다.
- 이러한 시스템에서 핸들러는 시그널이 캐치될 때마다 핸들러를 명시적으로 재설치 해야한다.
- 시스템 콜이 중단될 수 있다.
- 잠재적으로 프로세스를 장기간 block될 수 있는 read, wait, accept 같은 시스템 콜을 slow system calls이라 부른다.
- 일부 오래된 유닉스 버전에서 핸들러가 시그널을 캐치할 때, interrupt되는 느린 시스템 콜은 시그널 핸들러가 리턴된 후 재개되지 않고 오류 상태와 함께 사용자에게 즉시 오류값만을 리턴한다.
- 이러한 시스템에서 프로그래머는 interrupt된 시스템 콜을 수동으로 다시 시작하는 코드를 포함해야 한다.
- 일부 오래된 유닉스 시스템은 핸들러에 의해 시그널 k가 캐치된 후 시그널 k에 대한 동작을 기본값으로 복원한다.
- 이러한 semantic 문제를 해결하기 위해 Posix 표준은 사용자가 핸들러를 설치할 때 원하는 시그널 핸들링 semantics을 명확하게 지정할 수 있도록 하는 sigaction 함수를 정의한다.
#include <signal.h>
int sigaction(int signum, struct sigaction *act,
struct sigaction *oldact);
/*Returns: 0 if OK, −1 on error*/
- sigaction 함수는 사용자가 복잡한 구조의 항목을 설정해야 하기 때문에 다루기 힘들다.
- W. Richard Stevens가 제안한 더 분명한 접근법은 signal이라는 래퍼 함수를 정의하여 sigaction을 호출하는 것이다.
- 위 그림 8.38은 sigaction 함수를 호출하는 래퍼 함수 signal의 정의를 보여준다.
- wrapper 함수 signal은 다음과 같은 signal handling semantics를 가진 signal handler를 설치합니다:
- 핸들러에서 현재 처리 중인 유형의 신호만 차단됩니다.
- 모든 시그널 구현과 마찬가지로 시그널은 큐에 저장되지 않습니다.
- interrupt된 시스템 콜은 가능할 때마다 자동으로 다시 시작됩니다.
- 시그널 핸들러가 설치되면, signal의 인수가 SIG_IGN 또는 SIG_DFL 핸들러의 인수로 호출될 때까지 설치된 상태로 유지됩니다.
- SIG_IGN : 시그널을 무시한다.
- signal(SIGINT, SIG_IGN);
- CTRL+C 키가 아예먹지 않는걸 확인할 수 있을 것이다.
- SIG_DFL : 기본행동을 하도록 한다.
- 그렇다면 SIG_DFL은 언제 사용되는가 ?
- 별도의 시그널제어 함수를 사용하지 않는다면, 시그널에 대해서 기본행동을 하도록 되어 있는데, 사용할 필요가 있는가 하는 의문이 생길 수 있을 것이다.
- fork(2)를 이용해서 자식프로세스를 생성하면, 자식프로세스는 부모의 시그널정책까지를 그대로 복사해서 사용하게 된다. 즉 부모의 특정 시그널에 정책이 SIG_IGN 이였다면, 자식도 그대로 그 정책을 따른다. 때로, 자식의 시그널 정책을 달리할 필요가 있을 것이다. 이 경우 사용할 수 있다.
- SIG_IGN : 시그널을 무시한다.
8.5.6 Synchronizing Flows to Avoid Nasty Concurrency Bugs
- 같은 storage(memory등) 위치에 읽고 쓰고 하는 concurrenct flows을 프로그래밍 하는 방법의 문제는 컴퓨터 과학자들에게 도전 과제였다.
- flows의 잠재적 interleaving의 개수는 명령어 개수에 따라 기하급수적으로 는다.
- 이런 interleaving 중 일부는 정답이지만 일부는 아니다.
- (interleaving은 순서없이 데이터를 쓰고 읽는 방법이라고 한다.)
- 이 근본적인 문제는 각 실행 가능한 interleaving들이 정답을 만들 수 있는 가장 큰 실행 가능한 interleaving의 집합을 허용하기 위해 concurrent flows를 어떻게든 동기화 하는 것이다.
- concurrent programming은 12장에서 배울 것이지만 이 장에서 exceptional control flow에 대해 배운 것을 사용해서 concurrency와 관련된 흥미로운 문제에 대해 알아볼 수 있다.
- 예를 들어 아래 프로그램을 보자.
- 그림 8.39 미묘한 동기화 오류가 있는 shell 프로그램.
- 부모가 실행되기 전에 자식이 종료되면 addjob 및 deletejob이 잘못된 순서로 호출된다.
- 부모 프로세스는 전역 job list의 entry를 사용해서 현재 자식 프로세스를 추적한다.
- 한 entry 당 한 작업 (프로세스)가 있다.
- addjob, deletjob 함수는 job list에서 entry를 추가하고 제거하는 함수다.
- 부모 프로세스가 새 자식 프로세스를 만든 후 job list에 자식 프로세스를 추가한다.
- 부모 프로세스가 SIGCHLD signal handler에서 종료된 자식 프로세스를 받으면 (자식이 종료 되었다고 신호 받는다는 의미 같다) 부모는 job list에서 자식 프로세스를 제거한다.
- 대충 보면 이 코드가 맞는 것 같지만 아래와 같은 일들이 발생할 수 있다.
- 부모가 fork 함수를 실행하고 kernel이 생성된 자식이 부모 대신 실행되도록 예약한다.
- 부모가 다시 실행되기 전에 자식이 종료되고 kernel이 SIGCHLD 신호를 부모에게 전달한다.
- 나중에 부모가 다시 실행될 수 있지만 아직 실행이 안 되었을 때 kernel은 SIGCHLD 신호를 인식하고 부모에게 signal handler를 실행시켜 그 신호를 받도록 한다.
- signal handler가 종료된 자식을 받고 deletejob 함수를 호출하지만 아무 것도 없다. 왜냐하면 부모가 자식을 list에 아직 넣지 않았기 때문이다. (넣기도 전에 자식이 종료됨)
- handler가 끝난 후 kernel은 부모를 실행시키는데 이 부모는 이제 fork 함수에서 돌아와서 addjob 함수를 호출해서 존재하지 않는 자식을 job list에 잘못 추가한다.
- 즉 부모의 main 루틴과 signal handling flow의 일부 interleaving에 대해 addjob 전에 deletjob이 먼저 호출 될 수 있다.
- 이렇게 되면 job list에 존재하지도 않고 절대 제거되지도 않는 job을 위해 잘못된 entry가 생긴다.
- 반면에 이벤트가 올바른 순서로 진행되는 interleavings가 있다.
- 예를들어 kernel의 fork 호출이 return되고 자식 말고 부모를 실행하도록 예약하면 아래처럼 올바르게 된다.
- 부모가 자식을 올바르게 job list에 추가하고 자식이 종료되고 signal handler가 자식을 job list에서 올바르게 제거한다.
- 이것은 race condition이라고 알려진 classic synchronization error의 예시다.
- 이 경우 메인 루틴에서 addjob을 호출하는 것과 handler에서 deletejob을 호출하는 것 사이의 race인 것이다.
- addjob이 race를 이기면 정답이 되지만 지면 오답이 된다.
- 이런 error는 디버깅하기 매우 어려운데 왜냐하면 모든 interleaving에 대해서 테스트하는 것이 불가능하기 때문이다.
- 문제없이 코드를 수십억번 실행할 수 있지만 그 다음 test에서 race가 실행되는 interleaving이 발생할 수 있기 때문이다.
- 아래 코드는 위의 코드에서 race condition을 제거할 수 있는 방법 하나를 보여준다.
- 그림 8.40 sigprocmask를 사용하여 프로세스 동기화.
- 이 예에서 부모는 해당하는 deletejob 전에 addjob이 실행되도록 한다.
- fork를 호출하기 전에 SIGCHLD 신호를 block하고 addjob을 호출한 후에 unblock해서 자식이 job list에 추가된 후에 종료되고 받을 수 있게 한 것이다.
- 자식은 부모의 blocked set을 상속받으므로 자식이 execve를 호출하기 전에 자식에서 SIGCHLD를 unblock하도록 주의해야한다.
8.5.7 Explicitly Waiting for Signals
- 종종 main 프로그램이 특정 signal handler가 실행될 때까지 기다려야 하는 경우가 있다.
- 예를 들어 Linux shell이 foreground job을 만들면 다음 user command를 받기 전에 shell은 그 job이 종료되고 SIGCHLD handler에게 처리되어 받는 것을 기다려야 한다.
- (즉 job이 종료되고 처리되어야 다음 user command를 받을 수 있다는 것 같다. 왜냐하면 foreground job이기 때문에)
- 아래 그림은 기본 아이디어를 보여준다.
- 위 그림 8.41 spin loop가 있는 신호 대기.
- 이 코드는 정확하지만 spin loop는 낭비입니다.
- 부모는 SIGINT, SIGCHLD에 대한 handler를 설정하고 무한 루프에 들어간다.
- 위에서 말한 race condition을 피하기 위해서 SIGCHLD를 block한다.
- 자식을 생성하고 부모의 PID를 0으로 재설정하고 SIGCHLD를 unblock하고 PID가 0이 아닐 때까지 루프에서 기다린다.
- 자식이 종료되고 난 후에 handler가 자식을 처리하고 전역 변수 pid에다가 0이 아닌 PID를 넣는다.
- 이렇게 되면 루프가 종료되고 부모가 다음 루프에 들어가기 전까지 추가 작업을 한다.
- 이 코드는 문제는 없지만 spin loop는 processor resource를 낭비한다.
- 우리는 이 문제를 아래처럼 spin loop 안에 pause를 삽입해서 해결하고 싶다.
- 루프가 계속 필요한 이유는 pause가 SIGINT signals에 의해 interrupted 될 수 있기 때문이다.
- 하지만 이렇게 pause하면 심각한 race condition에 빠질 수 있다.
- 만약에 SIGCHLD가 while의 조건문을 통과하고 pause 함수 전에 전달되면 pause 함수는 영원히 잠들게 된다.
- 다른 해결 방법으로 pause를 sleep 함수로 바꾸는 것이다.
- 이 코드도 문제는 없지만 너무 느리다.
- 역시 while 조건문 통과하고 sleep 함수 전에 신호가 전달되면 프로그램은 다시 while 조건문 다시 체크할 때까지 오랜시간 기다려야 한다.
- 아주 nanosleep 같은 짧은 시간의 sleep 함수를 쓰는 것도 똑같은데 왜냐하면 sleep 하는 시간을 정하는 좋은 규칙 같은 것도 없다.
- 시간을 너무 짧게 하면 loop를 너무 많이 돌면서 낭비하고 너무 길게하면 위처럼 프로그램이 너무 오래 걸린다.
- 좋은 해결책은 아래처럼 sigsuspend를 사용하는 것이다.
- (sigsuspend 함수는 signal block 설정함과 동시에 signal이 도착할 때까지 중단.)
- sigsuspend 함수는 현재 blocked set를 mask(block할 signal 집합)로 교체하고 handler를 실행시키거나 프로세스를 종료하는 signal이 올 때까지 프로세스를 suspend한다.
- 프로세스를 종료하는 signal이 오면 프로세스는 sigsuspend에서 return하지 않고 종료한다.
- handler를 실행시키는 signal이 오면 handler가 끝나고 sigsuspend에서 return한다.
- 그리고 sigsuspend가 호출되었을 때의 blocked set으로 돌려 놓는다.
- sigsuspend 함수는 아래의 atomic 버전인 것이다.
- atomic 하다는 것은 1 line에 sigprocmask과 2 line의 pause 함수가 중단되지 않고 함께 발생한다는 것이다.
- 이렇게 되면 sigprocmask 호출하고 pause 호출되기 전에 signal이 오는 잠재적인 race condition이 제거된다.
- 아래 그림은 위의 spin loop를 낭비하는 프로그램을 대체해서 sigsuspend를 사용하는 프로그램 보여주는 것이다.
- sigsuspend를 호출하기 전에 SIGCHLD가 차단된다.
- sigsuspend는 SIGCHLD를 잠시 unblock하고 parent가 signal을 받기 전까지 sleep한다.
- 반환되기 전에 sigsuspend는 원래 SIGCHLD를 block하던 blocked set을 돌려놓는다.
- 만약에 부모가 SIGINT 신호를 받으면 while문의 조건문이 true가 되고 다음 반복에서 다시 sigsuspend를 호출한다.
부모가 SIGCHLD 신호를 받으면 조건문이 false가 되고 while(!pid)를 나간다.
이때 SIGCHLD가 block되어 있으니까 선택적으로 SIGCHLD를 unblock할 수 있다.
(??)
이것은 실제 shell에서 background job을 반환해야 할 때 유용하다.
이 sigsuspend 버전은 원래 spin loop 도는 것보다 낭비가 적고 pause해서 race condition을 피하고 sleep 함수보다 더 효과적이다.