[Operating System] – “프로세스 API”

[Operating System] – “프로세스 API”

10월 17, 2024

이 글은 도서 Operating Systems: Three Easy Pieces 를 읽고, 제가 이해한 방식대로 정리한 것임을 밝힙니다.
blog-driven-development 나 blog-driven-learning 은 위험합니다. 저의 글을 끊임없이 의심하고, 검증하고, 혹시라도 틀린 내용이 있거나, 논리적 비약이 있다면 가감없이 알려주시면 감사하겠습니다 :)

Unix 시스템의 프로세스 생성

시스템 호출 또는 시스템 콜(system call), 간단히 시스콜(syscall)은 운영 체제의 커널이 제공하는 서비스에 대해, 응용 프로그램의 요청에 따라 커널에 접근하기 위한 인터페이스이다. 보통 C나 C++과 같은 고급 언어로 작성된 프로그램들은 직접 시스템 호출을 사용할 수 없기 때문에 고급 API를 통해 시스템 호출에 접근하게 하는 방법이다.

https://ko.wikipedia.org/wiki/%EC%8B%9C%EC%8A%A4%ED%85%9C_%ED%98%B8%EC%B6%9C

운영체제는 응용 프로그램들이 메모리 접근, 디스크 접근과 같은 여러 기능들을 사용할 수 있는 API, 시스템 콜을 제공한다고 했습니다. 현대의 모든 운영체제에서는 대부분 프로세스 생성(새로운 프로세스를 생성할 수 있는 방법), 제거(프로세스를 강제로 제거하는 방법), 대기(어떤 프로세스의 실행 중지를 기다리도록 하는), 각종 제어(일시정지, 혹은 재개), 상태(프로세스의 상태를 얻어내는 방법) 를 다룰 수 있도록 하는 api 를 제공합니다. Unix 시스템에서는 시스템을 생성하기 위해서 fork(), exec() 시스템 콜을, 프로세스의 대기를 위한 wait() 을 제공하죠. 이번 포스팅에서는 그것들에 대해 알아볼 것입니다.

fork() 시스템 콜

unix 기반 시스템에서는 프로세스를 생성하기 위해서 fork() 시스템 콜을 사용합니다. 아래의 코드를 살펴봅시다:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

/**
 * 프로세스를 생성하는 fork() 함수를 사용하여 자식 프로세스를 생성한다.
 */
int main(int argc, char *argv[])
{
    // 현재 프로세스의 pid 를 출력
    printf("hello world (pid:%d)\n", (int)getpid());
    
    int rc = fork();
    
    // fork() 가 실패하는 경우
    if (rc < 0)
    {
        fprintf(stderr, "fork failed\n");
        exit(1);
    }
    
    // 자식 프로세스(새로운 프로세스)
    else if (rc == 0)
    {
        printf("hello, I am child (pid:%d)\n", (int)getpid());
    }
    
    // 부모 프로세스
    else
    {
        printf("hello, I am parent of %d (pid:%d)\n", rc, (int)getpid());
    }
    
    return 0;
}

실행 결과는 아래와 같습니다.

이 결과로부터 우리가 알 수 있는 몇 가지를 정리해 봅시다.

  1. 우리의 p1.c(코드의 이름) 은 gcc 컴파일러에 의해 컴파일되어 실행 가능한 프로그램이 됩니다.
  2. ./p1 으로 프로그램을 실행하는 순간, 저의 운영체제는 이전에 배웠듯이 프로그램을 프로세스로 만듭니다.
  3. 첫 번째 hello world 와 함께, 프로그램에서는 그 프로그램의 pid 를 출력합니다. pid 는 프로세스 식별자입니다.
  4. 그리고, 프로그램은 프로세스 생성을 위해 fork() 시스템 콜을 호출합니다. 결국, 아래와 같은 상황인 거죠.
// 생략 ..

// 프로세스 생성을 위해 fork() 시스템 콜 호출
int rc = fork();

if (rc < 0)
{
    // fork() 에 실패하면, 프로그램 종료
}
else if (rc == 0)
{
    // 현재 프로세스가 자식 프로세스라면, 이 분기를 탐
}
else
{
    // 현재 프로세스가 부모 프로세스라면, 이 분기를 탐
}
return 0;

그런데 이상한 부분이 있습니다. 분명히, 우리가 아는 프로그램 흐름대로라면 if -> else if -> else 분기에 따라서 printf 는 한 번만 호출되어야 합니다. 하지만 결과는.. 아래와 같죠.

