운영체제 4편 - 프로세스

이 글은 학부 운영체제 수업을 듣고 정리한 글입니다.
맥락없이 운영체제에 대한 내용들이 불쑥 등장하니 양해부탁드립니다.

이번 편은 프로세스에 대한 내용입니다.

프로그램과 프로세스

프로그램은 일련의 순차적으로 작성된 명령어들의 모음으로 disk와 같은 secondary storage에 저장되어 있다. 이들이 메모리에 올라오면서 실행이 되는 프로그램이 된다. 그러면 프로그램은 프로세스가 된다.
소스코드 단계부터 어떻게 이것이 프로세스가 되는지 단계적으로 살펴보자.
먼저 소스코드는 compile을 통해 Object 파일로 변환된다. Object 파일은 machine이 이해할 수 있는 기계어로 구성이 된 파일인데 이 자체로는 수행이 되지 못한다. 프로세스로 수행을 하기 위해서는 여러가지 다른 정보들이 삽입되어야 한다. 그리고 Object 파일은 relative한 주소값들을 가지게 된다. 각 소스코드 파일은 각각 Object 파일로 변환된다.
그리고 이후 linking을 통해 executable한 파일로 된다. 이때 relative 한 주소들이 absolute한 주소로 변경된다.

Relative한 주소라는 것에 대해 조금 더 얘기해보면, 컴파일러는 소스코드를 파일 한개씩 보고 compile을 한다. 각 코드를 machine code로 바꾸어 주는 역할을 하고 address는 해결을 해주지 못하기 때문에 relative한 address를 사용한다. relative는 간단하게 이 파일 내부에서 몇번째 주소인지 정도를 의미한다.

이런 relative한 주소는 linking에서 해결을 하는데 linking 과정에서는 여러개의 object 파일들과 라이브러리들을 연결하여 메모리에 load될 수 있는 하나의 실행파일을 생성한다. Linking은 Address Resolution 과정이라고 볼 수 있다.
여기서의 실행파일은 exe 파일 같은 executable 파일이며 특정한 환경(OS)에서 수행될 수 있는 파일을 뜻한다. executable 파일은 Process로의 변환을 위한 header, 작업 내용인 text, 필요한 데이터인 data를 포함한다. Object 파일은 여러개더라도 실행파일은 1개이다.
Compiler와 Linker는 결과물이 수행될 OS와 CPU에 따라 다른 실행파일을 생성한다.

프로그램과 프로세스


Linking

Linking 과정을 조금 더 보겠다. Linking에는 static linking과 dynamic linking이 있다.
static linking은 object file과 library들을 하나의 파일에 다 집어넣는다. 표준 C 라이브러리인 libc가 다 포함된다. 다만 이 방법은 너무 파일이 커지므로 dynamic linking이 대안될 수 있다.

dynamic linking은 linking을 할때 각 라이브러리를 붙이지 않고 runtime에 붙인다.
윈도우에서는 dll이라는 Dynamic Link Library가 존재한다. 라이브러리들이 메모리에 올라오면서 이들이 올라온 메모리의 주소를 찾아서 연결해준다. 이를 가능하게 해주는게 Runtime System 이다.
Runtime System은 프로그래밍 언어마다 존재하는데 Java에는 JVM, javascript에는 NodeJS 등이 있다. C programming에서 초기 실행함수가 main인 이유가 Runtime System이 호출하는 함수가 main 함수이기 때문이다.

Programming language는 각각 execution model 이라는 것을 정의하는데 process를 어떻게 메모리에서 배치할지, 어떻게 parameter passing을 할지를 정의한다.
우리가 흔히 자주 사용하는 environment variable 들도 Shell script에서 자주 사용하는데 이들에 대해 shell의 Runtime System에서 linking을 해준다.

static-linking vs dynamic-linking

Program to Process

그러면 다시 돌아와서 프로그램을 어떻게 프로세스로 만들까?
프로그램은 format이 있다. 기본적인 format은 맨 첫 부분에는 header가 들어간다. 그 다음에 text, 그 다음에는 data가 들어간다.
text 영역에는 code가 배치되고, data는 우리가 만든 static variable, global variable 들이 들어간다.
Executable을 정의하는 방식도 여러개 있는데 대표적으로 ELF(Executable and Linkable Format)과 COFF 등이 있다.
이런 format을 따르는 executable 파일들이 disk에 저장되어 있다.
이들을 실행시키면 프로세스가 되는데 text는 text segment로 data는 data segment로, uninitalize가 되어있는 global variable들은 bss(block start by symbol)로 올라간다.
각 영역의 크기들은 이미 executable header에 명시되어 있기때문에 이 값을 보고 크기를 정할 수 있다.

