Operating System Inside

출처 : Tong - pearlchoi님의 업무통



http://tong.nate.com/pearlchoi/31344011


Operating System Inside


Between the CA and the OS - CAOS?


컴퓨터를 전공하는 학생들에게 있어서 학부 수준에서 가장 중요한 과목중의 하나라면 단연 OS가 빠지지 않을 것입니다. 그러나 OS라는 과목이 공략하기에 쉽지 않은 과목입니다. OS를 제대로 이해하기 위해서는 Computer Architecture에 대한 충분한 이해와 컴퓨터에 대한 상당한 제반 지식이 필요한 것입니다. 그러한 이유로 이 글은 CA를 기반으로해서 OS를 이해하기 위한 basic들과 OS의 개괄적인 동작을 중점적으로 엮어보려 합니다. 아마 이 글을 읽기에 최적의 독자는 XT나 AT시절에 프로그래밍을 시작한 독자나 혹은 DOS시절 PC의 HW를 조금씩 다루기 시작한 사람이 될 것입니다. 다른 OS서적과 함께 읽는다면 도움이 되리라 생각됩니다. 어줍잖은 지식으로 제 자신의 지식 또한 시험하기 위함이오니 혹 잘못된 내용이 있다면 바로 지적해주시기 바랍니다.


저는 처음 컴퓨터를 접하면서 BASIC을 배웠고, C를 배웠습니다. C를 배웠던 책이 임인건님의 "터보C정복"이라는 책이었는데, 이런 훌륭한 책 덕분에 곧 C의 아름다움에 매료될 수 있었고, 프로그래밍의 재미에 빠져볼 수 있었습니다. 개인적으로 C에 관한한, 아니 컴퓨터에 관련된 국내서 중에서 이보다 더 훌륭한 책을 아직까지도 알지 못합니다. 그 이후에 보았던 숱한 컴퓨터 관련 국내서들이 엉터리 번역, 성의없는 번역등으로 실망만을 주었고, 우리말로 직접 집필하였다는 책도 베끼기 수준을 벗어나지 못하거나 성의없는 설명으로 일관하는등 국내서에 대한 실망감이 컸습니다. Stevens라든지 Knuth등 저자 이름이나 표지 그림 정도만으로도 "아, 그책"이라고 모두가 알고 있는 bible과 같은 위상을 차지하는 원서와 같은 책이 하나 정도 밖에 없다는 것이 (이 하나는 물론 임인건님의 책입니다.) 아쉬웠고, 제 작은 바램이 있다면, 이 작은 책이 제가 임인건님의 책을 통해 C를 쉽게 접하게 되었듯이 다른 사람들이 OS를 쉽게 이해할 수 있게 되는 매개체가 되었으면 하는 것입니다.


이 책을 읽으시면서 주의하실 점은, 용어의 혼란입니다. 하나의 용어가 여러 가지 경우에 서로 다른 개념을 가리키는 경우가 많기 때문에, 정확한 이해가 요구됩니다. 예로, segment라는 개념은 여러곳에서 나오지만, 어느것도 정확히 같은 것을 가리키고 있지는 않습니다. 어떤 것은 하드웨어적인 메모리의 범위를 나타내기도 하고, (intel CPU의 segment) 어떤 것은 소프트웨어적으로 구현된 level의 메모리 범위를 나타내기도 합니다. (linux의 VMA등) 또는 interrupt라는 용어도 주의가 요구됩니다. 어떤 책에서는 Hardware interrupt와 software interrupt(Exception)을 interrupt라고 통칭하기도 하고 다른 책에서는 엄밀히 구분하기도 합니다. buffer라는 말도 각 문맥에 따라서 구현된 level이 다른 경우가 많죠. 페이지라는 말도 physical page frame을 말하거나 혹은 virtual page를 말하기도 합니다. Thread역시 여러 레벨에서 다른 개념을 가리키는 경우가 흔합니다. 혼란의 여지가 있는 이러한 용어들의 경우 최대한 구체적으로 용어를 표현하겠지만 문맥에 따라 미묘한 차이를 가지고 있으니 이에 유의하시기 바랍니다.


또한 이책은 간간히 update되고 있기 때문에 통일성을 지키기 힘들 것같습니다. 때가 되면 정리할 수도 있겠지만 그전까지는 각 부분들의 내용들이 유기적으로 연결된 흐름을 가지기 어려울 것같습니다. 더 심각한 것은 내용의 수준이 basic에서부터 advanced까지 마구 섞여있다는 점입니다. -_-; 점점 저의 작업장 수준이 되어가고 있습니다. 이런 부분은 적당히 건너뛰시면서 읽거나 더 깊은 내용은 주어진 link들을 찾아 읽으시는게 좋을 것 같습니다.


이 책의 가장 최신판은 http://osinside.net/ 이나 혹은 제 개인 페이지인 http://abraxsus.pe.kr/ 에서 찾아보실수 있습니다.


이 문서를 개인적인 학습의 용도 이외의 용도로 사용하실 때에는 연락해주시기 바랍니다.



 



저자 이 민


email : abraxsus at yonsei.ac.kr


Copyright(C) 2003 Lee Min.


 


Last updated date : 2006/7/21


 


현재의 목차는 임시적입니다.


 



 






Contents


 



  1. OS란
  2. Computer Model
  3. Virtual Memory
  4. When memory was Not virtual
    Overlay
    Segmentation
  5. Reverse Mapping
  6. Kernel space vs User space

  7. Kernel mode vs User mode

  8. System call and API

  9. Real mode vs protected mode? Segmented?
  10. TLB & Cache
  11. Interrupt
  12. PC에서의 interrupt
  13. Interrupt vector
  14. 여러 Interrupt & exception
  15. Processes and threads
  16. Context switch
  17. Scheduler
  18. Reentrant kernel
  19. Nested kernel control path
  20. Preemptible kernel
  21. Bottom half
  22. Process의 구성
  23. Kernel memory manager
    Kernel memory allocator
    Slab allocator
  24. Synchronization #1
    Atomicity
    Bounded Buffer producer-consumer problem
    Short critical section and spinlock
    Long critical section and mutex
    spinlock vs mutex
    Bakery algorithm
    coarse-grained locking vs fine-grained locking
    Conclusion
  25. Synchronization #2
  26. Virtual address space management
  27. Page fault
  28. Demand paging
  29. COW(Copy on Write)
  30. Mapped files
  31. Swapping
  32. Page fault handler
  33. Disk cache - Page cache, buffer cache and unified cache
  34. Dynamic library
  35. Asynchronous I/O
  36. I/O Scheduler
  37. I/O
  38. Direct Memory Access (DMA)
  39. Symmetric Multiprocessor (SMP)
  40. Atomicity
  41. OS다시보기
  42. Virtual machine
     
  43. Biblography and reading list


  44. Computer Architecture
  45. Microarchitecture
  46. Microprogramming
  47. Memory model
     
  48. Appendix A - Linux


 






OS란


 


OS란 결국, 하드웨어를 총괄하면서 하드웨어간의 이질성을 끌어 안아 소프트웨어가 좀더 추상적이 될 수 있는 환경을 제공하는 근본 소프트웨어라고 할 수 있습니다. 또는 평상시에는 잠들어 있다가 Application이 필요로 하는 서비스를 제공해주는 데몬(daemon)이라고 볼 수도 있습니다. OS를 이해하기 위한 가장 핵심중의 하나는, OS가 HW위에서 application을 위한 추상 계층(layer)를 제공한다는 것입니다. 이것은 상이한 H/W들위에서 동일한 프로그램을 돌릴 수 있도록 해주는 것입니다. 이것이 당연하게 생각될 수도 있겠지만, 실제로 초창기에는 IBM등의 기업에서 H/W를 팔기위해서 해당 H/W만을 위한 S/W를, 즉 OS를 제작해주었다는 점을 생각한다면 OS와 H/W의 분리는 역사적으로 획기적인 발전이었다고 할 수 있습니다. 즉 초창기의 H/W를 팔기위해 S/W가 제작되어지는 상황이었다면 근래에는 S/W의 중요성이 날로 커지고 S/W와 H/W의 분리가 가속화되면서 S/W를 위한 H/W를 제작한다고 할 수 있습니다. 이러한 S/W중에서 그 꽃이라 할 수 있는 것이 이 OS 와 compiler입니다.



 OS 는 또한 자원관리자(resource manager)라는 관점으로 파악되기도 합니다. 이것은 모든 H/W로의 접근과 그 사용권한이 커널을 통해서만 이루어지기 때문이죠. Resource라는것은 사실상 H/W로 할수 있는 모든것을 의미합니다. CPU와 메모리부터 시작해서 H/W가 추상화되어 쓰일수 있는 모든 개념입니다. 디스크나 화일, 네트워크 등이 모두 resource로 취급되며, 이들을 잘 분배해서 나누어주는것이 커널의 일이라고 할수 있습니다. 이러한 관점에서 resource manager라고도 볼 수 있읍니다.


이러한 철저한 계층화(layered structure)는 system call이라는 것을 이용하여 구현되어 있습니다. 즉, Application은 H/W에 접근하기 위해서는 항상 OS가 제공하는 system call이라는 것을 통해야만 가능하다는 것입니다. 기존의 DOS같은 경우 이렇게 철저하게 분리되어 있지는 않았습니다. application은 BIOS 서비스와 OS가 제공하는 서비스를 모두 쓰는등 layed structure가 완전하지 못했습니다. 그러나 이제 386이후로 본격적인 OS들은 모두 이러한 계층화를 완전하게 이루고 있읍니다.



 






Computer Model


본격적인 이야기를 하기 이전에 먼저 컴퓨터라는 것에 대해서 생각해보고자 합니다. 컴퓨터는 결국 다음과 같은 간단한 모델이라고 생각할 수 있습니다.



I/O란 모니터나 키보드등의 모든 입출력과 관련된 부분이기 때문에 실제로는 CPU와 메모리만 있으면 컴퓨터라고 부를 수 있는 형태가 됩니다. 간단하지요. 이러한 모델을 computational model이라고 부릅니다. (네, 바로 튜링머신입니다. ^^;) 본질적으로 이러한 컴퓨터의 기본 구조를 폰노이만 구조라고 부르며  아시다시피 CPU는 명령이 주어지면 주어진 명령을 수행하는 프로세서의 역할을 하고, 메모리는 그러한 명령이나 연산결과등이 담기는 말 그대로의 메모리의 역할을 합니다. (CPU는 레지스터라고 하는 간단한 임시 메모리를 가지지만 이런 모델에서는 CPU는 메모리를 가지지 않는다고 가정합시다. 단순화하는거죠.) 메모리는 단순한 array라고 생각하시면 되고, 이제 실제로 이러한 모델이 어떻게 동작하는지 간단하게 살펴보면,


ADD 80번지, 20번지, 10번지


이와 같은 명령이 수행된다고 생각해보죠. 물론 ADD와 같은 instruction은 코드화되어서 메모리에 저장되어 있을 것이고, 이러한 명령들의 집합과 그 행동등은 이미 잘 정의되어 있고 CPU는 그러한 정의에 따라서 충실히 일을 수행하게끔 구현되어있습니다. 이러한 명령집합(instruction set)과 그 구체적인 행동등의 잘 정의된 내용들을 CPU Architecture라고 부릅니다. 우리가 흔히 부르는 x86이나 ARM등의 아키텍쳐가 이러한 CPU architecture의 예라고 할 수 있습니다.


주의하실 것은 이러한 아키텍쳐는 하드웨어가 아닌 단지 definition이라는 것입니다. 이러한 Architecture들은 책등으로 publish되어 있는 것뿐이고 이것을 실제 구현한 CPU는 얼마든지 다른 회사에서 만들어낼 수 있습니다. (라이센스 문제가 해결된다면 말이죠) x86이라는 아키텍쳐는 인텔에서 만들었지만 x86호환되는 CPU들은 많은 회사에서 독립적으로 만들어낸다는 얘기입니다.


위의 ADD명령의 의미가 80번지의 내용과 20번지의 내용을 더해서 10번지에 쓰는 것이라고 해봅시다. 아마 다음과 같은 동작을 하게 될 것입니다.


1) 80번지의 내용을 CPU안으로 읽어오고


2) 20번지의 내용을 CPU안으로 읽어오고


3) 둘을 더한 결과를 만들어내고


4) 10번지에서 그 결과를 써넣습니다.


사실 아무리 복잡한 현대의 컴퓨터라고 하더라도 폰노이만 형식의 컴퓨터구조는 본질적으로 위의 모델을 벗어나지 않습니다. 사실 현재의 많은 Embedded 기기들이나 오래된 구식 컴퓨터들은 거의 정확하게 이러한 모델을 따르고 있었습니다. 다만 현대의 컴퓨터들은 이러한 모델이 여러번의 추상화를 거쳐 virtualization을 제공한다는 것 때문에 복잡하게 느껴지는 것일뿐 application level에서는 아직도 여전히 이런 간단한 computer model을 제공합니다. 예를 들어 hello.c를 컴파일하고 실행하는 것은 여전히 위의 모델로 쉽게 이해되어질 수 있습니다. 그러나 application level의 프로그래밍과 달리 OS level에서의 프로그래밍이 어려운 이유는 OS가 제공하는 virtualization들을 모두 이해하고 그 메카니즘을 알아야 하기 때문입니다.


현재 가장 중요한 virtualization은 밑의 4가지정도로 생각할 수 있습니다.


1) Virtual CPU


2) Virtual Memory


3) Virtual File System


4) Virtual Machine


4번 Virtual machine을 제외한 3가지 virtualization은 모두 OS가 제공하는 것들이고 이러한 virtualization위에서 application level은 마치 위의 간단했던 computer model을 자신이 하나 가지고 있는 것처럼 편하게 프로그램되고 수행될수 있는 것입니다. 간단하게 설명하자면 Virtual CPU란 1개의 CPU를 마치 여러개의 CPU가 있는 것처럼 쓸 수 있다는 것입니다. 즉 multitasking을 말합니다. 우리가 가진 컴퓨터가 여러개의 창을 띄우고 여러개의 process가 수행될수 있는 것은 이 기능 때문입니다. 즉 개개의 프로그램은 마치 자신이 CPU를 모두 독점하고 있다고 생각할 수 있고 그런 가정에서 프로그램될 수 있는 것입니다. 이런 기능이 없다면 application을 짜는 사람은 얼만큼 실행한후 다음 프로세스에게 CPU를 넘겨준다라고 하는 일들을 손수 해주었어야 할 것입니다. 여기에 Virtual Memory는 프로세스가 마치 메모리 전체를 자기가 혼자 쓰고 있다고 생각할 수 있게끔 만들어줍니다. 만약 이런 Virtual Memory가 없었다면 역시 Application을 프로그램할 때는 어디서 어디까지의 구역은 자신이 쓸테니 그외의 다른 구역은 침범하지 않아야 한다는등의 규칙들을 지켜주어야 할 것입니다. 이런 복잡함을 Application이 신경쓰지 않고도 프로그램할 수 있게된 것이 이런 Virtualization의 목적입니다. 결국 CPU와 Memory에 대해서 프로세스는 위의 모델을 그대로 유지할 수 있게되고 Application은 마치 자신이 독립적인 하나의 컴퓨터 위에서 실행되고 있다고 생각할 수 있는 것입니다.


그러나 I/O의 경우는 좀 문제가 될 수 있습니다. CPU와 메모리는 간단하고 그 특성이 정해져있는 간단한 component라고 할 수 있지만 I/O는 그 특성상 복잡하고 미묘한 문제들이 많이 섞여있어서 다른 방식으로 추상화합니다. 물론 역시 OS가 이러한 추상화를 제공하며 모든 I/O들은 커널을 통해서만이 이루어지게 됩니다. 이것은 이후에 자세하게 다룰 것입니다.


이와 같이 실행의 단위인 process는 OS가 제공하는 virtualization을 통해서 여전히 위와 같은 간단한 모델을 유지할 수 있게 되고 이로써 프로그램이 단순해집니다. 이 모든 서비스를 process에게 제공해주는 것이 바로 OS입니다. 그러한 OS중에서도 핵심적인 부분들을 커널(kernel)이라고 부릅니다.


 






Virtual Memory


 


VM이라는 기법은 아마도 Computer Architecture에 있어서 기념비적인 혁신일 것입니다. OS를 공부하기 앞서서 VM에 대한 충분한 이해가 필수적입니다. 만일 아직도 DOS시절의 XMS, EMS등의 메모리 관리자가 이제 더 이상 쓰이지 않는 이유를 모르신다면, 또는 Vitrual address space와 Physical address space를 구별할 줄 모르신다면 아직 OS책을 펼치기에는 부족합니다. 따라서 이 문서에서는 VM이전과 VM이후에 대한 비교를 자주 하게 될 것입니다. 주로 intel과 linux를 대상으로 설명할 것이기 때문에, "VM이전"은 "386이전" 이라는 말로, "VM이후"는 "386이후"라고 표현될 것입니다. VM이전과 이후를 이렇게 구분하는 이유는, 사실상 VM의 도입 여부가 현대적 CPU인가 아닌가의 판단 기준이 되기 때문입니다. 따라서, "386이후"라는 표현은 "VM이후", 즉 현대적CPU라는 뜻으로 이해될 수 있읍니다.


VM은 1950년대 메모리의 부족, 즉 실행 화일이 메모리보다 더 큰 문제,(이를 해결하기 위해 overlay가 등장하지만 문제가 많았습니다.) 그리고 multiprogramming에 따르는 job들간의 protection의 문제등을 해결하기 위해서 등장했습니다. VM은 이러한 문제에 대한 훌륭한 해법이 되었고, 1960년대에 상업용 OS들 사이에서 널리 쓰이게 됩니다. 이후 thrashing이라는 문제점에 대해서 1970년대 후반 working-set을 이용한 해결책이 나오게 됩니다. 또한 캐쉬가 개발되면서 VM은 CA에 있어서 표준으로 자리잡게 됩니다. (Peter J. Denning 의 "Before memory was virtual" 참조)


VM의 기본적인 concept는 "virtual address"와 "physical address"의 분리입니다. 즉, 10번지의 내용물과 20번지의 내용물을 더해서 30번지에 넣으라는 instruction에 대해서 기존에는 10,20,30이라는 주소는 메모리의 실제 주소(physical address)였다면, VM은 10,20,30이 가상 주소(virtual address)입니다. 따라서 실제로 메모리의 어느 지점의 내용물들이 사용될런지는 이것만으로는 알 길이 없습니다. 따라서 VM을 구현하기 위해서는 MMU(Memory management unit)이라는 CPU내의 특수한 하드웨어가 필요합니다. 이 unit에 의해서 10,20,30이라는 physical address는 100,200,300 따위의 실제 주소(physical address)로 변환됩니다. 이러한 변환과정(mapping)은 매우 중요합니다. 아시다시피, 그렇지 않아도 빠른 CPU를 따라오지 못하는 Memory의 속도가 문제가 되는 시점(Von Neuman bottleneck)에서, 1번의 메모리 참조(reference)를 매번 이와 같은 변환 과정을 거쳐서 참조해야 한다는 것은 막대한 성능의 저하를 초래할 것이기 때문입니다. 그렇다면 이러한 성능의 저하를 감수하고라도 VM기능을 이용할 필요가 있는 것인가? 그렇습니다. 그에 따르는 수많은 장점들이 있기에 현대 CPU가 대부분 이를 사용하겠지요. 그렇다면, 이러한 mapping과정의 부하를 최대한으로 줄이는 것이 관건이 됩니다. 이를 위해 사용되는 것이 TLB(Translation Look-aside Buffer)입니다.


이러한 VM의 강력함은 그 부수적인 효과에서도 대단한 변화를 몰고 왔습니다. 즉, VM으로 인하여 각 process는 자신만의 4GB라는 거대한 address space를 가지게 된 것입니다. 이 space는 다른 process에게서는 보이지 않기 때문에 자신만의 공간이며, 4GB라는 풍족한 address space를 십분 활용하여 이전에는 생각하지 못했던 일들을 할 수 있습니다. 즉 남는 address space를 어떻게 physical space에 연결(mapping)시키느냐에 따라서 다양한 활용이 가능한것입니다.



Linux에서 init process의 memory map입니다. 첫 번째 컬럼의 0804800지점에 init 의 실행화일이 올라와 있는 것을 볼 수 있습니다. 그외에도 ld-2.3.2.so 나 libc-2.3.2.so 같은 image(실행화일)들이 올라와 있습니다. 이와 같이 4G라는 주소공간(address space)가 바로 virtual memory address입니다. 제가 실제 메모리를 4GB씩이나 가지고 있을 리가 없으니 말입니다. :-P 이것으로부터 알 수 있는 것이 init이라는 image는 ld 와 libc라는 또 다른 image들을 사용하고 있다는 점입니다. ld는 dynamic linker입니다. 즉, 공통으로 사용되는 libc를 init에서 사용하는데, 이 library를 동적으로 loading해주는 것이 ld라는 linker입니다. 이 ld 는 일반적으로 compiling에 사용되는 static linker이기도 하지만, 동시에 dynamic linker로도 쓰입니다. 여기서 알 수 있는 것이 dynamic library라는 또 다른 특징입니다. 이 간단한 화면으로도 많은 것을 이야기할 수 있습니다. 뒷부분에서 다시 살펴보게 될 것입니다.


 



(From intel manual)


위의 그림은 intel에서의 virtual address (intel architecture에서는 linear address라고 부릅니다)를 physical address로 변환하는 과정을 보여주는 그림입니다. virtual address는 3부분으로 나뉘는데, 가장 뒤 12비트는 offset으로서 아무런 변환도 거치지 않습니다. 앞의 10비트는 page directory에서의 index를 나타내는 부분으로 쓰이고, 중간의 10비트는 page table에서의 index를 나타내는 부분으로 쓰입니다. 또한, CPU내에는 page directory를 가리킬 하나의 레지스터가 필요합니다. intel에서는 CR3라는 레지스터가 있어, 이 레지스터가 Page directory의 주소를 가지고 있게 됩니다. context switching이 일어나서 다른 process의 virtual address space로 전환하려면 이 CR3의 내용을 해당 process의 page directory의 주소로 넣어줌으로써 각 virtual address space간의 전환을 하게 됩니다.


아시다피시, physical memory는 모두 4KB의 단위의 page로 구성되었다고 생각하고, 이러한 page단위로 접근하기 때문에, 모든 단위는 page로 이루어지는 것이 좋습니다. 따라서 위의 page table과 page directory는 모두 1 page를 차지하게 됩니다. 또한 각 entry는 4byte로 이루어지기 때문에, 자연히 1개의 page는 (위에서 각 page directory와 page table은) 1024개의 entry를 가지게 됩니다. offset은 변환이 완료된 physical page안에서의 offset만을 나타내기 때문에 아무런 변환이 없이 사용될 수 있습니다. 이제 하나의 메모리 참조를 하기 위해서는 CR3가 가리키는 페이지에서 virtual address의 앞 10비트를 index로서 사용해서 해당하는 entry를 참조합니다. 10비트이기 때문에 정확히 1024개의 entry를 cover하게 되는 것입니다. 이렇게 얻은 4byte자리 entry는 다시 다음 page table로의 base address를 제공하게 됩니다. 이때 다시 중간의 10bit를 index로서 사용하여, 역시 10bit이기 때문에 1024개의 entry를 cover하게 되고, 이제 page-table entry를 얻게 됩니다. 이때 나오는 page-table entry가 비로소 physical page의 물리적 주소를 제공하게 됩니다. 이제 이 주소에 원래 virtual address의 마지막 12bit를 합쳐주면 최종적인 physical address를 얻게 됩니다. 이러한 과정은 다음과 같은 2-level tree로서 구성해서 이해할 수 있습니다.



이 그림에서 보듯이 CR3를 root로 해서 tree구조를 형성하고 있습니다. CR3를 제외한 하나의 사각형은 모두 4KB짜리 페이지를 나타냅니다. 따라서 하나의 사각형당 최대 1024개의 화살표를 가질 수 있습니다. 위의 그림에서 page directory에서부터 각 level에서 virtual address의 각 10비트씩을 index로 사용하여 최종 단계에 이르러 (여기서는 page table) 실제 physical page의 주소를 얻게 됩니다. 이렇게 얻은 주소에 12bit의 offset을 합치면 physical address가 됩니다. 여기서 사각형 안에 있는 번호는 physical page number임을 주의하시기 바랍니다. 실제 개념도는 tree일지라도 page directory와 page table등은 모두 실제 메모리를 차지하는 하나의 page이기 때문에 실제로는 각 번호대로 일렬로 그려야 할 것입니다. 이러한 실제 메모리에 대한 그림을 뒤에 넣었으니 참조하시기 바랍니다.


계산을 좀 해보면, 하나의 page table은 1024개의 entry를 가지고, 한 개의 entry가 한 개의 page를 가리키기 때문에, 하나의 page table은 1024개의 page를 가리킬 수 있습니다. 역시 한 개의 page directory에 의해서 1024개의 page table을 가리키기 때문에, 하나의 page directory는 총 1024*1024개의 page를 가리킬 수 있게 됩니다. 이는 곧 4GB의 공간을 나타낼 수 있다는 것입니다. 그러나, 실제로 이 모든 mapping을 한다면, page directory와 page table을 위해서 1025개의 page를 소모하는 꼴이 됩니다. 이것은 대략 4MB의 용량입니다. 하나의 process가 이 mapping을 위해서 4MB씩을 소모할 수는 없는 노릇입니다. 당연히 이 mapping은 필요로 하는 부분만을 mapping하여 사용하게 됩니다. 위의 예에서 12번 물리 페이지를 사용하는 page directory는 4개의 화살표만을 가지고 있습니다. 이것은 곧, 5번째 이후의 entry들은 null일테고, mapping이 존재하지 않는다는 뜻입니다. 이말은 즉, 해당 virtual address에 대한 virtual address space가 존재하지 않는다는 것입니다. 한 개의 page directory entry는 1개의 page table에 대응하고, 하나의 page table은 4MB를 커버하기 때문에, 4개의 화살표는 0~16MB의 공간을 뜻합니다. 따라서, 위의 mapping에서는 16MB까지만의 virtual address가 valid한 것입니다. 사실, 좀더 정확히 얘기하자면, page table에서도 모든 화살표가 있는 것이 아니기 때문에, 화살표가 있는 부분만이 valid한 address space라고 할 수 있습니다. 그렇다면 invalid한 address space로 접근하게 되면 어떻게 될까요? 이런 경우에 page fault가 발생합니다.


또한 page table에서 실제 page로의 mapping이 임의의 방식대로 이루어질 수 있음에 주목하시기 바랍니다. 즉, 화살표가 아무런(임의의) physical page를 가리킬 수 있습니다. 이는 곧, contiguous한 virtual memory space가 실제로 physical memory에서는 아무렇게나 흩어질 수 있음을 뜻합니다. 반대로 physical memory에서 continuous한 영역이 virtual memory에서는 아무렇게나 흩어져있을 수 있습니다.


이와 같이 virtual address와 physical address를 mapping하는 작업은 공짜가 아닙니다. mapping이 많아질수록 물리 메모리를 많이 소비하게 되는 것입니다. 위에서는 8번 page가 현재 free page이기 때문에, 만약 process가 더 많은 virtual address를 요구하면, 커널은 이 8번 page를 추가적인 page table로 할당하여 mapping을 늘릴 수 있습니다. (brk와 sbrk시스템콜관련) 이와 같이, 커널은 전체적인 비어있는 페이지들을 관리하고, 할당할 필요가 있습니다. 이러한 것을 physical memory management라고 할 수 있습니다. linux에서는 대표적으로 이러한 관리를 buddy system을 이용해서 하고 있습니다.


 



 


물론 이러한 mapping을 각 process마다 하나씩 가지고 있습니다. 즉, 각 process는 독립적인 virtual address space를 가집니다. 예를 들어 위처럼 또하나의 process가 20,22,23,24번 page를 이용한 또다른 virtual address space를 가질 때, 하나의 physical page는 이 process들간에서 공유될 수도 있습니다. 위에서 1,6,7번 page는 공유되고 있는 page입니다. 이러한 shared memory는 IPC의 주요한 기법중의 하나로 활용될 수 있습니다.


 


옆의 그림에 이러한 가상주소공간의 실제적인 메모리안에서의 위치와 link관계를 나타내었습니다. page table과 page directory는 색깔로 구별하였으니, 그 의미의 차이를 꼭 구별하시기 바랍니다.


꼭 염두에 두어할 사항중 하나는, 이러한 mapping과정은 모두 옆의 그림처럼 linear한 형태의 메모리에서 일어나고 있는 과정이라는 것입니다. 앞으로 이러한 사항에 대한 구체적인 언급이 없이 "가상 주소를 mapping한다", "주소 공간을 새로 만든다/제거한다", "두 주소공간에서 하나의 physical page를 공유한다"등으로 표현하게 될 것입니다. 이러한 추상적인 표현뒤에 숨어있는 아키텍쳐의 동작을 항상 염두에 두시기 바랍니다.


 한가지 더 살펴보고자하는 것은, CR3의 역할입니다. 이 CR3는 현재 그림에서 12를 가리키고 있지만, 물론, 다른 페이지(이를테면 24번 페이지)를 가리킬 수도 있습니다. 매 instruction에서 memory reference가 일어날 때마다 MMU는 이 CR3의 내용에서부터 메모리를 찾아가기 때문에, 즉, 위의 tree구조에서 이 CR3가 root에 해당하기 때문에, 이 CR3의 값이 바뀐다는 것은 다시 말해 "가상주소공간(virtual address space)"를 변경한다는 말이 됩니다. 이것은 context switching때 반드시 이루어져야할 일중의 하나로서, 당연히 서로 다른 프로세스들은 서로 다른 주소공간을 가집니다. 즉, 현재 12번 페이지를 page directory로 가지는 A process의 10번지와 24번 페이지를 page directory로 가지는 B process의 10번지는 엄연히 실제로는 다른 공간인 것입니다. 이와 같이 CR3를 프로세스마다 하나씩 가지고 있는 page directory들 사이를 context switching때마다 multiplexing해가면서 각 process들이 서로간의 독립적인 주소공간을 소유할 수 있게 되는 것입니다.


그러한 이유로 프로세스 입장에서는 다른 프로세스의 주소공간은 구경도 못하게 되는꼴입니다. 이렇게 프로세스들을 서로간에 보호해주는 것을 "protection"이라고 합니다. 그렇다면 프로세스가 CR3를 바꾸면 되지 않느냐고요? 이 CR3는 그 중요성 때문에 kernel mode에서만 loading이 가능한 register입니다. 따라서 유저모드에서는 꼼짝없이 자신의 process공간에 갇혀있는 셈입니다.


 여기서 Thread를 생각해 봅시다. 뒤에서 보겠지만, Thread는 일반적으로 주소공간을 공유하는 process들이라고 생각할 수 있습니다. 즉, 같은 process에 속한 A라는 thread와 B라는 thread의 10번지는 동일한!! 공간인 것입니다. 이것은, context switching때 이 주소공간을 switching할 필요가 없다는 것을 뜻합니다. 즉, CR3의 값이 변할 필요는 없는 것입니다. 이러한 이유등으로 thread는 process보다 가볍다(light)고 이야기 합니다. context switching이 보다 빠르다는 이야기입니다.


또한 주소공간을 공유함으로써 얻는 큰 이득중 하나는, TLB를 flush할 필요가 없다는 것입니다. TLB란 이러한 virtual address와 physical address간의 변환을 빠르게 하기 위한 하드웨어 캐쉬입니다. 그러므로 당연히 주소공간이 바뀐다면 그 mapping이 전혀 달라지므로 TLB는 flush되어야 합니다. 그렇게 되면 context switching이후의 한동안의 memory reference는 계속 miss가 나게 되고, 이것은 상당한 성능의 감소를 가져오게 됩니다. 이와 같은 이유로도 thread가 process보다 선호될 수 있는 것입니다.


물론, 주소공간이 공유된다면 문제점도 발생합니다. synchronization이 그것입니다. 어떤 data가 두 개 이상의 실행 context에 의해서 공유된다면, 우리는 항상 그 synchronization을 고려해야 합니다. 이와 관련된 내용은 뒤에서 설명하겠습니다.


 


 


 


 


 


 


 


 


 


 


 


 


 


 


 


이와 같이 VM은 사실 현대 OS의 이해의 첫걸음이라고 할 수 있습니다. 계속되는 이후의 장에서 이해가 되지 않는 부분이 있거나 세부적인 동작을 읽기 어려울 때는 이 부분으로 돌아와서 다시 한번 읽어보시기 바랍니다.


 


i386에서의 VM에 대해서 여기 좋은 자료가 있네요. 참고하세요


http://liebmona.net/docs/kernel/memory_management.pdf


 



 






When memory was Not virtual


 


Overlay


VM의 기본적인 동작법을 살펴보았으니, 이제는 옛날 얘기가 되어 버린 VM이 없던 시절 기법을 좀 설명하겠습니다. 이를 통해서 VM의 등장 이유와 그 동기에 대해서 더 잘 이해하실수 있을 것입니다. 또한 사실상 지금은 의미없는 이야기일 수 있지만, CA를 배우는 과정으로서, 혹은 Embedded환경에서 아직도 쓰이고 있는 기법들이므로 도움이 될 수도 있을 것이라 생각합니다. 이와 더불어 old user들에게는 과거에 대한 향수를 일으킬지도 모르겠습니다. ( :-P ) 지금도 intel은 real mode에서는 여전히 메모리가 virtual이 아니기 때문에, 이러한 기법이 적용될 수 있습니다. 또한 VM가 없는 가벼운 embedded환경에서도 유용할 수 있습니다. 그럼 VM이전에는 어떻게 현재 VM로 해결하는 문제점들을 해결하였는지 살펴보겠습니다.


