운영체제 OStudy

[OStudy] 2주차 - 가상화의 세계

Zeka_P 2025. 9. 8. 21:27

이제 본격적인 프로세스에 대한 전반적 내용을 공부해보겠습니다.

이후의 스케줄링이나, 메모리 가상화 등 모든 개념에 대해, 이번 주차에 배우는 내용들이 가장 밑바탕으로 깔려 들어갑니다. 구심점이 되는 파트인 만큼, 굉장히 신경써야만 합니다. 

 

프로세스의 개념

 본격적으로 프로세스에 대해 공부하기 전에, 프로세스는 어떤 것을 의미할까요? 디스크에 저장된 명령어나 데이터의 집합을 프로그램이라 한다면, 프로세스는 실행중인 해당 집합, 즉 실행중인 프로그램을 의미합니다. 그리고 운영체제가, 명령어와 데이터 실행과정을 통해 이 프로그램을 동작시키며, 프로세스가 생성되죠. 저번 장에서도 다룬 이야기이지만, 프로세스는 하나만 실행되지 않습니다. 유튜브도 보면서 게임을 할 수도 있고, 동시에 공략 사이트를 킬 수도 있겠죠. 하지만 우리는 이런 모든 과정에서 큰 위화감을 느끼지 않습니다. 컴퓨터가 매우 빠른 시간 안에, 동시 처리를 진행하고 있기 때문이죠. 흔히 이를, 시분할 방식을 사용하여 타 프로세스로 전환하는 방식을 반복한다 합니다. 하지만, 어떤 효율적인 규칙을 정하지 않고, 이를 행한다면 기술적 성능이 미흡한 컴퓨터가 되겠죠. 따라서 이를 효율적으로 처리할 수 있음이 보장된, 저수준의 매커니즘과, 프로그램 실행 결정 정책(Policy)이 필요합니다. 그리고 이 정책을 기반으로 운영체제는 동작할 프로그램과, 교체할 타이밍을 선정(스케줄링)하며, 따라서 성능과도 직결됩니다. 

단순히 실행 중인 프로그램만으로 멈추지 않습니다. 프로세스가 동작하며 진행되는 작업을 통해, 메모리의 공간을 사용하며, 레지스터를 캐시로 이용합니다. 또는 직접 저장장치에 저장을 하는 경우도 있을 겁니다. 따라서 프로세스로 인해 사용되는 자원들 역시 고려해야 합니다.

 

프로세스 API

이런 전반적인 과정이 존재하기 때문에, 운영체제는 다음 5개의 기본 기능이 반드시 제공되어야 합니다. 프로그램을 실행시키려 할 때, 운영체제는 새 프로세스를 생성(Create)할 수 있어야 합니다. 반대로, 목적을 다 마쳤을 때, 스스로 종료되지 않는 프로그램을 고려하여, 강제적으로 불필요한 프로세스를 종료할 수 있는 제거(Destroy)기능도 기초되어야 합니다. 타 프로세스와의 종속성이 존재하는 등의 이유로 동기화나, 조건 충족이 필요하여 특정 프로세스를 대기(Wait)시킬 수 있어야 하며, 이런 프로세스를 여러 방면으로 제어(Miscellaneous Control) 가능해야 합니다. 그리고 프로세스의 현재 상태(status) 정보를 유지하여, 특이사항이 발생하진 않았는지 검토 가능해야 합니다. 

특히 이 생성의 경우, 먼저 로딩(loading) 과정을 통해 프로세스의 주소 공간을 불러옵니다. 디스크나 SSD에 실행 파일이 저장되어 있는데, 운영체제에서 해당 파일의 필요 부분들을 읽어 메모리에 적재하는 작업을 진행합니다. 이 또한 과거에는 코든 코드와 데이터를 째로 로딩하는 방식이었으나, 현재는 지연 로딩 방식으로, 보다 효율화된 기법을 사용하고 있습니다. 그리고 해당 데이터들이 로딩된 뒤에는, 실행 전 사전작업이 시행됩니다. 지역변수나, 함수인자, 리턴 주소 등을 저장하기 위한 스택(stack) 메모리가 할당되며, 동적 공간으로(말록? 말록! 말록!) 사용하기 위한 힙(heap) 메모리가 할당됩니다. 또한 기초적인 입출력을 위한 입출력 초기화 작업이 동반됩니다. 그리고 이 작업들이 모두 마쳐지고 나서야, 프로그램의 시작점인 entry point로 제어를 이동시키고, 여기까지 완료되어야 프로그램이 실행됩니다. 해당 과정으로 CPU 제어권이 생성한 프로세스에게 이양되고, 프로그램이 동작합니다. 

