Post

[운영체제 아주 쉬운 세 가지 이야기 - Virtualization] 5. Process API

[운영체제 아주 쉬운 세 가지 이야기 - Virtualization] 5. Process API

이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.


개요


이번 절에서는 운영체제 API와 사용법을 포함한 시스템의 실제적인 측면을 다루고, Unix 시스템의 프로세스 생성에 관해 논의힌다. Unix는 프로세스를 생성하기 위해 fork()와 exec() 시스템 콜을 사용한다. wait()은 프로세스가 자신이 생성한 프로세스가 종료되기를 기다리기 원할 때 사용된다.

그럼 프로세스를 생성하고 제어하려면 운영체제가 어떤 인터페이스를 제공해야하고, 유용하고 편리하게 사용하기 위해서 이 인터페이스는 어떻게 설계되어야 하는가?


fork() 시스템 콜


프로세스 생성에 fork() 시스템 콜이 사용된다. 이 시스템 콜은 우리가 사용할 시스템 콜 중에서 가장 이해하기 어려운 시스템 콜이다.

image

우선 Unix 시스템에서 프로세스 식별자(process identifier, PID)는 프로세스의 실행이나 중단과 같이 특정 프로세스를 대상으로 작업을 해야 할 경우 프로세스를 지칭하기 위해 사용된다. 우리가 알아야 될 중요한 점은 fork() 시스템 콜을 호출해 프로세스를 생성하면, 생성된 프로세스는 호출한 프로세스의 복사본이라는 것이다. 새로 생성된 프로세스는 자식 프로세스, 생성한 프로세스는 부모 프로세스라 부른다. 위 코드의 출력 결과를 보면, 자식 프로세스는 main() 함수 첫 부분부터 시작하지 않았고, fork()를 호출하면서부터 시작되었다는 걸 알 수 있다.

자식 프로세스는 부모 프로세스와 완전히 동일하지는 않다. 자식 프로세스는 자신의 주소 공간, 자신의 레지스터, 자신의 PC 값을 갖는 매우 중요한 차이점이 있다. 또한 fork() 시스템 콜의 반환 값이 서로 다르다. fork()로 부터 부모 프로세스는 생성된 자식 프로세스의 PID를 반환받고, 자식 프로세스는 0을 반환 받는다. 그리고 이 프로그램의 출력 결과가 항상 동일하지는 않다. 단일 CPU 시스템에서 이 프로그램을 실행하면 자식 프로세스가 실행되고, 부모 프로세스가 실행 될 수 있다.

CPU 스케줄러(scheduler)는 실행할 프로세스를 선택한다. 스케줄러의 동작은 일반적으로 상당히 복잡하고 상황에 따라 다른 선택이 이루어지기 떄문에, 어느 프로세스가 먼저 실행된다라고 단정하는 것은 매우 어렵다. 이 비결정성(non-determinism)으로 인해 멀티 쓰레드 프로그램 실행 시 다양한 문제가 발생한다.


wait() 시스템 콜


부모 프로세스가 자식 프로세스의 종료를 대기해야 하는 경우도 발생할 수 있다. 이러한 작업을 위해 wait() 시스템 콜 혹은 더 많은 기능을 가진 waitpid() 시스템 콜을 사용할 수 있다.

image

위 예제에서 부모 프로세스는 wait() 시스템 콜을 호출하여 자식 프로세스 종료 시점까지 자신의 실행을 잠시 중지시킨다. 이 프로그램은 항상 동일한 결과를 출력한다. 자식 프로세스가 부모 프로세스보다 먼저 실행되면 당연히 자식 프로세스가 먼저 출력된다. 부모 프로세스가 먼저 실행되면 곧바로 wait()을 호출하고, 이 시스템 콜은 자식 프로세스가 종료될 떄까지 리턴하지 않는다.

