[OS] 6. Mechanism: Limited Direct Execution

급식·2022년 3월 29일
0

OSTEP

목록 보기
5/24
post-thumbnail

글 쓰는 지금 18장 Paging까지 강의를 들었는데... 진짜 너무너무 어렵다. 큰일났다.
일단 지금 진도를 따라잡아 놔야 좀 더 빠릿빠릿하게 공부할 것 같으니 부지런히 출간해야겠다.


OS는 여러 프로세스들이 동시에 실행되는 것처럼 보이도록 하기 위해 물리적인 CPU를 공유하도록 지원한다. 그리고.. 3, 4, 5강에 걸쳐서 은근히 얘기했듯이, OS는 이걸 어떤 프로세스한테 잠깐 동안 CPU의 사용권을 넘겨줬다가, 시간이 좀 지나면 뺏어서 다른 CPU에게 주고, .. 하는 Time Sharing(시분할) 방식으로 CPU의 가상화를 구현할 수 있다.

음, 일단 말은 그럴듯 한데, 두 가지 문제점이 있다.

첫째로, 각각의 프로세스에게 CPU의 사용권을 줬다가, 뺏었다가 하는 Context switching은 공짜로 뚝딱~! 하고 되는 일이 아니다. 이 작업에도 마찬가지로 비용이 들기 때문에, 오버헤드를 최소화하는 방향으로 메커니즘을 설계해야 한다(Performance).

둘째로, CPU의 제어에 관한 문제이다. 만약 OS가 CPU의 Time sharing 메커니즘에 대한 제어권을 상실하면? 말도 안되는 어떤 프로세스가 CPU를 독점해 자기 혼자만 자원을 모두 끌어다 쓸지도 모른다(Control).

그럼 이 두가지 문제를 해결해줄 수 있는 메커니즘은 어떤 요소를 만족해야 할지 하나씩 살펴보자!


6.1. Basic Technique: Limited Direct Execution

LDE(Limited Direct Execution)은 OS 개발자들이 프로그램을 빠르게 실행하기 위해 개발한 기법으로, 여기서 "Direct execution"이란 CPU의 PC가 그냥 프로그램의 특정 부분을 직접 가리켜 실행시켜버리는 것을 의미한다고 한다.

일단 Direct execution부터 살펴보자.
위 예제에 의하면, (아직까지는 아무 제한이 없는) Direct execution은 다음과 같은 프로토콜로 실행된다.

  1. 우선 프로세스 리스트에 프로세스를 생성하고,
  2. 필요한 메모리를 할당하고,
  3. 프로그램 코드를 디스크에서 메모리로 올리고,
  4. entry point(C의 main 함수라던지)를 찾아 (= PC 값을 main 함수의 맨 앞으로 설정)
  5. 사용자가 작성한 코드를 실행하기 시작한다.

여기서 "Direct" 하다는 것은, 프로그램을 한 번 entry point부터 실행시켜 놓으면, 프로세스가 알아서 종료될 때까지 쭈욱 실행됨을 의미한다.

즉, Performance 측면에서야 context switching 없이 프로그램을 그냥 쭉 실행시키는 것 뿐이니 더 얘기할게 없는 대신 Control 측면에서 문제가 있는데, OS가 중간에 끼어들 틈이 없으니 프로세스가 딴짓을 하고 있는지 알 수도 없고, 이번 대단원의 목표인 CPU 가상화(Time sharing) 역시 불가능하기 때문이다!

이래서 똑똑한 OS가 필요한 것이다. 그리고 똑똑한 OS는 프로세스에 휘둘리는게 아니라, 프로세스를 제어할 수 있어야 한다. LDE에서 방금 빼놓고 얘기한 "Limited"는 이를 위해 프로세스가 제한적으로 활동할(?) 수 있도록 할 수 있도록 해야 한다는 것을 암시한다.


6.2. Problem #1: Restricted Operations

Direct execution은 context switching이니 뭐니, 그런거 신경 안쓰고 CPU를 원하는 만큼 직접 끌어다 쓸 수 있으니 당연히 빠르게 실행될 수밖에 없다.

그러나 CPU가 디스크에 I/O 요청을 하거나, CPU 또는 메모리와 같은 자원에 추가적인 접근을 요청한다면 어떻게 될까?

당연~히~! OS가 그냥 허수아비가 되어 버리는 것이다. 파일에 권한을 두어 OS가 파일에 대한 접근을 제한한다고 하더라도, 어쩔방구! OS가 끼어들 새가 없으니 그냥 디스크 전체를 읽고 쓸 수도 있다. 그냥 방치해서는 안된다!

