[OS] 5. Interlude: Process API

8768 단어 OSOS
  • 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는 무엇이 있는지 공부해볼 것이다!


5.1. The fork() System Call

fork()는 프로세스 생성에 사용되는 System Call이다. 대놓고 제일 이해하기 어려운 System call이라고 나와 있는데, 아래 코드를 살펴보면서 개념을 살펴보자.

위 프로그램을 실행하면, 아래와 같은 결과를 볼 수 있다.

어우.. 잘은 모르겠지만 위에서부터 천천히 뜯어보자.

6) 맨 처음 프로그램이 시작되면, 일단 인삿말과 해당 프로세스의 pid를 출력한다. 위 실행 결과에선 pid가 29146이다. 여기까진 ezez.

7) rc = fork();
여기부터 모르는 코드가 우루루 쏟아져 나오는데, 위에서 언급했듯이 일단 fork System call은 프로세스를 생성하는 역할을 한다. 뭔가 생성하는 것까지는 알겠는데, 실행 흐름이 갑자기 두 개가 생겨버려서 헷갈리게 되는 것 같다.

일단 실행 결과는 제쳐두고, 그 아래 세 블럭의 조건문을 먼저 살펴보자.

  • 8) case 1: rc < 0
    프로세스를 생성하는 과정에서 뭔가 문제가 생겼음을 의미한다.
    코드 블럭에서는 에러 메시지를 출력하고(10), exit code로 1을 반환하며 프로세스가 종료된다(11).
  • 12) case 2: rc == 0
    주석을 보면 새로 자식 프로세스가 생성된다고 되어 있다. 즉, 자식 프로세스는 rc값이 0으로 초기화된다.
    여하튼 위 아래 내용 제쳐두고 그냥 이 코드 블록만 보면, 부모가 그랬던 것처럼 자신의 pid(29147)를 출력하는 것(14)이 전부이다.
  • 15) case 3: else == (rc > 0)
    fork System call을 사용해 자식 프로세스를 성공적으로 생성하면, 자식 pid의 값을 반환한다(7).
    따라서 case 1, 2를 제외한 나머지 가능한 조건은 rc > 0 밖에 없으므로, 자식 프로세스를 성공적으로 생성한 부모 프로세스가 위의 fork statement 다음으로 도달하게 되는 지점이 바로 이 블럭이 된다(17).

일단 fork System call을 통해 발생할 수 있는 모든 조건을 다 살펴 봤으니, 이번엔 OS 입장에서 어떤 일이 발생한건지도 분석해보자.

...

OS 입장에서는 부모 프로세스(29146)가 fork를 호출하면 부모 프로세스의 복사본이 만들어지는데, 갑자기 프로그램 p1에서 생성된 프로세스가 2개가 존재하게 된 상황이라 할 수 있다.
대신 부모와는 별개의 프로세스로서 독립적으로 작동해야 하기 때문에 주소 공간, 레지스터는 당연히 부모와 다른 값을 가져야 할 것이다.

또한 이때 프로세서가 다음 실행할 명령어를 가리키는 Program Counter는 부모, 자식 프로세스 둘 다 fork()에서 값을 반환하기 직전이다. 즉, 실행 결과에서 볼 수 있듯 자식 프로세스는 위의 "hello world" 메시지를 출력하지 않는다!

...

여기까지. 그럼 코드 상의 조건문과 OS 입장에서 어떤 일이 발생했는지를 종합해서 살펴보면..

  1. 일단 부모 프로세스가 먼저 실행되고,
  2. fork() System call에 의해 부모 프로세스의 복사본인 자식 프로세스가 생성되어서,
  3. (별 문제 없이 자식 프로세스가 생성되었다는 가정 하에) 자식 프로세스의 rc값은 0으로 초기화되므로 rc == 0 블럭으로 이동되어 '나 자식임!' + pid를 출력해주고,
  4. 부모는 그 아래의 else 블럭으로 이동되어 '나 누구누구네 부모임!' 하고 출력해준다.
  5. 부모/자식 프로세스 둘 다 그 이상의 실행할 명령어가 남아있지 않기 때문에 그냥 exit code 0을 반환하며 종료된다.

CPU가 하나 뿐이고, 실행 흐름도 하나 뿐이니 지금까지는 별 문제가 없었는데,, 지금 이 예제를 보고 나서 뭔가 머릿속에서 꼬이는게 있다면 아마 '부모 -> 자식 -> 부모 ' 순서로 실행되는지, '부모 -> 자식 -> 자식 ' 순서로 실행되는지 확신이 없어서 그런 것 같다.

