- What interfaces should the OS present for process creation and control?
- How should these interfaces be designed to enable powerful functionality, ease of use, and high performance?
지난 글에서 Process가 무엇인지, 어떻게 생겼는지, 어떻게 살아 움직이는지 공부했다.
이번 장에서는 OS가 우리가 프로세스를 조작할 수 있도록 제공하는 API는 무엇이 있는지 공부해볼 것이다!
fork()는 프로세스 생성에 사용되는 System Call이다. 대놓고 제일 이해하기 어려운 System call이라고 나와 있는데, 아래 코드를 살펴보면서 개념을 살펴보자.
위 프로그램을 실행하면, 아래와 같은 결과를 볼 수 있다.
어우.. 잘은 모르겠지만 위에서부터 천천히 뜯어보자.
6) 맨 처음 프로그램이 시작되면, 일단 인삿말과 해당 프로세스의 pid를 출력한다. 위 실행 결과에선 pid가 29146이다. 여기까진 ezez.
7) rc = fork();
여기부터 모르는 코드가 우루루 쏟아져 나오는데, 위에서 언급했듯이 일단 fork System call은 프로세스를 생성하는 역할을 한다. 뭔가 생성하는 것까지는 알겠는데, 실행 흐름이 갑자기 두 개가 생겨버려서 헷갈리게 되는 것 같다.
일단 실행 결과는 제쳐두고, 그 아래 세 블럭의 조건문을 먼저 살펴보자.
일단 fork System call을 통해 발생할 수 있는 모든 조건을 다 살펴 봤으니, 이번엔 OS 입장에서 어떤 일이 발생한건지도 분석해보자.
...
OS 입장에서는 부모 프로세스(29146)가 fork를 호출하면 부모 프로세스의 복사본이 만들어지는데, 갑자기 프로그램 p1에서 생성된 프로세스가 2개가 존재하게 된 상황이라 할 수 있다.
대신 부모와는 별개의 프로세스로서 독립적으로 작동해야 하기 때문에 주소 공간, 레지스터는 당연히 부모와 다른 값을 가져야 할 것이다.
또한 이때 프로세서가 다음 실행할 명령어를 가리키는 Program Counter는 부모, 자식 프로세스 둘 다 fork()에서 값을 반환하기 직전이다. 즉, 실행 결과에서 볼 수 있듯 자식 프로세스는 위의 "hello world" 메시지를 출력하지 않는다!
...
여기까지. 그럼 코드 상의 조건문과 OS 입장에서 어떤 일이 발생했는지를 종합해서 살펴보면..
CPU가 하나 뿐이고, 실행 흐름도 하나 뿐이니 지금까지는 별 문제가 없었는데,, 지금 이 예제를 보고 나서 뭔가 머릿속에서 꼬이는게 있다면 아마 '부모 -> 자식 -> 부모 ' 순서로 실행되는지, '부모 -> 자식 -> 자식 ' 순서로 실행되는지 확신이 없어서 그런 것 같다.
다행히 책에 정확한 답변이 나와 있는데, 이런 상황에서 어떤 프로세스를 실행할지 결정하는 CPU Scheduler의 구조가 상당히 복잡하기도 하고, 그때그때 적당하다고 판단한 프로세스를 실행하기 때문에 자식의 명령을 먼저 실행할지, 부모의 명령을 먼저 실행할지는 알 수 없다고 한다. 이를 비결정적(Nondeterministic)이라고 표현하는데, 이 내용은 뒤의 Concurrency 부분에서 다룬다.
그러니까, 여기서 중요한건 실행 순서가 아니라 fork System call이 호출되면 부모 프로세스의 복제본인 자식 프로세스가 생성되고, 그 실행 흐름은 fork의 반환 시점으로 설정되며, 그 아래에서 조건문을 통해 부모/자식 프로세스가 수행해야 할 작업을 다르게 지정해줄 수 있다는 것이다. 와!
오케이. 다 좋은데, 만약에 자식 프로세스를 만들고 난 직후에 부모 프로세스가 자식 프로세스보다 먼저 실행되도록 보장해야 한다면, 즉 Deterministic한 결과를 내려면 어떻게 해야 할까? 이걸 제어할 수 없을리가 없다. 이게 안되면 복불복으로 프로그램이 실행되는 거니까....
wait() System call은 부모 프로세스가 자식 프로세스가 종료될 때까지 기다림으로써 이러한 deterministic한 (여기서는 자식이 부모보다 먼저 실행되는) 결과를 낼 수 있다. 코드로 살펴보자.
아까 봤던 코드랑 거진 비슷하다. 실행 결과는 아래와 같다.
코드를 좀 바꿔줬더니 이번엔 항상 자식이 먼저 표준 출력을 해주는, deterministic한 결과가 나온다. 무엇이 달라졌을까?
int rc_wait = wait(NULL);
wait System call을 호출하면, 부모 프로세스는 자식이 종료되는 시점에 자식의 PID를 반환한다.
참고로 저기 보이는 NULL parameter에는 변수의 주소(포인터)를 넣어줄 수있는데, wait이 반환해주면 주소가 가리키는 변수의 값이 자식 프로세스의 exit code로 초기화 된다고 한다.
예를 들어서 뭐.. 자식 프로세스가 생성되기는 했는데 제대로 작동하지 않은 상황을 handling 해주어야 하는 상황에서 유용할 것 같다.
앞에서 살펴본 fork는 자식 프로세스가 부모 프로세스의 복제본인데, 만약 자기 자신의 복제본 말고 아예 다른 프로세스를 실행하고 싶을 때는 어떤 API를 사용할 수 있을까?
exec() System call을 사용하면 되는데, 아래 예제 코드로 어떻게 작동하는지 살펴보자.
실행 결과는 아래와 같다.
rc < 0 블럭(10~12)이라던지, else 블럭(21~24)까지는 앞의 wait 예제와 같다. 부모 프로세스(29283)가 자식 프로세스가 종료될 때까지 기다리는 흐름은 같다는 이야기인데.. rc == 0 블럭(13~20), 그러니까 생성된 자식 프로세스가 실행할 코드 블럭이 좀 복잡해졌다.
어디까지 알고 있는지 확인했으니, 실행 결과부터 일단 살펴보자.
오..케이. 다 좋은데 정확히 20행에서 왜 표준 출력이 일어나지 않는지 궁금하지 않은가?
바로 19행의 child의 execvp system call 호출 시점에 PCB가 wc의 것으로 덮어 씌워지기 때문이다!
즉, wc의 실행 코드, 정적 데이터를 읽어 자식 프로세스 29384의 code, data 영역을 덮어 쓰고, Heap, Stack, Address space도 완전히 새로운 프로그램인 wc를 위해 다시 초기화된 상태에서 OS가 새 프로그램을 실행하여 기존의 자식 프로세스 29384를 완전히 대체하게 된 것이라 할 수 있다.
코드고 뭐고 전부 새로 초기화 되었으니, execvp 이후에 남아 있던 20행의 표준 출력 역시 당연히 실행될 수 없다.
그냥 프로세스를 생성하는 간단한 작업을 이렇게 요상한 방법으로 수행하는 걸까?
왜 이렇게 생겨먹었냐고 따지기 시작하면 진짜 모니터 부숴버리고 싶을 때가 많긴 한데 일단 무슨 얘기를 하는지 찬찬히 들어보자.
우리가 여태까지 쓰고 있던 쉘(Shell)은 프롬프트를 표시하고, 사용자가 뭔가 입력할 때까지 기다리고 있다가, 명령어를 입력하면 그걸 실행하기만 하는 아주 간단한 프로그램이다.
그리고.. fork와 exec가 분리되어 있어야 하는 이유는 UNIX에서 이 쉘을 구현하기 위함이라고 한다.
자세한 시나리오는 아래와 같다.
아직 와닿지 않는 것 같은데.... 두 가지 예제를 살펴보면서 이해해보자.
위 프로그램을 실행하면, 아까 살펴본 wc의 결과(p3.c의 단어 수 등)가 newfile.txt라는 파일로 redirection 된다('>' 기호). 즉, 결과값이 newfile.txt 파일에 쓰여진다.
너무 당연하게 사용해 왔어서 별 감흥이 없는데, System call 수준에서 이 작업이 어떻게 수행되는지 살펴보면 fork와 exec가 왜 분리되었는지 그 배경을 어렴풋이 알 수 있다.
위 명령어를 쉘이 입력받으면,,
아까 자식 프로세스 없이 하나의 프로그램을 실행하는 과정 중에 단계 2가 끼어들었는데, fork와 exec가 분리되어 있지 않았다면,, 단계 2는 이렇게 자연스러운(?) 방법으로 실행할 수가 없을 것 같기는 하다.
다음 예제로 위의 과정이 어떻게 작동하는지 조금 더 깊게 이해해보자.
실행 예제는 다음과 같다.
wc에 전달되는 매개 변수가 p4.c로 전달된 것 말고는 위의 exec 예제와 거의 비슷한데, 16, 17행이 조금 다르다.
이전의 redirection 예제의 연장선상에서 이 프로그램을 살펴보려면, 여기서 ./p4 프로그램을 OS로 생각하고, wc 프로그램을 shell을 통해 실행한 것처럼 생각해도 좋을 것 같다.
여하튼 UNIX 시스템은 미사용 중인 file descriptor를 0번부터 탐색해 나가는데, 이 경우 표준 출력에 해당하는 STDOUT_FILENO가 사용 가능한 첫 번째 file descriptor로 탐색되어 close statement에 의해 닫히고, 다시 open statement에 의해 file descriptor로 할당되는 과정을 코드로 보여준 것이다.
흠. fork와 exec가 따로 분리되어 있지 않았다면 실행 파일 생성 시점에 알 수 없는, 외부의 실행 환경에 따라 다른 실행 결과를 내야 하는 상황에서 이렇게 유연하게 처리하기 어려웠을 것 같다는 것 정도만 짚고 넘어가면 될 것 같다.
OS에서 프로세스를 생성하고, 자식 프로세스를 기다리는 기능을 제공하기 위한 System call을 살펴 보았다.
운영체제에서의 쓰임새와는 별개로, fork, exec의 분리로 유연하고 우아하게 어려운 작업을 수행하는 매커니즘은 뭐랄까.. 아름답기까지 하다. 햐........ 배우면 배울수록 배울게 계속 늘어나는 것 같다.