기본적으로 VM이 없이는 실제 메모리보다 큰 실행화일은 실행할 수 없습니다. 이것을 해결하기 위한 방법중 하나가 overlay라는 기법입니다. 고전 게임을 즐기셨던 분들이라면 디스크 1장정도에 파일하나만 들어가있는, ".ovl" 이라는 확장자의 파일을 기억하시는 분들이 있으실 것입니다. 이 파일이 overlay되는 파일들입니다. 실제 실행화일의 일부이지만, 적당한 크기대로 잘려져있는 파일입니다. overlay란 기본적으로 실행의 단계를 몇 개의 phase로 나누고, 각 phase가 진행될 때마다 메모리에 올려진 실행이미지의 일부를 바꾸어가는 방식입니다.



위와 같이 최초 실행시에는 common code와 phase1.ovl를 로딩합니다. common code는 두 phase모두에서 쓰일 코드와, overlay를 관리할, 즉, 각 phase에 맞춰서 해당 ovl화일을 load하는 driver가 존재하고, 이 driver에 의해서 phase간의 이동을 하게 됩니다. 최초에 phase1.ovl로 실행하다가 어느 시점에서 phase2 가 필요할 때 이제는 필요없어진 메모리상의 phase1.ovl 위치에 phase2.ovl을 올려서 사용합니다. 물론 이와 같은 실행을 위해서는 phase간의 구분과 한 phase에서 충분히 사용자가 오랫동안 머무른다는 등의 가정이 있어야 할 것입니다. 이러한 overlay기법은 사실 VM이냐 아니냐와는 상관없이 VM상에서도 쓸 수는 있는 기법입니다. (쓸 이유는 없겠지만 말입니다)


DOS를 써보셨다면, A드라이브로 부팅시 귀찮게도 무엇인가를 실행하고 나면 꼭 A드라이브에 command.com 이 담긴 디스크를 넣으라는 메시지를 만나보셨을 것입니다. 640KB의 한계가 있기 때문에, 어떤 파일을 실행할 때 용량이 꽤 큰 command.com (DOS에서의 command interpreter이지요)을 메모리에 여전히 남겨놓는 것은 상당한 메모리의 낭비입니다. 그만큼 실행가능한 이미지의 크기가 제한을 받기 때문입니다. 이러한 이유로, command.com 에 대한 메모리를 해제하고 application이 더 많은 메모리를 점유할 수 있도록 해줍니다. (지금의 관점에서 보면 별거 아닌 이득이지만.) 이러한 기법도 일종의 overlay기법이라고 할 수 있을 것입니다.(제 생각!)


 


Segmentation


VM이전에도 multi-programming은 존재하였습니다. (VM이후엔 주로 multitasking이라고 불리우죠) 즉 여러개의 program을 실행하는 것인데, 이때 문제는 process간 protection입니다. 서로간에 침범하여 다른 process를 망가뜨리는 일을 방지해야 합니다. VM은 각 process의 주소공간을 완전히 분리함으로써 이 문제를 해결하지만, 그렇다면 그 이전에는 어떤 방법을 썼을까요? 기본적으로 메모리를 각 process에게 나누어서 할당하는 방식을 생각할 수 있습니다. 이를 위해 고안된 것이 메모리의 특정 block을 segment로 만들어서 이 segment를 관리하는 방식입니다.



만일 2개의 editor process가 돌고 있다면, (process A, process B) 위와 같은 구성이 될 수 있습니다. 3개의 segment가 정의되고, 그중 code segment는 둘 사이에서 공유되면서, data segment만이 서로 다른 구조입니다. 각 segment는 base address라는 것이 있고, limit address가 있습니다.(limit은 segment의 크기값을 가질수도, 마지막 주소값을 가질 수도 있습니다. - intel같은 경우 크기값이 쓰입니다.) 이러한 주소값들은 각 레지스터에 저장되어 있으며, process의 전환시에 적합한 값들로 loading됩니다. 즉, 이 경우 process A에서 process B로 바뀐다면 data segment에 대한 register의 내용들이 (1500, 2000)에서 (2500,3000)으로 바뀔 것입니다. 공유되는 code segment에 대한 레지스터값들은 안 바뀌더라도 말입니다. 이러한 segmentation을 사용할 때 주소 지정방식은 단순히 linear address(즉 그냥 physical address)를 쓰는 것이 아니라, segment:offset 의 모양새로 쓰입니다. 즉, 먼저 사용될 segment를 지정한후, 해당 segment에서의 상대적인 주소(이것을 offset이라고 부릅니다.)를 이용하여 실제 주소를 만들어 냅니다. 예를 들어, 위의 code segment에서 실제 물리 주소 50번지를 가리키기 위해서는 CS:40 처럼 써야 합니다. CS는 code segment register를 의미하는 것으로, base address를 담고 있는 register입니다. 이 경우 CS는 10의 값을 가집니다. 그러면 10과 40을 더해서 50이라는 최종적인 physical address를 얻게 됩니다. 물론, code내에서 모든 주소는 offset만을 사용합니다. 프로그램의 처음에 CS 레지스터를 10으로 설정한 이후에, 예를 들어 20번지의 값과 30번지의 값을 더하여 40번지에 넣으라는 명령은, 실제로는 30번지의 값과 40번지의 값을 더해서 50번지에 넣는 행동을 합니다. 이렇듯 앞서서의 code안에 있는 주소는 offset만을 넣고, 실제로는 segment의 base address가 더하여져서 실행됩니다.


 



(from intel manual)


인텔에서의 방식입니다. 보다시피 segment descriptor라는 것이 있어서 이곳에 각 세그먼트의 정보가 들어있습니다. 여기에서 각 segment의 base address를 얻을 수 있고, 이 값이 offset (즉 프로그램에서는 그저 주소라고 생각되는)에 더해져서 최종적인 linear address가 얻어지는 것입니다.


이렇게 segment:offset의 형태로 쓰는 중요한 이유가 있습니다. 이것은 주소 바인딩(address binding)의 문제를 해결하기 위함입니다. 만일 이렇게 offset을 이용하지 않는다면, 현재 10번지에 load된 image는 process A와 B를 오갈 때마다 같은 변수에 대한 주소를 바꾸어 주어야 하는 문제가 생깁니다. 즉, A라는 변수가 process A의 변수라면 "1500+얼마" 의 위치에 있을테니, 이것을 수정하면 되지만, 이제 process B로 switch되어 다시 A라는 변수가 쓰일 때, 이것은 "process B의 변수 A"이기 때문에 실제 위치는 "2500+얼마"가 되는 것입니다. (여기서 '얼마'가 offset에 해당합니다.) 이처럼 변수A 라는 하나의 name에 process A냐 process B냐에 따라서 2개의 위치(location)이 대응되고 있습니다. 이중 어느것과 연결하느냐를 주소 바인딩(address binding)이라고 부릅니다. 이러한 binding이라는 개념은 비슷한 모습으로 여러곳에서 등장하는데, 뒷부분에서 설명하겠습니다. 여기서 이러한 문제를 '얼마'라고 하는 offset이 동일하다는 점을 이용해 1500,2500 이라는 base address만을 바꾸어 줌으로써 해결합니다. 이 base address를 register에 놓고, process에 따라서 (사실은 segment에 따라서) 변경만 해주면 각 경우에 따라 올바른 위치를 찾아갈 수 있게 되는 것입니다.


또 하나의 이점은, 메모리내에서 segment를 이동할 수도 있다는 것입니다. 즉, address binding이 base address를 사용하기 때문에 offset이 동일하게 유지되는 이상 실행도중이라도 segment를 메모리의 다른 위치로 이동할 수도 있는 것입니다. 이와 관련하여 relocatable 이라는 개념이 등장하는데, 이후에 설명하겠읍니다.



또한 protection을 위해서 각 segment는 limit값을 가집니다. segment의 범위를 벗어나는 메모리 참조를 차단하기 위함입니다. 이렇게 함으로써 segment간의 침범을 차단합니다. 위의 그림처럼, offset과 base address를 더한후에 그 값이 정당한 참조인지, 즉 segment내부에 대한 참조인지를 검사합니다. 만일 그렇지 못하다면 이것은 잘못된 참조로 exception을 발생시킵니다. (뒤에 나옵니다. 즉, 에러처리 됩니다.) 이렇게 함으로써 protection을 달성합니다. (위의 그림은 limit가 physical address일 때입니다. limit가 segment의 크기로 쓰일 때는 검사를 offset에 대하여 미리 해주는 방식이 되어야 하겠읍니다.)


 


다음은 intel CPU에서 어떻게 segmentation이 쓰일 수 있는지를 보여주는 예입니다.



(from intel manual)


 


Segmentation을 사용할 때의 문제점은, 각 segmentation의 배열입니다. 이른 바 외부 단편화(external fragmentation)이라는 것이 발생하는데, 이것은 segment들간의 위치의 문제입니다. 여러개의 segment가 physical memory에서 띄엄띄엄 위치하게 될 경우, 남는 메모리가 상당함에도 불구하고, 개개의 메모리 구간들이 너무 작아 충분히 크기가 큰 segment를 잡지 못하게 되는 상황이 벌어질 수 있습니다. 이런 경우 각 segmentation을 이동하여 가까이 붙임으로써 빈공간을 만들 수 있지만, (이런 과정을 compaction이라고 합니다.) 그 과정의 overhead도 문제가 되고, 스택과 같은 경우 segment가 자라나야 하는데, 이런 경우 문제가 더욱 심각해 집니다.


Multiprogramming에서 요구되는 protection을 제공하기 위해서 Segmentation이 쓰이고, protection도 이루어지지만, 여전히 실메모리보다 큰 이미지를 실행할 수는 없었고, 이를 위한 편법인 overlay는 사용이 가능한 제한적인 상황과 구현의 복잡성등으로 여전히 충분한 해결책이 되지는 못했습니다. 이 모든 문제점들을 해결하기 위한 멋진 해결책으로 나온 것이 VM인 것입니다.


 






Reverse Mapping


VM의 페이지 매핑을 구현하는데에는 보통 위와같이 tree구조를 사용합니다. (다른 많은 방식들도 있습니다. 공룡책보면 잘나오죠.) 이 tree구조의 문제는, reverse mapping을 구하기 어렵다는점입니다. 즉 특정한 물리 페이지가 매핑되어있는 주소공간들을 찾아내는일이 쉽지 않다는 것입니다. 이를 위해서는 별 수 없이 모든 페이지 테이블을 뒤져서 해당 물리 페이지에 매핑되어있는 entry들을 모두 찾아내야 합니다. 이것은 엄청난 overhead를 가지게 되죠. 이게 문제가 되는 것은 실제 OS에서 이런일을 해야할 필요성이 있다는 것입니다. 특정 물리 페이지를 할당해제할 때, 즉 memory allocator에게 돌려주기 위해서, (swapping을 할 때가 대표적인 예가 되겠습니다) 해당 물리 페이지의 매핑을 모두 끊어야하는 것입니다. 평소에는 문제가 없지만 메모리가 모자라 swap이 활발하게 사용되기 시작하면, swapping을 위해서 커널은 이렇게 매핑을 모조리 scan해야하고, 그렇지 않아도 시스템이 바쁜와중에 이 작업은 bursty하게 들어오게 됩니다. 이는 thrashing처럼 performance를 급격히 떨어뜨릴 수 있습니다. 이런 현상을 swapping storm이라고 합니다. 실제 이 문제는 리눅스에서 골치거리여서, 2.5대에서 이 문제를 해결하기 위한 방법들이 도입됩니다. 이를 reverse mapping (rmap)이라고 합니다.


더 자세한 내용은 다음을 참고하세요


http://www-128.ibm.com/developerworks/library/l-mem26/


http://www.uwsg.iu.edu/hypermail/linux/kernel/0306.3/1647.html


 






Kernel space vs User space


 



일반적으로 주소공간을 kernel space와 user space로 나눕니다. 이러한 kernel space는 모든 process가 공유하는 주소공간이 되는 것입니다. (리눅스의 경우 일반적으로 상위 1G를 kernel space로, 하위 3G를 user space로 나눕니다. 윈도우의 경우는 각각 2G/2G씩으로 나눕니다.) 즉, linux의 경우 상위 1G의 범위에 해당하는 address mapping을 공유하는 것입니다. 이러한 구조로 시스템이 kernel mode일 때는 두 space모두를 넘나들 수 있지만, user mode에서는 kernel space에 접근할 수 없게 됩니다. 이렇게 함으로써, system call이 일어날 때 context switch가 필요 없게 되며, kernel에서는 system call을 부른 process의 공간에 마음대로 접근할 수 있게 됩니다. 이러한 장점 외에도 context switch할 때 kernel space에 해당하는 영역에 대한 TLB는 flush할 필요가 없다는 점 때문에 TLB의 성능이 증대될 수 있다는 점도 있읍니다.


이러한 방식을 3/1 split이라고 합니다. 이것은 커널 컴파일시 option을 통해서 2/2 split등으로 바꿀수가 있읍니다.


혹은 이러한 방식이 아니라 아예 kernel space를 독립적인 address space로 만들수도 있읍니다. 이것을 separate address space 혹은 4/4 split이라고 하는데, system call때마다 TLB를 flush하며 switching을 해야하는 부담이 있읍니다. 이런 큰 부담에도 불구하고 이것을 쓰는 이유는 큰 양의 메모리를 제대로 활용하기 위해서입니다.


이런 가상주소공간은 현재 부족한 형편입니다. 3G의 user space도 shared library나 스택등으로 채워놓으면 큰 프로그램의 경우 부족해지기 일수이고, 특히 윈도우의 경우 2G의 좁은 공간안에 많은 구조들이 들어가기 때문에 이미 user space의 부족함은 일상적인 투정이 되어있습니다. (게임 엔진을 만드시는분들이 당장 부족하다며 아우성이더군요) 그러나 정작 더 큰 문제는 kernel space의 부족입니다. 불과 1G밖에 안되는 공간 때문에 실제 물리 메모리를 제대로 활용할 수 없는 상황입니다. 1G가 왜 부족할까요? 그것은 (리눅스에서) 기본적으로 이 1G안에 모든 물리 메모리들이 매핑되어 들어가야하기 때문입니다. 이것은 real mode에서 protected mode로 전환하기전에 반드시 이루어져야 하는 매핑인데, 이를 PAGE_OFFSET mapping이라고 합니다. (Linux 참조) 잘 생각해보면 모든 물리 페이지들이 1G안에 매핑되어 들어가야함을 이해할 수 있습니다. 일단 protected mode로 들어서면 이후의 모든 주소들은 virtual address이기 때문에 만약 어떤 물리 페이지에 대해서 real mode에서 매핑이 이루어지지 않은채로 protected mode로 들어섰다면 해당 물리 페이지에 접근할 수가 없을 것입니다. (아, 물론 방법이 아예 없진 않지만요, 그건 매우 번거롭고 성능을 저하시키겠죠 :-P) 따라서 Linux에서는 모든 물리 페이지들을 kernel space에 일렬로 쭉 매핑해놓고 원하는 물리 페이지에 접근하고 싶을 때엔 이 매핑을 이용해서 접근합니다. 이로써 메모리 관리가 편리해집니다. 그러나 문제는 1G라는 kernel space의 한계 때문에, 그리고 kernel space는 다른 용도로도 사용되기 때문에 실제 메모리중 대략 하위 896MB정도만이 이 매핑으로 커버됩니다. (이를 low memory라고 부릅니다.) 그 이상의 메모리는 high memory라고 부르며 이들에 접근하기 위해서는 그때그때 mapping을 만들어줘야하는 번거로움이 생기게 됩니다.


이런 문제들의 제대로된 해결책은 두말할 것도 없이 64bit으로의 이전이죠.


 


 






Kernel mode vs User mode


 


현대 CPU는 대부분 CPU의 동작 모드를 Kernel mode와 user mode로 구분합니다. (real mode와 protected mode와는 혼동하지 마시기 바랍니다.) 386이전에는 그저 부팅후 모든 instruction에 대해서 CPU는 실행을 할 뿐이지만, 386이후부터는 kernel mode인지 user mode인지에 따라서 실행될 수 있는 명령이 있고, 그렇지 않은 명령이 있습니다. kernel mode에서는 모든 instruction의 실행에 제한이 없지만, user mode에서는 특정 instruction들 (특정 register에 특정 값을 load하는, 혹은 I/O에 관련된 instruction 등)은 수행될수 없습니다. 이처럼 kernel mode에서는 막강한 권한을 가지기 때문에 특권 모드(privileged mode)라고도 합니다. 이러한 기법으로 protection을 달성하게 됩니다. 아시다시피, OS의 커널은 kernel모드에서 실행되고, 일반 process들은 user mode에서 실행됩니다. 그렇다면 kernel mode에서 user mode로의 전환는 쉽게 할 수 있겠지만, user mode에서 kernel mode로의 전환은 민감한 부분이겠지요? 그렇습니다. kernel mode로의 진입은 시스템을 완전히 장악할 수 있는 능력을 획득한다는 의미이기 때문에, 흔히들 말하는 hacking의 중요한 목적이 되는 것입니다. 그렇기 때문에 OS는 user mode에서 kernel mode로의 진입을 엄격하게 제한해야 합니다. 이런 제약에 따라서 user mode에서 실행중인 process가 kernel mode로 진입할 수 있는 유일한 길이 system call입니다.


이렇게 CPU의 동작모드를 여러단계로 나누어서 각 모드마다 권한이 제한되게 됩니다. 이런 모드들을 ring이라고도 부릅니다. 인텔에서는 4개의 ring을 제공합니다. 즉 ring 0 가 kernel mode이고, ring 3이 user mode입니다. (ring 1,2는 reserved)


 



 






System call and API


아시다시피 386이후부터는 모든 I/O와 시스템에 민감한 부분들은 모두 커널이 장악하게 있습니다. 이런 환경에서 process는 사소한 IO라도 하기위해서는 반드시 커널에게 부탁(?)을 해야 하는 입장입니다. 이러한 user mode process의 kernel에 대한 특정 서비스 요청이 시스템콜이라고 할 수 있습니다. 시스템콜은 user mode process에서 특정 인터럽트를 거는 행위로 나타납니다. 각 서비스에 대해서 미리 준비된 번호가 있고, 이 번호를 레지스터등(linux에서는 eax)에 올린후 특정한 인터럽트를 걸 게 되면, 인터럽트 매커니즘에 따라서 kernel mode로 진입하여 해당 서비스를 제공해주게 됩니다. DOS에서는 이런 경우 INT 21h를 사용하였고,(사실 DOS에서는 BIOS call과 시스템콜이 불분명하게 섞여있었죠. 리눅스에서는 BIOS콜은 생각안하셔도 됩니다.) linux에서는 인터럽트 0x80을 시스템콜을 위한 인터럽트로 사용합니다.


이러한 시스템콜은 일반적으로 C함수에서는 wrapper function에 의해서 표현됩니다. 즉, read라는 시스템콜이 있다면, 보통 거기에 대응하는 read() 함수를 C library에서 제공하고 있습니다. 따라서 C 프로그래머는 단지 이러한 wrapper function을 사용함으로써 시스템콜을 사용할 수 있게 됩니다. 그러나 반드시 이렇게 1:1로 대응하지는 않습니다. 예로, malloc(), calloc(), free() 등의 API에서 정의되고 있는 함수는 brk()등의 시스템콜을 사용하여 구현되고 있습니다. 즉, API는 여러 system call을 이용하여 더 기능을 덧붙이는 등의 과정을 거쳐서 만들어지게 됩니다. 물론 strcpy()같은 어떤 API는 시스템콜을 이용하지 않고도 구현되기도 합니다. (시스템콜을 쓰지 않기 때문에 이러한 함수들은 아무리 많이 이용해도 CPU는 커널모드가 아닌 유저모드에서만 동작하게 됩니다.) 이러한 API층은 system call 계층의 위에서 user program에게 제공되는 또다른 층이 되는 것입니다.


 



 


이 그림에서 각 계층의 모습을 잘 보여주고 있습니다. API와 system call의 차이를 좀더 잘 알기 위해서 C에서 다음과 같은 비교를 해볼 수 있을 것입니다.


























API


System call


FILE structure


fd (file descriptor)


stdin


STDIN_FILENO


stdout


STDOUT_FILENO


fread()/fwrite()


read()/write()


malloc()/free()


brk()


Buffered I/O


Unbuffered I/O


 


API와 같은 계층이 더 있음으로 해서 program입장에서는 자세한 H/W spec에 신경쓰지 않으면서도 훨씬 효율적인 I/O를 쓸 수 있게 됩니다. 예로, 화일 입출력을 위해서는 struct FILE 구조체를 이용하고 있는데, 이것은 system call과는 무관한 것입니다. 물론 내부적으로는 모두 read()나 write()를 쓰겠지만, 사용자로서는 더 편리한 fread()/fwrite()를 쓸수 있게 되는 것입니다. 그렇기 때문에 system call에서 쓰는 file descriptor는 C 의 API에서는 FILE 구조체에 해당하는 것이라고 할 수 있겠습니다. 이러한 계층이 사용자에게 편리함과 효율성을 제공하지만, 또한 가끔 이런 계층의 buffering 현상 때문에 예상하지 못했던 현상들이 나타나기도 합니다. 자세한 내용은 Stevens의 APUE를 참고하시기 바랍니다.


Linux에서 시스템콜에 대해서 자세히 알기 위해서 man syscalls를 해보십시오. 또한 소스화일의 include/asm/unistd.h 를 참고하세요.


 



 






Real mode vs protected mode? Segmented??


 


intel 의 x86계열은 애초에 kernel mode와 user mode의 구분이 없는 형태로 시작한 CPU입니다. 즉, DOS시절에 사용되던 CPU인 것입니다. VM이 없으므로 모든 memory에 직접적으로 접근할 수 있고, instruction의 실행에 아무런 제한이 없었던 것입니다. 그러나 PC가 발전하면서 다른 현대적 CPU가 모두 갖추고 있는 기능인 kernel mode와 user mode의 구분, 그리고 VM마저도 PC가 가질 필요가 생기게 되고,(memory의 빠른 증가와 낮은 CPU의 활용도(utility)등) intel에서도 이러한 기능들을 도입하게 됩니다. 286에서 부분적으로 도입된 이 기능들이 386에서 비로소 완전하게 구현되기에 이릅니다. 그러나 여전히 수많은 application과 게임들이 real mode에서 동작하고 있었고, 유저들을 놓치지 않으면서 앞으로의 발전을 보장할 수 있는 이러한 기능의 도입을 추진하기 위해서 고육지책으로 도입된 것이 real mode와 protected mode입니다. 즉, real mode란 386이전의 DOS시절의, 단지 빠르기만한 8086으로서 동작하는 모드라면, protected mode란 VM와 kernel/user mode등의 기능들이 작동하는 mode인 것입니다. 이러한 기형적인 형태로의 발전으로 인해서 역설적으로 intel 계열 CPU가 공부하기에는 가장 이상적인 CPU가 되었습니다. 즉, real mode와 protected mode의 구분은 intel계열에서만 존재하는 것이며, 이 문서에서 얘기하는 VM 따위의 모든 현대적 기능들은 protected mode에 해당하는 이야기들입니다. 즉 protected mode로 변환한 후에야 kernel mode와 user mode라는 기능이 쓰이기 시작하는 것입니다. Intel 계열의 CPU는 부팅시에는 real mode로서 부팅하지만, 어느 시점에서 OS는 protected mode로 전환합니다. 이 전환 과정을 이해하는 것 역시 VM을 이해하기 위한 훌륭한 과정일 수 있습니다.


또 하나, intel에서는 segment라는 것을 지원합니다. DOS시절 프로그래밍을 해보신 분이라면, 64KB의 한계라든지, memory model(COM과 EXE의 차이등)에 관해서 아실 것입니다. 이러한 것들이 intel이 가진 (real mode에서의) segment방식에 의해서 나타나는 것들입니다. 그러나 protected mode에서는 여전히 segment방식을 지원하지만, 전혀 다른 방식으로 지원합니다. 따라서 이전의 segment를 표현하기 위한 segment/offset방식이 selector/descriptor라는 방식으로 바뀌었으며, 레지스터의 크기는 변하지 않았지만 의미는 전혀 달라졌습니다. 자세한 내용은 intel manual을 참조하시기 바랍니다.


이러한 segment방식은 VM이전에 유용하게 쓰이던 방식이었지만, VM으로 인해서 필요성이 거의 없어진 기능입니다. 따라서 현대의 UNIX들에서는 이 segment기능을 이용하지 않습니다. (앞서 "When memory was Not virtual 참조") 이에 따라서 Linux역시 이 기능을 사용하지 않습니다. 이것은 segment를 사용하지 않는 다른 platform과의 portability라는 측면에서도 사용하지 않는 것이 좋을 것입니다. 이와 유사하게, intel은 VM의 구현을 위해 2-level mapping을 사용하지만, 실제 linux는 3-level mapping을 위한 코드를 사용하고 있습니다. 이중 하나의 level을 아무 의미없이 사용함으로써 x86에서 사용할 수 있게 되어있습니다. 이것 역시 다른 platform을 위한 고려라고 할 수 있습니다.


 


 



 






TLB & Cache


 


컴퓨터 시스템의 성능을 높이려면 일반적으로 세가지를 생각할 수 있습니다. 빠른 clock speed의 CPU, 또 빠른 access time의 메모리, 또한 빠른 전송 속도의 I/O system입니다. 그러나 이러한 요소들은 갈수록 성능을 올리기가 어려워지고 있고, 성능 향상에 대한 비용이 높아져 가고 있습니다. 이러한 이유로, 비교적 어렵지 않게 성능의 큰 향상을 꾀할 수 있는 방법들이 강구되는데, 그 대표적인 예가 cache라고 할 수 있습니다. 캐쉬는 memory hierarchy의 각종 메모리 계층간에서 사용될 수 있지만, 이중 CPU와 관련된 캐쉬로, TLB(Translation Lookaside buffer)와 L1 cache(onchip cahce 혹은 internal cache), L2 cache(external cache)가 있습니다. (요즘은 L3캐시까지도 있죠)


캐쉬는 참조의 지역성(locality of referece)를 활용하는 기법으로서, 기본 idea는 프로세스가 현재 직접 사용중인 부분들, 즉 current working set만을 메모리보다 빠른 캐쉬에 저장해둠으로써 메모리로의 실제 접근을 줄이려는 시도입니다.



Memory hierarchy


 


이러한 구조가 성립하는 이유는 가격과 속도 때문입니다. 상위의 메모리(캐시)는 비싸고 빠른 메모리이며, 하위의 메모리는 느리고 싼 메모리인 것입니다. 이 캐쉬는 DRAM보다 빠르고 비싼 SRAM을 사용해서 구현됩니다.


일반적으로 캐시는 inclusion property가 성립합니다. 이는 하위의 메모리의 내용중 같은 내용을 캐시가 가지고 있다는 것입니다. 당연하게 들리겠지만, L1캐시의 내용을 레지스터로 가져오는 것이고, L2캐시의 내용을 L1캐시로 가져오는 것이고, main memory의 내용을 L2캐시로 가져오는 것입니다. 따라서 캐시는 바로 밑의 층의 내용을 중복해서 가지게 됩니다. 이를 캐시의 inclusion property라고 합니다.


재미있는 것은 CPU내에서의 L1캐시와 L2캐시의 관계입니다. Intel의 경우 이런 일반적인 구조를 가져서 이 둘간에 inclusion property를 가지게 됩니다만, AMD의 경우 다른 방식을 도입했습니다. 거꾸로 AMD의 L1캐시와 L2캐시는 exclusion property를 가지게 됩니다. 이는 두 캐시간에 공유되는 데이터가 없다는 특징으로, 위의 hierarchy와는 다르게 L1캐시와 L2캐시가 disjoint한 관계가 됩니다. Intel의 경우 inclusion property를 지키고 있기 때문에 실제 L1&L2캐시로 인해 캐시가 가능한 용량은 L1 size+L2 size-L1 size, 즉 L2의 크기만큼만이 됩니다. 또한 L1은 L2보다 커질 수 없으며, L1와 L2간의 크기는 일정한 비율을 지키는 것이 효율성의 극대화를 위해서 좋습니다. Intel의 캐시에는 이러한 제한들이 적용되는 것입니다. 반대로 AMD의 경우 실제로 캐시가 가능한 용량은 L1+L2의 크기가 되고, 이러한 크기에서의 제약이 없게 됩니다. 반면에 exclusive cache의 경우 이러한 exclusion을 만들기 위해 추가적인 조치가 필요하기 때문에 L2의 성능이 저하될 수 있습니다. 이와같이 inclusive cache와 exclusive cache는 각각 장단점이 존재하고, Intel과 AMD가 각각 대표적인 경우라고 할 수 있습니다. (인텔과 AMD, 볼수록 흥미롭게도 서로 다른 디자인 결정을 내리죠? ^^; )


여기에 대한 자세한 내용은  http://www.cpuid.com/reviews/K8/index.php  를 참조하세요.


이 TLB는 VM의 핵심부분인 virtual address와 physical address의 변환과정을 빠르게 하기 위해서 도입되었습니다. TLB는 일반적으로 associative memory, 혹은 contents-addressable memory 라고 하는 특수한 메모리를 사용합니다. 이 메모리의 특성은 주소가 아닌 내용물을 입력으로 주면 해당 내용물이 들어있는 주소가 그 결과로 나온다는 것입니다. 이는 매핑을 캐시하기에 좋은 구조이기 때문에 TLB에 사용됩니다. 일반적인 2-level translation에서는 1번의 메모리 참조를 위해서 무려 3번의 참조가 필요하게 됩니다. 아무리 VM의 장점이 많다고하더라도 이러한 막대한 비용은 결국 VM을 쓰지 못하게 만들 것입니다. 이런 이유로 TLB의 성능은 컴퓨터 전체 성능에 결정적인 역할을 하게 됩니다. 이 TLB의 도움으로 3번의 참조라는 비용이 1.2번의 참조정도로, 즉 20%정도의 부하정도만으로 VM을 구현할 수 있게 됩니다. TLB와 캐시를 혼동하지 마시기 바랍니다. 캐시는 메모리 hierarchy에서 아랫단계에 있는 메모리의 내용물들을 똑같이 담고 있는 메모리이지만, TLB는 메모리의 내용들이 아닌 VM의 매핑관계를 담고 있는 메모리라는 것입니다. 즉 매핑관계를 캐싱하고 있는 것이 TLB입니다.


 


 


 



 






Interrupt


 


현대 컴퓨터들은 대부분 Interrupt-driven방식입니다. 이것은 정상적인 프로그램의 실행 도중 발생한 사건을 해결하기 위해 잠깐 다른 부분을 실행한 이후에 다시 원래 실행하던 것을 계속해서 실행해 나가는 방식을 이야기합니다. 대표적으로 I/O처리를 들 수 있습니다. 즉, 외부 장치들과 의사소통하기 위해서 Interrupt라는 방식을 사용합니다. 이 Interrupt는 CPU가 정신없이 일하고 있을 때, IO장비들이 뭔가 할 이야기(IO처리가 끝났다는등)가 있을 때 Interrupt라는 신호를 줌으로써 CPU에게 알 리는 것입니다. 이와 대조적으로 예전 Apple같은 경우 Polling이라는 방식을 썼었습니다. 이 방식은 CPU가 한 instruction이 끝날 때마다 IO장비들을 검색하여 자신에게 할 이야기를 가진 IO장비가 있는지를 살펴보는 방식이었습니다. 매우 비효율적이라고 할 수 있습니다. 당연히 Interrupt방식이 효율적입니다. 그 반대급부로 Interrupt는 구현이 복잡하다는 단점이 있습니다. 구현이 간단한 Polling의 경우, 먼저 scan하는 IO slot이 자연히 높은 우선 순위를 가지게 됩니다. Interrupt의 경우 우선순위는 HW적으로 어떻게 Interrupt를 구현하느냐에 달려있습니다.


CPU의 pin들중에 하나에 INTR pin이(interrupt request) 있습니다. I/O장비들중에서 interrupt를 걸게되면 이 line에 신호가 걸리게 되고, CPU는 machine cycle을 돌던중에 마지막에 이러한 신호를 체크하게 됩니다. 이때 interrupt신호가 있다면 interrupt handler로 제어를 옮깁니다.



어느 interrupt가 들어왔을 때, 이 interrupt를 처리해주는 코드를 interrupt handler라고 합니다. CPU는 interrupt가 들어오면 이 interrupt handler를 실행한후에 언제 그랬냐는 듯이 다시 이전 프로그램을 실행합니다. 이렇게 함으로써 현재 실행중인 프로그램을 방해하지 않으면서 (사실 속이면서?) 효과적인 IO를 달성합니다. real mode에서는 상대적으로 이러한 interrupt의 처리가 간단하였습니다. CPU는 interrupt가 오면 주소 0번에서부터 시작하는 interrupt vector table을 참조하여 해당 주소로 jump하기만 하면 되었습니다.(real mode에서 보통 메모리 0번부터 시작하는 주소는 그래서 금지된 주소입니다. NULL pointer가 항상 invalid하다는 것이죠.) 그러나 protected mode에서는, 훨씬 복잡해집니다. 인텔의 경우 IDT를 통하는데........


이와같이 IO device가 필요할 때 CPU에게 interrupt를 걸 수 있지만, 때로는 이러한 interrupt들을 무시해야할 때가 있습니다. 이럴 때 cli 와 같은 instruction을 사용하면 IO장비들로부터 오는 interrupt를 무시할 수 있습니다. 이것을 interrupt disable한다고 합니다. 반대로 sti instruction에 의해서 다시 interrupt enable할 수 있습니다. 그러나 이 명령들이 모든 종류의 interrupt들을 무시하게 해주는 것은 아닙니다. 이러한 instruction에 의해서 무시될 수 있는 interrupt들을 maskable interrupt라고 하고, 그렇지 않는 것들을 non-maskable interrupt라고 합니다.


intel CPU에는 INTR pin말고도 NMI pin이 있습니다. 이것은 nonmaskable interrupt의 신호가 들어오는 pin입니다. power failure같은 interrupt는 매우 중요한 interrupt이기 때문에 NMI에 속합니다. 즉, 무시할 수 없는, cli/sti instruction에 영향을 받지 않는 interrupt입니다.