Program to Process 메모리 구조

우리가 소스코드에서 많이 선언하는 local variable은 모두 stack으로 간다. stack은 push, pop으로 동작하는데 local variable 선언으로 push를 하면 그 주소를 알 수 있다. 이 주소로 local variable에 접근한다.

우리가 쓰는 program들의 stack 크기가 얼마정도일까?
Linux는 default 값으로 stack이 8196Kb 즉 8Mb로 설정이 되어있는데 보통의 프로세스에서는 사용량이 8Kb를 넘지 않는다.

위 그림에서 Heap 은 무엇일까?
C programming에서 dynamic memory allocation(malloc, calloc)을 할때 메모리를 heap에 할당한다.
이를 호출하면 heap에 할당된 주소를 받는다.
위 프로세스 메모리구조를 기반으로 보면 실제로 처음 Process가 시작하면 stack pointer가 주소에서 가장 큰값을 가리키고 있다.

Text segment에는 binary instruction들이 copy 된다.
값이 정해진 global variable들은 data segment에 들어가서 process에 복사된다.
uninitialized global variable들은 memory에 안잡히는 것이 아니라 process를 만들때 그만큼 bss영역에 memory가 잡히게 된다.

보통 heap을 이야기할때의 동적 메모리와 비교되는것은 array인데 이는 헷갈리기 쉽다. Array들은 어느 segment에 할당이 될까?
Global array는 data segment에 잡히고, local array는 stack에 잡힌다. 당연한 이야기지만 array를 define하려면 크기를 미리 알아야 한다.

참고사항으로 자바에서의 array는 object 이므로 stack 영역에 할당되지 않는다. 이는 자바의 Runtime System이 정한 내용으로 array는 heap영역에 할당되고 그에 대한 reference를 stack에 가지는 방식이 될 것이다.
(조금 더 정확하게는 자바의 Array는 primitive type 혹은 reference type들을 요소로 가지는 object들로 다른 object들과 같이 heap 영역에 할당된다. 하지만 Java 7에서 기본으로 탑재된 Escape Analysis로 object라고 해서 꼭 heap 영역에 할당되는 것은 아니다. Escape Analysis는 object의 scope에 관한 내용으로 만약에 object가 method scope에 있다면 JVM은 이 object가 다른 scope에서 사용되지 않는 다는 것을 알 수 있기때문에 Constant Folding 같은 최적화를 사용할 수 있고 object 자체를 Thread의 stack 영역에 할당할 수 있다.)


Process

프로세스는 크게 2가지를 하는 abstraction 이라고 볼 수 있다.

  1. scheduling의 단위이다. 다른 execution unit도 있는데 이는 Thread이다.
  2. protection domain을 제공해준다. 프로세스끼리 서로 접근을 못하게 해준다.

Protection domain을 제공한다는 것은 다른 프로세스가 우리 프로세스의 heap, stack, data, text segment 등에 접근할 수 없게 보호해준다는 것이다.
Kernal은 접근할 수 있다. Monolithic Kernel에서는 kernel과 process가 메모리상에서 같이 있기 때문이다.

Process는 어떻게 구현이 될까? 크게 이 핵심적인 3가지로 구현이 된다고 볼 수 있다.

  • program counter
  • stack pointer
  • data section 이라는 register

Program counter는 text segment에서 현재 실행중인 instruction을 가르키고, data section은 data 영역중 어딘가를 가르킨다.
프로세스가 execution 한다는 것은 PC가 움직이고 stack pointer가 움직인다는 것이다.


Context Switch

Process는 execution unit 이다. Process A가 실행되다가 Process B가 수행되게 바뀌는 것을 context switch(문맥전환)이라고 한다.
그럼 Context Switching은 언제 일어나는가?

  • Time quantum expires
  • I/O 호출

위의 두가지 경우에 한하여 Context Switching이 일어난다고 볼 수 있다. Process old가 위의 조건중 하나에 해당한다면 old의 수행을 멈추고, Process new를 수행한다.
Kernel에서 Context Switching을 담당하는 부분을 dispatcher라고 한다.

Context Switch가 발생하면 먼저 context를 저장한다. PCB(Process Control Block)라는 자료구조가 등장하는데, PCB는 Kernel data structure이다. PCB 안에 Process를 나타내는 모든 정보(Process id, Program Counter, CPU register, CPU scheduling information, Memory management information, I/O status information 등)가 다있다.
각 Process 들은 PCB로 표현할 수 있다.

PCB 구조

