SP - 1.5 Fundamentals of Shell & Signal

hyeok's Log·2022년 3월 24일
3

SystemProgramming

목록 보기
5/29
post-thumbnail

Process Hierarchy

  Linux에서 '프로세스 계층도(Process Hierarchy)'는 다음과 같은 형태를 띈다.

~> Linux 시스템에서 pstree라는 명령을 통해 이 Process Hierarchy 트리 구조를 선형적인 형태로 확인할 수 있다.
~> 맨 위 [0]에서 시작해, init Process가 단 하나의 Child로 존재하고, 거기서부터 트리가 뻗어나가고 있음을 확인할 수 있다. 무엇으로? fork로!


Shell

쉘(Shell) : 사용자 대신, '프로그램을 실행'시켜주는 응용 프로그램이다.

~> command를 읽어서 execute하는 행위를 계속 반복한다.
(Execution is a sequence of read-evaluate steps!)


  • 쉘의 종류
    • sh : UNIX의 Shell이다.
    • csh/tcsh : UNIX의 C기반 Shell이다.
    • bash : LINUX의 기본 쉘로, 'Bourne-Again Shell'의 줄임말이다. ★

Shell의 main & eval

  • 쉘은 아래와 같이 매우 간단한 형태의 메인 함수로 이루어져 있다. (실제 우리가 다루는 상용 쉘은 이보다 훨씬 복잡한 형태를 지닌다. 일례로, 아래는 백그라운드 프로세스 처리가 없다. 단순히 학습용으로 간단화시킨 것이다.)
int main(void) {
	char cmdline[MAXLINE]; 					/* command */

	while (1) {
		printf("> ");
		Fgets(cmdline, MAXLINE, stdin);		// 1. 명령 라인을 읽고
		if (feof(stdin))
			exit(0);

		eval(cmdline);						// 2. 명령을 Evaluate!
	}										// 3. 이를 계속 반복
}

  메인 함수의 eval함수는 아래와 같은 구조를 갖추고 있다. (PIPE와 Redirection, Signal 처리 기능이 없는, 가장 간단한 형태의 eval함수이다)

void eval(char *cmdline) {
	char *argv[MAXARGS]; 			/* Argument List */ 
	char buf[MAXLINE]; 				/* Temporary Buffer */ 
	int bg; 						/* Foreground Or Background */ 
	pid_t pid; 						/* Process ID */

	strcpy(buf, cmdline); 			// cmdline을 버퍼에 복사
	bg = parseline(buf, argv); 		// 버퍼를 argv 배열로 parsing하여 삽입

	if (argv[0] == NULL)			// 명령이 없을 때 : 그냥 리턴
		return; 		

	if (!builtin_command(argv)) { 
		if ((pid = Fork()) == 0) { 	// child process 생성, child 부분
			if (execve(argv[0], argv, environ) < 0) { 	// 새 프로세스에 덮어쓰기
				printf("%s: Command not found.\n", argv[0]);	// 명령 오류 상황
				exit(0); 
			} 
		}

		// Parent Process는 Foreground로 실행된 Child Process의 종료를 기다림!
		if (!bg) { 				// Child가 Foreground Process인 경우
			int status; 
			if (waitpid(pid, &status, 0) < 0) 	// 해당 Child로부터의 종료 Signal 대기 
				unix_error("waitfg: waitpid error"); 
		}
		else				
			;  // Child가 Background Process인 경우에 대한 처리는 없는 상태 (간단한 버전)
        
	return; 
}
  • parseline 함수 : cmdline을 복사한 buf를 받아, 이를 strtok 함수를 이용해 token들로 잘라 argv 배열에 하나 하나 삽입한다.
    • return값을 bg라는 정수 변수에 반환한다.
      • bg는 cmdline 끝에 '&'가 붙었는지를 판단한다. (백그라운드 여부 판단)
      • ex) ">ls -al &"와 같은 명령은 프로세스를 Background로 실행하라는 것이다.

  • &가 붙지 않은 cmdline은 입력받은 argv[0]을 Foreground로 수행하라는 것이다.
    • fork로 Child Process를 만들고, execve로 argv[0]에 해당하는 프로그램을 덮어씌운다.
    • Foreground인 경우, 생성된 프로세스가 끝날 때까지 Parent는 waitpid를 호출해 기다린다.
      • Child가 종료되었다는 Signal SIGCHILD가 도착하면 OS에게 reaping을 요청한다.

  • bg값이 0(False)이면 Foreground 상황, 0이 아닌 값, 즉, True이면 Background 상황인 것이다.

