mojo's Blog

Exceptional Control Flow: Exceptions and Process 본문

학교에서 들은거 정리/시스템프로그래밍

Exceptional Control Flow: Exceptions and Process

_mojo_ 2022. 3. 17. 19:11

Control Flow

Processors 은 오직 한가지 일을 한다.

 

  • 시작부터 종료까지 CPU는 일련의 명령을 한 번에 하나씩 읽고 실행(해석)한다.
  • 이러한 sequence 는 CPU의 control flow 라고 한다.

 

다음과 같이 startup 부터 shutdown 까지 n 개의 instruction 이 존재한다고 할 때, 시간이 지날때 마다 instruction_1 부터 시작하여 instruction_n 까지 실행되는 것을 알 수 있다.

 

 

Altering the Control Flow

control flow 를 변경하는 두가지 메커니즘이 존재한다.

 

  • Jumps and branches
  • Call and return

위 메커니즘으로 program state 의 변화에 대응한다.

 

유용한 시스템 부족으로 system state 의 변화에 대응하기 어렵다.

크게 low level과 high level으로 4가지가 존재한다.

 

  • 디스크 또는 네트워크 어댑터에서 데이터를 전송 => low level
  • 명령이 0으로 나누기 => low level
  • 사용자가 키보드에서 Ctrl + C를 누른다 => high level
  • System timer 가 만료 => high level

 

따라서 시스템은 "exceptional control flow" 에 대한 매커니즘을 필요로 한다.

 

Exceptional Control Flow

컴퓨터 시스템의 모든 level에 존재한다. (위에서 확인)

 

Low level mechanisms

1. Exceptions

  • 시스템 이벤트에 대한 제어 흐름의 변경(즉, 시스템 상태의 변경)
  • 하드웨어와 OS 소프트웨어의 조합을 사용하여 구현됨

 

Higher level mechanisms

2. Process context switch

  • OS 소프트웨어 및 하드웨어 타이머로 구현

3. Signals (ctrl + c : kill, ctrl + x : suspend)

  • OS 소프트웨어에 의해 구현됨

4. Nonlocal jumps : setjmp() 및 longjmp()

  • C 런타임 라이브러리에 의해 구현됨

 

※ Exceptions

예외는 일부 이벤트(프로세서 상태 변경)에 대한 응답으로 OS 커널로 제어 권한을 양도하는 것이다.

 

  • 커널은 OS의 메모리 상주 부분이다.
  • Examples of events : 0으로 나누기, 산술 오버플로우, 페이지 폴트, I/O 요청 완료, Ctrl + C 입력

 

 

Instruction 을 처리하는 과정에서 exception 이 발생하면, kernel 영역에 존재하는 exception handler 가 예외 처리를 해준다.

그리고 예외 처리가 끝나면 3가지 경우로 나뉘어서 실행된다.

 

  1. Return to I_current : 예외가 발생한 Instruction 부분으로 돌아간다.
  2. Return to I_next : 예외가 발생한 Instruction 의 다음 부분으로 돌아간다.
  3. Abort : 중단된다.

 

 

Exception table 으로 OS 내부(Kernel)에 구현되어 있다.

각 이벤트 유형에는 고유한 예외 번호 k가 있으며 k는 예외 테이블로 인덱스를 지정한다. (일명 인터럽트 벡터라고도 함)

exception k가 발생할 때마다 exception handler k가 호출된다.

 

Class of Exceptions

총 4가지로 Interrupts, Traps, Faults, 그리고 Aborts 가 존재한다.

 

Interrupts

  • I/O 장치로부터 시그널을 통해 일어나며 Async 방식이다.
  • 항상 다음 instruction 으로 이동하게 된다.

Trap

  • 의도적인 예외를 통해 일어나며 Sync 방식이다.
  • 항상 다음 instruction 으로 이동하게 된다.

Fault

  • 잠재적으로 error 를 처리할 수 있을 때 일어나며 Sync 방식이다.
  • 아마도 현재 instruction 으로 이동하게 된다.

