[운영체제] 프로세스 (Process) & Process API (fork(), wait(), exec())

전윤혁·2024년 8월 25일
0

OS

목록 보기
2/18

프로세스 (Process)

이전 글에서 운영체제의 개념을 설명하며, 프로세스라는 단어를 지속적으로 사용하였다. 이번 글에서는 프로세스가 정확히 무엇인지 알아본 후, 관련된 내용들을 살펴보도록 하자.


1. 프로세스 개념

1) 프로세스란?

프로세스란 단순히 실행 중인 프로그램을 뜻한다. 그렇다면 여기에서 프로그램은 무엇일까?

프로그램은 파일이 저장 장치에 저장되어 있지만, 실행 중이지 않은 정적인 상태를 의미한다. 이 때 프로그램을 실행하면 해당 파일은 컴퓨터 메모리에 올라가게 되고, 이 상태의 프로그램을 프로세스라고 한다.

2) 프로세스 메모리 구조

운영체제는 위와 같이 프로세스에게 독립적인 메모리 공간을 할당한다.

  • Code
    실행할 프로그램의 코드가 저장되는 영역이다.
    CPU는 해당 영역에 저장된 명령어를 하나씩 실행한다.

  • Data
    전역변수(Global)와 정적변수(Static)가 저장되는 영역이다.
    프로그램 시작 시 할당되고 종료 시 해제되며, 프로그램 전체에서 공유된다.

  • Heap
    동적 메모리 할당에 사용되는 영역이다.
    힙 영역은 주로 사용자(프로그래머)가 직접 관리한다. (런 타임)

  • Stack
    지역 변수, 매개변수, 리턴 주소 등이 저장되는 임시 데이터 영역이다.
    함수가 호출될 때 스택 프레임이 추가되고, 함수가 종료될 때 해당 스택 프레임이 해제된다. (컴파일 타임)

3) 프로세스 (Process) vs 스레드 (Thread)

  • 프로세스
    "운영체제로부터 자원을 할당받은 작업의 단위"

  • 스레드
    "프로세스가 할당받은 자원을 이용하는 실행 흐름의 단위"

위와 같이 각 개념에 대한 정의를 내릴 수 있다.

프로세스 메모리 구조에서 볼 수 있듯이, 각 프로세스의 메모리는 독립적이고, 공유되기 어렵다. 하나의 프로세스만으로 프로그램을 실행할 수 있다면 문제가 없지만, 대부분의 프로그램의 경우 단순히 한 가지 작업만을 수행하지는 않는다. 이를 위한 개념이 바로 스레드이다.

스레드는 프로세스보다 더 작은 실행 흐름의 단위이다. 스레드는 프로세스와 다르게 스레드 간 메모리를 공유할 수 있다. 즉, 스레드는 하나의 프로세스 내에서 실행되는 여러 실행 흐름이라고 생각할 수 있다.

직관적인 예시로 C 언어 프로젝트를 들어보자. 프로그램은 컴파일된 실행 파일, 프로세스는 그 실행 파일이 실행되어 메모리에서 동작하는 인스턴스, 스레드는 그 프로세스 내에서 병렬적으로 실행되는 여러 작업 흐름(함수)이라고 할 수 있다.


2. 멀티 프로세스 (Multi Process) & 멀티스레드 (Multi Thread)

소프트웨어 개발에서 멀티 태스킹을 구현하는 방법으로, 멀티 프로세스와 멀티 스레드가 주로 사용된다.

📌 멀티 태스킹이란?

하나의 CPU가 여러 작업을 동시에 처리하는 것처럼 보이도록, 빠르게 작업을 전환하며 실행하는 방식을 의미한다.

프로세스와 스레드의 차이를 알아봤으니, 멀티 프로세스와 멀티 스레드의 차이에 대해서도 감이 잡힐 것이라고 생각한다.

1) 멀티 프로세스

멀티프로세스는 독립된 메모리 공간을 가진 여러 개의 프로세스(Process)를 동시에 실행하는 방식이다.