Pipeline (Inter-Process Communication)

  • 다음과 같은 Pipelining Command Line이 입력될수있다.

    ">ls -al | grep root | tail -5"
    (의미 : ls의 출력에서 root라는 단어가 포함된 라인만 뽑아 아래서부터 5줄까지만 출력한다.)

~> 이 명령의 경우, Shell Process가 ls와 grep, tail 프로그램을 각각 생성한다.

~> 그리고, 파이프(|) 앞의 ls의 output이 파이프 뒤의 grep의 input으로 들어가고, 마찬가지로 grep의 output이 tail의 input으로 들어간다.

~> 이를, 파이프라이닝(Pipelining)이라 하고, 이러한 기능을 'IPC(Inter-Process Communication)'이라 한다.
=> 복수의 프로세스 간의 소통을 가능케 하는 기능이다.
=> 위의 eval 예제 코드에는 이 기능이 지원되지 않는다.

  • Shell이라는 Parent의 Child로 ls, grep, tail 프로세스가 생기는데, 이들은 모두 동일한 Parent Process 주소를 가지지만, 자신들의 주소는 별도로 존재하고 있다.
    • OS가 이 별도의 세 프로세스가 소통할 수 있게 IPC 기능을 제공하는 것이다. ★
    • 그러한 Support 중 하나가 PIPE인 것이다.

  • Pipe는 '단방향 Byte Stream'이다.
    • "Pa|Pb"이면, Pa 프로세스가 Pb 프로세스에게 데이터를 보낼 수 있다.
    • Pa와 Pb는 서로 별도의 주소에 있는 프로세스이므로, 이 PIPE를 통해 데이터를 넘겨주는 것이다.
    • Pa의 output이 Pb의 input으로 들어간다.
      • 즉, 논리적인 관점에서 Pipe는 FIFO(First In, First Out)의 Queue인 것이다.
    • 이때, 데이터를 그냥 보낼 뿐, 데이터에 대한 상세 정보, 예컨대 Size같은 것은 Sender나 Receiver나 알지 못한다. 그저 Byte Stream일 뿐!

