Linux에서 '프로세스 계층도(Process Hierarchy)'는 다음과 같은 형태를 띈다.
~> Linux 시스템에서 pstree라는 명령을 통해 이 Process Hierarchy 트리 구조를 선형적인 형태로 확인할 수 있다.
~> 맨 위 [0]에서 시작해, init Process가 단 하나의 Child로 존재하고, 거기서부터 트리가 뻗어나가고 있음을 확인할 수 있다. 무엇으로? fork로!
쉘(Shell) : 사용자 대신, '프로그램을 실행'시켜주는 응용 프로그램이다.
~> command를 읽어서 execute하는 행위를 계속 반복한다.
(Execution is a sequence of read-evaluate steps!)
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;
}
">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 예제 코드에는 이 기능이 지원되지 않는다.
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는 read를 통해 Pipe에서 나오는 output file의 "Hello World"를 읽어서, line 문자열에 집어넣고, 이를 출력한다.
위의 eval함수를 보면 알 수 있지만, Child가 Background 프로세스일 때는, Foreground일 때와 다르게, Parent가 wait으로 Child를 reaping하지 않는다.
Parent가 wait하지 않는 이유 : 그것이 백그라운드 본연의 의미이기 때문이다. 백그라운드로 Child를 수행하니, Parent(Shell)는 다시 Foreground로 나와 늘 그렇듯이 다시 명령을 받는다.
Wait을 하면 Shell이 Suspend되고(다른 명령을 못받음), 그것은 백그라운드의 의미와 맞지 않는다!!!
Background Process의 경우, 특별한 과정을 통해 처리한다. (하단 설명)
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 : OS Kernel이 제공하는 알람 시스템, Alert Mechanism이다.
Signal : 시스템에서 프로세스에게 '특정한 이벤트가 발생했음'을 알리는 Small Message이다.
초반에 다룬 Exception, Interrupt 이런 개념과 유사하다.
Kernel이 Process에게 Signal을 보낸다.
시그널은 1부터 30까지의 정수로 이루어진 ID로 구분된다.
Signal은 'Signal ID'와 '도착 정보'만을 포함한다. ★
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 공부 과정에서 별 의미 없다.
~> 그래서, 커널이 직접 프로세스에게 시그널을 보낼수도 있고, 다른 프로세스가 커널에게 시그널을 요청할수도 있는 것이다.
Kernel 관점에서 Signal은 Delivered(Sent)와 Received가 있다.
Delivered (Sent) : 목적 프로세스에게 시그널을 보냈고, 해당 프로세스가 해당 시그널에 대해 아직 Action을 취하지 않은 상태
Received : 목적 프로세스가 받은 시그널에 대한 Action을 취했을 때
목적 프로세스가 시그널을 받으면 바로 그 시그널을 수행하는 것이 아니다.
목적 프로세스가 시그널을 받으면, 받은 시그널을 Bit Vector에 넣어둔다.
그리고, 나중에 Context Switch로 인해 다시 자기(목적 프로세스)의 수행 순서가 돌아왔을때, 그 순간 Signal을 체크한다. ★
즉, Process는 (Context Switch로 인해) 실행될 때, 실행 직전에 Message Box(Bit Vector)를 체크해 Signal이 있으면 Action을 수행한다.
Ingnore (무시) : 예를 들어 SIGCHLD
Terminate (종료) : SIGINT, SIGSEGV, SIGALRM, SIGFPE 등
Catch & Call 'Signal Handler' (캐치)
시그널 핸들러를 수행하는 것은, OS의 인터럽트 핸들러가 비동기적으로 동작하는 것과 유사한 방식으로 동작한다. (즉, 비동기적이다)
비동기적이란 것은, 그냥 마치 프로그램의 일부 코드처럼 돌아간다는 것. Context Switch이 대상이라는 것.
Signal Pending : 시그널이 프로세스의 Message Box에 들어는 갔지만, 아직 Received 상태는 아닌 상황
Signal Pending : Delivered, but not yet Received!
영단어 'pend'는 '보류하다'라는 의미임.
Pending Bit Vector : 복수의 Signal을 기억할 수 있는 비트 배열
특정 타입에 대한 Pending Signal은 오로지 하나만 있을 수 있다.
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에 대한 정보를 받는다.
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에 복사한다. (별도의 매커니즘이 존재한다)
모든 프로세스는 정확히 하나의 프로세스 그룹에 속하게 된다.
이때, 각 그룹에 대해 'Process Group ID(pgid)'가 존재한다.
프로세스 그룹 안에는 여러 프로세스가 있을 수 있다.
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
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 ~> 날아가버렸다. 두 프로세스가!
example2라는 프로그램을 실행시키면 해당 프로세스의 PID가 12345이고, 거기서 fork로 인해 파생되는 Child가 12346이며, 이 두 프로세스가 하나의 그룹으로 묶여 그룹의 ID가 12345인 상황이다(무한루프를 도는 프로그램이라고 가정한다).
그때, Ctrl+Z를 눌러 example2 프로그램을 Suspended 상태로 만든다.(SIGTSTP) 그래서 해당 그룹에 속한 두 프로세스의 STAT이 T가 되었다.
이후, fg로 해당 그룹을 다시 포그라운드로 실행시킨다. 그 다음, Ctrl+C를 눌러 현재 수행 중인 example2 프로그램을 강제로 종료한다. (SIGINT)
이때, example2에 함께 그룹으로 묶여 있는 자식 프로세스가 같이 종료된다. (프로세스 그룹의 효과)
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
Exception이 발생한 후, Kernel의 Exception Handler가 수행되고, 다시 제어권이 Process에게 돌아가는 상황을 생각해보자.
즉, Block Bit Vector를 Negation 후, Pending Bit Vector와 AND 연산을 수행한다.
만약, pnb 변수 값이 0이면, Blocked Signal 상황이다.
만약, pnb 변수 값이 0이 아니면, Receive Signal 상황이다.
pnb를 이루는 비트열을 쭉 확인한다.
이 과정이 다 수행되면, 프로세스가 종료되지 않았다면, 제어권이 프로세스로 넘어가고, 프로세스의 'Next Instruction'으로 돌아간다.
앞서, 시그널에 대한 Action으로, 디폴트 액션 외에 User-Level Function인 'Signal Handler'를 둘 수 있다고 했다.
handler_t *signal(int signum, handler_t *handler)
심술난 프로그래머가, 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도 아니다. 그냥 사용자정의함수일 뿐이다.
그런데, 일반적인 프로세스 흐름이라고 생각하면 안된다.
시그널 핸들러는, 새로운 프로세스나 OS 코드가 아니다. 메인 프로그램과 함께 수행되는 Concurrent하면서 Separate한 Logical Flow이다.
자, 먼길을 돌아왔다. 이 백그라운드 차일드 프로세스의 리핑을 설명하기 위해 시그널 핸들러까지 온 것이다.
이 부분에 대한 본격적인 설명은 다음 포스팅부터 이어지기에, 간략하게 핵심 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을 하도록 하면 된다. 추후 자세히 설명한다.
유익한 글 감사합니다!