[운영체제] OS 기초 (Virtualization, Concurrency, Persistence)

전윤혁·2024년 8월 19일
0

OS

목록 보기
1/18

OS (Operating System)

"프로그램을 실행할 때 어떤 일이 일어나는가?"

위의 질문에 대해 구체적으로 생각해보자. 프로그램을 실행하는 것은 곧 명령어(Instruction)를 실행한다는 것을 의미한다.

프로세서(CPU)는 메모리에서 명령어를 가져오고, 해당 명령어가 무엇인지 파악하는 디코드 과정을 수행하며, 연산, 호출 등의 작업을 수행하게 된다. (컴퓨터 구조에 대해 공부했다면 이와 같은 흐름을 이해하고 있을 것이다.)

그렇다면, 이 프로그램을 메모리에 로드한 주체는 누구인가? 이 프로그램이 비정상적으로 동작할 때 제거해주는 역할은 누가 수행하는가?

이번 글부터는 운영체제에 대해 알아보도록 하겠다.

운영체제 시리즈의 내용은 기본적으로 <Operating Systems: Three Easy Pieces>
책을 기반으로 하고 있다. (책의 내용은 인터넷에 공개되어 있다.)

책의 모든 내용을 기술하기 보다는, 주요 내용을 선별하고, 필요한 설명을 추가하는 형식으로 작성할 예정이다.


1. OS (Operating System) 정의 + Kernel

운영체제의 정의를 간단하게 내려보면 아래와 같다.

"An OS is software that converts hardware into a useful form for applications."

"운영체제는 하드웨어를 응용 프로그램이 활용할 수 있는 형태로 변환해주는 소프트웨어이다."

먼저 서론에서의 의문점을 확장해보자.

두 개의 프로그램 A, B가 하나의 메인 메모리를 공유한다고 해보자. 이런 경우 프로그램 A가 프로그램 B의 데이터를 덮어쓰거나, 손상시키는 경우가 발생할 수 있지 않을까?

또 다른 예시로, 여러 개의 프로그램이 그보다 적은 수의 프로세서를 공유할 때, 어떤 프로그램이 어떤 프로세서를 쓸 수 있는지는 누가 결정하는걸까?

우리는 오직 한 쌍의 마우스와 키보드를 통해 컴퓨터를 사용한다. 그런데 문서 작업을 하다가 브라우저를 열면 즉시 해당 프로그램 내에서 마우스와 키보드를 사용할 수 있다. 어떻게?

이는 모두 운영체제의 역할이다. 아래와 같이 운영체제의 주요 역할을 정리해보자.

  • (여러 개의) 프로그램을 쉽게 실행할 수 있게 한다.
  • 프로그램 간 메모리 공유를 가능하게 한다.
  • 프로그램이 장치(마우스, 키보드 등)와 원활히 상호작용할 수 있게 한다.

즉, 운영체제는 시스템이 정확하고 효율적으로 동작하도록 한다.

위의 그림에서 운영체제는 소프트웨어와 하드웨어 사이에 위치하고 있다. 운영체제의 핵심 부분인 커널은 하드웨어와 소프트웨어 간의 중간자 역할을 수행하며, 소프트웨어가 하드웨어를 정확하고, 효율적으로 사용할 수 있도록 조율한다.

컴퓨터에서 Word를 실행해 문서를 작성하는 상황을 가정해보자.

CPU, 메모리, 키보드, 모니터 등의 하드웨어는 Word(소프트웨어)를 실행하고, 문서를 작성하는 데 필요한 물리적 자원이다.

이 때, 운영체제는 CPU가 문서 작성 프로그램을 실행하고, 키보드 입력을 프로그램에 전달하는 등, 하드웨어 자원을 프로그램이 효율적으로 사용할 수 있도록 한다.

이제 위에서 기술된 운영체제의 정의를 다시 한번 읽어보자!


2. Virtualization + System Calls

✅ Virtualization?

위에서 설명한 바와 같이, 운영체제는 프로세서, 메모리, 디스크 등의 물리적인 자원들을 일반적이고, 강력하고, 사용하기 쉬운 형태로 변환하는데, 이를 Virtualization(가상화)라고 한다.