Abort

  • error 를 처리할 수 없는 경우 일어나며 Sync 방식이다.
  • 이 경우는 절대적으로 return 이 없다.

 

Fault 에 대한 보충 설명

물리적 메모리와 가상 메모리에 대해서 위 사진과 같이 생각해본다.

우선, 물리적 메모리는 kernel 영역와 user 영역으로 나뉘며 kernel 영역에는 exception 을 처리할 수 있는 exception table과 system call 등이 있다.

가상 메모리는 한정된 물리적 메모리를 해결하기 위한 수단으로 애플리케이션들이 더 많은 메모리를 활용할 수 있게 해준다.

다음과 같은 상황을 생각해보도록 한다.

user 영역에서 노란색으로 색칠된 부분에서 page 가 가상 메모리에 존재하는 것을 확인할 수 있다.

따라서 page 가 존재하다는 것을 인식하여 OS 는 가상 메모리에 존재하는 해당 page 를 가져오는 것을 알 수 있다.

이번엔 page가 Disk 영역에 존재하는 상황이다.

이러한 상황을 Fault 즉, Page Fault 라고 부른다.

Page Fault 가 발생할 경우 OS 는 이러한 Exception 을 처리하기 위해 Disk 영역에 해당 Page 를 Virtual Memory 로 보내서 Page Fault 가 발생하지 않도록 한다.

그런데 Virtual Memory 로 직접 Disk 에 존재하는 Page 를 보내면 기존에 있던 Page 는 증발해버리는 현상이 발생한다.

이러한 현상을 어떻게 해결할 수 있을까?

가상 메모리에 Disk 에 존재하는 Page 를 보관하기 위한 여분의 메모리 영역이 존재하면 해결된다.

 


 

Asynchronous Exceptions (Interrupts)

 

Asynchronous Exceptions 은 프로세서 외부의 이벤트로 인해 발생한다.

 

  • 프로세서의 인터럽트 핀을 설정하여 표시한다.
  • 핸들러가 "다음" 명령으로 돌아간다.

 

 

(1) 현재의 instruction 을 실행하는 동안 인터럽트 핀이 high 로 이동한다.

(2) 현재의 instruction 실행이 완료된 후, 제어권을 interrupt handler 에 양도한다.

(3), (4) interrupt handler 가 실행되고 실행이 끝나면 그 다음 instruction 으로 되돌아간다.

 

 

Asynchronous Exceptions 의 예로 2가지가 존재한다.

 

Timer interrupt

 

  • 몇 ms 마다 외부의 timer chip 이 인터럽트를 유발한다.
  • 커널에서 사용자 프로그램으로부터 제어 권한을 가져오는 데 사용된다.

 

I/O interrupt from external device

 

  • 키보드에서 Ctrl-C 누르기
  • 네트워크에서 패킷 도착
  • 디스크에서 데이터 도착

 

Synchronous Exceptions

 

명령 실행의 결과로 발생하는 이벤트로 인해 발생 3 가지는 다음과 같다.

 

Traps

 

  • 의도적이며 system calls, breakpoint traps, special instructions 등이 있다.
  • 제어를 "다음" 명령으로 이동시킨다.

 

 

Faults

 

  • 의도하지 않았지만 복구할 수 있으며 page faults(복구 가능), protection faults(복구 불가능), floating point exceptions 등이 있다.
  • faulting("현재") 명령을 다시 실행하거나 중단시킨다.

 

 

Aborts

 

  • 의도하지 않았으며 복구 불가로 패리티 오류가 있다.
  • 현재 프로그램을 중단시킨다.

 

 

System Calls

 

각 x86-64 의 시스템 콜은 유일한 ID 넘버를 가지고 있다.

시스템 콜의 ID 넘버는 아래와 같이 존재한다.

 

 

특히 Kill 같은 경우 특정 프로세스가 다른 모든 프로세스를 전부 Kill 할 수는 없다. (권한이 필요)