프로세스는 세가지의 상태로 존재할 수 있습니다. 실행(Running), 준비(Ready), 대기(Blocked)로 나뉘게 되는데, 말그대로 당장 실행 중인 프로세스가 실행 상태에 해당하며, 당장 CPU만 받으면 즉시 실행 가능한 상태를 준비 상태라고 합니다. 즉 스케줄링을 기다리고 있는 입장을 의미합니다. 그리고 특정 이벤트의 실현을 기다리며, 해당 이벤트가 트리거로 동작해야만 실행 가능해지는 상태를 대기 상태라고합니다. 이는 위에서 언급했듯 동기화 등의 구현에 있어서 필요한 상태입니다. 이외에도, 생성 중인 단계에서는 초기(initial) 상태, 종료되었으나 메모리 상에 남아있는 경우를 종료(fianl)상태라 부르는데, 제대로 제거되지 않아, 계속 살아있다 하여, 유닉스 계열에선 이를 좀비(zombie) 상태라 부르기도 합니다. 이들을 추적해 제거할 수 있는 기법 또한 존재하나, 이후 다룰 예정입니다.

 

시스템 콜

 위에서 다루었던 프로세스 API는, 실제 어떤 코드나, 논리적 흐름대로 동작할까요? 가장 필수적으로 존재하는 fork(), wait(), exec() 등의 시스템 콜과 그 로직을 통해, 자세히 알아보겠습니다. 

먼저 fork() 시스템 콜은 현재 실행중인 프로세스와 똑같은 복사본을 생성하는 역할을 합니다. 흔히, 깃허브를 사용할 때, 사본을 생성하는 과정을 포크 뜬다 라고 하는데, 같은 의미입니다. 이 현재 실행중인 프로세스가 부모, 복사된 프로세스를 자식으로 칭하여, 자식 프로세스를 복사하여 생성하는 과정을 의미합니다. 컴퓨터는 이 프로세스들에게 각자의 ID를 부여합니다. 프로세스의 ID라 하여 PID라 부르는데, 해당 번호를 통해 프로세스의 원본 여부 등을 식별합니다. fork()로 생성된 자식 프로세스는 부모 프레세스 메모리 공간을 복사하여 소유합니다. 따라서 데이터(전역,정적변수 저장 영역), 코드, 힙, 스택(함수 호출, 지역변수 저장 영역)을 위한 공간이 복사됩니다. 그리고 이 부모 프로세스와 자식 프로세스는, 각기 다른 PID가 부여되며, 실행 또한 독립적으로 진행됩니다. 각 프로세스가 독립성을 가지므로, 여러 작업이 동시에 수행 가능해지며, 이는 효율성과 응답성 향상으로 이어집니다.

아래 코드는, fork() 시스템콜을 검증하는 코드입니다. fork() 코드를 통해 반환된 값을 프로세스의 PID와 비교합니다. fork()는 반환값으로, 자식 프로세스의 경우 0, 부모의 경우 본인의 PID를 반환하는 함수이기 때문에 가능한 코드입니다. 

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main() {
    pid_t pid;
    pid = fork();
    if (pid == 0) { // 자식 프로세스
        printf("이것은 자식 프로세스입니다.\n");
    } else if (pid > 0) { // 부모 프로세스
        printf("이것은 부모 프로세스입니다.\n");
    } else { // fork 실패
        printf("fork() 실패\n");
    }
    return 0;
}

 

wait() 시스템 콜의 경우, 특정 조건을 기다리는 역할을 맡습니다. 특히 이 기다린다는 의미는, 특정 조건이 성사되어, 대기 상태의 프로세스가 다시 실행 가능한 준비 상태가 될 때 까지, 정지되어 있음을 의미합니다. 따라서 CPU의 제어권을 이양받지 못하며, 그렇기에 이 트리거 역할로 인해 수동적으로 대기 상태가 풀리는 특징을 가지고 있습니다. 아래는 wait() 시스템 콜에 대한 전반적 이해를 도울 수 있는 코드입니다. 자식프로세스가 종료될 때까지 부모 프로세스의 실행이 중지되며, 자식이 종료될 경우, wait된 시점부터 다시 실행됩니다. 이는, 자식 프로세스가 종료될 때까지 대기했으므로, 자식의 반환값을 가지고 실행을 이어갈 수 있다는 특징을 보입니다. 종속성이나 동기화가 필요한 로직의 경우 이 wait() 기법을 고려할 수 있습니다. 

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

int main() {
    pid_t pid;
    int status;

    pid = fork();
    if (pid == 0) { // 자식 프로세스
        printf("자식 프로세스 실행\n");
    } else if (pid > 0) { // 부모 프로세스
        wait(&status); // 자식 프로세스의 종료를 대기
        printf("부모 프로세스 재개\n");
    } else { // fork 실패
        printf("fork() 실패\n");
    }

    return 0;
}

 

