IT 그리고 정보보안/Knowledge base

리눅스 프로세스 생성, 실행 그리고 제어

plummmm 2021. 4. 14. 13:44
반응형

프로세스 생성

자식 프로세스를 생성하는 fork 함수에 대해 알아보자.

 

보다시피 반환값을 2가지 가진다 (실패 제외)

자식프로세스 값, 부모 프로세스 값.

자식은 getppid() 함수를 이용해 부모 프로세스의 pid를 얼마든지 알아낼 수 있고, 부모는 하나뿐이지만

부모는 여러개의 자식프로세스를 가질 수 있고 별도의 자식pid를 알아낼 함수가 존재하지 않는다.

 

보통 자식프로세스가 생성되면 부모의 pid값에 +1한 값이 자식프로세스의 pid이다.

 

fork가 자식프로세스를 생성하고 나서도 부모, 자식 프로세스 두가지 모두 정상적으로 실행된다.

fork로 생성된 자식프로세스는 부모 프로세스의 복사본이다.

다만, 코드(텍스트) 세그먼트는 복사하지 않고 공유한다.

 

중요한 것은, 자식은 부모와 메모리 영역을 공유하는 것이 아니라!! 복사본일 뿐이다.

공유하는 부분은 텍스트 세그먼트 뿐이다.

 

근데 저렇게 하면 비효율적이다. 분명 복사본이라고 함은 부모 프로세스와 동일한 작업을 위해

fork를 사용하는 것일 터, 코드 세그먼트만 제외하고 나머지를 복사하는게 무슨 뻘짓인가.

그래서 나온 개념이 Copy-on-Write (COW) 이다.

 

현재는 fork의 구현이 cow를 사용한다고 한다.

 

한마디로 표현하자면 "부모 또는 자식이 쓰고자 할 때만 복사한다" 이다.

무슨말이냐. 모든 영역을 공유하고 있다가 둘 중 한 쪽에서 수정하고자 할 때"만" 복사하도록 하는 것.

(이 복사하는 단위도 하나의 page 단위로 복사한다.)

 

위 그림을 예제로 한번 보자.

parent 에서 수정이 필요하다고 느껴 page C를 복사하고, 기존에 page C를 가르키고 있던 포인터도 

copy of page C를 가르키게 되는 것이다.

이렇게 되어도 Child는 그것을 인지할 필요없이 그냥 원래대로 page C의 내용을 read하면 되는 것이다.

 

하지만 보통은 fork를 실행 후 바로 exec()를 호출하여 다른 내용으로 프로세스를 돌린다.

그래서 이렇게 copy-on-write가 구현되긴 했지만 vfork라는 아예 복사를 하지 않는 함수를 많이 쓴다.

(그건 내가 또 다음 포스팅에서 언급할 예정입니다)

 

예제 코드를 한번 보겠습니다

 

 

소스를 있는 그대로 해석해보면,

먼저 표준출력으로 write하고 fork함수로 자식프로세스를 생성한다.

자식프로세스가 조건에 들어온다면 변수값을 증가 시키고,

부모 프로세스가 조건에 들어오면 2초간 잠재우는 코드이다.

 

일단 여기서 생각해 보아야 할 점은.

fork로 자식 프로세스를 생성하고 난 후, 자식이 먼저 실행되는지 부모가 먼저 실행되는지에 대한 부분이다.

결론적으로 모른다. 커널에서 어떤 스케줄링 알고리즘을 사용하는지에 따라 다르기 때문이다.

만약 특정한 순서를 사용해야 한다면, 프로세스간의 통신으로 동기화를 시켜줘야 하는 번거로움이 생긴다.

 

그래서 이 코드에서는 부모 프로세스를 2초간 sleep 시킨다. 자식부터 실행되도록 유도하는 것이다.

그렇다고해서 무조건 자식이 먼저 실행되리라는 보장도 없긴하다. 하지만 2초나 재우는데... 자식이 먼저 실행된다 보통.

 

그리고 write함수 3번째 인자에 sizeof를 사용해서 1을 뺐는데, strlen을 사용했다면 