그러나 root 권한을 보유한다면, 모든 프로세스를 전부 Kill 할 수 있다.

 

 

※ System Call Example : Opening File

 

사용자 호출용인 open(filename, options) 이 있을때, 

open 함수를 호출하면 system call 명령어 syscall 을 호출하는 _open 함수를 호출한다.

 

 

  • e5d79 : 레지스터 rax 에 시스템 콜 open 을 호출할 수 있도록 하는 값 2 를 넣어준다. 
  • e5d7e : syscall => Exception => Open file 을 거친 후에 반환 값이 레지스터 rax 으로 들어온다. (반환 값이 음수면 에러)
  • e5d80 : 반환받은 레지스터 rax와 0xff...ff001 를 비교하는 작업을 한다.

 

※ Fault Example : Page Fault

 

다음과 같은 예시 코드를 보도록 하자.

int a[1000];
main ()
{
	a[500] = 42;
}

 

우선, page fault 가 발생하지 않는 경우를 살펴보도록 한다.

 

 

가상 메모리에 a[500] 에 해당하는 페이지가 존재하는 경우이다.

이 경우는 page fault 가 발생하지 않고 정상적으로 page 를 읽게 된다.

그렇다면 page fault 가 발생하는 경우를 살펴보도록 한다.

 

 

디스크에 a[500] 에 해당하는 페이지가 존재하므로 page fault 가 발생한다.

이러한 에러를 OS 의 커널 영역에서 disk 에 있는 page를 가상 메모리로 page 를 복사하여 다시 a[500] = 42 에 대한 instruction 을 실행하게 된다.

 

이를 다음과 같은 플로우로 이해할 수 있겠다.

 

 

※ Fault Example: Invalid Memory Reference

 

다음과 같은 예시 코드를 보도록 하자.

int a[1000];
main ()
{
	a[5000] = 42;
}

 

위 코드는 명백한 segmentation fault 이다.

SIGSEGV(허용되지 않은 메모리 액세스) 시그널을 사용자 프로세스에 보냄으로써 "segmentation fault" 로 인해 중단된다.

아래와 같은 플로우로 이해할 수 있다.

 

 

그렇다면 다음과 같은 예시 코드는 segmentation fault 인가?

#include <stdio.h>

int main()
{
        int x[1000];
        int y[4001];

        x[5000] = 100;
        printf("%d / %d\n", x[5000], y[4000]);
}

 

놀랍게도 결과는 "100 / 100" 으로 segmentation fault 가 발생하지 않는다.

visual studio 에서 실행하려고 하면 컴파일러 자체가 배열 x의 인덱스를 넘어섰다고 실행조차 못하지만 리눅스 환경에서는 정상적으로 컴파일되어 실행된다.

즉, 위와 같은 예시를 통해 항상 배열의 인덱스를 넘어섰다고 segmentation fault 가 발생한다는 것은 아니라는 점을 알아야 한다.

 

 

Processes

 

정의: 프로세스는 실행 중인 프로그램의 instance 이다.

 

  • 컴퓨터 과학에서 가장 심오한 아이디어 중 하나이다.
  • "프로그램" 또는 "프로세서" 와 동일하지 않다.

 

프로세스는 각 프로그램에 두 가지 주요 추상화를 제공한다.

 

→ Logical Control Flow

 

  • 각각의 프로그램은 CPU를 독점적으로 사용하는 것처럼 보인다.
  • 이러한 환각은 context switching 이라는 커널 메커니즘에 의해 제공된다.

 

Private address space

 

  • 각각의 프로그램은 메인 메모리를 독점적으로 사용하는 것처럼 보인다.
  • 이러한 환각은 virtual memory 라는 커널 메커니즘에 의해 제공된다.

 

여러 프로세스가 각각 CPU 를 독점하면서 메인 메모리를 독점하는 것처럼 보이는 환각으로 다음과 같이 생각해 볼 수 있다.

 

 

