운영체제

Virtualizing CPU,Memory/ Concurrency/ Persistence

땅콩콩 2023. 3. 7. 13:13
Virtualizing CPU (CPU의 가상화)
int
main(int argc, char *argv[])
{
	if (argc != 2) {
		fprintf(stderr, "usage: cpu <string>\n");
		exit(1);
	}
	char *str = argv[1]; //argv[0]은 자기자신, 커맨드 이름, 여기서는 cpu가 된다. argv[1]가 입력받은 값.
	while (1) {
		Spin(1);
		printf("%s\n", str);
	}
	return 0;
}

자기 자신을 포함한 command line parameter 개수가 2개가 아니면 에러메시지를 출력후 빠져나가고, 2개일 경우 str변수에 담기는 파라미터값을 1초 간격의 무한 루프로 반복 출력하는 프로그램이다.

prompt> ./cpu A & ./cpu B & ./cpu C & ./cpu D &

그런데 다음과 같이 프로그램을 여러개 동시에 실행할 수도 있다. 각 명령어들이 ';'으로 연결되었다면 순차실행되었겠지만, '&'으로 연결되어서 백그라운드에서 동시에 실행시키는 명령이 된다.

[1] 7353 //process id number(pid)
[2] 7354
[3] 7355
[4] 7356
A
B
D
C
A
B
D
C
A

그러면 process id number가 4개의 인스턴스 별로 부여되어 4개의 프로세스가 생성되었다는 것을 나타내고, ABCD가 임의의 순서로 무한 반복 출력된다.

1~2개 존재하는 cpu는 언제나 모자란데, 동시에 실행되는 프로그램은 매우 많다. 어떻게 그럴수 있을까?

그것은 cpu가 가상화되었기 때문이다.

다시 말해서 마치 cpu가 많이, 심지어 무한대로 있는것처럼 가상화되어 있다는 것이다. 

 

Virtualizing  Memory (메모리의 가상화)
int
main(int argc, char *argv[])
{
	int *p = malloc(sizeof(int)); // a1
	assert(p != NULL);
	printf("(%d) address pointed to by p: %p\n",
		getpid(), p); // a2
	*p = 0; // a3
	while (1) {
		Spin(1);
		*p = *p + 1;
		printf("(%d) p: %d\n", getpid(), *p); // a4
	}
	return 0;
}

malloc에 의해 (32비트 머신이라면) 4바이트의 메모리를 할당받아서 메인 함수 내부에 정의된 p라는 변수에 해당 주소를 저장한다. p라는 변수는 stack에 자리를 잡고, allocation은  heap공간에서 받아오는데 allocation size는 4바이트가 된다. 그리고 그 시작 주소가 지역변수 p에 들어오게 된다.

그리고 무한루프를 돌면서 프로세스 식별 번호(pid)와 *p(p가 가리키는 곳의 값)을 출력하는 코드이다.

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

이 코드를 백그라운드에서 동시 실행 시켜서 프로세스를 두개 생성한다.

그런데 두개의 다른 프로세스에서 p라는 변수에 할당된 주소가 똑같은 0x200000으로 확인된다.

그래서 얼핏 보면 두 프로세스가 서로 협력해서 p가 가리키는 곳의 값을 증가시키고 있는 것 같지만, 실행 결과를 살펴보면 실제로는 서로 독립되게 실행된다.

다시 말해, 각각의 프로세스가 서로 독립된 메모리 공간을 갖는다. (물리적인 메모리가 x, 가상화된 메모리)

그리고 이렇게 각각의 프로세스가 가지는 가상화된 독립적 메모리 공간을 virtual address space(=address space)라고 한다.

 

Concurrency (병행성)
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); //thread 2개 생성
	Pthread_create(&p2, NULL, worker, NULL);
	Pthread_join(p1, NULL);
	Pthread_join(p2, NULL);
	printf("Final value : %d\n", counter);
	return 0;
}

1. 전역변수

여기서 변수 counter와 loops는 전역변수가 하나이므로 메모리에 배치될 때 instance가 하나이다. 즉, 프로그램이 실행될때 이 변수들은 오직 하나만 존재하게 된다. 

하지만 같은 전역변수라고 해도, 초기값이 있냐 없냐에 따라서 실행 파일을 만들때 완전히 다른 구조를 갖는다.

실행파일에는 초기값이 있는 전역변수, 초기값이 있는 static variable, 프로그램 코드, BSS 영역의 크기가 포함된다.

초기값이 없는 변수는 실행파일에 속하지는 않고, BSS영역에 속해서 이후에 0(null)으로 초기화된다.

 

2. 지역변수

worker라는 함수에서 사용되는 integer i는 지역변수로, i의 범위는 worker내부가 되고 그 외부에서는 i의 존재를 알 수 없다.

또한 i는 지역 변수이기 때문에 stack에 자리를 잡고, worker가 호출될 때 i도 생성되었다가 worker가 리턴될 때 i도 소멸한다.

전역변수와 지역변수의 생성과 소멸

전역변수는 프로그램이 실행되려고 메모리에 탑재될 때 생성되었다가, program이 끝날 때 메모리에서 사라진다. 반면 지역변수는 해당 함수가 호출될 때 생성되었다가 함수가 return될 때 소멸한다.

그리고 밑에서 Pthread_create(&p1, NULL, worker, NULL);와 같은 코드를 이용하여 thread를 두개 생성하는데, 이때 맨 마지막 파라미터로 스택의 크기를 결정한다. (잘 모르겠으면 NULL로 두면 시스템이 적절히 만들어 줌.)

여기서 파라미터로 함수 이름이 주어졌는데, 함수 이름은 기계어로 번역하면 그 함수의 시작 주소가 된다.