이러한 maskable interrupt와 NMI외에도 CPU내부에서 발생하는 exception이 있습니다. 이 exception은 외부에서 발생하는 interrupt들과는 달리 CPU내부에서 발생하는 신호들입니다. 즉 instruction을 실행하다가 만나게 되는 문제점들 (0으로 나눈다던지, page fault등)에 대해서 CPU가 스스로 발생시키는 신호인 것입니다. 이런 이유로, 즉 exception이 항상 instruction과 동기화(synchronized)되어서 발생한다는 점 때문에 synchronous interrupt 라고 부르기도 하고, 반대로 NMI와 maskable interrupt를 IO장비에서 아무때나 전달되어오는 신호이기 때문에 asynchronous interrrupt라고 부르기도 합니다.


 














Asynchronous interrupt


Maskable interrupt


INTR pin으로 들어옴. cli/sti 로 금지시킬 수 있다. IO장비에서 오는 모든 인터럽트들.


NMI


NMI pin으로 들어옴.


Synchronous interrupt


exception


CPU내부에서 발생. page fault등.


 


interrupt라는 용어가 어떤 경우에는 이 3가지 종류의 신호들을 모두 가리키기도 하고, (왜냐하면 exception도 interrupt과 똑같이 처리되기 때문입니다.) 때로는 exception을 강조하여 interrupt는 maskable interrupt와 NMI만을 뜻하기도 합니다. 주로 과거에 interrupt라는 단일 용어로 쓰였었는데, 386이후부터는 VM등의 영향으로 CPU의 control unit이 내부적으로 처리해야할 상황이 많아지면서 (각종 fault들) 최근에는 exception과 interrupt를 구분해서 쓰이는 경향이 있습니다. 이 책에서도 exception와 interrupt를 구분하여 쓰도록 하겠습니다만, 가끔 그렇지 못한 경우도 있을 것입니다. 그리 어렵진 않으니 문맥에서 잘 판단하시기 바랍니다.


예외나 인터럽트가 걸리면 기본적으로 CPU는 자신이 현재 실행중이던 곳의 주소인 EIP를 스택 (커널 모드 스택)에 저장하고, 해당 인터럽트나 예외를 처리합니다. 인텔 매뉴얼에서는 exception을 이 저장되는 EIP의 값에 따라서 다음과 같이 나누고 있습니다.



  1. fault : fault란 발생한 사건을 복구하고 다시 재시작할 수 있는 상황들입니다. 따라서 이 경우 스택에 저장된 EIP에는 fault를 발생시킨 해당 instruction을 가리키고 있습니다. 따라서 fault handler가 끝나고 복귀할 때는 해당 instruction을 다시 실행하게 됩니다. 현대의 CPU들은 이러한 이유로 실행하다가 중지된 instruction을 undo 하는 기능을 가지고 있습니다. (나중에 더 자세히 살펴볼 기회가 있을지...) 대표적으로 page fault를 생각할 수 있읍니다.
  2. trap : trap은 해당 instruction이 종료되어서 다시 실행될 필요가 없는 경우, 그 다음 instruction의 주소를 스택에 넣게 됩니다. 따라서 이 trap을 처리한후 돌아와서는 그 다음 instruction을 실행하는 것입니다. 대표적인 용도로 디버깅을 들 수 있습니다. 매 instruction이 끝나고나서 그 결과를 보기 위해서 사용될 수 있습니다. 또는 breakpoint의 설정등에 사용됩니다.
  3. abort : 이것은 심각한 에러로 인하여 더 이상 진행이 될 수 없는 상황에서 발생합니다. 이때는 스택의 eip에는 의미없는 값이 저장될 수도 있고, 프로세스가 종료되어야만 하는 상황입니다.

 


이외에 INT instruction에 대해서도 알 필요가 있습니다. INT(interrupt)라는 instruction은 S/W에서 직접 exception이나 interrupt를 일으킬 수 있게 해주는 명령입니다. 이것은 system call을 구현할 때와 같은 경우에 필수적으로 필요한 기능입니다. 이런 경우를 인텔 매뉴얼에서는 Software-generated interrupts라고 하고 있습니다. 이 부분에 대해서는 뒤에서 좀더 자세히 살펴보도록 하겠읍니다.


 



 






PC에서의 interrupt


 


PC에서는 interrupt의 구현을 위해서 intel 8259A 칩을 사용합니다. 이러한 칩을 PIC (programmable interrupt controller) 라고 하는데, 이 PIC의 역할은 다른 device controller로부터 interrupt신호를 받아서 (이러한 선을 IRQ선이라고 합니다) 그중 priority가 높은 신호를 CPU에게로 전달해주는 (CPU의 INTR선을 통해서) 것입니다.



(from 8259A data sheet - 8259A interface to standard system bus)


original PC나 XT에서는 하나의 8259칩을 사용하였는데, 위의 그림에서 보시다시피, 최대 8개까지의 장치를 연결할 수 있었습니다. 8259A 는 이들을 직렬연결(cascade)을 통해 최대 64개의 장치까지를 연결할 수 있게 되어있습니다. AT이후부터는 이 8259A 2개를 연결하여 총 16개의 interrupt를 처리하고 있습니다. 여기서 눈여겨볼 pin은 IRQ0부터 IRQ7까지의 외부 device controller와 연결되는 IRQ선과, CPU의 INTR pin에 연결되어 interrupt를 요청하는 INT선, 그리고 CPU로부터 interrupt에 대한 ack를 받는 INTA pin입니다. 개념적으로 나타내면 다음과 같이 그릴 수 있습니다.



위에서 PIC 2개를 직렬연결(cascade)하였음을 볼 수 있습니다. 8259A 칩은 priority에 따라서 IRQ선으로 오는 신호를 처리하기 때문에, 위의 예에서의 priority는 0,1,8,9,10,..14,15,3,4,5,6,7 임을 알 수 있습니다. 또다른 NMI pin은 nonmaskable interrupt를 받는 pin입니다. PIC은 또한 CPU에게 어느 장치가 interrupt를 일으켰는지를 알려줄 interrupt vector를 넘겨주어야 합니다. 이것을 위해서 PIC는 들어온 선의 신호를 미리 지정된 번호(interrupt vector)로 바꾸어 IO공간에 써넣게 됩니다. 기본적으로 intel에서는 IRQ선 번호+32 를 씁니다. 즉, IRQ0번은 32번 interrupt vector에 해당합니다. 이러한 IRQ와 vector간의 mapping은 PIC에 입출력 명령을 써서 programming할 수 있읍니다.


이제 8259A의 동작을 살펴봅시다. 8259A는 IRR(Interrupt Request Register) 라는 레지스터를 가지고 있습니다. 이 레지스터는 8bit로 이루어져있으며, 각 bit는 각 IRQ선에 대응됩니다. 어느 한 IRQ선에서 신호가 들어올 때, 정확히는 신호의 rising edge가 파악되었을 때 해당 bit는 1이 됩니다. 또다른 8bit의 IMR(Interrupt Mask Register) 라는 레지스터는 각 IRQ선에 대해서 개별적으로 masking을 할 때 사용됩니다. IRR과 not(IMR)을 AND시킴으로써 masking이 이루어 집니다. 또한 ISR(In Service Register)라는 8bit의 레지스터가 있습니다. 이 register는 들어온 interrupt가 CPU에게 전달되었을 때 (CPU에서 INTA선을 타고 ack가 왔을 때) 1이 되고, CPU가 EOI (End of Interrupt)신호를 보내올 때 0이 됩니다. 즉, ISR에 있는 '1'은 해당 interrupt를 CPU가 처리중임을 표시합니다. 따라서 우선순위가 낮은 IRQ선에서 신호가 들어올 때, ISR의 그보다 높은 bit들중 '1'이 있을 때 그 interrupt의 처리는 미루어집니다.




  1. ISR, IRR, IMR 이 모두 0입니다.
  2. IRQ3에 신호가 실립니다.
  3. IRR의 3번째 bit가 '1'이 됩니다.
  4. IMR의 3번째 bit가 0이므로, IRR의 3번째 bit는 다음 회로의 input으로 들어갑니다.
  5. ISR의 모든 bit가 0이므로, 즉, 처리중인 더 높은 우선순위의 interrupt가 없으므로, INT선에 신호를 줍니다. 즉, CPU의 INTR선에 신호가 들어갑니다.
  6. CPU는 INTR의 신호를 감지하고 INTA신호를 줍니다.
  7. IRR중 가장 높은 우선순위가 3번 bit이므로 ISR의 3번째 bit를 set합니다.
  8. CPU가 두 번째 INTA신호를 줍니다.
  9. ISR의 가장 높은 3번 bit에 해당하는 interrupt vector를 IO공간에 씁니다.
  10. INT신호를 끄고, IRR의 3번째 bit는 0으로 reset합니다.
  11. 이후에, CPU는 처리를 마친후 EOI 신호를 주고, 이것은 ISR의 3번째 bit를 reset합니다.

 


이때 PIC에서 이루어지는 masking은 각 IRQ선에 대해서 개별적으로 이루어질 수 있습니다. 이 masking은 cli에 의한 interrupt disable과는 다릅니다.


더 자세한 내용은 8259A data sheet를 참조하시기 바랍니다.


 



 






Interrupt vector


이러한 모든 interrupt나 exception들은 0에서 255까지의 숫자로 구분됩니다. (할당됩니다.) 이러한 숫자를 interrupt vector라고 부릅니다. PC에는 interrupt vector table이 있어서 이 table에서 각 interrupt가 들어올 때 그것들에 대한 vector번호를 가지고 처리할 handler의 주소를 얻을 수 있게 되어있습니다. (386이후에는 기본적으로 같지만 좀더 복잡합니다.) 따라서 모든 interrupt나 exception들은 vector값을 가지고 있으면서, 해당 interrupt나 exception이 발생하면 vector table에서 handler의 주소를 찾아서 실행하게 되는 것입니다. 다음 테이블은 각 interrupt나 exception에 vector번호가 어떻게 할당되어있는지를 보여줍니다.


 


 


(from intel manual)


 


여기서 0부터 31번까지의 vector 번호가 예약되어 있음을 볼 수 있습니다. 이중 2번에 NMI가 할당되어 있음을 알 수 있습니다. 즉 NM와 exception은 0~31번에 할당되어 있읍니다. 따라서 maskable interrupt들은 32번 이후로 매핑이 가능합니다. 이러한 매핑은 APIC을 통해서 변경할 수 있게 됩니다. 따라서 OS에 따라서 매핑은 차이가 날 수도 있는 것입니다. Linux의 경우 IRQ번호+32번 vector에 각 IRQ들을 할당하고 있습니다. 즉 32번은 IRQ 0 번에 할당되어있는 것입니다.


 



 






여러 interrupt & exception


 


x86에서는 예외를 20여개정도 일으키는데, 각 경우마다 CPU의 동작이 조금씩 차이가 나기도 합니다. 이를 좀 살펴보면,


 





























































































































Vector no.


Mnemonic


Linux의 handler


설명


Signal


0


#DE


divide_error()


DIV나 IDIV가 0으로 나누려고 하거나 결과값이 표현하기에 너무 클 때 발생


SIGFPE


1


#DB


debug()


eflags의 T 플래그가 설정되는등의 디버깅을 위한 exception조건들이 있을 때 발생합니다.


SIGTRAP


2


 


nmi()


NMI


 


3


#BP


int3()


INT3 명령으로 발생하는데, 보통 디버거가 breakpoint를 만들기 위해 삽입해 넣습니다.


SIGTRAP


4


#OF


overflow()


INTO명령은 EFLAGS의 OF플래그가 켜져있을 때 overflow가 발생하면 이 exception을 발생시킵니다.


SIGSEGV


5


#BR


bounds()


BOUND명령이 operand가 주소 범위를 벗어났을 때 발생시킵니다.


SIGSEGV


6


#UD


invalid_op()


잘못된 op-code일때.


SIGILL


7


#NM


device_not_available()


x87 FPU, MMX,등의 장비가 사용준비가 되지 않았을때


SIGSEGV


8


#DF


double_fault()


CPU가 예외를 처리하는 중인데 예외가 다시 발생했을 경우. 보통 이런 경우 둘을 serial하게 처리할 수 있지만, 간혹 그럴 수 없는 경우가 발생하는데, 이런 경우에 발생.


SIGSEGV


9


 


coprocessor_segment_overrun()


최근 인텔의 프로세서에서는 발생하지 않지만, 예전 386에서만 387에서 문제가 발생했을 때 발생


SIGFPE


10


#TS


invalid_tss()


TSS가 잘못되었을 때.


SIGSEGV


11


#NP


segment_not_present


segment descriptor나 gate descriptor의 present flag가 꺼져있는 경우. 즉 존재하지 않는 세그먼트를 참조하는 경우에 발생


SIGBUS


12


#SS


stack_segment()


존재하지 않는 stack segment를 SS레지스터에 load하려는 경우거나 스택 세그먼트의 한계를 넘어서는 경우.


SIGBUS


13


#GP


general_protection()


보호모드에서 보호 규약을 어겼을때.


SIGSEGV


14


#PF


page_fault()


주로 참조하는 주소에 대한 페이지가 없거나 하는등의 paging 매커니즘의 규약을 어겼을때


SIGSEGV


15


 


 


(인텔이 예약)


 


16


#MF


coprocessor_error()


CR0의 NE flag가 켜져있을 때 발생하는데, x87 FPU가 에러를 발견했을 때 발생.


SIGFPE


17


#AC


alignment_check()


operand의 주소가 정렬되어 있지 않을때


SIGSEGV


18 - 31


 


 


(인텔이 예약)


 


 


위의 표에서 알아두어야할 주요 exception은 #SS, #GP, #PF 정도입니다. 13번 #GP의 경우 intel의 protected mode에서의 보호정책을 위반하였을 때 일어나는 exception입니다. 윈도우에서 자주보던 General Protection Violation입니다. :-P 또 14번 #PF는 우리 눈에 익은 page fault입니다. VM에 관련하여 이 page fault와 그 handler를 잘 이해하는 것이 중요합니다. #SS는 자라나는 스택에 대한 exception인데 이를 통해서 VMA를 더 잘 이해할 수 있을 것입니다.


이러한 exception들은 Linux등 unix system에서는 보통 현재 process에게로 전달됩니다. exception의 경우 현재 실행중이던 process에서 발생한 것이기 때문에 interrupt와 달리 현재 process에게 signal을 보내는 것으로 처리할 수 있습니다. 그렇게 함으로써 해당 process가 처리하도록 하기 때문에 커널입장에서는 exception은 손쉽고 빠르게 처리할 수 있습니다. 이처럼 예외의 경우, 커널은 signal로 해당 process에게 전달해주기 때문에 상대적으로 쉽고 빠르게 처리할 수 있지만, interrupt의 경우는 그렇지 않습니다. 왜냐하면 exception과는 달리 interrupt는 일어난 시점의 실행중이던 process와 아무런 관련이 없고, 전달된 interrupt를 해당 process에게 전달해주어야 하기 때문입니다. 이런 이유로 interrupt처리는 좀 더 복잡해집니다. 이에 대한 자세한 내용은 "Nested kernel control path"에서 살펴보도록 하겠읍니다.


이러한 인터럽트중에서 중요한 것으로 timer interrupt가 있습니다. timer정도가 뭐가 중요하냐고 반문하실지 모르겠지만, 이 timer interrupt 기능은 multitasking을 위한 기본적인 조건으로 HW는 일정한 시간간격마다 interrupt를 발생시킬 수 있어야 합니다. 그래야만 time sharing system 을 구현할 수 있기 때문입니다.


 


 


 



 






Processes and threads


Program을 실행하면, 커널은 그 image를 메모리에 올리고(load) 실행을 시작합니다. 이렇게 프로그램이 실행중인 상태에 있을 때 그 실행환경(context)과 메모리에 올라온 이미지를 process라고 부릅니다. (Linux에서는 task라고 부릅니다) 이 process는 UNIX에서 전통적으로 쓰이는 실행단위입니다.



multitasking이란 이러한 여러 process들을 동시에 실행시킬 수 있다는 의미로서, 실제로는 위의 그림과 같이 여러 process가 번갈아 가며 실행되는 환경입니다.


그러나 context switch가 너무 무겁고(즉 cost가 크고), fork의 비용이 크다는등의 단점을 보완하기 위해서 thread가 만들어졌는데, thread란 간단히 말해서 하나의 프로세스의 주소공간(address space)를 공유하는 여러 실행 단위들이라고 할 수 있습니다. 즉, 하나의 프로세스는 하나의 thread로서도 볼 수 있으며, 하나의 프로세스는 여러개의 thread로 이루어 질 수도 있습니다. 이러한 thread들은 data와 code를 공유하는 것입니다. 이러한 thread는 process에 비해서 context switch가 빠르며, 빠르게 만들어질 수 있다는 장점이 있습니다. (앞서서의 VM장을 참조하시기 바랍니다.)


thread에는 크게 2가지 종류의 thread가 있습니다. 첫 번째는 kernel-level thread이고, 두 번째는 user-level thread입니다. 이 둘간의 차이는 커널의 scheduler에 등록이 되어있느냐 아니냐입니다. 즉, 등록되어있을 때 kernel-level thread라고 하고, 그렇지 못할 때 user level thread라고 합니다. 즉, 커널이 그 존재를 인식하고 있을 때 kernel level thread인 것입니다. kernel level thread는 커널이 thread단위로 스케쥴링하기 때문에 각 thread들간이 독립적입니다. 이것은, I/O장비등에 의해서 하나의 thread가 block되었을 때도 다른 thread의 실행에 영향을 미치지 않는다는 것을 뜻합니다. 또한 당연히 다른 thread들과 공평한 CPU자원을 분배받을 수 있습니다. 스케쥴러에 의해서 하나의 동등한 단위로 인식되기 때문입니다. 그러나 user level thread에서는 어느 한 thread가 block되었다면, 모든 thread가 함께 block되어 버립니다. 스케쥴러는 그것을 하나의 실행단위로 인식하기 때문입니다. 즉, user level thread란 application level에서 직접 threading을 구현한 것입니다. 따라서 CPU자원은 하나의 process에 오는 양만큼의 자원을 가지고 각 thread가 나누어 가지게 되는 것입니다.


thread라 하면 일반적으로 kernel-level에서의 thread를 뜻합니다.


커널 자체는 process가 아닙니다. 커널은 실행되는 user process에 의해서 system call을 통해서 불리워지는 코드입니다. 이에 반해서 사용자 process외에 kernel thread라고 하는 process가 있습니다. 이 process들은 커널의 부팅 과정에서 만들어지는 process들로서 커널 모드에서 실행되는 process들입니다. kswapd 등의 process들이 이러한 kernel thread들입니다. 이들은 터미널을 가지지 않으며 시스템이 종료할 때까지 살아있는 process들입니다.


커널은 process가 아니라면, 커널은 언제 실행되는 것일까요? 첫 번째, 가장 흔하게 시스템콜이 호출된 경우입니다. 위의 그림에서 user process에 의해서 system call이 불리우면 커널코드가 실행되는 것입니다. 또한 각종 interrupt가 걸렸을 경우입니다. 당연히 모든 interrupt는 먼저 커널에 의해서 처리됩니다. 이러한 interrupt중 timer interrupt의 경우 context switching에 의해서 사용되기 때문에 scheduler가 실행되도록 되어 있습니다. 그외의 다른 I/O장비들로부터 오는 온갖 interrupt를 처리하기 위해서 위의 그림에서처럼 process B가 멈추고 커널코드가 실행됩니다. 또한 scheduler에 의해서 kernel thread가 선택된 경우, 이 kernel thread역시 커널 코드의 일부분이기 때문에 커널이 실행되는 경우입니다.


 


 



 






Context switch


여러개의 process들이 있을 때 CPU는 각 process들을 조금씩 번갈아 실행시켜가며 마치 사용자에게 모든 프로그램이 동시에 수행되는 것처럼 보이게 합니다. 이러한 방식을 time-sharing 시분할 방식이라고 합니다. 이때 하나의 process에게 주어지는 짧은 수행 시간을 time slice 혹은 time quantum이라고 합니다. 하나의 process의 수행을 마칠 때, 다음 process를 수행하기 위해서 현재 process의 context를 어떤 장소에 보존하고, 다음 process의 context를 올려오는 과정을 context switch라고 합니다. 여기서 context란 일반적으로 한 process가 실행되는 machine state를 말합니다. 즉, 해당 process를 둘러싼 실행 환경이라고 할 수 있습니다. 이 환경이란 다음 instruction의 주소를 가리키는 IP, 각종 레지스터값들, 해당 process의 virtual address space의 page directory의 주소(CR3로 load됩니다), SP (stack pointer), 등의 process가 실행되는데 필수적이고 개개의 process마다 존재해야 하는 모든 정보들입니다. 일반적으로 이러한 정보들은 모두 PCB(process control block) 이라는 구조체에 저장되어 있습니다. 커널은 하나의 process마다 이러한 PCB를 가지고 있으며, linked list같은 형태로 관리합니다. 따라서 context switch때 커널은 방금전에 실행중이던 process의 환경(context)를 그 process의 PCB에 저장하고, 다음 실행된 process의 PCB에서 context를 꺼내어 레지스터등의 적절한 위치에 넣습니다. 이러한 준비로 인해, process입장에서는 자신이 실행될 때 그사이에 어떤 일이 벌어졌는지 모르게 되고 마치 자신이 계속해서 CPU를 사용한 것과 같이 느끼게 됩니다. multitasking을 위한 이러한 작업(context switching)은 매우 비싼편(컴퓨터가 해야할 작업이 많죠)이기 때문에 time quantum의 길이를 길게해서 context switch를 덜 빈번하게 일어나게끔 하는 것이 좋겠지만, 그렇게 되면 process 입장에서는 외부의 입력에 대한 반응이 느려질수밖에 없습니다. 즉, response time이 길어지게 됩니다. 따라서 적절한 time quantum의 길이를 정하는 것이 중요합니다.


이러한 context switch를 가능케 하는 것이 timer interrupt입니다. 이 timer에 의해서 일정한 시간 간격때마다 interrupt가 걸리게 되고, 이것이 context switch를 일으키게 되는 것입니다. 이러한 timer interrupt는 리눅스에서는 틱(tick)이라는 이름으로 부릅니다. 이 틱이 발생하는 고정된 길이의 시간이 해당 시스템에서의 시간을 잴 수 있는 최소단위가 됩니다. 즉 time의 resolution이 되는 것입니다. 예를 들어 Linux 2.6에서는 x86에서 1/1000초 즉 1ms마다 tick이 발생합니다. 이것은 다른 시스템에 비해서 매우 짧은 편입니다. 1초에 천번씩이나 interrupt가 발생하게 되므로 이것은 시스템에 부담을 주게 되는 반면 마우스의 감도등 I/O처리등에 있어서 빠른 반응을 할 수 있게되기 때문에 반응성(responsiveness)가 좋아지게 됩니다. 밑의 그림들에서는 간단히 박스 하나로 처리된 time slice들이 사실은 여러개의 tick으로 구성되었다는 점에 유의하시기 바랍니다. 즉 time slice는 여러개의 tick들로 모여져서 이루어지는 것입니다. time slice 한 개의 길이는 가변적 (즉 tick의 수로 나타나죠) 이고 이것은 스케쥴러가 해당 process에게 얼마나 길 게 CPU를 사용하게 해줄 것인지를 결정하는 것입니다. 리눅스에서는 디볼트로 100ms의 길이가 정해져있습니다. 즉 이런 길이의 time slice동안 여러번의 tick이 발생하게 되고 이런 tick중에서 보다 중요한일이 있다면 이 프로세스는 preemption되고 남은 time slice는 나중에 다시 실행되게 됩니다. 그러나 tick의 발생시에 별다른일이 없다면 tick, 즉 timer handler는 간단한 accounting만을 하고 (매 tick마다 timer handler는 해당 process가 CPU를 1틱동안 썼다는 것을 accounting합니다) 마치 아무일 없었다는 듯이 종료하게 됩니다. 여기서도 알 수 있듯이 tick은 preemption을 위한 전제조건이며 preemption을 할 것인지의 여부를 매번 체크하는곳이 이 timer handler입니다. 동시에 tick은 preemption이 될 수 있는 최소의 단위이자 컴퓨터가 시간을 잴 수 있는 최소의 단위이기도 합니다. 즉 1개의 틱내에서는 당연히! 죽었다 깨어나도 preemption이 되지 않습니다. 그냥 CPU가 수행될뿐이니까요.


다만, 실행중이던 process가 CPU를 자발적으로 내놓을 수는 있습니다. I/O등의 작업을 하기 위해서 sleep하는 경우가 대표적인데요, 이런 경우 언제든지 CPU는 스케쥴러에 의해서 다른 프로세스에게 넘겨지게 됩니다. 이것을 yield()한다고 합니다.


스케쥴러를 잘 이해하기 위해서는 time slice보다는 tick을 기준으로 이해하시기를 권합니다. tick의 개념은 거의 모든 OS에 동일하게 쓰이는 반면 time slice혹은 time quantum이라는 용어는 다른 책에서는 지금 설명한 tick의 개념으로 쓰이기도 하며 스케쥴러마다 조금씩 다를 수 있기 때문입니다. 또한 time slice라는 것이 preemption이 되면 나머지 길이만큼은 나중에 다시 스케쥴러에 의해서 수행되기 때문에 각 process의 time slice조각들이 섞이기도 하는등 처음 접해서 이해하기 난감한 측면이 있습니다. 이제 이러한 time slice를 모두 써서 그 값이 0이 되면 expired되었다고 하며 스케쥴러는 새로운 값을 주게 됩니다...(생략)


이러한 tick은 시스템설계를 간단하게 해주지만 역시 그 부담이 적잖이 있습니다. 이를 해결하기 위해서 tickless system이라는 것도 많이 이야기되던 주제였습니다. tick을 없애고자 하는 시도인데요, ...(생략)


preemption되어서 context switching할 때 다음번에 어떤 process가 선택되어야 하는지를 결정하는 것을 scheduling이라고 합니다.


process는 어느 특정 시점에 user mode에 있거나 혹은 system call에 의해 kernel mode에 있을 수가 있습니다. 이때 time slice가 다해서 타이머에 의해 인터럽트가 발생했을 때(엄밀히 말해서는 타이머에 의해 time slice값이 줄어들었는데 그때 이 값이 0이되었을때) 해당 process가 user mode에 있다면 선점(preempt)될 것입니다. 즉 스케쥴러가 실행되어서 다른 프로세스가 실행될 것입니다. (앞서 "process and thread"편의 그림 참조) 그러나 process가 시스템콜로 kernel mode에서 수행중이었다면, time slice가 다되었음에도 불구하고 제어권을 내놓지 않을 수 있습니다. 이것을 커널이 preemptible하지 않다고 이야기합니다. 이것은 커널의 자료구조의 동기화 문제 때문인데, 이는 일반 프로세스와 달리 커널의 data structure들은 모든 프로세스들에 대해서 공유되고 있는 데이터들이기 때문입니다. 만일 커널이 preemptible하다면, 즉, 커널의 자료구조들이 동기화 되어 있다면 kernel mode에서도 선점될 수(preemptible) 있습니다. 이러한 커널 자료구조들의 동기화는 쉽지 않은 작업으로, Solaris의 경우 이러한 preemptible kernel이었지만 Linux는 이제 2.6에서부터 지원되기 시작했습니다. 이 부분은 곧 다시 살펴볼 것입니다.


특기할 만한 사항으로는, CPU가 어떤 process를 실행중일 때, context를 가지지만 예외적으로 몇가지 특수한 경우에 context를 가지지 않는다고 할 수 있다는 점입니다. 바로 인터럽트의 경우인데, interrupt handler같은 경우 실행 context가 없다고 볼 수 있습니다. 또는 streams service의 경우에 실행 context가 없다고 볼 수 있습니다. ("Unix Systems for Modern Architecture"참고)


다음과 같이 정리해 볼 수 있습니다. 시스템은 어느 특정한 주어진 순간에 다음과 같은 3가지 경우의 context중에 하나의 경우에 놓여있게 됩니다.



  • 커널모드, process context에서 해당 process의 요청에 의한 수행 (시스템콜)
  • 커널모드, interrupt context에서 process와는 상관없이 인터럽트 처리중 (인터럽트)
  • 유저모드, process 의 코드를 수행중

("Linux Kernel Development by Robert Love" 참조)


CPU는 결국 모든 경우에서 위의 3가지 경우들중에서 한가지 경우에 있다는 것을 항상 염두에 두시기 바랍니다.


 






Scheduler


 


스케쥴러


 






Reentrant kernel


모든 Unix 커널은 reentrant(재진입 가능)합니다. 따라서 linux kernel 역시 reentrant합니다. 이것은 즉, 여러개의 process가 동시에 커널 모드에서 실행중일 수 있다는 표현입니다. 예를 들어 Process A가 IO장비의 일이 끝나기를 기다리고 있고, 그때 Process B가 system call을 호출하여 커널 모드로 진입할 수 있습니다. 이렇게 reentrant하기 위해서는 일단 self-modify하지 않아야 합니다. 어떤 코드는 때론 자기 자신의 코드를 스스로 고치기도 하는데, 이런 코드는 reentrant하지 않은 코드입니다. (386이전에 모든 system을 하나의 process가 장악하고 있을 때는 이런 기법이 쓰일 수 있었고 문제가 없었겠지만 지금은 하나의 image에 여러개의 process가 뜰 수 있기 때문에 기본적으로 모든 프로그램은 self-modify하지 않아야 합니다.) reentrant하기 위해서 코드가 아닌 데이터에 있어서의 문제점은, 각자의 스택에 있는 local variables들은 문제가 안되지만, 공유하는 global data의 경우에는 동기화(synchronization)가 이루어져야 한다는 점입니다. 이것을 위해서 공유하는 data structure를 보호하기 위해서 locking 매커니즘을 사용하여 커널을 reentrant하게 만들 게 됩니다. 이러한 locking 매커니즘은 간단히 말해서 kernel의 특정 구간(reentrant하지 않은 부분, 즉 공유 data를 수정하는 부분)을 critical section으로 묶어 이 구간에는 하나의 프로세스만이 진입할 수 있도록 만드는 기법입니다. 이러한 커널 동기화는 쉽지 않은 부분으로, 앞으로 더 다룰 기회가 있을 것입니다. (왜냐하면 요즘 관심을 가지고 있기 때문입니다. :-P)


 



 






Nested kernel control path


"Processes and thread"편의 그림에서 살펴본 실행 흐름은 가장 간단한 편에 속합니다. 이제 좀 더 복잡한 경우를 살펴보겠습니다. 앞서와 같이 kernel mode에서 실행되는 제어 흐름을 kernel control path라고 하는데, 이 kernel control path는 여러 이유로 중첩(nested)될 수도 있습니다. 이것은 곧, kernel mode에 있을 때 인터럽트나 exception에 어떻게 대처하느냐 하는 문제입니다.


kernel control path가 어떻게 끝나거나 nested되는지 생각해 봅시다. 가장 간단하게, system call등으로 생긴 kernel control path가 자발적으로 CPU를 내놓을 수 있습니다. 이 경우는 I/O등에게 일을 시켜놓고 결과를 기다리는 것(blocking)과 같은 경우입니다. 이런때 커널은 스케쥴러를 실행하여 context switching을 하게 됩니다. 이런 경우는 nested된 경우가 아닙니다. 이 경우 그저 system call은 자발적으로 CPU를 반납했을뿐이지 여전히 이 kernel control path는 해당 process의 실행과정중 일부분입니다.(즉 해당 process의 context내에서 실행되고 있는 것입니다.) 이런 경우, 이 kernel control path는 다른 kernel control path가 공유되는 커널 자료를 수정할수 있음에 유의해야 합니다. 즉, 실행이 되돌아왔을 때, 자료가 변경되어 있을 수 있다는 것입니다.



즉, 이렇게 여러 kernel control path가 실행 context에 있을 때, 서로간에 kernel data를 변경시킬 수가 있읍니다. 따라서 CPU를 내놓은 후에 data가 변하지 않았는지 검사할 필요가 있게 됩니다.


이제 nested되는 경우를 살펴보기 위해서, 먼저 두가지 사항을 알아야 합니다.



  1. kernel mode에서 일어날 수 있는 exception은 page fault뿐이다. 또한 page fault는 exception을 일으키지 않는다.
  2. interrupt handler는 page fault를 일으키지 않는다.

이것을 염두에 두고 생각해봅시다. 이제 kernel control path가 nested되는 경우를 살펴보면, exception과 interrupt의 경우가 될 수 있습니다. exception이 발생한 경우, 예를 들어 page fault가 발생한 경우, 페이지를 할당받고 이 페이지를 virtual address space에 연결하게 됩니다. 여전히 이 작업은 해당 process의 context내에서 실행되는 것입니다. 그렇다면 이러한 exception이 여러번 중첩되어 일어날 수 있을까요? 그렇지는 않습니다. 1번 조건에 의해 대부분의 exception은 user mode에서 일어나며, (kernel에 버그가 없다면) kernel mode에서 일어날 수 있는 유일한 exception은 page fault뿐입니다. 또한 이 page fault handler내에서 exception이 일어나지 않으므로, 따라서 exception에만 국한해서 생각해본다면,(즉 interrupt가 안일어난다면) kernel control path는 최대 한번밖에 nested될 수 없습니다. (즉 두 개의 kernel control path가 중첩) 이런 경우에 두 개의 kernel control path는 모두 해당 process의 context에서 실행되는 것입니다.



 


그런데 이러한 page fault handler는 페이지를 disk로부터 읽어올 때 context switching이 일어날 수도 있습니다. 이런 경우에 다음과 같은 시나리오를 생각할 수 있읍니다.


 



 