PCB의 구조는 위와 같다.
Context Switching이 발생하였을 때에는 현재 process의 state, 즉 현재 프로세스의 PCB를 저장하고 다음 프로세스를 reload 한다. 그리고 다음 프로세스를 수행한다.
CPU의 입장에서는 지금 어떤 것을 수행하는지 아무것도 모른채 instruction만 계속 수행한다.

context switching

context switching in system call

프로세스가 I/O를 만나게 되면 system call로 trap이 발생하고 이 프로세스는 I/O가 끝날때까지 기다려야 하므로 sleep queue에 넣는다.
System call 자체는 Context Switching을 일으키지 않는다. 단지 이는 user mode에서 kernel mode로 갔다가 돌아갈 뿐이다.
Context Switching은 I/O 같은 요청이 와서 더이상 프로세스가 다음 instruction을 수행할 수 없을때 발생한다.
일반적인 System call에서는 문맥교환이 발생하지 않는다.


Computer Architecture in Context Switching

Context Switching은 컴퓨터 구조의 영향을 많이 받는다. 먼저 CISC와 RISC를 알아보자.

CISC

  • 복잡한 명령어 set으로 구성하여 효율을 높였지만, clock 속도가 저하된다.
  • 복잡한 회로로 물리적인 공간을 많이 차지하여 register 용량이 저하된다.
  • 예로 Intel pentium processor가 있다.

RISC

  • 간단한 명령어 set으로 구성하여 clock 속도를 높여 빠른 수행속도를 가진다.
  • 상대적으로 간단한 회로로 물리적인 공간에 보다 많은 register를 가진다.
  • 예로는 ARM processor가 있다.

RISC는 간단한 명령어 set으로 구성되어있어 똑같은 작업을 하는 프로그램도 크기가 CISC보다 더 늘어날 수 밖에 없다. 예를들어 CISC의 경우는 I/O 자체를 instruction으로 바로할 수 있다. Context Switching 입장에서는 RISC가 state가 더 많을 수 밖에 없기때문에 Context Switching을 할 때에 더 많은 것을 저장해야 하므로 register 내용변경에 큰 overhead가 생겼다. 그래서 Register window 같은 개념이 나오게 되었다.

Process State

프로세스는 실행을 하면서 여러 state를 가진다. 그전에 설명했던 context 의미를 가진 state와는 다른 state이다.
new, ready, running, sleep, terminated 가 있다. 간단하게 보자.

Process State 종류

  • new: 새로운 process가 만들어진 것이다.
  • running: 현재 수행되고 있는 상태이다.
  • sleep: I/O 같은 이벤트가 완료되기를 기다리고 있는 상태이다.
  • ready: process가 processor로 dispatch가 되기를 기다리는 상태이다.
  • terminated: 수행이 종료된 상태이다.

간단한 flow를 설명하면 new는 새로운 프로세스가 만들어 진 것이고, 이들이 ready queue로 들어간다.
그러면 dispatcher가 이들중 하나를 선택한다. 여기서 어떤 대상을 선택할지 결정하는 것이 스케줄링이다.
선택이 된 process는 running이 된다. 만약 여러개의 core가 있다면 running queue가 따로 존재한다. Time quantum을 다 소진해서 다시 ready 상태로 가거나 I/O가 발생하여 sleep을 하게된다.
이 모든것을 관리해주는 것을 process management라고 한다.
실제 Kernel 코드를 보면 sleep 자체도 여러가지 종류가 있다.

process state

Process creation

Program을 process로 만드려면 비용이 많이든다. 프로세스를 새로 만드는 것은 수행시간이 오래걸릴수 밖에 없는데 PCB같은 프로세스에 대한 Kernel data structure를 모두 만들어 주어야 하고 동적메모리도 전부 다 할당해야한다. Process creation의 생성시간은 최소 millisecond 단위이다.
그래서 더 효율적으로 프로세스를 생성하기 위해 기존의 프로세스에서 다른 프로세스를 만들 수 있게 해준다. 이를 fork라고 한다. fork는 시스템 콜이다.

프로세스 생성 시간을 줄이기 위해 fork를 한다. fork는 기존 프로세스 자체를 복사를 해버린다. 메모리 관점에서는 User space에서의 메모리를 복사하는 것도 있고 이와 동시에 Kernel 영역에 있는 PCB 같은 kernel memory도 복사를 한다. 이런 복사 자체도 시간이 걸리지만 처음부터 process를 새로 전부 만드는 것보다는 훨씬 시간절약이 많이된다. (위의 program to process에서 설명하였듯이 process로 올리기위해 Data 들의 크기를 재서 할당하고 이들의 alignment를 맞추고 하는 것보다는 절약이 많이된다는 의미이다)