즉 위의 명령어는 '이 thread의 시작점을 worker함수로 하겠다.' 라는 의미이다.

그럼 프로세스가 두개 생성되는데, 다음과 같은 구조가 된다.

여기서 stack과 heap은 방향이 다를 뿐 같은 공간을 쓰고, 둘이 만나서는 안된다. (중간에 미 사용공간이 있음)

또한 실행흐름이 2개가 되는 경우에는 stack을 따로 갖게 된다. (execution stack은 실행흐름별로 따로 가져야 한다.)

다시 말해 실행흐름이 여러개일 경우에 Memory에서 같이 쓰는 것은 프로그램 코드, data영역, heap공간이고, 실행흐름별로 따로 쓰는것은 stack이다.

 

thread들을 초기화할때는 각 스택의 크기를 정하거나 하는 등의 여러가지 일이 일어나고, 그 이후에 이 스택들에는 worker라는 함수를 실행시키기 위한 각각의 Activation Record가 들어간다. (worker함수에서는 arg, i )

그래서 결국 counter, loops와 같은 전역변수는 하나를 공유하지만, 이 Activation Record의 변수들은 각 thread가 별개로 가지고 있는 것이다.

Activation Record란?

stack에서 함수를 실행할 때 그 함수안에 있는 지역변수, 들어오는 파라미터 들을 다 모아두는 블럭

그래서 결국 worker함수가 실행될 때 각각의 thread가 공통의 counter변수를 증가시키는데에 협력하게 된다.

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

그래서 arg에 1000을 넣고 이 프로그램을 실행시키면 counter의 최종 기댓값은 2000이 된다.

두개의 thread가 1000번의 루프를 돌면서 counter를 0부터 증가시키기 때문이다.

그런데 실제 결과는 그와 다르다...

prompt> ./thread 100000 //기댓값은 200000
Initial value : 0
Final value : 143012 //실제 결과는 그에 못미침
prompt> ./thread 100000
Initial value : 0
Final value : 137298 //다시 해봐도 그에 못미친다. 심지어 두 시도의 값이 같지도 않음.

왜 이런 일이 일어나는 것일까? 간단히 말하면 병행성(Concurrency) 때문이다.

그리고 구체적으로는 counter++; 이 부분의 코드에서 문제가 발생한다.

 

얼핏보면 이 코드에서 counter를 증가시키는 일이 한번에 일어나는 것 같아 보이지만 실제로 기계어 수준에서는 lw, add, sw등 여러 명령어의 실행을 거치게 된다. (=atomic하지 않다. 메모리에 있는 값을 레지스터로 load하여 가져오고, ALU연산 과정을 거치고, 다시 그 값을 메모리에 store하고..) 기계어와 관련된 자세한 부분은 컴퓨터구조 카테고리에 일부 올라와 있다. (https://star-peanuts.tistory.com/category/%EC%BB%B4%ED%93%A8%ED%84%B0%20%EA%B5%AC%EC%A1%B0)

 

아무튼 이렇게 여러 단계를 거치는 과정 때문에 경쟁조건(race condition)이 발생하여 원하는 결과가 나오지 않기 때문에, 기대하는 결과를 만드려면 lw, add, sw등을 atomic하게 묶어 경쟁조건을 제거해야 한다.

atomic하다는 것은 뭘까?

오늘날 물리학에서는 그렇게 생각하지 않지만, 오랫동안 atom은 쪼개지지 않는것으로 믿어왔기 때문에, atomic하다는 것은 더이상 쪼갤 수 없다는 의미이다.

 

Persistence (영속성)

프로그램과 변수들은 하드디스크에 있다가 프로그램이 실행되면 DRAM 메인메모리로 올라오고, CPU는 여기있는 코드를 읽어서 해독하여 처리하는 일을 반복적으로 하게 된다.

그런데 이 CPU, Memory에 있는 장치들은 예외적인 상황이 아닌 경우 대부분 전원을 끄면 사라진다(volatile).

따라서 계산한 결과가 사라지지 않게 하기 위해서 하드디스크(SSD등)를 이용하여 전원을 꺼도 데이터가 사라지지 않게 해야한다.

그럼 변수의 내용을 어떻게 하드디스크에 써서 영속성(Persistence)을 갖게 할까? 바로 file로 만들어서 사용한다.

그러다보니 파일을 찾고, 열고, 다룰수 있기 위해서 file system을 구성한다.

하드디스크의 file system속에서 우리가 원하는 정보를 담는 단위를 file이라고 한다.

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

위의 코드에서 open(), write(), close()처럼 함수와 같이 생긴것은 System call로,  kernel에서 실행되는 운영체제 지원 함수이다.

file에는 file system을 통해 트리형태로 구성된 경로를 부여할 수 있고, 그 경로는 유일한 이름이 된다.

그리고 open()은 사람이 식별가능한 path로 구성된 파일 이름을 process내에서 그 파일을 식별하기 위한 file descript number로 전환해주는 역할을 한다.

사람과 컴퓨터가 파일의 이름을 식별하는 방법

사람은 파일의 경로(Path)를 통해서 식별하고, 프로세스는 File descript number를 통해서 식별한다.
컴퓨터의 장치들은 어떻게 추상화할까?

장치가 마치 file인 것처럼..!
장치와 연동된 특별한 파일을 open, write, read 등으로 접근하여 장치를 file system으로 추상화한다.

* 2023 국민대학교 소프트웨어학부 황선태 교수님의 운영체제 수업을 듣고 정리한 내용입니다.

* 원서 출처 : https://pages.cs.wisc.edu/~remzi/OSTEP/