int main(void) {
	int n, fd[2], pid; 			// fd는 File Descriptor이다. 총 2개의 파일지시자
    char line[100];				// fd[0]은 input file, fd[1]은 output file을 의미

	if (pipe(fd) < 0) exit(-1);	// pipe 함수는 두 데이터 스트림을 열어 연결!
    
	if ((pid = fork()) < 0) exit(-1);
	else if (pid > 0) { 		// parent process
		close(fd[0]);			// input file은 닫는다.
		write(fd[1], "Hello World\n", 12);	// output file에 "Hello World" 출력
		wait(NULL);				// child termination을 기다린다.
	}
	else {						// child process
		close(fd[1]);						// output file은 닫는다.
		n = read(fd[0], line, MAXLINE);		// input file을 읽어 line에 저장
		write(STDOUT_FILENO, line, n);		// line을 출력하고 종료한다.
	}
}
  • 자세한 설명은 주석에 있다.

  • Parent는 "Hello World"라는 문자열을 Pipe에 집어넣는다. (Parent's output)

    • Child의 종료를 wait으로 기다린다.
  • Child는 read를 통해 Pipe에서 나오는 output file의 "Hello World"를 읽어서, line 문자열에 집어넣고, 이를 출력한다.

    • 동작 수행 후 종료하고, reaping된다.

  • Shell Program에서 사용하는 '제대로된 Pipelining'에 대한 설명은 생략하겠다. 참고로, 본인은 실제 Linux-like Shell 개발 Project를 진행하였고, Pipeline 구축 과정에서 Recursive 구조로 구현하였는데, 자세한 Code-Level Detail과 이론적 배경을 알고 싶은 경우, 본인의 github에 있는 'MyShellProject' Repository에서 확인하자.

Background 처리

  • 쉘에서 백그라운드 명령을 입력해보면, 아무것도 화면에 표시되지 않고, 다시 쉘이 입력을 받는다.
    • 백그라운드로 프로세스를 수행하는 것이다.
      • 물론, 프로세스의 수행이 눈에 보일 정도로 오래 걸려야 이를 느낄 수 있다. ls와 같이 빨리 끝나는 프로그램은 백그라운드로 수행해도 사실 포그라운드처럼 보인다.

위의 eval함수를 보면 알 수 있지만, Child가 Background 프로세스일 때는, Foreground일 때와 다르게, Parent가 wait으로 Child를 reaping하지 않는다.

Parent가 wait하지 않는 이유 : 그것이 백그라운드 본연의 의미이기 때문이다. 백그라운드로 Child를 수행하니, Parent(Shell)는 다시 Foreground로 나와 늘 그렇듯이 다시 명령을 받는다.

Wait을 하면 Shell이 Suspend되고(다른 명령을 못받음), 그것은 백그라운드의 의미와 맞지 않는다!!!

Background Process의 경우, 특별한 과정을 통해 처리한다. (하단 설명)


  • Background Job
    • Reaping되지 않으면 역시나 Zombie Process가 된다. (부모인 Shell Process 자체를 죽여 Init이 처리하지 않는 이상)
      • Zombie가 되면 '메모리 누수(Memory Leakage, 커널의 메모리 초과)'와 '보안 취약(Security Hole)'의 가능성이 높아진다.

ex) ">ls -al &"
~> Background Handling을 해주지 않으면, Parent는 fork 이후 다시 Foreground로 계속 돌기 때문에 Child Process가 죽지 않고 Zombie로 남게 된다.
~> Parent는 Background 입력 시, 백그라운드 본연의 의미를 살리기 위해 wait을 하지 않고 바로 다시 Foreground로 돌아간다.

=> 그렇다면, 백그라운드 프로세스는 어떻게 Reaping해야할까?

ECF(Exceptional Control Flow)가 이를 해결해준다!
~> ECF가 Signal을 이용해 이 'Background Process Reaping' 문제를 해결해준다.

  이는, 아래서 다룰 Signal을 학습하면서 차차 확인하겠다.



Signal

Signal : OS Kernel이 제공하는 알람 시스템, Alert Mechanism이다.

Signal : 시스템에서 프로세스에게 '특정한 이벤트가 발생했음'을 알리는 Small Message이다.

  • 초반에 다룬 Exception, Interrupt 이런 개념과 유사하다.

  • Kernel이 Process에게 Signal을 보낸다.

    • Kernel이 스스로 보내거나,
    • 혹은 다른 Process의 요청에 의해 보내거나, ex) Child Process Reaping

  • 시그널은 1부터 30까지의 정수로 이루어진 ID로 구분된다.

  • Signal은 'Signal ID'와 '도착 정보'만을 포함한다. ★


Signal Types

ID		Name		Default Action		관련 Event
2		SIGINT		Terminate			Ctrl+C 눌림(Interrupt Exception)
~> Linux에서 프로세스 수행 중 Ctrl+C 입력 시, 현재 수행 Foreground Process를 죽인다.

9 		SIGKILL		Terminate			프로그램 강제 종료
~> Shell에서 kill 명령을 받으면, 함께 입력된 PID에 해당하는 프로세스를 강제로 종료한다.

11 		SIGSEGV		Terminate			Segmentation Fault(Fault Exception)
~> Process가 허용되지 않은 메모리를 접근 시 OS 커널이 해당 프로세스를 종료(Abort)시킨다.