이 때문에 OS는 user mode와 kernel mode가 도입되었다.

  • User mode
    아까 얘기한 디스크 I/O 요청이나, 자원 추가 요청 같은 작업을 수행할 수 없는, 달리 말하자면 제한된 기능만 수행할 수 있는 모드를 의미한다. 만약 User mode에서 이런 '월권'을 하려고 하면, 예외가 발생해 OS는 그냥 프로세스를 kill 해버리는 등의 조치를 취할 수 있다.
  • Kernel mode
    OS가 여러 기능들을 수행하기 위한 모드로써, 방금 얘기한 I/O 요청과 같이 user mode에선 제한된 모든 작업(Previliged)들을 수행할 수 있다.

좋다. 이렇게 코드 수준에서, 작업 수준에서 실행 권한을 나눠놓으면 OS가 아닌 프로세스가 함부로 다른 파일에 적절치 못하게 접근한다던가, 자원을 다 먹어버린다던가 하는 불상사는 일어나지 않을 것 같다.


그런데.. 열심히 틀어 막아놓고 나니까 내가 나갈 구멍이 없어졌다. 만약 사용자가 작성한 프로그램이 진짜 디스크 I/O 요청을 필요로 한다면,,? 만약 이런 기능이 user mode에서 완전히 막혀 있다면 우리가 유용하게 쓰고 있는 파일 입출력 기능을 아예 사용할 수 없없을 것이다.

그래서 필요한게 바로 이전 강의에서 배운 System call이다. 그래 뭐, 필요하다니 허락은 해주는데, OS의 관리 하에 수행할 수 있도록 제한적으로 내어준 것이다.

System call을 실행하려면, 프로그램은 반드시 trap이라는 특수한 명령어를 실행해야 한다. 이 명령어는 커널 안으로 점프! 함과 동시에 커널 모드로 전환시켜주는 역할을 한다. 커널 내부에서 커널 모드를 획득했으니, 이제 어떤 명령이든 다 처리할 수 있게 된 것이다.

요청한 작업을 마치고 나면, 이번엔 OS가 return-from-trap 이라는 특수한 명령어를 호출한다. 딱 이름만 봐도 느낌이 오는데, System call을 호출한 사용자 프로그램으로 다시 되돌아감과 동시에 유저 모드로 전환시켜주는 역할을 한다.


여기까지 얕게만 생각해보면 무지 간단하다! 사용자 프로세스가 알아서 민감한 작업을 수행하도록 하는 것이 아니라, OS에게 간접적으로 요청해 제한적으로 이를 수행하도록 한다는 것이다.

그런데 세상살이가 그렇게 쉽지만은 않다. 이것도 그냥 뚝딱~! 하고 커널 모드로 진입했다가 다시 마법처럼 복구되는 것이 아니라, OS가 return-from-trap 명령을 실행했을 때 System call을 호출했을 당시의 상태 그대로 돌아가기 위해 System call을 호출한 프로세스의 레지스터 값들을 각 프로세스의 kernel stack 등에 저장해놨다가, 반환됨과 동시에 상태의 복원을 위해 다시 Pop을 해주는 등의 작업을 추가적으로 해주어야 한다.

또 한가지 짚고 넘어가야 할 것이 있는데, trap 명령어가 실행되었을 때 실행할 코드가 OS의 어디에 위치해 있는지 알 수 있는 방법이 없다는 것이다.

아니, 있다고 하더라도 사용자의 프로세스가 'I/O 요청 기능을 수행하는 A 지점의 명령을 실행하겠다'고 명시할 수 있다는 것은 예를 들어 '프로세스에 자원을 더 할당하는 B 지점의 명령을 실행하겠다'고 명시할 수도 있다는 의미이기 때문에, 다른 방법을 생각해야만 한다.

그래서 OS의 알맹이인 커널은 부팅시(메모리에 올라갈 때) Trap table을 만들어 놓는다.
컴퓨터가 부팅될 때에는 커널 모드에서 작동하기 때문에 하드웨어를 원하는 대로 조작할 수도 있는데, 이때 OS가 특정 예외 상황(hard disk interrupt, keyboard interrupt, system call, ...)이 발생했을 때 실행할 코드들을 하드웨어에게 미리 알려줌으로써 trap을 처리할 명령어들의 위치를 외부(사용자 프로세스)에 노출시키지 않을 수 있다. (좀 더 정확히 말하자면, OS가 부팅시에 특정 privileged 명령어를 사용해 하드웨어에게 Trap handler의 위치를 알려주는 것이다.)

