Limetime's TimeLine
article thumbnail
반응형

프로그램은 매우 단순한 일을 한다. Fetch → Decode → Execute 즉, 명령어를 반입하고 파악(해석)하며 실행한다. 명령어 작업을 완료한 후 CPU는 다음 명령어를, 또 그 다음 명령어를 프로그램이 종료될 때까지 위의 과정을 반복한다. ⇒ 이것이 폰 노이만 아키텍처의 기초라고 한다.

운영체제는 위 과정인 프로그램 실행, 프로그램 간의 메모리 공유, 장치와 상호작용 등을 가능하게 해주는 소프트웨어다.

운영체제는 위의 일을 하기 위해 가상화(Virtualization) 기법을 사용한다. 프로세서, 메모리 or 디스크 같은 물리적(Physical) 자원을 이용하여 가상 형태의 자원을 생성한다. ⇒ OS를 가상 머신(Virtual Machine)이라고 부르는 이유다.

사용자 프로그램의 프로그램 실행, 메모리 할당, 파일 접근과 같은 가상 머신과 관련된 기능들을 운영체제에게 요청할 수 있도록 OS는 사용자에게 API를 제공한다. 이것이 바로 System Call. ⇒ OS가 표준 라이브러리를 제공한다고 일컫는 이유.

가상화는 많은 프로그램들이 CPU를 공유하여 동시에 실행할 수 있도록 한다. ⇒ 실제로는 시간 별로 쪼개서 동시에 실행하는 것처럼 보이는거임.

OS가 자원 관리자(Resource Manager) 역할을 하면서 프로그램들이 각자 명령어 실행, 데이터 접근, 장치 공유를 가능하게 만듦 ⇒ CPU, 메모리 및 디스크 등 시스템 자원을 효율적이고 공정하게 관리하는 것이 OS의 역할임.

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);
		printf("%s\n", str);
	}
	return 0;
}

<반복해서 출력하는 코드 cpu.c>

위 코드는 Spin()을 호출하는데, Spin()은 1초 동안 실행된 후 리턴하는 함수이다. 그런 후 사용자가 명령어 라인으로 전달한 문자열을 출력한다. 이 작업을 무한히 반복한다.

prompt> gcc −o cpu cpu.c −Wall
prompt> ./cpu "A"
A
A
A
A
∧C
prompt>

결과는 위와 같이 프로그램 실행 후 1초가 지나면 사용자가 전달한 입력 문자인 “A”가 출력된다. Ctrl+C를 누르면 프로그램을 종료 시킬 수 있다.

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개가 모두 동시에 실행되는 것처럼 보인다. 환상(Illusion)이다. 그것이 가상화니까.. 즉, 하나의 CPU 또는 소규모 CPU 집합을 무한 개의 CPU가 존재하는 것처럼 변환하여 동시에 많은 수의 프로그램을 실행시키는 것이다. ⇒ CPU 가상화.

특정 순간에 두 개의 프로그램이 실행되기를 원한다면 누가 먼저 실행되어야 할까? ⇒ OS의 정책(Policy)에 따라 다르다. 이것은 OS의 자원 관리 메커니즘을 어떻게 구현했느냐에 따라 다르겠지..

메모리 가상화


컴퓨터에서의 물리 메모리(Physical memory)는 바이트의 배열이고 메모리를 읽기 위해서는 주소를 명시해야 하며 쓰기 or 갱신을 하려면 주소와 데이터를 명시해야 한다.

메모리는 프로그램이 실행되는 동안 항상 접근된다. 무슨 의미냐면 프로그램이 실행되는 순간 모든 자료 구조를 메모리에 유지하고 Load와 Store 또는 기타 메모리 접근 명령어로 자료 구조에 접근한다. 당연히 명령어도 메모리에 있으므로 수시로 접근해야 한다.

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include“common.h ”
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;
}

<메모리 접근 프로그램 mem.c>

malloc() 함수로 메모리를 할당하는 프로그램을 작성해서 출력해보니 아래와 같다.

prompt> ./mem
(2134) memory address of p: 00200000
(2134) p: 1
(2134) p: 2
(2134) p: 3
(2134) p: 4
(2134) p: 5
∧C
  1. 메모리를 할당받는다. (새로 할당된 메모리의 주소는 00200000이다. )
  2. 할당받은 메모리의 주소를 출력한다.
  3. 새로 할당받은 메모리의 첫 슬롯에 숫자 0을 넣는다.
  4. 무한 루프에 집입하여 1초 대기 후 변수 p가 가리키는 주소에 저장되어 있는 값을 1 증가시키고 PID와 같이 출력시킨다.

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
(24113) p: 4
(24114) p: 4
...

<메모리 프로그램 여러 번 실행하기>