어떤 이유에서인지, 위의 분기에서 else-ifelse 분기를 모두 타 버린 겁니다. 어떻게 이런 일이 가능한 걸까요? 이를 알기 위해선 fork() 가 정확히 무슨 일을 하는지를 알아볼 필요가 있습니다.

컴퓨팅, 특히 유닉스 운영 체제와 유닉스 계열 환경에서 포크(fork)란 프로세스가 자기 자신을 복제하는 동작이다. 이는 일반적으로 시스템 호출의 일종이며, 커널 안에서 구현된다. 포크는 유닉스 계열 운영 체제에서 프로세스를 만드는 주된 방식이다. 복제의 대상을 부모 프로세스라 하고 그 결과물을 자식 프로세스라 한다.

https://ko.wikipedia.org/wiki/%ED%8F%AC%ED%81%AC_(%EC%8B%9C%EC%8A%A4%ED%85%9C_%ED%98%B8%EC%B6%9C)

맞아요, fork() 시스템 콜을 호출한 순간 fork() 는 자기 자신의 복제본 프로세스를 만들어냅니다. 원래 프로세스를 부모 프로세스, 복제된 프로세스를 자식 프로세스라고 한다면, 운영체제 입장에서는 fork() 시스템 콜을 호출한 순간 p1 이라는 프로그램으로부터 생성된 프로세스가 2개가 되어 버린다는 거죠. 부모 프로세스, fork() 호출로부터 복제된 자식 프로세스 말이에요.

그래서, 두 개의 프로세스가 값을 반환하게 되고, 부모 프로세스는 hello, I am parent of ... 를, 자식 프로세스는 hello, I am child ... 를 출력할 수 있던 거였죠.

그런데요, 그런데 말이죠. fork() 시스템 콜은 자기 자신을 복제하는 동작이다” 를 읽으셨다면 한 가지 의문이 더 들지 않나요? 분명히 부모 프로세스는 main() 이 프로세스의 진입점이므로 hello world (pid:%d)\n 를 출력해야 합니다. 그리고 fork() 시스템 콜이 부모 프로세스를 복제했다면, 같이 자식 프로세스도 hello world (pid:%d)\n 를 출력해야 하지 않겠어요?

그 이유는 자식 프로세스가 fork() 를 호출하면서 생성되었기 때문입니다. 자식 프로세스와 부모 프로세스는 완전히 동일하지 않습니다. 자신만의 주소 공간, 자신만의 레지스터, 자신만의 PC 값을 가집니다. 부모 프로세스는 fork() 로부터 자식 프로세스의 프로세스 식별자(pid) 를, 자식 프로세스는 0 을 반환받습니다. 따라서, 어떤 프로세스를 복제했을 때 이 프로세스가 자식 프로세스인지 – 부모 프로세스인지를 구분하여, 각각 다른 동작을 하게끔 프로그램을 작성할 수 있는 것이죠.

fork() 시스템 콜이 프로세스를 생성한다” 라는 것을 머릿속에 꼭 넣어둔 독자들은, “어, 그러면 – fork() 시스템 콜을 호출한 다음에는, 운영체제 안에서 자식 프로세스, 부모 프로세스가 동시에 존재하게 되는 건데, 운영체제는 어떻게 ‘이 프로세스 먼저, 저건 나중에’ 를 결정하는 걸까?” 라는 의문이 드실 수 있습니다. 운영체제에서 실행할 프로세스가 많을 때, 어떤 프로세스를 실행할지는 CPU Scheduler 가 결정합니다. 얼마 지나지 않아 스케쥴러에 대해서 알아볼 겁니다.

wait() 시스템 콜

위의 예제에서 혼란을 느끼셨다면(“자식 프로세스와 부모 프로세스가 있을 때, 어떤 프로세스를 먼저 실행해야 하는가?”), 무조건 부모 프로세스가 자식 프로세스의 종료를 기다려야 하는 프로그램은 어떻게 작성하는지에 대해 의문을 가지실 독자들도 있을 것입니다. Unix 기반 시스템에서는 이러한 상황을 위해 wait() 혹은 waitpid() 와 같은 시스템 콜을 제공합니다. 아래의 코드를 살펴봅시다:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

/**
 * 프로세스를 생성하는 fork() 함수를 사용하여 자식 프로세스를 생성한다.
 * wait() 함수를 사용하여 자식 프로세스가 종료될 때까지 기다린다.
 */