반면, interrupt는 얼마든지 중첩될 수 있습니다. (물론, kernel에는 interrupt를 disable한 영역이 있습니다. 이 영역을 제외하고 말입니다. ) 즉, IO장비에서 오는 interrupt에 대해서는 interrupt handler의 실행이 여러번 nested될 수 있습니다. 이러한 경우, interrupt에 의해서 새로이 시작되는 kernel control path는 해당 process의 context와는 무관합니다. (당연하죠. IO장비는 아무때던지 interrupt를 걸기 때문이죠 - accounting과 관련하여서는 이렇게 interrupt에 의한 kernel control path가 해당 process와는 무관함에도 불구하고, 사용된 CPU양은 해당 process가 사용한 것으로 계산(accounting)됩니다.)  interrupt에 관련되어서 kernel control path가 얼마든지 중첩될 수 있지만, timer interrupt에 따르는 context switching은 일어나지 않습니다. 즉, interrupt handler를 처리중인 Linux kernel은 time quantum이 다 소모되더라도 context switching을 하지 않는 non-preemptible kernel입니다. 이것은 만일 이를 허용했을 때 나타나는 kernel data들의 동기화문제를 피해가기 위해서입니다. Linux에서 Interrupt handler가 non-preemptible이라는 것은 조건2)에서 보다시피 interrupt handler는 page fault를 일으키지 않기 때문에, 즉, context switching을 일으킬 수 있는 page fault가 일어나지 않기 때문입니다. 이처럼 kernel mode에서 interrupt handler가 non-preemptible이고, timer interrupt에 의해 context switching하지 않기 때문에 결국 Linux kernel은 스스로 CPU를 내놓지 않는 이상 context switching이 일어나지 않게 됩니다. 이러한 kernel preemption은 Solaris등에서 구현되었었는데, 이것은 kernel data들을 동기화시켜야 함을 의미합니다. 이럴 때 커널은 preemptible kernel이 될 수 있고, Linux에서는 이번 2.6대에 들어서면서 지원되기 시작했습니다.



( Interrupt handler는 얼마든지 중첩될 수 있다.)


 


** 그런데 page fault 로 일어난 kernel control path에서 다시 interrupt가 걸리면 어떻게 되는걸까?) **


 



 






Preemptible kernel (내용의 정확성 의심됨-_-;;)


앞서 보았듯이 버전 2.6이전까지의 linux kernel은 non-preemptible kernel입니다. 이것은 linux뿐만이 아니라 고전적인 unix kernel이 커널 동기화(kernel synchronization)을 위해서 채택하는 방식입니다. 이번 linux 2.6대에 이르러 preemptible kernel을 지원하게 되었읍니다. 이것은, 즉, 커널코드들도 interrupt에 의해서 선점될 수 있다는 의미입니다. 이전 커널에서는 인터럽트에 대해서 핸들러들이 단지 need_schedule flag를 켜놓아서 다음 user mode로 들어갈 때 스케쥴러가 호출되어 적절한 process가 해당 인터럽트를 처리할 수 있도록 하는 것이었습니다. ( Linux 커널은 kernel mode에서 user mode로 돌아갈 때 need_schedule flag를 살펴보아서 켜있을 때 context switching을 일으킵니다. )


 


 



 






Bottom half


 


인터럽트는 무조건 빨리 처리되고 이전 일을 계속해야합니다. 인터럽트는 항상 기존의 어떤일(커널 모드였던 유저모드였던)을 멈추고 있는것이기 때문에 이렇게 인터러트가 빨리 처리되는것은 response time을 향상시키는 주요한 원인이 됩니다. 그뿐 아니라 다른 코드가 lock으로 인해 spin하고 있는등 interrupt handler가 길어지면 performance에도 악영향을 끼치게됩니다. 이를 위해서 OS에서는 bottom half라는 개념을 사용합니다. (이 용어는 특정 OS가 아닌 OS론 일반적인 용어입니다.) 이는 당장 급하지 않은 일들은 interrupt handler가 아닌 이후에 처리한다는 개념입니다. 그래서 해야할 일들을 뒤로 미루고 당장은 interrupt handler가 급하게 해야할일들만을 처리한후에 종료하는것입니다. 이를 통해서 당연히 response time이 향상됩니다. 일반적으로 interrupt handler가 당장 해야할일은 device에게 acknowledge를 던지는 일같은 것이 있읍니다. 이렇게해서 device도 다음일을 계속 할수 있죠. 그리고 급하지 않은 일들은 bottom half로 밀어넣습니다. (그래서 interrupt handler가 top half라고 보는거죠) 이 bottom half로 미루어진일들은 나중에 적절한 시점에 커널이 수행하게 됩니다. 예를 들어 network카드 같은경우 해야할일들이 많습니다. network에서 읽어온 데이터를 카드에서 메모리로 복사한이후에도 protocol stack을 거쳐서 처리해줘야합니다. 이러한 일들은 interrupt handler에서 바로 수행한다면 response time을 크게 저해할뿐 아니라 performance에도 큰 영향을 미칠것입니다. 따라서 interrupt handler는 network card의 데이터를 복사한이후에 ack를 날리는등 기본적인 일들만을 처리하고 이후의 일들은 bottom half로 미루어놓는것이 여러모로 좋을것입니다.


리눅스에서의 bottom half 매커니즘을 살펴보겠읍니다. 역사적으로 BH(Bottom Half)라는 매커니즘과 (여기서 BH는 특정 매커니즘을 가리키는 용어로 헷갈리게 하는 이름이죠.) task queue라는 매커니즘이 있었지만, 현재 리눅스에서는 사용되지 않고 softirq, tasklet, work queue라는 3개의 매커니즘을 사용합니다. 이에 대해서 간단히 살펴보겠읍니다.


먼저 softirq는 커널 소스에서 static하게 32개의 softirq를 정의하고, device driver가 여기에 등록을 하여서 사용합니다. 이렇게 등록된 softirq는 device driver등에서 적절한 시점에 raise합니다. softirq가 raise되었다는것은 해당 softirq에 등록된 bottom half들이 수행되길 원한다는 뜻으로, 이후의 어떤 적절한 시점에 커널이 수행해주게 됩니다. device driver는 보통 이렇게 등록된 자신의 bottom half를 interrupt handler의 끝부분에서 softirq를 raise하여서 실행해달라고 하게됩니다. 이런 softirq는 1) hardware interrupt handler가 끝날때 2)ksoftirqd에서 3) 네트워크 subsystem등에서 explicit하게 수행을 지정할때 와 같은 경우들에서 수행됩니다.


이 softirq의 단점은 static하게 정의되어있다는것입니다. 사용하기 까다롭습니다. 그 주요한 이유는 softirq의 장점이자 단점이기도한데, 같은 type의 softirq들이 다른 CPU에서 얼마든지 수행가능하다는것입니다. 이처럼 bottom half가 MP에서 scalable하다는것은 Linux 2.6에서 가지는 큰 장점입니다. 다른 type은 물론이고 같은 type의 softirq가 다른 CPU에서 수행가능하다는것은 반대로 이 bottom half들이 synchronization에 신경을 써야한다는것을 뜻합니다. 즉 자신의 코드가 reentrant하게 해야하기때문이죠. 이로 인해서 bottom half제작이 어려워집니다.


이제 tasklet에 대해서 살펴보면,(tasklet은 Linux의 task와는 무관합니다.) softirq위에서 구현된 bottom half로 좀더 사용의 편리함을 제공하는 방식입니다. softirq와는 달리 dynamic하게 등록되어서 수행될수 있읍니다. 그러나 이 tasklet은 같은 type의 tasklet이 동시에 다른 CPU에서 수행될수 없다는 단점을 가집니다. 이것은 곧 tasklet이 자신과의 synchronization에 신경쓸 필요가 없다는 뜻이 되고, 프로그래밍을 훨씬 편하게 만들어줍니다. 물론, 다른 종류의 tasklet은 얼마든지 다른 CPU에서 동시에 수행될수 있읍니다. 따라서 tasklet은 사용하기 어렵고 프로그래밍이 어려운 softirq에 대한 대안으로 성능(scalability)는 어느정도 포기하면서 편의성을 추구하는 tradeoff라고 할수 있읍니다. 이 tasklet은 그러한 점들만 뺀다면 본질적으로 softirq인것입니다.


대부분의 device driver에게 있어서 tasklet이면 충분합니다. 따라서 딱히 softirq가 필요한 상황이 아니라면 tasklet을 사용하면 됩니다. 그렇다면 언제 softirq를 써야하는걸까요? synchronization의 부담을 안고서라도 성능(scalablility)를 얻고 싶을때입니다. 그외에는 tasklet을 쓰면 됩니다. softirq의 존재이유는 scalability입니다. 현재 이러한 이유로 softirq로 등록된것은 timer와 network, scsi 장치들입니다. 처리될 일들이 많고 자주 들어오며, 이로 인해 scalability가 필요하기때문이죠. 따라서 이러한 경우가 아니라면 softirq를 사용하지 않아도 tasklet으로 충분합니다.


Work queue는...


사실 이러한 bottom half들은 interrupt handler가 종료된 이후에 보통 곧바로 수행됩니다. 그러나 여기서 중요한것은 bottom half가 interrupt가 enabled된 상태로 수행된다는 점입니다. 또 하나는 MP환경에서 다른 CPU들이 bottom half를 처리할수 있다는 점입니다. 즉 scalability가 높다는점입니다. 이것은 특히 tasklet보다 softirq가 가지는 장점인것입니다.


 



 






Process의 구성


하나의 process가 address space를 어떻게 사용하는지 살펴봅시다. 일반적으로 하나의 process는 주소공간에서는 4개의 부분들로 구성됩니다. 이 4개의 부분을 segment라고 부릅니다. (일부 CPU가 제공하는 segment메커니즘과는 별개의 것으로 생각합시다. 그것들을 사용할 수도 있지만, 독립적으로 구현할 수도 있습니다. 예로, 리눅스는 인텔의 segment메커니즘을 이용하지 않고 독립적으로 이 segment를 구현합니다.) 이 4개의 segment들이 text segment, data segment, BSS segment, stack segment입니다. text는 실행되는 image를 말합니다. 이 segment는 보통 read-only이며 (일반적으로 reentrant code이기 때문입니다. 자신을 수정하지 않는 code를 뜻합니다.), loader에 의해서 메모리에 load됩니다. data segment는 initialized data를 뜻하며, C코드에서 초기화가 되어 있는 global static 변수들이 여기에 해당됩니다. 반면, bss는 uninitialized data를 뜻하며, 초기화되지 않은 global static 변수들을 말합니다. 이 둘의 차이점은, initialized data는 초기값이 있기 때문에 실행화일내에 실제로 포함되어 있는 데이터인 반면, 즉 compile time에 초기값이 정해져 있어 이미지와 함께 loading되는 반면, bss는 초기화가 되어 있지 않아서 실행 파일내에 포함되지 않고, loader에 의해서 load후 메모리가 할당되고 0으로 채워진다는 점입니다. bss는 "Block Started by Symbol"의 약자로, 오래전 어셈블러 니모닉에서부터 온 이름입니다. stack segment는 스택으로 사용되는 segment입니다. 이중 text와 data는 compile time에 그 크기가 정해져있지만, bss(혹은 heap이라고도 불리웁니다)와 stack segment는 크기가 실행도중 바뀔 수 있습니다. 이 4개의 segment는 일반적으로 다음과 같이 배치됩니다.



 


(from "Unix Systems for Modern Architectures" by Curt Schimmel)


bss는 최상위 주소가 윗 방향으로 자라거나,즉 커지거나, 혹은 줄어듭니다. 반면, stack은 그와 반대로 아랫 방향으로 자라나거나 윗방향으로 줄어듭니다. bss는 sbrk 혹은 brk system call에 의해서 자라나거나 줄어듭니다.


그외에도, 모든 process는 user mode stack말고도 kernel mode stack을 가집니다. linux에서는 프로세스 생성시 kernel mode stack을 2개의 page를 할당하여 마련하는데, 이 stack은 kernel mode에서 실행될 때 사용되는 stack입니다. 또한 linux의 경우 task descriptor(PCB)를 이 kernel mode stack의 바닥에 놓습니다.



 프로세스는 그외에도 파일들을 소유합니다. file descriptor로 각 파일들에 접근할 수 있는 것입니다.


실제 쓰레드구조에서는 다음과 같이 복잡해집니다. 스택이 여러개가 생기고, 각 스택으로 TCB에서 포인터가 나가기 때문입니다. 각 쓰레드는 다른 쓰레드의 스택을 볼 수는 없습니다. 그 쓰레드의 SP를 가지고 있지 않으니까요. (하지만 꽁수등을 써서 강제로 접근한다면 물론 segment fault를 재주껏 피한다면 다른 쓰레드의 스택을 망칠 수는 있겠죠. 자신의 address space이니까요) 한 address space에 이처럼 여러개의 스택이 들어서면 문제가 될 수 있습니다. 스택 자체의 크기도 제한받게되고 어느곳에 적절히 배치할 것인가등이 문제가 될 수 있습니다. (아마도) Linux에서 스택의 크기를 고정시켜 버린 것도 이런 이유가 있을 것입니다.


사실 이런 thread들의 스택들간의 보호도 되어야하겠지만 현재 쓰레드들간의 보호는 그다지 이루어지지 않고 있습니다. 왜냐하면 쓰레드는 경량이라는 장점을 취하기 위한 것이기 때문이죠. 쓰레드가 마음먹고 다른 쓰레드를 망치려든다면 못할 것이 없는셈입니다. 그건 단지 잘못된 코드일뿐이겠죠. (자신을 쏘겠다는 사람을 말리진 않는거죠 :-P) 단지 리눅스에서는 스택이 자라면서 넘칠 수는 있기 때문에 각 스택들 사이에 guard page를 넣어두어서 이를 방지하고 있읍니다.



forking을 할 것인지 threading을 할 것인지를 결정하는 것 역시 중요한 사항입니다. 기본적으로 공유되는 데이터가 많다면 threading이 유리합니다.


 


 






Kernel memory manager


커널에서의 메모리 관리는 중요한 문제입니다. application에서와는 다르게 malloc/free등의 장치들이 없기 때문에 커널은 자신이 스스로 메모리를 할당하고 해제하는 문제를 풀어야합니다. 또한 이는 무척 효율적으로 이루어져야합니다. 메모리의 할당과 해제는 매우 빈번하게 일어나기 때문에 시스템의 성능에 큰 영향을 미치기 때문입니다.


 


Kernel memory allocator


이와 같은 커널 메모리의 할당과 해제를 담당하는 부분을 KMA라고 하는데, KMA는 기본적으로 페이지 단위로 할당과 해제를 합니다. 커널의 각 부분에서 물리 페이지를 필요로할 때 KMA에 요청하게 되고, 다 사용한 물리 페이지는 해지하게 됩니다. 이 KMA의 또다른 중요한 임무중의 하나는 최대한 물리적으로 연속된 메모리할당을 할 필요가 있다는 것입니다. 이것은 DMA를 위해서도 그렇고, 캐쉬의 효율성을 증대시키기 위해서도 물리적으로 연속된 메모리 할당이 필요합니다.


리눅스에서는 이를 위해 buddy algorithm을 사용합니다.


<buddy algorithm생략.>


 


 


Slab Allocator


실제로 커널에서 메모리의 할당이 빈번하게 일어나는 경우는 페이지 단위보다도 특정 구조체들의 경우입니다. 커널은 수십~수백바이트정도의 구조체들을 빈번하게 할당/해제할 필요가 있고, 이를 위해서 KMA에서 페이지단위의 할당을 받는 것은 좋은 생각이 아닙니다. 이를 해결하기 위해서 등장한 것이 slab allocator입니다. 이는 일종의 캐시라 할 수 있는데, KMA로부터 페이지를 할당받고 이 위에서 구조체 할당 요청을 해결합니다. 이를테면 구조체 할당의 pool인셈입니다. 예를 들어 A라는 구조체에 대해서 미리 KMA로부터 페이지들을 할당받아 놓은후에 여기에 A구조체를 여러개 만들어 놓습니다. 이후에 A구조체에대한 요청이 오면 그중 하나를 리턴해줍니다. A구조체에 대한 요청이 많아져서 pool이 모자르면 새로운 페이지들을 KMA로부터 할당받아서 pool을 늘리기도 하고, 커널이 메모리가 부족하여 페이지의 반환을 요구하면 slab allocator는 사용하지 않는 구조체들을 제거하고 해당 페이지를 반환하기도 합니다.또한 여기에는 OOP의 개념이 좀 들어가서 구조체를 할당하고 해제할 때는 설정되어있는 constructor와 destructor가 실행되게끔 되어있습니다. 또한 이런 구조체의 offset을 약간씩 조정해서 캐시의 효율을 올리는 기법도 사용됩니다.


즉, 이런 slab allocator는 구조상 KMA위에서 돌고 있는 일종의 캐시라고 할 수 있겠습니다. 리눅스에서의 slab allocator구현등은 물론 애초에 논문으로 나왔던 것과는 많이 다르지만, 처음의 논문을 살펴보는 것도 좋은 공부가 될 듯합니다. 다음은 Slab allocator논문입니다.


http://www.usenix.org/publications/library/proceedings/bos94/bonwick.html


지금부터는 제가 어느정도 요약한 내용입니다. 전부는 아니고 4장까지의 대략적인, 주요한 내용을 제가 다시 써봤읍니다. 시간이 나면 전체를 한번 번역해보도록 하지요. 그중 혹시 틀린 내용이 있다면 지적바랍니다. 원문이 워낙 잘 쓰여져있으니, 읽어보신후 제가 이해한 내용과 비교해보시면 도움이 되지 않을까싶군요.

커널이 자주 쓰는 복잡한 객체를 할당할 때는 메모리 할당보다 construction과 destroy에 비용이 더 든다.
이것을 줄여보자는 것이 기본 idea이다. 자주 쓰이는 object들은 object cache에 넣어서 유지하면서 필요시
할당되고 반환되지만 constructor와 destructor는 다시 불리우지 않는다. 이 object cache는 전역적인
메모리 압박에 dynamic하게 반응하며, object coloring을 사용하여 시스템의 전체 cache성능과 bus balance를
향상시킵니다. 또한 시스템의 여러 가지 문제를 해결할 때 유용할수 있는 여러 가지 통계치 디버깅 기능도
가지고 있습니다.

1. 서론
자주 쓰이는 커널 자료구조를 cache 함으로써 성능을 향상시킬수 있다.

2. Object caching
Idea는 construction이 된 초기상태의 불변(invariant)부분을 보존하자는 것이다.
예를 들어, mutex를 포함하는 객체는 객체가 생성될 때 단 한번만 mutex_init()이 불려지게 된다.
이후 캐쉬안에 있으면서 여러번 재사용될 것이다. object에 포함되어있는 locks와 condition variables,
reference counts, 다른 객체의 리스트, read-only data등은 모두 일반적으로 초기상태로서 간주한다.
이런 cache는 특히 멀티쓰레드 환경에서 매우 유용하다. 자주 쓰이는 객체들이 대부분 하나 이상의 내장된
locks나 condition variables등을 가지고 있기 때문이다. object cache의 구현은 간단하게, object가
요구되면 cache에서 꺼내주고, 없다면 새로이 만들어서 주면되고, object가 반환되면 단순히 cache에
되돌려줄뿐이다. 캐쉬가 전역 메모리 할당자에 의해서 메모리를 반환할 것을 요구받게되면, 객체들을
destroy하고 메모리를 반환하게 된다. 객체는 캐쉬에 들어올 때 한번만 초기화되며, 그 이후로는 객체의
할당과 반환은 trivial하다.

물론 이런 object cache는 중앙 할당자와는 별개로 독립적으로 구현될수 있으나 다음과 같은 한계가 있다.
1) 중앙할당자와의 메모리에 대한 tension이 있는데, 이것에 대처할수 없다. 즉, 중앙할당자가 페이지들을
필요로 할때 자신의 남는 페이지들을 반환해줄수가 없다.
2) 중앙 할당자를 우회해가기(bypass) 때문에 유용할수 있는 통계치나 디버깅 기능을 가지지 못한다.
3) 공통된 할당 문제에 대해서 이렇게 독립적으로 구현된 여러 cache들은 커널의 크기를 증가시키고
유지비용을 크게한다.

이러한 이유로 인해 object cache는 중앙할당자보다 그 client들과의 보다 긴밀한 협조를 필요로 한다.

인터페이스 설계를 위해서 다음을 생각해보자.
(A) 객체를 서술하는 내용들(이름,사이즈,정렬,생성자,소멸자등...)은 할당자가 아닌 client에 속한다.
(B) 메모리 관리는 중앙 할당자에게 속한다. 즉, client는 메모리의 할당과 해제에 대해서는 신경쓰지 않는다.

(A)에 의해서 객체 생성은 client-driven이어야하며 client가 객체에 대한 모든 정보와 spec을 가지고 있어야
함을 알수 있다. 이에 따른 인터페이스를 보면,
(1) struct kmem_cache *kmem_cache_create(char *name, size_t size,int align,
void (*constructor)(void *,size_t),
void (*destructor)(void *, size_t) );
object cache를 생성한다. 이름과 생성자와 소멸자를 받음을 알수 있다.

(B)에 의해서 client는 단순히 빈 객체를 할당/해제받는 함수만이 필요함을 알수 있다.
(2) void *kmem_cache_alloc(struct kmem_cache *cp, int flags);
캐쉬에서 객체를 얻는다. 물론 객체는 미리 만들어져있는 상태다. flags는 KM_SLEEP이나
KM_NOSLEEP이다. 이는 만일 현재 사용가능한 객체가 없다면 메모리를 할당받을때까지
기다릴지 아닐지를 나타낸다.
(3) void kmem_cache_free(struct kmem_cache *cp, void *buf);
캐쉬에 객체가 반환된다. 객체는 반드시 initial state에 있어야한다.
(4) void kmem_cache_destroy(struct kmem_cache *cp);
캐쉬를 제거하고 모든 메모리/자원을 반환한다. 모든 할당된 객체들은 캐쉬에 돌아와 있어야만 한다.

이러한 인터페이스를 써서 client의 요구에 부응하는 할당자를 구현할수 있다. 이런 의미에서
"맞춤형"할당자라고도 할수 있다. 이러한 맞춤은 client가 실행시간에 필요할 때에 할당자에게
알려서 사용할수 있게끔 한다.

이때 부가적으로 좋은점은, instruction cache가 생성자와 소멸자의 footprint를 가지지
않는다는 것이다.

3. 슬랩할당자의 구현

back end front end
-------- ---------
---------------
kmem_cache_grow() --> | | --> kmem_cache_alloc()
| cache |
kmem_cache_reap() <-- | | <-- kmem_cache_free()
---------------

front end는 client와 할당자와의 인터페이스이다. 이것은 객체들을 캐쉬에서 꺼내거나
집어넣게된다. back end는 캐쉬와 중앙 할당자와의 인터페이스로서, 캐쉬로의 메모리의
유입을 제어한다. kmem_cache_grow()는 VM시스템에서 메모리를 가져온다. 그리고
kmem_cache_reap()은 VM이 메모리를 필요로 할때 불려져서 쓰이지 않는 캐쉬의 메모리를
VM에게 반환한다. 이러한 back end의 활동은 오로지 메모리의 압박에 의해서만 호출됨을
유의하라. 캐쉬가 더 많은 객체가 필요할때 메모리는 캐쉬로 유입되고, 나머지 시스템이
더 많은 페이지를 필요로 한다면 캐쉬에서 메모리는 방출된다. 거기에는 어떤 제한이나
watermarks도 없다. 이러한 이력(hysteresis)에 의한 제어는 working-set 알고리즘에
의해서 제공된다.
슬랩 할당자는 어떠한 단일체라기보다는 독립된 object 캐쉬들의 느슨한 연합체라고 할수
있다. 이 캐쉬는 공통되는 상태(state)라는것이 없기때문에 각각의 캐쉬들은 자신들만의
locks를 가질수 있고, 각 캐쉬들은 동시에 접근될수 있다. 각 캐쉬들은 자신만의 통계치
를 가지는데, 이것으로 종합적 시스템의 동작상황을 알수 있다. 어떤 부분이 어느만큼의
메모리를 소모하고 있는지, 또는 memory leak현상이 있는지등을 알수 있다. 즉, 각 subsystem
의 activity level을 알수 있게된다.

슬랩 할당자는 일종의 customized segregated storage allocator이다. 이런 류의 할당자는
각 크기마다의 freelist를 유지한다. CustoMalloc할당자나 QuickFit할당자, Zone할당자들이
그러한 할당자들이다. 이들은 보통 space나 time에서 있어서 optimal이다. 이들은 미리
잘 쓰이는 할당크기들에 대한 정보를 가지고 있다. 슬랩 할당자도 이들과 같은 유형이다.
그러나 차별적인 점은 컴파일시간이 아닌 런타임에 client에 의해서 맞춰지는 client-driven
방식이라는 점이다. (이것은 Zone할당자도 마찬가지다.)

표준 kmem_alloc과 kmem_free는 내부적으로 이 캐쉬를 쓴다. 시작시에 8바이트에서 9K까지
대략 10-20%씩 증가하는 크기의 30개의 캐쉬를 유지한다. kmem_alloc()은 가장 가까운 크기의
캐쉬로부터 kmem_cache_alloc()를 수행한다. 9K보다 큰 할당은, 드물지만, 직접 중앙 할당자에
의해서 이루어진다.

슬랩은 캐쉬의 구성단위이다. 캐쉬가 늘어날 때 슬랩단위로 늘어난다. 여러개의 가상주소에서
연속된 페이지들로 구성된며 간단한 reference count를 가진다. 이 count는 이 슬랩에 속한 객체중
얼마나 많은 객체들이 할당되었는지를 나타낸다. 이 count가 0이어야지만 이 슬랩은 소멸될수 있다.
이런 간단한 구조에 의해서,
(1) 메모리수거가 편리하다. reference count가 0이면 그냥 반환될수 있다. 간단한 reference count
에 의해서 다른 할당자들이 쓰는 복잡한 비트맵, tree, coalescing 알고리즘등을 대체한다.
(2) 객체의 할당과 해제가 편리하다. 단순히 객체를 옮긴후 reference count만 바꿔주면 된다.
(3) 심각한 외부 단편화가 일어나지 않는다.
(4) 내부 단편화가 최소다.
- 하나의 슬랩이 n 개의 객체를 가질수 있다면, 단편화는 최대 1/n 이다. 따라서
이 조절은 슬랩의 크기에 의존한다. 그러나 너무 크면 외부단편화가 일어나게 된다.
이 사이엔 tradeoff가 있으므로,SunOS 5.4에서는 내부단편화를 12.5% (1/8) 로 제한하였다.



슬랩의 논리적 구조

--------
| kmem |
| slab |
--------
|
|
V
-------- -------- --------
| kmem | -----> | kmem | -----> | kmem |
|bufctl| |bufctl| |bufctl|
-------- -------- --------
| | |
| | |
V V V
--------------------------------------------------------
| | | | |
| buf | buf | buf |unused|
| | | | |
--------------------------------------------------------

|<---------------- one or more pages ----------------->|

kmem_slab 자료구조는 캐쉬에서의 슬랩의 연결을 관리하고, reference count를 가지고,
free list를 가진다. 이제, 각 버퍼(객체)는 kmem_bufctl에 의해서 제어되는데, freelist
linkage와, 버퍼의 주소, slab으로의 back pointer를 가진다. (그림에서 back pointer는
생략되었다.)

페이지의 1/8보다 작은 작은 객체에 있어서, 슬랩은 다음과 같이 페이지에 구성된다.

------------------------ --------------------------------------
| | | | | | un- | kmem |
| buf | buf | ... | buf | buf | used | slab |
| | | | | | | data |
------------------------ --------------------------------------

|<------------------------- one page -------------------------->|

여기서 각 버퍼는 freelist에 있는동안 스스로가 bufctl로의 역할을 한다. 다른것들은
모두 계산가능하므로, 실제 필요한것은 linkage뿐이다. freelist linkage는 버퍼의 끝에
위치한다. (이를 위해 버퍼는 생성된 객체보다 한 word가 더 크다.) 이는 디버깅을
편리하게 하기 위함이다. 자료구조의 끝보다는 앞이 active하기 때문이다. 만일 버퍼가
해제된후에 수정되었다면, freelist linkage가 변하지 않고 있을때 디버깅이 편하기때문이다.

큰 객체에 있어서는 슬랩의 구조는 그 논리적 구조와 동일하게 된다. 필요한 slab data와
bufctl data는 그들 스스로가 작은 객체이므로 자신들의 캐쉬에서 나오게 된다.


Freelist management

각 캐쉬는 환형 더블 링크리스트로 슬랩을 엮는다. 소팅된 순서로, 빈 slab(모든 버퍼들이

할당된 slab)이 먼저오고, 부분적으로 쓰인 슬랩이 다음에, complete 슬랩(ref count = 0인
slab)이 뒤에 온다. 캐쉬의 freelist포인터는 이중 첫번째 non-empty슬랩을 가리키고, 이
슬랩은 이제 자신의 버퍼에 대한 freelist를 가진다. 이런 이중 구조는 메모리의 해제를 쉽게
해준다. 메모리를 반환할 때, 버퍼들을 unlink하는게 아니라 단순히 slab을 unlink한다.

kmem_cache_free()가 reference count 가 0인 슬랩을 보면, 리스트의 끝으로 보낸다. 이렇게
해서 complete slab이 partial slab이 있음에도 사용되는일이 없도록 한다. 메모리가 부족하여
VM이 메모리 해제를 요청해올땐, thrashing방지를 위한 최근 사용된 15초 working set만 남기고
해제한다.

4. 하드웨어 캐쉬 효과

buffer address 의 분포는 성능에 많은 영향을 준다. 그래서 2^n의 주소에 정렬하는 알고리즘은
안좋은 영향을 준다. 구조체에서 자주 쓰이는 필드가 앞부분에 몰려있다. 이것 역시 좋지 않다.
예전엔 신경쓰지 못하던 부분들이지만 이제 중요하다. 슬랩 할당자는 간단한 slab coloring이라는
개념으로 buffer address를 캐쉬에 고루 분포시키고 있다. 새로운 슬랩이 만들어질때, 버퍼의
주소는 슬랩의 base로부터 약간씩 다른 offset(color)에서부터 시작한다. 이렇게 해서 좋은점중의
하나는 2^n의 중간 사이즈 버퍼는 최대의 coloring을 가진다는것이다. 이는 kmem_slab데이터 때문에
worst fit이 되기 때문이다.

작거나 midsize 버퍼에 대해서 또 좋은점은 이들이 한 페이지안에있기 때문에, 단일 TLB entry가
대부분의 action을 커버할수 있다는 점이다.

 


 






Synchronization #1


 


Atomicity


먼저 synchronization문제가 왜 생기는지 생각해봅시다. 그것은 공유되는 데이터 때문이죠. 공유된다는 것이 의미하는 것이 어떤것일까요. 이것은 그리 간단하지 않은 문제입니다. '공유'된다는 것은 두 개 이상의 여러개의 쓰레드에서 그것에 접근하여서 데이터를 write해넣을 수 있다는 것을 의미합니다. 공유되는 데이터라 하더라도 read-only라면 synchronization문제는 발생하지 않습니다. read는 사실 그리 중요하지 않습니다. 문제는 read한 직후에 그 값이 그대로 있지않을 수도 있다는, 다른 쓰레드가 write를 하여 변화가 발생했을지도 모른다는 것입니다. 모든 문제는 여기서부터 시작됩니다. 그러나 문제가 그리 간단하지는 않습니다. 이 synchronization문제는 하드웨어의 가장 밑바닥에서부터 DB와 같은 user level의 가장 높은 수준에서까지 모든 범위에 걸쳐서 발생합니다. 이것은 이 문제가 가장 본질적인 문제중의 하나라는 것을 뜻합니다. 이제 synchronization문제의 조건들을 생각해보겠읍니다.


앞서 이야기했듯이 '공유'된다고 말할 수 있는 모든 데이터에 대해서 이 synchronization문제는 발생합니다. 그러나 스택에 쌓이는 local variable들이나 별개의 주소공간을 가지는 프로세스들 사이에서는 이러한 문제가 없습니다. 즉 '공유'된다는 것은 위의 의미에 더해서 다른 누군가(thread)가 그 값을 변화(write)시킬 수 있는 가능성이, 즉 그러한 논리가 성립하고 있다는 것이 중요합니다.


그러나 한편으로는, 여러 쓰레드가 위와같이 공유하는 데이터라 할지라도 한순간에 단 하나의 쓰레드만이 write를 하고 나머지 쓰레드는 read만을 한다던지, read후의 값의 변화에 아예 관심이 없다면 역시 synchronization문제는 발생하지 않습니다. 예를 들어 A,B 두 개의 thread만이 있을 때 A는 그 값을 읽다가 1일 때만 1에서 0으로 내리고, B는 그 값을 읽다가 0일 때만 0에서 1로 올린다면 synchronization은 불필요할 것입니다. 즉 중요한 것은 데이터를 공유하는 쓰레드중 최소 하나가 그 값을 read혹은 write한후에 그 값에 관심이 있고, 논리적으로 한동안 그 값에 변화가 없음을 보장받아야 한다는 점입니다. (Handshaking protocol이나 이후에 나오는 bounded buffer producer-consumer problem 1번 solution을 생각해보세요.)


이제 쓰레드를 생각해보겠습니다. 쓰레드라는 단어 역시 여러 가지 의미를 가질 수 있기 때문에, 이 synchronization문제에 관해서 가장 넓은 범위에서 생각해보면, 데이터에 접근할 수 있는 모든 방법입니다. 단 한비트를 저장하는 flip-flop이라도 여기에 여러 wire가 같이 묶여있어서 동시다발적으로 값에 접근할 수 있다면 (물론 클럭등에 맞춰서 작동한더라 하더라도.), 이 각 wire는 우리가 생각할 수 있는 쓰레드이고, 이들간에서도 똑같은 synchronization문제가 발생할 것입니다. 사실 이 문제는 여러개의 CPU가 하나의 메모리를 공유할 때 나타나고 있죠. 더 나아가 CPU내의 레지스터에 대해서도 역시 이러한 문제는 발생합니다. 수퍼 스칼라나 하이퍼쓰레드같은 SMT기술을 채용하고 있다면 역시 이러한 문제를 겪을 것입니다. 즉 데이터가 존재하고 이것에 접근할 수 있는 경로가 두 개 이상일 때 synchronization문제는 발생할 수 있습니다.