같은 프로그램을 여러 번 실행시키면 프로그램들은 같은 주소(00200000)를 할당받지만 각각이 독립적으로 00200000 주소의 값을 갱신한다. 즉, 각 프로그램은 물리 메모리를 다른 프로그램과 공유하는 것이 아니라 각자의 메모리를 가지고 있는 것처럼 보인다. ⇒ 메모리 가상화!(Virtualizing Memory)

각 프로세스는 자신만의 가상 주소 공간(Virtual address space)를 갖는데, OS는 이 가상 주소 공간을 물리 메모리로 매핑(Mapping)한다. 그리고 다른 프로그램의 주소 공간에 영향을 주지 않는다. ⇒ 매핑된 주소는 같아 보이지만, 실제로 사용하는 물리 메모리는 다르다.

💡 mem.c가 위와 같은 결과를 출력하게 하려면 ASLR을 꺼야한다.

병행성


병행성(Concurrency)은 프로그램이 한 번에(동시에) 많은 일을 처리하려 할 때 발생하고 반드시 처리해야 하는 문제들을 가리킬 때 사용하는 용어이다. ⇒ OS 자체에서 발생하는데, 또 OS 만의 문제가 아닌게 멀티 쓰레드 프로그램도 동일한 문제를 일으킨다.

#include <stdio.h>
#include <stdlib.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;
}

<멀티 쓰레드 프로그램 threads.c>

  1. main 함수에서 pthread_create() 함수를 사용하여 두 개의 쓰레드를 생성한다.
    💡Thread는 동일한 메모리 공간에서 함께 실행 중인 여러 개의 함수라고 생각하면 된다.
  2. 각 쓰레드는 worker() 함수를 실행하는데, 해당 함수는 사용자가 인자로 준 값만큼 반복하면서 counter 값을 증가시킨다.
  3. counter 값은 전역 변수이므로 각 쓰레드는 해당 변수를 공유하고 있다.
prompt> gcc −o thread thread.c −Wall −pthread
prompt> ./thread 1000
Initial value : 0
Final value : 2000

각 쓰레드가 1000번씩 counter 값을 증가시켰기 때문에 최종 값은 2000이 된다. 즉, loops(입력 값) 변수를 N으로 두면 최종 출력은 2N이 될 것이라고 예상할 수 있는데… 과연 그럴까?

prompt> ./thread 100000
Initial value : 0
Final value : 143012 // ??
prompt> ./thread 100000
Initial value : 0
Final value : 137298 // 머임?

이번에 loops 변수를 100,000으로 주니 최종 값이 예상(200,000)과 달리 143,012가 출력되었다. 뭔가 이상하다..

counter를 증가 시키는 부분은 세 개의 명령어로 구성되어 있다.

  1. counter 값을 메모리에서 레지스터로 탑재하는 명령
  2. 레지스터를 1 증가시키는 명령
  3. 레지스터의 값을 다시 메모리에 저장하는 명령

사실 CPU는 한 번에 하나의 명령만 처리하는 원자성(Atomically) 그렇다. ⇒ 한 번에 세 개 명령어를 돌릴 수 없다는 것.

뭔 상관이냐? 각 쓰레드가 counter를 증가시키는 행위는 순차적으로 이루어지지 않을 뿐더러 명령어도 세부적으로 3단계로 나눠져 있기 때문에, 서로 꼬인다. 꼬이면? 값이 이상해지겠지

핵심 질문 : 올바르게 동작하는 병행 프로그램은 어떻게 작성해야 하는가? 
1. 같은 메모리 공간에 다수의 쓰레드가 동시에 실행한다고 할 때, 올바르게 동작하는 프로그램 어떻게 작성할 수 있는가? 
2.운영체제로부터 어떤 기본 기법들을 제공받아야 하는가? 
3. 하드웨어는 어떤 기능을 제공해야 하는가? 
4. 병행성 문제를 해결하기 위하여 기본 기법들과 하드웨어 기능을 어떻게 이용할 수 있는가?

영속성


영속성(Persistence)는 말 그대로 영원히 지속되는 속성이다. 무슨 소리냐면 DRAM과 같은 장치는 휘발성(Volatile) 방식으로 저장하기 때문에 전원 공급이 끊기거나 시스템 Crash가 발생하면 데이터가 손실될 수 있다. 하드웨어나 소프트웨어로 이 부분을 커버해야한다.

디스크를 관리하는 운영체제 소프트웨어를 파일 시스템(File System)이라고 부르는데, 사용자가 생성한 파일을 시스템의 디스크에 안전하고 효율적인 방식으로 저장할 책임이 있다.