이 준비 과정이 끝나면, HW는 예외가 발생했을 때 jump table의 시작 주소와 예외 상황별로 지정된 offset을 통해 바로 Trap handler에 접근할 수 있게 된다. 즉, 예외가 발생했을 때 앞에서부터 어떤 trap handler로 이동해야 할지 다 찾아보는 O(N)이 아니라, 임의 접근이 가능한 O(1)이라고 한다.


이 LDE의 준비 과정과 실제 LDE 매커니즘의 작동 예시를 위와 같이 표현할 수 있는데, 크게 두 단계로 나뉜다. 다시 정리해보자!


일단 컴퓨터가 부팅되면 커널 모드이므로, trap table을 만들어 예외가 발생했을 때 하드웨어가 각각의 예외 별로 어떤 코드를 실행해야 할지, 그 위치를 짝지어 알려준다.

이렇게 OS가 프로세스 실행 준비를 끝내 놓으면 프로세스가 열심히 실행을 하다가 disk I/O request 같은 privileged operation을 사용하고 싶을 때 System call을 통해 trap된다.

이때 아까 언급했듯이 privileged operation이 끝난 후 프로세스를 호출 직후의 상태로 온전히 복구할 수 있어야 하기 때문에, 해당 프로세스에게 주어진 커널 스택에 레지스터를 저장하고, 커널 모드로 전환되어, 하드웨어를 통해 간접적으로 trap handler의 위치로 이동한다.

교수님께서는 PC값이 갑자기 이상한 곳으로 팍!하고 튀는 것과 비슷하다고 하셨다. 그도 그럴것이 jump 같은 특수한 상황을 제외하면 PC값이 줄줄이 뒤쪽으로 이동했왔을 것인데, CPU 입장에서는 HW에 의해 웬 엉뚱한 곳으로 가서 명령어를 읽는 격이니까..

아, 이것도 그냥 넘어갈뻔 했는데 맨 처음 OS가 프로세스를 생성하여 entry point로 진입하기 이전의 초기화 작업 역시 OS가 수행하는 privileged operation의 영역이기 때문에 마찬가지로 준비가 완료되면 return-from-trap 명령어를 실행해 커널 모드에서 유저 모드로 전환되며 CPU로 레지스터 값을 복원(?)한다.

여하튼, 적절한 Trap handler에 의해 요청된 작업을 다 수행하고 나면 아까 커널 스택에 push 해놓은 레지스터 정보를 다시 pop해 읽어들여 작업을 재개한다. 물론 커널 모드가 유저 모드로 전환되는 과정 역시 반드시 수행되는 것이고. (trap handling에는 kernel stack push/pop이 필요하다는 것을 꼭! 잘! 기억해 두어야 한다.)

이후 이런 저런 작업을 하다가 프로세스가 종료될 때 마지막으로 호출하는 exit() 역시 System call이므로 trap 되는데, 이 시점에 OS가 프로세스의 뒷정리를 하며 프로세스의 생명 주기가 끝나게 되는 것이다. 와!


6.3. Problem #2: Switching Between Processes

6.2절에서 권한이 필요한 민감한 작업을 trap을 통해 제한적으로 실행할 수 있도록 하여 문제를 해결했다.

남은 두 번째 문제는 프로세스 간의 전환이 아직 불가능하다는 것이다.
왜냐? 멀리 갈 것도 없이 위의 예시만 봐도 프로세스가 System call을 호출하기 전까지는 OS가 여기에 간섭할 수가 없다. CPU를 죄다 사용자 프로세스가 쥐고 있으니까.

이를 어떻게 해결할 수 있을까?


A Cooperative Approach: Wait For System Calls

말이 협조적인(cooperative) 방식이지, 되게 순진한 방식같다.
이 방식은 OS가 각각의 프로세스가 '합리적'으로 행동할 것이라고 믿는다.

즉, 자기가 너무 CPU를 오래 쥐고 있었다 싶으면 알아서 다른 프로세스가 CPU를 사용할 수 있도록 알아서 내려 놓을 것이라고 가정한다는 것인데.. 만약 악의적으로 CPU를 안내려놓고 계속 쥐고만 있도록 프로그래밍 한다면? 아니면 실수로 무한 루프를 만들어 버린다면? 너무 수동적이다. 아니 수동적이다 못해 방관에 가깝다고 본다.