Multiprocessing : The (Traditional) Reality

 

단일 프로세서로 여러 프로세스를 동시에 실행할 수 있다.

 

  • 동일한 프로세스를 실행(멀티태스킹)
  • 가상 메모리 시스템에서 관리하는 주소 공간
  • 메모리에 저장된 비지정 프로세스에 대한 값을 등록한다.

 

 

 

(1) CPU 로 부터 현재 메모리 안에 있는 현재 프로세스의 레지스터들을 저장한다.

(2) 실행할 다음 프로세스를 스케쥴링 한다.

(3) Memory 로 부터 프로세스에 이미 저장되었던 레지스터들을 적재하고 주소 공간을 스위칭한다.

 

이러한 작업을 Context Switch 라고 부른다.

 

Multiprocessing : The (Modern) Reality

 

현대에는 단일 프로세서가 아닌 여러 프로세서들을 이용하여 프로그램을 실행한다.

각각 별도의 프로세스를 실행할 수 있으며 커널에서 수행한 코어에 대한 프로세스들의 스케줄링이 이뤄진다.

 

 


 

※ Concurrent Processes

 

각 프로세스는 논리적 제어 흐름이다.

만약 시간적으로 플로우가 중복된다면, 2개의 프로세스가 동시에 실행되는 경우이다.

즉, 이를 concurrent 하게 실행된다고 하며 그렇지 않을 경우 sequential 하게 실행된다고 할 수 있다.

다음 예시를 보도록 하자. (단일 코어로 실행된다고 가정)

 

 

이 경우 A와 B, A와 C를 concurrent 하게 실행된다고 볼 수 있으며, B와 C는 sequential 하게 실행된다고 볼 수 있다.

 

Concurrent, Sequential 에 대한 추가 예시를 보도록 하자.

 

 

위 사진은 Concurrent 하게 진행중인 예시로 프로세스 p2 가 시작하는 부분부터 프로세스 p2 가 종료되는 부분까지 concurrent 하게 진행된다고 볼 수 있다.

Context switch 로 인해 각 프로세스 마다 활성화되고 중단되는 것을 알 수 있다.

 

 

위 사진은 Sequential 하게 진행중인 예시로 프로세스 p1, 프로세스 p2가 각각 CPU 를 점유하여 concurrent 와 반대로 단일하게 실행되는 것을 알 수 있다.

 

※ Context Switchng

 

프로세스는 커널이라고 불리는 메모리에 상주하는 OS 코드의 공유된 chunk 에 의해 관리된다.

  • 커널은 별도의 프로세스가 아니라 기존 프로세스의 일부로 실행된다.

context switch 를 통해 프로세스 간에 제어 흐름이 전달된다.

 

 

프로세스가 번갈아가면서 실행될 때, 중간마다 발생되는 context switch 의 오버헤드는 어느정도일까?

 

마이크로 세컨드, 즉 μs 단위로 context switch 가 일어나면서 프로세스가 번갈아가면서 실행된다.

즉, 아래와 같이 프로세스가 번갈아가는 것을 짐작할 수 있다.

 

 

※ System Call Error Handling

 

오류가 발생하면 Linux 시스템 수준의 함수는 일반적으로 -1을 반환하고 전역 변수 errno 를 설정하여 원인을 나타낸다.

  • 모든 시스템 수준 함수의 반환 상태를 확인해야 한다.
  • void를 반환하는 소수의 함수는 예외이다.

 

예를 들어서 다음과 같이 fork() 함수의 반환 값이 -1 이 반환되어 오류가 발생한 경우 다음과 같이 처리한다.

if ((pid = fork()) < 0) {
	fprintf(stderr, "fork error: %s\n", strerror(errno));
    exit(0);
}

 

※ Error-reporting functions

 

위 코드를 아래와 같이 좀 더 단순화시킬 수 있다.

void unix_error(char *msg)
{
	fprintf(stderr, "%s: %s\n", msg, strerror(errno));
    exit(0);
}

