[운영체제 아주 쉬운 세 가지 이야기] 2. Introduction
이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.
운영체제 개요
프로그램이 실행될 때 어떤 일이 일어날까?
프로그램은 매우 단순한 일을 한다.
- 명령어를 실행한다
- 프로세서는 명령어를 초당 수십억 번 반입(fetch)하고
- 해석(decode)하고 (즉, 무슨 명령어인지 파악하고)
- 실행(execute)한다 (즉, 두 수를 더하고, 메모리에 접근하고, 조건을 검사하고, 함수로 분기하는 등의 정해진 일을 한다)
- 명령어 작업을 완료한 후 프로세서는 다음 명령어로, 또 그 다음 명령어로 프로그램이 완전히 종료될 때까지 실행을 계속한다.
이는 폰 노이만 컴퓨팅 모델의 기초를 설명한 것이다.
프로그램을 쉽게 실행하고 심지어 동시에 여러 개의 프로그램을 실행시킬 수도 있는, 프로그램 간의 메모리 공유를 가능케 하고, 장치와 상호작용을 가능케하고, 다양 흥미로운 일을 할 수 있게 하는 소프트웨어가 있다.
시스템을 사용하기 편리하면서 정확하고 올바르게 동작시킬 책임이 있기 때문에 소프트웨어를 운영체제(operatiing system, OS)라고 부른다.
운영체제는 앞으로 언급한 일을 하기 위하여 가상화(virtualization)라고 불리는 기법을 사용한다.
운영체제는 프로세서, 메모리, 또는 디스크와 같은 물리적인 자원을 이용하여 일반적이고, 강력하고, 사용이 편리한 가상 형태의 자원을 생성한다.
때문에 운영체제를 때로는 가상 머신이라고 부른다.
사용자 프로그램의 프로그램 실행, 메모리 할당, 파일 접근과 같은 가상 머신과 관련된 기능들은 운영체제에 요청할 수 있도록, 운영체제는 사용자에게 API를 제공한다. 보통 운영체제는 응용 프로그램이 사용 가능한 수백 개의 시스템 콜을 제공한다.
운영체제가 프로그램 실행, 메모리와 장치에 접근, 기타 이와 관련된 여러 작업을 진행하기 위해 이러한 시스템 콜을 제공하기 때문에, 우리는 운영체제가 표준 라이브러리 (standard library)를 제공한다고 일컫기도 한다.
마지막으로, 가상화는 많은 프로그램들이 CPU를 공유하여, 동시에 실행될 수 있게 한다. 프로그램들이 각자 명령어와 데이터를 접근할 수 있게 한다. 프로그램들이 디스크 등의 장치를 공유할 수 있게 한다.
이러한 이유로 운영체제는 자원 관리자(resource manager)라고도 불린다. CPU, 메모리, 디스크는 시스템의 자원이다. 효율적으로, 공정하게, 이들 자원을 관리하는 것이 운영체제의 역할이다. 운영체제의 역할을 좀 더 잘 이해하기 위해 몇 가지 예를 살펴 보기로 하자.
CPU 가상화
운영체제는 자원을 어떻게 가상화시키는가?
가상화 효과를 얻기 위하여 운영체제가 구현하는 기법과 정책은 무엇인가?
운영체제는 이들을 어떻게 효율적으로 구현하는가?
어떤 하드웨어 지원이 필요한가?
아래 프로그램은 많은 일을 하지 않고, 1초 동안 실행된 후 리턴하는 함수인 Spin()을 호출한다.
그런 후 사용자가 명령어 라인으로 전달한 문자열을 출력한다. 이러한 일련의 작업을 무한히 반복한다.
이 코드를 cpu.c라는 이름으로 저장하고 단일 프로세서 (== CPU) 시스템에서 컴파일하고 실행시킨다고 해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
int main(intargc, char*argv[])
{
if (argc != 2){
fprintf(stderr,“usage:cpu <string>\n”);
exit(1);
}
char* str = argv[1];
while(1) {
Spin(1);
printf(“%s\n”, str);
}
return 0;
}
1
2
3
4
5
6
7
8
prompt> gcc −o cpu cpu.c −Wall
prompt> ./cpu "A"
A
A
A
A
∧C
prompt>
시스템은 프로그램 실행 후 1초가 지나면 사용자가 전달한 입력 문자열을 출력하고, 무한히 반복한다. 이번에는 같은 작업에 대한 여러 인스턴스를 동시에 실행시켜 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
...
프로세서가 하나 밖에 없음에도 프로그램 4개 모두 동시에 실행되는 것처럼 보인다. 어떻게 이런 일이 일어날까?
하드웨어의 도움을 받아 운영체제가 시스템에 매우 많은 수의 가상 CPU가 존재하는듯한 환상(illusion)을 만들어 낸 것이다.
하나의 CPU 또는 소규모 CPU 집합을 무한 개의 CPU가 존재하는 것처럼 변환하여 동시에 많은 수의 프로그램을 실행시키는 것을 CPU 가상화(CPU virtualization)라고 한다.
이 책의 첫 부분의 주제이다.
프로그램을 실행하고, 멈추고, 어떤 프로그램을 실행시킬 것인가를 운영체제에게 알려주기 위해서 원하는 바를 운영체에제 전달할 수 있는 인터페이스 (API)가 필요하다.
이 책 전반에 걸쳐 이런 API들에 대해 논의한다.
API는 운영체제와 사용자가 상호작용할 수 있는 주된 방법이다.
다수의 프로그램을 동시에 실행시킬 수 있는 기능은 새로운 종류의 문제를 발생시킨다는 것을 인지했을 것이다.
예를 들어, 특정 순간에 두 개의 프로그램이 실행되기를 원한다면, 어떤게 실행되어야 하는가?
이 질문은 운영체제의 정책 (policy)에 달려있다.
운영체제의 여러 부분에서 이러한 유형의 문제에 답하기 위한 정책들이 사용된다.
운영체제가 구현한 동시에 다수의 프로그램을 실행시키는 기본적인 기법(mechanism)에 대해 다룰 것이다.
즉, 자원 관리자로서의 운영체제의 역할을 다룬다.
메모리 가상화
메모리에 대해 생각해 보자. 현재 우리가 사용하고 있는 컴퓨터에서의 물리 메모리(physical memory) 모델은 매우 단순하다. 바이트의 배열이다.
메모리를 읽기 위해서는 데이터에 주소(address)를 명시해야 한다.
메모리에 쓰기 혹은 갱신을 위해서는 주소와 데이터를 명시해야 한다.
메모리는 프로그램이 실행되는 동안 항상 접근된다.
프로그램은 실행 중에 자신의 모든 자료 구조를 메모리에 유지하고 laod와 store 또는 기타 메모리 접근을 위한 명령어를 통해 자료 구조에 접근한다.
명령어 역시 메모리에 존재한다는 사실을 잊지 말자.
명령어를 반입할 때마다 메모리가 접근된다.
malloc()을 호출하여 메모리를 할당하는 아래 코드와 출력을 살펴보자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main(int argc, char* argv[])
{
int* p=malloc(sizeof(int)); // a1
assert(p != NULL);
printf(“(%d) memory address of p: %08x\n”,
getpid(), (unsigned)p); // a2
*p = 0; // a3
while(1) {
Spin(1);
*p = *p+1;
printf(“(%d)p: %d\n”, getpid(), *p); // a4
}
return 0;
}
1
2
3
4
5
6
7
8
prompt> ./mem
(2134) memory address of p: 00200000
(2134) p: 1
(2134) p: 2
(2134) p: 3
(2134) p: 4
(2134) p: 5
∧C
이 프로그램은 몇 가지 작업을 수행한다.
a1 : 메모리를 할당 받는다
a2 : 할당 받은 메모리의 주소를 출력한다
a3 : 새로 할당받은 메모리의 첫 슬롯에 숫자 0을 넣는다
마지막으로 루프로 진입하여 1초 대기 후, 변수 p가 가리키는 주소에 저장되어 있는 값을 1 증가시킨다.
출력할 때마다 실행 중인 프로그램의 프로세스 식별자(PID)를 함께 출력한다. PID는 프로세스의 고유의 값이다.
위 실행 결과는 새로 할당된 메모리 주소가 00200000이라는 것을 보여주고, 프로그램이 진행되면서 천천히 값을 갱신하고 그 결과를 출력한다.
이 프로그램을 여러 개 실행시켜 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
(24113) p: 4
(24114) p: 4
...
프로그램들은 같은 주소에 메모리(00200000)를 할당받지만 각각이 독립적으로 00200000 번지의 값을 갱신한다.
각 프로그램은 물리 메모리를 다른 프로그램과 공유하는 것이 아니라 각자 자신의 메모리를 가지고 있는 것처럼 보인다.
운영체제가 메모리 가상화(memory virtualization)를 하기 때문에 이런 현상이 생긴다.
각 프로세스는 자신만의 가상 주소 공간(virtual address space, 때로는 그냥 주소 공간(address space))을 갖는다.
운영체제는 이 가상 주소 공간을 컴퓨터의 물리 메모리로 매핑(mapping)한다.
하나의 프로그램이 수행하는 각종 메모리 연산은 다른 프로그램의 주소 공간에 영향을 주지 않는다.
실행 중인 프로그램의 입장에서는, 자기 자신만의 물리 메모리를 갖는 셈이다. 실제로는 물리 메모리는 공유 자원이고, 운영체제에 의해 관리된다.
이러한 일들이 정확히 어떻게 일어나는지 역시 이 책의 첫 주제 가상화(virtualization)에 포함된다.
병행성
이 책의 다른 주요 주제는 병행성(concurrency)이다.
프로그램이 한 번에 많은 일을 하려 할 때 (동시에) 발생하는 그리고 반드시 해결해야 하는 문제들을 가리킬 때 이 용어를 사용한다.
병행성 문제는 우선 운영체제 자체에서 발생한다.
가상화에 관한 앞의 예에서 알 수 있듯이 운영체제는 한 프로세스 실행, 다음 프로세스, 또 다음 프로세스 등의 순서로 여러 프로세스를 실행시켜 한번에 많은 일을 한다.
이러한 행동은 심각하고 흥미로운 문제를 발생시킨다.
병행성 문제는 운영체제만의 문제는 아니다. 멀티 쓰레드 프로그램도 동일한 문제를 드러낸다.
#include<stdio.h>
#include<stdlib.h>
#include“common.h”
volatile int counter=0;
int loops;
void* worker(void* arg){
inti;
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(“Initialvalue: %d\n”, counter);
Pthread_create(&p1, NULL, worker, NULL);
Pthread_create(&p2, NULL, worker, NULL);
Pthread_join(p1, NULL);
Pthread_join(p2, NULL);
printf(“Finalvalue:%d\n”, counter);
return 0;
}
위 프로그램은 두 개의 쓰레드를 생성해 각 쓰레드는 worker()라는 루틴을 실행한다.
loops 변수를 1000으로 설정하여 프로그램을 실행시킨다면 counter 변수의 최종 값은 2000이 될 것이다.
하지만 동일한 프로그램에 loops의 값을 더 큰 값인 100,000으로 설정한다면 최종 값은 200,000이 아니라 143,012나 137,298 같은 잘못된 값이 나올 것이다.
예상하지 못한 결과의 원인은 명령어가 한 번에 하나 씩만 실행된다는 것과 관련 있다.
앞 프로그램의 핵심 부분인 counter를 증가시키는 부분은 세 개의 명령어로 이루어진다.
- counter 값을 메모리에서 레지스터로 탑재하는 명령어
- 레지스터를 1 증가시키는 명령어
- 레지스터의 값을 다시 메모리에 저장하는 명령어
이 세 개의 명령어가 원자적(atomically)으로 (한 번에 3개 모두) 실행되지 않기 때문에 이상한 일이 발생할 수 있다.
이 책의 후반부에서 자세하게 논의할 주제가 바로 이 병행성(concurrency) 문제이다.
아래와 같은 질문을 생각하며 병행성을 공부해보자
- 같은 메모리 공간에 다수의 쓰레드가 동시에 실행한다고 할 때, 올바르게 동작하는 프로그램을 어떻게 작성할 수 있는가?
- 운영체제로부터 어떤 기본 기법을 제공받아야 하는가?
- 하드웨어는 어떤 기능을 제공해야 하는가?
- 병행성 문제를 해결하기 위하여 기본 기법들과 하드웨어 기능을 어떻게 이용할 수 있는가?
영속성
이 책의 세 번째 주요 주제는 영속성(persistence)이다.
DRAM과 같은 장치는 데이터를 휘발성(volatile)방식으로 저장하기 때문에 메모리의 데이터는 쉽게 손실될 수 있다. 전원 공급이 끊어지거나 시스템이 크래시나면 메모리의 모든 데이터는 사라진다.
데이터를 영속적으로 저장할 수 있는 하드웨어와 소프트웨어가 필요하다. 저장 장치는 모든 시스템에 필수적이다.
디스크를 관리하는 운영체제 소프트웨어를 파일 시스템(file system)이라고 부른다. 파일 시스템은 사용자가 생성한 파일(file)을 시스템의 디스크에 안전하고 효율적인 방식으로 저장할 책임이 있다.
CPU나 메모리 가상화와는 달리 운영체제는 프로그램 별로 가상 디스크를 따로 생성하지 않는다. 오히려 사용자들이 종종 파일 정보를 공유하기 원한다고 가정한다.
- 데이터를 영속적으로 저장하는 방법은 무엇일까?
- 이러한 작업의 성능을 높이기 위해서 어떤 기법과 정책이 필요한가?
- 하드웨어와 소프트웨어가 실패하더라도 올바르게 동작하려면 어떻게 해야 하는가?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#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,“helloworld\n”, 13);
assert(rc==13);
close(fd);
return 0;
}
위 코드에서 프로그램은 운영체제를 세 번 호출한다.
- open() 시스템 콜은 파일을 생성하고 연다.
- write() 시스템 콜은 파일에 데이터를 쓴다.
- close() 시스템 콜은 파일을 닫는데, 프로그램이 더 이상 해당 파일을 사용하지 않는다는 것을 나타낸다.
이들 시스템 콜(system call)은 운영체제에서 파일 시스템(file system)이라 불리는 부분으로 전달된다. 파일 시스템은 요청을 처리하고 경우에 따라 사용자에게 에러 코드를 반환한다.
데이터를 디스크에 쓰기 위해서 운영체제가 실제로 하는 일이 무엇인지 궁금할 것이다.
운영체제는 새 데이터가 디스크의 어디에 저장될지 결정하고 파일 시스템이 관리하는 다양한 자료 구조를 통하여 데이터의 상태를 추적한다. 이런 작업을 하기 위해서는 저장 장치로부터, 기존 자료 구조를 읽거나 갱신해야 한다.
장치 드라이버(device driver)는 나를 대신하여 장치가 무언가 하게 하는 소프트웨어이다. 이를 작성하기 위해서는 저수준의 장치 인터페이스와 그 시멘틱에 깊은 이해가 필요하다.
운영체제는 시스템 콜이라는, 표준화된 방법으로 장치들을 접근할 수 있게 한다. 운영체제는 표준 라이브러리(standard library)처럼 보이기도 한다.
장치를 접근하는 방법과 파일 시스템이 데이터를 영속적으로 관리하는 방법은 이보다 훨씬 더 복잡하다.
성능향상을 위해서 대부분의 파일 시스템은 쓰기 요청을 지연시켜 취합된 요청들을 한 번에 처리한다. 쓰기 중에 시스템의 갑작스런 고장에 대비해서 많은 파일 시스템이 저널링(journaling)이나 쓰기-시-복사(Copy-on-write)와 같은 복잡한 쓰기 기법을 사용한다.
이런 기법들은 쓰기 순서를 적절히 조정하여 고장이 발생하더라도 정상적인 상태로 복구될 수 있게 한다. 효율적인 디스크 작업을 위해 단순 기스트에서 복잡한 B-트리까지 다양한 종류의 자료 구조를 사용한다.
설계 목표
이제 운영체제가 실제로 어떤 일을 하는지 어느 정도 감을 잡았을 것이다. 운영체제는 CPU, 메모리, 디스크와 같은 물리 자원을 가상화한다.
운영체제는 병행성과 관련된 복잡한 문제를 처리하고, 파일을 영속적으로 저장하여 아주 오랜 시간 동안 안전한 상태에 있게 한다. 이런 파일 시스템을 구현하려면 몇 가지 목표를 세워야 한다.
가장 기본적인 목표는 시스템을 편리하고 사용하기 쉽게 만드는 필요한 개념(abstraction)들을 정의하는 것이다. 컴퓨터 과학에서 추상화는 모든 일의 근간이다. 추상화를 통해 큰 프로그램을 이해하기 쉬운 작은 부분들로 나누어 구현할 수 있다.
운영체제의 설계와 구현에 중요한 목표는 성능이다. 다른 말로 표현하면 오버헤드를 최소화(minimize the overhead)하는 것이다. 가상화 및 다른 운영체제 기능을 과도한 오버헤드 없이 제공해야 한다. 오버헤드는 시간 (더 많은 명령어)과 공간(메모리 또는 디스크)의 형태로 나타난다.
또 다른 목표는 응용 프로그램 간의 보호, 그리고 운영체제와 응용 프로그램 간의 보호이다. 다수의 프로그램들이 동시에 실행되기 때문에, 운영체제는 한 프로그램의 악의적인 또는 의도치 않은 행위가 다른 프로그램에게 피해를 주지 않는다는 것을 보장해야 한다. 보호는 운영체제의 원칙 중 하나인 고립(isolation) 원칙의 핵심이다.
운영체제가 실패하면 그 위에서 실행되는 모든 응용 프로그램도 실패하기에 이러한 종속성 때문에 운영체제는 높은 수준의 신뢰성(reliability)을 제공하며 계속 실행되어야 한다.
다른 중요한 목표들도 있는데, 에너지 효율성(energy efficiency), 보안(security), 이동성(mobility) 등이 있다.
참고 링크