운영체제 아주 쉬운 세 가지 이야기 - OSTEP를 읽고 정리하는 스터디를 하게 되었다. 크래프톤 정글 8기 307반 동기들과 함께하는 스터디 시작한다.
운영체제 개요
프로그램을 쉽게 실행하고, 프로그램 간의 메모리 공유를 가능케 하고, 장치와 상호작용을 가능케 하고, 다양한 흥미로운 일을 할 수 있게 하는 소프트웨어가 있다. 시스템을 사용하기 편리하게 하면서 정확하고 올바르게 동작시킬 책임이 있기 때문에 이 소프트웨어를 운영체제(Operating System, OS)라고 부른다.
운영체제는 앞에서 언급한 일을 하기 위하여 가상화(Virtualization)라고 불리는 기법을 사용한다. 운영체제는 프로세서, 메모리, 또는 디스크와 같은 물리적(Physical)인 자원을 이용하여 일반적이고, 강력하고, 사용이 편리한 가상(Virtual) 형태의 자원을 생성한다. 때문에 운영체제를 때로는 가상 머신(Virtual Machine)이라고 부른다.
사용자 프로그램의 프로그램 실행, 메모리 할당, 파일 접근과 같은 가상 머신과 관련된 기능들을 운영체제에게 요청할 수 있도록, 운영체제는 사용자에게 API를 제공한다. 보통 운영체제는 응용 프로그램이 사용 가능한 수백 개의 시스템 콜을 제공한다.
가상화는 많은 프로그램들이 CPU를 공유하여, 동시에 실행될 수 있게 한다. 프로그램들이 각자 명령어와 데이터를 접근할 수 있게 한다. 프로그램들이 디스크 등의 장치를 공유할 수 있게 한다. 이러한 이유로 운영체제는 자원 관리자(Resource Manager)라고도 불린다.
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, "사용법: cpu <문자열>\n");
exit(1);
}
char *str = argv[1];
while (1) {
Spin(1);
printf("%s\n", str);
}
return 0;
}
위 코드는 많은 일을 하지 않는다. 하는 일은 Spin()
을 호출하는 것이다. Spin()
은 1초 동안 실행된 후 리턴하는 함수이다. 그런 후 사용자가 명령어 라인으로 전달한 문자열을 출력한다. 이러한 작업을 무한히 반복한다.
이 코드를 cpu.c
라는 이름으로 저장하고 단일 프로세서 시스템에서 컴파일하고 실행시킨다고 가정하자. 다음과 같은 출력을 볼 수 있을 것이다.
이번에는 같은 작업에 대한 여러 인스턴스를 동시에 실행시켜 보자.
프로세서가 하나밖에 없음에도 프로그램 4개가 모두 동시에 실행되는 것처럼 보인다. 하드웨어의 도움을 받아 운영체제가 시스템의 매우 많은 수의 가상 CPU가 존재하는 듯한 환상(Illusion)을 만들어 낸 것이다. 하나의 CPU 또는 소규모 CPU 집합을 무한 개의 CPU가 존재하는 것처럼 변환하여 동시에 많은 수의 프로그램을 실행시키는 것을 CPU 가상화(Virtualizing the CPU)라 한다.
다수의 프로그램을 동시에 실행시킬 수 있는 기능은 새로운 종류의 문제를 발생시킨다. 예를 들어 특정 순간에 두 개의 프로그램이 실행되기를 원한다면 누가 실행되어야 하는가? 이 질문은 운영체제의 정책(Policy)에 달려있다. 운영체제의 여러 부분에서 이러한 유형의 문제에 답하기 위한 정책들이 사용된다. 운영체제가 구현한 동시에 다수의 프로그램을 실행시키는 기본적인 기법에 대해 다룬다. 즉 자원 관리자 로서의 운영체제의 역할을 다룬다.
메모리 가상화
현재 우리가 사용하고 있는 컴퓨터에서의 물리 메모리(Physical Memory) 모델은 매우 단순하다. 바이트의 배열이다. 메모리를 읽기 위해서는 데이터에 주소(Address)를 명시해야 한다. 메모리에 쓰기를 위해서는 주소와 데이터를 명시해야 한다.
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include "common.h"
int main(int argc, char *argv[]) {
int *p = malloc(sizeof(int)); // 정수형 포인터 p에 메모리 할당
assert(p != NULL); // p가 NULL이 아닌지 확인
printf("(%d) p의 메모리 주소: %08x\n", getpid(), (unsigned) p); // 프로세스 ID와 p의 메모리 주소 출력
*p = 0; // p가 가리키는 값을 0으로 초기화
while (1) {
Spin(1); // 1초 대기
*p = *p + 1; // p가 가리키는 값을 1 증가
printf("(%d) p: %d\n", getpid(), *p); // 프로세스 ID와 p가 가리키는 값 출력
}
return 0;
}
메모리는 프로그램이 실행되는 동안 항상 접근된다. 프로그램은 실행 중에 자신의 모든 자료 구조를 메모리에 유지하고 load와 store 또는 기타 메모리 접근을 위한 명령어를 통해 자료구조에 접근한다. 명령어 역시 메모리에 존재한다. 명령어를 반입할 때마다 메모리가 접근된다. 위 코드를 살펴보자. 이 프로그램은 몇 가지 작업을 수행한다.
- 우선 메모리를 할당받는다.
- 그런 후 할당받은 메모리의 주소를 출력한다.
- 새로 할당받은 메모리의 첫 슬롯에 숫자 0을 넣는다.
- 마지막으로 루프로 진입하여 1초 대기 후
- 변수 p가 가리키는 주소에 저장되어 있는 값을 1 증가시킨다.
출력할 때마다 실행 중인 프로그램의 프로세스 식별자 (PID)라 불리는 값이 함께 출력된다. PID는 프로세스의 고유의 값이다.
같은 프로그램을 여러 번 실행시켜 보자.
프로그램들은 같은 메모리 주소를 할당받지만(내 컴퓨터는 멀티 코어라 좀 다른 가?), 각각이 독립적으로 값을 갱신한다. 각 프로그램은 물리 메모리를 다른 프로그램과 공유하는 것이 아니라 각자 자신의 메모리를 가지고 있는 것처럼 보인다.
운영체제가 메모리 가상화(Virtualizing Memory)를 하기 때문에 이런 현상이 생긴다. 각 프로세스는 자신만의 가상 주소 공간 (Virtual Address Space, 때로 그냥 주소 공간(Address Space)이라고도 불림)을 갖는다. 운영체제는 이 가상 주소 공간을 컴퓨터의 물리 메모리로 매핑(Mapping)한다. 하나의 프로그램이 수행하는 각종 메모리 연산은 다른 프로그램의 주소 공간에 영향을 주지 않는다. 실행 중인 프로그램의 입장에서는, 자기 자신만의 물리 메모리를 갖는 셈이다. 실제로는 물리 메모리는 공유 자원이고, 운영체제에 의해 관리된다.
병행성
이 책의 다른 주요 주제는 병행성(Concurrency)이다. 병행성은 프로그램이 여러 작업을 동시에 수행하려고 할 때 발생하는 문제들을 의미한다. 병행성 문제는 운영체제 만의 문제는 아니다. 멀티 쓰레드 프로그램도 동일한 문제를 가진다. 멀티 쓰레드 프로그램을 예로 들어 확인해 보자
#include <stdio.h>
#include <stdlib.h>
#include "common.h"
volatile int counter = 0;
int loops;
void *worker(void *arg) {
for (int i = 0; i < loops; i++) {
counter++;
}
return NULL;
}
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "사용법: threads <값>\n");
exit(1);
}
loops = atoi(argv[1]);
pthread_t p1, p2;
printf("초기 값 : %d\n", counter);
Pthread_create(&p1, NULL, worker, NULL);
Pthread_create(&p2, NULL, worker, NULL);
Pthread_join(p1, NULL);
Pthread_join(p2, NULL);
printf("최종 값 : %d\n", counter);
return 0;
}
기본 아이디어는 간단하다. 메인 프로그램은 Pthread_create()를
사용하여 두 개의 쓰레드를 생성한다. 쓰레드를 동일한 메모리 공간에서 함께 실행 중인 여러 개의 함수라고 생각할 수 있다. 이 예제 코드에서 각 쓰레드는 worker()
라는 루틴을 실행한다. worker()
루틴은 loops번만큼
루프를 반복하면서 카운터 값을 증가시킨다. loops
값을 1000으로 설정하면 아래와 같은 출력을 얻는다.
각 쓰레드가 1000번씩 counter
값을 증가시켰기 때문에 counter
의 최종 값은 2000이 된다. 이번에는 loops 값을 더 큰 값으로 지정해 보자.
이번 실행에서는 입력 값을 100,000으로 주었더니 최종 값이 200,000 이 아니라 130,072이 되었다. 다시 한번 동일한 조건으로 실행시켰을 때에는 또 잘못된 값이 출력되었을 뿐 아니라 직전 실행과도 다른 결과가 출력되었다. 예상하지 못한 결과의 원인은 명령어가 한 번에 하나씩만 실행된다는 것과 관련 있다. 앞 프로그램의 핵심 부분인 counter
를 증사시 키는 부분은 세 개의 명령어로 이루어진다. counter
값을 메모리에서 레지스터로 탑재하는 명령어 하나, 레지스터를 1 증가시키는 명령어 하나, 이 세 개의 명령어가 원자적(Atomically)으로 실행되지 않기 때문에 이상한 일이 발생할 수 있다. 이게 책의 후반에 다룰 병행성(Concurrency) 문제이다.
영속성
세 번째 주요 주제는 영속성(Persistence)이다. DRAM과 같은 장치는 데이터를 휘발성(Volatile) 방식으로 저장하기 때문에 메모리의 데이터는 쉽게 손실될 수 있다. 전원 공급이 끊어지거나 시스템이 갑자기 고장 나면 메모리의 모든 데이터는 사라진다. 데이터를 영속적으로 저장할 수 있는 하드웨어와 소프트웨어가 필요하다. 저장 장치는 모든 시스템에 필수적이다.
하드웨어는 입력/출력(input/output) 혹은 I/O 장치 형태로 제공된다. 요즘에는 Solid-State Drivers(SSDs)가 많이 사용되고 있기는 하지만 장기간 보존할 정보를 저장하는 장치로는 일반적으로 하드 드라이브(Hard Drive)가 사용된다.
디스크를 관리하는 운영체제 소프트웨어를 파일 시스템(File System)이라고 부른다. 파일 시스템은 사용자가 생성한 파일(file)을 시스템의 디스크에 안전하고 효율적인 방식으로 저장할 책임이 있다. 다만 운영체제는 프로그램마다 별도의 가상 디스크를 만들지 않고 대신 파일 정보를 여러 사용자와 프로그램이 공유할 수 있도록 한다.
핵심 질문 : 데이터를 어떻게 영구적으로 저장할 수 있는가? 파일 시스템은 데이터를 영속적으로 관리하는 운영체제의 일부분으로, 데이터를 안전하게, 효율적으로 저장하고 접근할 수 있는 다양한 기법과 정책이 필요하다. 또한, 하드웨어나 소프트웨어에 문제가 생겨도 데이터를 안전하게 보호할 방법에 대해서도 고려해야 한다.
#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); // "hello world\n" 문자열 쓰기
assert(rc == 13); // 쓰기 작업을 확인
close(fd); // 파일 닫기
return 0;
}
예제 코드는 문자열 "hello world"를 포함한 파일 tmp/file
을 생성하는 코드다. 여기서 프로그램은 운영체제를 3번 호출한다.
open() 콜은
파일을 생성하고 연다.write() 콜은
파일에 데이터를 쓴다.close() 콜은
단순히 파일을 닫는데, 프로그램이 더 이상 해당 파일을 사용하지 않는다는 것을 나타낸다.
이들 시스템 콜(system call)은 운영체제에서 파일 시스템(file system)이라 불리는 부분으로 전달된다. 파일 시스템은 요청을 처리하고 경우에 따라 사용자에게 에러 코드를 반환한다.
데이터를 디스크에 쓰기 위해서 운영체제가 실제로 하는 일은 그렇게 간단하지 않다.
- 파일 시스템은 많은 작업을 해야 한다. 먼저 새 데이터가 디스크의 어디에 저장될지 결정해야 하고
- 파일 시스템이 관리하는 다양한 자료 구조를 통하여 데이터의 상태를 추적해야 한다. 이런 작업을 하기 위해서는 저장 장치로부터, 기존 자료 구조를 읽거나 갱신해야 한다.
- 운영체제는 시스템 콜이라는, 표준화된 방법으로 장치들을 접근할 수 있게 한다. 운영체제는 표준 라이브러리(standard library)처럼 보이기도 한다.
장치를 접근하는 방법과 파일 시스템이 데이터를 영속적으로 관리하는 방법은 이보다 좀 더 복잡하다. 대부분의 파일 시스템은 응용프로그램들이 요청한 쓰기 요청들을 모아서 한 번에 처리한다. 성능향상을 위해서이다. 응용프로그램 입장에서는 요청한 쓰기의 내용들이 실제로 저장장치에 기록될 때까지 일정 시간의 지연이 발생하는 셈이다. 쓰기 요청 발생 후, 이 요청이 디스크에 실제 기록되기 이전에 정전 등의 문제가 발생하면 기록을 요청했던 내용이 손실될 수 있다. 문제가 그리 간단치 않다. 디스크에 기록하려고 모아놓은 내용 중 일부가 이미 디스크에 쓰였을 수도 있고, 더욱 문제인 것은 기록순서가 뒤바꿀 수 있기 때문이다.
쓰기 중에 시스템의 갑작스러운 고장에 대비하여 많은 파일 시스템들이 저널링(Journaling)이나 쓰기-시-복사(Copy-On-Write)와 같은 기법을 사용한다. 효율적인 디스크 작업을 위해 단순한 링크드 리스트에서 복잡한 B-Tree까지 다양한 종류의 자료 구조를 사용한다.
설계 목표
운영체제의 역할을 이해하기 시작했다면, 이제 그것이 어떤 목표를 가지고 설계되어야 하는지 알아보겠다. 운영체제는 컴퓨터의 CPU, 메모리, 디스크와 같은 물리적 자원을 가상화하고, 병행성 문제를 다루며, 데이터를 영구적으로 저장한다. 이러한 시스템을 만들기 위해서는 추상화, 성능, 보호, 신뢰성 등 여러 목표를 염두에 둬야 한다.
첫 번째로, 시스템을 사용하기 쉽게 만드는 데 필요한 추상화를 정의하는 것이 중요하다. 추상화는 복잡한 프로그램을 이해하기 쉬운 작은 부분으로 나누는 데 도움을 준다. 또한, 시스템의 성능을 최적화하면서 오버헤드를 최소화하는 것도 중요한 목표다.
응용 프로그램 간의 보호와 운영체제 자체의 보호도 필수적이다. 멀티프로그래밍 환경에서는 한 프로그램의 오류가 다른 프로그램이나 운영체제 전체에 영향을 주지 않도록 해야 한다. 또한, 운영체제는 계속해서 안정적으로 실행되어야 하며, 복잡해질수록 신뢰성을 유지하는 것이 더 어려워진다.
에너지 효율성, 보안, 이동성 등 다른 중요한 목표들도 있다. 특히 현재와 같은 네트워크 연결 환경에서는 보안이 더욱 중요해졌고, 모바일 장치 사용이 증가함에 따라 운영체제의 이동성도 중요해졌다. 이 수업을 통해 다양한 운영체제의 주제들을 탐구하면서 이러한 목표들이 어떻게 실현되는지 배우게 될 것이다.
역사 부분은 넘어가겠다. 이미 분량이 꽤 많기 때문이다. 궁금하면
https://os2024.jeju.ai/week01/intro.html
운영체제 개요 — 운영체제 2024
운영체제 개요 프로그램이 실행될 때, 그것은 단순히 명령어를 실행합니다. 프로세서는 초당 수백만에서 수십억 번 명령어를 가져와 해석하고 실행합니다. 이 과정은 프로그램이 완전히 종료될
os2024.jeju.ai
아래에서 확인하면 좋을 것이다.