Operating Systems : Three Easy Pieces를 보고 번역 및 정리한 내용들입니다.
실행 중인 프로그램이 하는 일은 간단히 말하면 명령어를 실행하는 것이다. 프로세서는 초당 수백만에서 수십억 개의 명령을 메모리로부터 페치, 디코드, 실행하는데, 하나의 명령어 실행이 끝나면 다음 명령어를 실행하고, 프로그램이 완료될 될 때까지 이를 계속한다.
운영체제(Operating System)는 프로그램을 실행하기 쉽게 만드는 소프트웨어로, 프로그램들이 메모리를 공유하고, 장치와 상호 작용하는 등의 일들을 할 수 있게 한다.
가상화(Virtualization) 란 프로세서, 메모리, 디스크 등의 물리적인 자원을 좀더 범용적이고 강력하고 사용하기 쉬운 virtual form으로 바꾸는 것이다. 그래서 OS는 때때로 Virtual Machine이라 불리기도 한다.
OS는 사용자들이 OS에 무엇을 할 지 알리기 위해, 그리고 이 가상 머신의 특성들을 사용할 수 있도록 API들을 제공한다. OS들은 응용 프로그램들이 사용할 수 있을 수 백개의 시스템 콜(System Call)들을 제공하는데, 이 시스템 콜들은 프로그램을 실행하기 위해, 혹은 메모리와 장치에 접근하기 위해 필요하다. 그렇기 때문에 OS는 이 응용 프로그램들에 Standard Library를 제공한다고 말하기도 한다.
가상화는 많은 프로그램들이 실행될 수 있게 하고, 프로그램들이 각자의 명령어와 데이터에 접근하고 장치에 접근할 수 있게 한다. 프로그램의 실행에 필요한 CPU, 메모리, 디스크는 시스템의 자원이며, OS는 이 자원들을 효율적으로 관리하는 역할을 가지고 있다. 그렇기 때문에 OS는 Resource Manager라고도 한다.
싱글 프로세서에서 돌아가는 다음과 같은 코드가 있다고 하자.
#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);//반복해서 시간을 체크하고 매초 리턴하는 함수.
printf("%s\n", str);
}
return 0;
}
prompt> gcc -o cpu cpu.c -Wall
prompt> ./cpu "A"
A
A
A
A
^C
prompt>
위 코드를 컴파일하고 인자로 "A"를 주면 ctrl + c로 프로세스를 종료할 때까지 매초 A를 출력한다.
그런데 이번에는 아래와 같이 4개의 프로그램들을 동시해 실행해보자.
prompt> ./cpu "A" & ./cpu "B" & ./cpu "C" & ./cpu "D"
A
B
D
C
A
B
D
C
A
^C
prompt>
우리는 싱글 프로세서를 사용하고 있음에도 4개의 프로그램들이 동시에 실행되고 있음을 볼 수 있다. 이는 OS가 제공해주는 환상으로, 한 CPU가 마치 무한히 많은 수의 CPU인 것처럼, 또 여러 프로그램들이 동시에 실행되는 것처럼 보이게 한다.
프로그램을 실행하고 중단하기 위해서, 또는 OS에게 어떤 프로그램을 실행할지를 알려주기 위해서는 OS에 무엇을 원하는지를 알려주기 위한 인터페이스가 있어야 한다.
그렇다면 만약 두 프로그램들이 모두 특정 시간에 실행되고 싶어한다면, 어떤 게 실행되어야 할까? 이는 OS의 정책(policy)에 의해 결정된다. 정책이란 OS 사용에서 일어날 수 있는 여러 종류의 물음들에 답하기 위해 사용된다.
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include "common.h"
int main(int argc, char *argv[]) {
int *p = malloc(sizeof(int)); // 메모리 할당
assert(p != NULL);
printf("(%d) address pointed to by p: %p\n", getpid(), p); // pid와 메모리의 주소를 출력
*p = 0; // 새로 할당받은 메모리의 첫 번째 슬롯에 0을 넣음
while (1) {
Spin(1); //1초 대기
*p = *p + 1; // p의 값을 증가
printf("(%d) p: %d\n", getpid(), *p);//pid와 p의 값을 출력
}
return 0;
}
물리 메모리는 간단하게 말해 바이트의 배열이라 할 수 있다. 메모리를 읽으려면 데이터가 저장된 주소에 접근하기 위한 주소를 명시하고, 메모리에 쓰려면 저장할 데이터와 데이터를 저장할 주소를 모두 명시해야 한다.
메모리는 프로그램이 실행되는 동안 항상 접근되는데, 프로그램은 그 모든 자료구조들을 메모리에 저장하고 다양한 명령어들로 거기에 접근한다. (이때 사용되는 명령어에는 load, store 등이 있다)
프로그램의 각 명령어들 또한 메모리에 있으므로, 메모리는 명령어를 페치할 때마다도 접근된다.
위 코드는 우선 새로운 메모리를 할당한 후, 현재 프로세스의 PID와 새로 할당 받은 메모리의 주소를 출력한다. 이후 새로 할당 받은 메모리의 첫 슬롯에 0을 넣고, 프로세스를 종료시킬 때까지 매 1초 동안 p에 든 값을 1씩 증가시키며 출력시킨다.
prompt> ./mem
(2134) address pointed to by p: 0x200000
(2134) p: 1
(2134) p: 2
(2134) p: 3
(2134) p: 4
(2134) p: 5
프로세스를 하나만 실행했을 때에는 크게 신경 쓸 부분이 없다.
prompt> ./mem & ./mem &
[1] 24113
[2] 24114
(24113) address pointed to by p: 0x200000
(24114) address pointed to by p: 0x200000
(24113) p: 1
(24114) p: 1
(24114) p: 2
(24113) p: 2
(24113) p: 3
(24114) p: 3
(24113) p: 4
(24114) p: 4
한편 두 개의 프로세스를 실행했을 때에는, 서로 다른 프로세스임에도 출력되는 메모리 주소가 같다. 마치 실행되고 있는 다른 프로그램과 같은 물리 메모리를 공유하기보다, 각자의 고유한 메모리를 가지고 있는 것처럼 말이다.
이는 OS가 메모리를 가상화하고 있기 때문이다. 각 프로세스는 OS가 물리 메모리에 매핑하고 있는 각자의 고유한 가상 메모리 주소 공간에 접근한다. 이로써 물리 메모리가 OS에 의해 관리되는 공유 자원임에도, 한 프로세스가 참조하는 메모리는 OS나 다른 프로세스들의 주소 공간에 영향을 주지 않게 된다.
#include <stdio.h>
#include <stdlib.h>
#include "common.h"
#include "common_threads.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;
}
한 프로세스 내에서 여러 가지의 일들을 동시에 실행하려 한다 해보자. 메모리 가상화 예제에서 볼 수 있듯, OS는 여러 일들을 하나씩 순차적으로 수행한다. 그렇다면 그 일들은 어떤 순서로, 어떻게 수행될 수 있을까?
스레드는 간단하게는, 같은 시간에 같은 메모리 공간에서 다른 함수들과 함께 실행되는 함수로 생각할 수 있다.
위 코드에서는 두 개의 스레드를 만들고 각각 worker()
함수를 실행한다. 만약 의도한대로 코드가 실행된다면 다음과 같은 결과가 나와야할 것이다.
prompt> gcc -o threads threads.c -Wall -pthread
prompt> ./threads 100000
Initial value : 0
Final value : 200000
하지만
prompt> ./threads 100000
Initial value : 0
Final value : 143012
prompt> ./threads 100000
Initial value : 0
Final value : 137298
실제로는 예상했던 결과와 다르게 나오고, 심지어 같은 인자를 넣어 다시 실행했을 때음에도 다른 값이 나오기도 한다.
그 이유는 명령어가 실행되는 방식 때문이다. counter++
에 해당하는 명령어는 메모리의 값을 레지스터로 로드하고, 값을 1 올리고, 다시 메모리에 저장한다. 근데 이 명령어가 원자적으로 실행되는 것은 아니기 때문에 이런 일이 발생하는 것이다.
시스템 메모리에서 데이터는 없어질 수 있다. 메모리로 주로 사용하는 DRAM은 휘발성이기 때문에, 즉 전원이 꺼지면 시스템 붕괴되고, 메모리에 있는 데이터들도 사라지기 때문이다. 데이터들을 영구적으로 저장하기 위해서는 하드웨어와 소프트웨어가 필요하다.
이런 역할을 하는 하드웨어를 I/O 디바이스라 부른다. 하드드라이브나 SSD등이 바로 그런 장기적으로 저장되어야 할 정보들을 저장하기 위한 저장소다.
한편 디스크를 관리하는 OS의 소프트웨어는 파일 시스템이라 부르는데, 사용자들이 만든 파일들을 신뢰성있고 효율적인 방식으로 디스크에 저장하기 때문이다.
OS는 메모리에서 그랬던 것과는 달리, 응용 프로그램마다 디스크를 가상화하지는 않는데, 사용자들이 파일 정보를 공유해서 사용하기 원할 것이라 가정하기 때문이다.
#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()
파일을 닫아서 더 이상 데이터를 쓸 수 없게 함.이 시스템 콜들은 OS 파일 시스템의 일부로, 요청을 처리하고 사용자에게 에러 코드를 반환한다.
그렇다면 여기에서 OS가 하는 역할은 무엇일까?
하지만 OS는 시스템 콜을 통해 그런 장치들에 접근하기 위한 표준적이고 간단한 방법들을 제공한다. 물론 장치들이 어떻게 접근되고 파일 시스템들이 그런 장치들에서 데이터를 어떻게 영구적으로 관리하는지에 대해서는 디테일이 있다.
OS는 시스템을 쉽고 간편하게 사용할 수 있게 하기 위한 추상화를 제공해야 한다. 추상화는 큰 프로그램을 더 작고 이해할 수 있는 조각들로 나누어 작성할 수 있게, 낮은 레벨의 언어들에 대해 생각하지 않고 높은 레벨의 언어로 작성할 수 있게, 어셈블리를 논리 게이트 대한 생각 없이, 게이트로 만들어진 프로세서를 트랜지스터에 대해 많은 생각을 하지 않고 만들 수 있게 한다.
OS는 될 수 있는 한 높은 성능을 제공해야 한다. 이는 OS 사용의 오버헤드를 줄여야한다는 말과 같다. 가상화, 추상화는 편하고 좋지만 추가적인 시간, 혹은 공간 등의 코스트를 필요로 한다. 이들을 큰 오버헤드 없이 구현할 수 있어야한다.
우리는 여러 프로그램들이 동시에 돌아갈 수 있게 하고 싶어한다. 그런데 이때 실행 중인 한 프로그램에서 일어나는 오류가 다른 프로그램에, 혹은 OS에 영향을 주는 것은 막아야 한다. 프로세스들을 다른 프로세스들로부터 고립시키는 것이 보호의 핵심이다.
OS는 중지되지 않고 실행돼야 한다. 만약 OS에 장애가 생기면 모든 응용프로그램들에도 장애가 생기기 때문이다. 이러한 의존성 때문에 OS는 높은 수준의 신뢰성을 제공해야 하지만, OS가 더욱 더 복잡해짐에 따라, 신뢰성 있는 OS를 구성하는 건 더 어려워지고 있다.
그 외에도 에너지 효율, 보안, 이동성 등, 시스템이 어떻게 사용되느냐에 따라 OS는 다른 목적들을 가질 수 있고, 그 목적들은 또한 상당히 다른 방식으로 구현될 수 있다.