만약 한비트짜리 flip-flop에 접근할 수 있는 경로가 두 개이상인데, 이것이 클럭등에 맞춰서 동작하지 않는다면, 즉 값이 asynchronous하게 변화하게 된다면, 논리적으로 이들간에 synchronization은 불가능할것입니다.(물론 이러한 경로들간의 다른 채널은 없다고 할때) 즉 최소한 하나의 경로가 이 데이터에 접근할 때는 다른 경로는 기다리고 있어야할 것입니다. 즉 이러한 기본적인 atomicity(즉 locking)이 하드웨어에서부터 제공되어야 합니다. 그렇지 않다면 그런 경로들간의 동기화는 불가능합니다. (network에서의 two army problem이죠.)


이런 관찰에 기반해서 이 문제에 대해서 다음과 같이 synchronization문제에 대한 조건들을 정리해보겠읍니다.


 


synchronization문제의 조건들


1) 데이터가 존재하고 이것에 접근할 수 있는 논리적 경로가 두 개 이상일때.


2) 경로중 최소 하나 이상이 그 값을 read혹은 write한후에 '한동안' 그 값에 변화가 없음을 보장받아야 할 때.


3) 최소한의 atomicity, 즉 lock기법이 제공될때.


 


3번 atomicity에 대해서 생각해보도록 하겠습니다. 결국 뒤에서 살펴보게될 critical section등은 이러한 atomicity를 달성하기 위한 기법들이라고 할 수 있습니다. 즉 우리가 synchronization문제를 푼다는 것은 3번에 주어진 최소한의 atomicity를 논리적으로 잘 쌓아서 2번에서의 '한동안'이라는 원하는 만큼의 구간을 atomic하게 만드는 것입니다. 즉 이러한 atomicity를 확장시키는 것이 synchronization문제를 푸는 것이라고 할 수 있습니다. 그 시작은 3)번에서 주어진 최소한의 atomicity입니다. 따라서 각 환경에서 이러한 밑바닥을 명확히 아는 것은 중요합니다. 그렇다면 우리가 기댈 수 있는 최소한의 atomicity는 UP환경에서는 instruction이고, MP환경에서는 micro-op(혹은 lock이 지원되는 instruction)이 됩니다. (좀더 자세한 내용은 Atomicity 장을 참조하세요.) 즉 이러한 최소한의 atomicity는 하드웨어가 제공하는 것입니다. 사실 synchronization을 위한 최소한의 atomicity는 메모리 버스의 lock에 의해서 제공되는 것입니다. 이 기능에 의해서 메모리 reference가 serialize가 되니까요. 우리는 이러한 하드웨어의 조건위에서 S/W적으로 atomicity를 확장시키는 것입니다. 즉 synchronization문제는 특정 상황이나 용어의 문제가 아닌 논리의 문제입니다.


이 뒤로는 논의의 편의를 위해 instruction level에서 최소한의 atomicity가 제공된다고 생각하겠읍니다.


 


 


Bounded Buffer producer-consumer problem


제한된 버퍼를 두 쓰레드가 한쪽에서 읽고 한쪽에서 사용하는 문제를 생각해봅시다. 공유되고 있는 버퍼는 다음과 같습니다.


 


#define BUFFER_SIZE 10


 


typedef struct {
...
} item;

item buffer[BUFFER_SIZE];
int in=0;
int out=0;

 


producer의 코드는 다음과 같습니다.


 


while(1) {
   /* produce an item in nextProduced */
   while(((in+1)%BUFFER_SIZE)==out)
       ;    /* do nothing */
   buffer[in] = nextProduced;
   in = (in+1)%BUFFER_SIZE;
}

consumer의 코드는 다음과 같습니다.


 


while(1) {
   while (in == out)
       ;   // do nothing

   nextConsumed = buffer[out];
   out = (out+1)%BUFFER_SIZE;
   /* consume the item in nextConsumed */
}


(공룡책 4장에서 가져옴.)


이 첫 번째 솔루션은 버퍼를 BUFFERSIZE-1개만큼만 사용한다는점만 빼면 잘 동작합니다. 이제 두 번째 솔루션을 보면,


producer의 코드는 다음과 같습니다.


while(1) {
   /* produce an item in nextProduced */
   while(counter == BUFFER_SIZE)
       ;    /* do nothing */
   buffer[in] = nextProduced;
   in = (in+1)%BUFFER_SIZE;
   counter++;
}

consumer의 코드는 다음과 같습니다.


 


while(1) {
   while (counter == 0)
       ;   // do nothing
   nextConsumed = buffer[out];
   out = (out+1)%BUFFER_SIZE;
   counter--;
   /* consume the item in nextConsumed */
}


counter라는 공유 변수를 쓴 두 번째 버전에서는 synchronization문제가 발생합니다. 그것은 ++와 -- 연산자가 read-modify-write하는 instruction이기 때문인데, 보통 ++는 instruction level에서 다음과 같이 컴파일됩니다.


register1 = counter
register1 = register1 + 1
counter = register1


--의 경우도 역시


register1 = counter
register1 = register1 - 1
counter = register1


이 두 개의 실행이 겹치게 되면 다음과 같은 문제가 발생할 수 있습니다.


T0:    producer    execute    register1 = counter          {register1=5}
T1:    producer    execute    register1 = register1 + 1    {register1=6}
T2:    consumer  execute    register2 = counter            {register2=5}
T3:    consumer    execute    register2 = register2 - 1    {register2 = 4}
T4:    producer    execute    counter = register1            {counter = 6}
T5:    consumer    execute    counter = register2            {counter = 4}


(공룡책 7장에서 가져옴.)


결과적으로 원하는 결과인 counter=5가 아니라 counter=4가 나올 수 있음을 볼 수 있습니다. 혹은 counter=6이 나올 수도 있습니다. 이 처럼 공유되는 데이터에 두 개 이상의 쓰레드가 한꺼번에 덤벼들어서 논리적인 문제가 발생하는 상황을 race condition이라고 합니다. 논리적으로 atomic해야 하는 부분에서 atomic하지 않을 때 이러한 race condition이 발생할 수 있읍니다.


처음의 solution에서 synchronization문제가 없었던 것은 사실 위에서의 2번 조건이 성립하지 않기 때문입니다. in과 out값을 읽어서 비교하는 부분에서 중요한 것은 그 순간의 비교 결과일뿐이지 read후의 값의 변화에는 관심이 없기 때문입니다. in에 대해서 생각해볼 때 한쪽에서만 write가 일어나고 다른쪽에서는 read만을 하고 있고, out에 대해서도 마찬가지입니다. write가 한쪽에서만 일어나고 있기 때문에 문제가 되지 않고 있습니다.


반면 두 번째 counter를 쓴 버전에서는 counter값이 양쪽 모두에서 write가 되고 있기 때문에 race condition이 발생합니다. counter++; 나 counter--;라는 statement는 atomic하지 않기 때문입니다. instruction level에서의 atomicity를 이러한 statement레벨, 혹은 block레벨까지 확장하는 것이 바로 synchronization문제 라고 할 수 있읍니다.


이 두 개의 solution을 비교하는 것은 무척 의미있는 일입니다. synchronization문제가 단순히 데이터가 공유된다고해서 일어나는 것은 아니라는점을 보여주고 있으며, 논리의 문제인 synchronization을 약간의 논리의 변화로 훌륭하게 풀 수 있음을 보여주기 때문입니다. 실제로 많은 경우에 약간의 논리의 변화로 해결될 수 있는 문제들을 불필요하게 synchronization해법으로 해결하는 경우가 있기 때문입니다. 어느쪽이 훌륭한 해법인지는 두말할 필요가 없을 것입니다.


 


 


Short critical section and spinlock


이러한 race condition을 막기 위해서 일련의 instruction들의 구간을 한번에 한 쓰레드만이 진입할 수 있게 만들어줄 때, 이러한 구간을 critical section이라고 부릅니다. 즉 쓰레드들에 대해서 mutually exclusive한 구간을 뜻합니다.


 


entry section
    critical section
exit section


 


entry section에서는 다른 쓰레드들의 진입을 막는 코드, 즉 lock을 걸고, exit section에서는 다른 쓰레드의 진입을 허용하는 코드, 즉 lock을 풀어주는 코드를 넣으면 됩니다. 이러한 lock으로 critical section이 짧은 경우 주로 spinlock이 쓰입니다. 이렇게  특정 공유변수들에 접근해서 race condition을 발생할 수 있는 코드들을 critical section으로 묶어서 atomicity를 확보합니다. 그러나 그 공유변수에 접근하는 모든 코드들을 이렇게 묶을 필요는 없습니다. 앞서 이야기했듯이 단순한 read와 같은 경우 race condition이 발생하지 않는다면 그럴 필요가 없습니다. critical section은 최소화할 필요가 있으니까요.


사실 이런 critical section이 atomic하다는 것은 아까와는 약간의 의미에서의 차이를 가집니다. 실제로 instruction들이 연속적으로 수행된다는 것이 아니라 context switching이나 interrupt등이 발생하여도 (물론 발생할 수 있으니까) race condition은 발생하지 않는다는 것을 뜻하게 됩니다. 다른 쓰레드들은 기다려줄테니까요. 다만 UP환경에서 interrupt disable/enable로 위와 같은 critical section을 구현한다면 context switching이나 interrupt에 방해받지 않고 말 그대로 critical section은 instruction level에서 atomic하게 수행될 것입니다. 이 기법은 UP환경에서 유용하게 쓸 수 있는 트릭입니다. (그러나 MP환경에서는 이 방법은 통하지 않습니다.) (반드시 lock으로 특정 변수가 쓰일 필요는 없다는 것을 보여주기도 하는군요)


 


disable_intr();
    critical section
enable_intr();


 


그러나 보다 일반적인 해법을 생각해보기 위해서 정수 S값에 대해서 다음과 같은 코드를 생각할 수 있습니다. S값을 lock으로 사용해서 한 쓰레드가 진입해있을 때 다른 쓰레드는 while루프에서 기다리도록 (busy waiting)하고자 합니다.


wait(S) {
   while(S<=0)
       ;    // no-op
   S--;
}


 


signal(S) {
   S++;
}
 


이 두 함수 wait와 signal을 사용하여 다음과 같이 critical section을 만들 게 됩니다.


 


wait(mutex);
   critical section
signal(mutex);


 


아이디어는 좋지만 문제가 있습니다. 바로 S--; 와 S++; 이라는 두 statement가 atomic하지 않기 때문입니다. 이 코드가 의도대로 동작하기 위해서는 이 두 statement가 atomic해야 합니다. 즉 critical section을 만들기 위한 코드인데 내부적으로 다시 critical section인 두 개의 statement가 들어있는 웃기는 상황인 것입니다. 이것의 해결을 위해서 보통 H/W의 도움을 받습니다. 즉 CPU는 synchronization을 위한 primitive들을 제공하는데, 대표적인 것이 TestAndSet과 Swap으로 다음과 같은 동작을 atomic하게 하는 instruction입니다.


 


boolean TestAndSet(boolean &target) {
   boolean rv = target;
   target = true;
   return rv;
}


 


(C++코드네요) 이를 이용해서 다음과 같이 critical section을 만들 수 있읍니다.


 


while(TestAndSet(lock));
   critical section
lock = false;


 


또는 Swap과 같은 instruction을 제공하기도 하는데 다음과 같은 동작을 atomic하게 하는 instruction입니다.


void Swap(boolean &a, boolean &b) {
   boolean temp = a;
   a = b;
   b = temp;
}


 


critical section은 다음과 같이 만듭니다.


key = true;
while (key == true);
    Swap(lock, key);
critical section
lock = false;


 


이러한 종류의 lock을 spinlock이라고 부릅니다. busy waiting을 하는 lock입니다. 따라서 이러한 spinlock은 잘못 사용하면 성능을 심각하게 저해할 수 있고 미묘한 문제가 발생할 수 있습니다. (밑에서 지적하겠지만 bounded waiting이 되지 않기 때문입니다.) 그러나 MP환경에서는 유용할 수가 있습니다. 왜냐하면 context switch가 필요 없기 때문입니다. 이러한 spinlock이 유용한 경우는 critical section이 무척 짧고, 그래서 그런 busy waiting이 드물 게 일어날 때 입니다. critical section이 너무 짧으면 busy waiting을 피하기 위해 context switching을 하는 것이 비효율적이 되고, 또 그런 busy waiting이 드물 게 일어난다면 충분히 잘 동작하기 때문입니다. 이런 spinlock은 잘쓰면 약이고 못쓰면 독이 되는 존재입니다. 그러나 아래에서 살펴보겠지만, spinlock은 어쩔 수 없이 사용해야 하는 기법입니다.


 


Long critical section and mutex


 


상대적으로 긴 critical section일 경우, spinlock을 쓰기에 적합하지 않기 때문에, 다음과 같은 mutex를 사용합니다. 주요 목적은 역시 busy waiting을 제거하는 것입니다.


typedef struct {
    int value;
    struct process *L;
} semaphore;


void wait(semaphore S) {
   S.value--;
   if (S.value < 0) {
       add this process to S.L;
       block();
   }
}


void signal(semaphore S) {
   S.value++;
   if (S.value <= 0) {
       remove a process P from S.L;
       wakeup(P);
   }
}


 


critical section은 다음과 같이 만듭니다.


 


wait(S);
    critical section
signal(S);


 


spinlock에서는 boolean형식의 lock을 썼지만(사실 그럴수밖에 없습니다. spinlock이니까요. :-0), 여기서는 1로 초기화되어있는 정수값을 쓰는데, 이 값은 음수가 될 수도 있습니다. 이 음수의 절대값은 기다리고 있는 쓰레드의 수가 됩니다. 그러나 역시 이 코드가 동작하기 위해서는 wait와 signal이 atomic해야 한다는 조건이 붙습니다. 앞서와 마찬가지로 웃기게도 critical section을 만들고자 하는 코드가 내부적으로 critical section인 wait와 signal을 가지고 있습니다. 이 부분은 10개정도의 instruction으로 구현될 수 있는 짧은 critical section이기 때문에 spinlock을 써서 해결합니다. 결국 웃기게도 mutex가 완전히 busy waiting을 없애지는 못하는 것입니다.


 


spinlock vs mutex


이제 spinlock과 mutex(semaphore)를 좀더 자세히 살펴보겠습니다. 사실 atomicity를 확보하기 위해 사용되는 wait와 signal이 스스로가 atomic해야한다는 것은 웃기지도 않는 상황입니다. 이 상황을 좀더 자세히 살펴보면 critical section은 사실 다음 그림과 같이 2중으로 되어 있습니다.


 



 


결국 짧은 critical section인 inner critical section, 즉 wait와 signal함수는 lock에 대한 critical section이라고 할 수 있고, 이를 바탕으로 구축된 더 큰 critical section이 각 공유 데이터마다의 critical section이라고 할 수 있습니다. 일반적으로 inner critical section은 H/W적인 방식으로 해결하면서 spinlock을 사용합니다. 즉 S++; 같은 것은 그냥 atomic한 instruction을 그대로 가져다가 쓰거나 while(TestAndSet(lock));을 씁니다. 이렇게 하는 이유는 wait와 signal이 짧기 때문입니다. 사실 생각해보면 그외에는 방법이 없습니다. instruction수가 10개가 채 안되는 코드들 때문에 context switching을 할 수도 없는 노릇일뿐더러, 이것 자체가 mutex인데 그 안에서 mutex를 쓸 수는 없으니까요! 그 안에 똑같은 구조를 만들어 넣는다고 해도 결국엔 process대기 큐를 조작하기 위해서는 다시 spinlock이 필요하니까요. 결국 spinlock이 synchronization문제를 풀기 위한 base ground인 것입니다. 그래서 inner critical section은 spinlock이 될수밖에 없습니다.


spinlock이 base ground라면, spinlock에 필수적인 H/W적인 primitive들이 제공되지 않을 때라면 어떻게 할까요? 아래에서 소개되는 bakery와 같은 방식을 쓸 수도 있겠지만, 이런 경우 배보다 배꼽이 더 큰 경우가 되겠지요. 결국 H/W의 지원이 필수적이라는 뜻입니다.


그렇다면 outer critical section이 spinlock일 때는? 이 경우 우습게도 이중으로 spinlock이 걸리는 상황이 연출될지도 모릅니다. -0-;; 그러나 사실상 이 경우 코드를 보면 아무 쓰레드도 inner critical section에 없다면 wait안에 있는 spinlock은 무의미해지기 때문에 correctness에는 문제가 없습니다. 이런 경우에는 wait와 signal이 atomic할 이유가 없어지는 것입니다. 단지 S++; 이 atomic해야한다는 의미가 될뿐입니다. 따라서 이런 경우엔 2중 구조는 무의미하고, 단지 spinlock이 될뿐입니다.


결국 inner critical section이 H/W에 의한 spinlock이 되고, outer critical section이 mutex로 구현되는 것이 가장 합당할 것입니다. 결국 mutex는 이러한 spinlock에 기반한 2중 구조로 되어있습니다. 즉 spinlock으로 짧은 구간에 대한 synchronization을 해결하고 이를 발판으로해서 제대로된 critical section인 mutex를 구현하는 것입니다.


우리가 spinlock을 피할 수는 없다고 하더라도 그래도 mutex가 내부에 spinlock을 가지고 있다는 점은 여전히 마음에 안드는 부분입니다. 여기서 공룡책에 나온 critical section에 대한 조건들을 살펴보겠읍니다.


 


1. Mutual Exclusion: 이 critical section에는 하나의 쓰레드만이 들어와야 한다는 조건.


2. Progress: critical section에 들어가있는 쓰레드가 없을 때 critical section에 들어가고자 하는 쓰레드는 언제든지 들어갈 수 있어야 한다는 것입니다. 즉 최소 어느 한 쓰레드는 계속 수행이 된다는 것이 보장이 되어야 하는 것입니다. 당연한 말처럼 들리겠지만, 잘못된 알고리즘으로는 critical section에 아무도 없음에도 불구하고 아무 쓰레드도 critical section에 들어가지 못하는 상황이 발생할 수도 있읍니다.


3. Bounded waiting: 기다리는 쓰레드는 무한히 기다리지 않는다는 것. 즉 기다림에 bound가 있다는 것입니다. starvation을 방지하자는 것이죠


 


spinlock은 1,2번은 보장하지만 bounded waiting은 보장하지 않습니다. mutex의 경우 일반적으로 (책에서는) FIFO queue를 쓴다면 세 조건을 모두 만족한다고 합니다. (FIFO방식이라면 bounded waiting은 자동적으로 보장되죠.) 하지만 mutex도 결국 내부적으로 spinlock을 가지고 있는데 어떻게 spinlock의 한계를 극복했다는 것일까요? 제 생각에는 엄밀히 말해서 mutex도 결국 bounded waiting을 보장하지는 못할 것입니다. 내부적으로 가진 spinlock 때문입니다. 비록 그런일이 실제로는 거의 일어나지 않는다고 해도 이론적으로 문제는 있을 수 있습니다. lock contention이 극심한 경우를 생각해봅시다. ...생략...


 


Bakery algorithm


semaphore를 사용하지 않을 때 critical section문제를 어떻게 풀지 생각해봅시다. 공룡책에 있는 내용을 따라가보겠습니다. 일단 두 개의 쓰레드만이 있는 경우를 생각해봅니다. 각 쓰레드를 P_i 라고 할 때 P0와 P1의 두 개만이 있는 경우입니다.


첫 번째 다음 알고리즘을 생각해봅시다.


while(turn!=i);
    critical section
turn=1-i;


turn이라는 공유변수가 어느 쓰레드가 들어와야하는지를 지정해주는 역할을 하게 되고, 이 두 개의 쓰레드는 '반드시' 번갈아 들어와야하는 상황이 됩니다. Mutual Exclusion은 되고 있지만, Progress조건과 Bounded waiting은 만족하지 못합니다. 서로 번갈아 들어가야하는 상황이므로 한쪽에서 이 critical section이 아닌 다른곳에서 한참을 머물거나 이 critical section에 들어오지 않는다면 나머지 쓰레드는 영영 기다리고만 있을 것입니다. 같은 이유로 역시 Bounded waiting도 안되고 있습니다.


다음 두 번째 알고리즘을 생각해봅니다.


flag[i] = true;
while(flag[j]);


    critical section


flag[i] = false;


turn이라는 변수를 boolean flag[2]; 로 바꾼 것입니다. 이 flag[i]가 true일 때는 해당 쓰레드가 critical section에 있다는 의미이므로 상대방의 flag가 true일 때는 기다려줍니다. 그러나 역시 Progress가 되지 못하는 경우가 발생할 수 있습니다. P0가 flag[0] = true로 한 직후에 context swtich가 되고 P1가 다시 flag[1] = true로 하는 경우입니다. 결국 두 쓰레드 모두 while문에서 영구히 돌 게 됩니다.


이제 세 번째 알고리즘을 살펴봅니다.


flag[i] = true;
turn = j;
while(flag[j] && turn == j);


    critical section


flag[i] = false;


두 번째 알고리즘의 약점을 극복하기 위해서 turn을 다시 도입했습니다. 아이디어는 두 번째 알고리즘에서와는 달리 turn=j; 라는 assignment는 atomic하므로 이에 기대어 둘이 같이 flag를 true로 설정하고 거의 동시에 turn=j;를 실행하고 while문으로 진입한다고 했을 때 turn이 누가 들어갈지를 결정한다는 것입니다. 이 알고리즘은 위의 3가지 조건을 만족하게 됩니다.


이 세 번째 알고리즘이 2개의 쓰레드간의 critical section문제를 풀었지만, 이제 그 이상의 쓰레드가 존재할 때를 생각해봅니다. bakery algorithm이라고 불리는 이 알고리즘으로 critical section을 다음과 같이 구현합니다.


공유되는 데이터로는


boolean choosing[n];


int number[n];


이 있고, 여기서 (a,b)<(c,d)는 a<c or if a == c and b<d 임을 뜻합니다.


 


choosing[i] = true;
number[i] = max(number[0], number[1], ... , number[n-1])+1;
choosing[i] = false;
for(j=0;j<n;j++) {
   while (choosing[j]);
   while ((number[j]!=0) && ((number[j],j) < (number[i],i)));
}


critical section


number[i] = 0;


 


이 알고리즘은...생략.


 


이 bakery algorithm에서 choosing이 왜 필요한가? 라는 질문이 있을 수 있읍니다.


 


repeat
choosing[i]:=true;                                                                                  
number[i]:=max(number[0],number[1],......,number[n-1])+1;                      
choosing[i]:=false;                                                                                
for j:=0 to n-1                                                                                      
      do begin                                                                      
            while
choosing[j] do no-op;                                                   
               while number[j]=!0  and (number[j],j) < (number[i],i) do no-op;
         end                                                                           
critical section

number[i]:=0;                                                                                            
remainder section
until false;


choosing은 말 그대로 번호를 고르고 있다는 뜻입니다. bakery에서는 같은 번호를 가진다고 해도 그 index(여기서는 i,j)에 따라서 순서가 정해집니다. 그러니까 모든 number들을 순차적으로 뒤져서 자기보다 빠른 값을 가지는것이 없을때 자신이 들어가겠다는 아이디어인데요, 같은 number를 가지는 process A,B가 다음처럼 동시에 critical section에 진입하는 경우가 존재할 수 있읍니다. A가 max값을 계산해서 대입하기 직전인데, 이때 B가 A의 number가 0인것을 보고 critical section에 쓱.. 들어갑니다..이어서 A는 max를 number에 대입하게 되고, A입장에서 볼때, B는 같은 number를 가지지만 index에서는 자신이 빠르니까 역시 critical section 으로 진입하게 됩니다. 이걸 방지하려면, 아예 A가 B보다 더 늦는 number를 가짐을 보장하던지 아니면 이 bakery algorithm에서처럼 max계산부분에 이미 진입한 process에 대해서는 max값의 계산이 끝날때까지 기다려줘야하는거죠.


이 bakery algorithm으로 critical section problem을 풀 수 있겠지만, 사실 문제가 많습니다. 공유데이터인 choosing과 number가 쓰레드의 수에 의존한다는 것, 즉 쓰레드가 생길 때마다 각 쓰레드는 자신만의 choosing이나 number같은 변수를 따로 가지고 있어야 할테고,...


아무래도 S/W적인 방법으로는 critical section문제를 해결하기가 어려워보입니다. 따라서 H/W가 synchronization을 위한 instruction들을 제공해줘야할 이유가 있다고 생각됩니다.


 


coarse-grained locking vs fine-grained locking


 


이러한 critical section은 보호하고자 하는 데이터에 따라 존재하는 것임을 주의하시기 바랍니다. 하나의 코드에 대해 여러 thread가 생길 수도 있지만, 여러 다른 코드들에서 같은 데이터에 접근할 때 그 각자의 코드들은 역시 이렇게 critical section으로 묶여야합니다. 그러나 여러 다른 코드들에 이처럼 여러군데에 critical section이 있다고 하더라도 그들이 같은 데이터를 보호하고 있다면 같은 critical section입니다. 즉 그들 모든 구간에 대해서 진입하고 있는 쓰레드는 하나뿐이라는 것입니다. 이것은 중요한 점을 시사하는데, critical section을 포함해서 synchronization문제는 코드가 아닌 *데이터*를 보호하는 매커니즘이라는 점입니다. 코드상으로 표현되고 코드위에서 작업을 할지라도, 심지어 다른 코드들에 있다고 하더라도 같은 데이터를 보호하고 있다면 같은 critical section입니다. 반면에 아무리 critical section이 많아도 서로 다른 데이터를 보호하고자 하는 것이라면 서로 영향을 주지 않는 다른 critical section입니다.



 


위에서처럼 각 변수에 대해서 3개의 critical section을 가질 수 있습니다. 이런 경우 각 변수는 자신만의 또다른 변수인 lock을 가지게 되고, 각 critical section에서는 해당 변수에 대한 lock을 잠그고(즉 entry section을 지나서) 들어가게 되고 나올 때는 다시 lock을 풀어주게 됩니다(즉 exit section을 지납니다). 즉 보호하고자 하는 변수들에 대해서는 lock을 가지게 되고, critical section은 이러한 lock을 통해서 데이터들을 보호하게 됩니다. 위의 그림은 fine-grained locks를 보여주고 있습니다. 각 변수가 각각의 lock을 가지고 있는데, 이렇게 해서 critical section을 최소화하는 일이 중요합니다. 예로 위와같이 3개의 쓰레드가 공유변수 X,Y,Z에 대해서 접근할 때 Thread A1이 X에 대한 critical section에 머물고 있다고 하더라도 다른 Thread A2와 Thread B는 다른 Critical section으로 얼마든지 진입할 수 있기 때문에 synchronization으로 인한 overhead를 최소화할 수 있습니다.


 



 


반면 위의 그림은 coarse-grained locks을 보여주고 있습니다. X,Y,Z 모두에 대해서 lock을 하나만 놓는다면 위의 3개의 Thread는 한 쓰레드가 한 critical section에만 들어가도 다른 쓰레드 둘은 기다려야 하는 상황이 발생하게 되고, 이것은 큰 overhead로 연결됩니다. 따라서 최대한 fined-grained locks로 만들어주는일이 중요합니다. 대개 비슷한 일을 하는 변수들은 함께 조작되기 때문에 이러한 변수들에 대해 적절하게 lock을 설정해주는 것이죠. 물론 하나의 변수에 대해서는 하나의 lock만이 있어야 할 것입니다. 실제 리눅스 커널이 preemptible하게 되기까지의 과정은 커널 data structure들에 대한 coarse-grained lock들을 fined-grained locks로 바꾸어가는 과정이었습니다. 커널내의 그러한 critical section내에서는 물론 non-preemptible이지만 이러한 구간들을 최소화함으로써 커널이 preemptible하다고 말할 수 있게 됩니다.


 


Conclusion


정리를 좀 해보겠읍니다. 공룡책에서는 spinlock과 mutex를 모두 아울러서 semaphore라고 하고 있습니다. spinlock을 mutex의 특수한 경우라고 본 것입니다. 하지만 앞서 살펴본 바와 같이 spinlock위에서 mutex가 구현되고 있기 때문에 spinlock이 하위 layer라고 한다면 mutex가 상위 layer이기 때문에 둘을 나누는 것이 좋겠습니다.


spinlock : busywaiting하는 lock


mutex == semaphore : sleep하는 lock. 그런데 이 mutex는 spinlock위에서 구현되었다. 즉 wait/signal이 자체적으로 critical section을 가지고 있고, 여기엔 spinlock을 쓴다. 이 spinlock은 TestAndSet같은 H/W적인 지원을 바탕으로 구현된다.


 


Synchronization #2


Bounded-buffer problem & reader-writer problem


이제 bounded-buffer problem을 semaphore를 써서 다음과 같이 해결할 수 있습니다. 아래는 producer의 코드입니다.


do {
   ...
   produce an item in nexp
   ...
   wait(empty);
   wait(mutex);
   ...
   add nextp to buffer
   ...
   signal(mutex);
   signal(full);
} while(1);


다음은 consumer의 코드입니다.


do {
   wait(full);
   wait(mutex);
   ...
   remove an item from buffer to nextc
   ...
   signal(mutex);
   signal(empty);
} while(1);


 


이제 readers-writers problem을 봅니다. 파일등에 접근하고자 하는 쓰레드가 여러개가 있을 때, 이중 read만을 하는 reader와 write를 하는 writer로 나누고, 이들간의 synchronization을 해봅니다.


이를 위해서 다음 데이터구조를 reader들이 공유합니다. wrt는 writer도 공유합니다.


semaphore mutex, wrt;
int readcount;


다음은 writer의 코드입니다.


wait(wrt);
   ...
   writing is performed
   ...
signal(wrt);


다음은 reader의 코드입니다.


wait(mutex);
readcount++;
if (readcount==1)
   wait(wrt);
signal(mutex);
   ...
   reading is performed
   ...
wait(mutex);
readcount--;
if (readcount==0)
   signal(wrt);
signal(mutex);


mutex는 readcount를 보호하는 lock임을 알 수 있습니다. readcount는 몇 개의 쓰레드가 현재 reading중인지를 알려주게 됩니다. wrt는 write를 위한 lock입니다.


 


The Dining-philosophers problem


생략.


 


Critical regions


semaphore는 사용하기가 어렵습니다. wait/signal을 잘못 쓴다든지, 한쪽을 생략하게 된다면 난리나는거죠. 이를 위해서 언어 차원에서 synchronization을 지원하기도 하는데, 여기서는 (conditional) critical region을 살펴봅니다.


어떤 경우에는 critical section에 들어가봐야지만 알 수 있는 조건이 있습니다. 이런 경우 들어가서 조건이 안맞으면 다시 나왔다가 다시 들어가서 다시 조건을 살펴보는 상황이 되어야하기 때문에 synchronization overhead가 무척 크게 될 것입니다. 따라서 이런 경우 들어갔다가 조건이 안맞으면 그 안에서 대기하는 것이 유용합니다. 이럴 때 critical region이 유용할 것입니다.


critical region은 다음처럼 사용합니다. 공유되는 데이터 v는 다음과 같이 선언되면


v: shared T;


이 변수 v는 다음과 같은 형식의 region에서만 access가 가능하게 됩니다.


region v when (B) S;


이것은 S가 수행될 때는 다른 쓰레드는 v에 접근할 수 없다는 뜻입니다. B는 v에 대한 식입니다. (v이외의 변수에는 의존하지 않습니다. 따라서 B값도 공유되고 있고 쓰레드마다 같은 값이 됩니다.) 이 region에 접근할 때 평가되는데 이게 false이면 프로세스는 B가 true가 되고 또한 v와 관련된 region에 아무 프로세스가 없을 때까지 기다리게 됩니다.


B를 true로 놓게되면, 다음과 같이 됩니다.


region v when (true) S1;


region v when (true) S2;


이 코드가 여러 쓰레드에 의해 수행되면 S1이 수행된후에 S2가 수행되던지, 아니면 S2가 수행된후에 S1이 수행된다는 것이 보장됩니다. 즉 critical section이 됩니다.


위와 같은 region v do S; 꼴의 plain critical region 은 critical section을 언어 차원으로 끌어올린 것입니다. 따라서 컴파일러에 의해서 mutual exclusion이 제공됩니다. 여기에 B라는 condition이 붙기 때문에 conditional critical region이라고 하는데, 이것은 critical region안에 들어가서 알 수 있는 조건들이 있을 때 들락날락할 필요가 없게끔 들어가더라도 조건이 안맞으면 잠들어있다가 조건이 성립할 때 실제적인 접근을 할 수 있게끔 해주었다는 것입니다.


이를 이용해서 bounded-buffer문제를 다음과 같이 해결합니다.


struct buffer {
   item pool[n];
   int count, in, out;
}


다음은 producer의 코드입니다.


region buffer when (count < n) {
   pool[in] = nextp;
   in = (in+1)%n;
   count++;
}


다음은 consumer의 코드입니다.


region buffer when (count < 0) {
   nextc = pool[out];
   out = (out+1)%n;
   count--;
}


 


다음은 implementation of the conditional-region construct 입니다.


mutex is initialized to 1;
the semaphores first-delay and second-delay are initialized to 0
the integer first-count and second-count are initialized to 0.
wait(mutex);
while not B
      do begin
               first-count:=fist-count + 1;
               if second-count >0
                         then signal(second-delay)
                        else signal(mutex);
               wait(fist-delay);
               first-count:=first-count-1;
               second-count:=second-count+1;
             if first-count > 0
                      then signal(first-delay)
                         else signal(second-delay);
               wait(second-delay);
               second-count:=second-count-1;
      end;
S;
if first-count >0
        then signal(fist-delay);
        else if second-count > 0
                   then signal(second-delay);
                   else signal(mutex);


 


왜 큐를 두 개를 사용하는가?