exec() 시스템 콜의 경우, 프로세스가 새로운 프로그램을 실행할 수 있도록 해줍니다. 현재 프로세스의 이미지 자체를 새 프로그램 이미지로 교체한다는 명령을 의미하며, 따라서 메모리 내용이 완전히 새로운 프로그램으로 바뀌는 것을 의미합니다. fork()의 경우 단순 복사본이 형성되는 시스템콜이라며, exec()의 경우, 완전히 모든 영역이 이양됨을 의미합니다. 아래는 exec() 시스템콜의 이해를 돕기 위한 코드입니다. execvp()가 실행되며, 기존 프로세스는 동작을 모두 마치고 다음 프로세스로 넘기기에, 그 하단에 있는 printf 함수는 실행되지 않습니다.

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

int main() {
    char *args[] = {"echo", "Hello, exec()!", NULL};
    execvp("echo", args);

    // execvp 호출 후 이 코드는 실행되지 않습니다.
    printf("이 문장은 실행되지 않습니다.\n");

    return 0;
}

 

제한적 집적 실행

 운영체제는 프로그램을 CPU에서 직접 실행 시키되, 운영체제가 CPU제어권은 잃지 않도록 하는, 제한적 직접 실행 기법을 통해, CPU 가상화 정책을 유지합니다. 이 과정에서, 프로세스의 행동에 제약을 걸어, CPU 제어권을 유지합니다. 이 프로세스가 다중적으로 동작하는 과정에서 안정성을 보장받을 수 있습니다.

기초 원리는 다음 네가지로 시작됩니다. 프로세스를 위한 메모리가 할당되며, 프로그램이 메모리에 적재됩니다. 그라고 CPU를 사용자 모드(user mode)로 전환시킨 뒤, 프로그램의 main()으로 이동시킵니다. 그리고 동작하는 프로그램이 시스템 콜을 통해 호출될 때만, 커널 모드(kernel mode)로 전환되며 운영체제가 사용 권한을 이양받아 처리합니다. 그리고 다시 사용자 모드로 전환됩니다.

특히 이 모드 전환의 경우, 제한된 연산 수행이 가능토록 해줍니다. 프로세스가 시스템 내 모든 자원에 접근 가능할 경우, 굉장한 보안적 문제가 생길 수 있습니다. 따라서, 보호, 통제가 보장된 정책 하에서만 자원이 공유되며, 필요한 경우에만 이를 요청할 수 있도록 구축되어야 합니다. 이러한 제한된 연산 수행 문제를 위해, 하드웨어는 위에서 언급한 사용자 모드를 통해 응용프로그램 실행 시 해당 모드로 전환하며, 하드웨어 자원 접근을 제한시킵니다. 그리고 필요 시, 시스템 콜을 통해, 커널 모드로 전환하며, 운영체제로 실행을 넘깁니다. 해당 모드에서는 하드웨어의 모든 자원에 접근할 수 있습니다. 사용자 모드에서 커널 모드로 넘어가는 명령어를 trap, 커널 모드에서 사용자 모드로 넘어가는 명령어를 return from trap으로 제공됩니다. 그리고 해당 트랩이 발생했을 때 해당 실행할 코드의 주소를 담고 있는 트랩 테이블을 하드웨어로 전달시킬 수 있어야 합니다.

프로세스의 전환에 대해서도 고려해야할 점이 존재합니다. 전환 방식은 기초적으로, 시스템콜이 호출되어 운영체제가 전환 과정을 실행할 때까지 대기하는 방식으로 이루어집니다. 즉, 시스템콜,yield 등의 호출이 일어나야 제어권 처리가 가능해짐을 의미하는데, 이는 프로그램 측에, 제어권 처리 호출 권한을 부여한다는 의미인데, 이 권한을 완전히 전담하게 될 경우, 어떤 사유에서든 해당 호출 명령어가 실행되지 않을 경우(무한루프 등) CPU가 이양되지 않고, 따라서 동시성 형성이 불가능해지는 문제가 생깁니다. 그렇기 때문에 이러한 협조 방식 뿐만 아니라, 운영체제에서 강제적으로 권한을 확보할 수 있는 비협조적 전환 방식 또한 동시에 존재해야합니다. 해당 권한을 전담하는 것은, 사실상 CPU 사용 권한을 프로그램 본인이 쥐고 있는 것이나 다름 없기 때문입니다. 타이머 인터럽트 방식이 이 비협조적 방식을 의미합니다. 하드웨어 타이머를 통해 일정 시간 단위마다, 인터럽트를 통해 CPU 권한을 프로그램으로부터 강제 회수 시킵니다. 그리고 준비 상태의 프로세스 중, 알맞는 선정 정책을 통해 차순위 프로세스에게 해당 권한을 넘겨주고 재실행 시키는 정책입니다. 해당 과정을 통해, 과도한 CPU 선점을 미연에 방지할 수 있습니다.