시스템 프로그래밍 1. 멀티 프로세스
건국대학교 시스템 프로그래밍 진현욱 교수님의 수업을 정리한 내용입니다.
[!question] 멀티 프로세스를 왜 사용하는가?{title} Shell과 같은 소프트웨어를 만들 때 반드시 필요하다. 쉘을 통해 실행파일을 실행하면, 쉘이 그 프로세스를 관리할 수 있어야 한다.
따라서 자식 프로세스를 만들어 사용해야 한다. 이것이 하나의 프로그램에 여러개의 프로세스를 실행해야 할 이유이다.
Fork
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <unistd.h>
#include <sys/types.h>
pid_t rpid = fork();
if (rpid == 0)
{
/* child */
}
else
{
/* parent */
}
pid_t getpid(void)
: 현재 프로세스의 PID를 얻는다.
여러개의 프로세스를 어떻게 만드는가? 기본적으로, 자식 프로세스(Child Process)를 만드는 개념이다. 자식 프로세스를 만든 프로세스는 부모 프로세스(Parent Process)가 된다.
자식 프로세스와 부모 프로세스를 묶어서, 하나의 Process Group가 만들어진다. PGID가 존재한다.
fork()
함수 호출시 Child Process를 생성한다. 자식 프로세스에게는 0을 반환하고, 부모 프로세스에게는 자식 프로세스의 PID를 반환해준다. 자식 프로세스의 PID는 이때밖에 얻을 수 없다. 나중에 이 PID를 사용해서 자식 프로세스에 접근해야 하기 때문에, Fork를 하면 따로 저장해 두는게 좋다. 그에 비해 본인의 PID는 getpid()
함수로 쉽게 알 수 있다.
fork()
함수가 실패하는 케이스는 다음과 같다.
- 프로세스 개수가 너무 많은 경우 : EAGAIN
- 프로세스를 만들려고 했는데 메모리가 부족한 경우 : ENOMEM
- 운영체제가 fork 인터페이스를 구현해주지 않은경우. 윈도우나 리눅스같은 검증된 운영체제에선 뜰 수 없긴 함 : ENOSYS
생성 원리는, 자식 프로세스는 자가 세포분열 마냥 부모의 Context 정보를 그대로 복사해서 가져간다.
코드를 실행하던 도중 fork()
함수를 만나면 Context가 그대로 복사된다. Virtual Address space 또한 그대로 복사된다. 따라서 부모 Process의 rpid 메모리 주소와, 자식 Process의 rpid 메모리 주소는 동일하지만, 서로 다른 Adress Space이므로 실제 메모리 주소는 서로 다른 곳을 가르키고 있다.
후에 자식 프로세스가 종료되면, 부모 프로세스가 이를 감지해서 Reaping, 자원 할당 해제를 해줘야한다. 그렇지 않으면, 자식 프로세스가 Zombie Process가 된다.
[!question]- 자식 프로세스가 종료되면 알아서 자원 할당 해제되도록 만들면 안되나?{title} 물론 그래도 되는데, 일부로 이렇게 설계한 이유가 있다.
자식 프로세스가 정상적으로 종료되는 경우는 exit(int), return int;로 종료했을 때다.
어차피 자식 프로세스에서 반환한 인자값을 부모에서 받아야하는 과정이 있어야 하므로, 받는 김에 자식 메모리 할당까지 같이 수행해주는 것이다.
만약 자식 프로세스가 예기치 않게 종료되서 오류코드를 반환했을 때, 이 인자값을 부모에서 일단 받을 수 있어야 다시 실행할지 말지 판단해야 할 수 있다. 따라서 인자값을 받는 과정은 꼭 필요하다.
[!example]- Example 1{title}
자식 프로세스는 부모 프로세스의 Address Space를 똑같이 복사해서 사용하기 때문에 x의 주소값은 똑같은 값을 반환하지만, x값은 Child has x = 2, Parent has x = 0을 출력한다. 메모리가 공유되지 않는다는 증거다.
[!example]- Example 2{title}
위 함수가 실제 동작하는 과정은 다음과 같다.
- 한개의 L0이 출력된다.
- 이후 fork를 사용해서 하나의 Process를 또 만들었다.
- Parent와 Child Process 둘다 L1을 출력한다.
- 두 프로세스가 각각 fork를 실행하기 때문에 총 4개의 프로세스가 생성되고, 생성된 4개 프로세스가 모두 Bye를 출력하여 Bye는 4개가 출력된다.
- 하지만 실행되는 순서는 스케쥴러(Scheduler)가 관리하기 때문에 알 수 없다.
Zombie Process
프로세스가 종료되었음에도 불구하고 메모리상에서 프로세스에 대한 정보가 사라지지 않은 상태를 Zombie Process라고 한다.
왜 좀비 프로세스가 생기는가? Child Process가 종료되는 상황은 두가지가 존재한다.
- exit 함수를 호출하는 경우
- main 함수가 return하는 경우
exit 함수로 전달되는 인자값, return이 반환하는 인자값은 바로 지워지면 안되고, 부모 프로세스에게 전달되어야 한다. 따라서 이 전달되는 동안 자식 프로세스는 지워지지 않는다. 부모 프로세스가 리턴값을 받고, 자식 프로세스에 할당된 자원을 완전히 다 해제해 줘야 한다. 만약 부모 프로세스가 해제해주지 않는다면, 자식 프로세스는 종료되었지만 계속 메모리에 자원이 할당되어있는 상태가 된다.
좀비 프로세스가 생기면 문제점은 무엇인가? 좀비 프로세스가 계속 늘어나면 사용하고있는 프로세스 개수가 계속 늘어나고, 점점 늘어나다 보면 프로세스 제한의 최대치가 걸려 더이상 Child Process가 생성되지 않는 경우가 있다. 만약 이런 서버가 있으면 계속 재부팅을 해줘야 겨우겨우 돌아갈 것이다..
[!example]- 실제 좀비 프로세스 예제{title}
1 2 3 4 5 6 7 8 9 10 11 12 13 void fork() { if (fork() == 0) { printf("im Child!\n"); exit(0); } else { printf("Running Parent"); while (1) /* Reaping을 하지 않고 작업을 계속 진행함 */ } }위 코드를 실행해보면 좀비 프로세스가 생긴다.
<defunct> = 좀비 프로세스 상태
tcsh는 리눅스의 Shell 프로그램이고, ps는 프로세스를 보여주는 프로세스. 얘는 이거 띄우면 바로 종료됨.
kill = 시그널을 보내는 명령어 딱히 무슨 시그널을 보낼지 명시하지 않으면 자동으로 프로세스를 종료하라 라는 시그널을 보냄
kill 6639 하면, 6639의 부모 process인 tcsh(쉘 프로그램)이 Reaping을 해준다. 6640은 부모 프로세스가 죽었으므로 init process가 대신 Reaping 해줌.
Reaping 방법
부모 프로세스가 종료된 자식 프로세스의 자원 할당을 해제해주는 것을 Reaping이라고 한다. Reaping 방법에는 크게 두가지가 존재한다.
- System Call을 사용한다.
int wait(int_p child_status)
int waitpid(pid_t pid, int_p child_status, int options)
- 부모 프로세스를 종료한다.
만약 부모 프로세스가 Reaping을 하지 않은 채 종료된다면, init process가 종료된 프로세스가 갖고있던 자식 프로세스를 자동으로 Reaping해준다.
[!question] What is Init Process?{title} 태초의 Process
시스템이 실행되면 가장 먼저 실행되는 Process이다. init process 생성 후 다른 여러 기본 프로세스가 이 init process에서 fork되어 만들어진다.
초기에 실행되자마자 이것저것 초기화 작업을 거치고 난 뒤에는, 별 일을 하진 않는다. 그렇다고 삭제가 되진 않고 시스템이 종료될 때까지 실행된다.
Blocking 방법
int wait(int* child_status)
bool WIFEXITED(int child_status)
bool WEXITSTATUS(int child_status)
wait()
함수를 사용하면 자식 프로세스가 아무거나 하나 종료될 때까지 부모 프로세스가 대기한다. 즉, 자식 프로세스가 종료되지 않고 계속 실행되면 계속 멈춰있는다.
자식 프로세스가 하나 종료되면, int child_status에 종료 정보를 저장하고, 종료된 자식 프로세스의 PID를 반환하며 함수가 끝난다.
따라서 wait()
함수는 함수 이름과 다르게 자식 프로세스가 종료될 때까지 기다리고, 종료되면 Reaping까지 해주는 두가지 역할을 수행한다.
[!example]- 사용 예제 코드{title}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 void fork() { int child_status; if (fork() == 0) { printf("im child!\n"); } else { printf("im parent!\n"); pid_t wpid = wait(&child_status); if (WIFEXITED(child_status)) { printf("%d Child is Reaped!\n", wpid); printf("Return value : %d\n", WEXITSTATUS(child_status)); } else { printf("%d Child Is NOT REAPED!!!!\n", wpid); } } }
wait()
함수 실행 후 반환된 child_status값을 WIFEXITED()
또는 WEXITSTATUS()
함수를 사용하여 분석할 수 있다. WIFEXITED()
는 프로세스가 정상적으로 종료되었는지 그 여부를 반환한다. WEXITSTATUS()
는 exit할 때 넣은 숫자를 반환한다. 만약 자식 프로세스를 종료할 때 exit(110)으로 종료했다면, 함수는 110을 반환한다.
Non-Blocking 방법
int waitpid(pid_t pid, int* child_status, int options)
자식 프로세스를 실행해두고, 부모 프로세스도 계속 작업을 할 수 있다.
자식 Child가 종료되면 부모 프로세스에게 SIGCHLD
시그널이 전송된다. SIGCHLD
의 Signal Handler에 Reaping하는 로직을 넣어서 자동으로 Reaping되도록 구현이 가능하다. Signal에 대한 내용은 다음 게시글에서 자세히 설명한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void handle_sigchld(int sig) {
int status;
pid_t pid;
// 자식 프로세스가 종료될 때마다 그 상태를 수집
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
printf("Child process %d terminated with status: %d\n", pid, WEXITSTATUS(status));
}
}
int main() {
signal(SIGCHLD, handle_sigchld); // SIGCHLD 시그널을 처리할 핸들러 설정
pid_t pid = fork();
if (pid == 0) {
// 자식 프로세스
printf("Child process\n");
sleep(2); // 자식 프로세스가 2초 후에 종료
_exit(0);
} else {
// 부모 프로세스는 계속 실행하며, 자식 프로세스의 종료 상태를 시그널로 처리
while (1) {
printf("Parent process doing other work...\n");
sleep(1);
}
}
return 0;
}
waitpid()
함수는 사용하는 방법에 따라 Blocking 방법이 될 수 있다.
만약 pid 인자에 PID를 넣을 경우, 특정한 PID를 가진 자식 프로세스가 종료될때까지 Blocking하고, 받으면 Reaping 한다.
만약 pid 인자에 -1를 넣을 경우, int wait(int_p child_status)처럼 아무 프로세스가 종료될 때까지 Blocking하고, 하나 받으면 Reaping 후 Reaping한 Process의 PID를 반환한다.
반환값은 Reaping하지 않았으면 0, Reaping했으면 해당 Process의 PID, 버그 나면 -1을 반환한다.
[!question]
wait()
함수 뿐만 아니라waitpid()
함수가 있는 이유는?{title} Non-Blocking한 방식으로 사용할 수 있기 때문이다.options에 WNOHANG를 넣으면, Reaping할게 없어도 기다리지 않고 바로 리턴한다.
errno
가장 최신의 에러가 발생한 코드를 저장하는 global variable이다. 이 변수에는 가장 최근에 발생한 오류 코드를 담고 있음.
1
2
3
4
5
6
7
8
9
10
rpid = fork();
if (rpid == 0)
{
/* child 코드 실행 */
}
else
{
/* paremt 코드 실행 */
}
[!warning] errno 사용시 주의사항{title} 값이 계속 덮어씌워지기 때문에 과거의 에러 코드를 확인할 길이 없다는 것이고 , 에러 코드를 이용해서코드를 짜두면 중간에 값이 오버라이드되면 예기치 못한 동작이 발생할 수 있다.
따라서. errno를 직접 쓰기 까다롭기 때문에 만든 함수가 바로 perror 함수이다.
execl
int execl(char* path, char* arg0, char* arg1, ..., 0)
path
: 경로. 문자열을 넣으면 됨.arg0
: 커맨드 이름. 보통 실행파일 이름과 똑같다.arg1, arg2, ...
: main 함수에 보내는 인자값.- 맨 마지막에는 인자의 끝을 알리는 0을 넣어줘야 한다.
path 경로에 있는 exe 파일을 실행한다. 만약 실행이 실패되면 -1을 리턴하고, 실행이 성공되면 현재 실행되고 있는 프로세스에서 새로운 프로세스를 덮어쓴다. 즉, 실행되는 순간 새로운 Address Space가 덮어씌워지며 기존에 갖고있던 변수, 코드 등이 싹 날아가고 새로운 exe 파일의 main 함수가 시작된다.
보통의 경우 기존에 실행되던게 완전히 날아가는걸 원하는 경우는 거의 없다. 따라서 fork()
와 execl()
을 같이 사용하는 패턴이 많다. 다음은 사용 예제 코드다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
rpid = fork();
int child_status;
if (rpid == 0)
{
// Child
execl("usr/bin/cp", "cp", "foo", "bar", 0);
}
// execl이 성공할 경우 어차피 뒤에 실행을 못하기 때문에 조건문을 넣지 않아도 된다.
wait(&child_status);
if (WIFEXITED(child_status))
printf("Copy Completed!");
else
printf("ERROR");
이렇게 사용하면 부모는 그대로 가져가면서, 자식 프로세스가 다른 프로그램을 실행하도록 할 수 있다. 이 방법으로 Shell을 구현할 수 있다.
[!tip] execl이 System Call 함수가 아닌 이유{title} 정확히는,
execve()
라는 System Call을 더 편하게 쓰기 위해 만든 Library 함수다.이렇게 사용하기 불편한 함수를 더 사용하기 좋게 만든 다른 함수들을 감싼 라이브러리를 Wrapper Library function라고 한다.