쉽게 말해서 S부분에서 v 가 update되었을 경우에만 B를 retest하겠다는겁니다. 불필요한 B의 retesting을 제거하겠다는거죠. 즉, 큐에 있는 녀석들이 계속 B를 retest함으로써 busy waiting할수 있는 것을 없애겠다는건데요. 물론 큐를 하나만 놓아도 작동은 하겠지만 busy waiting하게 되는거죠. first_delay는 방금 retest를 마친녀석들이고, second_delay는 이제 retest를 할 필요가 있어진 녀석들이 가는곳입니다.. 그 기준은 B의 값의 변화가 있을수 있는 여지가 있는곳, B는 v에 관한 식이기때문에 v의 변화가 가능한 코드, 즉 S인거죠. 즉, 어느 한 process가 S를 마치고 떠날때 first_delay에 있는녀석들이 모조리 second_delay로 내려가게 됩니다. 즉 이제 모두들 retesting한번 해보자는거죠. 코드가 약간 교묘하게 짜여지긴 했지만, 잘 뜯어보시면 알수 있으실 듯. 그러면 second_delay으로 떨어진 마지막 녀석이 어...first_delay가 비었네..하면서 second_delay에 있는 가장 앞쪽에 있는 녀석을 signal하게 되고, 이제부터 retesting이 일어나게 되는거죠... 코드 보시면, 이때 B가 false던 true던 second_delay에 있는 녀석들은 모조리 retesting을 한번씩 하게 되고 그중 또다시 false인 녀석들은 다시 first_delay로 가게됩니다. 이 녀석들이 다시 패자부활전할수 있는 기회는...-_-;; 누군가가 S를 통과해서 signal(first_delay)를 해주는길 밖에 없습니다. 그네들중에 아무도 그런 사람(프로세스)이 없다면 모조리 block되고 기다리고 있는거고,그때 대문( wait(mutex) )가 열려져있으니까 누군가 와서 이들을 풀어주길 고대하는거고요. 만일 이걸 큐 하나로만 구현해보려면 조금 곤란하겠죠..

예를 하나 들자면, 프로세스 1,2,3,4가 있는데 1,2,3이 바보라서 B테스트를 맨날 false한다고 하면 1번이 들어갔다가 first_delay에 wait되고, second_delay가 비어있으니까, mutex(대문)을 엽니다. 이제 2번이 들어왔다가 또 first_delay에 갇히고.. 이렇게 1,2,3번이 first_delay에 있다고 할때, 이때 아무도 안와주면 다들 이상태로 몇십년이건 지내겠으나..(busywaiting이 아님!) 이제 조금 똘똘한 4가 와서 S를 통과합니다..즉 v가 수정되었을 가능성이 있고, (왜냐면 v는 이 region에서만 수정되니까) 즉 B가 다른값을 가질수 있는 가능성이 있는거죠! 이제 패자부활전...-_-;; 그러면 4가 나가면서 first_delay를 signal해주면, 첫번째던 1번이 second_delay로 옮겨가고, 친구들도 살려줘야하니까 signal(first_delay)합니다. 그러면 2번이 second_delay로 옮겨오고. 또 signal(first_delay)하고, 이제 3번이 와서는 first_delay가 비었으니까 이제 부활전 시작합니다..즉 signal(second_delay)...! 이제 공은 1번에게 돌아왔고... 1번은 retesting에 들어갑니다.


즉 B의 업데이트를 감지해서 그 필요한 시점에만 retesting을 하겠다는, busy-waiting을 피해보겠다는 매커니즘입니다.

 


 


 


 


plain critical region은 세마포어랑 같은게 아닙니다. plain critical region은 critical section과 동일합니다. 즉 critical region은 세마포어가 할수 있는 일들중 일부만을 할수 있는거죠. 세마포어를 좀더 편리하게 만들어보자고 critical region이란걸 만들었더니, 좋긴한데 좀 약하다는거죠. bounded-buffer producer-consumer 문제 같은 경우는 critical region으로 풀수가 없읍니다. 그래서 좀더 강력하게. 대문(mutex)을 들어왔더라도 한번더 조건을 통과해야하게끔 만들어주는거죠. conditional이란 말이 붙은 것은.. synchronization은 해결을 봤으나 즉, region에는 한번에 하나의 process만이 들어올수 있지만. 이런 경우 먼저 온녀석이 무작정 먼저 들어가버립니다. 우리가 하고싶은것은 먼저 왔더라도 특정 조건이 만족하지 않으면 안들여보내려는 거고요. 이런 condition이 붙기때문에 conditional이라고 할뿐인거죠. bounded-buffer producer-consumer의 경우 buffer가 차거나/비거나 하는 조건들이 필요하니까요. 세마포어같은 low level에서는 당연히 손수 코딩해줄수 있으니까, 이런것이 가능한건 당연한거고.. 그걸 language에서 편하게 제공해주자는거죠

plain은 critical section과 같네.


conditional은 그럼 semaphore와 같다고 할 수 있나?


conditional synchronization이 plain과 semaphore사이의 간격을 다 메워주고 있는 것일까??


 


 


 


monitors


생략.


 



 






Virtual address space management


Linux에서는 일종의 segmentation기법인 VMA(virtual memory area)를 구현합니다. 소프트웨어적으로 구현하는 것입니다. (intel의 segmentation을 사용하지는 않습니다.) 앞서서 모든 virtual address가 존재하는 것은 아니며, 존재하지 않는 virtual address에서는 page fault라는 것이 발생한다는 을 이야기했습니다. 이렇게 해서 valid/invalid virtual address가 구분될 수 있습니다. 그러나 좀더 주소 공간을 잘 활용하기 위해서 그 위에 하나의 층을 더 놓는데, 이것이 VMA입니다.


대표적인 VMA로는 위의 process를 구성하는 code, data, bss, stack이 있습니다. 이러한 VMA들을 살펴보기 위해서 linux상에서 /proc/번호/maps 파일을 열어볼 수 있습니다. 다음은 1번 process인 init의 VMA들입니다.



"Process의 구성"편에서 보았던 일반적인 VMA들의 구성과는 다소 다르다는 것을 볼 수 있습니다. linux에서는 이와 같이 놓여짐을 확인할 수 있습니다. 맨 마지막 line인 bffff000-c0000000 의 주소에 위치하는 stack segment를 볼 수 있습니다. 스택은 kernel space바로 밑에서부터 거꾸로 자라게 됩니다. 또한 첫 번째 줄은 init의 text입니다.(code segment) 두 번째는 init의 data입니다. (data segment) 세 번째는 옆에 파일명이 안써져 있는 것으로 bss임을 알 수 있습니다. (bss segment) 옆에 /sbin/init 이라고 파일명이 써있는 것은 이 VMA에 해당 파일이 mapping되어 있다는 것입니다. 이러한 파일들을 memory mapped files라고 합니다. 옆에 있는 46732는 해당 file의 I-node입니다. 위의 예에서 text와 data의 경우 파일에 mapping되어 있지만, bss와 stack은 그렇지 못함을 볼 수 있습니다. 이 memory mapped file은 뒤에서 다루겠습니다. 이와 같이 하나의 process가 실행되기 위해서는 여러개의 VMA들이 정의되고 각 VMA의 특성에 맞는 행동이 커널에서 지원되어야 합니다.


각 VMA는 기본적으로 시작점과 끝점을 가집니다. 즉, 위 그림의 첫 컬럼에서 나타나는 주소가 해당 VMA의 영역입니다. 따라서 어떤 메모리 참조가 일어났을 때 이런 VMA외부 영역에 대한 참조라면 invalid한 참조가 됩니다. 또한 각 VMA는 permission을 가집니다. read-only일 수도 있고, rw가 가능할 수도 있습니다. 이것이 위의 2번째 컬럼에 나타나있습니다. 이러한 permission에 어긋나는 참조 역시 invalid합니다. 그 외에도 어떤 VMA는 파일에 mapping되기도 하고 (text,data) 어떤 VMA는 특정 방향으로 자라날 수 있습니다. (bss와 stack) 커널은 이러한 VMA를 내부적으로 관리하면서, page fault가 일어났을 때 해당 참조가 valid한지 invalid한지를 판단하기 위해서 이 VMA에 대한 정보를 이용합니다.


 



 






Disk Cache - Page cache, buffer cache and unified cache


Unified cache


실제 컴퓨터를 사용함에 있어서 메모리가 얼마나 필요할까요? 물론 OS마다 그 필요한 정도는 다르지만 간단하게 생각해볼 때 커널의 이미지, 커널이 쓰는 Data structure, 그리고 실행되는 프로세스들의 Code가 차지하는 메모리, 프로세스들이 쓰는 스택이나 힙등의 메모리, 이정도만 있으면 됩니다. 아마 리눅스등의 부팅 직후의 사용된 이러한 메모리의 양은 그렇게 크지 않을 것입니다. 즉 메인메모리가 1GB이던지 2GB이던지 메모리가 아무리 많아도 컴퓨터의 성능에 영향을 끼치지 않는 것입니다. 그정도의 필요한 메모리량만 넘긴다면 (부족하다면 스왑 때문에 성능이 급격히 떨어지겠지요) 남는 메모리는 그냥 낭비되는 것입니다. 우리가 알고 있는 메모리가 많을수록 컴퓨터의 성능이 좋아진다는 상식과는 반대되는 이야기입니다. 사실 메모리의 양이 성능에 미치는 영향은 이와 같이 OS가 얼마나 그 남는 메모리를 효과적으로 활용하는가에 달려있습니다. 메모리가 충분하다는 것이 시스템의 성능에 영향을 끼치는 것은 스왑을 안하게 한다는점과, 이런 남는 메모리를 디스크를 캐시하는데에 활용할 수 있다는 두가지 사실에서부터 나옵니다. 남는 메모리를 안쓸이유가 없기 때문에 남는 메모리는 전부 디스크캐시로 활용하게 됩니다. 제 경험상 보통 메모리의 절반 이상은 이런 디스크캐시로 쓰더군요. 윈도우즈나 Linux모두 보통 메모리의 절반가량을 디스크 캐시에 쓰고 있으며 디스크 I/O가 심한 작업을 할 경우에는 70%, 80%의 메모리까지도 모두 디스크 캐시로 씁니다. 유저 입장에서는 큰 메모리로 인해 성능의 차이를 느낄 수 있는 가장 큰 부분이 바로 디스크 캐시입니다.


쉽게 얘기해서 디스크 캐시는 어떤 이유로든 디스크로부터 읽어온 내용들을 버리지 않고 담고 있다가 다음에 다시 그 부분을 읽을 때 캐시된 그 내용을 그대로 사용하는 것이라고 생각할 수 있습니다. 디스크는 block device이므로 이 단위는 block단위라는 것에 주의하세요. 이러한 디스크 캐시는 유닉스 세계에서는 보통 버퍼캐시라고 불리웁니다. 버퍼라는 것은 단순히 디스크의 block이 메모리에 올라와있는 것을 뜻하는 것이죠. 먼저 프로세스가 디스크 I/O를 일으키는 경우를 살펴보면 (커널이 일으키는 것은 일단 생략하고) 다음과 같은 두가지 경우가 있습니다.



(from Operating System Concepts by Silberschatz, Galvin, Gagne)


위와 같이 프로세스는 mmap()된 방식이나 혹은 read/write를 통한 두가지로 디스크 I/O를 수행할수 있습니다. 위의 그림에서 보시다시피 두 경우 모두 버퍼캐시 위에서 동작하고 있습니다. 여기서 페이지 캐시가 등장하는데, 이 page cache는 디스크로부터 읽어와서 메모리에 mapped()된 페이지들을 캐시합니다. 버퍼캐시가 디스크의 block단위임에 비해서 페이지는 여러개의 block들의 모음임을 주의하세요. 보통 disk block이 512Byte나 1KB이고 page size가 4KB이므로 여러개의 (연속된) block들이 하나의 페이지를 채우게 됩니다. 이제 버퍼캐시위에서 mmap()을 통해서 읽혀진 이러한 페이지들을 다시 한번 캐시하고 있는 것이 페이지 캐시입니다. (캐싱의 단위가 block과 page로 다르죠) 이러한 두 개의 캐시가 역사적인 이유로 독립적으로 존재해있었습니다. (리눅스 2.2때까지 이처럼 2개의 캐시가 있었습니다)


이러한 두 개의 캐시는 첫 번째로는 double caching의 문제가 있습니다. 즉 디스크상의 같은 한 block의 내용이 두 개의 캐시에 모두 존재할 수가 있었습니다. 이를 통해 메모리가 낭비됩니다. 또 inconsistency의 문제가 존재하기 때문에 요즘은 (Linux 2.4부터) 다음과 같은 단일한 버퍼캐시를 사용합니다.



(from Operating System Concepts by Silberschatz, Galvin, Gagne)


이것을 unified cache라고 합니다.


명칭에 대한 다소간의 혼돈이 있을 수 있지만 현대의 OS들은 unified cache, 즉 디스크 캐시를 하나를 가진다고 말할 수 있습니다. Linux의 경우 2.4부터 페이지 캐시로 통합됨으로써 페이지 단위의 캐싱을 하고 있습니다. 리눅스에서 이러한 캐시를 관리하기 위해서 LRU를 흉내낸 scheme을 사용하고 있습니다. inactive list와 active list라는 두 개의 리스트를 통해서 다음과 같이 관리합니다.


(생략)


 






Page Fault


만일 process가 정의되지 않은 address를 참조하면 어떻게 될까요? 위의 "Virtual memory"편에서 mapping이 존재하지 않는 구역의 address를 참조하려고 한다면, CPU는 인터럽트를 발생시키게 됩니다. 이것은 page fault라고 합니다. 이렇게 인터럽트가 걸리면 커널의 page fault handler에게 제어가 넘어오고, 만일 정말 이 process가 잘못된 주소에 접근하는 것이었다면 이 process는 강제로 종료되게 됩니다. Unix계열에서는 core를 남기고 "segmentation fault"라는 메시지를 남기고 종료되고, 윈도우에서는 흔히 보는 blue screen이나 "치명적 오류"를 남기고 커널에 의해서 종료됩니다. 즉, 비록 process가 4GB의 주소공간을 가진다고 생각할 수 있겠지만, 그중 자신에게 할당되지 않은 주소 영역에 침법하는 행위는 차단되는 것입니다. 이렇게 해당 참조가 valid한지 invalid한지를 판단하기 위해 VMA들이 사용됩니다. 우리는 VMA와 address mapping와 관련하여 어느 메모리 주소에 대해 4가지 경우를 생각할 수 있습니다.


  1. VMA정의되고 address mapping이 없는 경우 : page fault에 걸리겠지만, valid한 참조입니다.
  2. VMA정의되고 address mapping이 있는 경우 : 정상적인 참조가 이루어집니다.
  3. VMA정의되지 않고 address mapping이 없는 경우 : invalid한 참조입니다. 프로세스를 강제로 종료시킵니다.
  4. VMA정의되지 않고 address mapping이 있는 경우 : 발생해서는 안되는 경우입니다.

1번의 경우, valid한 경우이지만, 실제 page가 할당되지 않은 경우입니다. 이런 경우 page fault handler는 해당 page를 마련하고 실행을 계속시킵니다. 그러나 이것은 이해를 돕기 위한 일반적인 이야기이고, 스택같은 경우 예외적으로 처리될 수 있습니다. 스택에서 자신에게 할당된 page를 모두 쓰고, page boundary를 넘어서서 page fault가 발생하였을 때, kernel은 VMA를 자라게 하고, 새로운 page를 할당받아 스택을 늘리게 됩니다.


 



 






Demand Paging


대부분의 현대적 OS에서는 이 page fault를 오히려 유용한 방법으로 사용합니다. 이러한 기법을 demand paging이라고 하는데요, 일부러 최소한의 address mapping만으로 process를 시작시킵니다. 물론 그렇더라도 VMA들은 제대로 설정되어 있습니다. 그러면 process는 어느 시점에서 자신의 valid한 영역에 접근하려고 시도했음에도 page fault에 걸리게 됩니다. (위의 2번 경우) 이 시점에 page fault handler는 이 영역이 valid하기 때문에 실제 mapping을 추가하게 되는 것입니다. 예를 들어, stack의 경우, 프로그램이 시작할 때 심지어 address mapping이 아예 없을 수도 있습니다. 그러나 VMA에서는 valid함을 표시하고 있기 때문에, page fault handler는 비어있는 페이지를 하나 가져와 mapping시키고, 실행을 계속 시킵니다. 이와 같은 방식으로 process는 실행하면서 실제로 정말 그 page가 사용될 시점에서만 page를 사용하게 됩니다. 따라서 어떤 프로그램을 실행시켰다고 해도 그중 특정 기능을 사용하지 않는다면 그 기능에서 필요로 하는 page들은 VMA에서만 valid함을 표시할뿐 실제로 mapping조차 이루어지지 않을 수 있습니다. 이러한 기능을 demand paging이라고 합니다.


 



 






COW(Copy On Write)


demand paging과 더불어 페이지 사용의 효율성을 높이는 기법이 COW입니다. 이 기법은 fork에서 특히 유용하게 사용될 수 있는데, 부모 process의 text 와 같은 read-only page들은 복사할 필요없이 해당 physical page를 공유함으로써 쉽게 fork할 수 있습니다. 그러나 read/write가 가능한 그외의 page들은 원칙적으로 복사되어서 자식process만의 page를 할당해야 합니다. 그러나 write가 가능한 page라고 해서 항상 writing을 하는 것은 아니기 때문에, 먼저 마치 read-only page인 것처럼 공유를 해놓습니다. 그러면 두 process중 어느 하나가 write를 시도할 때 page-fault가 나게되고, 이때서야 비로소 새로운 page에 복사를 하여 read/write의 permission을 주어서 두 개의 페이지로 분리합니다. 이렇게 임시적으로 read-only page로 만들고 page-fault때 page를 분리하는 방식을 COW (copy-on-write)이라고 합니다. 이렇게 함으로써 불필요한 copy와 page의 낭비를 피할 수 있게 됩니다.


 


 



 






Mapped files


virtual memory의 예기치못한 사용법의 하나로 memory mapped files가 있습니다. 이 기능은 virtual memory address의 일부를 physical memory가 아닌 디스크상의 file에 mapping시키는 기법입니다. 즉, 만약 virtual address 100번지부터를 a.out이라는 파일에 mapping시켰다면 우리가 100번지에 있는 문자를 읽을 때 실제로는 a.out화일의 첫 번째 문자를 읽게 되는 것입니다. 즉, 이 기법에 의해서 일반적으로 메모리 참조를 위한 instruction들이 file에 그대로 적용될 수 있게 됩니다. 특히 어떤 실행 이미지를 실행시킬 때, 기존에 파일을 읽어 메모리에 모두 올리던 방식에서 벗어나, 단지 파일의 I-node를 text segment에 연결만 시켜주면 loading이 끝나는 것입니다. 더구나, demand paging이 쓰이기 때문에 image중에서 실제로 쓰이지 않는 부분들은 실제로 loading조차 되지 않게 됩니다. 따라서 physical memory보다도 크기가 큰 실행 이미지도 실행시킬 수 있게 됩니다.



이것은 VMA의 특징입니다. file을 mapping시키는 VMA는 해당 file의 I-node를 가지고 메모리 접근이 발생할 때 해당 file에서 참조하게끔 해줍니다. 이 부분은 page fault handler가 구체적으로 구현해줍니다.


이러한 mapped file은 mmap() system call에 의해서 이루어지고, munmap() system call에 의해서 해제됩니다.


 



 






Swapping


앞서 kernel이 physical page들을 할당하거나 반환받는등의 관리를 한다고 하였습니다. 그중, physical page들이 모자랄 때, 즉 물리 메모리가 상대적으로 부족할 때 kernel은 swapping이라는 작업을 수행할수 있습니다. 이것은 어떤 의미에서는 demand paging과 반대 개념이라고 볼 수 있는데, demand paging이 필요한 page를 필요한 순간에 할당하는 방식이라면, swapping은 불필요해진 page를 메모리에서 빼내는 것이라고 할 수 있습니다. 이것 역시 locality에 따라서, 현재 물리 페이지들중 활발하게 쓰이는 페이지가 있는가 하면, 어떤 페이지들은 필요하던 시점이 지나가서 더 이상 쓰이지 않거나, 최소한 앞으로 한동안은 쓰이지 않을 페이지들이 많이 존재합니다. Swapping의 기본 idea는 이러한 page들을 잠시 디스크상으로 옮겨놓고자 하는 것입니다.


Linux를 설치할 때 swap partition을 잡아보신 경험이 있으실 것입니다. 이 swap partition이 바로 이 swapping을 할 때 메모리에서 안쓰는 page들을 디스크로 옮겨놓기 위한 공간인 것입니다. 이러한 swapping을 위한 공간은 disk상의 파일로도 만들 수도 있습니다. 단지 file system이라는 계층을 통과하지 않고 바로 disk에 access함으로써 속도를 향상시키기 위한 방법으로 swap partition을 쓰고 있는 것입니다. swapon등의 명령어를 통해서 swapping공간을 더 추가해주거나 더 줄여줄 수 있습니다.


swapping을 결정하였다면, 어떤 page를 희생양으로 삼아 메모리에서 디스크로 옮겨갈 것인가를 결정하여야 합니다. 이러한 결정사항을 page replacement policy라고 합니다. 원칙적으로 가장 좋은 경우는 앞으로 가장 뒤늦게 사용될 페이지를 선택하는 것인데, 우리가 미래의 경우를 알 수 없으므로, 일반적으로 LRU(least recently used)를 현실적으로 가장 이상적인 page replacement policy로 생각합니다. 그러나 사실 LRU를 제대로 구현하기에는 overhead가 크기 때문에, 일반적으로 LRU에 근접할 수 있는 다른 알고리즘들을 이용합니다. linux는 그중에서 aging기법을 사용합니다.


dirty page란? 한 page는 여러번에 걸쳐서 메모리에 올라왔다가 disk로 옮겨갔다가하는 과정을 반복할 수 있습니다. 이때 disk상에 있는 page와 그 page가 방금 메모리에 올라와있을 때는 복사되어 메모리로 옮겨왔으므로 당연히 둘은 같은 내용일 것입니다. 이때 만일 다시 이 page가 swapping되어진다면, 이 page는 구태여 disk에 쓰여질필요가 없습니다. 그저 해당 page를 빈 페이지로 표시하기만 하면 됩니다. 그러나, 만약 메모리에 올라와서 내용에 변경이 가해졌다면, 이 페이지는 다시 swapping되기 위해서는 디스크에 쓰여져야만 합니다. 이와 같이 디스크상의 자신의 내용에 비해서 변경이 가해진 page들, 그래서 디스크로 swap될 때 disk I/O를 유발시킬 page들을 dirty page라고 부릅니다. 각 page는 dirty page bit가 있어 dirty page가 될 때 해당 bit에 표시를 함으로써 자신이 swap될 때 disk로 써져야 할 필요가 있음을 표시합니다.


 


 



 






Page fault handler


이제 page fault handler의 역할을 좀더 구체적으로 살펴볼 준비가 된 것 같습니다.


 


 


 



 






Dynamic library


우리가 쓰는 프로그램들은 대부분 dynamic linking으로 link되어 있습니다. 즉, 많은 다른 프로그램과 공유되는 부분들은 실제로 이미지 내부에 가지고 있지 않은 것입니다. 대표적으로 c library인 libc.so 같은 library들은 대부분의 프로그램에서 공유되는 부분이기 때문에 예전처럼 이러한 부분을 하나의 image안에 넣는다는 것은 (이런 것을 static-linking이라고 합니다) 비용면에서 엄청난 낭비가 됩니다. windows에서는 dll이라는 확장자를, linux에서는 so 확장자를 가지는 것이 바로 이러한 dynamic library들입니다.



여기서 볼 수 있는 것은, init이 libc-2.3.2.so 라는 파일을 이용하고 있다는 점입니다.


 


dynamic library의 특징으로는 PIC이어야 한다는 것입니다. Position independent code (PIC)는 코드내의 absolute address가 없는 코드를 뜻합니다. 코드가 주소공간의 어디에도 붙을수 있기위해서는 이러한 absolute address가 없어야합니다. 이것은 dynamic library가 갖추어야하는 조건이기 때문에, dynamic library들은 PIC코드여야합니다. (실행되는 코드 역시 PIC이면 더 좋고, 사실 그래야할것같은데요. 제 생각.) function이나 global variable들의 주소가 포함되지 않는 이런 PIC코드는 이들에 접근하기 위해서 GOT(global offset table)을 통해서 접근하게 됩니다. GOT는 이러한 주소들의 테이블로 OS의 일부인 dynamic loader는 프로그램을 올릴때 GOT를 채워넣게됩니다.


(VMT가 생각나네요)


당연히 이 PIC는 non-PIC보다 이미지가 조금더 크고, 조금더 느립니다.


gcc에서는 -fpic 옵션이나 -fPIC옵션이 PIC코드를 만들어냅니다. (-fPIC는 H/W적인 지원을 같이 받게 됩니다)


relocatable code는 loader가 특정 위치에 부치기 위해서 주소들을 fixup해주어야하는 과정을 거쳐야합니다. 심볼 테이블을 통해서 이루어지게 됩니다.


 


 


 






Asynchronous I/O



 


blocking I/O and non-blocking I/O, asynchronous I/O


 


I/O관련 system call은 blocking일수도 있고, 혹은 non-blocking일수 있읍니다. 많은 경우에 blocking을 사용하지만 user interface등의 경우에 non-blocking I/O가 필요하기도 합니다.


 


 


 






I/O Scheduler


 


대표적인 device type인 block device에서의 I/O Scheduler를 Linux를 통해서 살펴보겠읍니다. 각 block device는 request queue를 유지하고 있읍니다. 이런 request queue에는 file system등의 상위 시스템에서 read/write요청(request)가 왔을때 쌓여있다가 실제 장치로 command가 내려가게되는 것입니다(commit). 따라서 각 장치들은 자신의 request queue가 비어있지 않는한 항상 바쁘게 일하고 있게 됩니다. 여기서 하나의 request란 adjacent block들에 대한 read/write요청입니다. 가장 단순하게 이러한 request queue가 FIFO방식이라면, 즉 들어온 순서대로 장치에 commit한다면, 디스크의 경우 큰 문제가 됩니다. seek가 너무 많이 일어나기때문입니다. 따라서 이러한 request들을 적절히 배열하고 적절한 순서로 장치에 commit할 필요가 있읍니다. 이러한 역할을 해주는것이 I/O scheduler입니다. process scheduler와 혼동하지 마시기 바랍니다. process scheduler가 CPU를 virtualize하여서 제공한다고하면 I/O scheduler는 block device를 virtualize해서 제공한다고 할 수 있읍니다.


I/O scheduler는 결국 request queue를 적절히 조작하여 seek time을 최소화하면서 global throughput을 최대화하는것인데, 여기엔 merging과 sorting의 두개의 기본 동작이 쓰입니다. 즉 request가 들어왔을때 큐에 이미 그 request가 있거나 adjacent한 block에 대한 request가 있을때 두개의 request를 합치는것입니다. 또한 디스크의 seek를 줄이기 위해서 새로 들어온 request를 FIFO방식으로 뒤에 붙이는것이 아니라 이미 기다리고 있는 request들 사이에 block번호에 따라 sorting된 상태가 되게끔 삽입해 넣는것입니다. 이렇게 함으로써 디스크의 seek를 최소화하고 disk의 arm은 디스크를 왕복 횡단하면서 서비스를 할수 있게됩니다. 이러한 모습은 마치 elevator와 비슷하기 때문에 I/O scheduler는 elevator라고도 불리웁니다.


요즘의 디스크들은 Logical Block Address를 사용하며, 디스크는 block number만을 주면 자신의 geometry에 맞춰서 해당하는 블럭을 찾아가기때문에 OS에서는 디스크의 geometry를 신경쓸 필요가 없읍니다. 여기서 중요한 가정은, 디스크가 block number를 각 block에 매핑시키는것이 sequential한 경향이 있다는점입니다. 즉 logical block number n과 logical block number n+1은 물리적으로 adjacent하는 경향이 있다는것입니다. (이것이 지켜지지 않을때는 어떻게 될까요?? 또 왜 이것을 지키지 않을때가 있을것이며, 그럴때 I/O스케쥴러는 어떻게 되야하는걸까요?)


이 주제에 대한 더욱 자세한 내용을 여기에서 살펴보시기 바랍니다. - http://www.linuxjournal.com/article/6931


 


Linus Elevator


Linux 2.4에서 쓰이던 I/O scheduler는 Linus elevator라고 불리우는 간단한 스케쥴러입니다. 새로운 request가 오면, 먼저 merging을 시도합니다. 이게 잘 안되면 sorting된 상태가 될수있는 적당한 위치를 찾아 삽입을 해넣게 되는데, 만약 이때 기존 request들중에서 너무 오래된(미리 정해진 값이 있읍니다)것이 발견되면 삽입을 하지 않고 큐의 끝에 넣게 됩니다. 이것은 가까이 뭉쳐있는 request들이 bursty하게 들어오게될때 이로 인해 기존의 다른 request들이 starvation하게 될것이기때문에 이를 방지하기 위한 것입니다. 그러나 이 age check방식이 아주 훌륭한것은 아닙니다. request latency를 줄여주기는 하지만 여전히 request starvation이 발생할 수 있었기 때문입니다. 이러한 starvation은 Linux 2.4 I/O scheduler의 문제점이었읍니다. global throughput때문에 fairness의 문제가 생기는것입니다.


write는 보통 process와는 asynchronous하게 수행됩니다. 즉 process가 write콜을 했을때 그 내용들은 실제 디스크가 아닌 버퍼에 쓰인 후에 곧바로 return되고 실제로 request queue에는 나중에 들어가서 디스크에 쓰여지는 것입니다. 이런 writeback으로 인해서 bursty하게 디스크에 쓰여지게 됩니다. 반면에 read의 경우는 file system이 한 구역을 조금 읽고, 다시 다음 구역을 조금 읽고, 하는 방식이 됩니다. 더구나 meta data를 읽기위해서 엉뚱한 구역을 또 조금 읽은 후 그 내용에 따라서 또 다른 read를 하게 됩니다. 더 중요한것은 process와 synchronous하게 동작한다는것입니다. 즉 하나의 read가 완료되기전까지 process는 block되게 됩니다. 이러한 차이점은 I/O scheduler입장에서 보면 write request는 근접한 영역에 bursty하게 들어오는 반면 read request는 시간적 여유를 두고 조금씩 들어오는 것입니다. (dependent read request들이 들어온다는 것입니다.)


이럴때 request starvation문제가 심각해집니다. write request의 bursty함때문에 request starvation의 희생양은 주로 read request가 되는것이고, 더구나 이런 천천히 연달아 들어오고 있는 read request가 모두 starvation에 시달리게되면 해당 process는 극심하게 느려지게 될수밖에 없읍니다. 이것을 writes-starving-reads라고 합니다. global throughput을 위해서 디스크의 한 지역에 대한 서비스를 먼저 해주는것이 디스크의 다른 지역에 대한 서비스를 못하게끔 하는, 이러한 unfairness가 발생하는것입니다. 사실 write는 늦어져도 별 상관없지만 (물론 그렇다고 버퍼에 오래두는것은 좋지 않지만) read의 경우 프로세스가 다음일을 진행할 수 없기때문에, 즉 process가 block되게 되고, 이것은 곧바로 latency가 되기 때문에 심각한 문제가 됩니다.


Deadline I/O scheduler


이러한 문제를 해결하기 위해서 Deadline I/O scheduler가 도입됩니다. global throughput을 최대한 보장하면서도 local unfairness를 해결하기 위해서입니다. Deadline I/O scheduler는 기존의 request queue를 sorted queue라고 부르고, 여전히 block number에 대해서 sorting된 상태로 유지하고 있읍니다. 여기에 추가로 2개의 큐를 더 추가하는데, 각각 read FIFO queue와 write FIFO queue입니다. 새로 들어오는 request는 sorted queue뿐 아니라 그 종류에 따라서 나머지 둘중에 하나의 큐에 들어가게 됩니다. 다만 이 두개의 큐에서는 FIFO방식으로 들어갑니다. 시간에 따라 배열되는것입니다. 그리고 read FIFO queue는 (기본값) 500ms의, 그리고 write FIFO queue는 (기본값) 5초의 expiration time을 가지고 있읍니다. 보통때는 sorted queue에서 request들을 꺼내서 처리하다가, 만약 나머지 두개의 큐에서 시간이 다되었다면, (이것은 현재 시간이 각 큐에서 정해진 expiration time보다 커지는 경우입니다. 각 큐의 첫번째 request가 가장 오래된것이므로 이 request들의 시간만 보면 되는것입니다. 따라서 soft deadline입니다.) 해당 FIFO queue를 처리하게 됩니다. 이렇게 해서 FIFO queue들의 request들이 expiration time을 크게 넘기지 않고 처리됩니다. 물론, deadline이 엄격하게 지켜지고 있지는 않읍니다. 이것으로 request starvation을 해결할수 있읍니다. write보다 read가 훨씬 작은 expiration time을 가지기 때문에 writes-starving-reads를 해결할수 있읍니다.


 



 


Anticipatory I/O scheduler


deadline I/O scheduler가 훌륭하기는 하지만, 여전히 문제가 있읍니다. 그런 read latency를 줄인것은 결국 global throughput을 희생한것이기 때문입니다. 그리고 이런 현상이 가끔은 심각하게 나타날수 있읍니다. write가 심하게 일어나는중에 read request가 주기적으로 들어오고 있는 경우에는 디스크는 write를 하다가 read request하나를 처리하기 위해서 seek를 하고, 다시 돌아와서 write를 하다가 다시 read request를 하기 위해서 seek를 하고, 이것을 반복할수 있읍니다. 이것은 오히려 read request때문에 seek가 심해져 read와 write모두 손해를 보고있는 경우가 됩니다. anticipatory I/O scheduler는 이런 점을 해결하기 위해 deadline I/O scheduler를 좀더 똑똑하게 동작하도록 바꾼것입니다.(anticipation heuristic이 추가됩니다.) anticipatory I/O scheduler에서는 read request가 디스크에 commit된 이후에 바로 다른 request를 처리하는것이 아니라 아무것도 하지 않고 잠시 기다립니다. (디볼트값은 6ms) 그리고 이 사이에 들어온 근접한 영역에 대한 request는 곧바로 처리합니다. 보통 이때에 연달아 그 다음 read request가 들어오기때문에 불필요한 seek를 없애고 read request처리에 집중할수가 있는것입니다. 그 사이에 그런 request가 없었다면 다시 이전 상태로 돌아가 원래대로 다음 request를 처리하게 됩니다. 이 예측이 성공하면 2번의 seek를 아끼는것이고, 실패한다면 기다린 시간은 버려지는것입니다. 이것을 위해서는 process와 file system의 행동을 잘 예측해야하는데, 이를 위해 heuristic들을 사용합니다. anticipatory I/O scheduler는 각 프로세스별로 block I/O와 관련된 통계치들을 가지고 이를 토대로 예측을 합니다. 이를 통해 read latency를 줄이면서도 global througput을 높이게 됩니다. 대부분의 workload에서 잘 작동합니다. (서버를 위한 스케쥴러라고 하는데, seek-happy databases관련된 특수한 경우에는 매우 안좋다고 합니다.)


