여기서는 UNIX 시스템에서의 프로세스 생성에 대해 논의한다. UNIX 는 fork()와 exec() 라는 시스템 호출을 사용하여 새 프로세스를 생성하고 wait()를 사용하여 생성된 프로세스를 대기시킨다.
운영체제는 프로세스 생성,제어를 위해 어떤 인터페이스를 제공해야하는지, 인터페이스는 어떨게 설계되었는지에 대해 집중해보자
fork() 시스템 호출은 새로운 프로세스를 생성하는데 사용된다.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
printf("hello (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) {
// fork failed
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
// child (new process)
printf("child (pid:%d)\n", (int) getpid());
} else {
// parent goes down this path (main)
printf("parent of %d (pid:%d)\n", rc, (int) getpid());
}
return 0;
}
이 프로그램(p1.c)을 실행하면 다음의 결과를 볼 수 있다.
prompt> ./p1
hello (pid:29146)
parent of 29147 (pid:29146)
child (pid:29147)
프로그램이 실행되면 hello 메시지와 프로세스 식별자인 PID 를 출력한다.
UNIX 시스템에서 PID 는 프로세스를 지칭하는데 사용된다.
그 다음 프로세스는 fork() 시스템 호출을 한다. 이것은 OS가 새로운 프로세스를 생성하게 한다.
이상한 점은 fork() 시스템 호출로 생성된 프로세스는 현재 실행중인 프로세스와 거의 동일한 복사본이라는 점이다. 두 프로세스는 모두 fork() 시스템 호출을 return 한다. 생성된 프로세스는 main()에서 실행되지 않았고, fork()를 직접 호출한 것처럼 살아난다. (생성된 프로세스는 hello메시지를 출력하지 않았다.)
자식 프로세스(생성된 프로세스)는 실행중인 프로세스의 정확한 사본이 아니다. 자식 프로세스는 자체의 주소공간(메모리), 레지스터, PC 등을 갖고 있지만 fork() 호출자에 반환하는 값은 다르다. 부모 프로세스는 자식의 PID를 받고, 자식 프로세스는 반환 코드로 0을 받는다.
위의 예시에서는 부모 프로세스가 먼저 실행되어 메시지를 출력했다. 하지만 아래 예시(p2.c)에서는 wait()를 사용했기에 그 반대의 경우가 일어났다.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(int argc, char *argv[]) {
printf("hello (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) { // fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) { // child (new process)
printf("child (pid:%d)\n", (int) getpid());
} else { // parent goes down this path
int rc_wait = wait(NULL);
printf("parent of %d (rc_wait:%d) (pid:%d)\n", rc, rc_wait, (int) getpid());
}
return 0;
}
prompt> ./p1
hello (pid:29146)
child (pid:29147)
parent of 29147 (pid:29146)
CPU 스케줄러는 어떤 프로세스가 실행될지 결정한다. 이 결정을 함에 있어서 결정적인 가정을 할 수 없는 문제가 있다. 특히 다중 스레드 프로그램에서 다양한 문제를 야기하는데, 동시성을 공부할 때, 더 많은 결정적이지 않음을 볼것이다.
wait() 시스템 호출을 통해 부모 프로세스가 자식 프로세스의 작업이 완료되기를 기다리게 할 수 있다. 위의 p2.c 프로그램의 예시에서 자식이 작업을 마치면 wait()는 부모로 반환된다. wait()호출을 추가함에 따라 출력을 결정적으로 만든 것이다. 아래는 p1.c와 p2.c의 코드 비교

