-
[시스템프로그래밍] Process Management시스템프로그래밍 2023. 12. 13. 23:03
프로그램과 프로세스, 메모리에 대해 알아봤으니
이제 본격적으로 프로세스를 다루는 법에 대해 알아보도록 하겠습니다.
지난 내용은 아래 글들을 참고하시기 바랍니다.
https://scwcart.tistory.com/entry/Process
Process
앞선 글들에서 다른 개념들을 설명하면서 '프로그램'과 '프로세스' 두 개의 단어가 계속 뒤섞여 나왔습니다. 프로그램은 뭐고 프로세스는 뭘까요? 결론부터 말하자면 '실행되고 있는 프로그램'
scwcart.tistory.com
https://scwcart.tistory.com/entry/Computer-Memory-and-Program
Computer Memory and Program
프로그램이 메모리 공간을 할당받아 메모리에 적재되면서 프로세스 인스턴스가 생성된다고 하였습니다. 그럼 메모리에 대해서 좀 더 알아볼까요? Computer Memory : User space and Kernel space 컴퓨터 메
scwcart.tistory.com
Process Management - main system calls
프로세스 관리와 관련된 system call들을 먼저 알아보도록 하겠습니다.
1) fork(): fork()는 하나의 프로세스가 새로운 프로세스를 생성하도록 해줍니다. 생성한 프로세스가 parent, 생성된 프로세스가 child입니다.
2) exit(status): exit()은 프로세스를 종료시키고 해당 프로세스가 사용했던 모든 리소스(메모리, fd) 등을 커널에게 반납해 뒤에 오는 다른 프로세스들이 해당 리소스들을 재할당받아 사용할 수 있게 해 줍니다. 괄호 안에 status는 프로세스가 종료될 당시 종료 상태를 알려줍니다.
3) wait(&status): 먼저 child process가 아직 exit()을 호출하여 종료되지 않은 경우, wait()은 자식 프로세스 중 하나가 종료될 때까지 실행하지 않고 기다리도록 합니다. 즉 wait()이 위치한 프로세스는 부모 프로세스겠죠?
또한 위에서 자식 프로세스가 exit()을 호출하며 본인의 종료 상태를 알린다고 했습니다. 부모 프로세스는 wait()을 통해 자식이 알린 종료 상태를 알 수 있습니다.
4) execve(pathname, argv, envp): 새 프로그램의 경로명, 명령어 리스트, 환경변수 리스트를 메모리에 적재해줍니다. 즉, 새 프로그램의 실행 코드를 현재 프로세스에 적재해 기존의 실행코드와 교체하여 새로운 프로세스를 실행하는 것입니다. 이렇게 하면 기존에 실행되고 있던 프로그램의 기능은 없어지겠죠? 이런 프로세스 실행과 관련된 함수들을 exec 계열 함수라고 하는데, 후반부에서 좀 더 자세히 알아보겠습니다.
이 system call들로 process가 실행되는 과정을 보도록 하겠습니다.
처음에 A 프로그램이 실행됩니다.
A 프로그램이 실행되다가 fork()를 만나 기존에 A를 실행하던 프로세스는 부모 프로세스가 되고, A를 복사한 자식 프로세스가 하나 더 생겼습니다.
자식 프로세스는 A를 복사해와 실행하다가 execve()를 만나 B를 실행시킵니다. 아예 덮어쓰는 것이기 때문에 자식 프로세스에서 A는 더 이상 실행되지 않고 사라집니다. 그 사이 부모 프로세스에서는 자식 프로세스의 실행이 끝날 때까지 기다립니다.
자식 프로세스에서 B 실행이 다 끝나면 status값과 함께 exit()을 호출합니다.
자식 프로세스에서 exit()을 호출하면, 부모 프로세스에게 자식 프로세스의 작업 종료를 알리는 signal이 전달되는데,
이 signal이 SIGCHLD이며, 부모 프로세스는 SIGCHLD를 받으면 대기 상태에서 재시작 상태가 되어 남은 코드를 실행시킵니다.
How Program runs
그럼 Shell은 프로그램을 어떻게 실행시킬까요?
Shell이 프롬프트를 출력하고 사용자가 shell에 명령어를 입력하면, shell이 명령어를 실행합니다.
실행이 끝나면 shell은 또 다시 프롬프트를 출력하고, 사용자는 명령어를 입력하고... 과정을 반복합니다.
프롬프트를 출력하는 화면 뒤에서 어떤 일이 일어나는지 그림과 함께 보도록 하겠습니다.
A) 사용자가 a.out 파일에 명령어(프로그램)를 입력합니다.
B) shell은 a.out 파일을 받아 새 프로세스 인스턴스를 생성합니다.
C) 그 후 shell이 디스크에서 메모리에 있는 프로세스 인스턴스로 프로그램을 적재합니다.
D) 프로그램이 완료될 때까지 프로세스에서 실행됩니다.
전체 과정을 의사코드와 다이어그램으로 보면 다음과 같습니다.
while ( !end_of_input ) get command execute command wait for command to finish
처음에 shell이 ls라는 명령어를 읽고,
fork를 만나 새 자식 프로세스가 생성되어 자식 프로세스에서 ls 명령어를 실행한 뒤
exit()으로 종료했음을 알리고
자식이 종료되기까지 기다리고 있던 부모 프로세스가 종료 상태값을 받아 다시 실행되는 것을 반복하고 있습니다.
정리하면,
1) 프로그램을 실행하고
2) 프로세스를 생성하고
3) exit()이 호출될 때까지 기다립니다.
exec()
그럼 위에서 잠깐 언급했던 exec() 계열 함수에 대해 좀더 알아보도록 하겠습니다.
exec() 계열 함수에는 여러 가지가 있습니다.
먼저 l 계열과 v 계열로 나눌 수 있는데요,
l 계열은 실행 파일의 경로와 실행 인자들을 가변 인자로 전달합니다.
execl("/bin/ls", "ls", "-l", (char*)NULL);
첫 번째 인수는 실행 파일의 경로를 나타내고, 두 번째 인수부터는 명령행 인자입니다.
인자로 전달되는 문자열 배열은 반드시 NULL로 종료되어야 하기 때문에 l계열 v계열 모두 NULL로 배열의 끝을 나타내 주어야 합니다. 따라서 이 NULL을 종료 마커라고도 부릅니다.
위 코드는 ls -l 명령어를 실행한 것입니다.
v 계열은 실행 파일의 경로와 실행 인자들을 배열로 전달합니다.
char *args[] = {"ls", "-l", NULL}; execv("/bin/ls", args);
첫 번째 인수는 execl처럼 실행 파일의 경로를 나타내되, 뒤에 들어가는 명령행 인자는 배열로 모아서 전달합니다.
exec() 함수는 다시 e 계열과 p 계열로도 나눌 수 있습니다. e와 p는 l이나 v 뒤에 붙습니다.
e 계열은 실행될 새로운 프로그램의 환경 변수를 직접 지정할 수 있습니다.
char *env[] = {"HOME=/usr/home", "LOGNAME=example", NULL}; execle("/bin/ls", "ls", "-l", (char *)NULL, env);
코드를 보면 env[] 배열에 HOME과 LOGNAME에 해당하는 환경 변수를 넣고,
명령행 인자의 종료 마커 뒤에 env 배열을 넣어 환경변수를 지정해 주고 있습니다.
예시로는 execle만 들었지만, v계열과 함께 사용한 execve도 사용 가능합니다. 이때는 명령어를 배열로 전달해야겠죠.
p 계열은 실행 파일의 경로를 찾을 때 환경 변수 PATH를 사용하기 때문에 다른 함수들과 달리
첫 번째 인수 자리에 실행 파일의 경로가 아니라 단순히 실행 파일의 이름만 적습니다.
char *args[] = {"ls", "-l", NULL}; execvp("ls", args);
코드를 보면 다른 함수들에서는 "/bin/ls"와 같이 실행 파일의 경로가 적힌 반면,
execvp에서는 실행 파일의 이름인 "ls"만 적혀 있는 것을 볼 수 있습니다.
여기서도 마찬가지로 l계열과 함께 사용한 execlp도 사용 가능합니다. 이때는 명령어를 가변인자로 전달해야 합니다.
How Does a Program Run a Program?
다시 프로그램을 실행시키는 곳으로 돌아와 보겠습니다.
여기서는 execvp()로 예를 들겠습니다.
프로그램이 execvp()를 호출하면 execvp()함수는 execvp( 실행파일이름, 명령어 배열) 형태로 호출됩니다.
그럼 커널은 먼저 해당 이름을 가진 프로그램을 디스크에서 프로세스로 로드하고,
그다음 프로세스에 명령어 배열을 복사해 넣습니다.
그후 프로그램을 실행시키기 위해 커널은 main(argc, argv)를 호출합니다.
프로그램이 실행되면 커맨드라인으로 받은 명령어(shell에 단어들로 파싱됨)들은 2개의 명령행 인자로 나뉘어 main 함수에 들어갑니다.
첫 번째 명령행 인자는 int argc 형태로, 커맨드라인으로 받은 명령어, 즉 shell이 파싱한 결과 단어가 몇 갠지를 가리킵니다.
두 번째 명령행 인자는 char *argv[] 형태로, shell이 파싱한 단어들을 문자열 배열로 저장합니다.
이런 과정을 거쳐 프로그램이 현재 프로세스 위치로 들어가 실행됩니다.
프로세스의 메모리 위치는 exec()로 호출한 프로그램의 공간 요구사항에 맞게 변경됩니다.
Writing the First Shell
shell 프로그램을 써 보기 위해 사용#자에게 프로그램 이름과 명령어를 묻고, 해당 프로그램을 실행시키는 프로그램을 짜 보겠습니다.
#include <stdio.h> #include <stdlib.h> #include <signal.h> #include <string.h> #include <unistd.h> #define MAXARGS 20 #define ARGLEN 100 int execute( char ** ); char *makestring( char * ); int main() { char *arglist[MAXARGS+1]; int numargs; char argbuf[ARGLEN]; char *makestring(); numargs = 0; while ( numargs < MAXARGS ) { printf("Arg[%d]? ", numargs); if ( fgets(argbuf, ARGLEN, stdin) && *argbuf != '\n') //명령어 배열 만들기 arglist[numargs++] = makestring(argbuf); else { if( numargs > 0 ) { //명령어 입력이 끝나면 arglist[numargs] = NULL; //명령어 배열 close execute( arglist ); //실행 numargs = 0; } } } return 0; } int execute( char *arglist[] ) { execvp(arglist[0], arglist); //execvp로 실행 perror("execvp failed"); exit(1); } char *makestring( char *buf ) //입력받은 명령어 문자열로 치환 { char *cp; buf[strlen(buf)-1] = '\0'; cp = malloc( strlen(buf)+1 ); if( cp == NULL ){ fprintf(stderr, "no memory\n"); exit(1); } strcpy(cp, buf); return cp; }
간단하게 명령어를 입력받아 문자열 배열로 만들고,
해당 명령어 배열을 execvp에 전달해 프로그램을 실행시키는 코드입니다.
실행시켜 보면,
이렇게 명령어를 준 대로 잘 실행하는 것을 볼 수 있습니다.
그럼 이제 fork()를 사용해 새로운 프로세스를 만든 뒤, 거기서 execvp()로 새 프로그램을 실행시켜 볼까요?
How Do We Get a New Process
코드를 작성하기 전에 잠깐 fork()에 대해 복습하고 넘어가겠습니다.
fork()를 호출한 프로세스는 부모 프로세스, fork()에 의해 새로 생성된 프로세스는 자식 프로세스입니다.
부모와 자식 프로세스는 각각 다른 메모리 공간에서 돌아갑니다.
fork()된 순간에는 아직 자식 프로세스에 어떤 처리도 들어가지 않은 상태이기 때문에 같은 내용을 가지고 있습니다.
이후 자식 프로세스는 exec() 같은 함수들을 만나 새로운 프로그램을 실행하고,
부모 프로세스는 wait()을 만나 자식을 기다리는 등의 동작을 하겠죠?
일단 fork()가 호출되면, 커널은
먼저 새로운 메모리 공간을 할당하고 이에 대한 자료구조를 만듭니다(메모리 할당 정보를 담을)
그 다음 해당 공간(새 프로세스 공간)에 부모 프로세스의 명령어 코드와 데이터를 복사합니다.
실행 중인 프로세스 집합에 새 프로세스를 추가한 뒤,
제어권을 두 프로세스에게 줍니다.
그림으로 보면 다음과 같습니다.
그럼 fork()를 활용해 프로그램을 짜보겠습니다.
#include <stdio.h> #include <unistd.h> int main() { int ret_from_fork, mypid; mypid = getpid(); //parent process pid printf("Before: my pid is %d\n", mypid); ret_from_fork = fork(); //child process pid sleep(1); printf("After: my pid is %d, fork() said %d\n", getpid(), ret_from_fork); return 0; }
해당 프로그램을 실행시켜 보면
처음 fork()를 호출한 프로세스, 즉 부모 프로세스의 pid는 781이고,
fork()가 pid 782를 가진 자식 프로세스를 생성했음을 알 수 있습니다.
fork()를 여러 번 연속으로 호출해 볼까요?
#include <stdio.h> #include <unistd.h> int main() { printf("my pid is %d\n", getpid()); fork(); fork(); fork(); printf("my pid is %d\n", getpid()); return 0; }
fork()를 3번 호출했더니 826, 827, 828, 829, 830, 831, 832, 833 총 8개의 프로세스가 생긴 것을 확인할 수 있습니다.
그림과 같이 자식 프로세스가 다시 부모프로세스가 되어 fork()를 호출하고, 그 자식 프로세스가 다시 부모가 되어 fork()를 호출하는 과정을 반복해 2의 지수승 단위로 커지기 때문입니다.
fork()를 3번 호출했으므로 2^3 = 8(개)의 프로세스가 생기는 것이 맞습니다.
위 두 코드에서 부모 프로세스의 pid는 getpid()로, 자식 프로세스의 pid는 fork()에서 받아오고 있습니다.
fork()를 실행시키면, 부모 프로세스에서는 자식 pid가 반환되고, 자식 프로세스에서는 0이 반환됩니다.
이를 이용해 두 프로세스에서 각기 다른 동작을 시킬 수 있습니다 .
#include <unistd.h> #include <stdio.h> int main() { int fork_rv; printf("Before: my pid is %d\n", getpid()); fork_rv = fork(); if ( fork_rv == -1 ) perror("fork"); else if ( fork_rv == 0 ) //child process printf("I am the child. my pid = %d\n", getpid()); else //parent process printf("I am the parent. my child is %d\n", fork_rv); return 0; }
실행결과를 보면 fork()로 반환받은 값이 0인 경우 getpid()로 구한 값과, 그냥 바깥에서 fork()로 반환받은 값이 같은 것을 볼 수 있습니다. 이렇게 반환받은 값을 활용해 두 프로세스에게 각기 다른 동작을 시킬 수 있습니다.
끝!
'시스템프로그래밍' 카테고리의 다른 글
[시스템프로그래밍] Curses (1) 2023.12.16 [시스템프로그래밍] Wait (0) 2023.12.14 [시스템프로그래밍] Computer Memory and Program (0) 2023.12.13 [시스템프로그래밍] Process (0) 2023.12.13 [시스템프로그래밍] Signals (0) 2023.12.12