멀티프로세스는 여러 프로세스를 동시에 실행하여 병렬 처리를 수행하는 방식으로, 앞서 설명했듯이 각 프로세스는 독립적인 메모리 공간을 가지므로 데이터 공유가 어렵다.

  • 장점
    하나의 프로세스가 실패해도 다른 프로세스에 영향을 주지 않으므로(독립된 메모리 공간), 안정성이 높다.

  • 단점
    데이터 공유를 위한 프로세스 간 통신(IPC)의 비용이 높고, 각 프로세스의 메모리가 독립적이기 때문에 같은 데이터가 중복 저장되어 메모리 사용량이 증가할 수 있다. 또한, 새로운 프로세스를 생성하는 비용이 크다.

2) 멀티 스레드

멀티스레드는 하나의 프로세스 내에서 여러 스레드(Thread)를 사용하는 방식이다.

멀티 프로세스는 같은 프로세스 내 스레드들이 메모리를 공유하는 방식이다. 즉, 하나의 프로세스 안에서 여러 스레드가 동일한 자원을 사용하여 병렬 처리를 수행한다.

  • 장점
    메모리와 자원을 공유하므로 데이터 공유가 용이하고, 중복 데이터로 인한 메모리 낭비가 적다. 또한, 새로운 스레드를 생성하는 비용이 적다.

  • 단점
    데이터 공유가 용이하지만, 그만큼 동기화 문제가 발생할 수 있다. 동기화 문제란 여러 스레드가 동시에 공유 자원에 접근할 때 발생하는 문제이다.

동기화 문제를 기억하자! 이후 글에서 알아볼 뮤텍스(Mutex), 세마포어(Semaphore)는 동기화 문제와 관련된 중요한 주제이다.


3. Process Creation

위의 내용을 토대로 프로세스의 생성 과정을 정리해보자.

  1. 프로그램 코드 메모리 로드
    실행 가능한 형태로 디스크에 존재하는 프로그램을 로드한다. 운영체제는 로딩 과정을 Lazy loading (지연 로딩)으로 수행한다.

📌 Lazy loading과 Eager loading

Lazy loading (지연 로딩)은 필요한 시점에 데이터를 불러오는 방식이고, Eager loading (즉시 로딩)은 프로그램 실행 시점에 필요한 모든 데이터를 미리 로드하는 방식이다.

  1. 프로그램의 런타임 스택 할당
    스택은 지역 변수, 함수 매개변수, 반환 주소 등을 저장하는 데 사용된다. 스택은 main() 함수의 인수인 argcargv 배열을 통해 초기화된다.

  2. 프로그램의 힙 할당
    힙은 동적으로 할당된 데이터를 저장하는 데 사용된다. 프로그램은 malloc()을 호출하여 공간을 요청하고, free()를 호출하여 반환한다.

  3. Initialization
    OS는 이외에도 여러 초기화 작업을 진행하는데, 대표적으로 입출력(I/O) 설정이 있다. 기본적으로 프로세스는 표준 입력, 표준 출력, 에러를 위한 세 개의 파일 디스크립터를 갖는데, OS는 각 프로세스가 해당 파일 디스크립터를 갖도록 초기화한다.


4. Process States

프로세스는 아래와 같은 세 가지 상태 중 하나에 있을 수 있다.

  • Running
    프로세스가 현재 프로세서에서 실행되고 있는 상태이다.

  • Ready
    프로세스가 실행될 준비가 되어 있지만, 특정 이유로 인해 운영 체제가 이 시점에 해당 프로세스를 실행하지 않은 상태이다.

  • Blocked
    프로세스가 특정 종류의 작업을 수행한 상태이다. 예를 들어, 프로세스가 I/O 요청을 수행하는 경우, 다른 프로세스가 프로세서를 사용할 수 있도록 Blocked 상태가 된다.


5. Process API

OS는 사용자가 프로세스를 생성하고 관리할 수 있도록 API를 제공한다. API가 제공하는 주요 기능은 아래와 같다.

  • 생성 (Create)
    새로운 프로세스를 생성하여 프로그램을 실행

  • 파괴 (Destroy)
    비정상적으로 동작하는 프로세스를 중지

  • 대기 (Wait)
    특정 프로세스가 종료될 때까지 대기

  • 기타 제어 (Miscellaneous Control)
    프로세스를 일시 중단했다가 다시 재개할 수 있는 기능 등

  • 상태 (Status)
    프로세스에 대한 상태 정보 확인