14 		SIGALRM		Terminate			Timer Signal(Interrupt Exception)
~> Time-Sharing 시, 일정 Time-Quantam이 지나면 하드웨어의 Timer가 Timer Interrupt를
   걸어 프로세스를 종료시킨다.

17		SIGCHLD		Ignore				Child process termination
~> Reaping 과정에서 쓰이는, 자식 프로세스가 "나 끝났어~"하고 알리는 시그널이다.

  대표적인 시그널들이다. 참고로, 각 시그널의 ID와 이름을 외우는 것은 불필요한 행위이다. SP 공부 과정에서 별 의미 없다.


Signal Sending

  • Kernel은 Signal을 Destination Process에 보낸다.
    • Pdest의 Context를 Signal로 변경시키는 것이다.
  • 모든 프로세스는 Message Box를 가진다. 커널은 각 프로세스의 Message Box로 Signal을 보내는 것이다.
    • Message(=Signal)를 보내 해당 목적 프로세스의 Context에 있는 State를 변경(Update)하는 것이다.
      • 사실, 말이 보내는 것이지, Context 정보, Message Box도 사실 다 커널에 있다. ★


  • 커널이 시그널을 보내는 이유
    • Kernel이 'Division by Zero'와 같은 시스템 이벤트를 감지한 경우 (SIGFPE)
    • Kernel이 'Child Process의 Termination'을 알았을 때 (SIGCHLD)
    • Kernel에게 특정 Process가 '다른 Process에 대한 kill System Call'을 바라는 '명시적 요청(Explicit Request)'을 보낸 경우

~> 그래서, 커널이 직접 프로세스에게 시그널을 보낼수도 있고, 다른 프로세스가 커널에게 시그널을 요청할수도 있는 것이다.


Kernel 관점에서 Signal은 Delivered(Sent)와 Received가 있다.

  • Delivered (Sent) : 목적 프로세스에게 시그널을 보냈고, 해당 프로세스가 해당 시그널에 대해 아직 Action을 취하지 않은 상태

  • Received : 목적 프로세스가 받은 시그널에 대한 Action을 취했을 때


Signal Receiving

목적 프로세스가 시그널을 받으면 바로 그 시그널을 수행하는 것이 아니다.

목적 프로세스가 시그널을 받으면, 받은 시그널을 Bit Vector에 넣어둔다.

그리고, 나중에 Context Switch로 인해 다시 자기(목적 프로세스)의 수행 순서가 돌아왔을때, 그 순간 Signal을 체크한다. ★

즉, Process는 (Context Switch로 인해) 실행될 때, 실행 직전에 Message Box(Bit Vector)를 체크해 Signal이 있으면 Action을 수행한다.


  • 프로세스가 Signal을 Receive할 때의 Action 양상
    • Ingnore (무시) : 예를 들어 SIGCHLD

      • 왜 Ignore냐고? Parent가 Child를 직접 Reaping하는 것이 아니라, OS에게 Reaping을 요청하므로. 즉, Parent의 State에는 변화가 없는 것임.
    • Terminate (종료) : SIGINT, SIGSEGV, SIGALRM, SIGFPE 등

      • Time-Sharing에서 Switch도 Terminate에 해당함. 단지, 그 순간이 매우 빨라 인지가 되지 않는 것일 뿐!
    • Catch & Call 'Signal Handler' (캐치)

      • Signal에 대한 Default Action을 취하지 않고, User가 미리 정의한 'User-Level Function'을 수행한다.
      • 이러한 함수를 'Signal Handler'라고 부른다.

        시그널 핸들러를 수행하는 것은, OS의 인터럽트 핸들러가 비동기적으로 동작하는 것과 유사한 방식으로 동작한다. (즉, 비동기적이다)

        비동기적이란 것은, 그냥 마치 프로그램의 일부 코드처럼 돌아간다는 것. Context Switch이 대상이라는 것.


