이 글은 포스텍 김종 교수님의 컴퓨터SW시스템개론(CSED211) 강의를 기반으로 재구성한 것입니다.
이 글에서는 exceptional control flow에 대해 알아본다.
Exceptional Control Flow
processor가 시작된 순간부터 끝나는 순간까지 CPU는 연속된 instruction으로부터 한 번에 하나의 instruction을 읽고 실행한다. 이러한 순서를 CPU의 control flow라고 부른다.
control flow가 바뀌는 경우는 크게 3가지가 있는데, 그 중 2가지는 앞에서 배운 call과 return / jump와 branch이고 나머지 하나가 exceptional control flow이다.
call and return 또는 jump and branch의 경우 program state에 반응한다. 그런 만큼 system state의 변경에는 반응하지 않기 때문에 이것만으로는 좋은 프로그램을 만들 수 없다. 이러한 경우를 해결하기 위해 exceptional control flow를 사용한다.
system state의 예시로는 disk나 network adapter로부터 정보 수신, 0으로 나누기, 사용자가 ctrl+c를 입력해 프로그램을 강제종료하는 경우 등이 있다.
exceptional control flow는 컴퓨터의 모든 level에 존재한다. 하드웨어의 경우 특정 이벤트를 감지하면 해당 event handler로 control flow가 이동하며, OS의 경우 context switch를 통해 한 process가 가지고 있던 control flow를 다른 process에게 넘긴다. application의 경우 system call을 호출해 OS에게 특정 행동을 요청할 수 있고 signal을 보내 다른 process의 signal handler로 control flow를 넘길 수도 있다.
Exception
exception은 processor state의 변화인 event를 감지해 에 대해 control flow가 이동하는 것이며 주로 OS kernal로 이동한다. 예시로 0으로 나누기, arithmetic overflow, page fault 등이 있다.
exception을 감지한다면 exception table을 참조해 어떤 exception handler로 control flow를 이동할 것인지 결정한다. exception handler가 작업을 마친 후 아래 3가지 중 하나를 수행하게 되는데, 어떤 동작을 수행할지는 exception과 handler에 따라 다르다.
- I$_{current}$로 돌아오기
- I$_{next}$로 돌아가기
- 프로그램을 중단하기
Exception Table
exception table에는 각 exception을 어떻게 처리할지에 대한 handler의 주소가 있으며, exception 종류마다 고유의 exception number를 할당받는다. exception이 발생하면 exception table에서 exception number의 값을 참조해 해당 exception handler로 program counter를 이동시킨다. 즉, excpetion table은 일종의 jump table로써 작동하는 것이다.
Exception의 종류
대분류 | 중분류 | 완료 후 동작 | 예시 |
Asynchronous (비동기) 명령어 실행과 관계없음 |
Interrupt | next instruction으로 복귀 | timer interrupt, I/O interrupt |
Signal | next instruction으로 복귀 | 사용자가 직접 보냄. SIGINT 등. | |
Synchronous (동기) 명령어 실행 중 발생 |
Trap | next instruction으로 복귀 | system call |
Fault | current insturction으로 복귀 또는 프로그램 종료 | page fault, protection fault, floating point exception | |
Abort | 프로그램 종료 | parity error, machine check |
Interrupt
process 외부로부터 발생하는 exception. 명령어의 실행과는 관계없는 예외이기 때문에 asychronous exception이다. handling 이후에는 next instruction으로 복귀하며 예시로 timer interrupt나 I/O interrupt(ctrl + c 등)이 있다.
Trap
명령어 실행 중 의도적으로 발생하는 exception. 명령어 실행 도중 발생하기 때문에 synchronous exception이다. handling 이후에는 next instruction으로 복귀하며 예시로 system call 등이 있다.
system call은 function call과 유사하지만 control flow가 kernel로 옮겨지는 것만 다르다. 예시로 open file, read file, fort, exit 등이 있다.
Fault
의도적으로 발생하지 않았지만 복구 가능한 에러에 의해 발생하는 exception. 명령어 실행 도중 발생하기 때문에 synchronous exception이다. handler가 error를 고치는 것에 성공하면 current instruction으로 복귀하고, 실패하면 프로그램을 종료한다. 예시로 page fault(recoverable), protection fault(unrecoverable), floating point exception 등이 있다.
page fault의 경우 memory에 로딩되지 않은 부분에 접근하려 하면 나는 exception인데, disk로부터 memory로 load가 끝난 후에는 해당 부분에 접근할 수 있다. 이후 원래 instruction을 다시 실행하면 memory에 로딩된 부분에 접근하므로 정상적으로 수행된다.
Abort
의도적으로 발생하지 않았고, 복구 불가능한 에러에 의해 발생하는 exception. 명령어 도중 발생하기 때문에 synchronous exception이다. 복구가 불가능하기 때문에 항상 프로그램을 종료한다.
Process
process는 실행 중인 프로그램의 instance를 의미한다.
process는 다음 2가지의 핵심 abstraction을 제공하며, 이 두가지 abstraction 때문에 multiprocessing이 일어나는 것처럼 보인다.
- logical control flow : 각 program이 CPU를 독점으로 사용하는 것처럼 보인다. 그러나 실제로는 context switching이라는 메커니즘으로 동작하고 있다.
- private address space : 각 program이 main memory를 독점으로 사용하는 것처럼 보인다. 그러나 실제로는 virtual memory라는 메커니즘으로 동작하고 있다.
Multitasking
사용자 입장에서 [여러 process들이 동시에 실행된다]고 여기지만, 실제로는
위 그림과 같이 CPU는 각 program을 돌아가면서 각 program의 instruction을 실행하지만 이 간격이 매우 좁기 때문에 사람은 마치 [여러 프로그램이 동시에 돌아간다]고 인지한다. 이 때 CPU가 다른 program으로 이동하는 것을 context switching이라 하며 각 program에 저장된 register를 복구하고 address space를 바꾸는 과정을 거친다.
위 예시는 single-CPU인 경우의 예시이고 만약 CPU가 여러개라면 위 그림에서 CPU만 여러개로 늘어난 상태가 된다.
각 process는 개별적인 control flow를 가지고 있으며 실제로는 왼쪽 그림처럼 processor가 각각의 process의 control flow를 조금씩 실행한다. 그러나 사용자가 보기에는 오른쪽 그림과 같이 각각의 process가 parallel하게 실행하는 것처럼 보인다.
Context Switching
context란 process를 실행할 때 필요한 모든 정보이다. context에 들어가는 정보로는 program counter, register, user stack 등이 있다.
모든 process는 OS의 kernel에서 관리하며 모든 memory에 접근할 수 있기 때문에 context switch는 kernel을 거쳐 일어난다. 이 때 현재 process를 중지하고 다른 process를 시작하는 scheduling은 다음 과정을 거친다.
- 현재 process의 context를 저장한다.
- 실행할 process의 context를 복구한다.
- control flow를 실행할 process로 넘긴다.
이렇게 context switching은 복잡한 과정을 거치는데, 이 과정에서 발생하는 overhead보다 context switching으로 얻는 이점이 더 많기 때문에 이러한 과정을 거친다. 예를 들어 어떤 process가 특정 system call을 기다리는 상황이거나 file input 등으로 인해 더 이상 해당 process를 실행할 필요가 없는 경우에 다른 process로 control flow를 넘겨 실행할 수 있는 instruction을 실행한다. 굳이 이런 경우가 아니더라도 OS가 한 process가 너무 오래 실행되었다고 판단하면 timer interrupt가 발생해 context switch가 일어난다.
Process Control
모든 process는 양의 정수값으로 정의된 process id인 PID를 가지므로 PID로 process를 식별할 수 있다.
process는 크게 3가지 state로 나누는데,
- running : CPU에서 실행 중 / 실행 대기 중 / kernel에 의해 scheduling 된 상태
- stopped : 추가적인 notice 전까지 scheduling되지 않는 상태. STOP, SIGTSTP, SIGTTIN, SIGTTOU 등의 signal을 받으면 해당 상태로 간다.
- terminated : 영구적으로 정지된 상태. process 정지 signal을 받거나 main에서 return 또는 exit이 발생한 경우 해당 상태로 간다. 여기서 exit은 단 1번 호출되며 return하지 않는다.
Process 생성
fork() 함수를 이용해 process를 생성할 수 있다.
한편 process에는 hierarchy가 있는데, 어떤 process A가 fork()를 호출해 process B를 생성했다면 A는 B의 parent process가 되고, B는 A의 child process가 된다. 즉 fork()를 호출한 process가 parent가 되며 child process는 parent의 code, data segment, heap, shared library, user stack 등 모든 정보의 복사본을 갖는다.
fork() 함수는 1번 호출되면 2번 return한다. 1번은 생성된 child process에서 0을 리턴하고, 한 번은 parent process에서 child process의 PID를 리턴한다.
if(fork() == 0){
// ... child process logic
}
else{
// ... parent process logic
}
따라서 fork() 함수를 이용해 process를 나눌 때는 위와 같이 코드를 작성한다.
비록 process 간의 hierarchy가 있지만 모든 process는 독립적이기 때문에 무엇이 먼저 실행될지 모른다. 그래서 여러 process가 실행되는 모든 경우의 수를 따지는 process graph를 이용해 경우의 수를 따진다.
process graph 예시
void myFork(){
printf("L0\n");
fork();
printf("L1\n");
fork();
printf("Bye\n");
return;
}
위와 같은 함수가 호출되었을 때 어떤 결과들이 수행될까?
일단 제일 위의 printf("L0\n")은 무조건 먼저 실행된다. 이후 fork()가 일어나고, fork() 이후에는 parent/child proces로 나뉜다. parent process의 printf()와 fork()가 수행된 후 child process의 printf()와 fork()가 수행될 수도 있고, parent의 print() - child의 printf() - child의 fork() - parent의 fork()가 수행될 수 있다. 그 결과는 아무도 모른다!
그러나, 확실한 것은 Bye가 L1보다 먼저 출력될 수는 없다는 것이다. 이는 process graph를 topological sort 한 후 역방향인지 정방향인지를 통해 알 수 있다.
위와 같은 process graph가 있다고 하자. a 다음에 수행되는 것이 b일 때 a->b라고 하자. 그러면 a->b, b->e, e->f, b->c, c->d와 같이 표현할 수 있고 이를 topological sort하면 아래와 같은 그림이 된다. 이렇게 그렸을 때 topological sort의 정방향으로는 수행될 수 있지만 역방향(예를 들어 e->f)는 불가능하다.
Zombie Process
process가 terminate되었을 때 OS가 해당 process의 모든 자원을 바로 삭제하는 것이 아니다. 이와 같이 terminated되었지만 clean되지 않은 process를 zombie process라고 한다.
parent process가 wait()나 waitpid() 함수를 호출하는 경우 해당 process의 child process가 가지고 있는 모든 자원을 정리한다. 그러나 parent process가 child process의 자원을 정리하지 않고 종료되는 경우(wait()를 호출하지 않고 exit() 등) 해당 child process는 orphaned process가 되며, 제일 처음에 만들어지는 init process가 orphaned process의 자원을 정리한다. 그러나 init process는 컴퓨터가 꺼질 때 자원을 정리하므로 orphaned process가 가진 자원은 컴퓨터가 꺼질 때까지 메모리를 점유한다. 또는, parent process가 오래 실행되는 경우 child process가 zombie process가 된 상태로 계속 유지되므로 이 또한 메모리에 계속 남아있는다.
따라서 parent process는 child process가 terminate될 때까지 terminate되지 않아야 하며, wait() 또는 waitpid() 함수를 사용해 zombie process의 생성을 막아야 한다.
- int wait(int *child_status) : child process가 모두 terminate할 때까지 이 함수를 호출한 process를 정지시킨다.
- pid_t waitpid(pit_t pid, int *statusp, int options) : pid값을 가진 child process가 terminate할 때까지 이 함수를 호출한 process를 정지시킨다. 만약 해당 pid값을 가진 child process가 없거나 특정 signal에 의해 waitpid() 함수가 중단되었다면 -1을 리턴한다.
Process 실행
execve() 함수를 사용한다. execve() 함수는 한 번 호출되며 return하지 않는다.
execve() 함수는 현재 process context 내에서 새 process를 실행시키며 새로 만들어지는 process는 기존에 있는 process context를 모두 덮어씌운다. 때문에 기존에 있는 instruction이 모두 사라지기 때문에 보통 child process에 할당한다.
- int execve(const char *filename, const char *argv[], const char *envp[])
- filename : executable object file
- argv : 인자 list
- envp : 환경변수 list
Process Sleep
sleep() 함수와 pause() 함수를 사용한다.
- unsigned int sleep(unsigned int secs) : secs 시간이 지나면 return 0, else 남은 시간동안 해당 process를 sleep시킨다.
- int pause() : 이 함수를 호출한 process에 signal이 올 때까지 sleep시킨다.
Signal
signal은 system에서 어떠한 event가 일어났다는 것을 말해주는 작은 message이며, kernel에서 process로 보내진다. 예를 들어 어떤 process가 divide by 0을 실행하면 kernel이 SIGFPE를 process로 보낸다. (exception과 유사하다.) signal은 1에서 30정도의 정수로 식별한다.
signal을 process로 전달하는 것은 크게 2단계로 이루어진다.
- send signal : kernel이 signal을 보낼 때, 목적지 process context의 state를 일부 바꿈으로써 signal을 목적지 process로 전달한다.
- receiving signal : 목적지 process는 kernel이 해당 signal에 반응하도록 강제할 때 signal을 수신한다.(kernel mode에서 user mode로 전환될 때) 이 때 아무것도 하지 않는 ignore, process를 종료하는 terminate, 사용자가 정의한 signal handler를 실행하는 catch 3가지 action을 취할 수 있다. catch의 경우 interrupt와 유사하게 동작한다.
Send Signal
Pending & Block
kernel이 signal을 보냈지만 process가 아직 받지 않았을 때 이 signal을 pending signal이라 한다. 같은 종류의 signal은 최대 1개의 pending signal을 가질 수 있다. 즉 signal은 queueing되지 않는다! 어떤 signal이 이미 pending되었다면 해당 signal이 또 도착한 경우 버린다.
또한 process는 특정 signal을 block할 수 있다. 이 경우 해당 signal들은 unblock되기 전까지는 받지 않는다.
pending과 block은 해당 process context 내의 bit vector(bitmask)로 표기한다. (1이면 pending / block, 0이면 unpending / unblock)
Process Group
모든 process는 단 하나의 process group에 속하며 양수인 정수로 이를 식별한다.
- pid_t getpgrp(void) : 호출한 process의 group id 리턴
- int setpgid(pid_t pid, pid_t pgid) : pid에 해당하는 process의 group id를 pgid로 변경한다. 만약 pid가 0이면 해당 함수를 호출한 process의 pid가 사용되고, pgid가 0이라면 pid에 해당하는 process의 group id가 사용된다.
다양한 방법이 있다.
- Linux의 경우
- /bin/kill -9 15213 : 9번 signal SIGKILL을 process id가 15213인 process에게 보낸다.
- /bin/kill -9 -15213 : 9번 signal SIGKILL을 process group id가 15213인 process들에게 보낸다.
- keyboard 입력
- foreground process group의 모든 process에 signal이 간다.
- function call
- int kill(pid_t pid, int sig) : pid에 해당하는 process에게 sig에 해당하는 signal을 보낸다. 만약 pid가 0이면 SIGKILL signal이 가고, pid가 음수이면 pid에 해당하는 process group으로 해당 signal을 보낸다.
- unsigned int alarm(unsigned int secs) : sec초 후에 SIGALRM signal을 해당 process로 보낸다.
Receiving Signal
목적지 process는 kernel이 해당 signal에 반응하도록 강제할 때 signal을 수신한다. (kernel mode에서 user mode로 전환될 때)
exception handler에서 return되거나 context switch를 할 때와 같이 process가 kernel mode에서 user mode로 전환될 때 kernel은 unblocked pending signal을 확인한다.
- signal이 있는 경우
- kernel은 process가 작은 signal부터 수신하게 강제하며, signal handler로 flow control을 넘긴다.
- signal handler가 없다면 default action을 수행한다.
- signal이 없다면 process의 next instruction을 수행한다.
default actoin은 아래 4가지가 있다.
- process terminate
- process terminate & core dump
- SIGCONT signal에 의해 재시작할 때까지 해당 process stop
- ignore
handler_t *signal(int signum, handler_t *handler)
default action을 사용하기 싫은 경우 사용자가 직접 user defined signal handler를 만들 수도 있으며, C의 경우 위 함수의 handler를 직접 수정하 signum에 해당하는 signal을 handler 함수가 handling한다. handler에서 return문이 실행되는 경우 process로 flow control로 돌아온다. 단, handler는 main program과 concurrent하고 global data structure를 공유하기 때문에 주의깊게 사용해야 한다.
작동 방식은 아래와 같다.
Nested Signal Handler
handler는 다른 handler에 의해 interrupt될 수 있으며 이 경우 nested hanlding이 일어난다.
잘못된 내용이나 지적, 오탈자 등은 언제나 환영합니다.
'CS > OS' 카테고리의 다른 글
[컴퓨터 SW] Virtual Memory (0) | 2023.06.21 |
---|---|
[컴퓨터 SW] System Level I/O (0) | 2023.06.20 |
[컴퓨터 SW] Static Linking (0) | 2023.06.17 |
[컴퓨터 SW] Cache와 Locality (0) | 2023.06.15 |
[컴퓨터 SW] Storage - RAM & Disk (0) | 2023.06.14 |