이와 같은 운영체제의 역할로 인해, 어플리케이션은 직접적으로 하드웨어에 접근하거나, 볼 수 없다.

즉, 어플리케이션은 운영체제에 의해 가상화된 Virtualized Hardware를 사용하게 되는 것이다.

✅ Dual Mode & System Calls

그렇다면 어플리케이션은 어떻게 원하는 작업을 수행할 수 있을까?

System Call이 바로 어플리케이션이 운영체제에게 무엇을 하고 싶은지 전달하는 수단이다.

즉, 여기서 System은 어플리케이션과 운영체제 사이의 인터페이스라고 볼 수 있겠다. 아래의 그림을 살펴보자.

복잡하다! 하지만 일단은 복잡하게 생각할 필요는 없다.

  • User Space (User Mode)는 사용자 어플리케이션이 실행되는 영역이다.

  • Kernel Space (Kernel Mode)는 커널의 기능이 실행되는 영역으로, 하드웨어와 직접적으로 소통한다. ls, ps와 같은 명령어를 본 적이 있을 것이다.

위와 같이 두 영역으로 구분되어 있는 모드를 Dual Mode라고 한다.

어플리케이션이 파일 입출력, 메모리 할당, 프로세스 생성 등과 같은 작업을 수행하려고 한다고 해보자.

하드웨어로의 직접적인 접근은 커널만이 가능하기 때문에, 어플리케이션은 시스템 콜을 호출한다. 이 때 Trap이라는 매커니즘을 통해 커널이 개입하게 된다.

커널은 시스템 콜 요청을 받으면 해당 요청에 맞는 작업을 처리한 후, 결과를 반환해준다.

간단한 예시를 들어보자.

#include <stdio.h>

int main() {
    printf("Hello, World!\n");
    return 0;
}

위의 코드에서 printf() 함수를 호출하면, 사실 내부적으로 write()라는 시스템 콜이 호출된다. 커널은 write() 시스템 콜을 받아들이고, 출력 장치로 데이터를 보내는 방식으로 요청을 처리한다.

📌 Interrupt와 Trap

Interrupt와 Trap 모두 인터럽트이지만, 하드웨어 인터럽트인지, 소프트웨어 인터럽트인지에 따라 구분된다.

  • Interrupt
    보통 Interrupt라고 하면 하드웨어 인터럽트를 의미한다. CPU가 특정 기능을 수행하는 도중에 급하게 다른 일을 처리하고자 할 때 주로 사용된다.
  • Trap
    소프트웨어 인터럽트는 프로세스에서 생성되는 오류나 예외 조건으로 인해 발생한다. Trap은 의도적으로 발생시키는 소프트웨어 인터럽트이다. (운영체제에 특정 작업을 요청하기 위해)

3. OS Three Pieces

OS의 역할에 대해 상당히 길게 설명했다. 그 이유는 OS의 역할이 앞으로 다룰 모든 내용을 포함하고 있기 때문이다. 이후 다뤄질 내용들은 모두 운영체제가 "어떻게" 설명된 역할을 수행하는지를 설명한다.

아래는 운영체제의 역할을 크게 3가지 큰 주제로 나눈 것이다.

각각의 주제를 C 코드 예시를 통해 이해해보자.

1) Virtualization (가상화)

각 응용 프로그램이 자원을 혼자 사용하는 것처럼 느끼게 한다.
(Processes, CPU scheduling, Virtual memory)

1-1) Virtualizing The CPU (CPU 가상화)

#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <assert.h>
#include "common.h"

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "usage: cpu <string>\n");
        exit(1);
    }
    char *str = argv[1];
    while (1) {
        Spin(1); // Repeatedly checks the time and returns once it has run for a second
        printf("%s\n", str);
    }
    return 0;
}

코드는 입력된 문자열을 일정한 간격으로 지속적으로 출력하는 간단한 프로그램이다.

prompt> gcc -o cpu cpu.c -Wall
prompt> ./cpu "A"
A
A
A
ˆC
prompt>

프로그램을 위와 같이 실행하면, 단순히 문자열 "A"를 반복적으로 출력하게 된다. 이 때, 해당 프로그램을 동시에 여러 개 실행하면 어떤 결과가 나올까?