Signal Pending & Blocked Signal

Signal Pending : 시그널이 프로세스의 Message Box에 들어는 갔지만, 아직 Received 상태는 아닌 상황

Signal Pending : Delivered, but not yet Received!

영단어 'pend'는 '보류하다'라는 의미임.

  • Pending Bit Vector : 복수의 Signal을 기억할 수 있는 비트 배열

  • 특정 타입에 대한 Pending Signal은 오로지 하나만 있을 수 있다.

    • 특정 타입의 시그널에 대해서는 많아야 하나만 가질 수 있다.
    • 물론, 다른 타입의 시그널이 오면, 이는 Queue에 Push(Enqueue)한다.

ex) 만약, A라는 Type의 Signal이 Pending되어 있다면, 그 다음에 후속으로 오는 똑같은 A Type의 Signal이 있으면, 프로세스는 이를 Discard한다.
~> 이를 Blocked Signal이라 한다.
~> B Type의 Signal이 오면, 그것은 큐잉한다.


Blocked Signals can be delivered, but will not be received!


  • Bit Vector 관점에서의 Blocking 과정

    • 각 프로세스의 Message Box에는 Pending Bit Vector와 Block Bit Vector가 있다.

    • Context Switch가 일어날 때, 수행하기 직전에 Signal 도착 여부(Message Box)를 확인한다.

    • Pending Bit Vector : Signal Delivered 시점에 Signal Type에 대한 정보를 받는다.

      • 프로세스는 Delivered 시, 그 Signal Type을 그대로 Block Bit Vector에 복사한다. by sigprocmask 함수
    • received 상태가 되면, Kernel이 Bit Vector를 비운다.

ex) Pa라는 현재 프로세스가 있는데, 이 프로세스에 대한 Bit Vector가 위와 같이 두 개가 있다.
~> 이때, 사용자가 키보드로 Ctrl+C를 눌러, OS가 SIGINT를 프로세스에게 보낸다.
~> SIGINT가 Delivered되어 Pa의 Pending Bit Vector의 1(SIGINT)번 비트가 Set된다.
~> 똑같이 Block Bit Vector에도 1번 비트를 Masking한다. by sigprocmask func
~> 이때, SIGINT 시그널이 또 다시 Pa에게 도달하면, Blocked된다. (Received되기 전까지)
(물론, 시간이 매우 짧아, 실제로 이를 확인하긴 어려울 것)


사실, Message Box는 정말 말그대로 프로세스에게 붙어있는 것은 아니고, OS Kernel의 각 프로세스에 대한 Context 보관 구역에 존재한다.
~> 즉, 두 Bit Vector는 커널에 있다.
~> 앞선 시각자료는 Illusion인 것이다.

  • Pending : 프로세스에 k Type의 Signal이 Delivered되면, 해당 프로세스의 Message Box 내의 Pending Bit Vector에 k번째 bit가 SET(1)된다.

  • Clear Pending : Signal이 Received되면, 즉, 프로세스가 Action을 취하면, Kernel은 Pending Bit Vector에서 해당 비트를 CLEAR(0)한다.

  • Blocked(Signal Mask) : Delivered 시 sigprocmask라는 함수를 통해 Pending Bit Vector를 Block Bit Vector에 복사한다. (별도의 매커니즘이 존재한다)

    • 똑같은 k Type의 Signal이 오면, 이를 체킹해 Block한다.
    • received 상황에서 Block Bit Vector가 비워질 때도 sigprocmask 함수가 동작한다.

Process Group

  모든 프로세스는 정확히 하나의 프로세스 그룹에 속하게 된다.

  • 이때, 각 그룹에 대해 'Process Group ID(pgid)'가 존재한다.

  • 프로세스 그룹 안에는 여러 프로세스가 있을 수 있다.

  • getpgrp() : Return Process-Group-ID of Current Process
  • setpgid() : Change Process-Group-ID of A Process

Signal Exercise

Example 1