The complete Fair Queuing I/O Scheduler


CFQ I/O scheduler는 지금까지의 스케쥴러와는 다릅니다. 각 process는 자신만의 request queue를 가지고 있고, request들은 이러한 자신만의 request queue로 들어가게 됩니다. 그리고 각 큐들에서 request들은 merge가 되고 sorting이 됩니다. 차이점은 각 process가 자신만의 request queue를 가진다는것입니다. 이후 CFQ I/O scheduler는 한번에 정해진수(기본값 4개)만큼의 request들을 round robin방식으로 각 큐들에서부터 처리합니다. 즉 process level에서 fairness를 보장합니다. 이 방식은 multimedia workload에 맞춰서 설계된 방식이지만 거의 모든 workload에서 이상 현상없이 잘 동작합니다. desktop 환경에서 추천되는 스케쥴러입니다.


The Noop I/O Scheduler


Noop I/O scheduler는 단지 merging만을 하는, 그외에는 전혀 아무런 작업도 하지 않는 스케쥴러입니다. 이 스케쥴러는 디스크가 아닌, 플래시 메모리와 같은 완전히 random-access인 장치들을 위한 스케쥴러입니다.


I/O Scheduler Selection


리눅스 2.6 에서는 모든 block device를 위한 이러한 스케쥴러를 선택할수 있는데, 디볼트는 anticipatory I/O scheduler입니다. 커널 command line에서 elevator=xxx 옵션으로 선택할수 있읍니다.

















as


Anticipatory


cfq


Complete Fair Queuing


deadline


Deadline


noop


Noop


 






I/O


 


실제 OS의 code에 있어서 I/O를 위한 code가 훨씬 많을만큼 I/O는 다양하고, 그만큼 복잡하기도 한 영역입니다. 어떤 의미에서는 "지저분"하다고도 표현하기도 합니다. 보통 Hardware적인 관점에서 볼 때 I/O를 두가지 방식으로 분류합니다. 이 분류는 I/O를 위해 사용하는 address space가 main memory address와 독립적인지 아닌지의 여부입니다.



Independent I/O : Independent I/O에서는 IO instruction이 따로 있습니다. 보통 In/Out 과 같은 instruction을 이용하여 (main memory address space와는 별개로) 따로 독립되어 있는 IO address space에 접근하며 I/O를 하는 방식입니다. 이 IO address space의 각 주소는 각 장비들의 register들에 (버스의 arbitration등에 의해서) hardware적으로 mapping되어 있습니다. 따라서 이 address space에 쓰거나 읽는 것이 그러한 장비들의 register에 쓰거나 읽는 동작이 됩니다. 이때 각 register들에 해당하는 주소들을 port라고 부릅니다.


memory mapped I/O : 반면 memory mapped I/O는 특별히 다른 address space를 사용하지 않고, 똑같이 main address space에 장비들의 register나 메모리와 hardware적으로 mapping됩니다. 따라서 이런 경우 load, store같은 일반적인 메모리 접근 instruction을 써서 IO를 할 수 있게 됩니다.


어떤 시스템들은 이러한 두가지 방식을 혼용해서 쓰기도 합니다. 대표적으로 PC의 경우 두가지 방법을 모두 사용합니다. 먼저 인지하셔야 할 것은 실제 메모리의 존재와 메모리주소(address)의 존재는 별개라는 것입니다. 그 예를 잘 보여주는 것이 PC의 예입니다. PC에서는 역사적인 이유로 인해 (물리주소) 640KB에서 1MB사이의 주소공간에 대응하는 main memory는 없습니다. 이런 부분을 memory hole이라고 합니다. 이 640KB-1MB사이의 주소는 비디오 메모리등 다른 I/O장비들의 메모리와 연결되어 있는 구역입니다. (예전 386이전에 비디오 I/O를 위해 쓰였던 방식입니다.) 대표적인 memory mapped I/O의 예라고 할 수 있습니다. 또한 아마 old user들은 serial port나 parallel port등의 장비들의 COM포트등의 주소를 맞춰주는 등의 설정을 해보신일이 있을 것입니다. 이러한 장비들이 independent I/O방식의 예라고 할 수 있습니다.



PC의 하위 1MB 에 대한 간략한 memory map입니다. 버스구조에서 볼 수 있듯이 실제 RAM은 640KB밖에 없고, 이 영역이 640KB까지 해당하는 부분입니다. 640KB-1MB, 이른 바 memory hole에 해당하는 부분은 버스에 의해서 ROM-BIOS나 Video memory에 연결되어 있습니다. bus는 각 주소에 따라서 어느 곳에서부터 해당 내용물을 읽어와야할지를 결정해야하는데, 이러한 기능을 버스의 arbitration이라고 합니다.



위의 /proc/ioports에서는 IO address space에 어떤 port들이 있는지를 살펴볼 수 있습니다. IBM PC에서는 I/O address space를 위해서 32bit의 address line중 16bit만을 쓰기 때문에 64KB의 크기를 가지는 것을 볼 수 있습니다. (0xffff 까지죠)


 


 


또한 /proc/iomem 에서는 물리 주소의 mapping을 살펴볼 수 있습니다.


 



 






Direct memory access(DMA)


 


DMA이전에는 I/O장비들이 메모리에 접근하기 위해서는 CPU가 그 작업을 해주었습니다. 이는 CPU에 많은 부하를 주게 되고, 특히 메모리에서의 copy등은 CPU에 많은 부담을 주게 됩니다. 이를 위해 I/O장비가 CPU와 상관없이 메모리에 직접 읽고 쓸 수 있게 하기 위해서 개발된 것이 DMA입니다. CPU는 DMA를 이용한 메모리 접근의 시작과 끝등을 제어하기는 하지만 그외의 실제적인 메모리 접근등은 CPU모르게 이루어지게 됩니다. 이를 통해 CPU는 I/O와 메모리간의 작업에서 해방될 수 있게 됩니다.


DMA controller는 DMA작업이 시작되면 CPU가 메모리를 사용하지 않을 때를 틈타서 메모리에 접근하여 자신의 일을 계속 수행합니다. 이를 cycle stealing이라고 부릅니다. 그리고 작업이 끝나면 CPU에게 알려주게 됩니다.


이 DMA는 유용하지만, 가끔 문제가 되는 것은 DMA는 physical address로 메모리에 접근한다는 것입니다. 이 때문에 DMA로 사용될 메모리는 물리적으로 연속되어 있을 필요가 있게 됩니다. 이는 Linux등의 OS가 최대한 메모리 할당에서 물리적으로 연속된 형태로 할당을 하려는 이유가 됩니다.


 



 






Symmetric Multiprocessor(SMP)


 


SMP는 가장 간단한 형태의 tightly-coupled system입니다. 공유되는 하나의 커다란 메모리가 있고, 여기에 여러 CPU들이 연결된 형태입니다. Symmetric이라는 것은 모든 CPU들이 동등하다는 의미입니다. 즉, 메모리등에 접근하기 위해서 질서를 만드는 특정 CPU(master CPU)가 없다는 뜻입니다. 이와는 반대로 하나의 CPU가 그외의 다른 CPU들을 관리하고 접근권한을 제어하는 master-slave구조도 있습니다. 그러나 여기서는 SMP만을 다룹니다. 따라서 MP와 SMP를 같은 뜻으로 사용하도록 하겠습니다. 이러한 MP구조의 장점중의 하나는, 기존의 programming model을 변경하지 않는다는 것입니다. 즉, 기존의 시스템콜을 그대로 사용함으로써 기존 S/W를 그대로 사용할 수 있다는 장점이 있습니다. 이와는 반대로, 다른 구조들에서는 API(시스템콜)을 새롭게 설계할 수도 있습니다. 이러한 경우 기존의 S/W가 새롭게 쓰여져야한다는 단점이 있지만, 병렬구조의 장점을 최대한 활용할 수 있다는 장점이 있을 수 있습니다.


Linux 2.0에서 초보적으로 지원되던 SMP는 2.2에서 본격적으로 지원되기 시작했습니다. ( 2.2에서는 인텔의 MP spec 1.4를 따릅니다. )


먼저 MP의 구조를 알아봅시다.



(from "Unix Systems for Modern Architectures" by Curt Schimmel)


일단 UP와 다른 것으로, 위의 그림에서 shared memory라는 것을 알 수 있습니다. 즉 각 CPU는 개개의 캐쉬 이외에는 메모리를 다른 CPU와 공유하고 있는 것입니다. 따라서, 당연히 이러한 공유되는 memory로의 접근을 직렬화(serialize)하고, 즉, 중재하는 장치가 필요합니다. UP에서 쓰였던 memory arbiter가 이러한 역할을 수행합니다. 단지 여기서의 memory arbiter는 좀더 복잡하게 작업을 수행하게 됩니다. 이 memory arbiter가 메모리에 대한 요구들을 하나씩 처리하게끔 해주는 것입니다. 또한 I/O장비 역시 모든 CPU에 의해서 공유되고 있습니다. 어느 CPU든지 IO장비를 사용할 수 있습니다. 또한 I/O장비 역시 DMA를 통해 memory에 대해 CPU와 똑같이 접근할 수도 있읍니다.


이러한 구조에서는 중앙의 Bus가 중요한 역할을 합니다. 이 bus를 통해서 모든 CPU와 I/O (DMA controller)는 메모리에 접근하기 때문에, 사실 이러한 CPU들은 멀리 떨어질래야 떨어질 수가 없습니다. 버스의 길이가 제한적이기 때문입니다. (tightly-coupled일수밖에 없는 이유죠) 또한 이 bus의 대역폭은 얼마나 많은 CPU가 연결될 수 있는지를 결정하는데 매우 중요합니다. 예를 들어, 만약 버스의 대역폭이 20Mb/sec이라면, 그리고 IO장비가 5Mb/sec만큼을 DMA로 사용한다면, CPU를 위한 대역폭으로는 15Mb/sec만이 남습니다. 이때 만일 CPU가 instruction을 지연되지 않게 실행하기 위해 3Mb/sec가 필요하다면, 이 경우 최대 5개의 CPU만이 사용될 수 있을 것입니다. 이 이상의 CPU들은 추가되더라도 심한 delay현상에 시달리게 되고, 결국 전체 시스템 성능향상에는 도움이 되지 않게 됩니다. 사실 CPU가 몇 개까지 지원되어야 하는가는 여러 가지 요소에 의해 결정되는데, 그중 또한 문제가 되는 것은, CPU내의 하드웨어 캐쉬의 consistency를 유지하는 것입니다. CPU가 많아질수록 이 consistency를 유지하는 것이 어려워지고, 이를 위해 각 CPU간에 communication하는데 더 많은 cycle을 소모하게 됩니다. 이러한 trade-off와 bus의 대역폭에 의해서 CPU의 갯수는 제한되게 됩니다. 이처럼 CPU들은 서로간의 캐쉬의 consistency를 유지하기 위해서 일련의 동작들을 취하는데, 이것을 cache snooping이라고 합니다.


MP에서는 memory model 이라는 것이 있습니다. 여기서는 항상 sequential memory model을 가정하고 있지만, SPARC ver. 8 등에서는 다른 memory model을 사용하기도 합니다. memory model이란 메모리에대한 load/store명령(micro operation)이 어떤 순서로 처리되는지, 또는 동시에 메모리에 접근하는 요청에 대해서 어떻게 처리되는지등에 대한 정책들을 말합니다. 예를 들어, 여기서 우리가 생각하는 sequential memory model은 프로그램에서 정의된대로 (compiler에 의한 load/store의 재배열은 생각지 않습니다) load/store명령이 수행되는 경우입니다. (그런 이유로 strong-ordering이라고도 합니다.) 당연한 것처럼 들리겠지만, multiported-memory 등에서는 load/store명령들이 동시에 실행될 수도 있고, 다른 memory model에서는 효율성의 이유로 load/store명령들이 재배열될 수도 있습니다. 여기서는 이러한 사항을 고려하지 않고 sequential memory model만을 고려합니다. 가장 단순한 형태인 이 memory model의 장점중의 하나는, (당연하겠지만) load/store등의 메모리 접근 명령이 atomic하게 수행된다는 것입니다. (이러한 memory operation은 micro operation입니다. 다음에 얘기하는 instruction의 non-atomicity와 구분하시길.) 이 memory model에 대해서는 다른 챕터에서 자세히 다루도록 하겠읍니다.


 


또한 MP에서의 interrupt의 처리는 UP때와 달라졌습니다. PC에서는 (intel MP spec에 따라서) APIC (Advanced Programmable Interrupt Controller)가 기존의 PIC인 8259를 대체하게 되면서, 많은 변화가 생기는데,


 


이러한 시스템에서의 OS는 UP(Uniprocessor)에서와는 달리 여러 가지가 바뀌어야 합니다.


 


 



 






Atomicity


 


일반적으로 하나의 프로그램은 여러개의 instruction으로 이루어집니다. 또한 일반적으로 CPU에서 하나의 instruction은 ROM에 저장된 micro-programming에 의해서 수행됩니다. 즉, 하나의 instruction은 여러개의 micro-op으로 이루어져있다는 뜻입니다. (chapter Microprogramming 참조) 이처럼 하나의 instruction은 여러 micro-op으로 이루어지기 때문에, 비록 micro-op이 atomic하다고해서 instruction이 atomic하지는 않습니다. 이러한 구조(MP)에서는 일단 instruction이 atomic하다는 것의 의미가 달라지게 됩니다. 즉, UP에서는 단일 instruction에 대해서는 atomic하다고 할 수 있지만, MP구조에서는 단일 instruction조차도 atomic하지 않을 수 있는 것입니다. 즉, (sequential memory model에서) UP나 MP모두 micro-op은 항상 atomic합니다. 왜냐하면 메모리 bus를 한번에 하나의 controller만이 사용할 수 있기 때문입니다. 그러나 instruction level에서 UP에서는 그외의 메모리로 접근하는 것이 없기 때문에 자연히 instruction도 atomic하지만, MP에서는 instruction이 atomic하지 않을 수 있습니다. 이것은 메모리를 공유하기 때문인데, incl 명령과 같이 여러번 메모리를 참조하는 instruction의 경우 자신이 먼저 메모리에서 읽은후에, 다른 CPU에서 역시 같은 메모리에 접근한후에, 자신이 다시 메모리로 쓸 수 있기 때문에 atomic하지 않을 수 있게 됩니다. 이러한 race condition이 발생할 수 있는 것입니다. 이를 좀더 자세히 살펴보면, 예를 들어 incl 과 같은 메모리의 내용물의 값을 1만큼 증가시키는 increment instruction의 경우, 대강 다음과 같은 과정의 micro-op들을 거치게 됩니다.


1) 주어진 주소의 내용물을 A라는 레지스터에 싣는다. (read)
2) A 레지스터를 1증가. (increment)
3) A 레지스터의 내용물을 다시 주어진 주소에 써넣는다. (write)


이 경우 2번의 메모리 접근이 있음을 알 수 있습니다. 이런 때 bus arbitration을 보면, request/grant 선을 통해서 1)번에서 버스를 사용하고, 다시 반납한후에, 2번에서는 bus가 idle한 상태이고, 3번에서 다시 request/grant를 통해 버스를 사용하게 됩니다. UP환경에서는 이처럼 여러개의 micro-instruction이 모여서 하나의 instruction이 되지만 중간에 2)번에 끼어들 요소가 없으므로(메모리를 공유하지 않으므로) 자연히 하나의 instruction은 atomic해집니다. 그러나 MP에서는 2)번에 다른 CPU가 끼어드는 사태가 벌어지고, micro-instruction level에서는 atomic하더라도  instruction level에서는 atomic하지 않는 사태가 벌어집니다. 이 경우의 시나리오는, A CPU와 B CPU가 동시에 같은 메모리에 대해서 incl를 수행한다면, 다음과 같을 수 있습니다.


 


CPU A                CPU B


read
                            read
increment            increment
write
                           write


 


바로 race condition이 발생하게 됩니다. 이 경우 2번의 incl 이 수행되었지만 결과적으로 1밖에 증가되지 않는 상황이 벌어지는 것입니다. 원래 의도대로라면, 다음과 같아야하겠습니다.


 


CPU A                CPU B


read
increment
write
                        read
                        increment
                        write


 


이런 경우 2만큼 증가되게 됩니다. 이와 같이 MP에서는 한 CPU가 기존처럼 1번에서 read하고 버스를 놓아버리고, 다시 3번에서 버스를 차지하는 것이 아니라 1번에서 버스를 차지하고, 3번까지 버스를 꽉 잡고 있어야만 instruction level에서의 atomic함이 보장됩니다. intel 계열에서는 이러한 일을 lock prefix가 해줍니다.


좀더 부연해보자면, 아시다시피, 하나의 bus를 여러 controller가 공유할 때는 bus arbitration이 필요합니다. 이것은 하나의 bus는 하나의 controller만이 한순간에 쓸수 있기 때문에 (하드웨어적으로) 쉽게 말해서 여러 controller에게 bus를 스케쥴링해준다는 할 수 있읍니다. 이 arbitration controller가 버스를 잠그게 되고, 그렇게 되면 다른 controller에서 쓰고 싶어도 버스를 쓰지 못하게 됩니다. 하드웨어적으로 간단히 살펴보면, controller의 버스에 대한 사용은 일반적으로 request line에 신호가 걸리고, 이를 받아서 granted line에 신호가 걸림으로써 이루어지게 되는데, bus에 lock을 걸기 위해서 arbitration controller는 (아마도) 다른 request line에 대한 응답인 grant를 주지 않을 것입니다. 그러면 버스는 잠기게 되는 것이고, 다른 controller가 사용하지 못하게 됩니다. 이제 현재 사용중인 CPU가 lock을 풀면 그때 grant가 다른 CPU에게 넘어가게 될 것으로 생각됩니다. 이런 구조로 이 lock을 구현할수 있습니다.


이상과 같은 시나리오를 통해서 또한 알 수 있는 것은, 한번의 메모리 접근만을 수행하는 instruction은 MP에서도 atomic하다는 것입니다. 따라서 이러한 현상은 어느 한 operand에 대해서 두 번이상 접근을 하는 instruction이라고 할 수 있습니다. 이러한 instruction의 non-atomic함을 해결하기 위한 것이 lock prefix인 것입니다. 이 lock prefix는 이러한 instruction에 대해서 다른 processor의 해당 메모리(피연산자)로의 접근을 차단해 주는 것입니다. 286부터 지원된 이 명령은 각 lock이 붙은 instruction에 대해서 버스를 잠금으로써 해당 instruction을 atomic하게 만들 게 됩니다. 펜티엄에서는 18개의 명령에 대해서 lock이 붙을 수 있고, xchg와 같은 명령에 대해서는 lock이 없어도 버스를 잠그기 때문에 lock이 붙은 것과 같다고 생각할 수 있습니다.


 



 






OS다시보기


이번에는 OS란 무엇인가를 다시한번 생각해보고자 합니다. 첫장에서 OS에 대한 기초적인 설명을 하였고 이 부분까지 읽으셨다면 OS가 무엇인지 아시겠지만, 사실 현대에 들어서는 어디까지가 OS이고 어디까지가 OS가 아닌가를 결정하는 것은 그리 간단한 것 같지 않습니다. 예를 들어 'Linux' 혹은 'Linux system'이라는 말로 사람들이 받아들이는 것은 Linux커널뿐만이 아니라 library, compiler, shell등을 함께 아무르는 것 같습니다. 엄밀한 의미에서 Linux라는 상표는 (Linux는 trademark입니다) 커널부분만을 뜻하는 상표입니다. 따라서 Stallman의 지적대로 제대로 명명하기 위해서는 'Linux/GNU system'이라고 불리워야 마땅할 것입니다. (Stallman이 좀 억울하게 생각하는 부분이기도 한만큼 이 책을 읽으시는 분이라면 이해하실만할 것 같네요.) 또는 윈도우의 경우 커널내부에 windowing system를 탑재하고 있습니다. 즉 GUI서비스를 커널에서 제공하고 있는 것입니다. 리눅스에서는 X등의 application에게 맡기는 것과는 대조적입니다. 따라서 커널에 어떠한 서비스를 넣고 어떠한 서비스를 커널밖으로 꺼낼 것인지에 따라서 커널의 영역이 바뀔 수가 있습니다. 즉, 처음의 그림에서 하드웨어를 관리하는 아랫부분은 그대로이지만 application에게 서비스를 해주는 윗 영역은 OS마다 다를 수 있다는 것입니다.


윈도우즈같은 경우 최대한 많은 서비스들을 커널에 넣어놓은 경우라고 한다면 리눅스는 전통적인 영역까지만을 넣고 있는 경우입니다. 만일 이러한 서비스들을 최대한 밖으로 꺼낸다면 어떻게 될까요? 최근들어 연구되고 있는 Virtual machine이 됩니다. VMM(Virtual machine monitor)라는 개념은 사실상 커널이 극단적으로 최소화된 경우라고 생각할 수가 있는 것입니다.


보통 이러한 커널이 제공하는 서비스들은 모두 커널이라는 하나의 실행 이미지안에 모두 들어가있는데 이러한 것을 monolithic kernel이라고 부릅니다. 전통적인 방식이고 리눅스 역시 이러한 monolithic kernel의 형식을 가지고 있읍니다. 이 방식은 속도가 빠르고 단순하다는 장점이 있는 반면 커널의 크기가 커진다는 단점이 있읍니다. 리눅스는 이러한 단점을 모듈이라는 장치를 통해서 극복하는데, 이 모듈은 필요할때만 메모리에 올렸다가 필요가 없어지면 다시 내리는 형식의 커널코드의 일부분입니다. lsmod와 insmod등의 명령을 통하여 이러한 모듈을 살펴볼수 있읍니다. 이와 대조적으로 micro kernel이라는 형식의 커널은 각 커널의 서비스들을 server라고 하는 프로세스들로 나누어 놓은 형식입니다. 따라서 monolithic과 다르게 각 서비스간의 switching이 일어나야 하고 이러한 context switching의 overhead와 함께 서비스를 받기 위한 message passing의 overhead가 속도를 느리게 한다는 단점이 있읍니다. (monolithic의 경우 커널모드로의 진입에 address space의 switching등이 없기 때문에 -address space가 user space와 kernel mode로 split되어있을경우- 빠르게 동작할수 있죠) 장점이라면 monolithic과 다르게 커널의 어느 한부분에 문제가 있더라도 해당 부분만이 죽게 되고 해당 서비스를 다시 실행해주면 다시 동작할수 있다는것, 또는 network을 건너서도 동작할수 있다는등의 장점이 있읍니다. 개념적으로 잘 정리되어 있다는것 역시 장점이 될수 있을것입니다.


이 외에도 exokernel과 같은 형식의 커널이 있는데 이것은 커널의 기능을 극도로 최소화 시키는 형식입니다. 이것은 virtual machine과 함께 다룰수 있기때문에 다음에 논의하겠읍니다.


 






Virtual machine


 


 VM은...


 


 



 






 


Biblography and reading list


책들 뒤에 붙어잇는 Biblography나 reading list는 멋이 아닙니다. :-)


 


Books


Stevens 의 Advanced Programming in the UNIX Environment 과 TCP/IP Illustrated 시리즈


-> UNIX와 network 프로그래밍의 기본! 완벽한 이해와 경험 그리고 노력! 완벽한 앙상블을 보여주는 책들입니다. 이런 사람을 우리는 엔지니어라고 부르죠.


 


Linux Kernel Development by Robert Love


-> 현재 2.6에 대해서 가장 최신의 정보와 심도있는 내용! 초강력 추천! 개인적으로 이 책의 big fan이며 저자의 humorous함에 반해버렸다는거. :-)


 


TLK : The Linux Kernel


-> David A. Rusling의 TLK. 초보자에게 강력추천


 


"UNIX Systems for Modern Architectures : Symmetric Multiprocessing and Caching for Kernel Programmers"


-> Curt Schimmel의 책. MP환경에서의 캐쉬와 Kernel synchronization에 대한 심도깊은 이해와 설명!


Understanding the Linux Kernel


-> VM나 OS의 구조에 익숙하지 못하신 분들에게는 그다지 추천하지 않고싶습니다. 위의 TLK같은 다른 좋은 입문서들을 살펴본 후에 보셔도 늦지 않으실 듯.


References


 


IA-32 Intel Architecture Software Developer's Manual


-> 인텔 32비트 CPU에 대한 모든 것을 담고 있습니다. 부분적으로라도 꼭 읽으시기 바랍니다. RTFM.


Intel MP spec 1.4


-> 말그대로 Intel의 MP spec.


Articles


 


Peter J. Denning 의 Before memory was virtual


-> VM의 발전사, thrashing을 working model로 극복함. locality...


 


Joe Knapka의 Outline of the Linux Memory Management System


-> 제가 번역한 것도 읽어보시길...


 


"Memory Management in Linux : Desktop companion to the Linux Source Code"


-> 저자인 Abhi Nayani 의 사이트인 http://www.symonds.net/~abhi 에서 꼭 받으시길.


 



 






Computer Architecture


(시험적인 chapter)


Operating System도 결국엔 H/W위에서 돌아가는 S/W입니다. 따라서 당연하게도 H/W에 대한 지식과 이해가 얼마만큼인가에 따라서 Operating System에 대한 이해도도 달라집니다. 특히 Computer Architecture에 대한 이해는 Operating System을 제대로 이해하기 위한 선결 과제입니다.


Computer Architecture는 결국 우리가 알고 있는 computer model을 어떻게 효율적으로 구현할 것인가 하는 문제라고 할 수 있습니다. 즉 CPU를 어떻게 디자인하고 만들 것인가 하는 문제죠. 이를 위해 가장 기본적으로는 Instruction set이 결정됩니다. 이를 ISA(Instruction Set Architecture)라고 합니다. 우리가 일반적으로 IA32(x86이죠), IA64, x86-64등의 이름으로 부르는 일반적인 아키텍쳐가 바로 ISA입니다.


CPU는 개념적으로 크게 두 부분으로 나눌 수 있습니다.


<생략...>


 


 


컴퓨터 모델에 대한 실질적이고 보다 자세한 설명을 다음에서 읽어보시기 바랍니다.


http://arstechnica.com/paedia/c/cpu/part-1/cpu1-1.html


여기서는 기본적인 개념만이 설명되었고, 다음의 기사에서는 Pipelining과 Superscalar Excution에 대한 기초적인 설명을 합니다.


http://arstechnica.com/paedia/c/cpu/part-2/cpu2-1.html


Superscalar는 여러개의 ALU를 뜻합니다. instruction stream을 reordering함으로써 여러개의 ALU을 활용할 수 있게 되고 이로 인해 성능은 향상됩니다. 사실상 parallel machine이 됩니다. 물론 프로그램에서는 여전히 1개의 code stream과 1개의 data stream을 보고 있지만 실제로 CPU내부에서는 이러한 stream은 적절히 섞여져서 2개의 ALU에 입력으로 들어가게 됩니다. 그러나 여기서부터 dependency의 문제등이 발생하게 되죠.


여러개의 ALU가 동시에 수행되기 위해서는 그만큼 많은 레지스터가 필요하게 되지만, 레지스터등의 자원이 부족해질 때 동시에 수행될 수가 없게되고, 이것을 Structural hazard라고 부릅니다.


여기서 pipelining과 superscalar에 대해서 더 자세히 알아봅니다.


http://arstechnica.com/articles/paedia/cpu/pipelining-1.ars/1


http://arstechnica.com/articles/paedia/cpu/pipelining-2.ars/


SMT에 대한 소개도 조금나오는데, 이것의 장점이라면, 한 thread의 pipelining이 stall되어서 진행하지 못하고 있을 때 놀고 있는 unit들을 다른 stall되지 않은 thread가 쓸 수 있게 된다는 것입니다.


 


다음에서 K7에 대해서 살펴봅니다.


http://arstechnica.com/cpu/3q99/k7_theory/k7-one-1.html


http://arstechnica.com/cpu/3q99/k7_theory/k7-two-1.html


 


다음에서 K8 아키텍쳐에 대해서 살펴봅니다.


http://www.cpuid.com/reviews/K8/index.php


흥미로운 것은 메모리 컨트롤러가 CPU안으로 들어갔다는 것이고, 또한 Intel과 비교하여 L1캐시와 L2캐시의 관계가 exclusive하다는점등 눈여겨볼 부분들이 많습니다.(이부분은 나중에 다시...)


 


Pentium M에 대한 리뷰입니다.


http://www.cpuid.com/reviews/PentiumM/index.php


 


 


다음에서 Multithreading과 Superthreading, HyperThreading(SMT)에 대해서 자세히 알아봅니다.


http://arstechnica.com/articles/paedia/cpu/hyperthreading.ars/1


 


 


 


다음에서 컴퓨터 시스템에 대한 개괄을 볼 수 있습니다. memory bus, FSB(Frontside bus), chipset, southbridge, northbridge, bus protocol등에 대해서 알아봅니다. 칩셋은 southbridge와 northbridge두개의 칩을 합쳐서 부르는데 특히 northbridge는 CPU와 메모리, PCI버스등을 연결하는 역할을 합니다. 여기에 메모리버스를 컨트롤하는 메모리 컨트롤러와 FSB를 컨트롤하는 컨트롤러등이 모여있습니다.


내용중에도 나오지만 최근 인텔의 경우 northbridge와 southbridge를 각각 Memory Controller Hub(MCH), I/O Controller Hub(ICH)라고 바꾸어 부르고 있습니다. 이 내용은 AGP이전의 시대의 내용이지만, 컴퓨터 시스템을 전체적으로 살펴볼 수 있습니다.


http://arstechnica.com/articles/paedia/hardware/mobo-guide-1.ars


이후 AGP가 등장하면서 그래픽 카드가 고성능 프로세서를 작창하면서 PC는 사실상 RAM을 공유하는 Asymetric multiprocessing system이 되어버립니다.


<Mother board - Part II>


 


이런 것들에 대한 좀더 informal한 가이드가 있네요.


http://blog.naver.com/jslk.do?Redirect=Log&logNo=20014425918


 


 


 


70년대와 80년초반에 연구되던 data flow에 대해서.


http://en.wikipedia.org/wiki/Data_flow


 


OOO는 이 연구의 제한적인 적용이라고 할 수 있는데,


http://en.wikipedia.org/wiki/Out-of-order_execution


 


register renaming이 도움이 되죠.


http://en.wikipedia.org/wiki/Register_renaming


 


문제는 OOO로 인해 load/store같은 memory operation들도 reordering된다는 것인데, 보통 single thread인 경우에는 문제가 되지 않지만 그외에서 문제가 됩니다. 이로 인해 memory barrier가 나오게 되는데,


http://en.wikipedia.org/wiki/Memory_barrier


 


리눅스에서 이를 어떻게 다루는지 봅니다.


http://www.linuxjournal.com/article/8211


 


 


 



 






Microarchitecture


어떤 주어진 ISA에 대해서 실제로 칩위에 어떻게 CPU를 구현할 것인가하는것이 microarchitecture입니다. 실제 engineering이라고 할 수 있는 부분입니다. Intel이나 AMD의 CPU들의 코어에 해당하는 부분이기도 합니다. Intel의 P5, P6, NetBurst, Core등 혹은 AMD의 K5, K6, K7, K8, K8L등의 microarchitecture들간의 경쟁은 컴퓨터 산업을 이끌어온 핵심 부분이기도 하죠. 이들간의 경쟁을 통해서 microarchitecture를 살펴보는 것도 흥미로운 일입니다.


 


P5 vs K5


P6 vs K6,K6-2,K6-III


NetBurst vs K7


Core vs K8, K8L


 


Informal하게 둘간의 혈전을 얘기해보자면...


(둘은 한판 한판 숨막히는 일전을 벌여왔는데, 아무래도 NetBurst에서 인텔이 삽질한 것같다. 일단 이름부터 맘에 안들자나..-_-;; 웬 NetBurst.. 아키텍쳐에 안어울리는 이름을.. 1.0GHz의 clock race에서 뒤쳐지면서 AMD가 일대 반격을 가한 한판승. 이후 NetBurst는 K7에게 밀리는 양상을 보이기 시작한다... 결국 인텔, P8이라 할수 있는 Core아키텍쳐를 뽑아드는데,... 사실 Core는 P6의 후계자라할수 있다. NetBurst가 아니라말이다. 20 stage의 pipelining이라는 놀라운 쇼를 보여준 NetBurst.. 이 쇼를 하기 위해 L1-I캐시도 trace캐시라는 희안한 방식을 도입한다. 거기에 돈줄이라 할수 있는 FSB대역폭도 K7에 밀리고 만다... 이제 관중을 즐겁게해준 NetBurst.. 이정도로 하고 퇴장..-0-;; 결국 Core는 이런 이벤트를 선보인 NetBurst의 성의에도 불구하고 "나는 P6의 자식이에요..흥.." 이라며 생부를 P6로 밝히고 만다. 물론 AMD집안은 이런 골아픈 집안내력이 없었으니 K8은 당당한 K7의 후손이다. 다만 메모리 콘트롤러를 몸에 품고있는 희안한 녀석이라면 희안한놈일까-_-;; 그러나 역시 만만치 않은 인텔, 비록 출생의 비밀을 안고있는 Core지만, 차세대 아키텍쳐로 뽑아든다. 그것도 쌍둥이로-_-;; 듀얼코어Core라니.. (왜 이름도 이따위냐.. Core라니.. 사람 헛갈리게..) 그러나 역시 AMD도 그동안 시장에서 몸이 뜨거운 아이라거나 정수연산은 잘하더니 소수점만 들어가면 못하더라는 놀림등을 받으며 강하게 커온 내력이 있다. 이대로 물러설수 있으랴.. K8을 한번더 중무장시키고 덥다고 벗어놨던 L3캐시까지 덤으로 붙여서 K8L로 내보낸다. 맞짱한번 떠보자는 것이다. 그것도 네쌍둥이로 말이다-_-;; 인텔이 둘이라면 자기는 네 개라나..머라나.. 이제 곧 한판승부가 벌어질것같다...과연??)


 


 






