1. Process의 개념과 배경
(1) 프로세스란?
프로세스의 정의는 단순히 "돌아가고 있는 프로그램" 이다.
안돌아가는 프로그램은 우리가 실행하기 전까지 HDD 구석에 박혀있으니, OS를 배우는 단계에서는 그냥 프로세스=프로그램 이라고 봐도 무방하겠다.
프로세스에 왜 그렇게 집착을 하느냐.
이전 포스트에서 스레드는 하드웨어 자원을 공유하고 프로세스는 공유하지 않는다고 했다.
여기서 가장 중요한 하드웨어 자원은 두 가지인데, 하나는 CPU고 다른 하나는 메모리다.
우리는 이걸 효율적으로 쓰기 위해서 "가상화"라는 작업을 할 것인데,
요건 파트를 따로 두어야 할 정도로 양이 많으니 아주 간단하게만 설명할 것이다.
(2) CPU 가상화(CPU Virtualization)
본격적인 내용은 Scheduling 포스트로.
한정된 CPU 자원을 여러 프로세스가 공유해야한다. 나눠써야한다는 뜻이다.
똑똑한 사람들이 어떻게 나눠쓰면 좋을 지 생각을 많이 해놨다.
1) Time Sharing
OS는 프로세스한테 마치 가용 CPU 코어가 남아있는 것처럼 환상을 준다.
여기에 낚인 프로세스는 대기열에 서게 되고,
시간 단위로 CPU를 썼다가 뺏겼다가 하면서 나눠쓴다.
당연한 말을 왜 이렇게 쓰냐고?
여러분이 컴퓨터만 전공해서 당연해 보이는 것이다.
무선 통신을 전공한 율무한테는 Bandwidth단위로 나눠쓰거나, Orthogonal Frequency Division으로 나눠쓰거나, Squence별로 코딩해서 Spread spectrum으로 나눠쓰거나, Frequency hopping해서 나눠쓰거나 등등...
다른 방법이 훨씬 더 많은데 시간으로 나눠 쓴다는 것 자체에도 의미가 있어보인다.
2) Context Switching
비유하기를 HDD는 창고, RAM은 책상이라고 하는데,
CPU의 레지스터는 손 정도로 비유하면 좋을 것 같다.
CPU가 프로세스를 바꿔가면서 실행할 때 어떻게 바꿀까?
CPU가 움직이는게 아니라 프로세스의 코드가 CPU로 갖다 박는다.
미륵이 풍혈을 쓰듯이 CPU의 레지스터에 값이 들어오는 것이다.
그렇다고 원래 있었던 레지스터 값을 아예 없애버릴 순 없다.
그럴거면 왜 계산을 하나? 원래 자리에 두어야 한다.
게임에서 세이브파일 저장하고 다른 캐릭터 키우듯이 CPU의 레지스터에 있던 값을 갈아끼우는 전체적인 과정을 Context Switching이라고 한다.
3) Scheduling Policy
아 시간별로 나눠쓰는거 알겠고, 어떻게 바꾸는지도 알겠는데.
이 많은 프로세스 중에 누구한테 자리를 넘겨줄까?
우선 순위 잘못 짠 예로, IBM 컴퓨터중 한 대는 폐기 직전 전원을 끄며 40년만에 프로세스가 실행된 전적도 있다.
맨 앞 줄 평똥맨보다 맨 뒷 줄 급똥맨이 화장실에 먼저 들어갈 수도 있는 노릇.
컴퓨터 세계의 평화를 지키는 방법에 대해 연구하는 내용이다.
(3) 메모리 가상화(Memory Virtualization)
본격적인 내용은 Virtual Memory 포스트로.
1) 가상 메모리 구조
왜 가상을 쓰는지, 어떻게 쓰는지를 말하기 이전에 가상 메모리 구조부터 말하게 되어 참담한 심정이다.
그냥 받아들이는게 정신건강상 이롭다.
가상 메모리 영역은 크게5가지로 나눌 수 있는데, kernel, stack, heap, data, code이다.
kernel은 OS가 관장하는 영역, Code는 실행 코드가 있는 영역, Data는 항상 자리를 차지하는 변수들이 들어있는 영역이다.
우리는 stack과 heap을 중요하게 관찰할 것인데, 컴퓨터를 만든 사람들은 stack은 위에서 내려오게, heap은 아래에서 올라오게 설계해놓았다.
그 이유는, stack과 heap은 그 크기가 매우 많이 변하기 때문이다.
stack은 function등을 실행할 때 잠깐 생성되는 지역변수나 return 이후 돌아갈 주소를,
heap에는 malloc 등으로 선언한 동적 변수나 커다란 array를 넣기 때문에
두 영역 모두 가변적이다.
두 영역이 같은 방향으로 커진다면 가용공간이 남아있을 때에도 한 영역이 더 늘어나지 못하는 불상사가 벌어질 수 있기에 가상 영역에서 만큼은 이렇게 만들어놓았다.
물론 물리적으로는 이렇진 않는 경우가 많다.
2) 메모리 맵핑
32비트 운영체제에서는 메모리 주소를 32bit로 표현한다.
32bit으로 표현할 수 있는 주소는 2^32가지 이므로 4GB까지의 RAM만을 인식할 수 있다.
컴퓨터는 메인 메모리로 시금치 DRAM을 쓴다.
요즘에는 작아봐야 4GB, 많으면 32GB나 64GB도 쓰지만, 512MB 램 시절에도 32bit 컴퓨터는 잘 돌아갔다.
실제 메모리랑 프로그램이 인식하는 메모리랑 일치하지가 않는다는 말이다.
내 메모리가 1GB밖에 안되는데
가상 메모리 공간은 0~4GB로 넉넉하게 잡아놓고, 물리적으로 짝지을 때 중간의 빈 공간을 더 좁게 해놓으면,
비록 stack이 커지다가 총 공간이 1GB가 되는 순간 램이 나가버리겠지만
그 전까지는 모든 프로세스들이 각자 4GB가 되는 것처럼 마음껏 쓰고있지 않겠나?
아무 것도 모르고 즐기게 두라는 것이고,
이래서 계속 환상(illusion)이라고 한 것이다.
용량이 부족하지 않더라도, 맵핑하는 것 자체도 더 효율적으로 할 수 있는 여지가 있다.
뭐하러 stack을 통채로 물리적으로 맵핑을 하는가? 쪼개서 하면 되는데.
규칙적으로 쪼갠다음 맵핑하고, 그 맵핑한 위치를 테이블에 저장해서 넣어놓으면
비록 쓸 때마다 테이블을 확인해야 되는 딜레이도 생기고, 관리도 더 어려워지지만
빈 공간을 더 잘 활용할 수 있고, 캐시구조와 연동해서 쓰기에 편해지는 장점이 생긴다.
모르겠다고? 꼴랑 이거 듣고 이해했으면 대학원 가면 된다.
2. 프로세스 API
(1) Process API
OS는 프로세스를 다루기 위해 몇 가지 인터페이스들을 지원한다.
보통은 1) Create 2) Destroy 3) Wait 4) Status 5) Miscellaneous Control 의 다섯 가지를 기본으로 한다.
1) Create
프로그램은 실행되기 전까지 디스크(HDD/SSD)에 있다.
우리가 실행하면 OS가 I/O bus를 통해 디스크에다가 '데이터좀 줘' 하고 속삭인다.
그러면 디스크는 메모리한테 정보를 전달해서 넣어두라고 툭툭 친다.
그럼 메모리는 '아이 이럴 필요는 없는데 별걸 다' 하면서 호주머니를 활짝 연다. stack이나 heap을 키운다는 뜻이다.
CPU가 달라고 했는데 메모리한테 주는게 포인트다. 이것을 DMA(Direct Memory Access)라고 한다.
그러면 CPU는 받았는지 어떻게 아냐고? 이것 또한 OS가 신호를 준다.
이 부근의 지식은 OS가 아니라 마이크로아키텍쳐, 시스템프로그래밍의 영역이다.
커널 뜯어서 어셈블리 하는 사람 아니면 맛만 보고 넘어가자.
2) Destory
메모리에는 데이터의 위치를 저장해놓는 표가 있다.
데이터를 실제로 삭제해서 0으로 밀어버리는 게 아니고, 표에서 밀어버린다.
사실 표에서 밀어버리는 것도 아니고, 유효성을 체크하는 validation bit 하나를 0으로 만든다.
다음에 새로운 데이터를 저장하기 전까지는 데이터가 그대로 있으니 꺼내 쓸 수도 있기는 하지만(디지털 포렌식)
그 위치가 어딨는지를 모르는데 어떻게 찾나?
집안에 리모콘 있는건 아는데 어딨는지 모르는 것과 같다.
3) Wait
기다린다.
프로세스를 멈춰놓는다는 뜻이다.
물론 여러 종류가 있어서 다른 프로세스는 여전히 실행가능하고 요런 것들은 있는데
그냥 기다린다고 보면 된다.
4) Status
Status라고도 하고 States라고도 한다.
프로세스의 현재 상태를 말한다.
나중에 Scheduling할 때 똥빠지게 보게 된다.
New는 방금 만들었다는 뜻
Ready(Runnable)는 실행 대기열에 있다는 뜻
Running은 실행중이라는 뜻
Waiting(Blocked)은 어떤 신호를 받아야 대기열에 들어간다는 뜻
Terminate 는 실행은 끝났는데 아직 삭제는 안되었다는 뜻이다.
다른건 몰라도 runnable, running, waiting의 삼각관계는 화살표 내용이랑 같이 잘 보고 넘어가자.
5) Miscellaneous control
프로세스가 특정 동작을 하면 어쩔건지에 대한 내용이다.
왜 특정 동작이라고 어물쩡거리냐면, 내용이 너무 많아서 그렇다.
대략적으로 잡아도 다음과 같다.
- OS한테 나 괜찮다는 신호를 보내고 싶음
- 다른 프로세스한테 값을 넘겨주거나 받고싶음
- 계산 끝나면 신호를 보내주기로 했음
- 정기적으로 context switching함
- 키보드 치는 등 I/O 일어남
- 인터넷 패킷 에러났음
자세한 내용을 알고 싶다면 구글에 trap, exception, interrupt 를 검색해보면 좋다.
아무튼, shell에서는 CTRL+Z 나 CTRL+C를 누르면 실행중인 프로세스를 강제종료하는 경우가 많은데 이런걸 프로세스의 miscellaneous control이라고 한다.
(2) Process Control Block(PCB)
PCB는 프로세스 상태를 나타낸 block 데이터다.
말하기가 되게 오묘한데, 실제로 여러 데이터를 짬뽕해놓은 거라서 그렇다.
포함되는 데이터는 보통 다음과 같다.
1) Status : 위에서 이야기 한 process의 states를 말한다. Runnable인지 Running인지 등.
2) IP/PC(Instruction Pointer/Program Counter) : 다음에 실행할 명령이 있는 장소. 다음이라고 해서 헷갈리는데 조금 더 쉽게 생각하면 현재 위치라고 보면 된다.
3) Register Context : Context Switch할 때 말한 세이브 파일. 지금 CPU에서 작업하고 있던 내용.
4) Scheduling Information : 우선 순위, 대기열 위치(포인터), 현재 등수 등.
5) Memory Information : 가상 메모리 베이스 주소, 크기, 맵핑 표 위치(포인터) 등.
대충 요약하자면, 다음 프로세스로 바꿀 때 필요한 모든 정보 를 말한다.
struct pcb {
enum process_state state; // Current process state
int pid; // Process ID #
struct reg_context context; // Register context
char *mem; // Process memory offset
unsigned sz; // Memroy size
...
};
위는 PCB를 C언어로 구현한다는 것을 가정하고 만든 구조체다.
모든 프로세스는 PCB를 가지고 있고, 다른 프로세스에게 CPU 점유권을 넘겨줄 때 이 내용을 메모리에 저장해놓는다.
복구하는 과정을 고려해보면, 어딘가엔 PCB의 주소를 모아놓은 Table이 있고 이 Table로 scheduling을 할 것이란 걸 예측할 수 있다.
3. 프로세스 API 예제들
(0) POSIX
Portable operating system interface for uniX 의 약자로, UNIX 기반 OS에서 공통으로 사용되는 C API 들이다.
다음과 같은 것들이 있다.
• File operations: mkdir, symlink, etc.
• Process handling: fork, execvp, pipe, etc.
• Memory management: mmap, mlock, etc.
이 중 몇 개만 살펴보자.
(1) fork
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void) {
printf("main (pid=%d)\n", getpid());
int rc = fork();
if(rc < 0) {
fprintf(stderr, "fork() failed\n"); exit(1);
}
else if(rc > 0) {
printf("parent process (pid=%d) of child process (pid=%d)\n", getpid(), rc);
}
else {
printf("child process (pid=%d)\n", getpid());
}
return 0;
}
fork는 fork() 실행 이후의 내용을 똑같이 실행하는 child process를 만드는 함수다.
몇가지 살펴볼 점이 있다면, 1) return값 2) PCB 3) 실행 순서 가 있겠다.
1) return값
위 코드에서 return값인 rc는
에러가 나면 -1
child process에서는 0
parent process에서는 child process의 pid
를 가진다.
2) PCB
부모와 자식의 pid는 다르다. 프로세스가, PCB가 다르다는 뜻이다.
프로세스가 다르니 당연히 state도 다르고, 메모리도 공유하지 않는다.
사실 약간의 구라가 있다.
child의 내부 변수를 살펴보면, print 등 참조만 할 경우 메모리의 주소가 같고, modify 이후 참조하면 메모리의 주소가 다른 것을 확인해 볼 수 있다.
최초에는 parent의 변수 위치를 공유하다가, 이후 modify할 일이 있으면 독자 메모리 공간을 만든 뒤 copy를 하고 나서야 modify를 한다는 뜻이다.
아무튼 수정을 할 일이 있으면 스택을 새로 만들고 메모리 카피를 하는 건 똑같으니, 아주 크게 신경 쓸 일은 아니라고 할 수 있다.
시스템 프로그래밍을 하면 File Descriptor와 함께 자세한 내용을 배울 수 있다.
3) 실행순서
부모와 자식간의 실행순서는 non-deterministic 하다.
며느리도 모른다는 뜻이다.
사실 조금 정해져있기는 한데,
같은 연산 하는데 child는 modify할 때 copy 작업을 해야한다.
그리고 copy는 연산량을 조금 잡아먹는 작업이니, 아무래도 child가 늦을 확률이 조금 더 높다.
실험 결과 60~70%의 확률로 parent가 빠르더라.
(2) wait
바로 위해 parent와 child의 실행순서는 non-deterministic하다고 했다.
맘에 안들지 않은가? 프로그래머는 자고로 불-편함을 없애는 것을 좋아한다. 랜덤함수도 씨드 넣고 짜는데 저걸 그대로 놔둘리가 없다.
parent 부분의 코드를 다음과 같이 수정해보자.
else if(rc > 0) {
wait(NULL); // This is equivalent to waitpid(-1, NULL, 0);
printf("parent process (pid=%d) of child process (pid=%d)\n", getpid(), rc);
}
fork로 생성된 child process는 작업을 완료하면 그냥 사라지는 것이 아니라 OS로 PCB에 포함된 몇가지 신호를 준다.
그 중 process id와 process group id를 이용하는 방식이다.
waitpid의 인수와 의미는 다음과 같다.
pid_t waitpid(pid_t pid, int *statloc , int options);
pid<-1 : 종료된 자식 프로세스의 그룹 id가 입력받은 pid 절대값과 같은 것을 기다림
pid==-1 : 아무 자식 프로세스나 종료되기를 기다림
pid==0 : 같은 프로세스 그룹 id를 가진 자식 프로세스 종료를 기다림
pid>0 : 종료된 자식 프로세스의 pid가 입력받은 pid와 같은 것을 기다림
statloc은 정상 종료 여부, 비정상 종료 이유 등을 알 수 있는 반환값이고
option은 비정상 종료 및 재개시에도 반환을 받는 옵션이다.
위 wait(NULL);을 사용함으로써 부모 프로세스는 자식 프로세스의 종료를 기다린 다음 printf를 출력하게 된다.
(3) exec
다른 포럼의 글을 빌려오겠다.
else {
printf("child process (pid=%d)\n", getpid());
char *argv[3];
argv[0] = strdup("wc"); // Executable path - word counting program
argv[1] = strdup("fork.c"); // Input argument of another program
argv[2] = NULL; // Input argument vector ends with NULL.
execvp(argv[0], argv); // Launch the program (i.e., wc)
}
else 부분의 코드를 위와 같이 수정해보자.
exec은 fork와 비슷하지만, 새로운 프로세스를 생성하지 않고, 해당 프로세스 안에서 받은 인수를 기반으로 프로그램을 실행한다.
이 때문에 exec 이후의 코드는 실행되지 못한다.
여기서 의문점이 생길 수 있다. child process가 실행되지 못했는데 parent는 계속 기다리고 있나?
child가 비정상적 종료되었으므로, waitpid의 두 번째 인수인 statloc을 이용하면 비정상종료 여부와 원인을 알 수는 있을 지언정, 종료되었다는 사실이 달라지지는 않는다.
(4) open, close
else {
printf("child process (pid=%d)\n", getpid());
char *argv[3];
argv[0] = strdup("wc");
argv[1] = strdup("fork.c");
argv[2] = NULL;
// Close the default output file descriptor (stdout). STDOUT_FILENO = 1.
close(STDOUT_FILENO);
// Redirect the output to a file (open.out).
open("open.out", O_CREAT | O_WRONLY | O_TRUNC, S_IRWXU);
execvp(argv[0], argv); // Launch the program (i.e., wc)
}
위와 같이 코드를 바꾸어보자.
STDOUT_FILENO는 1의 값을 갖는데, 이는 기본 file descriptor의 output 파일이다. 즉, 현재 shell에 표시되는 내용을 말한다.
이를 close 하고 새로운 open을 했으니, 기존 shell에는 자식 프로세스의 exec의 내용이 출력되지 않을 것이다.
대신 새로 open.out을 create하고, write only 옵션으로 열어서, truncate하고, write했으니
cat open.out
을 한다면 아까 출력되었어야 하는 내용이 출력될 것이다.
(5) pipe
pipe에 앞서 file descriptor를 알 필요가 있다.
File Descriptor는 어떤 프로세스가 가지고 있는 표다.
어떤 표냐면, 지금 이 프로세스의 인풋 파일이 무엇인지(fd=0), 아웃풋 파일이 무엇인지(fd=1), 열고있는 파일이 무엇인지(fd>2)를 정리해놓은 표다. (fd=2는 에러)
터미널에서 프로그램을 실행하면 fd=1은 보통 터미널이 된다.
open을 실행할 때 마다 fd가 하나씩 늘어나며 파일을 연다.
만약 같은 파일을 열면? fd=3이 가진 file table과 fd=4이 가진 file table은 다르지만, 각자가 가리키는 v-node table의 내용은 같다.
만약 fd가 가리키는 파일을 변경시키고 싶다면 어떻게 하면 될까?
대표적으로 dup()과 dup2() 함수가 있는데, dup2함수에 대해서만 설명하겠다.
dup2(oldfd, newfd)를 실행하면, oldfd가 가리키는 파일 포인터를 newfd 자리에 복사한다.
같은 파일을 참조하고있다는 뜻이다.
그런데 아까 fd=1는 보통 터미널이라고 했는데, 이 경우 터미널 a에서 파일 b로 변경되었다.
우리가 어떤 명령을 실행했을 때, 그 결과를 터미널에 저장하지 않고 파일b에 써버리겠다는 뜻이다.
우리는 지금 fork에 관한 예제를 보고 있으니 이에 대해 생각해보자.
한 프로세스가 하나의 File Descriptor table을 가지고 있으니, 자식 프로세스는 독자적인 프로세스를 가지고 있을 것이다.
하지만 이미 open해놨던 파일 테이블의 내용은 변한 게 없으니 같은 파일테이블을 공유할 것이고, 이는 결국 같은 파일을 참조하고 있다는 뜻이다.
다시 pipe로 돌아가서,
pipe는 두 프로세스간에 공유되는 파이프를 만드는 함수인데,
한 프로세스의 reading end와 다른 프로세스의 writing end를 file desciptor를 이용하여 연결해주는 방법으로 공유한다.
무슨 소리인지 이해가 잘 안갈텐데, 예시를 한 번 보자.
$ ls | grep a
>> data.c
>> printf.asm
>> wait.c
터미널에 ls를 치고 엔터를 누르면, ls 라는 프로세스가 실행된다.
ls 프로세스는 현재 디렉토리에 있는 파일들의 이름을 파싱해서 터미널에 보여주는 함수다.
즉, fd=1이 터미널을 가리키고, 그 내용은 현재위치의 파일이름들인 프로세스다.
| 는 파이프를 만들어주는 명령어다.
grep은 터미널로부터 인수(argv)를 받아, 두번째 인수에 첫번째 인수가 있다면 출력하는 함수다.(옵션, 파일경로는 제외하고 설명)
여기서 두번째 인수에는 ls의 결과물이 자동으로 들어가도록 pipe를 설정해주었으니, ls에 있는 출력물 중 a를 포함하고있는 파일이름만을 출력한것이다.
원래의 fork 예제로 돌아와서, 다음을 보자.
#include <assert.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(void) {
int filedes[2];
assert(!pipe(filedes));
int rc = fork();
if(rc < 0) {
fprintf(stderr, "fork() failed\n");
exit(1);
}
else if(rc > 0) {
printf("parent process (pid=%d)\n", getpid());
dup2(filedes[0], 0);
close(filedes[1]);
char *argv[3] = {"grep", "a", NULL};
execvp(argv[0], argv);
}
else {
printf("child process (pid=%d)\n", getpid());
dup2(filedes[1], 1);
close(filedes[0]);
char *argv[2] = {"ls", NULL};
execvp(argv[0], argv);
}
return 0;
}
1) fidledes를 선언하고 pipe를 이용해서 두 filedes[0]과 filedes[1] 사이에 파이프를 만들었다.
2) fork를 선언하고 그 값을 rc로 받았다.
3) 에러가 아니고, 부모 프로세스인 경우 (rc>0) : fd=0(프로세스의 인풋)이 가리키는 곳을 filedes[0]이 가리키는 곳으로 바꾼다.
이후, grep a 를 실행한다.
4) 에러가 아니고, 자식 프로세스인 경우 (rc==0) : fd=1(프로세스의 아웃풋)이 가리키는 곳을 filedes[1]이 가리키는 곳으로 바꾼다.
이후, ls를 실행한다.
코드의 흐름을 보면,
자식 프로세스가 ls를 실행하고 그 출력을 filedes[1]이 가리키는 곳에 적으면
부모 프로세스가 filedes[0]이 가리키는 곳에서 이를 읽어와 grep a (*filedes[0]) 한다.
신기한 점이 두 가지가 있는데,
1) parent 가 child 보다 먼저 실행되었을 때에도 child의 결과를 parent가 쓸 수 있다는 점
2) filedes[0], filedes[1]이 가리키는 곳을 따로 지정하지 않고 파이프만 연결했음에도 자동으로 지정했다는 점
이다.
즉, pipe를 사용하면 wait()를 사용하거나 R/W 둘 다 가능한 새로운 파일을 생성할 필요 없이 자동으로 처리해준다는 것이다.
'System > OS' 카테고리의 다른 글
[OS] Scheduling (0) | 2020.05.05 |
---|---|
[OS] Introduction, Properties (0) | 2020.05.01 |