linux> ./example1 
Child1: pid=12345 pgrp=12344 		~> PID는 다르지만 PGID는 같은 두 프로세스
Child2: pid=12346 pgrp=12344 

linux> ps
PID 	TTY 	TIME 		CMD 
11111 	pts/2 	00:00:00 	tcsh
12345 	pts/2 	00:00:02 	forks 	~> 두 개 프로세스가 돌고 있음
12346 	pts/2 	00:00:02 	forks 
13333 	pts/2 	00:00:00 	ps

linux> /bin/kill -9 -12344 			~> 그룹 전체를 Kill

linux> ps
PID 	TTY 	TIME 		CMD 
11111 	pts/2 	00:00:00 	tcsh	~> 그룹에 속한 프로세스가 모두 Kill됨
13333 	pts/2 	00:00:00 	ps
  • /bin/kill -9 PID
    • User의 요청에 의해 커널이 PID에 해당하는 프로세스를 강제 종료한다. (SIGKILL)
      • PID에 해당하는 프로세스의 Pending Bit Vector를 Kernel이 9로 설정한다.
      • 프로세스는 Time-Sharing으로 돌아가므로, Context Switch 후, 다시 이 프로세스가 실행될 때, 실행에 앞서 Message Box를 확인하고, SIGKILL을 Receive한 후, 죽어버리는것!

  • /bin/kill -9 -PGID
    • ID 앞에 '-'를 붙여주면, 프로세스 그룹 ID를 의미하게 된다.
      • 해당 그룹에 속하는 모든 프로세스를 죽인다.
      • 즉, 그룹에 소속된 모든 프로세스에게 SIGKILL을 보내는 것임.

Example 2

linux> ./example2
Child: pid=12346 pgrp=12345		~> 두 프로세스가 있는 상태
Parent: pid=12345 pgrp=12345
<types ctrl-z>					~> 키보드로 Ctrl+Z를 누름
Suspended						~> Foreground Job이 Stopped 상태로 변화

linux> ps w
PID 	TTY 	STAT 	TIME 	COMMAND
11111 	pts/8 	Ss 		0:00 	-tcsh
12345 	pts/8 	T 		0:01 	./example2   ~> 12345와 12346 프로세스가 둘 다 그냥
12346 	pts/8 	T 		0:01 	./example2	 ~> 상주하는 상태
12347	pts/8	R+		0:00	ps w

linux> fg						~> fg : 중단된 프로세스를 Foreground에서 실행시키는 명령
./example2
<types ctrl-c>   				~>  그 상태에서 Ctrl+C를 눌러 프로세스를 모두 Kill

linux> ps w
PID 	TTY 	STAT 	TIME 	COMMAND
11111 	pts/8 	Ss 		0:00 	-tcsh
12347 	pts/8 	R+ 		0:00 	ps w		~> 날아가버렸다. 두 프로세스가!
  • STAT(Process State)
    • 첫번째 글자
      • S : Sleeping
      • T : Stopped
      • R : Running
    • 두번째 글자
      • s : 세션의 리더
      • + : Foreground Process Group

  example2라는 프로그램을 실행시키면 해당 프로세스의 PID가 12345이고, 거기서 fork로 인해 파생되는 Child가 12346이며, 이 두 프로세스가 하나의 그룹으로 묶여 그룹의 ID가 12345인 상황이다(무한루프를 도는 프로그램이라고 가정한다).

  그때, Ctrl+Z를 눌러 example2 프로그램을 Suspended 상태로 만든다.(SIGTSTP) 그래서 해당 그룹에 속한 두 프로세스의 STAT이 T가 되었다.

  이후, fg로 해당 그룹을 다시 포그라운드로 실행시킨다. 그 다음, Ctrl+C를 눌러 현재 수행 중인 example2 프로그램을 강제로 종료한다. (SIGINT)

  이때, example2에 함께 그룹으로 묶여 있는 자식 프로세스가 같이 종료된다. (프로세스 그룹의 효과)


Example 3

