[Kernel of Linux] 3. System Call (2)
지난 강의 요약 - System Call (1)
system call이 불리는 과정, wrapper routine, vendor마다 다른 system call number, 새로운 system call 추가에 대한 장단점 및 대체방안 등을 알아봤다.
그리고 kernel이 해주는 중요한 임무 중 하나로 Process Management가 있다. kernel은 HW, SW 간의 접근 제어를 해주어야 하므로 각각의 devices와 processes를 표현해주는 metadata를 가지고 있다. 그중에서도 user process의 정보를 담고 있는 data structure를 PCB라고 한다.
지난 강의 마지막 부분에서는 command를 입력할 때마다(새 utility를 실행할 때마다) 생기는 Child Process에 대해 다뤘다. fork()는 parent와 완벽히 동일한 child process를 생성해주는 역할을 하고 exec()은 disk에서 새 image를 child에 load 해주는 역할을 한다고 배웠다.
이제 마지막에 나왔던 예제를 이어서 보자.
1. System Call - continue
1.1 fork() system call
원래 process가 생성되고 시작하면 C언어 기준으로 main을 불러와서 시작할 테지만 여기서는 child가 parent 걸 그대로 복사해왔기 때문에 fork()를 부른 시점 이후부터 실행되게 된다.
실행 결과도 살펴보자. a.out을 실행해서 출력된 문자열을 보면 처음에는 I am Parent, 다음으로는 I am Child가 찍혔는데 각각 parent, child process가 찍은 것이다.
1.2 exec() system call
이번에는 exec() 예제를 보자. 먼저 fork()로 child process를 만들어 준 뒤에 child는 execlp()를 통해서 child process를 덮어쓰게 된다. 그리고 main() 함수로 달려간다.
exec 뒤에 붙은 게 신경 쓰인다면 아래의 표를 참고하자.
fork() 때의 예제와는 달리 date 명령어의 실행 결과가 추가된 것을 볼 수 있다. child의 data가 덮어씌어져 실행된 것이다.
process가 wait() system call을 실행하면 kerel에서 해당 process를 block 시킨다. 다시 말해 그 process의 cpu를 뺏는다.
간단히 말해서 원래라면 parent로 돌아가야하는데 kernel에서 child process가 다 종료될 때까지 기다리고 나서 parent로 돌아간다.
exec() 예제에서 wait() 하는 부분이 추가되었다. 주석에도 설명되어 있듯이 parent는 chlid가 run이 끝날 때까지 sleep 하고 있다가 깨어난다. 깨어나면 ready queue로 옮겨진다.
1.3 exit() system call
exit() system call은 사용자가 코드에 작성할 일이 왠만하면 없을 것이다. 하지만 컴파일 후 생성된 바이너리를 확인해보면 exit()가 자동으로 추가되어 있다. 이는 컴파일러가 자동으로 추가해준 것이다.
exit() system call은 다음과 같은 기능을 한다.
- 모든 signals 무시
- 사용하는 files close
- memory space 해제
- parent process에게 끝났다고 signal 보내기
- 자신의 상태(state)를 ZOMBIE로 만들기
exit()를 호출하면 state가 zombie가 된다. 이 시점에서 child process는 아직 종료되지 않았다. 왜냐하면 parent가 알고 싶어하는 정보(예를 들어 return 값, exit()로 전달된 값 등)가 남아있을 수 있기 때문에 여전히 그 정보들을 유지해야 할 필요가 있어서이다.
parent가 가만히 있어도 그 값들을 받을 수 있는 게 아니라 kernel에게 요청해서 회수해야 한다. 이 요청은 wait() system call을 통해 할 수 있고 비로소 child process는 종료될 수 있게 된다.
만약 zombie process를 회수하지 않는다면 자원은 반환되었지만 여전히 남아있는 child(zombie)가 리소스 유출(resource leak)을 야기할 수 있기 때문에 parent는 이를 잘 회수하여야 할 것이다.
1.4 Summary: system calls for process
- fork: child process를 만드는데 그냥 parent와 똑같이 만든다(copy)
- exec: 새 image를 덮어씌운다. 새로운 프로그램이 올라갔으니 처음부터 시작이다.
- wait: 자신 아래에 있는 child들이 종료될 때까지 sleep 상태로 대기한다.
- exit: 자신이 가진 모든 resource들을 반환하고 parent에게 알린다.
1.5 Context Switch
- parent인 shell에서 ls command를 받아서 fork()를 실행한다. kernel은 sh의 a.out(실행파일) & PCB를 copy 한다.
- child는 바로 실행되지 않고 일단 ready queue에서 대기한다. 아직은 image일 뿐이다.
- parent가 wait() 시스템 콜을 하고 cpu를 뺏긴다. 그리고 queue에 들어간다.
- child는 똑같은 sh 코드를 run 한다. 그리고 execlp() 시스템 콜을 호출해서 sh 위에 overwrite 하고 실행된다.
- child가 끝날 때 exit() 시스템 콜을 함으로써 parent에게 종료 신호를 보낸다.
- parent는 wait queue에서 ready queue로 간다. 그리고 다시 실행할 준비를 한다.
- kernel이 sh를 다시 올리게 된다.
Context Switch에 대해 cpu와 pcb 관점에서 살펴보자.
P1이 wait() system call을 호출하면서 스스로 block 될 필요가 생겼다. kernel 안에는 PCB, CPU를 위한 data structure가 존재하기 때문에 이를 컨트롤해주어야 한다.
먼저 P1의 PCB 안에다가 현재 CPU 상태를 저장한다. kernel은 내부에 존재하는 ready list에서 다음으로 부를 process를 찾는다(여기선 P2). 그리고 찾은 process의 PCB로부터 정보를 가져와서 load 해준다. 정보 중에는 CPU registers에 대한 정보도 있으므로 PC point로 jump 한 뒤 계속 실행된다.
이렇게 context switching을 해주는 function이 kernel 안에 존재한다. 바로 schedule() 함수다.
이 함수는 kernel internal function(not known outside a.out)이다. 즉 kernel 내부에서만 알려진 함수이므로 커널 외부에서 호출할 수 없다. 반대로 외부에 알려진 함수는 대표적으로 syscall이 있다. read(), wait(), exit() 같은 syscall들이 schedule() 함수를 invoke 해서 계속해서 cpu를 잡아먹지 않도록 만든다.
이 함수는 다음에 run 할 process를 선택한 뒤 context_switch() 함수를 호출한다. context_switch() 함수는 다음과 같은 프로세스를 거친다.
- switch_to() - CPU switching
1. save current CPU state -> PCB (retiring process)
2. 이 프로세스를 sleep으로 만든다.
3. load CPU registers <- PCB (arising process) - switch_mm(): virtual memory mapping
- ...
이후 arising 한 process로 달려간다. PC로부터 다음 instruction을 fetch 해서 시작하게 되는 것이다.
정리하자면 schedule() 함수는 CPU registers가 바뀌어야 할 때마다 불리게 된다.
총정리를 하면서 동작을 정확히 이해해보자.
- (아직 아니지만) parent가 실행 도중 fork()를 호출한다.
- 커널에서는 parent의 copy를 통해 똑같은 모양의 child를 만든다.
- fork()는 call once, return twice이기 때문에 OS가 return시 다른 값을 준다. parent는 else 부분으로 달려간다.
- parent에서 wait() 함수를 호출한다.
- wait() 내부에서 schedule() 함수를 호출하고 schedule() 함수 내에서 불린 context_switch() 함수에 의해 parent의 CPU state가 PCB에 저장된 뒤 sleep 상태가 되고, ready list에서 다음으로 CPU를 사용할 process의 PCB에서 정보를 가져와 CPU에 load 한다. 이후 pc를 보고 다음 명령어가 실행된다.
- child는 parent와 똑같은 상태이기 때문에 fork()를 불렀던 다음 명령어부터 실행되게 된다.
- child는 parent와 다른 return 값을 받기 때문에 then 분기로 jump 하게 된다. 이후 exec() 함수를 호출한다.
- exec() 함수는 예제를 기준으로 ls 명령어의 스크립트를 disk에서 가져온다.
- 이후 child를 새로 덮어 씌운다(overlay).
- 그리고 처음부터 실행시키게 된다.
- c언어 기준으로 main()이 실행된다.
- main() 함수가 끝나고 컴파일러가 자동으로 넣어준 exit() 함수가 호출된다.
- exit() 함수 또한 schedule() 함수를 invoke 하게 되고 마찬가지로 context switch를 수행하게 된다. 그리고 parent에게 자신이 종료되었다는 것을 알린다.
- 스케줄링을 하면서 sleep 상태였던 parent가 ready list에 연결되게 되고 cpu를 획득한 순간 wait()을 호출한 다음부터 다시 실행을 시작한다.
1.6 Concept of a Process
process에 대한 개념을 정리해보자.
process는 실행되고 있는 프로그램이고, main() 함수부터 시작하고, scheduling의 단위고, protection domain을 가지고, 자원을 할당받고, user mode/kernel mode를 왔다 갔다 하면서 실행된다.
process의 context는 어떻게 구성되어 있는지 살펴보자.
(1) user space
- text(code 들어있는 곳) / data, bss (초기화된, 안된 전역 변수들) / heap / stack [argv, envp]
(2) kernel space
- user, proc (PCB에 든 정보) / stack
(3) HW
- state vector (PC, SP, flags, reg0, ...)
1.7 Daemon Process
Daemon(or Server) process를 살펴보자.
- format: a.out - 실행 파일이라는 것
- 일반적인 알고리즘
- 항상 기다리고 있다(loop).
- request 오면 service 해준다. - 보통
- user id = system
- boot 할 때 만들어짐.
- 영원히 실행됨. - mission
- system을 도와주는 것 (print, network, paging, ...)
ps -e 명령어를 입력해보자. 여러 daemon process들이 보일 것이다. 이름 뒤에 보통 d가 붙어 있으니 알아보기 쉽다.
daemon process들은 위에서 보이는 것과 같이 web server나 ftp server와 통신할 때 쓰이는 등 system을 도와주는 일을 하는데 그렇다면 kernel 안에 넣지 않은 이유는 무엇일까? 바로 size와 flexible의 문제 때문이다.