마지막 null바이트를 제외하고 길이를 반환했겠지만..

sizeof는 null바이트를 포함시켜 반환한다. 그래서 -1한 것이다.

 

그럼 자식이 먼저 실행된다는 가정하에, 실행한 화면을 보자.

 

 

 

일단 그냥 터미널에서 실행했을 때는 예상한대로 자식만 변수가 증가하고, 부모는 증가하지 않았다.

지역변수, 전역변수를 따로 둔 이유는 data 세그먼트와 stack 세그먼트 모두 fork로 호출된 자식프로세스가

부모 프로세스를 복사 한다는 것을 보여주기 위함인듯.. (copy-on-write든 아니든 일단 수정되면 복사하므로)

 

근데 출력을 temp.out 으로 리다이렉션 한 경우.. 결과값이 뭔가 이상하다?

 

이건 왜이렇게 찍히냐. 리다이렉션과 printf 때문이다.

write함수 같은 경우는 버퍼링 없이 곧바로 표준 출력에 write된다.  그래서 "a write to stdout" 문자열은 한번만 찍힌것이고,

하지만 표준 I/O 함수인 printf 는 리다이렉션 한 경우, 전체 버퍼링이 적용된다. 

터미널에 바로 실행할 때는 printf는 줄단위 버퍼링이 적용되므로, "before fork\n" 의 개행문자(\n)를 만나면 flush 되어 

한번만 찍혔던 것이다. 

하지만 temp.out으로 리다이렉션한 경우는 전체 버퍼링이 적용되어 개행문자를 만났지만 곧 바로 flush 되지 않고 

버퍼에 남아있는 상태(즉, 디스크에 쓰지 않은 채로) 였기 때문에 두번 찍힌 것이다. (버퍼링에 대한 얘기를 다시 언급하겠다)

 

그리고 위 코드를 보면서 한가지 알 수 있는 부분이 있다.

부모의 표준출력을 리다이렉션하니까 자식도 같이 리다이렉션 되었다.

fork로 생성된 자식프로세스는 부모 프로세스가 사용하는 open된 파일 디스크립터를 공유한다. 는 점이다

fork로 자식을 생성할 때 파일 디스크립터가 복제된다는 말이다. 아래 그림처럼..

 

 

근데 저렇게 파일 디스크립터들을 공유하다보면 또 동기화의 문제가 발생하지 않을까.

동일한 파일 offset을 공유하고 있기 때문에.

뭔가 액션을 취하지 않으면 둘의 기록이 뒤섞이게 될 것이다. 그래서 두가지 정도의 처리방식이 있다.

 

1. 부모가 파일에 작업할 내용이 없는 경우, 자식의 작업이 완료될 때까지 기다리고 갱신한다.

2. 각각 작업이 있는 경우, fork 실행 후에 자신이 사용하지 않는 fd는 닫는다. (주로 네트워크 서버에서 쓰임)

 

 

파일 디스크립터 외에도 부모와 자식이 공유하는 속성들이 몇가지 있다.

1. ruid, euid, rgid, egid, setuid, setgid 

2. 제어 터미널

3. 현재 작업 디렉토리, root 디렉토리

4. 시그널 핸들러

5. 환경

6. 자원 한계

 

그리고 명백한 차이를 보이는 항목들

1. fork의 리턴값

2. PID, PPID

3. 자식의 자원 활용값들은 0으로 셋팅

4. 처리되지 않은 시그널

 

 

이제 마지막으로 fork를 왜 쓰는지, 실패하는 이유는 뭔지 정도에 대해 알아보자.

fork가 실패하는 경우 부터 알아보자. 왜 실패할까

큰 두가지 이유가 있다.

1. 이미 너무 많은 프로세스가 시스템에서 실행되고 있는 경우.

 - 이런 경우는 보통 시스템 자체에 무언가 문제가 있는 상황이 많다.

2. 호출한 프로세스의 ruid에 할당된 프로세스 상한값을 넘은 경우.

 - CHILD_MAX 라는 메크로에 상한값이 설정되어 있다. 

 