Microprogramming


CPU를 구현하는 가장 무식-_-;한 방식은 hard-wired방식으로 구현하는 것입니다. Digital Logic시간에 배운 논리들을 이용해서 instruction들의 동작을 직접 구현하는 것이지요. 아주 간단한 CPU정도나 이런 방식이 가능하겠죠. (뭐 학부에서 프로젝트로 나가기도 하던데요...)


이런 간단한 CPU가 아닌 웬만큼 복잡한 CPU들은 모두 microprogramming방식을 사용합니다. 이것은 하나의 instruction을 여러개의 micro-op에 의해서 수행하는 방식인데, 이러한 프로그램들을 micro-programming이라고 하고, 통상적으로 ROM으로 구현되어 있읍니다.


 


 






Memory model


 


Memory model...


 


 



 






Appendix A - Linux


(다른 책에서 볼 수 있는 지루한 얘기는 생략하죠.) 다들 아시는 바로 그 Linux입니다. Robert Love의 책에서 인용하자면, Linux가 만들어지게된 배경은 이렇습니다. Minix를 마음대로 쓰지 못하게 되자,...Linus did what any normal, sane, college student would do : He decided to write his own operating system. :-) 리눅스는 이렇게 시작되서 현재까지 이르고 있읍니다.


Linux는 수많은 개발자에 의해서 개발되고 있지요. 현재 2.6.x.y대의 개발이 이루어지고 있는데, 이런 개발과정 다음을 통해 봅시다.


http://linux.tar.bz/articles/2.6-development_process


 


이하 내용은 Linux에서의 MM(memory management)를 여러 문서를 보고, 소스를 보면서 연구한 내용들입니다. 아직까지는 적당히 끄적거린 낙서장 수준입니다.


 


Linux에서의 가상 메모리


Linux는 4GB중 상위 1GB를 kernel의 주소공간으로 할당해놓았습니다. 아니, 각 가상주소공간(virtual address space)는 각 process마다 독립적으로 가진 것인데, 그중 1GB를 커널이 가진다는 것은 무슨 의미일까요? 다른 말로, 모든 process가 가지는 각각의 주소 공간 중에서 상위 1GB는 모두 공유한다는 것입니다. 즉, 각 주소공간의 상위 1GB는 동일한 physical memory로 mapping된다는 것입니다. 따라서, context switch가 일어나더라도 상위 1GB는 항상 동일한 영역을 가리키고 있으므로, 커널 입장에서는 user space로의 접근이 용이하며(만일 커널이 독립적인 address space를 가진다면 system call때마다 context switch가 일어나야 하며, user space로의 접근이 매우 힘들 것입니다.), 또한 TLB의 효율성도 증대됩니다. TLB를 flush하더라도 user space만을 flush하면 되니까 말입니다. 이러한 커널 공간은 당연히 kernel mode에서만 접근이 허용되는 구간입니다. (이 1GB라는 공간은 linux에서 PAGE_OFFSET이라는 이름으로 정의되어 있습니다. PAGE_OFFSET은 보통 0xC0000000 로 정의됩니다. 즉, 3GB입니다. 이것을 수정함으로써 조절할 수 있습니다.) 따라서 linux에서 하위 3GB만이 process의 address space가 됩니다.


아마 linux에서는 1GB를 넘는 메모리는 다르게 처리한다는 것을 아실겁니다. 사실 컴퓨터에 탑재된 메모리가 960MB대를 넘어가면 나머지 memory는 high memory라고 부르며 (DOS시절 High memory와는 다릅니다.) 그 이하의 메모리와는 좀 다른 방식으로 커널에서 처리됩니다. 이 이유는, 바로 커널이 이 커널 공간(상위 1GB)에 실제로 존재하는 물리 메모리를 모두 mapping하기 때문입니다. 사실, 물리 주소 0부터 시작해서 PAGE_OFFSET이후의 주소로 mapping됩니다. 이 커널 공간에는 커널 이미지와 여러 커널이 사용하는 데이터구조들이 있고, 나머지 공간들은 물리 메모리를 mapping하는데 사용합니다. 즉, 모든 물리 메모리들(페이지들)은 이 커널공간에 반드시 하나의 mapping을 가집니다. 즉, VM를 사용하면서 이렇게 모든 물리 공간을 쭉 mapping시켜놓음으로써 커널 입장에서는 편리하게 메모리 관리를 할 수 있습니다. 이렇게 커널 공간 1GB속에 남는 공간이 대략 960MB대이기에, 만일 이보다 많은 물리 메모리를 가진다면, 이들은 mapping될 수가 없게 되고, 커널에 의해서 특수하게 관리되어 집니다. 이러한 메모리를 high memory라고 부릅니다.


이와 같이 커널 공간 1GB는 PAGE_OFFSET이라는 주소부터 모든 물리 주소를 mapping합니다. 그렇다면, 우리는 VM을 사용하면서도 편리하게 물리주소를 그대로 사용할 수가 있습니다. 즉, 물리주소에 PAGE_OFFSET을 더하기만 하면 그것이 VM을 사용할 때의 커널공간의 주소가 되고, 그곳에 바로 해당 page가 mapping되어 있는 것입니다. 그 반대과정도 마찬가지죠. 이렇게 물리 주소와 가상 주소를 변환해주는 매크로가 __va(phys_addr)과 __pa(virt_addr)매크로입니다. PAGE_OFFSET을 빼거나 더하는 것입니다.


fixmap과 kmap 페이지테이블들은 커널 가상공간의 윗부분을 차지합니다. - 그래서 PAGE_OFFSET매핑에서 물리 메모리를 영구적으로 매핑하는데 쓰일수 없게 되는 주소들인것입니다. 이런 이유로, 커널 VM의 상위 128MB는 예약되어있읍니다. (vmalloc 할당자도 또한 이 영역을 씁니다.) 그렇지 않았었다면 PAGE_OFFSET매핑에서 4GB-128MB 범위에 매핑되었을 물리 페이지들은 그 대신에 (만약 CONFIG_HIGHMEM이 지정되었다면) high memory zone에 속하게 되고, 오로지 kmap() 을 통해서만 커널이 access하게 됩니다. 만약 CONFIG_HIGHMEM이 true가 아니면, 이런 페이지들은 사용하지 못하게 됩니다. 이것은 900-odd MB나 그 이상의 큰 메모리를 가진 기계에서만 문제가 됩니다. 예를 들어, 만약 PAGE_OFFSET이 3GB라면 그리고 기계가 2GB의 램이 있다면, 단지 첫 번째 1GB-128MB만이 PAGE_OFFSET과 fixmap/kmap 주소의 시작번지 사이의 범위에 매핑될수 있습니다. 나머지 페이지들은 아직 쓸수 있읍니다. - 사실 user-process 매핑에 있어서 그들은 direct-mapped pages처럼 똑같이 행동합니다 - 하지만 커널은 그들을 직접 access할수 없읍니다.


 


Linux에서의 MM의 초기화


 


리눅스 2.4.18을 기준으로 쓰여졌습니다. 소스를 보기위해 lxr에 접속후에 한줄씩 건너가면서 공부해보시기 바랍니다.


용어 ----------------------
PGD : page directory table
PGT : page table
PTE : page table entry


head.S부터 시작 ------------------


일단 시작은 head.S에서부터 보도록 하겠읍니다. head.S에서 Paging Enable이 이루어 지므로 그 이전의 것은 여기서 다루지 않겠읍니다. 리눅스에서 메모리의 초기화는 두단계로 볼수 있습니다. 처음 페이징을 켜기 직전까지 만들어지는 임시 페이지 테이블과, 이후에 start_kernel이 호출된이후 새롭게 페이지 테이블이 만들어지는 두 단계입니다. 페이징을 켜는 부분을 봅시다. 커널이미지가 리얼모드에서 메모리에 막 올라왔을 때 코드의 물리 주소는 0x00100000으로 1MB에 위치합니다. PC계열에서 하위 1MB는 많은 예약된 부분들이 있기 때문에 피해간것입니다.

+------------------+
| 실제 커널의 text |
| |
+------------------+ 0x106000
| 실제 커널의 text |
| |
+------------------+ 0x105000 (stext와 _stext)
| empty_zero_page |
| |
+------------------+ 0x104000 (empty_zero_page)
| PGT |
| 물리주소 4-8MB |
+------------------+ 0x103000 (pg1)
| PGT |
| 물리주소 0-4MB |
+------------------+ 0x102000 (pg0)
| 커널의 PGD |
| |
+------------------+ 0x101000 (swapper_pg_dir)
| |
| ?? |
+------------------+ 0x100000

이때의 물리 주소를 보면 상위 1MB에서부터 1번째 페이지는 잘 모르겠고 (아마 1번째 페이지에는 6번째 페이지의 커널 코드로 점프하는 코드가 있지 않을까 싶습니다.) 2번째 페이지는 바로 커널의 PGD입니다. 따라서 swapper_pg_dir의 값은 0x101000이 됩니다. 세 번째 페이지는 바로 앞의 두 번째 페이지에서 연결되는 PGT입니다. 이것은 물리주소 0부터 4MB까지를 매핑하게 되는것입니다. 다음 네 번째 페이지는 다음 PGT로서 4MB에서 8MB까지를 커버합니다. 다음 5번째 페이지는 empty_zero_page로서 쓰이지 않는 더미 페이지이고, 다음 6번째 페이지부터가 실제 커널의 text가 들어가는 곳입니다. (stext 와 _stext) 이런 메모리 맵을 가지고 head.S에서는 임시적 PGD와 PGT을 마련합니다. 이제 CR3에 swapper_pg_dir을 넣기만 하면 되는것입니다. PGD의 내용을 살펴봅시다. 보다시피 1024개의 엔트리중 4개만을 정의합니다. 앞에 두 개는 바로 다음으로 나오는 두 개의 페이지를 가리킵니다. 즉, 2개의 PGT을 가지게 됩니다. 2개의 엔트리이므로, 이것이 0부터 8메가까지를 매핑함을 알수 있습니다. 여기서 주의깊게 볼 것은, 그 이후에 766개의 엔트리를 건너뛴 후에 같은 매핑을 가진다는것입니다. 이 부분은 3G부분으로, 커널의 가상주소공간이죠. 따라서, 이 임시적 매핑은 0-8MB의 물리 공간을 가상공간의 0MB부터 8 MB와 3G부터 (3G+8MB)에 매핑시키게 됩니다. 앞부분인 identity mapping은 페이징이 막 켜진 직후의 혼란을 막기 위함이고, 뒷부분은 커널 주소공간에 커널 이미지를 넣는다는 의미가 됩니다. 이제 조금씩 살펴보죠.


 


375 /*


376 * This is initialized to create an identity-mapping at 0-8M (for bootup


377 * purposes) and another mapping of the 0-8M area at virtual address


378 * PAGE_OFFSET.


379 */


380 .org 0x1000


381 ENTRY(swapper_pg_dir)


382 .long 0x00102007


383 .long 0x00103007


384 .fill BOOT_USER_PGD_PTRS-2,4,0


385 /* default: 766 entries */


386 .long 0x00102007


387 .long 0x00103007


388 /* default: 254 entries */


389 .fill BOOT_KERNEL_PGD_PTRS-2,4,0


 


base address 가 각각 00102, 00103이고, 007에서 7은 111로 user권한, RW, Present를 표현. BOOT_USER_PGD_PTRS는 pgtable.h 참고. 이값은 __PAGE_OFFSET을 22비트만큼 쉬프트하여 즉, 4MB단위가 몇 개가 들어있는지를 나타낸다고 할수 있다. 여기서 2를 뺀다.(두개는 이미 설정했으니까) 즉, 766개의 텅빈 엔트리를 채워넣고, 이번엔 PAGE_OFFSET에서부터 8M를 같은 페이지로 설정해서 공유한다. 나머지는 같은 원리로 254개를 채워넣는다. 즉, 766+254+4 = 1024 로서 일단 page directory를 마련한다. 이제 물리 메모리의 구성을 대강 알았으니, 코드를 살펴봅시다.


 


/*


* Enable paging


*/


3:


movl $swapper_pg_dir-__PAGE_OFFSET,%eax


movl %eax,%cr3 /* set the page table pointer.. */


movl %cr0,%eax


orl $0x80000000,%eax


movl %eax,%cr0 /* ..and set paging (PG) bit */


jmp 1f /* flush the prefetch-queue */


1:


movl $1f,%eax


jmp *%eax /* make sure eip is relocated */


1:


 


이 부분에서 paging이 켜집니다. swapper_pg_dir에서 __PAGE_OFFSET을 빼서 실제 물리주소인 0x00101000 라는 주소를 CR3에 넣어서 page directory로 접근할 수 있도록 하고, 페이징을 켭니다. (PG bit을 켭니다.) 여기서 중요한 것은 jmp명령에 의해서 eip가 재위치된다는것입니다. 이전까지 EIP는 1MB위의 어디쯤에 있었을것입니다. 레이블들은 모두 커널 가상주소 공간에 있기 때문에, jmp를 하게 되면 EIP는 3GB위의 어디쯤으로 비로소 옮겨가게 되는 것입니다. jmp로 prefetch큐를 비우고, 다시 점프로 eip를 재위치시킵니다. (prefetch큐가 비워지는 jump에서 이미 eip가 재위치되는 것으로 생각됩니다.)


다음은 Knapka의 부가설명입니다.-------------
하지만 paging켜기전에도 이런 label들로의 jump명령문들이 있읍니다. 어떻게 이런 점프들이 작동하느냐고요? x86 기계어코드에서는 256바이트보다 작은 점프들은 상대적인 점프들로 코드되기때문에, head.S에서 페이징켜기 전의 모든 점프들은 short jump입니다. head.S의 코드는 페이징이 켜지기전까지는 절대 주소로의 직접적인 참조는 절대 하지 않습니다! (head.S의 코드로의 호출이 있기전까지의 모든것들은 real mode에서 벌어지며 boot-time magic의 일부분임이 분명합니다; 저는 여기서 head.S이전에 일어나는것들에 대해서는 신경쓰지 않겠습니다.)
------------------------------------------


(의문점이 많습니다. 정확히 relocating은 어느 점프에서 일어나나? 첫 번재? 두 번째? 페이징이 켜진전과 후의 jump명령은 어떻게 행동을 달리 하는가?? 보호모드에서는 near/far의 구분이 없다는데,... 컴파일러가 구분하는 것 같지는 않고, CPU자체가 동일한 OP코드를 가진 점프에 대해서 모드에 따라서 달리 행동한다는 얘기같은데..)


이렇게 해서 페이징을 켠후 start_kernel()을 호출합니다. 이것은 호출되는 첫 번째 C함수이자, 이후에 idle process인 pid 0번 프로세스가 되는 그 프로세스입니다.


 


start_kernel() -------------------------------


이 함수에서 "init" 커널 쓰레드가 시작됩니다. 여기서 주요한 일 중 하나가 setup_arch()의 호출입니다. 이것은 아키텍쳐에 specific한 설정들을 하는 함수입니다.


 


이 setup_arch()에서 호출하는 paging_init()이 끝난후, 다른 커널 subsystem의 추가적인 setup을 더 합니다. 어떤것들은 bootmem allocator 를 이용해서 추가적인 커널 메모리를 할당하기도 합니다. MM관점에서 이중 중요한 것은, kmem_cache_init()입니다. 이건 slab allocator 의 data를 초기화합니다.


 


 


 


setup_arch() ---------------------------


여기서 하는 메모리 관련 첫 번째일은 사용 가능한 low메모리와 high메모리의 페이지들의 수를 계산하는겁니다. 각 메모리 타입에서 가장 높은 page번호는 각각 highstart_pfn과 highend_pfn이라는 전역변수에 저장됩니다. 다음으로, setup_arch()는 boot-time memory allocator를 초기화하기위해 init_bootmem()을 부릅니다. ( bootmem allocator는 영구적인 커널 data를 위한 페이지들을 할당하기 위해서 단지 부팅시에만 사용됩니다. 앞으로 그것에 대해선 크게 다루지 않을것입니다. 기억해야할 중요점은 bootmem allocator가 커널 초기화를 위한 페이지들을 제공해준다는 점입니다. 그리고 이런 페이지들은 영구적으로 커널을 위해 예약됩니다. 거의 마치 커널 이미지와 함께 로딩되었듯이 말입니다. 그들은 부팅이후 어떠한 MM에서도 참여하지 않습니다. ) 그후, paging_init()를 부릅니다.


 


 


 


init_bootmem() ---------------


setup_arch()에서 호출되어서 boot mem 할당자를 초기화합니다.


 


 


 


paging_init() --------------------


setup_arch()에서 오직 한번만 호출되어서 커널의 page table들을 마무리 짓습니다.


pagetable_init()을 부릅니다.


이제 우리는 아마도 단순히 첫 번째 kmap page table을 캐쉬하는[in the TLB?] kmap_init()을 호출함으로써 kmap() 시스템을 더 깊이 초기화시킬수 있을겁니다. 그러면, 우리는 zone의 사이즈를 계산하고 mem_map을 세우고 freelists를 초기화하기 위해 free_area_init()을 호출함으로써 zone 할당자를 초기화시킬수 있습니다. 모든 freelists는 텅 빈채로 초기화되고 모든 페이지들은 reserved로 mark됩니다. (VM시스템이 access못하게) 이 상황은 나중에 다시 고쳐질겁니다.


paging_init()이 완료되면, 우리는 이런 물리 메모리를 가지게 됩니다. [이건 2.4에서는 꼭 맞지는 않습니다.]


0x00000000: 0-page


0x00100000: kernel-text


0x????????: kernel_data


0x????????=_end: whole-mem pagetables


0x????????: fixmap pagetables


0x????????: zone data (mem_map, zone_structs, freelists &c)


0x????????=start_mem: free pages


이러한 메모리의 구역들은 swapper_pg_dir과 whole-mem-pagetables에 의해서 PAGE_OFFSET주소에 매핑됩니다.


 


 


 


 


 


pagetable_init() -----------------


전체 물리 메모리를 mapping하기위해, 혹은 최대한 그것들을 PAGE_OFFSET에서 4GB사이에 넣으려고 시도합니다. 여기서 우리는 swapper_pg_dir에 있는 커널 page table을 전체 물리 메모리 범위가 PAGE_OFFSET으로 들어오게끔 mapping해버립니다. ( 이것은 단순히 산수좀 하고 page directory와 page tables로 정확한 값들을 채워넣는 일일뿐입니다. 이 mapping은 커널 페이지 디렉토리인 swapper_pg_dir안에서 만들어집니다. 이것은 또한 paging을 초기화하기 위해서 사용되는 page directory이기도 하죠. 만약 mapping되지 않은 물리 메모리가 남았다면, 그건 물리 메모리가 4GB-PAGE_OFFSET보다 크다는겁니다. 바로 CONFIG_HIGHMEM 옵션이 설정되지 않으면 사용되지 못하는 메모리들인것입니다.) 이 함수의 끝쯤에서 fixrange_init()을 부릅니다.


 


fixrange_init() -----------------------------


이 함수는 컴파일 시간에 고정된 가상주소의 매핑을 위한 페이지 테이블들을 예약하기 위해서 pagetable_init()에서 호출됩니다. 이 함수는 내용을 채우지는(populate) 않습니다. 이 table들은 커널에서 하드 코드되었지만 loading된 커널 자료는 아닌 가상주소들을 매핑시킵니다. 이 fixmap table들은 set_fixmap()에 의해 runtime에 할당된 물리 페이지들로 매핑됩니다.


 


set_fixmap() --------------------


fixmaps를 초기화한 후에, 만약 CONFIG_HIGHMEM이 설정되어있으면, kmap() 할당자를 위한 페이지테이블들도 할당합니다. (결국 4GB밑은 64MB가 kmap에 의해 쓰이고, 나머지 64MB는 vmalloc과 fixmaps에 의해서 쓰인다는 얘긴데...) kmap()은 커널이 임시적인 사용을 위해 물리주소의 어떤 페이지든 커널의 가상주소공간에 mapping하게 해줍니다. 예를들면, pagetable_init()에서 직접적으로 매핑이 될수 없는 물리 페이지들을 필요시에 매핑을 하는데 사용됩니다.


 


 


kmem_cache_init() -----------------


이건 slab allocator의 data를 초기화합니다.


여기선 얼마후 mem_init()을 부릅니다. 이 함수는 free physical pages를 위해서 free_area_init()에서 시작되었던 freelist초기화를 zone data에 있는 PG_RESERVED 비트를 clear함으로써 마무리 짓습니다. DMA로 쓰일수 없는 페이지들에겐 PG_DMA비트도 클리어합니다. 그리고 모든 사용가능한 페이지를 그들 각각의 zone에다 free합니다. *3*


마지막에 free_all_bootmem_core()를 호출합니다.


 


 


free_all_bootmem_core() -----------------


bootmem.c의 free_all_bootmem_core()에서 수행되는 이 마지막 단계가 재밌습니다. *4* 단지 이 함수는 그들을 free_pages_ok() 함수를 이용해서 free 함으로써, 존재하는 모든 예약되지 않은 페이지들을 서술하는 buddy bitmap과 freelists을 세웁니다. 한번 mem_init()이 불리면, bootmem할당자는 더 이상 못쓰게 됩니다. 왜냐하면 그것의 모든 페이지들이 zone 할당자의 세계로 free되어버렸기때문입니다.


 


 


 


 


__free_pages_ok() ----------------


60번줄의 comment에서도 알수 있듯이 버디 알고리즘으로 free를 하는 main함수입니다. 인자로 주어지는 page가 order만큼의 block이라고 생각하고 free합니다. 몇가지 검사를 한후, 93번줄에서 reference bit와 dirty bit를 reset합니다. 95번줄에서 이 task가 local_freelist를 사용한다면, local_freelist로 점프를 합니다. 그렇지 않다면 진짜 free를 하기 위해서 mask와 base를 준비하고, page_idx를 준비합니다. 그리고 order에 맞춰서 올바르게 align이 되어있는지 체크합니다. (104번줄) 그후 bitmap을 적절히 조정해가며 free작업을 수행합니다. buddy1은 내 버디이고, buddy2는 나 자신입니다. 이중에서 134번줄을 보면, buddy1을 그가 속한 list에서 빼는 것을 볼수 있습니다. 140번줄에서는 위의 while루프에서 작업이 끝난후, 최종적인 block을 해당 free_list에 넣습니다.


 


 


expand() ---------------------


zone은 할당이 일어난 zone이고, page는 할당된 페이지입니다. index는 할당된 페이지의 mem_map으로의 인덱스이고, low 는 요구된 할당의 차수입니다. high는 freelists에서 실제로 제거된 블록의 차수이고, area는 실제 할당된 블록의 차수를 위한 free_area_struct입니다. 이 함수는 더 높은 차수의 freelist에서 블록이 제거되었을 때, 불필요하게 많이 할당된 부분들을 다시 제거하는 역할입니다. 즉, high > low일때는 계속 나머지 부분을 잘라냅니다. 167부터 169번줄에서 한 차수를 내리고, 그 절반을 170번줄에서 해당 freelist에 집어넣고, 171줄에서 bitmap을 조정한후, 172,173에서 인덱스를 나머지 절반으로 옮겨갑니다. 이렇게해서 절반씩을 떨궈냅니다.


 


 


rmqueue() --------------------


이 함수야말로 할당을 하는 main함수입니다. 주어진 zone에서 order차수만큼의 블록을 떼어내서 그 맨앞 page포인터를 줍니다. 183에서 area가 주어진 차수의 area를 가리키고, 190의 루프로 들어가면, 해당 freelist의 head와 그것의 next를 취합니다. 이 둘이 같지않다면, 즉, 빈 freelist가 아니라면, 197에서 block의 첫 번째 page로의 포인터를 취한후, 200에서 해당 block을 제거합니다. 201에서 그 page의 index를 구한후, 203에서는 bitmap을 조정합니다. 204에서 zone->free_pages를 줄이고, 206에서 expand를 불러 만일 우리가 더 높은 차수의 블록을 할당했다면 나머지를 회수합니다. 209에서는 count를 1로 만들어서 할당되었음을 표시합니다. 216에서 이 page를 return합니다. 만일 이 freelist가 비어있다면, 더 높은 차수를 검색해보기 위해서 218로 가게됩니다. 그래도 없다면 223에서 할당은 실패합니다.


 


 


 


balance_classzone() ---------------------


이 함수는 사용할수 있는 메모리가 거의 없으면서 kswapd이 메모리를 만들어주기까지 기다릴수 없을상황에서 불리웁니다.


 


 


 


__alloc_pages() ----------------


이 함수는 rmqueue()보다 한단계위에 있는 함수로, 버디 알고리즘의 구현입니다. 인자로 주어진 zonelist의 순서대로 zone을 찾아다니며 order차수만큼의 블록을 gfp_mask의 mask로 할당합니다. 318에서 첫 번째 zone부터 시작해서, 320에서 최소한 order차수만큼의 페이지수는 있어야 함을 뜻하고, 다음 for루프는 zonelist대로 찾아다니며 적합한 zone이 있는지를 찾습니다. 326에서 각 zone의 pages_low를 min에 더하여 할당후에도 pages_low보다 작지 않도록 하며, 327에서 그런 조건인 zone이 있다면, rmqueue를 불러 할당합니다. 334까지 왔다면 아까 설정한 이 zonelist의 첫 번째 zone인 classzone을 이용해서 need_balance를 1로 하여 kswapd에게 필요성을 알립니다. mb()는 일단 의미없는거 같고, 336에서 kswapd을 깨웁니다(?). 340에서 다시 한번 min을 설정하고, for루프에서 시도합니다. 이번엔 상황이 급하므로, 347부터 349까지에서 기다릴수 없는 할당이라면, 이 zone의 pages_min을 1/4 로 줄여서 min에 더해보면서까지(즉, pages_min밑으로까지 내려갈수 있게 됩니다) 할당을 시도해봅니다. 이렇게해도 할당이 안된다면, 360에서 이 task가 PF_MEMALLOC이나 PF_MEMDIE가 설정되어있다면, pages_low를 무시하고 할당을 시도해봅니다. 376에서 이 할당이 기다릴수 없는 할당이라면, 실패해버립니다. 기다릴수 있다면, 379에서 balance_classzone을 불러 할당을 시도합니다. 383부터 이번엔 pages_min을 보존하면서 할당을 할수 있는지 시도해봅니다. 399에서 너무 큰 block이라면 포기하고, 그렇지 않다면, 403에서 kswapd이 일할 것을 기대하고 스케쥴을 양보한후, 다시 한번 goto rebalance로 시도해봅니다.


 


 


 


__get_free_pages() ------------


alloc_pages()에 대한 wrapper입니다. 다만, page_address를 통해 리턴값이 달리진다는 것(?)


#define page_address(page) ((page)->virtual)


 


 


__get_zeroed_page() ------------


위의 __get_free_pages처럼 page->virtual을 리턴하지만, clear_page를 써서 0으로 채운다(?)


#define clear_page(page) mmx_clear_page((void *)(page))


 


 


 


__free_pages() -----------------


page가 Reserved되어있지 않아야 하고, put_page_testzero()에서 페이지의 reference count를 감소시키고 만약 감소후 reference count가 0이라면 1을 리턴합니다. 그러므로, 만일 호출자가 페이지의 마지막 사용자가 아니라면, 그것은 실제로 해제되지 않을것입니다. 두조건을 통과했다면, __free_pages_ok()를 호출합니다.


 


 


 


void free_pages(unsigned long addr, unsigned int order) --------------


addr가 0이 아니어야 하고, 이 addr은 커널의 가상주소입니다. 이것은 virt_to_page에 의해서 page형 포인터로 변환되어서 __free_pages 로 호출됩니다.


#define virt_to_page(kaddr) (mem_map + (__pa(kaddr) >> PAGE_SHIFT))


#define __pa(x) ((unsigned long)(x)-PAGE_OFFSET)


 


 


 


unsigned int nr_free_pages (void) -------------------


할당 가능한 메모리의 양을 페이지 단위로 리턴.


각 node를 돌면서, 각 node의 각 zone에서의 zone->free_pages를 더한 결과를 리턴.


 


 


unsigned int nr_free_buffer_pages (void) --------------


버퍼 메모리로서 할당 가능한 메모리의 양을 페이지 단위로 리턴(?)


 


 


void show_free_areas_core(pg_data_t *pgdat) --------------


 


 


void show_free_areas(void) --------------


 


 


 


static inline void build_zonelists(pg_data_t *pgdat) --------------


free_area_init_core의 마지막에서 호출되는 이 함수는, 주어진 node의


 


 


 


void __init free_area_init_core() -------------------


memory map이 세워진후, freelist와 비트맵을 세웁니다. lmem_map은 보통 0입니다.


 


644에서 zone_start_paddr이 align되어있는 것을 확인한후, (우리의 컴에서는 0이므로 당연히 align) 647부터 메모리의 용량을 계산합니다. 인자로 받은 unsigned long* zones_size 는 MAX_NR_ZONES의 크기를 가지는 배열인데, 이는 이 node의 각 zone들이 어느정도의 크기일지를 byte단위로 알려줍니다.(이거 page단위인거 같은데??) 이것들을 모두 더합니다. 이것이 totalpages이고, realtotalpages는 여기서 각 zone에서의 hole size를 뺍니다. 역시 인자로 받은 zholes_size를 이용합니다. 그후, active_list와 inactive_list를 초기화합니다. (왜 이걸 여기서??) 669부터 memory map을 위한 메모리를 할당합니다. alloc_bootmem_node를 이용하여 할당합니다. 675부터 679까지 pgdat의 각 멤버의 값을 초기화해줍니다. 즉, 이 노드의 값을 채워줍니다. 이제 각 페이지를 초기화합니다. 686에서 page->count를 0으로 놓고, reserved로 놓고, page->list를 초기화합니다.


 


693부터 zone을 초기화합니다.


size는 zones_size에서부터 오고, realsize는 여기서 zholes_size를 뺀것입니다. zone의 size를 정한후, 이름을 연결하고(zone_names), LOCK은 풀어둔후, 해당 node로 연결해놓고, free_pages는 0으로, need_balance도 0으로 놓습니다. 노드의 nr_zones를 설정하고, pages_min과 pages_low, pages_high를 설정. zone_mem_map을 mem_map에 offset을 더한 것으로 설정하고, zone_start_mapnr와 zone_start_paddr을 설정.


 


731에서 그 안의 모든 page들의 zone을 지금 이 zone으로 설정하고(page->zone 설정) HIGHMEM이 아니라면, page->virtual을 자신의 가상 주소로 설정한다.


page->virtual = __va(zone_start_paddr);


#define __va(x) ((void *)((unsigned long)(x)+PAGE_OFFSET))


이므로, 즉, 물리 메모리를 가상 주소로 바꿔주는 것이다. zone_start_paddr이 PAGE_SIZE단위로 매번 뛰고 있음을 주목하자.


 


739에서 다음 zone을 위해 offset을 size만큼 더해주고, 740에서부터 freelist를 초기화하고, bitmap을 마련한다. bitmap의 크기를 계산하여 alloc_bootmem_node로 할당을 받는다. 마지막으로 build_zonelists를 호출한다.


 


 


 


 


void __init free_area_init(unsigned long *zones_size) ----------


이 함수는 paging_init()의 마지막부분에서 호출됩니다.


NUMA가 아닌 경우 전역변수인 contig_page_data로 나타나는 하나의 node만이 있으므로, 그에 해당하는 mem_map과 함께 core를 호출합니다.


free_area_init_core(0, &contig_page_data, &mem_map, zones_size, 0, 0, 0);


 


 


 


 


 


static int __init setup_mem_frac(char *str) -----------


"memfrac=" 커널 옵션을 처리하기 위한 함수.


 


 


 


 


 


 


Free page란?


 


어느 page가 free인가를 판단하는 것을 Joe Knapka는 다음과 같이 얘기합니다.


1) 페이지가 존재한다.
2) 페이지가 PAGE_OFFSET+1MB와 start_mem사이의 커널 정적 메모리의 일부분이 아니다.
3) mem_map의 페이지에 해당하는 reference count 가 0이다.


1번은 당연해 보이겠지만, 많은 플랫폼에서 주소공간에 hole을 가진다는 사실을 상기한다면, 즉, PC에서 640KB와 1MB사이의 hole을 생각해본다면, 필요한 조건일 것입니다. 또한 2번은 커널이 점유하는 메모리가 아니어야 한다는 것이죠. 3번은 해당 page를 참조하는 테이블이 없다는, 즉, free page라는 조건입니다. 이런 조건을 만족할 때 그 page는 free page라 할 수 있습니다. 또한 빈 물리 페이지들은 정확하게 하나의 매핑을 가집니다: 커널 페이지 테이블에서, PAGE_OFFSET+physical_page_address에서 말입니다.


출처 : Tong - pearlchoi님의 업무통

'KB > Win32/x86' 카테고리의 다른 글

Win DDK extreme MVP  (0) 2008.11.05
intel mmx/sse simd  (0) 2008.04.25
system call  (0) 2007.06.20
sysinernals mark 블로그~  (0) 2007.04.30
컴파일러 설계강의 정리  (0) 2007.04.27

+ Recent posts