if ((pid = fork()) < 0)
	unix_error("fork error");

 

※ Error-handling Wrappers

 

Stevens 스타일의 오류 처리 래퍼를 사용하여 코드를 더욱 단순화 시킬 수 있다.

pid_t Fork(void)
{
	pid_t pid;
    
    if ((pid = fork()) < 0)
    	unix_error("Fork error");
	return pid;
}

pid = Fork();

 

※ Obtaining Process IDs

 

pid_t getpid(void) : 현재 프로세스의 PID 값을 반환한다.

pid_t getppid(void) : 부모 프로세스 PID 값을 반환한다.

 

 

※ Creating and Terminating Processes

 

프로그래머의 관점에서, 우리는 프로세스가 세 가지 상태 중 하나에 있다고 생각할 수 있다.

 

▲ Running

프로세스가 실행 중이거나 실행 대기 중이며, 커널에 의해 최종적으로 스케줄링(실행하도록 선택되는것) 된다.

 

▲Stopped

프로세스의 실행이 정지되어 추후 통지가 있을 때까지 스케줄이 잡히지 않음

 

▲ Terminated

프로세스가 영구적으로 중지된다.

 

 

※ Terminating Processes

 

프로세스는 다음 3가지 이유 중 하나로 종료된다.

  • default 액션이 종료되는 신호를 수신
  • 메인 루틴에서 리턴될 때
  • 종료 함수 호출

 

void exit(int status)

  • 상태의 종료 상태로 종료
  • 규칙: 정상 반환 상태는 0, 오류 발생 시 0이 아닌 값
  • 종료 상태를 명시적으로 설정하는 또 다른 방법은 메인 루틴에서 정수 값을 반환

 

exit은 한 번 호출되지만 반환되지 않음

 

 

※ Creating Processes

 

부모 프로세스가 fork() 를 호출하여 새로운 실행 중인 자식 프로세스를 생성한다.

 

int fork(void)

  • 하위 프로세스에 0을 반환하고 상위 프로세스에 하위 프로세스의 PID를 반환한다.
  • 자녀는 부모와 거의 동일하다.
    • 자녀는 부모의 가상 주소 공간과 동일한(단, 개별) 복사본을 가져온다.
    • 하위는 부모의 file descriptors 의 동일한 복사본을 가져온다.
    • 하위의 PID가 상위와 다르다.

 

신기하게도, fork() 는 한 번 호출되지만 두 번 반환된다! (그로 인해 복잡하다)

 

 

다음과 같은 fork() 의 예시 코드를 보도록 하자.

int main()
{
	pid_t pid;
    int x = 1;
    
    pid = Fork();
    if (pid == 0) {
    	printf("child : x=%d\n", ++x);
        exit(0);
    }
    
    /* parent */
    printf("parent: x=%d\n", --x);
    exit(0);
}

 

이러한 코드를 실행할 때, 메모리 관점으로 생각해보도록 하자.

우선 process 는 stack, heap, data, 그리고 code 영역으로 4가지 영역이 존재한다.

위 코드를 실행할 때 메모리에 다음과 같이 형성된다고 가정해보자.

 

 

위 프로세스 명을 P1 이라고 할 때, Fork() 를 실행하기 전 상태이며 PC 레지스터는 현재 pid = Fork() 명령어 이전을 가르키고 있는 상태이다.

여기서 다음 명령어인 pid = Fork() 를 실행하게 된다면, 다음과 같은 일이 발생하게 된다.

 

 

P1 의 자식 프로세스 P2 가 생성되며 stack, heap, data, 그리고 code 의 복제본이 생성되고 심지어 PC register가 해당 명령어를 가르키도록 복제된다.

 

만일 Fork() 를 띄우고 Context switch 가 일어나면 어떻게 될까?

 

다음과 같이 context switch 가 일어나면서 P2 가 CPU 를 점유하는 상태가 발생한다.

 

 