void example3(void) {
	pid_t pid[N];					// pid라는 Integer Array가 있다. N = 4라 하자.
	int i;
	int child_status;

	for (i = 0; i < N; i++)
		if ((pid[i] = fork()) == 0){// Child
			while(1)				// 4개의 child 프로세스가 무한루프를 돈다.
				;					// child들이 종료하지 않는다.
		}

	for (i = 0; i < N; i++) {		// Parent
		printf("Kill Process %d\n", pid[i]);
		kill(pid[i], SIGINT); 		// 각 child 프로세스에게 SIGINT를 보낸다(죽인다).
	}
    
	// 각 child들의 펜딩 비트 벡터에 1(SIGINT)이 세팅되고, 그 프로세스들이 다시 실행될때
	// 종료하게 되고, SIGCHLD를 OS에게 보낸다.
	for (i = 0; i < N; i++) {
		pid_t wpid = wait(&child_status);	// parent는 wait으로 기다린다.

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

(출력)
> ./example3
Kill Process 12001
Kill Process 12002
Kill Process 12003
Kill Process 12004
Child 12003 terminated abnormally
Child 12001 terminated abnormally
Child 12004 terminated abnormally
Child 12002 terminated abnormally

  • 분명히 순서대로 각 Child에게 SIGINT를 보냈다.
    • 그런데, 출력 순서는 무작위이다.
      • OS의 스케쥴링 순서, 그리고 Context Switch 상황에 따라 달라지는 것이다.


Signal Receiving Detail

  Exception이 발생한 후, Kernel의 Exception Handler가 수행되고, 다시 제어권이 Process에게 돌아가는 상황을 생각해보자.

  • 10ms의 Time-Quantam을 사용하고 비동기식 Timer Interrupt Exception이 걸려서 Context Switch가 수행되는 상황이다.
    • 다음 프로세스로 넘어간다.
      • 이때, 그 '다음 프로세스'는 자신의 Message Box에 도착한 Signal이 있는지 확인한다.
        • 없으면 그대로 프로세스의 User Code를,
        • 있으면 Signal에 대한 Action을 취한다.
  • Kernel은 해당 프로세스 Context가 저장된 부분에 있는 두 Bit Vector에 대하여
    • Pending & ~Block 연산을 수행해, 그 결과를 pnb라는 변수에 저장한다.

      즉, Block Bit Vector를 Negation 후, Pending Bit Vector와 AND 연산을 수행한다.


  • 만약, pnb 변수 값이 0이면, Blocked Signal 상황이다.

    • 따라서, Saved Registers에 저장된 정보를 바탕으로, Process의 Logical Flow에 맞는 'Next Instruction'을 수행한다. (제어권이 프로세스로 넘어간다)
  • 만약, pnb 변수 값이 0이 아니면, Receive Signal 상황이다.

    • pnb를 이루는 비트열을 쭉 확인한다.

      • Nonzero인 비트에 대해, 해당하는 인덱스와 대응하는 Type의 시그널을 Receive한다.
      • 해당 Signal이 프로세스에게 Action을 취하도록 Trigger한다.
      • 이 과정이 모든 Nonzero bit에 대해 수행된다.
    • 이 과정이 다 수행되면, 프로세스가 종료되지 않았다면, 제어권이 프로세스로 넘어가고, 프로세스의 'Next Instruction'으로 돌아간다.


  • Signal에 대한 Default Action
    • 각 시그널에 대해 미리 정의된 Action이 있다.
      • Process Terminate ex) SIGINT (Ctrl+C), SIGKILL, SIGSEGV, SIGARLM, SIGTERM
      • Process Stop ex) SIGTSTP (Ctrl+Z), SIGSTOP
        • SIGCONT Signal이 Stopped Process를 다시 실행시킨다.
      • Ignore ex) SIGCHLD
        • Wait이 이를 받아들이고, 무시해야함. 왜냐? 종료하거나 멈출 순 없지 않는가. 무시하는 대신, OS에게 Reaping 요청을 하는 것임 ★★★