int main(int argc, char *argv[])
{
    printf("hello world (pid:%d)\n", (int)getpid());
    int rc = fork();
    if (rc < 0)
    {
        // fork 실패; 종료
        fprintf(stderr, "fork failed\n");
        exit(1);
    }
    else if (rc == 0)
    {
        // 자식 (새로운 프로세스)
        printf("hello, I am child (pid:%d)\n", (int)getpid());
        sleep(1);
    }
    else
    {
        // 부모 (원래 프로세스)
        int wc = wait(NULL);
        printf("hello, I am parent of %d (wc:%d) (pid:%d)\n",
               rc, wc, (int)getpid());
    }
    return 0;
}

위의 코드와 비슷하지만, 아래의 코드에서 wait() 를 새로 호출하고 있는 것을 확인할 수 있죠? wait() 시스템 콜에 대한 설명을 조금 더 읽어봅시다.

컴퓨터 운영 체제에서 프로세스 (또는 작업)는 다른 프로세스가 실행을 완료할 때까지 기다릴 수 있습니다. 대부분의 시스템에서 부모 프로세스는 독립적으로 실행되는 자식 프로세스 를 만들 수 있습니다. 그런 다음 부모 프로세스는 wait() 시스템 호출 을 실행하여 자식이 실행되는 동안 부모 프로세스의 실행을 일시 중단할 수 있습니다. 자식 프로세스가 종료되면 운영 체제에 종료 상태를 반환하고 대기 중인 부모 프로세스로 반환됩니다. 그러면 부모 프로세스가 실행을 재개합니다.

https://en.wikipedia.org/wiki/Wait_(system_call)

그러면 프로그램의 실행 결과는 어떻게 될까요? 부모 프로세스는 wait() 을 호출함으로서 자식 프로세스의 실행이 완료될 때까지 실행을 일시 중단할 수 있습니다. 당연히 결과는 몇 번을 실행하던, 아래와 같겠죠. 자식 프로세스가 먼저 실행되고, 그 때까지 wait() 시스템 콜을 호출해 기다린 부모 프로세스가 이후로 실행되는 겁니다.

이것은 첫 번째 코드에서 “자식 프로세스가 부모 프로세스보다 먼저 시작하는 경우”, “부모 프로세스가 자식 프로세스보다 먼저 실행되는 경우” 에도 같은 출력 결과를 보장합니다. 어떤 이유에 의해서 부모 프로세스가 먼저 실행된다면 바로 wait() 시스템 콜이 호출되고 자식 프로세스가 끝날 때까지 대기하게 됩니다.

exec() 시스템 콜

exec() 시스템 콜은 자기 자신이 아닌 다른 프로그램을 실행할 때 사용해야 하는 시스템 콜입니다. 아래의 코드를 먼저 살펴봅시다:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>

/*
 * 이 프로그램은 부모 프로세스와 자식 프로세스를 생성한다.
 * 자식 프로세스는 "wc" 프로그램을 실행하여 파일 "p3.c"의 단어 수를 센다.
 * 부모 프로세스는 자식 프로세스가 종료될 때까지 기다린다.
 */

int main(int argc, char *argv[])
{
    printf("hello world (pid:%d)\n", (int)getpid());
    int rc = fork();
    if (rc < 0)
    {
        // fork 실패; 종료
        fprintf(stderr, "fork failed\n");
        exit(1);
    }
    else if (rc == 0)
    {
        // 자식 (새로운 프로세스)
        printf("hello, I am child (pid:%d)\n", (int)getpid());
        char *myargs[3];
        myargs[0] = strdup("wc");   // 프로그램: "wc" (단어 수 세기)
        myargs[1] = strdup("p3.c"); // 인수: 셀 파일
        myargs[2] = NULL;           // 배열의 끝을 표시
        execvp(myargs[0], myargs);  // 단어 수 세기 실행
        printf("this shouldn't print out");
    }
    else
    {
        // 부모는 이 경로로 진행 (원래 프로세스)
        int wc = wait(NULL);
        printf("hello, I am parent of %d (wc:%d) (pid:%d)\n",
               rc, wc, (int)getpid());
    }
    return 0;
}

지금까지 경험해왔던 코드들처럼, fork() 시스템 콜을 호출해서 자식 프로세스를 만들어낸 다음, 생성된 프로세스가 자식 프로세스인지 – 부모 프로세스인지에 대해서 분기 처리를 하고 있네요. else 분기를 살펴보시면, 부모 프로세스일 때 프로그램은 wait() 시스템 콜을 호출하여 자식 프로세스가 종료될 때까지 기다리는 것도 위의 글들을 읽으셨다면 알 수 있으시겠죠.