다음 fork를 사용하는 이유에 대해 알아보자.

1. 부모와 자식이 각각 다른 코드를 수행할 수 있도록 하기 위해.

 - 주로 네트워크 서버에서 많이 쓰이는 방식인데.. 스레드가 나온 후로는 이 이유의 의미는 퇴색되고 있다.

2. 부모와 자식이 각각 다른 프로그램을 실행하게 하기 위해.

 - fork 반환 직후에 exec로 다른 프로그램을 실행시킨다. 

 

2번째 이유에서 다른 프로그램을 실행시킨다고 하는데, 이 작업을 좀더 유연하게 하기 위해 나온 함수가 있다.

오로지 두번째 이유만을 위해 호출하는 함수. 부모와 자식이 모든 내용을 공유하는 것이다. 

 

vfork()

fork를 사용하는 두번째 이유에 특화된 함수, 브이포크다.

fork랑 생겨먹은 것도 완전히 똑같다.

 

이녀석이 fork와 가장 다른점은 "부모 프로세스와 자식 프로세스가 모든 것을 공유한다."

복사가 아니라 공유 (이 부분이 좀 헷갈리면 fork를 다시 보고 오셈.)

 

주소 공간을 아예 copy를 하지 않는다. exec()를 이용하여 바로 다른 코드를 실행하기 위해서.

어차피 다른 프로그램을 돌릴 건데 뭐하러 복사를 하냐 이말이다.

 

exec나 exit 가 호출되기 이전에는 자식 프로세스는 아예 부모의 주소 공간에서 실행된다.

페이징 기법을 사용하는 시스템에서는 이걸로 약간의 성능 향상을 맛볼 수도 있다.

Copy-on-write를 fork가 사용한다 해도, 아예 복사하지 않는 vfork와 비교할 순 없을 터,

 

또 다른점이 하나 있다면, fork의 예제코드에서 말했던 누가 먼저 실행되냐에 대해

vfork는 확실하게 자식부터 실행이 되도록 보장해준다.

​exec나 exit가 호출되기 전에 부모가 실행되어 자식 프로세스가 부모의 작업에 의존되어 버리면 

교착상태에 빠질 가능성이 있다. 그래서 일단 부모는 block!

 

예제 코드를 보고 개념을 굳히자.

 

저번 코드와 비슷하지만, 일단 자식이 먼저 실행되는 것이 확실하다.

자식 프로세스에서 glob와 var를 1씩 증가 시키고 exit(0)으로 종료된다.

그리고 나서 부모 프로세스가 실행이 되고, printf로 찍히는데

 

vfork는 자식과 부모가 모든 메모리를 공유하고 있으므로, 자식 프로세스에서 변수가 증가했으므로

부모 프로세스에서도 당연히 증가 했을 것이다.

 

프로세스 종료

프로세스를 종료하는 방식과 종료시켰을 때 발생되는 상황들에 대해 알아보자.

 

먼저 프로세스를 정상적으로 종료하는 방법들에 대해 알아봅시다.

1. main() 함수의 return에 의한 종료

  - exit함수를 호출하는 것과 동일한 결과가 나온다.

 

2. exit() 함수를 호출하여 종료

  - exit 함수를 사용하면 할당된 메모리, open file 등을 정리하고 종료한다.

 

3. _exit(), _Exit() 함수를 호출하여 종료

  - exit함수와 다르게 _exit 와 _Exit 함수는 아무 작업을 하지 않고 커널로 바로 반환된다.

 

위에 아직 언급하지 않은 3가지 함수들이 나온다.. 어떤 녀석들인지 함 보자.

 

 

생긴 것도 똑같은데 내가 왜 굳이 _exit()를 따로 뒀을까.

_exit() 는 unix standard 헤더에 정의된 System call 함수이다.

나머지는 그냥 라이브러리 함수.

3가지 함수의 차이점은 위에 설명했다. 마무리 작업을 하냐 안하냐. exit()만 마무리 작업을 한다.

안에 status 인자로 0을 넣으면 정상종료, 1을 넣으면 비정상 종료이다.

 

 