정리하자면, fork()를 띄우면 stack, heap, data, 그리고 code 영역에 대한 복제본이 메모리의 다른 주소공간에 배치가 되면서 PC 레지스터가 가르키고 있는 명령어 라인까지 복제된다.

따라서 위에 예시 코드를 실행하게 된다면, 다음과 같이 두가지 결과가 나오게 된다.

linux > ./fork
parent: x=0
child : x=2

linux > ./fork
child : x=2
parent: x=0

 

2가지 결과가 나오는 이유는 context switch 로 인해 자식 프로세스로 CPU 점유권이 넘어가게 되거나 Fork() 를 띄웠음에도 부모 프로세스가 CPU 를 지속적으로 점유하는 경우로 나뉠 수 있겠다.

 

그렇다면 P1 즉, main() 프로세스의 부모 프로세스는 어떤걸까?

 

main 프로세스를 띄우기 위한 부모 프로세스가 존재할 것이고, 부모 프로세스의 또 다른 부모 프로세스가 존재하는 것을 짐작할 수 있다.

대략적으로 아래와 같은 Tree 구조로 루트 프로세스인 Init 부터 시작하여 다음과 같은 형태로 이루어 진다고 한다.

 

 

※ Modeling fork with process Graphs

 

프로세스 그래프는 concurrent 프로그램에서 문장의 부분 순서를 캡처하는 데 유용한 도구이다.

  • 각 정점은 스테이트먼트의 실행이다.
  • a -> b는 b보다 a가 먼저 발생함을 의미한다.
  • 간선들은 변수의 현재 값으로 라벨링할 수 있다.
  • printf 정점들은 output으로 라벨을 붙일 수 있다.
  • 각 그래프는 inedge가 없는 정점으로 시작한다.

 

그래프의 모든 위상 정렬은 실현 가능한 전체 순서에 해당한다.

  • 모든 간선이 왼쪽에서 오른쪽으로 가리키는 정점의 총 순서를 의미한다.

 

위 코드를 기반으로 Process Graph 를 작성하면 다음과 같다.

 

 


 

※ fork Example : Two consecutive forks

 

void fork2()
{
	printf("L0\n");
    fork();
    printf("L1\n");
    fork();
    printf("Bye\n");
}

 

위 코드를 그래프로 나타내면 다음과 같다.

 

 

Feasible output : L0 => L1 => Bye => Bye => L1 => Bye => Bye (여러 케이스 중 하나)
Infeasible output : L0 => Bye => L1 => Bye => L1 => Bye => Bye (L0 => Bye 가 잘못됨)

 

※ fork Example : Nested forks in parent

 

