Operating Systems : Three Easy Pieces를 보고 번역 및 정리한 내용들입니다.
프로세스를 생성하고 제어하려면 어떤 OS 인터페이스를 사용해야 할까? 이 인터페이스들은 어떻게 설계해야 높은 기능성, 사용의 용이함, 높은 성능을 제공할 수 있을까?
fork()
시스템 콜fork()
시스템 콜은 새로운 프로세스를 생성하기 위해 쓰인다.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
//프로그램 p1
int main(int argc, char *argv[]) {
printf("hello (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) {
// fork 실패
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
// 자식 프로세스
printf("child (pid:%d)\n", (int) getpid());
} else {
// 부모 프로세스는 아래의 일을 계속한다
printf("parent of %d (pid:%d)\n", rc, (int) getpid());
}
return 0;
}
위 코드를 실행하면 다음과 같이 출력된다.
prompt> ./p1
hello (pid:29146)
parent of 29147 (pid:29146)
child (pid:29147)
prompt>
프로그램을 처음 실행하면 프로세스는 프로세스 식별자(process identifier, PID)를 포함한 메시지를 출력한다. UNIX 시스템에서 PID는 프로세스의 이름으로, 해당 프로세스로 어떤 작업들, 예컨대 프로세스를 중지시키는 작업 등을 할 때 사용한다.
프로세스가 fork()
를 호출하면, OS는 새로운 프로세스를 생성한다. 이때 새로 만들어진 프로세스는 fork()
를 호출한 프로세스의 복제가 되는데, 다시 말해 OS는 프로그램 p1
의 실행 중인 복제를 두 개 가지게 된다. 이때 새롭게 만들어진 프로세스를 자식 프로세스라 하고, fork()
를 호출한 프로세스를 부모 프로세스라 한다. 이 자식 프로세스는 main()
함수가 실행된 시점이 아니라 fork()
가 호출된 시점 이후로 살아있기 시작한다.
하지만 사실 자식 프로세스가 부모 프로세스의 완전한 복제라고 하기는 힘들다. 위 코드에서 볼 수 있듯, 자식 프로세스는 fork()
의 반환값으로 0을 갖는 한편, 부모 프로세스는 새로 생성된 자식 프로세스의 PID를 fork()
의 반환값으로 가지기 때문이다. 이 차이를 이용해 위 코드와 같이 자식과 부모, 두 프로세스가 서로 다른 행동을 할 수 있게 만들 수 있다.
한편 p1.c
를 실행했을 때 출력은 비결정론적이기 때문에, 위의 출력과 달리 다음과 같을 수도 있다.
prompt> ./p1
hello (pid:29146)
child (pid:29147) p
arent of 29147 (pid:29146)
prompt>
CPU 스케줄러는 특정 시간에 어떤 프로세스를 실행할지를 결정하는데, 이 스케줄러가 작동하는 방식은 복잡하기 때문에, 우리는 사실 이 스케줄러가 어떤 프로세스를 먼저 실행하도록 결정할지 알기 힘들다. 이러한 비결정론적 특성은 멀티 스레드 프로그램들에서도 마찬가지로 여러 문제들을 낳는다.
wait()
시스템 콜#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
//프로그램 p2
int main(int argc, char *argv[]) {
printf("hello (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) { // fork 실패
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) { // 자식 프로세스
printf("child (pid:%d)\n", (int) getpid());
}
else { // 부모 프로세스
int rc_wait = wait(NULL);
printf("parent of %d (rc_wait:%d) (pid:%d)\n",
rc, rc_wait, (int) getpid());
}
return 0;
}
wait()
자식 프로세스가 작업을 완료할 때까지 부모 프로세스를 대기시키는 시스템 콜이다. 위 예제에서 부모 프로세스는 wait()
를 호출하고, 자식 프로세스가 실행을 완료할 때까지 대기한다. 자식 프로세스가 완료되면 wait()
는 부모 프로세스에 종료된 자식 프로세스의 PID를 반환한다.
prompt> ./p2
hello (pid:29266)
child (pid:29267)
parent of 29267 (rc_wait:29267) (pid:29266)
prompt>
위 코드에서 자식 프로세스는 항상 부모 프로세스보다 먼저 출력하고, 부모 프로세스는 자식 프로세스가 실행되고 종료된 후에 출력한다.
exec()
시스템 콜fork()
의 경우 실행 중인 프로그램의 복제를 만드는 데 쓰이는 한편, exec()
시스템 콜은 호출하는 프로그램과는 다른 프로그램을 실행하고자 할 때 사용된다.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
//프로그램 p3
int main(int argc, char *argv[]) {
printf("hello (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) { // fork 실패
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) { // 자식 프로세스
printf("child (pid:%d)\n", (int) getpid());
char *myargs[3];
myargs[0] = strdup("wc"); // 프로그램 : wc
myargs[1] = strdup("p3.c"); // wc의 인자
myargs[2] = NULL; // 인자 배열의 끝을 표시
execvp(myargs[0], myargs); // 프로그램 실행
printf("this shouldn’t print out");
} else { // 부모 프로세스
int rc_wait = wait(NULL);
printf("parent of %d (rc_wait:%d) (pid:%d)\n",
rc, rc_wait, (int) getpid());
}
return 0;
}
위 예제에서 자식 프로세스는 execvp()
를 호출해 파일의 줄 수, 단어 수, 바이트 수를 세는 프로그램 wc
를 실행한다.
prompt> ./p3
hello (pid:29383)
child (pid:29384)
29 107 1030 p3.c
parent of 29384 (rc_wait:29384) (pid:29383)
prompt>
exec()
은 실행 가능한 프로그램의 이름과 인자들을 받으면, 해당하는 프로그램의 코드와 정적 데이터들을 로드해 현재의 코드, 정적 데이터 세그먼트에 덮어 씌운다. 이때 힙, 스택, 프로그램의 메모리 공간의 일부는 다시 초기화된다. 이후 OS는 해당 프로세스에 argv
에 주어진 인자들을 전달하면서 해당 프로그램을 실행한다. 다시 말해 exec()
은 새로운 프로세스를 생성하는 게 아니라 현재 실행 중인 프로그램을 다른 실행 중인 프로그램으로 대체한다. exec()
은 성공적으로 호출되고 실행되는 경우, 다시 이 프로그램으로 돌아오지 않는다.
왜 위와 같이 fork()
와 exec()
를 분리해서 사용해야 할까? 이렇게 fork()
와 exec()
을 분리하는 것은 UNIX 셸을 만드는 데에 필수적이다. 왜냐하면 이렇게 분리해야 셸이 fork()
이후, exec()
이전에 코드를 실행할 수 있도록 할 수 있기 때문이다. 이 코드는 실행될 프로그램의 환경을 변경해 여러 기능을 쉽게 구축할 수 있도록 한다.
셸은 일종의 사용자 프로그램이다. 셸은 프롬프트를 보여주고, 사용자가 여기에 뭔가 타이핑하기를 기다린다. 사용자가 커맨드, 즉 실행할 수 있는 프로그램의 이름과 인자들을 입력하면 셸은 해당하는 실행 가능한 프로그램이 파일 시스템의 어디에 위치하고 있는지를 찾고, 커맨드를 실행하기 위한 새 프로세스를 fork()
생성한 후 exec()
으로 실행하고, wait()
로 해당 프로그램이 종료될 때까지 대기한다. 자식 프로세스가 완료되면 셸은 wait()
으로부터 리턴하고, 프롬프트를 다시 보여주고 다음 커맨드를 위해 준비한다.
fork()
와 exec()
의 분리는 셸이 여러 유용한 것들을 좀 더 쉽게 할 수 있도록 한다.
prompt> wc p3.c > newfile.txt
위 예제에서, 프로그램 wc
의 출력은 아웃풋 파일 newfile.txt
로 리다이렉트한다. 이러한 작업을 위해 셸은 fork()
로 자식 프로세스를 만들고 exec()
을 호출하기 전에 표준 출력을 닫고 새 파일 newfile.txt
를 연다. 열려 있는 파일 디스크립터의 경우 exec()
이 호출되어도 열려 있으므로, 실행될 프로그램 wc
의 결과는 스크린 대신 파일 newfile.txt
에 출력된다.
UNIX 시스템에서는 사용 가능한 파일 디스크립터를 0에서부터 찾는다. 위에서는 표준 출력을 닫았기 때문에, open()
을 호출하면 파일 디스크립터 STDOUT_FILENO(=1)가 사용 가능한 가장 첫 번째가 되고 할당된다. 자식 프로세스에서의 표준 출력을 통한 출력은 모두 open()
으로 열린 파일에 리다이렉트된다. 즉 위 예제에서는 newfile.txt
로 리다이렉트 된다.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <sys/wait.h>
//프로그램 p4
int main(int argc, char *argv[]) {
int rc = fork();
if (rc < 0) {
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
// 자식 프로세스 : 표준 출력을 파일로 리다이렉트
close(STDOUT_FILENO);
open("./p4.output", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);
//프로그램 wc를 실행
char *myargs[3];
myargs[0] = strdup("wc");
myargs[1] = strdup("p4.c");
myargs[2] = NULL;
execvp(myargs[0], myargs);
} else {
int rc_wait = wait(NULL);
}
return 0;
}
prompt> ./p4
prompt> cat p4.output
32 109 846 p4.c
prompt>
p4
를 실행하면 아무 것도 실행되지 않는 것처럼 보이지만, 이는 wc
의 출력이 표준 출력이 아닌 p4.output
으로 리다이렉트 됐기 때문이다. cat
커맨드로 p4.output
의 내용을 출력하면 wc
로 출력했어야 할 내용들을 확인할 수 있다.
UNIX 파이프도 위와 비슷한 방법으로, 다만 pipe()
시스템 콜을 통해 구현되어있다. 이 경우 한 프로세스의 출력은 커널 파이프에 연결되고, 다른 프로세스의 입력도 같은 파이프에 연결된다. 즉 한 프로세스의 출력이 다른 프로세스의 입력으로 연결되는 것이다. 파이프를 사용하면 이와 같이 여러 커맨드들을 순차적으로 엮어서 사용할 수 있다.
지금까지는 프로세스 API를 high level에서 다뤘지만, 위 시스템 콜들에 대해서는 아직 많은 디테일들이 남아있다. 이것들은 나중에 다루게 될 것이고, 지금은 fork()
와 exec()
의 조합이 프로세스를 만들고 조작하는 데 몹시 유용하다는 것 정도만 생각해두도록 하자.
fork()
, exec()
, wait()
이 아니더라도 UNIX 시스템에는 프로세스와 상호작용하기 위한 많은 인터페이스들이 있다. 예를 들면 kill()
은 프로세스에 정지, 종료, 그리고 이외의 여러 유용한 명령 시그널을 보내는 데에 쓰인다. UNIX 셸은 편의상 이러한 시그널을 보내기 위한 여러 키 조합들을 제공하고 있는데, 예를 들면 control-c는 SIGINT
(인터럽트), control-z는 SIGSTP
(정지) 시그널을 실행 중인 프로세스에 보내는 데에 쓰인다.
시그널 서브시스템은 개별 프로세스나 프로세스 그룹로부터 시그널을 받고, 처리하고 보내는 등, 외부 이벤트를 프로세스에 전달하기 위한 인프라를 제공한다. 이런 상호작용을 위해서 프로세스는 signal()
시스템 콜을 이용해 여러 시그널들을 캐치함으로써, 특정 시그널이 프로세스에 전달됐을 때에 정상 실행을 일시 중단하고 시그널에 대한 응답 코드를 실행하도록 한다.
그렇다면 프로세스에 시그널을 보낼 수 있는 것은 누구고, 시그널을 보낼 수 없는 것은 누구일까? 일반적으로 우리가 사용하는 시스템들은 동시에 여러 사람들이 사용할 수 있는데, 이들 중 누구나 SIGINT
와 같은 시그널을 보낼 수 있다면, 이는 시스템의 가용성이나 보안에 영향을 줄 수 있다. 그렇기 때문에 현대 시스템들은 사용자(user)라는 개념을 사용한다.
사용자는 시스템 자원들을 사용하기 위해 패스워드를 이용해 로그인한다. 이후 사용자는 여러 프로세스들을 시작하고 프로세스들에 대한 제어권을 행사한다. 사용자는 일반적으로 자신의 프로세스들만을 제어할 수 있으며, 시스템의 전반적인 목적을 달성하기 위해 CPU, 메모리, 디스크 등의 자원들을 사용자에게 분배하는 일은 OS의 역할이다.
fork()
시스템 콜은 UNIX 시스템에서 새 프로세스를 실행하는 데 쓰인다. 새 프로세스를 만드는 프로세스는 부모, 새로이 만들어진 프로세스는 자식이라 불린다. 자식 프로세스는 부모 프로세스와 거의 같은 복제다.wait()
시스템 콜은 자식 프로세스가 실행을 완료할 때까지 부모 프로세스를 대기할 수 있게 한다.exec()
함수군은 자식 프로세스를 부모 프로세스로부터 벗어나 완전히 새로운 프로그램을 실행할 수 있게 하기 위해 호출된다.fork()
, wait()
, exec()
으로 사용자 커맨드를 시작한다. fork()
와 exec()
의 분리는 입출력 리디렉션, 파이프 등 여러 기능들을 실행되는 프로그램을 변경하지 않고도 사용할 수 있게 한다.