실제로 API의 사용 예시를 fork(), wait(), exec 시스템 콜을 통해 알아보자.

✅ fork()

fork() 시스템 콜은 프로세스를 생성하는 역할을 한다.

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

int main(int argc, char *argv[]) {
    printf("hello world (pid:%d)\n", (int) getpid());
    int rc = fork();
    if (rc < 0) { // fork failed; exit
        fprintf(stderr, "fork failed\n");
        exit(1);
    } else if (rc == 0) { // child (new process)
        printf("hello, I am child (pid:%d)\n", (int) getpid());
    } else { // parent goes down this path (main)
        printf("hello, I am parent of %d (pid:%d)\n", rc, (int) getpid());
    }
    return 0;
}

코드는 fork() 시스템 콜을 사용하여 프로세스를 복제한다.

코드 실행 과정을 요약하면 다음과 같다.

  1. 부모 프로세스 실행
  2. fork() 시스템 콜을 통해 자식 프로세스 생성
  3. 생성 시, 자식 프로세스의 rc 값은 0으로 초기화
  4. 부모 프로세스와 자식 프로세스 각각 rc 값에 따른 문장 출력

📌 자식 프로세스가 "hello world"를 출력하지 않는 이유

자식 프로세스에서 hello world가 출력되지 않는 이유는 자식 프로세스가 부모 프로세스의 Context를 그대로 복사하기 때문이다.

따라서, 자식 프로세스의 PC(Program Counter)는 부모 프로세스와 똑같이 fork()가 끝난 시점을 가리키고 있다.

Context에 대해서는 다음 글에서 다시 알아보도록 하자.

해당 코드의 실행 결과는 아래의 둘 중 하나가 된다.

prompt> ./p1
hello world (pid:29146)
hello, I am parent of 29147 (pid:29146)
hello, I am child (pid:29147)
prompt> ./p1
hello world (pid:29146)
hello, I am child (pid:29147)
hello, I am parent of 29147 (pid:29146)

실행 결과가 둘 중 하나가 된다는 뜻은, 부모 프로세스와 자식 프로세스 중 어떤 것이 먼저 실행되는지 알 수 없다는 뜻이다.

이를 비결정적(Nondeterministic)이라고 표현하며, 어느 프로세스가 먼저 실행되거나 더 빨리 끝나는지는 운영체제의 CPU 스케줄링에 따라 달라진다.

✅ wait()

wait() 시스템 콜은 특정 프로세스가 다른 프로세스의 종료를 기다리도록 하는 역할을 한다.

이전 예시의 경우 프로세스의 실행 순서가 비결정적(Nondeterministic)이었다. 만약 자식 프로세스가 부모 프로세스보다 먼저 실행되도록 순서를 보장해야 하는 경우, 아래 예시와 같이 wait() 시스템 콜을 사용할 수 있다.

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

int main(int argc, char *argv[]) {
    printf("hello world (pid:%d)\n", (int) getpid());

    int rc = fork();
    if (rc < 0) { // fork failed; exit
        fprintf(stderr, "fork failed\n");
        exit(1);
    } else if (rc == 0) { // child (new process)
        printf("hello, I am child (pid:%d)\n", (int) getpid());
    } else { // parent goes down this path (main)
        int wc = wait(NULL);
        printf("hello, I am parent of %d (wc:%d) (pid:%d)\n",
               rc, wc, (int) getpid());
    }
    return 0;
}

코드에서 눈여겨봐야할 부분은 int wc = wait(NULL); 부분이다. 이 때 NULL은 자식 프로세스의 종료 상태 정보를 받지 않겠다는 의미로, 단순히 자식 프로세스의 종료를 기다린다는 의미이다.

아래와 같이 항상 자식 프로세스가 먼저 실행되는, 결정적(Deterministic)인 코드 실행 결과를 확인할 수 있다.

prompt> ./p2
hello world (pid:29266)
hello, I am child (pid:29267)
hello, I am parent of 29267 (wc:29267) (pid:29266)

✅ exec()

exec 시스템 콜은 현재 프로세스의 주소 공간을 새로운 프로그램으로 교체하는 역할을 한다.

앞에서 살펴본 fork()는 현재 프로세스를 복제할 때 사용되고, exec()은 아예 다른 프로세스를 실행시킬 때 사용된다.

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