prompt> ./cpu A & ; ./cpu B & ; ./cpu C & ; ./cpu D &
[1] 7353
[2] 7354
[3] 7355
[4] 7356
A
B
D
C
A
B
D
C
A
C
B
D
...

컴퓨터에 CPU가 4개가 존재하는 것은 아닐 텐데, 마치 여러 프로그램이 동시에 실행되고 있는 것처럼 보인다.

이처럼 CPU 개수가 프로세스(실행 중인 프로그램)보다 적더라도, 하나의 CPU가 마치 여러 개의 가상 CPU처럼 작동하게 하는 것이 CPU 가상화이다. CPU 가상화를 통해, 각 프로세스는 자신이 CPU 전체를 점유하고 있다고 착각하게 된다.

물리적으로는 하나의 CPU만 존재하기 때문에, 특정 시점에 어떤 프로그램을 실행시킬지에 대한 규칙이 필요하다. 이것이 뒤에서 알아볼 스케줄링(Scheduling)이다.

1-2) Virtualizing Memory (메모리 가상화)

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

int main(int argc, char *argv[]) {
    int *p = malloc(sizeof(int)); // a1: allocate memory
    assert(p != NULL);
    printf("(%d) address of p: %08x\n", getpid(), (unsigned) p); // a2: print out the address of the memory
    *p = 0; // a3: put zero into the first slot of the memory
    while (1) {
        Spin(1);
        *p = *p + 1;
        printf("(%d) p: %d\n", getpid(), *p); // a4
    }
    return 0;
}

이 코드는 동적으로 할당된 메모리의 값을 매초마다 1씩 증가시키고, 이를 출력하는 프로그램이다.

prompt> ./mem
(2134) memory address of p: 00200000
(2134) p: 1
(2134) p: 2
(2134) p: 3
(2134) p: 4
(2134) p: 5
ˆC

프로그램을 실행해보면, PID가 2134인 새로운 프로세스가 만들어지고, 메모리(힙)의 0x200000 주소의 값을 0으로 초기화 후, 1씩 더해주고 있다.

📌 PID란?
Process ID 의 줄임말로 운영체제에서 프로세스를 식별하기 위해 부여하는 번호를 의미한다.

이번에는 해당 프로그램을 동시에 두 개 실행시켜보자.

prompt> ./mem &; ./mem &
[1] 24113
[2] 24114
(24113) memory address of p: 00200000
(24114) memory address of p: 00200000
(24113) p: 1
(24114) p: 1
(24114) p: 2
(24113) p: 2
(24113) p: 3
(24114) p: 3
...

이상하다! 두 프로세스에서 할당된 메모리의 주소는 모두 0x200000로 같다. 그렇다면 해당 메모리 주소의 값이 두 배로 빠르게 증가해야 할텐데, 각 프로세스는 값을 독립적으로 1씩 증가시키는 것으로 보인다.

두 프로세스 모두 할당된 주소가 0x200000로 보이지만, 이는 Virtual address space(가상 메모리 주소)로, Physical memory(실제 주소)가 아니다. 운영체제는 Virtual address space와 Physical memory를 매핑하여, 각 프로세스가 독립적인 메모리 주소를 갖도록 한다. 이것이 바로 메모리 가상화이다.

Virtual address space는 각 프로세스의 고유한 메모리 공간으로, 다른 프로세스가 간섭할 수 없는 메모리 공간이다. Physical memory가 바로 자원이 공유되는 실제 메모리 공간으로, 이를 운영체제가 관리하여 Virtual address space와 매핑하는 것이다.

2) Concurrency (동시성)

동시에 발생하는 이벤트를 올바르게 처리한다.
(Threads, synchronization)

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include "common.h"

volatile int counter = 0;
int loops;

void *worker(void *arg) {
    int i;
    for (i = 0; i < loops; i++) {
        counter++;
    }
    return NULL;
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "usage: threads <value>\n");
        exit(1);
    }

    loops = atoi(argv[1]);

    pthread_t p1, p2;
    printf("Initial value : %d\n", counter);

    pthread_create(&p1, NULL, worker, NULL);
    pthread_create(&p2, NULL, worker, NULL);

    pthread_join(p1, NULL);
    pthread_join(p2, NULL);

    printf("Final value : %d\n", counter);
    return 0;
}