process fork 과정

Parent 프로세스가 fork를 호출하면 커널은 이를 그대로 복사해 child 프로세스를 만든다. 그러면 Child 프로세스는 기존의 Parent 프로세스와 동일한 메모리의 상태를 가지게 된다. 그러면 Child 프로세스는 exec이라는 시스템콜을 호출한다. 이 시스템콜은 새로운 프로그램을 load하도록 한다. 프로세스 메모리 구조에서 data segment와 text segment만 copy를 하면 된다. 그러면 Parent 프로세스와 Child 프로세스는 서로 다른 프로그램을 수행시킬 수 있다. stack 같은것은 따로 복사를 안하기 때문에 이전 프로세스의 스택의 값을 그대로 가지고 있다.

vfork라는 것도 있는데 address space를 공유를 하다가 나중에 fork off를 한다.
위에서 copy를 하게될때 parent 프로세스보다 child 프로세스가 더 text 영역이 크면 copy를 못하는 것이 아닌가 라고 생각을 할 수 있지만 처음 소개했던 process memory 구조는 logical 한 구조이다. 즉 나중에 더 보게되겠지만 virtual memory의 관점이기 때문에 이는 문제되지 않는다.

그러면 프로세스들이 fork를 통해 계속해서 생성된다면 맨처음에는 프로세스를 직접 전부 만들어줘야 하지 않을까?
그 프로세스를 init 프로세스라고 부른다. 아래의 프로세스 Hierarchy에서도 init 프로세스를 찾아볼 수 있다. 이는 모든 프로세스들의 시초가 된다. init 프로세스를 만들고 이후로는 모두 프로세스들이 fork로 생성된다. init 프로세스를 kill하면 프로세스가 더이상 만들수없다. init 프로세스는 운영체제가 부팅을 할 때 만들어진다.

process fork 과정

사실 init 프로세스를 fork한 shell 프로세스라는 것이 존재한다.
init -> shell(Command Line Interface, window) -> User process
Shell 프로세스는 CLI나 window를 의미하는데 예를들어 CLI에서 우리는 프로그램을 실행시킴으로서 프로세스를 만들때 shell 프로세스가 새로운 프로세스를 만들어내게 된다.

Child 프로세스가 terminate 할때에는 SIGCHLD라는 signal을 parent 프로세스에게 날린다. parent는 이를 받을수도 있고 안받을 수도 있다.

c에서는 fork()함수를 통해 프로세스를 생성할 수 있다. fork()함수는 생성한 child 프로세스의 pid를 반환한다. 예제코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <unistd.h>

int main(int argc, char* argv[]) {
int counter = 0;
pid_t pid;

printf("Creating Child Process\n");
pid = fork();

if (pid < 0) { // Error in fork
fprintf(stderr, "fork failed, errno: %d\n", errno);
exit(EXIT_FAILURE);
} else if (pid > 0) { // This is Parent Process
int i;
printf("Parents(%d) made Child(%d)\n", getpid(), pid);
for (i = 0; i < 10; i++) {
printf("Counter: %d\n", counter++);
}
} else if (pid == 0) { // This is Child Process
int i;
printf("I am Child Process %d!\n", getpid());
execl("/bin/ls", "ls", "-l", NULL); // Run 'ls -l' at /bin/ls
for (i = 0; i < 10; i++) { // Cannot be run
printf("Counter: %d\n", counter++);
}
}

wait(NULL); // Wait for child termination
return EXIT_SUCCESS;
}

위의 코드에서 parent process면 fork()의 반환값으로 child process의 pid를 전달받고 child process이면 0을 반환받아 서로 다른 실행흐름을 가지게된다.
Child 프로세스의 경우에는 execl 시스템 콜을 호출하여 첫 인자인 /bin/ls 의 프로그램을 자신의 메모리에 copy하기 때문에 그 다음의 for loop을 실행할 수 없다.

Process Termination

프로세스를 terminate할 때는 exit 시스템 콜을 호출한다. exit을 호출해야 커널이 프로세스 및 PCB등을 지운다.
그러면 C언어에서 main 함수를 작성할 때 main 함수 내에서 직접 exit을 하지 않았는데에도 프로세스가 종료되었다. 이는 어떻게 설명할 수 있을까?
Runtime System이 자동으로 exit을 호출해준 것이다.

abort라는것으로 프로세스가 종료될 수 도 있는데 이는 비정상적으로 종료되었다는 것을 의미한다. 이는 signal도 보내고 core dump도 만들어준다.

댓글