새로운 시스템 콜은 if-else 분기에서 등장합니다. execvp() 시스템 콜을 호출하여 자신의 소스 파일 이름(p3.c) 를 호출하여 wc 프로그램을 실행하죠. wc 는 단어 수를 세는 프로그램인데, myargs 에 자신의 소스 파일인 p3.c 를 넣어 코드의 행 수, 단어의 개수, 바이트의 개수가 결과로 나오는 것을 확인할 수 있습니다.

exec() 시스템 콜의 후드 아래에서는 다음과 같은 일이 벌어지고 있습니다.

  1. 실행파일의 이름 (wc), 인자(myargs) 를 받으면, wc 의 코드와 정적 데이터를 읽습니다.
  2. 실행 중인 프로세스 (p3.c 프로세스) 의 코드 세그먼트와 정적 데이터를 덮어씁니다.
  3. 힙, 스택은 새로운 프로그램의 실행을 위해 다시 초기화하고, argv 와 같은 인자를 전달하여 새로운 프로그램을 실행시킵니다.
  4. exec() 시스템 콜은 새로운 프로세스를 생성하지는 않습니다. 현재 프로세스를 다른 실행 중인 프로그램(wc) 로 대체하는 것이죠.
  5. exec() 시스템 콜이 성공하면, p3.c 의 결과는 리턴되지 않습니다. printf("this shouldn't print out"); 가 결과로 나오지 않는 것을 확인할 수 있죠?

Unix 에서 이렇게 간단해 보이는 작업(새로운 프로그램을 실행하는 것) 을 위해 fork()exec() 를 분리하는 것은 그렇게 구현해야 Unix 쉘을 구현할 수 있기 때문입니다. 사용자에게 프롬프트를 출력하고, 입력을 기다리는 프로그램은 쉘은 입력(어떤 실행 파일의 이름, 그것의 실행에 필요한 인자) 을 받으면 아래와 같이 동작합니다.

  1. 파일 시스템에서 실행 파일의 위치를 찾습니다.
  2. 새로운 프로세스를 실행해야 하기 때문에, fork() 시스템 콜을 호출합니다.
  3. exec() 시스템 콜의 변형 중 하나를 호출하여 실행하고자 하는 프로그램을 실행시키고, wait() 시스템 콜을 호출하여 그 프로그램이 끝나기를 기다립니다.
  4. 프로그램(fork() 시스템 콜을 호출하여 생성되고, exec() 의 변형으로 대체된 프로세스) 가 끝나면, 쉘은 wait() 로부터 리턴하고 – 프롬프트를 출력하고 다음 입력을 기다립니다.

위와 같이 fork() / exec() 를 분리하는 것은 아래와 같은 작업을 쉽게 만들어줍니다.

위의 명령어가 쉘에 입력되는 순간, 어떤 일이 일어나는지 우리는 배웠습니다. 한번 정리해 볼까요?

  1. 파일 디스크립터는 ls 라는 실행 프로그램의 위치를 찾을 겁니다.
  2. fork() 시스템 콜을 호출하여 자식 프로세스를 생성합니다.
  3. exec() 시스템 콜이 호출되기 전에, 표준 출력 파일을 닫고 ls_result.txt 파일을 열어 ls -a 의 결과가 ls_result.txt 에 쓰여지도록 합니다.
  4. .. 배웠던 그대로, exec() 시스템 콜을 통해 ls 프로그램이 실행되고, wait() 시스템 콜을 호출하여 프로그램의 실행이 종료될 때까지 대기하고, 결과를 쓰고, 쉘은 새로운 프롬프트를 출력합니다.

아무튼, 우리가 기억해야 할 것은 fork()exec() 는 프로세스를 생성&조작하기 위한 강력한 방법이라는 것입니다.

요약

우리는 이번 포스트에서 Unix 기반 시스템이 제공하는 중요한 API(시스템 콜)인 fork(), wait(), exec() 에 대해 알아보았습니다. 운영체제가 어떻게 프로세스를 생성하고, 프로세스를 대기시키고, 새로운 프로그램을 실행시키는지에 대한 것을 알게 되었습니다. 다음 포스트에서, 우리는 운영체제가 시스템에 대한 통제를 잃지 않으면서 CPU 를 가상화하는 방법에 대해 알아볼 것입니다.

Leave A Comment

Avada Programmer

Hello! We are a group of skilled developers and programmers.

Hello! We are a group of skilled developers and programmers.

We have experience in working with different platforms, systems, and devices to create products that are compatible and accessible.