흠흠 여하튼, 모든 프로세스가 그런 이상적인 프로그램이라고 치자. 그럼 어떤 방법으로 CPU를 내려 놓을 수 있을까?

첫째로 위에서 배웠듯이 System call을 호출해주면 CPU의 제어권이 일단 OS로 넘어가는데, 보통 널리 제공되는 yield라는 system call을 OS에게 CPU의 제어권을 바로 넘기는데 사용한다고 한다.

또 다른 시나리오로, Zero Division이라던지, 접근할 수 없는 메모리에 접근하려고 하는 상황에서 trap이 발생해 OS로 CPU의 제어권이 넘어갈 수도 있다. 이건 HW에 Trap table로 저장되어 있기 때문에 가능하다.

너무 순진한 방식이기 때문에, 더 볼 것도 없이 pass!


A Non-Cooperative Approach: The OS Takes Control

보다시피 추가적인 조치가 없는 Cooperative Approach에서는 System call 말고는 HW의 도움 없이 소프트웨어적으로 할 수 있는 일이 거의 없다. 그냥 재부팅 해버리는 것 말고는.

그래서 이걸 타이머 장치를 사용한 Timer interrupt를 통해 해결하는 방법이 무려 59년 전에 고안되었다(McC+63).
타이머 핸들러는 딱 두 가지 기능을 수행하는데, 시간을 재다가(1), 일정 주기로 interrupt를 발생시킨다(2). 이 timer interrupt를 프로세서가 감지하면 현재 실행중이던 프로세스는 중단되고, OS의 interrupt handler가 실행되어 OS가 다시 CPU의 제어권을 얻게 되는 것이다.

이 timer interrupt에 의한 interrupt handler 역시 커널이 부팅시 HW에 Trap handler로써 등록해 두었기 때문에 가능한 것인데, 특정 privileged 명령어를 통해 잠깐 이 타이머 기능을 꺼둘 수도 있다고 한다(Concurrency에 대해 배울 때 다시 나온다고 한다).

그럼 같은 맥락에서 이를 timer interrupt에 의해 OS로 프로세스가 trap 되는 것으로 이해하면, 마찬가지로 trap시 레지스터의 상태를 저장해 두었다가 return-from-trap시 다시 상태를 복구할 수 있는 기능 또한 지원되어야 한다.


Saving and Restoring Context

아까부터 레지스터의 상태를 저장한다느니, 복원한다느니 했는데, 이 과정을 좀 더 자세히 들여다 보자.

강제로 뺏든, 알아서 내려 놓든, 어떤 계기에 의해 프로세스가 CPU의 제어권을 내려 놓으면 OS의 Scheduler가 지금 실행하고 있던 프로세스를 계속 실행할 것인지, 아니면 다른 프로세스에게 제어권을 넘겨줄지 결정하게 된다.

안넘겨주기로 했으면 뭐. 그냥 다시 쭉 실행하면 되는거고,
다른 프로세스에게 제어권을 넘겨주기로 결정했다면 현재 실행중인 프로세스의 레지스터 값을 커널 스택 같은 곳에 저장하고, 다음으로 제어권을 넘겨 받을 프로세스의 상태 값을 해당 프로세스의 커널 스택으로부터 복원하는 Context switch를 수행하면 된다. 이게 있어야 return-from-trap 명령어가 실행되었을 때 원래 실행하던, trap된 프로세스 말고 다른 프로세스로도 넘어가서 실행할 수 있다.
+) Context switch 과정에서 저장/복구되는 정보를 뭉뚱그려서 레지스터 값이라고만 써놨는데, 여기에는 General purpose register, PC, kerner stack pointer 등이 저장/복구의 대상이 된다.


Context switching에서 어떤 데이터를 저장하고, 복구하는지, 어떤 방법으로 OS는 제어권을 뺏어올 것인지 배웠으니 예제를 통해 Timer interrupt에 의해 두 프로세스가 서로 번갈아가며 실행되는 흐름을 따라가 보자.

  1. 컴퓨터가 부팅되며, OS는 trap table을 초기화 한다. 즉, HW에 Trap handler에 대한 정보가 기록된다.
  2. OS가 interrupt timer 시작을 명령하면, 타이머가 X초마다 timer interrupt를 일으키도록 작동을 시작한다.
  3. (프로세스 A B 둘 다 프로세스 생성 과정은 생략한다!)
  4. 프로세스 A가 실행되며 열심히 CPU를 쓰고 있다가, timer interrupt가 발생한다.
  5. 이때 A의 레지스터 정보를 A의 커널 스택에 넣어 놓고, 커널 모드로 진입하여 Timer interrupt handler로 trap 된다.
  6. Scheduler가 B에게 CPU 제어권을 넘기기로 한 모양이다. switch() 루틴 호출!
  7. A의 레지스터 상태를 A의 PCB에 저장하고 / B의 PCB로부터 레지스터를 복원하고 / 마지막으로 B의 커널 스택으로 전환한 후 / return-from-trap을 통해 B 프로세스로 다시 돌아간다.
  8. HW는 B의 커널 스택을 사용해 레지스터 상태를 다시 복구하고 / 사용자 모드로 전환하여 / B의 마지막으로 실행되고 있던 PC를 가리켜 프로세스 B가 실행되도록 한다.