그럼 이번에는 비정상 종료되는 경우를 한번 알아보자.

1. abort() 호출에 의해 종료.

  - SIGABRT 이 발생된다. 현재 상태를 코어 덤프 뜨고 비정상 종료한다.

 

2. 특정한 시그널에 의한 종료

  - 프로세스 자신, 다른 프로세스 아니면 커널이 보낼 수도 있다. (segmentation fault, n/0 같은 경우 커널에서 발생)

 

뭐 정상 종료든, 비정상 종료든 결국 커널안에 있는 동일한 코드를 실행하게 된다.

open된 파일 디스크립터를 모두 닫고, 메모리 해제 등의 마무리 작업을 하는 코드이다.

 

프로세스가 종료되고 나면 부모가 자식이 종료되었는지 확인이 가능해야 할것아닌가.

exit 류 3가지 함수에 의해 종료가 된다면, 함수의 인자인 status로 부모가 종료 상태를 알 수 있다.

하지만 비정상 종료의 경우 커널이 종료 상태를 결정한다.

어떤 경우이든 부모 프로세스는 wait 함수나 waitpid 함수를 통해 종료 상태를 얻을 수 있다.

(wait, waitpid 에 대한 내용은 다음 포스팅에서 언급하겠다)

 

 

부모 혹은 자식의 종료 상태에 따라 일어나는 케이스가 있는데,

부모 잃은 프로세스 즉, 고아 프로세스가 나타나는 상황과 프로세스의 좀비화, 좀비 프로세스 생성 두가지가 있다.

 

먼저 좀비 프로세스

자식 프로세스가 종료되었는데, 아직 부모 프로세스가 그 종료상태를 회수하지 않은 상태를 말한다.

다시 말해, 부모 프로세스는 wait call을 기다리고 있는데 자식은 이미 종료가 되버린 상태를 말함.

ps 명령에서 좀비 상태의 프로세스를 'Z' 로 표시한다.

 

그리고 고아 프로세스가 생성되는 경우이다.

이 경우는 자식 프로세스보다 부모 프로세스가 먼저 종료되는 경우이다.

모든 프로세스는 부모 프로세스를 가져야 한다고 말했다. 근데 부모가 종료되면? 고아가 되는 것이다.

이럴 때는 PID 1번이라고 말했던 init 프로세스가 그 고아 프로세스의 부모가 되어 준다.

일반적으로 프로세스가 종료되면, 커널은 모든 활성 프로세스를 탐색하여 고아가 있는지 확인한다.

 

그럼 init 프로세스의 자식이 된 고아 프로세스가 종료되면 그것도 좀비상태가 될까?

답은 "아니다".

init 프로세스는 자기의 자식 프로세스들 중 하나가 종료되면 wait 류 함수를 호출하여 종료상태를 회수한다.

 

 

프로세스 종료 상태 회수

부모가 자식 프로세스의 종료 상태를 회수하기 위해서 사용하는 함수인 wait, waitpid를 알아보자.

먼저 wait 함수부터 알아보겠다.

 

wait 계열 함수들은 쉽게 설명해서 부모 프로세스가 자식 프로세스가 종료될 때까지 기다리게 하고,

종료 상태를 회수할 때 쓰는 함수이다.

반환되는 pid는 종료된 자식 프로세스의 pid이다.

종료 상태까지 알 필요 없는 경우 statloc에 NULL을 집어넣는다.

 

저번에도 말했지만 부모 프로세스는 자식이 종료될 때 그 상태를 알아야 한다.

근데 자식놈이 언제 종료될지 모르지 않나. 그래서 종료되면 커널이 SIGCHLD 라는 시그널을 보낸다.

기본 default 처리 방식은 그냥 무시하는 건데. 처리를 해야한다면 이 신호를 처리할 함수를 등록해야지.

이 때 wait 함수가 쓰인다.

 

위에 리턴에 실패, 성공만 언급했는데 다른 경우가 많다.

1. 자식 프로세스들이 모두 실행 중이라면 부모는 블록된다.

2. 자식이 좀비상태라면 바로 반환된다.