int main(int argc, char *argv[]) {
    printf("hello world (pid:%d)\n", (int) getpid());

    int rc = fork();
    if (rc < 0) { // fork failed; exit
        fprintf(stderr, "fork failed\n");
        exit(1);
    } else if (rc == 0) { // child (new process)
        printf("hello, I am child (pid:%d)\n", (int) getpid());
        char *myargs[3];
        myargs[0] = strdup("wc"); // program: "wc" (word count)
        myargs[1] = strdup("p3.c"); // argument: file to count
        myargs[2] = NULL; // marks end of array
        execvp(myargs[0], myargs); // runs word count
        printf("this shouldn’t print out");
    } else { // parent goes down this path (main)
        int wc = wait(NULL);
        printf("hello, I am parent of %d (wc:%d) (pid:%d)\n",
               rc, wc, (int) getpid());
    }
    return 0;
}

코드 실행 결과는 아래와 같다.

prompt> ./p3
hello world (pid:29383)
hello, I am child (pid:29384)
29 107 1030 p3.c
hello, I am parent of 29384 (wc:29384) (pid:29383)

부모 프로세스가 실행되고, 자식 프로세스가 생성된 후 부모 프로세스가 wait()을 호출하는 흐름은 이전 예시와 같다. 다른 점은, 자식 프로세스 내에서 execvp(myargs[0], myargs);를 통해 새로운 프로그램을 실행한다는 점이다. 자식 프로세스 부분의 내용만 정리해보자.

  1. myargsexecvp 호출에 사용할 인수 목록을 저장하는 배열

  2. myargs[0]에는 실행할 프로그램인 wc가 저장, myargs[1]에는 "p3.c"라는 인수가 저장, NULL은 인수 목록의 끝을 의미

  3. execvpmyargs[0]에 지정된 프로그램 wc를 실행하고, myargs 배열을 인수로 전달한다.

그렇다면 printf("this shouldn’t print out"); 부분은 왜 출력되지 않는걸까? execvp() 호출이 성공하면, 현재 프로세스의 코드와 데이터는 새로운 프로그램으로 교체되므로 execvp() 이후의 코드는 실행되지 않는다. 달리 말하면, 현재 프로세스의 PCB(Process Control Block)가 wc의 것으로 덮어 씌워진다.

PCB는 우선 프로세스의 실행 상태를 유지하는 데이터 구조라고 이해하면 되겠다!

새로운 프로세스 실행 과정 요약

fork()exec()wait()exit()

새로운 프로세스의 생성과 실행은 위와 같은 과정으로 이루어진다.

  1. fork
    fork() 시스템 콜을 통해 현재 실행 중인 부모 프로세스를 복제하여 자식 프로세스를 생성한다. OS 또한 프로세스라는 사실을 인지하자.

  2. exec
    exec() 시스템 콜을 통해 자식 프로세스에서 새로운 프로그램을 실행한다.

  3. wait
    wait() 시스템 콜을 통해 부모 프로세스는 자식 프로세스의 종료를 기다린다.

  4. exit
    exit() 시스템 콜은 프로그램이 종료될 때 호출된다.

왜 복잡하게 fork()exec()을 분리하여 새로운 프로세스를 실행하는걸까? 프로세스 생성과 덮어 씌우기를 한 번에 진행하면 되지 않을까?

fork()exec()을 분리하는 이유는, I/O Redirection, File Descriptor 관계성 설정 등을 유연하게 하기 위함이다.

예를 들어, 프로세스의 출력 내용을 output.txt 파일에 저장하도록 설정하거나, 특정 File Descriptor 관계성을 설정하는 작업 등은 모두 exec() 이전에 설정된다. fork()exec()이 분리되어 있기 때문에 이러한 설정이 가능해지는 것이다.

File Descriptor는 우선 "운영체제가 파일이나 입출력 자원을 식별하고 관리하기 위해 사용하는 정수 값" 정도로 이해하고 넘어가자!


마치며

이번 글에서는 OSTEP의 내용에 더불어, 프로세스에 대한 개념을 정리했다. 프로세스에 대한 이해를 토대로, 다음 글에서는 PCB와 Context Switching에 대해 알아보도록 하겠다.

profile
전공/개발 지식 정리

0개의 댓글