exec() 시스템 호출은 호출한 프로그램과 다른 프로그램을 실행하려는 경우 유용하다. 예를 들어, p2.c에서 fork()를 호출 하는 것은 동일한 프로그램의 복사본을 계속 실행하고 싶을때만 유용하다. 하지만, 다른 프로그램을 실행하고 싶을 수가 있는데 exec()를 통해 달성할 수 있다.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
int main(int argc, char *argv[]) {
printf("hello (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) { // fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) { // child (new process)
printf("child (pid:%d)\n", (int) getpid());
char *myargs[3];
myargs[0] = strdup("wc"); // program: "wc"
myargs[1] = strdup("p3.c"); // arg: input file
myargs[2] = NULL; // mark end of array
execvp(myargs[0], myargs); // runs word count
printf("this shouldn’t print out");
} else { // parent goes down this path
int rc_wait = wait(NULL);
printf("parent of %d (rc_wait:%d) (pid:%d)\n",
rc, rc_wait, (int) getpid());
}
return 0;
}
이 예시에서 자식 프로세스는 단어 수를 세는 프로그램 wc를 실행하기 위해서 execvp()를 호출한다. p3.c에서 wc가 실행되며, 파일에 얼마나 많은 줄,단어,바이트가 있는지 알려준다.
execvp(myargs[0],myargs); 를 통해 프로그램 wc의 인자로 p3.c라는 파일이 전달되고
자식 프로세스29384의 PCB는 wc의 것으로 덮어 씌워진다.
29384 의 코드와 데이터는 wc것으로 대체되고 Heap,Stack,Address Space 는 새로운 프로그램을 위해 초기화 된다.
따라서 printf("this shouldn’t print out"); 이 부분이 실행되지 않는다.
prompt> ./p3
hello (pid:29383)
child (pid:29384)
29 107 1030 p3.c
parent of 29384 (rc_wait:29384) (pid:29383)
따라서 새로운 프로세스를 생성하지 않고 현재 실행 중인 프로그램을 다른 프로그램으로 변환해준다.
fork() 와 exec()의 분리는 UNIX 셸을 구축하는데 필수적이다. 셸이 fork() 호출 후, exec()가 호출되기 전에 프로그램의 환경을 바꿀 수 있는 코드를 실행할 수 있게 하기 때문이다.
셸은 다니 사용자 프로그램이다. 프롬프트를 표시하고, 입력을 기다린다. 명령(실행 파일 이름과 인수)이 입력되면 실행 파일이 위치한 파일 시스템을 확인하고, fork()를 호출하여 명령을 실행할 자식 프로세스를 생성한 뒤, 명령을 실행하기 위해 exec()의 변형을 호출하고, 완료될때 까지 wait()을 호출하여 대기한다. 자식 프로세스가 완료되면, 셸은 wait()에서 반환 되어 다시 프롬프트를 출력하고, 다음 명령을 기다린다.
prompt> wc p3.c > newfile.txt
위의 예시는 wc의 출력이 newfile.txt 에 저장된다.
아래 코드는 위의 예시를 하게하는 프로그램(p4.c)이다.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <sys/wait.h>
int main(int argc, char *argv[]) {
int rc = fork();
if (rc < 0) {
// fork failed
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
// child: redirect standard output to a file
close(STDOUT_FILENO);
open("./p4.output", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);
// now exec "wc"...
char *myargs[3];
myargs[0] = strdup("wc"); // program: wc
myargs[1] = strdup("p4.c"); // arg: file to count
myargs[2] = NULL; // mark end of array
execvp(myargs[0], myargs); // runs word count
} else {
// parent goes down this path (main)
int rc_wait = wait(NULL);
}
return 0;
}
자식이 생성될 때, exec()를 호출하기 전에 자식 프로세스의 코드는표준 출력을 닫고 파일 newfile.txt를 연다. 이렇게 하면 wc의 출력이 파일로 전송된다.
UNIX시스템은 0번 부터 free file descriptors를 찾는다. 이때, STDOUT FILENO 가 사용가능한 첫번째 파일 기술자가 되며,open()이 호출될 때 할당된다. 예를 들어, printf()와 같은 루틴에 의한 자식 프로세스의 표준 출력 파일 기술자는 화면이 아닌 파일로 경로가 바뀐다.(원문: be routed)
P4.c를 실행하면 아무 일도 일어나지 않는 것처럼 보이지만 출력 파일을 cat하면 wc의 결과가 출력된다. (cat p4.output p4.output의 내용을 출력)
prompt> ./p4
prompt> cat p4.output
32 109 846 p4.c
UNIX 파이프도 비슷한 방식으로 구현 된다. 그러나 pipe() 시스템 호출로 사용한다.
이 경우, 한 프로세스의 출력이 내부 커널 파이프(큐)에 연결되고, 다른 프로세스의 입력이 해당 파이프에 연결되어 한 프로세스의 출력이 다음 프로세스의 입력으로 자연스럽게 사용된다.
예로 파일에서 단어를 찾고 세는 것을 생각해보라 명령 프롬프트에 grep -o foo file | wc -l 를 입력하면 해결된다.
지금은 fork()와 exec()의 조합이 프로세스를 생성하고 조작하는 강력한 방법이라고 말할 수 있지만, 더 많은 것이 남아있다.
fork(), exec(), wait() 이외에도 UNIX 시스템에서 프로세스와 상호작용하는 인터페이스들이 존재한다. 예를 들어 kill() 시스템 호출은 프로세스에 시그널을 보내어 일시정지, 종료등의 명령을 내린다. 특정 키 조합이 시그널을 보낼 수 있게 구성되있기도 하는데, 예를 들어 control-c 는 SIGINT(인터럽트)시그널을 보내어 프로세스를 종료시키며, control-z는 SIGTSTP(중지)시그널을 보내어 프로세스를 일지 정지 시킨다.(fg와 같은 명령으로 재개 가능)
전체 시그널 하위 시스템은 외부 이벤트를 프로세스에 전달 하기 위한 인프라를 제공하며, 개별 프로세스 내에서 이러한 시그널을 송,수신하고 처리하는 방법을 포함한다. 이런 통신을 사용하려면 프로세스는 signal() 시스템 호출을 사용하여 시그널을 잡아야한다. 잡았다면, 그 시그널이 프로세스에 전달될 때 정상 실행이 중단되고 시그널에 응답하여 특정 코드 조각을 실행한다.
그렇다면 누가 프로세스에 시그널을 보낼 수 있을까? 우리가 사용하는 시스템은 일반적으로 동시에 여러 사람이 사용할 수 있다. 이들 중 한명이 시스템을 종료시키는 시그널을 보낸다면 사용 편의성과보안이 저해될 것이다. 따라서 시스템들은 사용자 개념이라는 것이 있다. 사용자는 자격 증명을 위하여 비밀번호를 입력해서 로그인을 한다. 그 사용자는 프로세스를 시작하여 리소스에 액세스 할 수 있고, 제어할 수 있다. 운영체제는 각 사용자에게 CPU,메모리,디스크 같은 리소스를 할당하는 역할을 수행한다.
OSTEP에는 특정 시스템 호출이나 라이브러리 호출을 언급할때 매뉴얼을 읽으라고 권장한다.
manual pages 또는 man pages는 UNIX 시스템에 존재하는 문서이며,
웹이 존재하기 이전에 만들어졌다.
매뉴얼을 읽는 것은 시스템 프로그래머로 성장하는 과정에서 중요하다.
매뉴얼에는 유용한 정보가 많이 있으며,
특히 사용하는 쉘(tcsh 또는 bash)과 시스템 호출에 대한 것이 유용하다.