프로세스의 생성과 복사를 이해하기 위해선 우선 시스템 프로그래밍을 이해해야 한다. 프로세스의 생성과 복사를 설명하기 전에 우선 프로세스의 구조를 파악하자.
프로세스의 구조
프로세스는 코드 영역, 데이터 영역, 스택 영역으로 구성된다. 데이터 영역은 다신 일반 데이터 영역과 힙 영역으로 구분된다.
영역 종류 | 설명 |
코드 영역 | 프로그램의 본문이 기술된 역역으로 텍스트 영역(text area)이라고도 한다. 프로그램이 해당 영역에 탑재되고 읽기 전용으로 처리된다. 프로그램이 자기 자신을 수정할 수 없기 때문이다. |
데이터 영역 | 코드가 실행되면서 사용하는 변수나 파일 등의 각종 데이터를 모아놓은 곳이다. 이 영역의 데이터는 읽기/쓰기가 모두 가능하다. 다만, 상수로 선언된 변수는 읽기 전용이다. |
스택 영역 | OS가 프로세스를 실행하기 위해 필요한 기타 데이터를 모아놓은 곳이다. 운영체제가 사용자의 프로세스를 작동하기 위해 유지하는 영역이기 떄문에 사용자에게 보이지 않는다. |
프로세스의 생성과 복사
사용자가 프로그램을 실행하면 OS는 프로그램을 메모리로 가져와 코드 영역에 넣고 PCB를 생성한다. 그 후, 데이터 영역과 스택 영역을 확보하고 프로세스를 실행한다.
프로세스를 새로 실행하는 것뿐만 아니라 복사하는 방법도 존재한다.
fork() 시스템 호출의 개념
fork() 시스템 호출은 실행 중인 프로세스로부터 새로운 프로세스를 복사하는 함수다. 크롬에서 command + n을 누르면 크롬이 하나 더 생기는 것이 fork()를 사용한 예시다. 이렇게 생성한 프로세스는 부모-자식 관계를 가진다. 기존 프로세스가 부모, 새로 복사된 프로세스가 자식이다.
fork() 시스템 호출의 동작 과정
fork()를 호출하면 부모 프로세스 영역의 대부분이 자식 프로세스에게 복사된다. 이때, PCB 중 다음과 같은 일부 데이터는 변경이 된다.
- PID가 바뀐다
- 메모리 관련 위치가 바뀐다. 부모 프로세스와 자식 프로세스가 차지하는 메모리 위치는 다르다.
- 부모 프로세스 구분자(PPID)와 자식 프로세스 구분자(CPID)가 바뀐다. 부모 프로세스의 CPID는 fork()를 통해 생성한 자식 프로세스의 PID이다. 자식 프로세스의 PPID는 자신을 생성한 부모 프로세스의 PID이다. 자식 프로세스가 또 다른 자식 프로세스를 가지지 않는다면 자식 프로세스의 CPID는 -1이다.
fork() 시스템 호출의 장점
새로운 프로세스를 만드는 대신 fork()를 이용해 복사한다면 다음과 같은 이점을 가진다.
- 프로세스 생성 속도가 빠르다
하드디스크로 부터 가져오는 대신 메모리를 복사했기 때문에 자식 프로세스 생성 속도가 빠르다.
- 추가 작업 없지 자원을 상속할 수 있다.
부모 프로세서가 사용하던 모든 자원을 추가 작업 없이 자식 프로세스에 상속할 수 있다.
- 시스템 관리를 효율적으로 할 수 있다.
부모 프로세스와 자식 프로세스가 CPID와 PPID로 연결되 있기 때문에 자식 프로세스를 종료하면 사용하던 자원을 부모 프로세스가 정리할 수 있다. 정리를 부모 프로세스에 맡기기 때문에 시스템이 효율적으로 관리된다.
fork() 시스템 호출의 예
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
|
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
int pid;
// 부모 프로세스에게 0보다 큰 값을 반환한다
// 자식 프로세스에 0을 반환한다. 반환값이 0보다 작으면 생성되지 않았음을 의미한다.
pid = fork();
if (pid < 0 ) {
printf("%d Error\n", pid);
exit(-1);
} else if (pid == 0) {
printf("%d Child\n", pid);
exit(0);
} else {
printf("%d Parent\n", pid);
exit(0);
}
}
// Output:
// 345295 Parent
// 0 Child
|
cs |
fork() 사용 시 다음과 같은 점들을 주의해야 한다.
fork() 문 이전에 파일을 열거나 변수를 선언하면, 이들은 자식 프로세스에게 상속된다. 부모 프로세스와 자식 프로세스는 서로 독립적이기에 "Parent", "Child" 중 어느 것이 먼저 실행되는지 알 수 없다.
프로세스의 전환
exec()를 사용하면 현재 프로세스가 완전히 다른 프로세스로 전환된다. exec()를 사용하면 새로 프로세스를 생성하는 것에 비해 PCB, 메모리 영역, 부모-자식 관계를 그래도 사용하고, 새로운 코드 영역만 가져올 수 있어서 프로세스의 구조체를 재활용할 수 있다.
exec()를 호출하면 코드영역은 새로운 코드로 채워지고, 데이터 영역은 새로운 변수로 채워지고, 스택 영역은 리셋된다. 또 한, 프로그램 카운터 레지스터 값을 비롯해 레지스터와 사용한 파일 정보는 모두 리셋된다.
exec() 시스템 호출의 예
exec() 시스템 호출 예시를 위해 execlp()를 사용해 보자. execlp()는 path에 등록된 디렉터리를 참고해 다른 프로그램을 실행시킨다. exec()의 원형은 다음과 같다.
1
2
3
4
|
// 첫 번째 인자 path에 등록 된 모든 프로그램을 실행시킨다.
// arg는 실행될 프로그램에 넣을 argument 이다.
// 실패하면 -1 반환.
int execl( const char *path, const char *arg, ...)
|
cs |
이 예시를 실행하기 위해 exec() 시스템 콜로 자식 프로세스가 실행할 프로그램을 지정하자. 다음과 같은 간단한 코드를 작성 후 이를 "helloWorld"라는 이름의 오브젝트 파일로 만들자.
1
2
3
4
5
6
|
#include <stdio.h>
int main(int argc, char* argv[]) {
printf("hello %s\n", argv[0]);
return 0;
}
|
cs |
이제 예시 다음과 같이 예시를 작성하고 실행해보자.
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
|
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
int pid;
pid = fork();
if (pid < 0) {
printf("%d Error\n", pid);
exit(-1);
} else if (pid == 0) {
execlp("./helloWorld", "world", NULL);
exit(0);
} else {
wait(NULL);
printf("mplayer Terminated\n");
exit(0);
}
}
// output:
// hello world
// mplayer Terminated
|
cs |
위 예시 동작 과정은 다음과 같다.
1. 부모 프로세스에서 fork() 문을 실행해 자식 프로세스를 생성한다.
2. wait() 문을 실행해 지식 프로세스가 끝날 때까지 기다린다. wait()는 자식 프로세스와 동기화를 위한 코드로, 자식 프로세스가 끝날 때까지 부모 프로세스가 기다린다.
3. 새로 생성된 자식 프로세스는 부모 프로세스와 같지만 execlp()가 실행되는 순간, 자식 프로세스의 코드 영역은 helloWorld의 코드로 바뀌고 처음부터 다시 실행된다.
4. helloWorld의 실행이 끝나면 부모 프로세스의 wait()가 있는 곳으로 돌아오고 부모 프로세스는 "mplayer Terminated"를 출력한다. 다시 부모 프로세스로 돌아올 수 있는 이유는 PCB의 프로세스 구분자(PID, PPID, CPID)가 변경되지 않기 때문이다.
프로세스의 계층 구조
위에서 살펴본 프로세스 복사와 전환은 프로세스 생성과 계층 구조를 이해하는 데 중요한 열쇠가 된다. 이제 유닉스를 통해 프로세스의 계층 구조를 알아보자.
유닉스의 프로세스 계층 구조
유닉스에서 커널이 처음 메모리에 올라와 부팅되면 커널 관련 프로그램을 여러 개 만단다. 그중 init 프로세스가 전체 프로세스의 출발점이 된다. init 프로세스가 생성되면 init이 나머지 프로세스를 자식 프로세스로 생성한다. 따라서 OS의 모든 프로세스는 init의 자식 프로세스다.
프로세스 계층 구조의 장점
프로세스의 계층 구조는 동시에 여러 작업을 처리하고 종료된 프로세스의 자원을 회수하는 데 유용하다.
여러 작업의 동시 처리
위 계층구조에서 login 프로세스는 동시에 한 명만 처리할 수 있다. 그래서 동시에 여러 사용자가 접근한다면, OS는 fork() 시스템 호출을 이용해 login 프로세스를 여러 개 만들어 대응한다.
login 프로세스를 사용한 뒤에는 shell 프로세스가 필요하다. shell이 존재해야 사용자가 OS에 명령을 내리고 결과를 받을 수 있다. 따라서 login 프로세스를 종료하고 shell 프로세스를 생성해야 하는데, 이때 효율적으로 login 프로세스에 할당한 메모리를 재활용하기 위해 exec() 시스템 호출을 사용한다.
이후 shell 프로세스에서 명령어로 애플리케이션을 실행할 때도 fork()와 exec()를 사용한다.
용이한 자원 회수
프로세스를 계층 구조로 만들면 프로세스 간의 책임 관계가 명확해져 시스템을 수월하게 관리할 수 있다. 프로세스가 부모-자식 관계를 가지면 부모 프로세스가 자식 프로세스를 회수하면 되지만, 이런 관계가 없다면 OS가 매번 직업 자원을 회수해야 한다.
고아 프로세스
만약 부모 프로세스가 먼저 동료 되거나, 자식 프로세스가 비정상 종료되면 자식 프로세스가 사용하던 자원이 그대로 남아있게 된다. 이렇게 프로세스가 종료 후에도 남아있는 프로세스는 두 종류로 나뉜다.
- 고아 프로세스(orphan process): 부모 프로세스가 자식보다 먼저 죽는 경우
- 좀비 프로세스(zombie process): 자식 프로세스가 종료했음에도 부모가 자원을 수거하지 않는 경우
C언어에서 main 함수 끝에 작성하는 return 또는 exit는 자식 프로세스가 끝났음을 알려서 부모 프로세스가 자원 정리나 자식 프로세스와의 동기화를 할 수 있다.
출처 - 쉽게 배우는 운영체제