Signal Handler Installation

  앞서, 시그널에 대한 Action으로, 디폴트 액션 외에 User-Level Function인 'Signal Handler'를 둘 수 있다고 했다.

handler_t *signal(int signum, handler_t *handler)

  • Signal Handler의 동작 유형
    • SIG_IGN : signum에 해당하는 Signal을 무시한다.
    • SIG_DFL : signum에 해당하는 Signal에 대한 Default Action을 그대로 수행한다.
    • 그 외 : User-Level Signal Handler의 주소로 넘어간다.
      • signum에 해당하는 시그널을 받았을 때 호출된다.
      • 이러한 핸들러를 두는 것을 '설치(Installing)'한다고 칭한다.
      • Signal Handler를 수행하는 것을 'Catching' 또는 'Handling'이라고 부른다.
      • 시그널 핸들러의 수행이 끝나면, 제어권은 다시 프로세스에게 넘어가고, Signal에 의해 인터럽트되기 이전의 프로세스의 Control Flow에서 다음 명령에 해당하는 명령이 수행된다.

  심술난 프로그래머가, SIGINT가 입력되면 바로 종료하지 않고 앙탈을 부리다 종료하는 프로세스를 만들고 싶은 상황이다.

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

~> 이처럼, Signal에 대해 뭔가 새로운 행위를, 사용자가 원하는 행위를 하고 싶을때 Signal Handler를 설치한다.
(참고로 위의 예시 코드는 바람직하지 않은 코드이다. 다음 포스팅에서 이유를 설명한다.)


Signal Handler는 새로운 프로세스인가? : 아니, 그냥 Subroutine이다.

메인함수가 실행되다가, 주기적으로 입력받은 Signal이 있으면, 이를 체크해서 이벤트를 핸들링하는 것이다.

애초에 User-Level이므로 Exception도 아니다. 그냥 사용자정의함수일 뿐이다.

그런데, 일반적인 프로세스 흐름이라고 생각하면 안된다.


Signal Handler is a Concurrent Flow

  시그널 핸들러는, 새로운 프로세스나 OS 코드가 아니다. 메인 프로그램과 함께 수행되는 Concurrent하면서 Separate한 Logical Flow이다.

  • 캐치하고자 하는 시그널이 오면, 핸들러를, 현재 프로세스와 동시적으로 흐르게 하는 것이다.
    • 마치, 논리적으로 새로운 Child 프로세스가 Concurrent하게 흐르는 것처럼 보이지만, 그렇지 않은 것이다.

  • 핸들러는 프로세스의 서브루틴, 즉, 프로세스의 일부다.
    • 따라서, 위의 그림처럼, 핸들러 처리 도중 Timer Interrupt가 걸리면, 거기서 멈추고 Switch가 수행된다.
      • 그래서, 핸들러는 조심히 다루어야한다. 이에 대해선 다음 포스팅에서 자세히 설명한다.

Background Process Reaping

  자, 먼길을 돌아왔다. 이 백그라운드 차일드 프로세스의 리핑을 설명하기 위해 시그널 핸들러까지 온 것이다.

  이 부분에 대한 본격적인 설명은 다음 포스팅부터 이어지기에, 간략하게 핵심 Idea만 설명하겠다.

Foreground Child Process의 Reaping은 Parent 프로세스에 wait을 두어 해결했다.
Background Child Process의 Reaping을 위해선, 결국, wait이 필요하지만, Background 본연의 의미를 위해 Parent는 wait 없이 Foreground 수행을 해야한다.

Signal Handler를 이용하면 Concurrent Flow가 가능하다.

그렇다. 특정 시그널, SIGCHLD에 대한 핸들러를 두어, 다른 일을 열심히 하던 Parent(Shell)이 SIGCHLD 캐칭 상황에서만 Concurrent하게 wait을 하도록 하면 된다. 추후 자세히 설명한다.

2개의 댓글

유익한 글 감사합니다!

1개의 답글