..휴! 복잡하다!

특히 A가 timer interrupt에 의해 레지스터 값을 커널 스택에 저장해 놓고 switch 루틴에서 왜 또 PCB에 레지스터를 저장하는건지, 반대로 B가 schedule 되었을 때 레지스터 상태를 B의 PCB로부터 읽어놓고 다시 B의 커널 스택으로부터 레지스터 상태를 복구하는건지, ..그러니까 왜 같아 보이는 일을 두 번 하는 건지 많이 헷갈렸는데,
PCB에 값을 저장/복구하는 것은 context switching에서 요구되는 것이기 때문에 switch 루틴에 의해 실행된 것이고, trap에 의해 커널 스택에 값을 쓰거나 return-from-trap에 의해 커널 스택으로부터 다시 값을 읽어들이는 것은 단순히 trap handling에 의해 발생한 것이다.

즉, 만약 Scheduler가 A가 timeout된 이후에도 다음 프로세스로 A를 선택한다면 Context switching이 발생하지 않으므로 switch 루틴이 호출되지 않아 PCB에 값을 쓰고, PCB로부터 값을 읽어들이는 과정 역시 발생하지 않게 되는 것이다. (커널 스택이 PCB에 저장되는 것 역시 context 자체를 복구하기 위해 필요한 과정인 것이다!)


자신감이 좀 붙은 것도 같다. 그럼 이 switch가 어떻게 작동하는지 코드로 다시 뜯어보자.
아 참고로 루틴 이름이 switch가 아니라 swtch로 되어 있는데, switch가 너무 general한 이름이라 교수님께서 그렇게 지은 거라고 하셨다.

..어휴 자신감도 입맛도 뚝 떨어진다. 어셈블리어를 배우다 말아서 그런지 잘 안읽혀진다.
어쨌든 현재 실행중이던 프로세스의 context를 old context에 저장해주고, 다음으로 실행할 프로세스의 context를 new context로부터 읽어 오는 것 정도만 알고 넘어갈란다. 멀미나거든,,


6.4 Worried About Concurrency?

여기까진 좋은데, 만약 System call을 처리하는 동안 timer interrupt가 발생한다던가, Interrupt를 처리하고 있는데 또 다른 interrupt가 발생하는 복잡한 상황이 발생하면 어떻게 처리해야 할까?

뒤의 Concurrency 부분에서 자세히 배우겠지만, 가장 간단한 방법은 interrupt를 처리하는 동안 다른 interrupt가 발생할 수 없도록 막아 버리는 것이다.
이렇게 무식하게 막아버리면 interrupt 처리 중에 다른 interrupt가 발생하지 않는 것까지는 보장되지만, 너무 오래 막고 있게 되는 경우 interrupt를 놓쳐버릴 수도 있어 그다지 좋은 방법은 아니라고 한다. (무슨 말인지는 아직 잘 모르겠다.)

또한 OS는 내부의 데이터 구조에 여러 프로세스들이 동시에 접근하는 것을 방지하기 위한 정교한 locking 기법을 포함하고 있어 이를 통해 동시에 여러 작업들이 수행될 수 있다. (유용하지만 디버깅이 빡셔진다고 한다.)


마무리

사용자의 프로세스가 제한적으로 I/O Request 등의 민감한 작업을 제한적으로 수행할 수 있도록 하기 위한 매커니즘인 Limited Direct Execution과 context switching 등을 Trap handling 관점에서 공부했다.

처음 공부할 때에는 왜 PCB Store/Load, Kernel stack Store/Load로 두 번의 레지스터 저장/복구가 발생하는지 잘 이해가 되지 않았는데, 다시 훑어보고 나니 좀 알 것 같아서 기분이 좋다!

profile
뭐 먹고 살지.

0개의 댓글