근데 CPU나 메모리 가상화와 달리 OS는 프로그램 별로 가상 디스크를 생성하지 않는다. 왜? 사용자들이 종종 파일 정보를 공유하고 싶어한다고 가정해보자

예를 들어, C 프로그램을 작성할 때, 우선 에디터(반드시 Emacs?)를 사용하여 C 파일을 생성하고 편집한다 (emacs -nw main.c). 편집이 끝나면 소스 코드를 컴파일한다 (gcc -o main main.c). 컴파일이 완료되면 새로 생성된 실행 파일을 실행할 수 있다 (./main). 파일이 여러 다른 프로세스 사이에서 공유된다. emacs라는 편집기가 컴파일러가 사용할 파일을 생성한다. 컴파일러는 입력 파일을 사용하여 새로운 실행 파일을 생성한다. 마지막으로 실행 파일이 실행된다.

#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;
}

<입출력을 수행하는 프로그램 (io.c)>

문자열 “hello world”를 포함한 파일 /tmp/file을 생성하는 코드이다. 여기서 프로그램은 운영체제를 세 번 호출한다.

  1. open() call은 파일을 생성하고 연다.
  2. write() call은 파일에 데이터를 쓴다.
  3. close() call은 단순히 파일을 닫는데, 프로그램이 더 이상 사용하지 않는다는 것을 의미한다.

System Call은 OS에서 파일 시스템(File System)이라 부르는 부분으로 전달된다. 파일 시스템은 요청을 처리하고 경우에 따라 사용자에게 에러 코드를 반환한다.

데이터를 디스크에 쓰기 위해서 OS가 무슨 일을 할까?

  • 새 데이터를 디스크의 어디에 저장해야 될지 고민
  • 파일 시스템이 관리하는 다양한 자료 구조를 통해 데이터의 상태 추적
  • 저장 장치로부터 기존 자료 구조를 읽거나 갱신

장치가 나를 대신 어떤 것을 하도록 만드는 것이 어렵다. 그래서 저수준의 장치 인터페이스와 그 시맨틱에 대한 깊은 이해가 필요하다. 운영체제는 시스템 콜이라는, 표준화된 방법으로 장치들을 접근할 수 있게 한다. 운영체제는 표준 라이브러리(Standard Library) 처럼 보이기도 한다.

핵심 질문 : 데이터를 영속적으로 저장하는 방법은 무엇인가?
1.
 파일 시스템은 데이터를 영속적으로 관리하는 운영체제의 일부분이다. 
2. 올바르게 일하기 위해서는 어떤 기법이 필요할까? 
3. 이러한 작업의 성능을 높이기 위해서 어떤 기법과 정책이 필요한가? 
4. 하드웨어와 소프트웨어가 실패하더라도 올바르게 동작하려면 어떻게 해야 하는가?

장치를 접근하는 방법과 파일 시스템이 데이터를 영속적으로 관리하는 방법은 훨씬 더 복잡하다. 성능 향상을 위해 대부분의 파일 시스템은 쓰기 요청을 지연시켜 취합한 요청들을 한 번에 처리한다. 쓰기 중에 시스템이 갑작스럽게 고장나는 상황을 대비해서 많은 파일 시스템들이 저널링(Journaling)이나 쓰기 시 복사(Copy-On-Write) 와 같은 복잡한 쓰기 기법을 사용한다. 이런 기법들은 쓰기 순서를 적절히 조정하여 고장이 발생하더라도 정상적인 상태로 복구될 수 있게 한다.

효율적 디스크 작업을 위해 단순 리스트에서 복잡한 B-tree까지 다양한 종류의 자료 구조를 사용한다.

설계 목표


OS는 CPU, Memory, Disk와 같은 물리적 자원을 가상화한다. 또, 병행성 문제를 처리하고 파일을 영속적으로 저장하는 책임을 가지고 있다. 이를 구현하려면 목표를 세워야 한다.

기본적인 목표는 시스템을 편리하고 사용하기 쉽게 만드는데 필요한 개념(abstraction)들을 정의하는 것이다. ⇒ 추상화

두 번째 목표는 성능이다. ⇒ 오버헤드 최소화

세 번째 목표는 응용 프로그램 간의 보호이다. 동시에 실행되는 프로세스 간 고유 자원을 보호해야 한다. ⇒ 격리(Isolation)

네 번째 목표는 OS가 계속 실행되어야 한다는 것이다. OS가 죽으면 위의 응용 프로그램은 당연히 다 죽는 종속성이 있다. ⇒ 신뢰성(Reliability)

다른 중요한 목표 : 에너지 효율성, 보안, 이동성 등…

반응형
profile

Limetime's TimeLine

@Limetime

포스팅이 좋았다면 "공감❤️" 또는 "구독👍🏻" 해주세요!