다행히 책에 정확한 답변이 나와 있는데, 이런 상황에서 어떤 프로세스를 실행할지 결정하는 CPU Scheduler의 구조가 상당히 복잡하기도 하고, 그때그때 적당하다고 판단한 프로세스를 실행하기 때문에 자식의 명령을 먼저 실행할지, 부모의 명령을 먼저 실행할지는 알 수 없다고 한다. 이를 비결정적(Nondeterministic)이라고 표현하는데, 이 내용은 뒤의 Concurrency 부분에서 다룬다.

그러니까, 여기서 중요한건 실행 순서가 아니라 fork System call이 호출되면 부모 프로세스의 복제본인 자식 프로세스가 생성되고, 그 실행 흐름은 fork의 반환 시점으로 설정되며, 그 아래에서 조건문을 통해 부모/자식 프로세스가 수행해야 할 작업을 다르게 지정해줄 수 있다는 것이다. 와!


5.2. The wait() System call

오케이. 다 좋은데, 만약에 자식 프로세스를 만들고 난 직후에 부모 프로세스가 자식 프로세스보다 먼저 실행되도록 보장해야 한다면, 즉 Deterministic한 결과를 내려면 어떻게 해야 할까? 이걸 제어할 수 없을리가 없다. 이게 안되면 복불복으로 프로그램이 실행되는 거니까....

wait() System call은 부모 프로세스가 자식 프로세스가 종료될 때까지 기다림으로써 이러한 deterministic한 (여기서는 자식이 부모보다 먼저 실행되는) 결과를 낼 수 있다. 코드로 살펴보자.

아까 봤던 코드랑 거진 비슷하다. 실행 결과는 아래와 같다.

코드를 좀 바꿔줬더니 이번엔 항상 자식이 먼저 표준 출력을 해주는, deterministic한 결과가 나온다. 무엇이 달라졌을까?

int rc_wait = wait(NULL);

wait System call을 호출하면, 부모 프로세스는 자식이 종료되는 시점에 자식의 PID를 반환한다.
참고로 저기 보이는 NULL parameter에는 변수의 주소(포인터)를 넣어줄 수있는데, wait이 반환해주면 주소가 가리키는 변수의 값이 자식 프로세스의 exit code로 초기화 된다고 한다.
예를 들어서 뭐.. 자식 프로세스가 생성되기는 했는데 제대로 작동하지 않은 상황을 handling 해주어야 하는 상황에서 유용할 것 같다.


5.3. Finally, The exec() System call

앞에서 살펴본 fork는 자식 프로세스가 부모 프로세스의 복제본인데, 만약 자기 자신의 복제본 말고 아예 다른 프로세스를 실행하고 싶을 때는 어떤 API를 사용할 수 있을까?

exec() System call을 사용하면 되는데, 아래 예제 코드로 어떻게 작동하는지 살펴보자.

실행 결과는 아래와 같다.

rc < 0 블럭(10~12)이라던지, else 블럭(21~24)까지는 앞의 wait 예제와 같다. 부모 프로세스(29283)가 자식 프로세스가 종료될 때까지 기다리는 흐름은 같다는 이야기인데.. rc == 0 블럭(13~20), 그러니까 생성된 자식 프로세스가 실행할 코드 블럭이 좀 복잡해졌다.

어디까지 알고 있는지 확인했으니, 실행 결과부터 일단 살펴보자.

  1. 부모 프로세스(29383)가 생성되어 표준 출력을 수행했다.
  2. 부모 프로세스가 fork System call을 호출하여 자식 프로세스(29384)를 생성했고,
  3. 부모 프로세스가 else 블럭으로 넘어갔으나 wait System call의 호출로 인해 일단 자식 프로세스가 종료될 때까지 대기하게 된다.
  4. 부모 프로세스가 대기하고 있는 동안 일단 '나 자식임!' 하고 출력 한 번 해주고,
  5. 16 ~ 19행이 좀 생소한데, 풀어서 써보자면 16, 17, 18행을 통해 각각
  • 16) 어떤 프로그램을 실행할 것인지, (이 경우 인자로 받은 파일의 단어 수를 세어 주는 wc 프로그램)
  • 17) 어떤 값을 인자로 넘겨줄 것인지, (이 경우 p2.c라는 파일을 열어서 단어 수를 센다.)
  • 18) ..까지 명시해주고, 더 이상 wc에 넘겨줄 인자가 없음을 Null 값으로 기록한다.
  • 19) 16 ~ 18행에서 설정된 값을 토대로, execvp 명령어를 통해 w3 프로그램을 실행한다.
  1. 20행의 코드는 실행되지 않는다.
  2. 새로 대체된 자식 프로세스가 종료되었으므로, 기다리고 있던 부모 프로세스가 '나 부모임!' 하고 표준 출력을 수행한다.