3. 실행 중인 자식 프로세스가 없으면 오류가 반환된다.

 

 

그럼 wait와 waitpid 함수는 뭐가 다를까? waitpid 함수를 보자.

 

pid는 종료를 기다릴 자식의 pid를 넣는 것이고,

option에는 상세한 waitpid의 작동 방식을 넣는 것이다. 보통은 0을 넣으면 된다.

 

wait는 가장 먼저 종료되는 임의의 자식 프로세스를 기다리지만, waitpid는 직접 종료를 기다릴 자식을 

지정할 수 있다. 그리고 차단되지 않고 호출할 수 있는 기능을 설정할 수 있다.

 

wait, waitpid 모두다 자식 프로세스의 상태를 회수하는데, 이 상태에 대한 메크로가 정의 되어 있다.

 

1. WIFEXITED(status) : 정상적으로 종료되었다면 참

   WEXITEDSTATUS(status) : 이것을 이용해 종료 상태를 알 수 있다. 

 

2. WIFSIGNALED(status) : 신호를 처리하지 않아 비정상종료되었다면 참

   WTERMSIG(status) : 종료를 유발한 신호의 번호 알아낼 수 있음

 

3. WIFSTOPPED(status) : 자식이 현재 정지 중이면 참

   WSTOPSIG(status) : 자식의 정지를 유발한 신호의 번호를 알아낼 수 있음.

 

그럼 이제 예제 코드를 보자.

먼저 종료 상태에 대한 메세지를 출력하는 함수인 pr_exit()를 보자. 이후 계속 쓰임.

 

#include <sys/types.h>

#include <sys/wait.h>

#include <stdio.h>

 