참고로 wait()이 자식 프로세스가 종료하기 전에 리턴하는 몇 가지 경우가 있다. 시그널에 의한 중단, 자식 프로세스가 없는 경우, 이미 종료된 좀비 프로세스 회수의 경우 등이 있다.


exec() 시스템 콜


프로세스 생성 관련 API 중에서 마지막으로 중요한 시스템 콜은 exec() 시스템 콜이다. 이 시스템 콜은 자기 자신이 아닌 다른 프로그램을 실행해야 할 때 사용한다. 중요한 점은 새로운 프로세스를 만드는 것이 아니라, 현재 프로세스를 새 프로그램으로 교체하는 것이다.

image

위 예제에서 자식 프로세스는 현재 실행 중인 프로그램을 wc라는 새로운 프로그램으로 대체한다. 실행 파일의 이름과 약간의 인자가 주어지면 해당 실행 파일의 코드와 정적 데이터를 읽어 들여 현재 실행 중인 프로세스의 코드 세그먼트와 정적 데이터 부분을 덮어 쓴다. 힙과 스택은 새로운 프로그램 실행을 위해 초기화된다.

exec() 시스템 콜은 6가지 변형이 존재한다. execl(), execle(), execlp(), execv(), execve(), execvp()가 그것이다.


왜 이런 API가 필요한가?


새로운 프로세스를 생성하는 간단한 작업 같은데, 왜 이런 이상한 인터페이스를 사용할까? Unix의 쉘을 구현하기 위해서는 fork()와 exec()를 분리해야 한다. 그래야만 쉘이 fork()를 호출하고 exec()를 호출하기 전에 코드를 실행할 수 있다.

쉘은 단순한 사용자 프로그램이다. 쉘은 프롬프트를 표시하고 사용자가 무언가 입력하기를 기다린다. 그리고 명령어를 입력한다. 대부분의 경우 쉘은 파일 시스템에서 실행 파일의 위치를 찾고 명령어를 실행하기 위하여 fork()를 호출하여 새로운 자식 프로세스를 만든다. 그런 후 exec()의 변형 중 하나를 호출하여 프로그램을 실행시킨 후 wait()을 호출하여 명령어가 끝나기를 기다린다. 자식 프로세스가 종료되면 쉘은 wait()으로부터 리턴하고 다시 프롬프트를 출력하고 다음 명령어를 기다린다.

fork()와 exec()를 분리함으로써 쉘은 많은 유용한 일을 쉽게 할 수 있다.

1
prompt> wc p3.c > newfile.txt

위 예제에서 wc 프로그램의 출력은 newfile.txt 파일로 리다이렉트된다. 이러한 작업을 수행하는 방법은 간단하다. 자식이 생성되고 exec()가 호출되기 전에 표준 출력(standard output)을 닫고 newfile.txt 파일을 연다. 이런 작업을 해 놓으면 곧 실행될 프로그램인 wc의 출력은 모두 화면이 아니라 파일로 보내진다.

아래 프로그램은 위 명령어의 작업을 수행한다.

image

우선 Unix 시스템은 미사용 중인 파일 디스크립터를 0번부터 찾아 나간다. 이 경우 STDOUT_FILENO(표준 출력)가 첫 번째 사용 가능 파일 디스크립터로 탐색되어, open()이 호출될 때 할당된다. 이후 자식 프로세스가 표준 출력 파일 디스크립터를 대상으로 하는 printf()와 같은 쓰기는 새로 열린 파일로 향한다.

이 결과는 흥미로운 점이 있다.

첫째, 프로그램을 실행해도 화면에 아무 일도 일어나지 않는다. 그러나 실제로는 프로그램이 fork()를 호출하여 새로운 자식 프로세스를 생성하고, exec()의 변형 중 하나를 호출하여 wc 프로그램을 실행시킨다. 그리고 출력이 p4.output 파일로 리다이렉트 되었기에 화면에는 아무 것도 출력되지 않는다.