오..케이. 다 좋은데 정확히 20행에서 왜 표준 출력이 일어나지 않는지 궁금하지 않은가?
바로 19행의 child의 execvp system call 호출 시점에 PCB가 wc의 것으로 덮어 씌워지기 때문이다!

즉, wc의 실행 코드, 정적 데이터를 읽어 자식 프로세스 29384의 code, data 영역을 덮어 쓰고, Heap, Stack, Address space도 완전히 새로운 프로그램인 wc를 위해 다시 초기화된 상태에서 OS가 새 프로그램을 실행하여 기존의 자식 프로세스 29384를 완전히 대체하게 된 것이라 할 수 있다.

코드고 뭐고 전부 새로 초기화 되었으니, execvp 이후에 남아 있던 20행의 표준 출력 역시 당연히 실행될 수 없다.


5.4. Why? Motivating The API

그냥 프로세스를 생성하는 간단한 작업을 이렇게 요상한 방법으로 수행하는 걸까?
왜 이렇게 생겨먹었냐고 따지기 시작하면 진짜 모니터 부숴버리고 싶을 때가 많긴 한데 일단 무슨 얘기를 하는지 찬찬히 들어보자.

우리가 여태까지 쓰고 있던 쉘(Shell)은 프롬프트를 표시하고, 사용자가 뭔가 입력할 때까지 기다리고 있다가, 명령어를 입력하면 그걸 실행하기만 하는 아주 간단한 프로그램이다.
그리고.. fork와 exec가 분리되어 있어야 하는 이유는 UNIX에서 이 쉘을 구현하기 위함이라고 한다.

자세한 시나리오는 아래와 같다.

  1. 사용자가 Shell을 통해 프로그램을 실행하려고 하면,
  2. 해당 명령어와 연결된 프로그램을 파일 시스템을 찾아 fork 명령어를 통해 일단 새로운 자식 프로세스를 만든다. 왜냐? OS도 프로세스니까. 우리가 실행하려는 프로그램 역시 OS라는 프로세스의 자식인건 당연하다.
  3. 그 다음 exec 비슷한 System call을 사용해 실행할 프로그램을 실행시키고 (전 단계에서 만든 자식 프로세스를 명령받은 프로그램으로 대체하고),
  4. wait을 호출하여 프로그램이 끝날 때까지 기다리다가,
  5. 프로세스가 종료되면 쉘은 결과값을 출력하고, 다시 다음 명령어를 기다린다.

아직 와닿지 않는 것 같은데.... 두 가지 예제를 살펴보면서 이해해보자.



위 프로그램을 실행하면, 아까 살펴본 wc의 결과(p3.c의 단어 수 등)가 newfile.txt라는 파일로 redirection 된다('>' 기호). 즉, 결과값이 newfile.txt 파일에 쓰여진다.

너무 당연하게 사용해 왔어서 별 감흥이 없는데, System call 수준에서 이 작업이 어떻게 수행되는지 살펴보면 fork와 exec가 왜 분리되었는지 그 배경을 어렴풋이 알 수 있다.

위 명령어를 쉘이 입력받으면,,

  1. 파일 시스템으로부터 wc 프로그램을 찾아 fork 명령어로 새로운 자식 프로세스를 만든다.
  2. 표준 출력 파일(UNIX 계열에서는 표준 출력 역시 file descriptor로 다룬다고 배웠다)을 닫고, newfile.txt 파일의 descriptor를 연다.
  3. exec 명령어를 통해 wc 프로그램을 실행시키고,
  4. OS가 wc의 종료롤 기다리고 있다가,
  5. 프로세스가 종료되면 다시 다음 명령어를 기다린다.

아까 자식 프로세스 없이 하나의 프로그램을 실행하는 과정 중에 단계 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의 분리로 유연하고 우아하게 어려운 작업을 수행하는 매커니즘은 뭐랄까.. 아름답기까지 하다. 햐........ 배우면 배울수록 배울게 계속 늘어나는 것 같다.

좋은 웹페이지 즐겨찾기