void pr_exit(int status){

  if(WIFEXITED(status)){

    printf("normal termination, exit status= %d\n", WEXITSTATUS(status));

  

  else if(WIFSIGNALED(status)){

    printf("abnormal termination, signal number= %d\n", WTERMSIG(status));

 

  else if(WIFSTOPPED(status)){

    printf("child stopped, signal number= %d\n", WSTOPSIG(status));

}

 

자 이제 그럼 wait함수에 대한 예제 코드를 한번 보자.

 

#include <sys/types.h>

#include <sys/wait.h>

#include <stdio.h>

 

int main(void){

  pid_t pid;

  int status;

 

  if((pid=fork()) < 0 )

    printf("fork error");

  else if(pid == 0)

    exit(7);

 

  if ( wait(&status) != pid)

    printf (" wait error");

  pr_exit(status);            // 정상 종료시에 종료 상태 출력

 

  if ((pid = fork())< 0 )

    printf ("fork error"); 

  else if ( pid ==0 )

    abort();

 

  if (wait(&status) !=pid)

    printf("wait error");

  pr_exit(status);    // 비정상 종료 시에 종료 상태 출력 (SIGABRT = 6)

 

​이 소스코드를 실행 시키면 아래와 같이 출력된다. 

 

normal termination, exit status = 7

abnormal termination, signal number = 6

 

 

프로세스 실행 - exec계열 함수

프로그램을 실행시키는 exec류 함수에 대해 알아보자.

fork() 설명할 때 사용하는 fork를 사용하는 이유중 하나가 자식 프로세스를 생성하여

다른 프로그램을 실행시키고자 할 때 fork 호출 후 exec류 함수를 호출하여 자식 프로세스가

부모와 다른 프로그램을 실행시키도록 하는 목적이 있다고 했다. (프로세스 id는 변동없음)

그때 나온 exec류 함수들 6가지에 대해 알아본다.

 

먼저 execl, execle, execlp 함수에 대해 알아보자.

 

일단 얘네들을 묶은 이유는 인자를 받는 방법의 차이이다. 이건 그냥 받는데,

밑에 설명할 3개의 함수들은 인자를 배열로 받는다.

 

execl 과 execle 함수는 일단 파일의 절대 경로로 인자를 받는다. 

반면 execlp 함수는 인자를 파일 이름으로 받는데, 환경변수에 등록된 경로를 읽어 파일 명만 입력하면

알아서 그 경로를 타고 실행된다. 

 

filename으로 인자를 받는 함수들의 경우 파일이름에 '/(슬래쉬)'가 포함되어 있으면 경로로 인식한다.

그리고 실행가능한 형태의 파일이 아닌 경우 셸스크립트로 간주하고 /bin/sh 를 실행시켜 인자로 받는다.

 

위 세가지 함수들은 실행될 프로그램의 인자를 받고 마지막에 널포인터를 지정해줘야 한다.

상수 0으로 하면 안되고 반드시 포인터 형식으로 해야한다.

 

execl, execle, execlp 중 execle는 특이하게 인자로 환경변수를 문자열로 받아 설정도 가능하다.

 

그럼 다음으로 execv(), execve(), execvp() 에 대해 알아보자. 

어째보면 이것들은 진화형?이다.

 

위 세가지 함수들 이름에 l 대신 v가 붙었다.

인자를 문자열이 아닌 2차원 배열로 받아 저장하는 것이 다른점이고, 다른건 다 똑같다.

p가 붙으면 환경변수 PATH를 탐색한다고 하여 파일 이름을 인자로 받는다고 외우면 되고.

e가 붙으면 envp[] 즉, 환경변수를 인자로 받을 수 있는 함수라고 보면 되고.

아무것도 안붙은 애는 그냥 기본형태.

 

다만, 여기에 시스템콜 함수는 execve 뿐이다. 그래서 셸코드 작성할 때 execve를 그렇게 썼나보더라.

 

각 함수들의 관계를 이쁘게 정리한 그림을 보자.

 

결국 최종적인 형태는 execve 함수 인 것 같다.

개인적으로 파일 이름으로 인자를 받는게 진화형태인줄 알았는데, 알고보니 절대경로로 인자를 주는 것이

진화형이었다. execve를 뺀 나머지 함수들은 라이브러리 함수들인데, 얘내들을 호출해도 결국에는

execve가 호출이 된다.

 

6가지 함수의 기능적 차이점을 봅시다.

 

그럼 이번에는 예제코드를 한번 보겠소

 

위 코드는 그냥 execle와 execlp를 사용하는 것이다.

아래 나와 있는 echoall 이라는 프로그램을 두번 실행하는 코드이다. 

 

 

echoall은 인자들과 환경변수를 출력해주는 프로그램..

출력하면 위와같이 나온다. 두번째 프로그램 실행할 때 shell 프롬프트가 뜨는데,

부모가 자식프로세스의 종료를 기다리지 않았기 때문이다.

 

 

프로세스 실행 - system 함수

시스템 함수에 대해 알아보자.

이녀석은 프로그램 내부에서 명령어를 실행할 수 있도록 하는 함수이다.

자, 쉽게 말해 셸을 이용하여 명령어를 처리할 수 있도록 하는 것이라고 이해하면 쉽다.

 

/bin/sh -c [명령어]

 

를 이용해서 지정된 명령어를 실행하고, 끝난후 반환되는 것이다.

sh에 -c 옵션을 주면 해당 프로그램이 아니라 다음 커맨드라인 인자를 받아오라는 것임.

 

생긴건 진짜 간단하게 생겼다. 쉽게 사용하라고 이렇게 만들었는듯.

system함수는 fork, execl, waitpid 함수의 조합으로 구현되었다.

위에서 반환값에 대한 내용이 길어 아래에서 설명한다고 했다. 알아봅시다.

 

1. fork함수가 실패하였을 경우와 waitpid가 EINTR 이외의 오류코드를 돌려준 경우

- system은 에러넘버를 해당 값으로 설정하고 -1을 반환한다.

 

2. execl 함수 호출이 실패한 경우(=/bin/sh를 실행할 수 없는 경우)

- exit(127)을 호출할 때와 같은 127이 반환된다.

 

3. 모두 성공한 경우

- 명령어의 리턴 코드가 반환된다.

 

시스템 함수가 어떻게 구현되어 있는지 확인해보자.

 

위에서 설명한 것들이 고스란히 구현되어 있다.

반응형