Unix 파이프가 이와 유사한 방식으로 구현되지만 pipe() 시스템 콜을 통하여 생성된다. 이 경우, 한 프로세스의 출력과 다른 프로세스의 입력이 동일한 파이프에 연결된다. 한 프로세스의 출력은 자연스럽게 다음 프로세스의 입력으로 사용되고, 명령어 체인이 형성된다. 아래와 같은 명령어처럼 말이다.

1
grep foo file | wc -l


그 외의 API들


Unix 시스템에는 fork(), exec(), wait() 외에 많은 프로세스 관련 인터페이스가 있다. 예를 들면, kill() 시스템 콜은 프로세스에게 시그널(signal)을 보내는 데 사용된다. 시그널은 프로세스를 중단(block)시키고, 삭제 하는 등의 작업에 사용된다. 시그널이라는 운영체제의 메커니즘은 외부 사건을 프로세스에게 전달하는 토대이다. 이 기반 구조는 시그널을 보내거나 전달받는 방법을 모두 포함한다.

ps 명령어는 어떤 프로세스가 실행 중인지 알아보기 위하여 사용된다. top 명령어 역시 시스템에 존재하는 프로세스와 그 프로세스가 CPU 및 다른 자원들을 얼마나 사용하고 있는지를 보여주기 때문에 유용하다.


끄적끄적 - fork()와 exec()의 분리, 파일 디스크립터


위에서 공부한 것처럼 fork()와 exec() 시스템 콜을 분리하였기에 프로세스 생성과 프로그램 실행 사이에 추가 작업을 수행할 수 있는 시간과 공간을 제공할 수 있다.

1
prompt> wc p3.c > newfile.txt

가령, 위 명령어처럼 표준 출력 파일 디스크립터를 조작하여 프로그램의 출력을 파일로 리다이렉트할 수 있다. fork()는 프로세스 복제만 담당하고, exec()는 프로그램 실행만 담당한다.

최근에 C++로 객체지향 프로그래밍을 공부하며 단일 책임 원칙(Single Responsibility Principle, SRP)를 복기하였는데, 이와 유사한 부분이 많다.

Unix 시스템은 1970년대에 개발되었다하고, 객체지향 프로그래밍의 SOLID 원칙은 2000년대 초반에 정의되었다고 한다. Unix 시스템을 설계한 사람들이 객체지향 원칙을 의식하고 적용한 것은 모르겠지만, 객체지향 설계 원칙들이 Unix 시스템의 성공 사례를 분석하며 정의하지 않았을까?

또한 이러한 리다이렉션은 Unix/Linux 시스템은 모든 것은 파일이라는 철학이 있기에 가능한 일이다. 파일 디스크립터는 프로세스가 파일을 다룰 때 사용하는 추상적인 개념이다. 일반적인 파일, 폴더, 소켓, 파이프 등 모든 객체를 파일로 관리한다. 심지어 입출력 장치조차 파일로 다룬다.

프로세스가 실행 중에 파일을 열면 운영체제는 사용하지 않는 파일 디스크립터 중 가장 작은 값을 할당한다. 기본적으로 0, 1, 2번은 표준 입력, 표준 출력, 표준 오류에 할당된다.

1
2
3
4
5
fd 0: 표준 입력 (stdin)
fd 1: 표준 출력 (stdout)
fd 2: 표준 오류 (stderr)
fd 3: x
...

fd 0번은 키보드와 같은 입력 장치이고, fd 1번과 2번은 터미널 화면으로 출력된다. 기본적으로 표준 출력이 ‘터미널 화면’이라는 파일로 출력되게 되어 있으므로, 이 ‘터미널 화면’ 파일을 닫고 출력을 원하는 newfile.txt와 같은 파일을 열고, printf()를 호출하면 해당 파일로 출력이 된다.

printf()는 기본적으로 표준 출력인 fd 1번으로 출력되기 때문이다.


참고

This post is licensed under CC BY 4.0 by the author.