2023-04-20
운영체제 #3 — 프로세스와 스레드
프로세스의 실체
프로세스는 프로그램이 실행되는 데 필요한 모든 것을 저장하는 자료구조다. 안에 들어 있는 것들은 이렇다.
- text — program code.
- stack — function call과 관련된 것. 파라미터, 호출이 끝나고 돌아갈 return address, 호출된 함수의 local variable 등.
- data — static, global variable. 프로그램의 어느 부분을 실행 중이든 접근할 수 있는 데이터다.
- heap — 동적 메모리 할당으로 저장된 데이터.
- control 정보 — 이 프로세스가 동작하기 위해 필요한, 현재 상태를 저장하는 데이터. 대부분 PC와 레지스터 값이다.
왜 하필 stack이냐는 중첩된 함수 호출을 생각하면 된다. A가 B를 부르고 B가 C를 부르면, C가 끝나고 B로 돌아갈 return address가 top에 있어서 pop만 하면 된다. 그러면 B가 끝나고 A로 돌아갈 주소가 top으로 올라온다.
control 정보가 프로세스 안에 들어 있는 이유는 멀티프로그래밍과 멀티태스킹 때문이다. 여러 프로그램이 돌아가면서 실행되니 그때마다 상태를 저장해야 한다.
주소 공간
메모리 상에서 프로세스가 차지하는 영역을 address space라고 한다. 0부터 MAX까지다. 논리적으로는 연속이지만 physical memory 어디에 있는지는 모른다. 물리 메모리에서는 연속이 아닐 수 있고, 그건 memory management가 결정한다. 프로세스 입장에서는 그냥 0~MAX를 내가 쓰고 있다고 논리적으로 생각한다.
프로세스의 메모리 영역에는 고정 크기 영역과 가변 크기 영역이 있다.
- 고정 — text(code), data(static/global은 새로 생기거나 크기가 바뀌지 않는다).
- 가변 — heap(동적 할당과 free), stack(호출과 return).
그래서 고정된 부분을 밑바닥에 깔고, 가변적인 부분을 양쪽에 배치해 아래위로 커졌다 작아졌다 하게 만든다. 주소 공간의 크기가 논리적인 것이라 MAX가 매우 크게 설정되므로, 둘이 만날 가능성은 거의 없다.
다섯 가지 상태
프로세스는 상태를 가지고, OS가 그걸 관리한다.
- new — 막 만들어져 내용이 채워지고 메모리에 올라간 상태. 아직 CPU 스케줄링 대상이 아니다. 우리가 쓰는 일반적인 운영체제에는 new 상태가 거의 없다고 봐도 된다. 바로 할당 대상에 포함된다. 그러나 실시간 시스템에서는 다르다. 데드라인을 지키며 돌고 있는 프로세스들 사이에 새 프로세스가 끼어들면 데드라인을 못 지킬 수 있다. 그래서 스케줄링 대상이 되지 않고 new에 머물다가, 기존 프로세스가 끝나서 편입해도 되겠다고 판단되면 그때 편입한다.
- running — CPU를 잡고 일하는 상태. 코어가 하나면 running인 프로세스는 단 하나다.
- waiting — 어떤 이벤트를 기다리는 상태. 대부분 I/O 이벤트다. I/O가 끝나지 않아 CPU를 잡으면 안 되는 상태라고 봐도 된다. 인터럽트가 와서 I/O가 끝났음을 알게 되는 시점에 벗어난다. ready로 간다.
- ready — 일할 준비는 됐지만 아직 CPU가 할당되지 않은 상태. waiting과 마찬가지로 일을 안 하고 있지만, 언제든 CPU를 받을 수 있다.
- terminated — 마지막 instruction까지 실행을 마친 상태. 더 이상 스케줄링 대상이 아니고, OS가 이 프로세스를 free시킨다. 즉 마지막 명령을 끝냈으나 아직 소멸되지는 않은 상태다.
상태 전이
프로세스가 만들어지면 new로 간다. 실시간 시스템에서 스케줄링 대상이 되어도 되겠다고 admission control이 판단하면 admitted되어 ready로 간다. ready에서 CPU를 할당받으면 running이 되는데, 이걸 scheduler dispatch라고 한다.
running에서 빠져나오는 경우는 세 가지다.
- 마지막 instruction을 마치고 terminated될 때.
- I/O를 발생시켜 끝나기를 기다릴 때. CPU를 잡고 있는 게 무의미하니 놓고 waiting으로 간다. I/O가 끝났다는 인터럽트가 오면 ready로 간다.
- 이 프로세스에 할당된 시간이 끝났을 때. time sharing을 위해 멀티태스킹을 하니, 시간이 끝나면 CPU를 넘겨야 한다. I/O를 발생시킨 게 아니므로 waiting이 아니라 ready로 간다. I/O 디바이스뿐 아니라 timer 디바이스도 인터럽트를 보낸다.
PCB
프로세스를 실제로 형태 있게 만드는 자료구조를 **PCB(Process Control Block)**라고
한다. 운영체제마다 만드는 방법이 다르고, 리눅스에서는 task_struct라는
구조체로 선언되어 있다. 이게 하나의 프로세스다.
PCB에 들어 있는 것은 프로세스의 현재 상태, PC, CPU 레지스터, CPU 스케줄링 정보(priority 같은 것), memory management 관련 정보(메모리 어느 영역에 올라가 있고 stack과 heap이 어디까지 차 있는지), accounting 정보(시작한 지 얼마나 지났는지, 메모리를 얼마나 쓰는지, CPU를 얼마나 썼는지), I/O 상태 정보(어떤 I/O를 요청했는지, 어떤 파일을 열었는지)다. 정말 복잡하다. 그 프로세스에 대한 모든 것이 들어 있다.
큐와 스케줄러
어떤 상태에 있는 프로세스가 하나가 아니기 때문에 큐에 보관한다.
- Job queue — 모든 프로세스(PCB의 포인터)를 가지고 있다.
- Ready queue — CPU를 기다리는(ready 상태인) 프로세스가 들어 있다.
- Device queue — 그 디바이스의 I/O를 기다리는(waiting 상태인) 프로세스의 집합.
상태가 변하면 다른 큐로 이동한다. device queue에서 I/O를 기다리다 인터럽트를 받으면 ready queue로 가고, ready queue에서 CPU를 할당받으면 빠져나온다. (job queue에는 계속 포함된다.)
스케줄러는 세 종류다.
- Long term scheduler(job scheduler) — new에서 ready로 프로세스를 옮긴다. 비교적 드물게 일어난다. 새 프로세스가 생성되고 실행 중인 것 중 하나가 끝났을 때 집어넣을지 판단하는 것이기 때문이다. CPU 스케줄링 대상이 되는 프로세스의 개수를 degree of multiprogramming이라 하는데(running, waiting, ready 포함), long term scheduler가 이걸 조절한다.
- Short term scheduler — ready 중에서 어떤 프로세스를 실행할지 정한다. 일반적으로 말하는 CPU 스케줄러다. time quantum이 매우 짧으므로 매우 자주 일어난다.
- Medium term scheduler — 메모리가 부족할 때 프로세스 하나를 골라 디스크로 내리고(swap out), 그 공간에 새 프로세스를 채운다(swap in). swap out된 프로세스가 다시 실행될 때가 되면 메모리로 가져와 ready queue에 넣는다. 주로 swap out 대상이 되는 건 suspend된 프로세스다.
프로세스들은 스케줄러 입장에서 매우 중요한 정보를 하나 가지고 있다. I/O를 많이 발생시키는 프로세스냐(에디터, 메신저 — CPU를 한 번 쓰는 시간은 짧지만 빈번하다. many short CPU bursts), 아니면 열심히 계산만 하는 프로세스냐(한 번 시작하면 계속 계산. few very long CPU bursts)다.
Context switch
프로그램 하나를 실행할 땐 문맥이 유지되지만, 다른 프로그램으로 넘어가면 완전히 다른 문맥으로 CPU가 일하게 된다. 그래서 context switch다. 바꾸기 전 프로세스의 상태를 저장하고 바꾸려는 프로세스의 상태를 로드하는 일이 따라온다. 멀티프로그래밍을 하려면 감내해야 하는 오버헤드다.
context switch 자체의 오버헤드뿐 아니라 동반되는 오버헤드도 있다. cache miss다. 지금 CPU가 일하며 메모리에서 읽어들인 내용이 캐시에 있는데, 전혀 다른 프로그램이 실행되면 그 캐시는 의미가 없다. 그래서 캐시를 비우고(flushing) 시작하니 직후에 cache miss가 많이 난다.
timer interrupt가 오거나 system call이 오면 CPU switch가 일어난다. 그런데 system call 쪽은 한동안 헷갈렸다. 유저 프로세스와 그 프로세스가 호출한 시스템 콜을 처리하는 것은 동일한 프로세스다. 그러면 왜 context switch가 일어나는가?
기본적으로는 같은 프로세스에서 시스템 콜을 처리한다. 다만 처리 과정에서 커널 내부의 다른 작업이 필요하면 커널이 다른 프로세스를 실행하게 될 수 있고, 이때 context switch가 일어난다. 이건 별개의 프로그램이 실행되는 게 아니라 현재 프로세스의 요청에 의해 커널이 일시적으로 다른 PCB를 로드하는 것이다. (스레드도 PCB를 하나씩 가지는 듯하고, 여기서 로드되는 게 시스템 콜 처리를 위한 커널 스레드의 PCB인 듯하다.) 일단은 어떤 경우에는 시스템 콜 호출 시 context switching이 일어날 수 있다 정도로 정리해뒀다.
프로세스 생성 — fork와 exec
더블클릭해서 프로세스를 만든다고 생각하기 쉽지만, 사실은 클릭 이벤트를 받아들이는
프로세스가 fork()로 자식을 만드는 것이다. 즉 프로세스가 또 다른 프로세스를
생성한다. 그래서 프로세스에는 가계도가 있고, 모든 프로세스는 root 프로세스(예를
들어 shell)부터 시작한다. 모든 프로세스는 pid를 가진다.
부모와 자식 사이에는 "부모가 자식을 만들었다"는 관계밖에 없다. context 측면에서는 아무 관계가 없다. 그런데도 이 관계를 관리해야 하는 이유는, 자식이 죽을 때 return 값을 받아줄 프로세스가 있어야 하기 때문이다. 그 역할을 부모가 한다.
자식의 주소 공간은 부모의 주소 공간을 그대로 복제한다. fork()된 직후의 상태는
똑같다. PC, 레지스터, 메모리, open file까지 전부 같다. PC도 같으니 부모와 같은
지점에서 시작한다. 그래서 return 값을 다르게 준다.
- 부모에게는 자기가 만든 자식의 pid가 return된다.
- 자식에게는 0이 return된다.
- 음수면
fork()실패다.
wait(NULL)을 하면 자신이 만든 자식이 끝날 때까지 대기한다.
fork()는 주소 공간을 복제할 뿐이다. exec()를 해야 그때 파라미터로 들어간
프로그램으로 주소 공간이 채워지고, 완전히 서로 다른 프로세스로 독립한다.
실습에서 —
fork와exec의 차이를 한 줄로 답하라는 문제가 있었다.fork는 호출한 프로세스를 복사한 자식을 만들고,exec는 호출한 프로세스를 인자로 받은 프로그램으로 대체한다. 그리고 소스코드를 주고 process tree를 그리라는 문제가 나왔는데,fork가 중첩된 코드에서 어느 분기가 어느 부모를 갖는지 손으로 따라가 보면 return 값을 다르게 주는 이유가 확실히 와닿는다.
종료와 좀비 — xv6의 exit과 wait
xv6 실습에서 프로세스 종료를 따라가 본 게 도움이 됐다. A → B → C 순으로 부모-자식
관계가 있고 C가 exit()을 부르면:
- C는 자신이 열었던 파일을 모두 닫는다.
- C에게 자식이 있었다면 그 자식들의 부모를
initproc으로 바꾼다. - 그중 zombie인 자식이 있으면
initproc을 깨워 처리하게 한다. - 그리고 C 자신은 zombie가 된다.
이후 C의 부모가 wait()을 호출해서 C의 자원을 회수한다. 부모가 이미 exit해서
initproc으로 바뀌었다면 initproc이 회수한다. wait()은 자식이 zombie일 때
자원을 회수하고 그 자식의 pid를 반환하며, 자식이 없거나 기다리는 도중 kill되면
-1을 반환한다.
부모가 먼저 exit하는 경우도 같은 규칙을 따른다. A가 exit()하면 자식들의 부모가
모두 initproc이 된다. 그 상태에서 사용자가 kill()로 C의 killed를 1로
세팅하면, 스케줄러가 C에 CPU를 준 뒤 인터럽트 핸들링을 할 때 exit()을 호출하게
된다. 이때 C의 부모는 initproc이므로 initproc이 wait할 때 자원이 회수된다.
스레드
하나의 프로세스 안에 들어 있는 스레드들은 프로세스가 아니기 때문에 자기만의 코드를 가지지 않는다. 같은 프로세스 안에서 만들어졌으니 같은 코드를 실행한다. 코드는 공유하고, 스레드가 프로그램 어디를 실행하든 data section(전역 변수)도 공유하고, open한 파일도 공유한다.
그럼 뭐가 달라져야 하나. 어떤 스레드는 A 함수를, 다른 스레드는 B 함수를 수행할 수 있다. function call이 다르게 일어나니 stack에 저장되는 return address, 파라미터, local variable이 다르다. 그래서 stack 영역은 스레드마다 따로 가진다. 상태 정보와 레지스터 값도 당연히 다르다.
요약하면 스레드는 하나의 프로세스 안에 존재하는 동적인 개념이고, 독립적인 레지스터 상태값과 stack을 가진다.
왜 스레드를 쓰나
스레드는 lightweight process라고 부른다. 일을 하는 기본 단위이고, 프로세스보다 공유하는 부분이 많아서(data, code, files) 가볍다.
- 응답성. 스레드 없이 프로세스 하나로 동작하면, I/O를 발생시키거나 blocking system call을 부를 때 waiting으로 갈 수밖에 없다. 여러 스레드로 만들어 서로 다른 위치를 실행하게 하면, 하나가 waiting에 들어가도 나머지는 계속 일한다. 프로그램 하나로 봤을 때 진도가 계속 나간다.
- 자원 효율. 그럼 스레드 대신 프로세스를 여러 개 만들면 되지 않나? 그래도 되지만 프로세스는 자기만의 주소 공간을 가진다. 똑같은 일을 하는 프로세스를 만들어도 code와 data 섹션이 복사되어 메모리에 올라가니 자원 활용이 나쁘다. 스레드로 만들면 같은 내용이 하나만 올라간다.
- 경제성. 프로세스를 여러 개 만드는 건 오버헤드가 크다.
fork()는 context와 주소 공간을 복사하는 행위라 오래 걸린다. 스레드는 공유할 건 공유하고 따로 가져야 할 것만 따로 만드니 생성과 삭제가 싸다. - 확장성. 멀티코어에서 하나의 프로세스는 절대 여러 코어에서 동시에 돌 수 없다. 여러 스레드로 구성되어 있으면 하나의 프로세스라도 각 스레드가 여러 코어에서 동시에 돌 수 있다.
커널 스레드와 유저 스레드
커널 스레드는 커널 레벨에서 지원하는, 커널이 만들어주는 스레드다. 커널이 스레드를 관리할 수 있고, CPU 스케줄링도 스레드 단위로 할 수 있다.
유저 스레드는 커널이 스레드가 만들어졌는지 모른다. 유저 레벨에서 스레드 라이브러리를 통해 생성되고, 프로세스 안에서 스레드라는 객체를 만들 뿐이다. 커널 입장에서는 그냥 프로세스 하나만 보이고 안의 스레드는 알 수 없으니, CPU 스케줄링도 프로세스 단위로 할 수밖에 없다.
그럼 커널 스레드가 무조건 좋은가 하면 그렇지도 않다. 유저 스레드는 생성과 소멸이 커널의 도움 없이 일어나므로 가볍고 빠르다. 커널 스레드는 커널에 요청해야 하니 느리다. 다만 요즘은 대부분의 커널이 스레드를 지원하므로 커널 레벨 스레드가 많다.