코드는 p1, p2 두 개의 스레드를 사용하여 전역 변수 counter의 값을 동시에 증가시키는 프로그램이다.

prompt> gcc -o thread thread.c -Wall -pthread
prompt> ./thread 1000
Initial value : 0
Final value : 2000

위와 같이 1000을 입력값으로 하여 프로그램을 실행하여, 이상적으로 2000이 나오는 것을 확인하였다. (두 개의 스레드에서 각각 1000을 더해주므로)

prompt> ./thread 100000
Initial value : 0
Final value : 143012 // huh??
prompt> ./thread 100000
Initial value : 0
Final value : 137298 // what the??

그렇다면 입력값을 100000으로 크기를 대폭 늘린다면? 이상하다! 괴상한 값이 나올 뿐더러 값이 일정하지도 않다. 이유를 생각해보자.

사실 counter 값을 증가시키는 작업은 아래의 instruction으로 나누어진다.

  • counter의 값을 메모리에서 레지스터로 로드한다.
  • 연산을 수행한다.
  • 결과값을 다시 메모리에 저장한다.

문제는, 이 3개의 instruction이 하나의 작업으로 atomic하게 실행되지 않는다는 점이다.

counter의 값이 1일 때 예시를 들어보자. p1 스레드가 counter의 값 1을 메모리로 로드하여 연산을 수행한 후, 결과값을 메모리에 반영하기 직전에 p2 스레드가 counter의 값을 로드했다. p1은 결과값 2를 메모리에 반영하지만, 이후 p2 역시 결과값 2를 메모리에 덮어쓰게 된다.

운영체제에서는 이러한 현상을 경쟁 상태, Race condition이라고 한다. 입력값이 1000인 경우에는 경쟁 상태가 심하지 않지만, 100000과 같이 커지는 경우 경쟁 상태가 심해진다고 이해하면 되겠다.

이와 같이 동시에 프로그램이 실행될 때 발생하는 문제를 동시성 문제라고 하며, 이를 올바르게 처리하는 것 또한 운영체제의 역할이다.

3) Persistence (지속성)

정보를 영구적으로 접근할 수 있도록 하며, 예기치 않은 실패가 발생해도 정확성을 유지한다.
(Storage, File systems)

DRAM과 같은 장치들은 휘발성 메모리로, 전원이 꺼지면 저장된 값이 사라진다. 따라서 데이터를 지속적으로 저장하기 위한 하드웨어와, 그것을 관리할 소프트웨어가 필요하다.

운영체제는 데이터를 비휘발성 메모리인 디스크에 쓰고 관리하는데, 이 때 디스크를 관리하는 소프트웨어를 File System이라고 한다.

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

int main(int argc, char *argv[])
{
    int fd = open("/tmp/file", O_WRONLY | O_CREAT | O_TRUNC, S_IRWXU);
    assert(fd > -1);
    int rc = write(fd, "hello world\n", 13);
    assert(rc == 13);
    close(fd);
    return 0;
}

위 코드는 /tmp/file"hello world"라는 문자열을 작성하고, 파일 작업이 성공적으로 이루어졌는지 확인하며, 마지막에 파일을 닫는 프로그램이다. 이 때, open(), write(), close() 등의 시스템 콜은 운영체제의 File System 전달되어, 정확하고 효율적으로 처리될 수 있다.

운영체제는 데이터를 디스크의 어느 위치에 저장할지 결정하고, 저장 장치에 데이터 쓰기 작업을 요청(Issue I/O request)한다.

또한, 시스템 충돌이나 예기치 않은 종료가 발생할 경우 데이터의 손실을 방지하기 위해 Journaling(변경사항 기록), Copy-on-write(새로운 위치에 데이터를 복사한 후 수정), 쓰기 순서 관리 등을 수행한다.


마치며

이번 글에서는 운영체제의 개념을 알아본 후, 크게 3가지 주제로 운영체제의 역할을 대략적으로 살펴봤다. 앞으로는 운영체제가 실제로 각 역할을 어떻게 수행하는지, 세부적인 내용들을 하나씩 뜯어보도록 하자.

profile
전공/개발 지식 정리

0개의 댓글