void fork4()
{
	printf("L0\n");
    if (fork() != 0) {
    	printf("L1\n");
        if (fork() != 0) {
        	printf("L2\n");
    }
    printf("Bye\n");
}

 

부모 프로세스인 경우만 다시 한번 fork() 를 띄우는 코드이다.

이 코드를 그래프로 나타내면 다음과 같다.

 

 

Feasible output : L0 => L1 => Bye => Bye => L2 => Bye (여러 케이스 중 하나)
Infeasible output : L0 => Bye => L1 => Bye => Bye => L2 (Bye => L2 가 잘못됨)

 

※ fork Example : Nested forks in children

 

void fork5()
{
	printf("L0\n");
    if (fork() == 0) {
    	printf("L1\n");
        if (fork() == 0) 
        	printf("L2\n");
    }
    printf("Bye\n");
}

 

이번엔 자식 프로세스인 경우에 또 다시 fork() 를 띄우는 코드이다.

위 코드에 대한 그래프를 나타내면 다음과 같다.

 

 

Feasible output : L0 => Bye => L1 => L2 => Bye => Bye (여러 케이스 중 하나)
Infeasible output : L0 => Bye => L1 => Bye => Bye => L2 (Bye => L2 가 잘못됨)

 

※ Reaping Child Processes

 

Idea

  • 프로세스가 종료되어도 시스템 리소스는 소비된다.
    • ex : 종료 상태, 각종 OS 테이블들
  • 이를 좀비라고 부른다.
    • 살아 있는 시체로, 반은 살아있고 반은 죽은 시체

 

Reaping

  • 종료된 자식에 대해 부모가 wait 또는 waitpid 을 사용하여 자식을 수확해야 한다.
  • 부모에게 종료 상태 정보가 제공된다.
  • 그런 다음 커널이 좀비 자식 프로세스를 삭제한다.

 

부모가 거두지 못하면?

  • 부모 중 한 명이 자식을 수집하지 않고 종료하는 경우 고립된 자식은 init process(pid == 1)에 의해 수확된다.
  • 따라서 장기적으로 실행되는 프로세스에서 명시적인 수확만 있으면 된다. (ex : shells, servers)

 

Reaping 과정을 그려보면 다음과 같다.

 

 

wait() 함수를 호출하는 이유는?

 

좀비 프로세스 즉, 죽어버린 프로세스를 캐치하여 Kernel 에게 reaping 을 요청하기 위함이다.

 

※ Zombie Example (자식 프로세스가 좀비되는 경우)

 

void fork7() {
	if (fork() == 0) {
    	printf("Terminating Child, PID = %d\n", getpid());
        exit(0);
    } else {
    	printf("Running Parent, PID = %d\n", getpid());
        while(1)
        	;
    }
}

 

위 코드는 자식 프로세스가 exit(0) 을 호출하여 죽어버리고 부모 프로세스는 지속적으로 while 루프를 도는 코드이다.

이를 실행하여 [ctrl + Z] 키를 눌러서 중단시킨 후, ps 명령어를 입력하여 프로세스 상태를 확인해본다.

 

 

자식 프로세스의 ID 는 4174313 이고 <defunct> 가 나타나는 것을 확인할 수 있다.

이런 표시를 통해 이 프로세스는 Zombie 프로세스 임을 짐작할 수 있으며, 이를 kill 시켜줘야 한다.

 

 

프로세스 ID 가 4174312 인 프로세스를 Kill 함으로써 자식 프로세스 즉, 좀비 프로세스를 Reaping 할 수 있다.

그렇다면, 좀비 프로세스를 Kill 하게 된다면 어떠한 현상이 일어날까?

 

 

좀비 프로세스가 reaping 되지 않는 것을 알 수 있다.

 

※ Non-terminating Child Example 

 

void fork8()
{
	if (fork() == 0) {
    	printf("Running Child, PID = %d\n", getpid());
        while(1)
        	;
    } else {
    	printf("Terminating Parent, PID = %d\n", getpid());
        exit(0);
    }
}

 

 

이번엔 부모 프로세스 즉, main 프로세스가 exit(0) 을 했기 때문에 정상적으로 실행이 종료된 것 처럼 보인다.

그러나 ps 명령어를 입력하면 자식 프로세스는 while - loop 를 돌기 때문에 프로세스 ID 인 4174531 가 현재 CPU 를 점유하고 있는 상태임을 짐작할 수 있다.

위와 동일하게 kill 명령어를 통해 자식 프로세스를 kill 하게 된다면, 다음과 같이 정상적으로 프로세스가 kill 된다.

 

 

※ wait : Synchronizing with Children

 

부모가 wait() 함수를 호출하여 자식을 reaping 해야 한다.

 

int wait(int *child_status)

  • 하위 프로세스 중 하나가 종료될 때까지 현재 프로세스를 일시 중지한다.
  • 반환 값은 종료된 하위 프로세스의 pid 이다.
  • child_status != NULL 인 경우 이 정수는 자녀가 종료된 이유와 종료 상태를 나타내는 값으로 설정된다.

 

※ wait : Synchronizing with Children

 

void fork9() {
	int child_status;
    
    if (fork() == 0) {
    	printf("HC: hello from child\n");
        exit(0);
    } else {
    	printf("HP: hello from parent\n");
        wait(&child_status);
        printf("CT: child has terminated\n");
    }
    printf("Bye\n");
}

 

위 코드를 그래프로 나타내면 다음과 같다.

 

 

  • 부모 프로세스가 실행되는 경우 : wait() 함수를 호출할 때, 부모 프로세스는 suspend 되면서 자식 프로세스가 실행
  • 자식 프로세스가 실행되는 경우 : 자식 프로세스가 실행되면서 exit() 함수가 호출되면서 부모 프로세스가 실행될 때 wait() 함수을 호출함과 동시에 자식 프로세스의 ID 값이 반환되면서 진행

 

 

왼쪽 실행 결과는 위 코드를 순수하게 돌린 결과며 오른쪽 실행 결과는 exit(0) 을 제거한 결과이다.

 

※ Another wait Example

 

여러 개의 자식들이 완료된 경우, 임의의 순서로 처리된다.

매크로 WIFEXITED 및 WEXITSTATUS 를 사용하여 종료 상태에 대한 정보를 얻을 수 있다.

 

void fork10()
{
    pid_t pid[N];
    int i, child_status;

    for (i = 0; i < N; i++)
        if ((pid[i] = fork()) == 0) {
            exit(100+i); /* Child */
        }
    for (i = 0; i < N; i++) { /* Parent */
        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 terminate abnormally\n", wpid);
    }
}

 

위 코드에서 N 값이 5 라고 가정한다면, 실행 결과는 다음과 같다.

 

 

※ waitpid : Waiting for a Specific Process

 

pid_t waitpid(pid_t pid, int &status, int options)

  • 현재 프로세스를 특정 프로세스가 중단될 때 까지 중단한다.
  • 다양한 옵션들이 존재한다.

 

void fork11()
{
    pid_t pid[N];
    int i;
    int child_status;

    for (i = 0; i < N; i++)
        if ((pid[i] = fork()) == 0)
            exit(100+i); /* Child */
    for (i = N-1; i >= 0; i--) {
        pid_t wpid = waitpid(pid[i], &child_status, 0);
        if (WIFEXITED(child_status))
            printf("Child %d terminated with exit status %d\n",
                   wpid, WEXITSTATUS(child_status));
        else
            printf("Child %d terminate abnormally\n", wpid);
    }
}

 

waitpid() 함수는 신기하게도 직접 자식 프로세스를 지정하여 suspend 시킬 수 있다.

이에 대한 실행 결과는 다음과 같다.

 

 

※ execve : Loading and Running Programs

 

int execve(char *filename, char *argv[], char *envp[])

현재 프로세스에서 로드 및 실행:

  • Executable file : filename
    • #! interpreter 로 시작하는 오브젝트 파일 또는 스크립트 파일이 될 수 있다. (ex : #!/bin/bash)
  • argument list : argv 
    • 관례에 따라 argv[0] == filename 이다.
  • environment variable list : envp
    • "name=value" 문자열 (ex : USER=droh)
    • getenv, putenv, printenv

 

코드, 데이터 및 스택에 대한 Overwrite

  • PID, 열린 파일 및 신호 컨텍스트를 유지한다.

 

한 번 호출되며 절대로 return 되지 않음. (오류가 있는 경우는 제외)

 

 

※ execve Example

 

현재 환경을 사용하여 자식 프로세스 내에서 "/bin/ls  -lt  /usr/include" 를 실행한다.

 

if ((pid = Fork()) == 0) {
	if (execve(myargv[0], myargv, environ) < 0) {
    	printf("%s: Command not found.\n", myargv[0]);
        exit(1);
    }
}

 

위 코드는 자식 프로세스에서 execve() 함수를 실행하며 음수값이 반환되면 error 처리를 하는 것을 알 수 있다.

"/bin/ls  -lt  /usr/include" 를 실행하게 된다면 스택에 다음과 같이 쌓이게 된다.

 

 

 

Comments