Operating System Inside - General

Preface

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/osinside 에서 찾아보실수 있습니다.

구글그룹을 만들었습니다. 가입하지 않더라도 볼수있는 public한 그룹입니다. 다만, 포스팅은 멤버만이 할수 있습니다. 물론 가입/탈퇴는 자유롭습니다. http://groups.google.com/group/osinside 시스템 전반에 걸친 이야기를 할수 있는 포럼으로 사용합시다. 연습장겸 다목적용 osinside위키도 있답니다.

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

                 \|/ 
                (@ @)
     +----oOO----(_)-----------+
     |                         |
     |        Last updated     |
     |        2011 September   |
     |                         |
     +-----------------oOO-----+
               |__|__|  
                || ||
               ooO Ooo

 

 

저자 이 민

email : abraxsus (at) yonsei.ac.kr

Copyright(C) 2003 Min Lee



Contents

 

 

    Part I

  1. Operating System
  2. Computer Model
    Turing machine
    Procedure and thread
    Variable arguments
  3. Virtual Memory
    Page Table structure
    Reverse Mapping
  4. When memory was Not virtual
    Real mode vs protected mode? Segmented?
    Overlay
    Segmentation
  5. Dynamic Allocation
    Garbage collection
  6. Kernel vs User
    Kernel mode vs User mode
    Kernel space vs User space
    System call and API
  7. TLB & Cache
    TLB flush
    Cache flush
  8. Interrupt
    Interrupt controller
    Interrupt vector
    CPU Protection
  9. Control Flow
    Processes and threads
    Context switch
    Nested kernel control path
    Preemptible kernel (Reentrancy)
    Bottom half
  10. Virtual Address Space
    Process layout
    Virtual address space management
    Dynamic library
  11. CPU Scheduler
    Process state
    Overview
    Priority
    Priority inversion
    Linux scheduler
    SMP
    Fork
    Real time
  12. Physical Memory Management
    Kernel Memory Allocator
    Slab Allocator
    Disk Cache - Page cache, buffer cache and unified cache
    Dentry Cache and Inode cache
    Swapping
    Vmalloc
    Page Replacement Policy
    Global Page Reclamation
    Working Set and Thrashing
  13. Address Binding
    Late binding
    Dynamic linker
  14. Synchronization #1
    Abstract
    Atomicity
    Bounded Buffer producer-consumer problem
    Short critical section and spinlock
    Long critical section and mutex
    Spinlock vs mutex
    Bakery algorithm
    Memory model
    Semaphore implementation
    Coarse-grained locking vs fine-grained locking
    Conclusion
  15. Synchronization #2
    Bounded-buffer problem and reader-writer problem
    The dining philosophers problem
    Critical regions
    Monitors
  16. Synchronization #3
    Spinlock revisited
    Futex
    Read-Copy-Update
  17. Lock-free Code
  18. Transactional memory
    Software TM
    Hardware TM
  19. Transaction
  20. Deadlock
  21. Interprocess Communication
    Pipes and FIFOs
    Signals
    Sockets
    System V IPCSystem V IPC
    Shared memory
    Semaphores
    Message queues
  22. Remote Procedure Call
  23. Paging
    Page fault
    Demand paging
    COW(Copy on Write)
    Mapped files
    Page Fault Handler
  24. I/O
    Memory mapped vs programmed
    Asynchronous I/O
    I/O Scheduler
    Direct Memory Access (DMA)
  25. Symmetric Multiprocessor (SMP)
  26. File System
    Distributed File System
    Log-structured File System
  27. Shared Memory Machine
  28. Clustered Systems
  29. Distributed Systems
  30. Real Time
  31. User Space
  32. GUI
  33. Part II

  34. OS Revisited
  35. Threads, layers, and boundary
  36. Scalability
  37. Linux
  38. L4
  39. Plan9
  40. Part III

  41. Virtual machine
  42. Para-virtualization vs full-virtualization
  43. Software vs hardware
  44. Other models
  45. Two physical memory
    M2P,P2M table
    Shadow page table
  46. System and I/O
  47. Virtual Machine Scheduling
  48. VM migration
  49. Part IV

  50. Computer Architecture
  51. Microarchitecture
  52. Microprogramming
  53. Memory model
     
  54. Biblography and reading list

  55. Appendix A - Linux
  56. Appendix B - Linux Network

 

Topics

  1. branching과 performance

 



Part I

Operating System

 

OS란 결국, 하드웨어를 총괄하면서 하드웨어간의 이질성을 끌어 안아 소프트웨어가 좀더 추상적이 될 수 있는 환경을 제공하는 근본 소프트웨어라고 할 수 있습니다. 또는 평상시에는 잠들어 있다가 Application이 필요로 하는 서비스를 제공해주는 데몬(daemon)이라고 볼수도 있습니다. (이것은 정확한 이해는 아닙니다.데몬이라고 할수는 없죠. 차라리 library에 가깝습니다. 어쨌든 Application이 필요로하는 서비스를 제공하는 코드라는점은 중요합니다.) 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들은 모두 이러한 계층화를 완전하게 이루고 있습니다.

모든 SW가 그렇듯이 OS역시 각 환경에서의 필요성에 맞춰져서 디자인됩니다. PC와같은 환경에서는 사용자의 편의를 위해서 performance가 중요시되나 resource utilization은 곧잘 무시되죠. 반면 server환경에서는 throughput혹은 resource utilization이 중시됩니다. 최근에는 mobile환경등에서는 energy efficiency가 매우 중시됩니다. 이와같이 목적에 따라 OS디자인과 철학은 달라집니다. 또한 CA와 OS는 뗄수없는 밀접한 관계에 있습니다. SW와 HW가 서로 영향을 주고받으며 발전해온 대표적인 경우죠. 과거 mainframe시절에는 단순한 batch system이었기에, punch card를 쌓아놓고 OS는 단지 control을 transfer해주는 정도에서 그쳤습니다. 매우 간단했지요. 이제 디스크가 나와서 모든 job을 디스크에 넣고 direct access 가 가능해지자 드디어 job scheduling이 가능해지고, 이것이 곧 multi-programming을 촉발해서 resource utilization을 높이게 되었습니다. 즉 비싼 기계를 효율적으로 쓰고자하는 utilization 관점에서의 발전이 되어온거죠. 이런 multi-programming이 time-sharing으로 발전하면서 OS가 현재의 모습을 갖춰가게 됩니다. 즉 time-sharing이 multiprogramming의 논리적인 확장인 셈입니다.

 


Computer Model

Turing machine

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

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)이라고 부릅니다.

Procedure and thread

다시 한번 instruction을 살펴봅시다. 프로그램의 분자(molecule)라고도 할수 있는 instruction은 간단히 말해 하나의 동작입니다. 당연한 말이겠지만 어떠한 동작에는 동사역할을 하는 operator와 목적어 역할을 하는 operand가 있습니다. 아무리 복잡한 동작이라도 결국 하나의 operator와 하나의 operand로 이루어진 하나의 instruction으로 쪼개질수 있으니, 이 instruction은 프로그램을 이루는 가장 작은 단위라고도 할수 있습니다. 즉 Instruction = operator + operand 로 정의해봅시다. operator는 더하기나 빼기같은 동작이고, operand는 메모리의 어떤 위치입니다. 즉 변수죠. 변수란 다름 아니라 메모리 위치 (memory location) 일 뿐입니다.

이들의 종류..TODO.. opcode , operand encoding.. TODO

자, 이제 프로그램을 해봅시다. 그냥 적당한 instruction들을 쭉 나열하면 됩니다. 간단하죠? 그런데 일일이 이렇게 하려니 힘듭니다. 조금만 커져도 머리가 아프군요. 그래서 좀더 쉬운 방법을 찾아봅시다. 간단히 procedure call로부터 시작해봅시다. 대부분의 현대 CPU들은 함수(혹은 procedure)라는 기본적인 basic block의 개념을 바탕으로 디자인되어 있습니다. 이 함수 또는 procedure는 앞서 말한 instruction들의 집합일 뿐입니다. (1) tmp = b; (2) b = a; (3) a = tmp; 라고하는 3개의 instruction을 합하면 훌륭한 하나의 procedure가 됩니다. a와 b라는 두 변수(memory location)를 바꿨네요. 얘네들을 swap이라고 부릅시다. 그럼 앞으로 이 swap이라는 동작이 필요할때마다 3개의 instruction을 매번 추가해넣어주면 됩니다. 그러면 너무 프로그램이 커지고 흐름을 쫓아가기 힘들어지겠네요. 그럼 아예 swap이라는 instruction을 만들어 넣어버리면 됩니다. 그외의 다른 기능들도 필요할때마다 만들어서 instruction으로 만들어버립니다. 물론 그러면 instruction이 복잡해지고 양도 많아지죠. 하지만 읽기도 편해지고 프로그램의 크기도 줄어드는 장점도 있습니다. 이런 철학을 CISC라고 합니다.

RISC..TODO

TODO 그림

하지만 이것도 한계가 있으니 우리는 어쩔수없이 instruction들의 집합을 통째로 다룰수 있는 방식이 필요합니다. 이 procedure는 항상 같은 패턴이므로 프로그램안에서 매번 반복하는것은 큰 낭비죠. 그래서 call/return이라는 개념을 도입합니다. procedure는 하나뿐이지만 그것을 이용하고자 할때마다 CPU는 그 주소로 가서 (call) 실행한후에 다시 돌아옵시다(return). 언제 호출해야할지는 알겠지만 언제 돌아와야할지는 모르기때문에 자연히 원래 자리로 돌아오는 return명령은 앞의 swap의 뒤에 붙입시다. 이제 swap은 4개의 instruction입니다. 이제 다른 procedure에서 필요할때마다 call명령을 수행하면 됩니다. 하지만 원래자리로 돌아가려면 자신이 어디서 왔었는지를 기억하고 있어야 합니다. 여러 다른 procedure가 swap을 호출할수 있으니 caller에 대한 정보는 swap이 가질수 없죠. 그래서 caller의 정보를 어딘가에 적어놓고 나중에 return할때 이 정보를 이용해서 원래 자리로 돌아갑시다. 이 용도에 완벽하게 맞는 자료구조가 있으니 바로 stack입니다. 함수 A가 swap을 부를때는 stack에 돌아올 위치 (return address)를 적어놓습니다. 그래도 아직 빠진게 있습니다. swap을 불러도 swap은 a,b만을 바꿀뿐이기 때문에 별로 의미가 없죠. 즉 swap에게 어느 두변수를 바꾸라는 정보까지 줘야합니다. parameter가 등장합니다. 자연히 함수 하나는 함수이름과 parameter로 특징지어집니다. A가 swap을 부를때와 B가 swap을 부를때 swap의 입장에서는 자신에게 주어진 데이터 (함수 입장에서의 operand)만이 다를 뿐입니다. 만약 이 데이터가 없다면, 고정된 전역변수에 의존할수밖에 없게됩니다. 즉 상수함수가 되어버리는 꼴입니다. 따라서 A가 swap을 부를때와 B가 swap을 부를때 두개의 swap을 다르게 만들어주는 정보들의 존재를 알수 있습니다. 이들을 local variable 즉 지역변수라고 부릅니다. 반대로 어떤 고정된 장소에 있는 변수는 그 고정된 장소라는 특징때문에 모든 함수가 접근할수 있으므로 전역변수라고 합니다. 즉 상수인 셈이죠. 이러한 지역변수들은 parameter의 확장이라고도 할수 있습니다. 잘 생각해보면 return address역시 이 local variable임을 알수 있습니다. 이런 local variable이 static한 swap이라는 코드와 달리 dynamic한 swap call을 만들어냅니다. 따라서 매 call은 이런식의 한벌의 local variable들을 생성한다는 것을 알수 있습니다. 이제 당연한 말이 되겠지만, by definition, call은 local variable의 생성이며 return은 그 소멸입니다. 스택에서 이렇게 생성된 지역변수들의 모임을 activation record라고 부릅니다. 이왕 이렇게된거 parameter를 좀더 확장하면 함수는 전역변수와는 별도로 매 call마다 자신만의 지역변수를 가질수도 있게 됩니다. 물론 이 바탕에는 스택이라는 놀라운 자료구조가 모든것을 가능하게 해줍니다. 단순한 스택이라는 논리의 힘으로 local이라는 개념이 탄생한것입니다. 이것은 앞으로도 보게될 program와 process의 차이이기도 합니다.

이제 마지막으로 계산된 결과를 caller에게 돌려주는 return value만 주면 됩니다. 파라미터가 caller가 callee의 지역변수에 값을 써넣는것이라면 이 경우는 callee가 caller의 지연변수에 값을 써넣는다고 할수 있습니다. 이상의 내용을 정리해보면 함수 하나는 static하게는 그 코드를 나타내는 이름과, 파라미터의 갯수와 타입으로 정의됩니다. 따라서 function(int a1, int a2)와 같은 형식으로 표현할수 있는데, 이를 함수의 signature라고 합니다. (물론 C++의 경우엔 리턴형까지도 signature에 포함합니다만) 반면 dynamic하게는 하나의 함수는 위에서 본바와 같이 activation record로 정의됩니다. 스택에 쌓인 local variable들이지요.

이와같이 activation record가 만들어지는 caller와 callee간의 규약을 calling convention이라고 합니다. TODO..cdecl 정리

cdecl을 기준으로 살펴보면, 먼저 caller가 파라미터들을 push해넣습니다. 즉 activation record의 시작이죠. 그 다음 call instruction을 쓰면 자신의 바로 다음 instruction의 주소가 스택에 쌓입니다. callee함수의 앞부분에 다음과 같은 두개의 instruction이 들어갑니다.

    push ebp
    mov ebp, esp

enter라는 instruction은 이 두 instruction과 동일합니다. 그 직후에 적당한 양만큼을 (x) 자신이 쓸 local variable을 더 마련합니다.

    sub esp, x

이제 실제 함수의 코드가 들어가고, return하기전에 local variable을 해제하고 (하지만 실제로는 이 과정이 필요없습니다.)

    add esp, x

다음과 같은 코드가 들어가 activation record를 정리합니다.

    mov esp, ebp
    pop ebp

leave라는 instruction은 이 두 instruction과 동일합니다. 이제 return instruction을 호출하면 caller가 쌓아놓았던 return address로 점프합니다. 이제 caller에게 돌아왔으니 caller는 parameter들을 pop해버리면 이제 이 시점에서 함수 호출이 완료되어 그 이전의 모습으로 돌아왔음을 알수 있습니다. 이제 activation record는 완전히 사라졌군요. 실제로 다음과 같은 C함수가 어떻게 수행되는지 봅시다.

int sum(int i)
{
    int k = 1;
    return i + k;
}

int main(void)
{
        sum(100);
}

이를 실제 disassemble하면,

08048344 :
int sum(int i)
{
 8048344:       55                      push   %ebp
 8048345:       89 e5                   mov    %esp,%ebp
 8048347:       83 ec 10                sub    $0x10,%esp
    int k = 1;
 804834a:       c7 45 fc 01 00 00 00    movl   $0x1,-0x4(%ebp)
    return i + k;
 8048351:       8b 45 fc                mov    -0x4(%ebp),%eax
 8048354:       03 45 08                add    0x8(%ebp),%eax
}
 8048357:       c9                      leave
 8048358:       c3                      ret

08048359 
: int main(void) { 8048359: 8d 4c 24 04 lea 0x4(%esp),%ecx 804835d: 83 e4 f0 and $0xfffffff0,%esp 8048360: ff 71 fc pushl -0x4(%ecx) 8048363: 55 push %ebp 8048364: 89 e5 mov %esp,%ebp 8048366: 51 push %ecx 8048367: 83 ec 04 sub $0x4,%esp sum(100); 804836a: c7 04 24 64 00 00 00 movl $0x64,(%esp) 8048371: e8 ce ff ff ff call 8048344 } 8048376: 83 c4 04 add $0x4,%esp 8048379: 59 pop %ecx 804837a: 5d pop %ebp 804837b: 8d 61 fc lea -0x4(%ecx),%esp 804837e: c3 ret 804837f: 90 nop

main함수가 sub $0x4,%esp 를 통해 파라미터를 준비하고 100(0x64)이란 값을 넣은후에 호출하는것을 볼수 있습니다. call다음에서는 반대로 이를 해제합니다. callee에서는 prolog/epilogue와 함께 local variable의 할당과 해제도 역시 볼수 있습니다. epilogue에는 leave를 쓰는군요. 실제로는 k라는 변수 하나지만 alignment를 위해 16바이트의 공간을 잡는것을 볼수 있습니다. 해제하는 부분은 딱히 없군요. leave가 esp를 복구하면서 저절로 되기때문이죠. k 라는 변수가 -0x4(%ebp)로 접근되는것을 볼수 있습니다. ebp는 activation record의 베이스를 지정하는 역할을 합니다. 이를 위해 epilogue에서 esp값을 받아놓은것을 알수 있죠. 더한값을 eax에 넣어서 리턴하고 있습니다. 실제로는 이론과 약간 다르게 여러 optimization이 도입되는 것을 볼수 있죠.

스택의 모습은 이렇게 보일것입니다.

이러한 calling convention을 통해서 함수들을 호출하여 사용합니다. 이러한 instruction들의 순차적인 흐름을 thread라고 합니다. thread는 순차적인 제어의 흐름(control flow)이기 때문에 내부적인 의존성(dependency)를 특징으로 합니다. TODO..의존성 설명. thread가 직접 함수를 호출하는 이런 경우를 synchronous call이라고도 합니다. 하나의 thread만 실행할때에는 이것으로 끝이지만 I/O와 OS protection, multithreading등의 이유로 현대의 대부분의 CPU는 인터럽트라는 방식의 asynchronous call을 구현합니다. 인터럽트(혹은 예외)는 단지 특별한 형태의 call일뿐입니다. 다만 쓰레드가 모르는 상태에서 아무때나 발생할수 있기때문에 asynchronous call이라고도 부릅니다. 역시 스택이라는 논리덕분에 현재 실행중인 thread는 이러한 call이 있었는지 알지 못하는 상태로 실행을 계속합니다. 예를들어 타이머 인터럽트가 실행되는 예를 살펴봅시다.

이처럼 A라는 thread가 수행중에 외부 장치인 timer가 인터럽트를 걸면 CPU는 자동적으로 어떤 함수(핸들러)를 바로 호출하게 됩니다. 시스템콜이나 예외, 인터럽트등은 모두 이런식으로 불리우는 asynchronous call입니다. 이 경우엔 물론 약간 다른 calling convention이 적용됩니다. CPU의 상태를 스택에 쌓아놓아야 하기 때문이죠. CPU는 자동적으로 현재 레지스터값등을 스택에 쌓아올립니다. 따라서 return했을때엔 이전 thread의 상태로 완전히 복귀할수 있게 됩니다. (하나더. 여기서의 스택은 커널모드 스택입니다.)

이제 thread의 시작과 끝을 생각해 봅시다. 사실 OS혹은 시스템 전체에서 볼때 하드웨어에 의해 수행되는 첫번째 instruction이 시작일 것이고, reset/shutdown등을 위한 instruction들이 마지막 명령일것입니다. CPU입장에서는 전원이 들어온 순간부터 전원이 꺼지는 순간까지 그저 instruction의 나열일뿐 그안에 시작과 끝은 없는 셈이죠. 즉 한 process의 시작과 끝은 OS가 만들어내는 개념입니다. process의 시작은 보통 elf이미지에 적혀있는 주소로 점프하는것으로 시작되죠. 끝은 exit 시스템콜로 끝나는것이 보통입니다. 즉 exit 시스템콜은 return하지 않습니다. 프로세스가 소멸합니다. 재미있는 광경인데요, 이러한 특수한 종류의 call들은 (뭐 알고보면 그다지 특수하지도 않습니다만) return하지 않습니다. 커널내에서도 가끔씩은 이런식으로 함수콜이 리턴하지 않는 경우가 있으며, 어플리케이션에서도 종종 볼수 있습니다. (longjmp 등의 명령을 보세요.) 그러한 함수들은 stack의 activation record를 직접 삭제하거나 변경하는 방식으로 쉽게 구현할수 있습니다. 더 나아가 어떤 경우에는 call/return방식을 사용하지 않고/무시하고 그냥 jump한다던지, stack을 직접 건드리는 편법등이 쓰일수도 있겠습니다. 물론 일반적으로는 쓰이지 말아야할 방식입니다만, 커널이나 hypervisor구현에서는 쓰일수 있습니다. (따라서 이런 구현상의 특징을 모르다가는 debugging으로 날밤을 새는 수가 있습니다-_-;)

TODO context에 대해서..

Variable arguments

calling convention과 관련된 특수한 경우로 variable arguments가 있는데 대표적으로 printf와 같은 경우입니다. 인자의 수가 가변적이기 때문에 callee는 직접 가변인자의 갯수등을 다뤄야합니다. 이를 위해서 C에서는 stdarg.h 에서 va_start, va_end, va_arg 라는 매크로를 정의합니다. 흔히 x86+C 에서 쓰이는 cdecl은 인자들을 스택에 쌓습니다. 이런 경우엔 구현도 간단한데 보통 다음처럼 구현합니다.

#define __va_rounded_size(TYPE)  \
  (((sizeof (TYPE) + sizeof (int) - 1) / sizeof (int)) * sizeof (int))

#define va_start(AP, LASTARG)                                           \
 (AP = ((char *) &(LASTARG) + __va_rounded_size (LASTARG)))

#define va_end(AP)

#define va_arg(AP, TYPE)                                                \
 (AP += __va_rounded_size (TYPE),                                       \
  *((TYPE *) (AP - __va_rounded_size (TYPE))))

보다시피 가변인자는 calling convention과 밀접한 관계가 있고 따라서 그 구현은 calling convention에 따라 달라진다는 것을 알수 있습니다. 물론 이 구현은 cdecl용이므로 다른 calling convention에서는 쓸수 없습니다. 예를 들어 x86-64 Linux 에서는 rdi,rsi,rdx, rcx, r8, r9에 순서대로 integer나 포인터가 하나씩 레지스터를 씁니다. 실수(floating)들은 xmm레지스터에 넣는군요. 그 이후에 스택을 사용합니다. 윈도에서는 또 약간 다릅니다. http://en.wikipedia.org/wiki/X86_calling_conventions 그래서 이 경우엔 새롭게 구현해야 합니다. 다행히도 이런 수고를 덜어주기 위해 gcc와 같은 컴파일러는 내부적으로 builtin으로 구현해줍니다. calling convention을 구현하는것도 컴파일러이므로 사실 당연한 것이기도 합니다. 그래서 프로그래머로서는 구체적인 아키텍처나 시스템에 관계없이 다음과 같이 구현해서 쓸수 있습니다.

typedef __builtin_va_list va_list;
#define va_start(ap, X) __builtin_va_start(ap, X)
#define va_arg(ap, type) __builtin_va_arg(ap, type)
#define va_end(ap) __builtin_va_end(ap)

물론 이것은 user space에서의 procedure call에 대한것이고, 다른 형태의 특별한 콜들, 예를들어 시스템콜같은 경우 다른 형식의 calling convention을 쓴다고 할수 있습니다. 예를들어 리눅스의 경우 eax, ebx, ecx등의 레지스터를 통해 파라미터를 전달합니다. 이 부분은 시스템콜 섹션에서 설명하겠습니다.

참고 : http://coding.derkeiler.com/Archive/C_CPP/comp.lang.c/2007-03/msg04314.html


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이라는 virtual 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시스템콜관련. TODO ) 이와 같이, 커널은 전체적인 비어있는 페이지들을 관리하고, 할당할 필요가 있습니다. 이러한 것을 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을 고려해야 합니다. 이와 관련된 내용은 뒤에서 설명하겠습니다.

이것은 page table entry입니다. 각 페이지 디렉토리나 페이지 테이블은 이와같은 엔트리를 1024개씩 가집니다. 보다시피 무척 비슷합니다. 물론 가장 중요한 정보는 다음 단계로의 포인터역할을 하는 base address이고, 그외에 Read/Write bit은 해당 페이지가 read-only일지를 결정하고, User/Supervisor bit은 해당 페이지를 user space에서 접근가능한지 아니면 커널모드에서만 접근가능한지를 나타냅니다. 그외에는 Access bit이 있는데 해당 페이지가 access되면 1로 세팅됩니다. 또한 Dirty bit은 해당 페이지가 write되면 1로 세팅됩니다. 이러한 비트들은 CPU가 1로 세팅하며 절대 0으로 세팅하지는 않고, OS만이 0으로 세팅합니다. OS는 이러한 CPU가 주는 정보를 다양한 방식으로 이용하게 됩니다. 그외에는 Present bit이 해당 entry가 존재하는지를 나타내며, TODO...

위의 그림은 64비트의 경우입니다. 32bit의 경우를 확장한 것인데, 처음 4G의 물리메모리가 부족해지자 인텔은 PAE(Physical address extension)이라는 이름으로 physical address를 36비트로 확장하고, page table구조를 바꿉니다. 각 엔트리는 크기가 2배로 늘어 한 페이지안에는 512개의 엔트리만이 들어가게 되었습니다. AMD가 이 구조를 약간 확장하여 하나의 level을 더 추가하여 64비트에서도 계속 사용합니다. TODO....이중 NX bit이 추가됩니다. buffer overflow등의 보안문제를 막기위해서 데이터영역은 실행을 할수 없도록 실행가능여부를 지정하는 NX bit가 추가되었습니다.

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


Page Table Structure

VM의 페이지 매핑을 구현하는데에는 보통 위와같이 tree구조를 사용합니다. 하지만 그외에도 다양한 방식들도 있습니다. PDP-11에서는 레지스터에page table을 두기도했지만, 이건 작은 페이지 테이블이었기에 가능했었던거죠. 그외에 Hashed Page Table이 있습니다. Offset을 제외한 virtual address가 해쉬함수로 들어가서 linked list를 찾아들어가 스캐닝하면서 찾아갑니다. 이 리스트안의 엔트리는 virtual page number, physical page number, pointer to next element in the list. 로 이루어집니다. 순차적으로 virtual page number를 비교해가며 찾는거죠. 그외에 Inverted Page Table이 있습니다. 이경우엔 거꾸로 각 physical page마다 하나의 엔트리를 가집니다. 그리고 여기에 해당 페이지로의 virtual address들을 넣습니다. 따라서 시스템전체에 하나의 페이지 테이블만이 있게되죠. 검색을 위해서는 보통 ASID를 사용합니다. 그래서 offset이외의 부분과 ASID를 가지고 page table을 검색해서 그 index를 physical page number로 사용하게됩니다. 64비트 UltraSPARC과 PowerPC가 이런방식을 사용합니다. (TODO:그림 9.15) 메모리사용량은 줄겠지만 그러나 검색에 시간이 듭니다. 전체 테이블을 끝까지 검색할수도 있죠. 이를 위해서는 앞서서의 hashed page table방식을 도입해 합칠수도 있겠습니다.

어떤 아키텍쳐의 경우엔 TLB miss handler도 있습니다. TLB가 미스났을때 불리우는 핸들러로 OS가 직접 TLB를 채워주는것입니다. x86의 경우엔 HW가 직접 페이지테이블을 읽은후에 직접 TLB에 값을 채워넣죠. 이 방식이 빠르고 간편한반면 페이지 테이블의 구조가 HW에 의해서 정해진다는 점이 있습니다. HW-defined page table이라고 하죠. x86과 PowerPC가 그렇습니다. 반면 OS가 TLB미스를 처리하는경우는 SW-defined page table이라고 합니다. UltraSparc, MIPS, Alpha가 그렇습니다. 이 경우는 overhead는 있지만 더 flexible하죠.

Reverse Mapping

이 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

 


When memory was Not virtual

 

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을 위한 고려라고 할 수 있습니다. AMD가 x86-64에서 이 segmentation을 제거해버렸죠. 그만큼 인기가 없었던거죠.

 

 


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)인 것입니다.

 

Dynamic Allocation

malloc/free와 같이 주어진 양의 메모리에서 n만큼의 메모리 할당/해제 요청을 처리하는 문제를 생각해봅시다. 3가지 기본적인 방법이 있는데, First fit, Best fit, Worst fit입니다. 첫번째로 찾을수 있는 free블럭에서 할당하는것이 First fit, 할당가능한 크기의 블럭중에서 가장 작은것을 찾는것이 best fit, 가장 큰블럭에서 할당하는것이 Worst fit입니다. 시뮬레이션 결과로는 first fit과 best fit이 worst fit보다 시간과 공간활용에서 낫고, first fit과 best fit은 공간효율에선 비슷하지만 first fit이 일반적으로 빠릅니다. 하지만 이런것들은 external fragmentation이 있습니다. 진행될수록 남은 공간이 잘라지기때문이죠. 즉 총 공간은 충분하지만 contiguous하지 않아서 할당이 실패합니다. 이건 심각한 문제가 될수도 있습니다. first-fit과 best-fit중 어느것을 고를지의 다른 요소는 블럭의 어느공간을 줄것인가, 즉 앞부분을 할당하고 뒷부분을 남길것인가 뒷부분을 할당하고 앞부분을 남길것인가하는 문제가 있습니다. first fit에 대한 통계적인 분석결과, optimization에도불구하고, N개의 할당된 블럭에 대해서 0.5N개의 블럭들은 단편화때문에 사용못하게 됩니다. 즉, 메모리의 1/3은 낭비됩니다. 이런걸 50-percent rule이라고 합니다. 또한 internal fragmentation도 있는데, 보통 메모리블럭의 단위를 정하고 그 단위의 배수로 할당/해제를 하기때문에 실제 사용량보다 약간더 메모리가 할당되게됩니다. 이런 경우에 남는양이 internal fragmentation입니다. external fragmentation을 해결하기 위해서 페이징 기법이 되입됩니다. 물론 단지 그것만을 위한것은 아니지만, 세그멘테이션과 비교할때 페이징의 가장 큰 장점중의 하나가 바로 이것이죠. 페이징을 써서 contiguous를 virtual/physical로 나눌수 있기때문입니다. 물론 이런경우에도 internal fragmentation이 있겠지만 보통 절반의 페이지, 즉 2K가량의 단편화를 예상할수 있습니다.

물론 이건 OS등의 시스템에서 사용하는 기법이고, 유저레벨의 malloc에서는 여전히 dynamic allocation문제가 존재합니다. 작은양의 메모리의 할당/해제가 서비스되어야하기때문이죠. 여기나 Doug Lea malloc, Hoard malloc (SMP-friendly)등, 그리고 The Art of Computer Programming Volume 1 의 Dynamic Storage Allocation 섹션을 통해 공부할수 있습니다.

Garbage collection

OS와는 좀 무관하지만, garbage collection (GC) 을 살펴봅시다. 기초적인 두가지 방식이 있습니다. reference counting방식과 mark-and-sweep입니다. ((non-moving) mark-sweep GC를 McCarthy가 만들었군요. 네, Lisp의 창시자.) 먼저 간단하게 object-reference graph를 생각해봅시다. 그리고 여기에는 root라고 할수 있는 reference들이 주어집니다. 이 root들은 register, global, static, local등에서 모아질수 있겠습니다. 즉 접근의 시작점이 되는 reference들의 모임이라고 할수 있습니다. 이 root로부터 그래프를 따라 접근가능한 object들은 현재 사용중이라고 볼수 있고 그외에는 reference를 잃은 object라고 할수 있겠네요. 가장 간단한 mark-and-sweep은 간단합니다. 주기적으로 또는 메모리가 부족할때 이러한 root들로부터 recursive하게 object를을 mark해서 사용중임을 표시하고 mark되지 않은 object들은 모두 수거하는거죠. root를 모으는것은 런타임이 해줘야하겠습니다. 좋은점은 프로그램의 보통실행시의 부담이 없다는것과 cyclic reference를 처리한다는점. 하지만 예기치 않은 GC실행시에 부담으로 실해중에 pause가 생긴다거나, 메모리를 거의 다 뒤져야한다는점, 메모리 사용량이 많으면 thrashing할수 있다는 점등의 단점이 있습니다. (물론 이에 대한 대안들이 있긴하겠지만..TODO) 또한 스택오버플로의 문제가 있습니다. 스택의 높이가 reference chain의 최장길이와 같아지기 때문이죠. 물론 iteration으로 바꾸어서 overflow를 체크할수는 있지만, 근본 문제가 해결되지는 않습니다. 이를 위해 여러가지 방법이 쓰이는데, TODO 다른 문제점인 pause가 생기는 단점을 극복하기 위해서, generational GC등이 나옵니다. 이 optimization은 몇가지 관측에 기반하는데, 대부분의 object가 빨리 죽는다거나 한번 GC cycle에서 살아남으면 그 다음에도 보통 살아남는다는 것이죠. object가 GC cycle에서 죽지않고 살아남을수록 다음 세대로 옮겨가면서 GC대상에서 제외해나가는 방식입니다. 따라서 낮은 세대일수록 자주 GC의 대상이 되도록 합니다.

반면 전혀 다른 방식인 reference counting GC는 object마다 자신을 가리키는 reference 즉 포인터의 수를 세는 값을 가지고 이값이 0이 되면 수거하는 방식입니다. object가 즉시 사라지는 장점이 있는 반면, space overhead가 있을수 있고, 자신을 가리키거나하는 등의 cyclic reference의 경우에는 object를 지우지 못한다는것, (따라서 주기적으로 mark-and-sweep등의 알고리즘을 또 써줘야합니다), 그리고 포인터 조작때마다 overhead가 있다는것, 그리고 멀티코어등의 환경에서 reference update에 lock을 써야하기때문에 오버헤드가 있다는점등의 단점이 있습니다. 한가지 optimization은 stack에서의 포인터에 대한 reference를 제외하고, refcount가 0이 되어도 즉시 수거하지 않고 기다리다가 주기적으로 stack들을 살펴봐서 stack에도 reference가 없을때에 수거하는것이죠. common case에 해당하는 stack에서의 reference에 대한 부담을 제거하는것입니다. 또한 space에 대해서도 1byte정도의 refcnt만을 쓰고, refcnt가 꽉 찬 경우에는 주기적으로 특수하게 취급할수도 있겠죠.

이 둘은 상호보완적으로 쓰이는듯합니다. 그외에도 여러 이슈가 있는데, 예를들어 런타임의 지원이 없을때 object들이 가지고 있는 포인터를 어떻게 찾아내는지도 문제가 됩니다.TODO

물론 GC가 memory leak을 해결해주는 것이 아닙니다. 프로그래머가 reference를 어딘가에 두고 잊고있다면 leak은 여전히 발생하지요. GC가 프로그래머를 메모리관리에서부터 완전 해방시켜주는 것이 아닙니다. 메모리 관리는 여전히 힘든 문제군요.

여기참조하세요. C++은 reference count를 그동안 자동적으로 해주는 smart pointer를 만들어 쓸수 있었는데 이제는 C++11 에서는 아예 공식적으로 unique_ptr, shared_ptr, weak_ptr (C++98의 auto_ptr는 deprecated네요)등을 지원하기 시작하는군요. C++에서의 (non-moving) Mark-sweep GC는 라이브러리 형태로 제공되는것들이 있습니다. 반면 C++의 특성상 moving Mark-sweep GC는 쉽게 쓸수가 없군요. 포인터들이 전부 update해줘야 하기때문이죠. 이제 C++11 에서 최소한의 GC ABI를 제공한다고 합니다.

 

Kernel vs User

Kernel mode vs User mode

 

현대 CPU는 대부분 CPU의 동작 모드를 Kernel mode와 user mode로 구분합니다. 혹은 이러한 모드들을 ring이라고 부릅니다. (real mode와 protected mode와는 혼동하지 마시기 바랍니다.) 386이전에는 그저 부팅후 모든 instruction에 대해서 CPU는 실행을 할 뿐이지만, 386이후부터는 kernel mode인지 user mode인지에 따라서 실행될 수 있는 명령이 있고, 그렇지 않은 명령이 있습니다. kernel mode에서는 모든 instruction의 실행에 제한이 없지만, user mode에서는 특정 instruction들 (중요한 register에 값을 load하는, 혹은 I/O에 관련된 instruction 등)은 수행될수 없습니다. 이러한 명령들을 priviledged instruction이라고 합니다. 또는 조금 유연하게 특정 조건이 만족해야만 수행됩니다. (I/O관련된 이러한 sensitive instructions은 지정된 ring이상의 권한을 요구합니다.) 이처럼 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입니다.

유저모드에서도 system call과 같은 software interrupt를 일으킬수 있습니다. 이게 유일한 kernel 진입과정이니까요. 그러나 유저가 interrupt vector table을 수정한다면? 혹은 page table을 수정한다면? 문제가 됩니다. 따라서 메모리 보호가 먼저 선행되어야 함을 알수 있습니다. 즉 memory protection은 ring이전에 필요한거죠.

이렇게 CPU의 동작모드를 여러단계로 나누어서 각 모드마다 권한이 제한되게 됩니다. 이런 모드들을 ring이라고도 부릅니다. 인텔에서는 4개의 ring을 제공합니다. 즉 ring 0 가 kernel mode이고, ring 3이 user mode입니다. 때로는 Ring1에서 device driver가 돌기도 합니다. Intel 386의 I/O관련 sensitive instruction들은 각 포트들에 대해서 지정된 ring이상이면 수행될수 있는데, 이는 device driver제작을 위해 구현된 사항인듯합니다. 즉 각 device driver들에게 port를 할당하고 이들을 ring1과 같은 non-kernel mode에서 실행할수 있습니다. (물론 이건 너무 intel-specific이긴합니다만) 또는 Xen과 같은 VMM을 이용하면 Application이 ring 3에서, OS는 ring 1에서, VMM이 ring 0에서 돌게됩니다.

그러나 AMD가 x86-64를 설계하면서 segmentation과 함께 거의 쓰이지않던 두개의 ring을 제거해버려 xen의 경우 문제가 되었습니다. 그외에도 IA64등과 같은 아키텍쳐에서 ring이 2개밖에 없어서 이런 경우엔 OS는 application과 같은 ring에서 수행되며 별수없이 paging에 의해서 메모리를 보호받게 됩니다. 따라서 시스템콜때마다 페이지 테이블을 수정하고 TLB플러시를 해야하기때문에 상당한 오버헤드를 가지고 있습니다. Xen에서 64-bit PV guest 가 오버헤드를 가지는 이유입니다. 다만 유저모드 pte들의 Global bit (TODO) 을 켜서 조금이나마 오버헤드를 줄일수 있다는정도가 위안이 되겠습니다. AMD는 뒤늦게 다시 4개의 ring으로 확장하는데 아직까지 인텔은 그럴 마음이 없는것같군요.

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밖에 안되는 공간 때문에 실제 물리 메모리를 제대로 활용할 수 없는 상황입니다. kernel space 1G가 왜 부족할까요? 그것은 (리눅스에서) 기본적으로 이 1G안에 모든 물리 메모리들이 매핑되어 들어가야하기 때문입니다. 이것은 linux가 real mode에서 protected mode로 전환하기전에 반드시 하는 매핑인데, 이를 PAGE_OFFSET mapping이라고 합니다. (Linux 참조) Linux에서는 모든 물리 페이지들을 kernel space에 일렬로 쭉 매핑해놓고 원하는 물리 페이지에 접근하고 싶을 때엔 이 매핑을 이용해서 접근합니다. 이로써 메모리 관리가 편리해집니다. 그러나 문제는 1G라는 kernel space의 한계 때문에, 그리고 kernel space는 다른 용도로도 사용되기 때문에 실제 메모리중 대략 하위 896MB정도만이 이 매핑으로 커버됩니다. (이를 low memory라고 부릅니다.) 그 이상의 메모리는 high memory라고 부르며 이들에 접근하기 위해서는 그때그때 mapping을 만들어줘야하는 번거로움이 생기게 됩니다.

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

 

kernel space와 user space의 분리는 효과적인 방법이지만, 둘간의 데이터의 전송이 문제가 됩니다. 특히 network에서 문제가 되는데, copy_to_user / copy_from_user를 써서 kernel buffer와 user buffer사이의 복사가 이루어지는 것입니다. (TODO 부연 설명)

 

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을 시스템콜을 위한 인터럽트로 사용합니다. 윈도NT에서는 int 0x2e 를 사용합니다.

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

 

 

이 그림에서 각 계층의 모습을 잘 보여주고 있습니다. library 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

 

library 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를 참고하시기 바랍니다.

구체적으로는, 32bit과 64bit에서의 system call이 상당히 다릅니다. 64bit에서는 내부적으로 시스템콜 번호를 전부 바꾸고 정리했네요. 예를들어 64bit에서는 0번부터 read,write,open,close입니다. 32bit에서는 restart, exit, fork, 순이었지요. call convention도 바뀌었습니다. rax에 콜번호가, 이후에 rdi, rsi, rdx,..등의 순서로 넣는군요. 또한 user쪽에서의 convention과 kernel쪽에서의 convention이 서로 좀 다릅니다. 아마 syscall instruction때문인것 같은데, 64bit에서는 int 0x80 을 쓰지 않고 syscall instruction을 사용합니다. 헌데 이 명령어의 특성상 kernel쪽에서 받는 argument에 약간의 변화가 생겼네요. sysenter와 vsyscall등의 스토리도 있지만, 생략하고, 결론은 64bit에서는 syscall instruction을 쓴다는 것입니다.

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

 



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입니다. 매우빠른 반면, 하드웨어는 비싸서 보통 TLB의 엔트리수는 천개가 채되지 못합니다. 수백개에서 기껏 천개정도까지죠.

또한 TLB의 효율성을 위해서 address-space identifiers(ASID)를 각 엔트리에 두는 경우도 있습니다. 흔히 tagged TLB라고 하죠. ASID가 매치해야 히트로 보는것입니다. 이러면 여러 프로세스에서부터의 매핑을 동시에 가지고 있을수 있습니다. 이런 tag가 없는경우엔 context switch와 같이 새로운 주소공간이 들어올때는 TLB를 통째로 flush해야합니다.

TLB flush

page table 내용이 바뀔때마다 해당하는 TLB엔트리는 수정되어야합니다. TODO.. TLB shootdown, invlpg, etc

 

Cache flush

캐시도 때로는 flush되기도 하고...TODO

 



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를 통하는데........TODO

이와같이 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라고 하고 있습니다. 이 부분에 대해서는 뒤에서 좀더 자세히 살펴보도록 하겠습니다.

 

Interrupt controller

 

PC에서는 interrupt의 구현을 위해서 intel 8259A 칩을 사용합니다. 이러한 칩을 PIC (programmable interrupt controller) 라고 하는데, 이 PIC의 역할은 다른 device controller로부터 interrupt신호를 받아서 (이러한 선을 IRQ선이라고 합니다) 그중 priority가 높은 신호를 (CPU의 INTR선을 통해서) CPU에게로 전달해주는 것입니다. 키를 눌렀다든지 타이머 시간이 다 되었다든지, DMA전송이 끝났다거나 packet이 도착하는등의 사건들이 인터럽트로 들어옵니다. device가 IRQ선에 신호를 주면 PIC은 vector를 I/O port에 쓰고, INTR핀을 통해 CPU에 인터럽트를 보냅니다. 그리고 CPU가 ack하기를 기다렸다가 INTR선을 clear합니다.

(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공간에 써넣게 됩니다. 보통 많은 OS들이 PIC을 초기화할때 보통 IRQ선 번호+32 를 씁니다. 즉, IRQ0번은 32번 interrupt vector에 해당합니다. 0에서 31번까지는 인텔이 exception을 위한 번호로 쓰기때문에 충돌을 피하기 위함이죠. 이러한 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이 됩니다. IRR에 있는 요청들중에서 우선순위가 가장 높은 것이 ISR에 전달되는것입니다. 즉, 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합니다.

 

예를 들어보죠. IRQ 1,2,5번 이 발생했을때 IRR은 00100110 이 되고 이중 우선순위가 가장 높은 1번 IRQ가 IMR에도 안걸리기때문에 ISR로 전달됩니다. 만약 IMR에 걸렸더라면 IRQ2번이 전달되겠죠. 만약 ISR에 0번 IRQ가 처리중이라면 ISR로 전달되지 못하겠지만, 그렇지 않다면 ISR로 전달됩니다. 예를들어 현재 ISR이 01000000 로서 6번 IRQ를 처리중에 있다면 1번IRQ가 전달된후에 01000010 이 됩니다.

물론 PIC에서 이루어지는 masking은 각 IRQ선에 대해서 개별적으로 이루어질 수 있습니다. 이 masking은 cli에 의한 CPU의 interrupt disable과는 별개입니다. 또한 EOI역시 주의하세요. 인터럽트 핸들러는 EOI를 전달하여야 합니다. 그렇지 않다면 ISR에 여전히 처리중으로 남게되고 IRR의 그보다 우선순위가 낮은 인터럽트들은 ISR로 전달되지 못하게 됩니다. 보통 IRET전에서 EOI를 보내주죠.

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

요즘같은 MP환경에서는 인터럽트 처리를 위해서 APIC을 사용합니다. APIC은 멀티코어등의 mp머신들을 위한 인터럽트 처리 시스템입니다. 인텔환경에서는 각 코어는 LAPIC (local apic)을 가지고, 전체 시스템은 외부 인터럽트를 처리하는 하나이상의 IO apic을 가집니다. 외부 인터럽트는 IO apic을 통해서 local apic으로 전달되는 구조죠. 특이할만한 사실은 local apic은 각 CPU별로 접근이 된다는것인데, 즉 해당 물리메모리주소가 CPU-local이라는 얘깁니다. (디볼트로 0xFEE00000 에서 시작하는 local apic영역) 그래서 이 같은 주소에 접근하더라도 각 CPU는 자신만의 CPU번호를 가져오게되고, 이것이 CPU번호로 쓰일수 있습니다. 물론 IO apic은 일반적인 다른 메모리와 같이 모든 코어에 대해 global합니다. 참고로 첫번째 IO apic의 주소가 0xFEC00000 입니다. apic은 IO port를 가지지 않으며 이 영역에 직접 값을 읽고써서 접근합니다. 8259A는 이제 IO apic에 연결되어서 인터럽트를 처리하게 됩니다. IO apic은 전달받은 인터럽트를 어느 CPU에 전달할지등을 결정하게 되는 것이죠. lapic은 또한 IPI를 처리하는데도 쓰이고, 타이머도 가지고 있지요.

 

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 번에 할당되어있는 것입니다.

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

 

Vector no.

Mnemonic

Linux의 handler

설명

Signal

0

#DE

divide_error()

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

SIGFPE

1

#DB

debug()

eflags의 TF 플래그가 설정되는등의 디버깅을 위한 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은 #GP, #PF 정도입니다. 13번 #GP의 경우 intel의 protected mode에서의 보호정책을 위반하였을 때 일어나는 exception으로 이 표에 들어가지 못한 온갖 잡다한 에러들의 경우가 모두 포함되는, 쉽게말해 그외의 모든경우들..이라고 할수 있겠습니다. 윈도우에서 자주보던 General Protection Violation입니다. :-P 또 14번 #PF는 우리 눈에 익은 page fault입니다. 가상메모리에 관련하여 이 page fault와 그 handler를 잘 이해하는 것이 중요합니다. #SS는 원래 세그멘테이션 관련하여 자라나는 스택에 대한 exception인데 리눅스와 같이 세그멘테이션을 쓰지 않는다면 볼일이 없겠군요. 리눅스에서는 그런 경우 역시 모두 #PF로 처리됩니다. 하지만 이와 같이 스택이 넘치는 경우, 혹은 자라나는 스택에 대한 처리등을 통해서 VMA를 더 잘 이해할 수 있을 것입니다.

#GP를 비롯해 많은 예외들이 SIGSEGV로 처리되는것을 볼수 있습니다. SIGSEGV역시 온통 잡동사니통이로군요.

디버깅 관련해서는 #DB와 #BP가 있습니다. 각각 보통 single-step exception 과 break point exception 이라고도 불리는데, 둘다 SIGTRAP이라는 시그널로 전달되는것을 알수 있죠. 이전에 하드웨어적인 지원이 없을때 int3 명령으로 (#BP) 디버거를 구현했었습니다. (물론 지금도 유효) int3이 한바이트짜리 명령이라 해당 지점의 명령의 op코드를 이 int3로 바꿔치기해넣는 방식이었는데, 이후에 HW지원이 들어오면서 새롭게 #DB가 추가되었습니다. 그래도 호환성이나 유용성등의 이유로 여전히 #BP도 많이 쓰죠. 이 두 예외와 다른 디버깅 레지스터들이 디버거에 의해서 사용됩니다. 이런 디버깅 레지스터들이 instruction breakpoint와 data breakpoint를 둘다 지원하며 이런 지원이 없을때 instruction breakpoint구현하기는 좀 곤란한데, 코드를 수정해야 하기 때문이죠. 그런 방법으로도 불가능한 것이 ROM과 같은 영역에 대한 instruction breakpoint인데, 그래서 또한 이러한 하드웨어 지원이 필요합니다. (가상머신을 이용한 디버깅이라면 물론 더 많은것들이 가능하겠지만, 오버헤드가 크겠죠.) 4개까지의 주소를 지원하는데, 가상주소를 가지는 DR0부터 DR3까지 레지스터가 debug address register, DR4, DR5는 reserved, DR6는 status register, DR7은 control register입니다. 그외의 자원으로는 EFLAGS의 Single-step flag (TF), Resume flag(RF) (Allows an instruction to be restarted after a debug exception without immediately causing another debug exception due to the same condition.), 그리고 잘 안쓰지만 TSS의 Trap bit도 있습니다. 물론 이런 자원들은 모두 privileged입니다. breakpoint와 별도로 single-stepping을 지원하는 것을 볼수 있네요. 자세한 사항은 http://pdos.csail.mit.edu/6.828/2009/readings/i386/s12_02.htm 를 살펴보세요. 아직 많은 디버거들이 op-code를 patch하는 방식으로 int3을 끼워넣어서 instruction breakpoint를 구현하는것 같습니다. 이 방법으로 하면 모든 쓰레드가 해당 포인트에 hit한다는점이나 watchpoint는 구현할수 없다는것등이 제약점이 됩니다. 반면 HW를 활용한다면 기본적으로 쓰레드당 break point를 설정하게 된다는점과 watchpoint의 구현이 쉽다는것등이 좋은점입니다.

#UD의 경우 오히려 유용하게 사용되기도 하는데...TODO

#OF역시 잘 쓰면 유용하게 사용할수있는 ...TODO

이러한 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"에서 살펴보도록 하겠습니다.

CPU Protection

이러한 인터럽트중에서 중요한 것으로 timer interrupt가 있습니다. timer정도가 뭐가 중요하냐고 반문하실지 모르겠지만, 이 timer interrupt 기능은 multitasking을 위한 기본적인 조건으로 HW는 일정한 시간간격마다 interrupt를 발생시킬 수 있어야 합니다. 그래야만 time sharing system 을 구현할 수 있기 때문입니다. 즉 우리는 OS가 항상 control 을 가질수 있게끔해야하는데, 그래야 user가 무한루프에 빠졌을때도 OS가 시스템을 유지할수 있으니까요, 이를 위해 필요한것이 timer 입니다. 이를 통해 CPU를 보호하는거죠. 당연히 타이머를 조작하는 명령도 priviledged입니다. 보통 타이머가 0번 IRQ죠. 그만큼 중요합니다.

 

타이머얘기가 나온 김에, 쉬운듯 쉽지않은 타이머의 세계를 들여다 봅시다. 먼저 HW적인 clock source를 살펴봅니다. (1) Real-time clock (RTC) 오래된 방식이죠. 전원이 나가있을때도 동작하고 물론 wall-clock입니다. 그리고 Programmable Interval Timer (PIT) , 오래된...TODO...자세한 사항은 위키를 참조. (2) HPET, RTC보다 높은 resolution을 제공하는.... TODO. (3) APIC, APIC역시 타이머를 가지고 있습니다...TODO (4) TSC 는 cpu cycle을 이용한 방식입니다. time stamp counter이죠. cpu안에 cycle을 세는 64bit register가 있고 이 값을 읽어오게 됩니다. (대개의 cpu들은 그 복잡성때문에 특정한 cycle의 번호를 정의하기 어렵습니다. 이때문에 cpuid와 같은 instruction을 끼워넣습니다.) 가장 resolution이 높다는 장점이 있는 반면, 멀티코어나 SMP와 같은 현대의 환경에서는 각 코어별로 skew가 존재한다는 점때문에 각 코어간의 시간을 비교할때에는 보정이 필요합니다. 또한 DVS와 같은 cpu frequency를 바꾸는 기술을 쓴다면 역시 그것도 고려한 보정이 필요합니다. 또한 타이머는 아닌 단순 clock source입니다. 자세한 사항은 위키를 참조하세요.

이처럼 각 소스는 각각 장단점과 resolution, 그리고 SW에게까지 전달되는 방식(interrupt)가 있습니다. (어떤 계층에서는 이벤트가 사라져버리기도 합니다.) 이렇게 전달된 타이머 이벤트가 실제로 application에게 전달되기까지 여러 계층을 거쳐서 올라가게 됩니다. 당연히 여기에서 delay가 발생합니다. 그렇기 때문에 여러 계층에 따라서 자신이 가질수 있는 timer resolution이 달라지게 됩니다. 물론 커널은 (혹은 hypervisor)는 직접적으로 타이머를 이용할수 있지만 어플리케이션은 다릅니다. 예를들어 Linux에서 setitimer로는 HRT없이는 jiffie의 한계 아래로는 내려갈수가 없습니다. (그래서 보통 10ms 의resolution을 가지는군요.) 또한 심지어 이벤트가 사라질수도 있습니다. TODO그림. 가상화환경이라면 물론 OS조차 timer resolution이 커지게 됩니다. hypervisor가 event를 전달하는 과정이 끼어들기 때문이죠. 따라서 컴퓨터가 가진 모든 종류의 timer event라는것들은 정확히 그 시간에 발생하는것이 아니며, 단지 지정된 시간이 지난후에 발생한다는점만이 보장될뿐입니다. 따라서 유저레벨에서 여러 타이머 이벤트의 skew나 lost를 경험하는 것은 흔한 일입니다.

이런 논의에서 볼수 있듯이, 기계가 시간을 다룬다는 것은 무척 어려운 문제가 됩니다. 따라서 real-time과 같은 특수한 영역이 생깁니다.

 


Multithreading

현대의 S/W는 Multithreading을 기본으로 하고 있습니다. 즉 parallelism을 얻으려는 것입니다. 어째서 parallelism이 좋은 것일까요? 첫 번째, HW의 능력을 십분 활용할 수 있습니다. 즉 utilization이 좋아집니다. multicore나 SMP환경등 시스템들이 직렬 연산의 한계를 병렬성을 통하여 극복하고자하고 있기 때문에 S/W가 이러한 풍부한 H/W를 활용하기 위해서는 parallelism을 추구해야하겠습니다. 즉 단순히 빠른 직렬연산을 추구하는 방식의 한계에 다다르는 것입니다. 두 번째로는 I/O에 적합하다는 점입니다. S/W의 기본 동작을 계산+I/O라고 볼 때 계산의 부분은 직렬성이 적합하다고 할 수 있지만, I/O의 부분은 병렬성을 필요로 하는 부분입니다. 대표적인 경우로 GUI등 사용자입력을 위한 시스템을 들 수 있습니다. I/O의 특성상 blocking이 많기 때문에 이것을 위해서 여러 쓰레드를 사용하는 것이 프로그래밍에 편리하다고 할 수 있습니다. responsiveness가 좋아진다고 할수 있겠습니다. 그외에 쓰레딩에 대해서 one-to-one이니 many-to-one이니 many-to-many니 하는것들이 있는데, 말장난입니다. 쓰레딩에 대해서는 몇가지 고려사항들이있는데, (1) fork에 대해서는 어떻게 할지? 또는 exec때는? exec때는 보통 모든 쓰레드를, 즉 프로세스 전체를 죽입니다. 그러면 fork때 만약 모든 쓰레드들을 복제했다면 exec가 연달아올때 쓸데없는짓을 한것이 되죠. (2) thread cancellation때는? 프로세스와 달리 (사실 프로세스도 마찬가지지만) thread의 cancel은 좀 어려운 구석이 많습니다. 공유되기 때문인데, 예를들어 lock을 쥐고있는채로 죽이면 무척 곤란해지죠. 또는 여러 thread들이 결과를 찾다가 한 thread가 결과를 찾았을때 다른 쓰레드들은 멈춰버릴 필요가 있다든가, 그럴때 스레드를 취소해야합니다. 그래서 즉시 죽일수도 있겠지만, 그보다 해당 쓰레드가 주기적으로 끝내야할지를 체크해서 스스로 끝내는 방식을 쓸수 있습니다. 이런 지점들을 cancellation points 라고 하고 이런 방식을 deferred cancellation이라고 합니다. 이것말고도 signal의 처리가 애매해집니다. 시그널이 thread때문에 발생한 경우(synchronous signal)에는 어느 쓰레드가 받아야할지가 명확하지만 그외에 외부적인 시그널(asynchronous signal)은 누가 받을지가 불분명합니다. 예를들어 Ctrl-C를 누를때같은 경우죠. 어떤 OS는 모두에게 전달하되 각 thread가 signal mask를 써서 받을지 안받을지를 결정하는 방식을 쓰기도 하지만, 이런 asynchronous signal은 단 한번만 처리되어야 하기때문에 시그널을 막지 않는 첫번째 쓰레드로 전달됩니다. Windows 2000의 경우엔 APC(Asynchronous Procedure calls)를 이용해 시그널과 비슷한 효과를 내는데, APC는 받은 notification에 대해서 어떤 함수를 호출할지를 지정해주게 됩니다. 이런 경우 어느 쓰레드가 받을지에 대한 혼란이 없지요.

보통의 경우 thread pool을 유지합니다. 이건 thread creation 오버헤드때문이기도하고, 요청이 들어오는대로 thread를 만들면 무한대의 쓰레드를 만들게될수도 있기에 pool로 관리를 합니다. 그리고 thread-specific data가 있습니다. 예를들어 UNIX의 errno와 같은 경우 새롭게 구현되었는데......(생략)

쓰레딩을 지원하기 위해서는 커널 내부에서도 적지않은 변화가 필요합니다. 대표적으로 PCB에 TCB(Thread control block)을 달아야하는등의 변화죠. Linux에서는 독특한 방식으로 이것을 처리하는데, 리눅스에서는 프로세스와 쓰레드의 구별이 없이 task라는 이름으로 불리웁니다. 그리고 fork()는 clone()이라는 함수를 콜하는 wrapper일뿐입니다. clone()은 새로운 task를 생성하는데 부모task와 어떤 부분들을 공유할지를 결정합니다. 여기서 주소공간을 공유할지 안할지가 결정되는데 이와같은 flag들에 의해서 어느정도 공유가 될지가 결정됩니다. 따라서 여기에 따라서 쓰레드인지의 여부가 결정되죠.

자바는 언어가 threading을 지원하는 몇안되는 케이스중에 하납니다. 그리고 JVM이 쓰레딩을 구현하기때문에 기본적으로 user level쓰레딩이라고 할수 있겠지요. 그러나 보통 one-on-one으로 커널쓰레드에 매핑시키는것 같습니다.

그러나 쓰레딩 구조에 대한 비판도 많습니다. 대표적인 대안으로 event-driven방식의 프로그래밍을 제안하기도 하는데, GUI등을 이와같은 event-driven형식으로 설계할 수도 있겠습니다.

threading이 어려운것은 data가 sharing되기때문이죠. 그리고 isolation이 안되기때문이죠. 그래서 light-weight 한것이죠. 반대로 process가 무거운것은 isolation이 제공되기때문입니다. sharing을 피하기위해서는 message-passing과 같은 기법등을 쓸수 있습니다. 이 기법은 copy등의 오버헤드가 발생합니다. Fibers(자바의 green threads도 그렇고) Fibers는 heavyweight threads의 대안이 아닙니다. 병렬로 실행되지 않기때문이죠, 그건 차라리 flow-of-control construct라고할수 있습니다. 코루틴처럼요.. TODO...

 

Control flow

multitasking을 위해서는 A라는 일을 조금 실행하다가 그 CPU상태를 저장하고 B의 이전 상태를 다시 복구해서 조금 실행하다가 다시 그 상태를 저장해놓은후 방금전 저장한 A의 상태를 다시 복구하는 방식을 씁니다. x86과 같은경우 하드웨어가 이러한 작업을 대신해주기도 합니다만, (x86의 task기능) 보통은 SW적으로 동일한 기능을 구현할수 있기때문에 사용하지 않습니다. 구현이야 어떻든 그 원리는 동일하며, 앞의 A,B와 같은 일련의 실행흐름은 보통 thread (혹은 task)라고 불립니다. 옛날에는 process라고도 불리웠는데, mulththreading의 등장으로 좀더 정확히 thread라고 부릅니다. process는 여러 thread를 가지며, 특히 주소공간과 unix의 file descriptor를 비롯, 그외에도 많은 resource를 가지는 보다 상위 단위입니다.

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장을 참조하시기 바랍니다.)

context switch가 무거운 이유는 주로 캐시 warming-up과 TLB의 flush 때문입니다. 즉 address space의 전환입니다. TLB flush후엔 한동안 매 memory reference가 각각 3번씩의 메모리 참조를 하게되기 때문입니다. (보통 이때는 page table의 내용은 캐시에서 사라진 후겠죠.) 거기에 locality의 변화가 생기기 때문에 cache미스가 마구 일어나는 것이 또한 context switching을 무겁게 만들 게 됩니다. 사실 몇 instruction되지 않는 state의 save/restore는 그 자체로는 큰 부담이 되지는 않습니다. 시스템콜보다 context switch가 훨씬 무거운 이유입니다. 따라서 threading간의 switch는 이와같은 address space의 전환이 없기 때문에 훨씬 가볍습니다. 그래도 locality의 변화만을 좀 겪겠지요. 따라서 따져보면 오버헤드는 context switch(address space switch) >> system call > thread swtich 가 될 것입니다.

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가 나누어 가지게 되는 것입니다.

user-level thread는 하나의 thread가 blocking system call을 하게되면 모든 thread들이 block되기 때문에, 이를 해결하기 위해서 다음과 같은 두가지 방법을 생각할 수 있습니다. 하나는 그러한 systsem call을 thread library로 통하게 하고 library는 blocking될 system call을 defer하는 것입니다. 그후 다른 모든 thread가 block되어도 좋을 지점에서 비로소 system call을 부릅니다. (물론 안좋겠죠) 두 번째는, 커널이 프로세스가 multi-threaded라는 것을 인식하고 있다가 blocking system call이 와서 block되면 library에서 upcall로 그 사실을 알려주는 것입니다. (이것도 별로 마음에 안드네요.:-) )

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역시 커널 코드의 일부분이기 때문에 커널이 실행되는 경우입니다.

보통 PCB(Process control block)이라고 부르는 구조체가 하나의 프로세스를 대표합니다. 리눅스의 경우 task_struct 라는 구조체가 sched.h에 있죠. 흥미로운것은 이 구조체를 각 태스크의 커널스택의 밑바닥에 놓는다는 것입니다. 일단 커널코드안에서는 커널진입전에 실행중이던 태스크가 무엇인지 알필요가 있는데요, 즉 현재의 context를 알아내야하죠. (물론 interrupt context에선 의미없겠지만) 커널스택에 놓은 편법이 이를 편하게 해줍니다. 일단 슬랩할당자로 task_struct 할당을 한후에 커널스택 바닥에 넣으면 커널내의 어느 시점에서든 esp를 적당히 bitwise and시켜주면 (하위 12개 혹은 13개 비트면 reset해주면) 구조체에 쉽게 접근할수 있다는 것이죠. current 매크로가 이것을 처리해줍니다. 그래서 현재 태스크를 쉽게 구할수 있죠. 2.6에서는 task_struct와 서로간의 pointer를 가지는 thread_info 라는 구조체로 바뀌었고 이걸 스택의 바닥에 놓습니다. 그리고 포인터로 task_struct로 연결되어있죠. arch-dependent한 부분을 따로 독립시킨것이죠.

편의상 그리고 보안상 각 쓰레드는 커널 스택을 가집니다. 즉 커널모드에서 사용하는 스택입니다. x86에서는 TSS라는 구조체에 커널스택으로의 포인터를 넣어서 커널에 진입하면 자동적으로 스택 스위치가 이루어집니다. 태스크 하나가 사용하는 메모리는 이 스택+PCB가 거의 전부라고 할수 있겠습니다. 매 태스크마다 존재하기때문에 메모리 사용량이 꽤 되죠. 그래서 예전엔 커널스택이 2페이지 였습니다만, 요즘엔 1페이지로 줄어든것같습니다. 2페이지는 메모리 낭비가 심하기때문이고, 또...TODO

 

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가 수행될뿐이니까요. 아, 물론, 인터럽트나 예외가 발생하는 경우엔 때에 따라 preemption하기도 합니다.

다만, 실행중이던 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중에 하나의 경우에 놓여있게 됩니다.

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

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

 

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 (Reentrancy)

모든 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)가 이루어져야 한다는 점입니다. 즉 모든 커널 데이터를 서로에 대해서 보호해야합니다. Linux 2.4까지만해도 이것은 어려운 문제였기 때문에 한 시점에 하나의 프로세스만이 커널 모드에 진입해있을수 있도록 함으로써 커널을 reentrant하게 했었습니다. 즉 한 프로세스가 커널에 진입했을때 스스로 yield하거나 커널을 나가지 않는 이상 다른 프로세스는 커널로 진입할수 없었던 것입니다! 따라서 이런 커널을 non-preemptible kernel이라고 부릅니다. 즉 한 프로세스가 이미 다른 프로세스에 의해 수행중인 커널을 preemption할수 없었다는 것입니다. 다른 관점에서 말한다면 커널만큼은 cooperative scheduling을 한다는것입니다. (또는 커널 전체가 critical section이라고도 할수 있겠습니다.) 가장 간단한 해법이죠.

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

장점은 synchronization을 피할수 있다는것 정도라고 할까요. (그러나 물론, 이런 경우에서조차 interrupt나 exception등의 쓰레드는 언제든지 CPU를 차지할수 있기때문에 반드시 커널과의 synchronization을 해줘야합니다. 다시한번 interrupt 의 특수성을 알수 있습니다.) UP환경이라면, 아마 큰 성능의 저하없이 latency의 희생정도로 끝날수도 있겠습니다만, MP환경에서는 심각한 문제를 발생시킵니다. 즉, 오로지 한 CPU만이 커널모드로 진입할수 있다는것이죠. 따라서 우리는 보다 적극적으로 모든 CPU혹은 프로세스가 커널모드에 있을수 있도록 해야할것입니다. 즉, 이를 위해 공유하는 모든 data structure를 보호하기 위해서 locking 매커니즘을 사용하여 커널을 reentrant하게 만들 게 됩니다. 이러한 locking 매커니즘은 간단히 말해서 kernel의 특정 구간(reentrant하지 않은 부분, 즉 공유 data를 수정하는 부분)을 critical section으로 묶어 이 구간에는 하나의 프로세스만이 진입할 수 있도록 만드는 기법입니다. 따라서 MP환경에서도 다른 CPU들이 모두 커널로 진입할수 있게됩니다. 이를 또한 preemptible kernel이라고 합니다. 즉 한 CPU에서 커널이 수행되고 있을지라도 다른 process에 의해서 얼마든지 preemption이 가능해지는 것입니다. 이것은 MP환경에서의 성능뿐만이 아니라 latency를 향상시키게 됩니다. 물론, 이러한 preemptible kernel이라고 해도 개개의 critical section 안에서는 여전히 한 CPU혹은 프로세스만이 들어갈수 있음을 상기하시기 바랍니다. 단지 이러한 critical section이 매우 짧기때문에 전체적으로 preemtible이라고 할수 있게되는것입니다. 이러한 preemptible kernel을 구현하는것은 매우 어려운 과정입니다. 이를 위해 초기에 BKL(Big Kernel Lock)이라고하는 커널 전체에 대해서 lock을 거는 거대한 lock을 만들고 이 lock을 잘게 부수어 나가는 기법을 사용합니다. 이렇게 해서 최종적으로 이 BKL을 제거함으로써 리눅스를 preemtible하게 만들수 있게되었습니다.

느끼시겠지만, 이것은 library가 thread-safe하다는것과 같은 맥락입니다. thread가 등장하기전까지는 library는 reentrant하지 않아도 괜찮았지만, thread때문에 library들은 reentrant해야하는것입니다. 여러 쓰레드가 하나의 코드/데이터를 함께 공유하기 때문이죠. 사실 이와같은 문제는 시스템 거의 모든 레벨에서 발견할수 있습니다. synchronization의 문제가 단순한 특정 레벨에서의 문제가 아닌 쓰레드와 관련된 시스템 자체의 문제라는것을 알수 있습니다.

 

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는...TODO

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

 



Virtual Address Space

Process layout

하나의 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이 유리합니다.

 

Virtual address space management

Linux에서는 가상 주소공간을 관리하기 위해 VMA(virtual memory area)를 구현합니다. 마치 소프트웨어로 구현하는 segmentation이라고나 할까요? 소프트웨어적으로 구현하는 것입니다. 하드웨어 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 layout"편에서 보았던 일반적인 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에 대한 정보를 이용합니다.

구체적으로는 vm_area_struct라는 구조체가 이를 구현합니다. vm_end, vm_start, vm_flags, vm_inode, vm_ops, vm_next 등의 필드가 있죠. inode는 mmap되는 해당 파일을 나타내죠. ops는 open, close, unmap, protect, sync, advise, nopage, wppage, swapout, swapin등의 operation들을 가지고 있습니다. 실제 물리 페이지가 없을때 불리는것이 nopage, COW을 위해서 write-protected된 페이지일때 불리는것이 wppage 입니다. 이 vma들은 검색의 편의를 위해서 AVL과 같은 구조를 사용합니다. 페이지 폴트를 빠르게 처리하기 위함이죠. 페이지 폴트가 났을때 vma를 찾을수 없다면 잘못된 접근으로 판명되고 SIGSEGV 시그널이 전달됩니다. vma가 존재하더라도 여러 가능성이 있는데, anonymous page를 가져와야한다거나, swap되었으니 디스크에서 읽어와야 한다거나, COW된 페이지이니 복사본을 만들어야한다거나 하는것이죠.

 

Dynamic library

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

여기서 볼 수 있는 것은, init이 libc-2.3.2.so 라는 파일을 이용하고 있다는 점입니다. ld-2.3.2는 dynamic linker입니다. 커널이 execve를 통해 executable을 매핑한후엔 executable에서 지정되어 있는 dynamic linker를 매핑합니다. executable과 DSO와의 차이는 별로 없는데, executable은 mapping되는 주소(loading address)가 정해져있다는 것정도가 중요한 차이점입니다. 이후에 DSO들은 이 dynamic linker가 처리하게 됩니다. dynamic linker도 DSO이지만, 다른 shared library와 다른점은 커널이 바로 매핑할수 있도록 complete해야한다는 점입니다. 즉 unresolved symbol등이 없어야하는 것이죠. -- 실제로는 그렇지 않은것같습니다. dynamic linker는 complete하지 않고 현재 자신 스스로 심볼들을 처리합니다.

리눅스에서의 dynamic library의 특징으로는 PIC이어야 한다는 것인데, Position independent code (PIC)는 주소공간내의 어디에도 붙을수 있게끔 코드내의 absolute address가 없는 코드를 뜻합니다. executable의 경우도 PIC일수도 있는데 이때 PIE(Position independent executable) 이라고 불립니다. PC-relative addressing과 같은것이 PIC코드가 되죠. function이나 global variable들의 주소가 포함되지 않는 이런 PIC코드는 이들에 접근하기 위해서 GOT(global offset table)을 통해서 접근하게 됩니다. GOT는 이러한 주소들의 테이블로 dynamic loader가 DSO를 매핑할때 GOT를 채워넣게됩니다. GOT의 entry를 채워넣은 후에는 PIC이라도 코드에서부터 데이터까지의 거리는 일정하다는 점을 이용해서 자신의 주소로부터 GOT까지의 거리는 상수이므로 이것을 더해서 GOT의 주소를 구합니다. 그후에는 GOT를 통해서 다른 global들에 접근할수 있게됩니다. 함수로의 접근의 경우 PLT라는 단계를 하나더 거치게 됩니다. TODO 그림 from Levine's book 이러한 PIC은 로딩될때 relocation이 필요없습니다. 따라서 코드가 read-only가 되므로 여러 프로세스가 공유가능하게 됩니다.

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

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

relocatable code는 loader가 특정 위치에 붙이기 위해서 주소들을 fixup해주어야하는 과정(relocation)을 거쳐야합니다. ELF에서는 .rel.text 와같은 REL섹션에 관련 정보들이 있습니다. 아래의 Dynamic linker를 참조하세요.

링커와 로더에 대해서는 내용이 방대해서 책한권이 따로 나올만하고 또 실제로 나와있으므로, 관심있으신분은 Linkers & Loaders 라는 책을 직접 보시기 바랍니다. 또한 ELF와 같은 binary format과 매우 밀접한 관련이 있으므로 binutils 를 살펴보는것도 도움이 될것같군요.

요즘은 보안상의 이유로 Address space layout randomization 라고 주소공간에 랜덤한 위치에 코드들을 붙입니다. cat /proc/self/maps 를 여러번 쳐보시면 매번 cat process의 주소가 바뀌는것을 볼수 있죠. non-PIC인 executable만 loading address가 변하지 않고 나머지는 랜덤하게 정해지는군요.

 


CPU Scheduler

Process state

먼저 태스크의 관점에서 어떻게 태스크가 살아가는지 짧게 살펴보겠습니다. (TODO 그림) 태스크의 관점에서 볼때 태스크는 간단히 CPU에서 실행중이거나, 어떤 이벤트를 기다리면서 잠들어있거나(sleeping 또는 blocked로 I/O를 기다리는 경우들 모두입니다.) 둘중의 한 상태입니다. 즉 이 두 상태를 계속 왔다갔다하는거죠. 물론 좀더 세분해서 런큐에 들어가서 대기하고 있는 상태를 (runnable) 따로 나타내기도 합니다. 리눅스의 경우 sleeping상태가 사실 두가지 interruptible과 uninterruptible의 상태로 표현하기도 합니다. 이 두가지 상태의 차이점은 signal로 인해 깨어나느냐 그렇지 않고 커널이 직접 wake_up()함수를 불러주어야 깨어나느냐하는 차이입니다. (TODO) 어떤 경우에는 시스템콜로 커널에 있는 경우에 시그널을 받게 되어서 시스템콜이 자신의 일을 종료하지 못하고 리턴하기도 합니다. 리턴값을 살펴보면 이런 경우들을 볼수 있죠. (TODO example) 참고로 커널 안에서 sleeping하는 것은 생각보다 쉽게 이루어지지는 않습니다. http://www.linuxjournal.com/article/8144를 참조. 어쨌든 간단히 말해서 태스크는 실행중이던가 아니면 block상태입니다. 실행중인 태스크들은 런큐에 있는 태스크들인것이고, 스케쥴링은 이들중 어느녀석을 언제 얼마만큼 실행해줄 것인지를 결정해주는 기능이죠.

Overview

스케쥴링은 여러 level에서 여러 factor들을 생각할수 있지만, 일단 여기서는 short-term CPU scheduling을 얘기해봅시다. (long-term scheduler혹은 job scheduler라고 불리는 녀석은 과거의 유물이라서 별 의미가 없습니다.) 스케쥴러는 크게 두부분으로 이루어집니다. 런큐에서 어떤녀석을 골라낼지를 정하는 schedule()함수, 그리고 실제로 그 프로세스(혹은 쓰레드)를 CPU에 올린후 실행시키는 dispatcher죠. dispatcher는 보통 architecture-specific한 부분이고 (그래도 뭐 대부분 비슷비슷합니다.) schedule()함수가 보통 핵심입니다. 가장 단순하게 생각할수 있는것이 FIFO입니다. 런큐에 있는 프로세스들을 순서대로 실행해주고 CPU를 내놓을때까지 무작정 기다립니다. 물론 현실적이지 못합니다. 태스크 하나가 CPU를 내놓지 않는다면 시스템이 그대로 서버리는군요. (리눅스의 SCHED_FIFO가 이걸 구현합니다.) 그래서 time slice라는것을 도입합니다. 즉 정해진 (리눅스에서 100ms) 시간만큼을 허용하고 그 시간내에 CPU를 내놓지 않는다면 preemption을 합니다. 즉 강제로 CPU를 뺐는것이죠. (리눅스의 SCHED_RR이 이걸 구현합니다.) 런큐에서 한녀석씩 계속 차례로 조금씩 실행해주는 Round-robin 방식입니다. 가장 간단하죠.

스케쥴링은 수행되는 각 태스크들의 성격에 맞춰 요구사항들을 충족시켜줘야합니다. 그래서 먼저 태스크들의 행동을 이해할 필요가 있죠. 기본적으로 태스크를 두가지로 분류합니다. CPU-bound와 I/O bound입니다. CPU를 100%씩 계속 쓴다던지하는 CPU소모형인지 아니면 I/O를 주로하는 (또는 interactive한) 형태인지하는 것이죠. 전자의 경우 사용하는 CPU양이 중요하고 얼마나 제시간에 스케쥴링되는지는 그다지 중요하지 않은 반면, 후자의 경우 일반적인 패턴이 wakeup-짧은실행-sleep 의 반복이기때문에, 이벤트가 왔을때 wakeup한후에 재빨리 스케쥴링되는것이 중요합니다. 이 시간을 latency로 표현할수있고 사용되는 CPU의 양보다는 이러한 latency가 중요한 부류입니다. latency time은 다른 각도에서 response time이라고도 부르고, IO-bound는 interactive job이라고도 합니다. 즉 GUI같은 사람이 느끼는 작업등에서는 response time이 중요시되죠. 그래서 평균 response time을 줄이려고 노력하지만, average못지 않게 중요한것이 (혹은 더 중요한것이) response time의 variance입니다. 즉 편차를 줄이는것이 사람의 관점에서 predictable하게 느껴지기때문이죠. 수학적 계산이나 시뮬레이션등이 보통 CPU-bound이고 text editor라든가 하는것이 I/O-bound라고 할수 있습니다. 어떠한 프로세스들은 두 경우 모두 해당되기도 합니다. media player와 같은 경우 latency가 중요하면서도 encoding/decoding을 위해서 꽤나 많은 CPU를 사용하기 때문에 그렇습니다. X Window server와 같은 경우도 그런경우로 알려져 있습니다.

Round robin방식은 간단한만큼 각 프로세스의 특성을 살려서 스케쥴링 하지 못합니다. 예를들어 I/O-bound process들에겐 latency가 중요한데 잘 지원해주지 못하게 됩니다. 즉 스케쥴러는 각 프로세스의 특성을 살려서 스케쥴링을 해줄 필요가 있죠. 그렇지 않다면 단순히 round-robin방식을 쓰면 될겁니다. 이점은 스케쥴링의 복잡함의 이유가 application들의 특성의 다양함에 있다는점을 확인시켜줍니다. 스케쥴링 부분은 OS에서도 특히나 heuristic 에 의존하고 그 알고리즘의 종류도 무척이나 다양하다는 특징을 보이고있는데요, 그것은 이러한 application의 종류와 그 특성이 그만큼 다양하여 한가지 알고리즘이 모든것을 충족시키지 못한다는것을 보여줍니다. 대표적으로 서버용 OS와 클라이언트용 OS는 서로 상반된 스타일의 스케쥴링을 적용합니다. 서버측에서는 throughput을 최대화하려하고 클라이언트는 latency를 최소화하려고 합니다. 이처럼 tradeoff가 존재하고 또 다양하기 때문에 스케쥴링 알고리즘 역시 다양하고 목적에 맞춰 사용하는것이 최선이됩니다.

따라서 몇가지 이러한 고려요소들을 나타내는 metric들을 살펴보면, 첫째로 throuhgput 과 latency가 있습니다. throughput을 위해서는 최대한 스케쥴링(context switching)을 줄여서 timeslice를 늘리게 됩니다. 물론 그에 따라 latency가 희생됩니다. 반대로 latency를 위해서는 짧은 timeslice가 유리합니다. 그리고 두번째로, 빼놓을수 없는 fairness입니다. 이 fairness는 무척이나 논란거리인 개념이지만, 기본적으로 사용한 CPU의 양이 같아야 합니다. 즉 3개의 프로세스가 같이 돌고있다면 CPU의 1/3씩을 할당받는 것이 fair하죠. 어떤 경우엔 weight를 두어서 할당양을 조절하기도 합니다. CPU할당량의 비율을 정해놓는 경우입니다. (credit scheduler의 weight) 흔히 proportional-fair이라고 부릅니다. 이를 위해서 보통 credit과 같은 개념을 사용하는데, 즉 CPU를 사용할수 있는 권한을 숫자로 나타내고 사용한 양만큼 빼주는 방식입니다. 그리고 주기적으로 credit을 분배해주죠. 이런 방식에서는 CPU-bound 태스크들은 credit을 금새 써버리는 반면, interactive 태스크들은 credit이 쌓여가기 때문에 모자라는 일이 별로 없게됩니다. 그외에 몇가지 더 생각할 것들은, starvation이 발생해서는 안된다는 점입니다. (TODO:starvation) 그리고 특수한 경우일수 있지만, embedded 환경에서 강조되는 Realtime이 있습니다. (일단 realtime은 생략..) 이러한 여러 조건에 따라서 어떤 스케쥴러는 fairness를 강조하고, 어떤것은 throughput을, 혹은 latency를, realtime을 강조하며 다양한 스케쥴러가 존재합니다.

또하나 생각할것이 스케쥴러 자체의 overhead입니다. 스케쥴러의 overhead는 첫째로 얼마나 자주 스케쥴러가 불리는지와 스케쥴러의 효율성(O(1)인지 O(n)인지)에의해서 주로 결정되죠. 보통 작은 overhead만을 가지지만 잘못 설계되면 절반이 넘는 CPU시간을 스케쥴러 오버헤드가 차지하는 사태도 실제로 벌어집니다. (또 생략..)

Priority

앞서의 RR은 I/O-bound 프로세스에겐 나쁜 response time을 주게됩니다. 따라서 이런종류의 프로세스에게 우선권을 주는 방식이 도입되는데, 이게 priority입니다. priority가 높은 작업이 큐에 들어오게되면 즉시 이작업에게 CPU를 내줍니다. 따라서 우선권이 높은 태스크는 response time이 매우 좋아지게 됩니다. 반면 CPU를 빼앗기는 태스크들은 무작정 기다려야하는 문제가 있습니다. starvation등의 문제가 있는 것이죠. 또한 priority를 잘못 조절할 경우 심한 경우 시스템 전체가 멈춰서는 것도 예사일입니다. high priority task가 버그등으로 문제를 일으키면 치명적이 되기 때문이죠. 그래서 priority는 많이 쓰이기는 하지만 많은 비판을 받고 있고 (e.g. Lottery scheduling) 실제로 많은 문제를 일으키기도 합니다. (priority inversion등) 사실 이러한 단순한 발상은 과거의 job scheduling시절의 유물인데, 아직까지도 스케쥴링에선 기본적으로 이 개념이 쓰이고 있습니다. priority는 realtime (리눅스의 SCHED_FIFO, SCHED_RR등)의 영역에서는 유용하게 쓰이는것 같지만, 그외의 대부분의 영역에서는 너무 과도한 정책인것 같습니다. 그래서 리눅스등에서도 dynamic priority라는 것을 도입하게 됩니다. real-time이 아닌 영역에서는 보통 이런식으로 과도한 priority는 피해갑니다. 그래서 사람들을 더 혼란스럽게 하는데, 이쯤되면 credit과 priority의 중간쯤되는 의미라고도 할수 있겠습니다. 그래도 기본적인 아디이어인 I/O bound process에게 즉시 우선권을 줘서 response time을 높이겠다는 의미정도는 여전히 유효합니다.

이런 dynamic priority의 개념은 multilevel-queue scheduling에서 잘 나타납니다. priority에 해당하는 여러 큐들을 놓고 큐안에서는 RR과 같은 혹은 각자만의 스케쥴링을 사용합니다. 자신보다 높은 큐에 프로세스가 하나라도 있으면 자신은 절대 실행될수 없는 구조입니다. 높은 큐에서 실수로 무한루프에라도 빠진다면 난리나겠죠. 그래서 각 큐간에 프로세스가 이동할수 있게하는 feedback구조를 형성합니다. multilevel feedback queue scheduling이라고 하죠.

Priority inversion

priority의 대표적인 문제점으로 지적되는 priority inversion입니다. 이건 priority-based scheduling과 locking이 결합해서 만들어내는 문제인데, 화성에 갔던 path finder를 무한 리부팅으로 몰고갔었던 것이 이 문제였다고 하는군요.

priority inversion은 다음과 같은 상황입니다. 우선순위가 High, Medium, Low인 세 프로세스가 있을때 어떤 자원에 대한 lock을 Low가 잡고 있습니다. 이때 High가 들어와서 이 자원을 기다리며 잠드는데, 보통은 Low가 실행되면서 lock을 놓으면 금방 High가 실행되므로 아무 문제가 없지만 여기서 다시 Medium이 들어옵니다. Low보다 우선순위가 높기때문에 CPU를 선점하고 Low는 실행되지 못하죠. 그러면 High역시 무한정 기다려야하는 상황이 벌어질수 있습니다. Medium이 CPU를 죄다 차지하고 있기때문이죠. 이런 현상은 고정된 priority를 쓰는 real time scheduling에서 나타납니다. 이의 해결을 위해서 Priority inheritance라는게 제안되는데, 간단히말해서 이런 상황에서 Low에게 High의 우선순위를 준다는거죠. 그러면 Low가 재빨리 수행된후 High가 정상적으로 수행되고나서 Medium이 실행되게 됩니다.

Linux scheduler

리눅스의 스케쥴러들을 살펴보겠습니다. 먼저 리눅스 태스크들은 크게 두가지 priority를 가집니다. real-time (0~99) 과 non-realtime(100~140)입니다. 그리고 POSIX에 따라 세가지 정책이 구현되어있습니다. SCHED_FIFO, SCHED_RR 가 두가지 realtime 정책이고, SCHED_OTHERS가 그외의 non-realtime 정책입니다. 일반적으로 보통의 태스크들은 non-realtime이고, 유닉스의 전통적인 nice value (-19~20) 가 이 영역(100~140)에 매핑됩니다. real-time정책인 SCHED_FIFO, SCHED_RR은 그보다 높은 우선순위인 0~99를 가질수 있는 정책들입니다.

먼저 리눅스 2.6이전에 쓰였던 스케쥴러는 CPU에서 실행될수 있는 양을 credit이라는 숫자로 각 프로세스에게 분배해준후 이들이 모두 credit을 소모했을때 새롭게 다시 credit을 분배해줍니다. 이 한 주기를 epoch이라고 합니다. O(n)알고리즘을 썼으며 그것도 매번 전체 프로세스에 대한 값들을 계산해주는 방식이었습니다. 그리고 런큐도 하나였습니다. 당연히 scalability가 떨어졌습니다. SMP나 멀티코어에 좋지않죠. 프로세스의 수(n)이 커질수록 스케쥴링 오버헤드도 커졌죠. 또한 preemption을 하지 않았습니다. 일단 태스크의 time slice가 시작된 이후에 더높은 priority의 태스크가 들어오더라도 preempt하지 않고 해당 timeslice가 끝나기를 기다렸죠. 무척 간단하고 구현하기 편한 방식입니다.

그리고 2.6 초기에 쓰인 Ingo Molnar's O(1) scheduler. priority들을 bitmap으로 펼치는 편법으로 O(1)을 달성합니다. 각 CPU마다 run queue array두개를 둡니다. active와 expired두개죠. 그리고 이둘을 swap해가면서 bitmap을 이용해 다음 태스크를 스케쥴링합니다. 제 생각에는 그보다 흥미로운것은 sleepness를 계산하는것인데, process가 자신에게 주어진 퀀텀을 다 소모했는지 아니면 중간에 yield했는지 를 따져서 sleepness를 계산합니다. 즉 퀀텀을 다 쓴다면 CPU-bound 라고 해석하고, yield를 한다면 I/O-bound라고 해석합니다. 헌데 이 계산법이 꽤나 정교해서 쓸만한것 같더군요. 어쨌든 O(1)이라고 해서 히트쳤던 스케쥴러입니다. http://en.wikipedia.org/wiki/O(1)_scheduler 이전것에 비하면 per-CPU 런큐에, preemption도 하고, O(1)인점등 장점이 많았습니다.

그리고 2.6.23정도에서는 CFS(complete fair scheduler)로 바뀝니다. http://en.wikipedia.org/wiki/Completely_Fair_Scheduler http://people.redhat.com/mingo/cfs-scheduler/sched-design-CFS.txt

CFS 는 간단하다. process scheduling 을 시스템이 완벽한 multitasking processor 을 가지고 있다고 가정한다. 각 process 는 1/n 만큼의 processor time 을 받게된다. n 은 runnable process 의 개수이다. CFS 는 switching cost 를 고려한다. CFS 는 각 process 를 적정시간만큼 돌리고 가장 적게 돌았던 process 를 다음으로 돌린다. 각 process 에 timeslice 를 assign 하기보다는 proportion 을 주어서, process 가 도는 시간을 전체 runnable process 의 개수의 함수로 결정한다. nice value 를 weight 으로 쓴다. higher value(lower priority) 는 fractional weight relative to the default nice value, lower value 는 큰 weight 을 받는다. (By Jsyang)

각 process 는 timeslice - weight/total weight of all runnable threads 에 비례하는 만큼 돌고, 실제 imeslice 를 계산하기 위해서 CFS 는 infinitely small scheduling duration target 을 정한다. 그 target은 targeted latency 라고 부른다. 작을수록 interactivity 는 좋아지고, perfect multitasking 에 가까워지지만 higher switching costs 또 그것에 따른 throughput 저하를 부를수 있다. targeted latency 가 20 ms 라고 했을때, 똑같은 priority 의 두개의 runnable tasks 가 있다고 하자. task의 priority 의 상관없이, 각 task 는 preempting 전에 10ms 만큼 run 한다. 4개 tasks 일때 각 5ms 돌고, 20 개이면 1 switching cost 때문에 CFS 는 minimum granularity 를 정한다. default 로 1ms 이다. (By Jsyang)

다시 두개 runnable processes 를 생각해보자. 하나는 default nice value 0 이고 다른 하나는 5라고 해보자. 이둘은 다른 process time 의 proportion 을 가지게 된다. nice-5 process 의 weight 은 1/3 정도가 된다. target latency 가 20 ms 일때 process 들은 각각 15ms 와 5ms 를 받게된다. 이와 같이, CFS 에서는 각 process 가 processor time 의 fair share-proportion- 을 받기 때문에 f fair scheduler 이다. (By Jsyang)

윈도우는 multilevel feedback queue를 사용합니다. 여러개의 큐를 프로세스가 옮겨다니는 방식이죠. 하지만 Linux도 여러개의 스케쥴링 policy를 지원하기 때문에 결국 같은 multilevel queue방식이라고 생각됩니다.

SMP

이제 SMP의 경우를 생각해봅시다. 스케쥴러를 구현하려할때 먼저 고려할 사항은 런큐입니다. 개별큐를 쓸것인지, global queue를 쓸것인지. 물론 global queue를 쓰면 synchronization문제가 발생하며 performance에 안좋죠. (물론 CPU수가 몇개 안되는 경우엔 큰 문제가 안됩니다.) 즉 scale하지 못합니다. 그보다 더 중요한것은 global queue를 쓰면 태스크들이 매번 다른 CPU에서 돌기때문에 affinity에 안좋다는 것입니다. ping-ponging한다고 하는데, 태스크가 스케쥴링때마다 매번 다른 캐시에서 돌게되어 캐시 효율이 떨어집니다. 즉 process-CPU affinity가 안좋아집니다. per-cpu 런큐의 경우 affinity가 좋아진다는 장점이 있습니다. 하지만 이런 경우 load-balancing을 해줘야합니다. 주기적으로, 또는 때때로 각 런큐간의 태스크들을 옮겨줘야 합니다. TODO... load balancer

그외에 최근 고려하고 있는 중요한점이 multicore/SMP/cache/NUMA 등의 topology입니다. 예를들어 SMT코어들간의 경우에는 런큐를 공유하는 것이 좋아보입니다. 이처럼 코어수가 늘어남에 따라 스케쥴링방법도 달라지고, NUMA와 같은 메모리 환경에 따라서도 부가적인 스케쥴링이 필요하게 됩니다. 최근의 연구 분야이기도 합니다. 또한 가상화 기법에 따른 hypervisor에서의 스케쥴러 역시 새로운 분야입니다. (또 생략...)

Fork

보통 fairness를 위해 기본적으로 사용되는 credit-based 스케쥴링에서 한가지 유의할점중 하나는 무한fork와 같은 경우입니다. 새로운 태스크가 생겼다고 새롭게 full-credit을 주게되면 안됩니다. 만약 부모가 무한fork를 하고 자식이 큐에서 부모다음에 오게 된다면 시스템 전체가 서게 되는수가 있습니다. 물론 자식을 큐의 맨끝에 놓으면 되겠지만 이럴때에도 시스템 전체가 서는 수가 있습니다. 그래서 이런 credit방식에서는 부모가 fork를 하면 자식은 부모의 credit을 절반 나눠서 가져오게되는데, 그래야만 전체 프로세스들의 credit양이 일정하게 유지되기 때문입니다. 자식이 credit을 새롭게 생성해서 가지게 되면 무한fork의 경우 epoch이 끝나지 않고 다른 모든 프로세스 들은 실행되지 못하게되어 시스템 전체가 서게됩니다. 만약 credit방식을 사용한다면 반드시 주의해야할 점입니다.

이건 fork할때의 정책의 문제중 한 경우로, 자식 process에게 부모의 resource를 나눠주게 할것인지, 새로운 resource를 할당해줄것인지의 문제입니다. 메모리나 화일등을 부모것을 쓰게하는것은 무한fork를 통해 시스템을 망가뜨리는것을 막게 됩니다. 이와같이 fork때의 부모와 자식간의 자원분배/할당 문제도 일반적인 문제입니다.

참고로 do_fork()에서는 자식을 먼저 수행하려고 합니다. 보통 자식은 곧바로 exec()를 하기때문에 부모가 먼저 수행되면 COW오버헤드가 생길수 있기때문에 이걸 피하자는 것이죠.

Real Time

일단 Hard realtime은 생략. (TODO) Linux는 soft-real-time을 지원하는데, 꽤나 지원이 잘된다고 하는군요. SCHED_OTHERS보다 높은 우선순위 (0~99)를 가지는데, chrt 명령으로 태스크들에게 이런 realtime priority를 줄수 있습니다. 자연스럽게 real-time task가 하나라도 런큐에 있으면 다른 non-realtime들은 실행되지 못하게 됩니다. 따라서 realtime priority를 주는 일은 매우 신중하게 결정되어야 하는데, 실제로 매우 제한된 태스크만이 realtime으로 설정된것을 볼수 있습니다. (top명령은 현재 99의 RT-priority만을 RT라고 표시해주는군요) SCHED_FIFO와 SCHED_RR의 차이점은 timeslice의 유무일뿐인데, SCHED_FIFO에 들어간 태스크는 자신이 내놓기전까지 preempt되지 않고 영원히 도는반면 SCHED_RR의 timeslice를 가지고 같은 priority를 가진 태스크끼리 round robin방식으로 돈다는 차이점뿐입니다. 물론 priority가 높은 녀석은 원한다면 영원히 돌겠죠. 그래서 real-time priority는 무척 위험한데, 여기에서 한가지 예를 보여주고 있습니다. SCHED_OTHERS는 dynamic priority를 가지고 있기 때문에 이러한 priority의 위험이 덜하지만 real-time에서는 priority가 변하지 않는 static priority라서 그렇습니다.

최근에 SCHED_OTHERS는 SCHED_NORMAL 로 이름이 바뀌고 SCHED_BATCH가 추가되었군요. 스케쥴러가 항상 cpu-intensive 로 처리한다고 합니다. 그외에 SCHED_ISO 가 추가될 예정인듯한데, starvation을 피하고자 SCHED_FIFO/RR의 unprivileged version을 만드는 것으로 생각됩니다. 추후에 자세히 알게되면 추가하도록 하죠.


Physical Memory Management

http://www.makelinux.net/ldd3/chp-8.shtml LDD의 8장을 적극 참조하세요. 아니 그냥 LDD를 통째로 읽으세요.

메모리 할당 문제는 오래된 문제입니다. Space-time간의 tradeoff가 있지요. 초창기의 first-fit과 같은 방식으로 heap관리를 하면 fragmentation과 같은 문제가 생깁니다.

Kernel Memory Allocator

유저공간에서와 커널공간에서의 메모리 관리의 공통점을 봅시다. 먼저 유저공간에서도 메모리 관리는 힘든 문제이듯, 커널에서의 메모리 관리도 역시 힘든 문제입니다. 또한 이는 무척 효율적으로 이루어져야합니다. 메모리의 할당과 해제는 매우 빈번하게 일어나기 때문에 시스템의 성능에 큰 영향을 미치기 때문입니다.

그렇다면 차이점을 봅시다. application에서와는 다르게 malloc/free등의 라이브러리들이 없기 때문에 커널은 자신이 스스로 메모리를 할당하고 해제하는 문제를 풀어야합니다. 두번째로 커널은 가상 주소공간과 실제(물리) 주소공간을 둘다 관리해야합니다. 앞서서 유저의 가상주소공간이 어떻게 관리되는지 살펴보았으므로 여기서는 물리적인 메모리의 관리법과 커널에서의 가상주소공간의 관리를 살펴보겠습니다. 앞서의 유저 가상공간관리는 말그대로 유저에서의 가상공간이므로 실제적 메모리의 사용량등보다는 주소공간의 배열등을 얘기하는것이기때문에 실제 메모리를 관리하는 KMA야말로 실제적인 의미의 메모리관리라고 할수 있겠습니다. 그외에 커널공간의 가상공간관리는 주로 부족한 커널의 가상공간때문에 일어나는 관리문제입니다. (vmalloc) 세번째로, 커널은 보통 hardware를 직접적으로 이용하기때문에 page단위의 할당/해제를 하는 경우가 많고 또한 효율적입니다. 이를 위해 보통 두단계의 할당매커니즘을 씁니다. 첫번째로는 물리 page를 관리하는 KMA를 만들고 그위에 두번째로 byte단위의 malloc/free를 할수 있는 slab allocator를 구현합니다. 따라서 간단한 data structure등을 위해서는 slab allocator를 쓰지만, 버퍼등의 큰 메모리를 할당하고자 할때는 직접 KMA를 불러서 4K단위의 페이지들을 할당하는것이 일반적입니다.

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

리눅스에서는 이를 위해 buddy algorithm을 사용합니다. http://students.mimuw.edu.pl/SO/Wyklady-html/04_pamiec/pagealloc.htm를 참고하세요. 생략...TODO


from http://www.halobates.de/memory.pdf

위의 그림이 커널이 메모리를 어떻게 사용하는지 잘 보여줍니다. 먼저 부팅시에는 임시적으로 bootmem이 메모리를 관리하다가 KMA인 Page allocator에게 넘깁니다. 이중에 struct page 나 Big hash같은 것은 미리 정적으로 할당되어 버립니다. 이후 KMA가 필요할때마다 버디알고리즘을 사용해 페이지를 할당하는데 주로 slab allocator와 page cache가 할당받아가게 됩니다. 그중 slab allocator는 그것을 slab으로 삼아서 다시 object단위의 할당을 해주게 됩니다. (요즘은 SLUB이라는 놈을 씁니다. 리눅스의 slab할당자의 문제 몇가지를 수정해서 다시 구현했습니다. 예를들어 slab안의 메타데이터를 뺐다든가, per-node per-cpu 큐를 없애버렸네요. 리눅스엔 현재 SLOB 이라는 놈도 있는데, 작은 시스템환경을 위한 슬랩할당자입니다. 다른 구현들이니, 골라쓰시면 되겠습니다.) 그중 중요한것이 dcache (dentry cache) 와 inode cache입니다. 실질적으로 가장 많이 쓰이는 캐시이죠. 아래에서 논의하겠습니다. 더 자세한 리눅스의 메모리 사용량에 대해서는 Andi Kleen의 논문을 참고하세요.

 

 

Slab Allocator

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

위의 그림은 slab할당자가 slab위에서 object들을 관리하는 모습을 보여줍니다. slabtop명령으로 상태를 살펴볼수 있는데, 주요한 object로 dentry, inode_cache등이 있음을 볼수 있습니다. 그외에도 buffer_head나 vma등도 보입니다. kmalloc() 역시 object 할당을 위해 slab allocator를 이용하는데, kmalloc-128 등이 그러한 예입니다. kmalloc이 할당하는 128바이트짜리 object인것이죠. kmalloc은 이렇게 object의 크기를 표시해놓습니다.

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

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을 커버할수 있다는 점이다.

 

 

Disk Cache - Page cache, buffer cache and 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를 수행할수 있습니다. 위의 그림에서 보시다시피 두 경우 모두 버퍼캐시 위에서 동작하고 있습니다. 전통적인 read/write의 경우엔 버퍼캐시의 내용물이 유저 버퍼에게로 복사 되어가기 때문에 별문제가 없습니다. 단순히 캐싱만 하는 경우입니다. 반면 버퍼를 직접적으로 유저공간에 매핑시키는 mmap()의 경우에는 문제가 달라지는데, 일단 4K라는 페이지안에 해당 화일상의 연속된 데이터들이 꼭 맞게끔 배열되어 있어야 하죠. 그래야만 유저공간에 노출시키수 있습니다. 여기서 페이지 캐시가 등장하는데, 이 page cache는 이처럼 버퍼를 paging을 통해서 적절히 유저공간에 노출시키기 위한 장치입니다. 버퍼캐시가 디스크의 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부터 페이지 캐시로 통합됨으로써 페이지 단위의 캐싱을 하고 있습니다. 그러나 그렇다고 버퍼캐시가 없어진 것이 아닙니다. 예를들어 buffer_head와 같은 버퍼캐시의 중심 data structure가 여전히 중심적인 역할을 하고있기 때문이며 버퍼를 유지하는한 버퍼캐시가 없다는것은 말이 안되기 때문이죠.

그외에도 이러한 디스크 캐시외에도 몇가지 중요한 캐시가 더 있습니다. inode cache와 dentry cache같은것들이죠. 이것들은 file system과 관련된 캐시입니다. 아래에서 다룹니다.

이와 같은 캐시들로 인해 여러 현상들이 생기는데, 예를들어 디스크캐시에 대한 hit ratio를 재고자 할때, 두가지 이슈가 있습니다. 첫째는 이와같은 상위 캐시들이고, 두번째로 read-ahead입니다. 여기에서 생각해볼때, 정확한 hit ratio라는 것이 정의하기도 쉽지 않은것 같습니다. 예를들어 몇번이나 access가 있었는지도 알길이 없습니다. 유저공간에서 직접 접근하니까 말이죠. TODO: read-ahead. 시스템콜을 통한 read/write방식은 버퍼의 dirty함을 커널이 직접 표시할수 있기때문에 문제가 없지만 페이지 캐시의 경우 노출된 버퍼에 user process가 직접 접근하기때문에 clean한지 dirty한지를 결정하기가 매우 힘든 측면이 있습니다. (아마 이러한 이유로) 유닉스에서는 shared모드로 (MAP_SHARED) 여러 프로세스가 특정 화일을 mmap() 할때에는 msync() 나 munmap()을 하기전까지는 디스크와 sync가 보장되지 않습니다. (관심있으신분은 mmap, munmap, msync 를 찾아보세요.) 또한 설령 어느 페이지가 dirty해졌다는것을 파악한다고 해도 해당 page내의 어느 버퍼(혹은 블락)이 dirty인것인지는 알기 어렵기 때문이기도 하죠.

mmap()은 또한 실제적으로 shared memory로서 이용될수 있기때문에 (System V IPC인 shmem을 대신해서) IPC의 방식으로써 곧잘 쓰이기도 합니다.

일반적으로 메모리는 많을수록 좋은것은 이처럼 디스크 캐시 용량이 늘어나기 때문입니다. 과거에는 메모리가 부족한 편이었기때문에 그러했지만, 최근에는 메모리가 풍족해져서 디스크 캐시가 무작정 많다고만 좋은것은 아닌듯 싶습니다. 이미 사용자의 워킹셋을 넘어서는 수준이기 때문이죠. 한 16GB정도의 메모리에서 메모리를 무식하게 먹는 workload가 아닌 일반적인 데스크탑 유저들의 경우에 한 12GB정도는 전부 디스크 캐시로 쓰이는것 같습니다. 커널과 프로세스들의 메모리 사용량을 합쳐도 4G미만인것 같군요. 이때 예를들어 5G정도의 디스크 캐시만이 활발히 사용되고 있다면 나머지 7G의 디스크 캐시는 실제로는 낭비되는 셈입니다. 또 캐시의 한계로 인해 한 11G쯤되는 화일을 sequential하게 쭉 읽어온다면 디스크 캐시가 flush되어버리는 효과가 나타나게 됩니다. 이런때에는 유저는 차라리 램디스크등으로 남는 램을 활용하는 것이 이득입니다. 물론 일반적으로 디스크 캐시가 잘 해주기때문에 램디스크는 효용성이 없습니다만 이처럼 워킹셋을 넘기는 경우에는 차라리 유저가 디스크캐시보다 더 효율적으로 램을 활용할수 있겠습니다. 결론은 이런경우엔 램디스크등 다른 방식으로 램을 활용해라. 단, OS보다 잘 활용해라. 그럴 자신없으시면 그냥 OS에게 넘기시면 되겠습니다.

리눅스에서 이러한 캐시를 관리하기 위해서 LRU를 흉내낸 scheme을 사용하고 있습니다. inactive list와 active list라는 두 개의 리스트를 통해서 다음과 같이 관리합니다.

(생략)

open()에는 O_DIRECT 라는 옵션이 있습니다. 디스크캐시를 통과하지 않고 IO를 한다고 알려진 녀석인데, 간단히 말해서 쓰지 마십시오. 과거에 DB를 하는 사람들이 즐겨썼던 모양인데, 캐시를 관리하고 싶으면 madvise()나 posix_fadvise()와 같은 함수를 대신써야합니다. Linus가 한말입니다.

O_SYNC 에 대해서...TODO.. synchronous write

O_NONBLOCK.. TODO

Dentry Cache and Inode cache

커널이 가진 주요한 두개의 캐시가 directory entry cache (dentry cache)와 inode cache입니다. path name을 inode로 변환하기 위해서는 디렉토리를 참조해야하는데, 이를 빠르게 하기 위해서 dentry cache를 사용하고, inode로의 접근 역시 빈번하므로 이를 캐싱합니다. 둘다 file system에서의 meta data임을 알수 있습니다. TODO...

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되어 나가있는 장비를 backing store라고 합니다.

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로 써져야 할 필요가 있음을 표시합니다.

이와같은 swapping은 과거 메모리가 적을때 큰프로그램등을 수행하기 위해서 필요했지만 요즘같이 큰 메모리를 가진 시대에는 그다지 큰 의미가 없어지고 있습니다. 단지 메모리가 부족해서 큰프로그램을 실행할수 있다는 정도의 의미일뿐 보통 swapping이 일어나게되면 성능이 급격히 떨어지기때문에 유저들은 workload를 줄인다던지 메모리를 증가시키는법을 선호하죠.

때로는 swap되었던 페이지가 다시 메모리로 올라온 상태에서 overwrite되지 않고 있는 경우가 있습니다. 이때 메모리와 디스크상의 내용이 같은 상태로 남게되죠. 이를 추적하기 위해서 swap cache를 사용합니다. 페이지가 지저분해지면 그때서야 swap cache에서 제거하는 방식이죠.

 

Vmalloc

vmalloc등은 일반적으로 권장되는 방법이 아닙니다. 이 함수류는 커널 가상공간내에 할당받은 물리 페이지들을 배열하여 contiguous하게 보이게하지만, 보통은 쓸 이유가 없을뿐 아니라 어떤 아키텍처등에서는 비효율적으로 구현될수 있기때문입니다. 32비트 PC와 같은 경우 보통 high memory등이 이런식으로 접근되는데, 이는 커널 가상주소공간이 부족하기 때문입니다. 이는....생략...

이와같이 가상공간의 부족으로 물리 페이지들을 필요에 따라 임시적으로 매핑하여 쓰고 (또 그것을 캐싱까지 해가는) 그런 기법이 xen에서도 쓰입니다. 특히 32비트의 경우에 64MB라는 한정된 공간때문인데,...

 

Page Replacement Policy

OS가 빈페이지가 필요할때, 안쓰고있는 빈페이지가 있다면 그것을 씁니다만, 더이상 안쓰는 페이지가 없을때는 보통 가장 먼저 버퍼캐시를 줄입니다. 더티페이지보다는 clean페이지를 몇개 버리면 쉽게 빈페이지가 생기지요. 더티페이지는 write가 동반되기때문에 비용이 큽니다. 그러나 또한 버퍼캐시를 너무 줄이면 성능저하가 일어나기때문에 어느시점부터는 신중하게 생각해서 버릴 페이지를 선택해야합니다. 이를 위해서 리눅스는 active list와 inactive list간의 비율을 정해놓고 있습니다. 이런 선택을 Page Replacement Policy라고 합니다. 그리고 페이지를 버려서 free하게 만드는것을 page reclaim한다고 합니다. 만약 버퍼캐시에서 더이상의 페이지를 줄이기 어려워졌다면 Swapping까지 동원해서 프로세스의 페이지들을 뺏어오기 시작합니다. 이조차도 한계가 있기때문에 그이상이 되면 OOM(Out of memory)으로 생각하고 프로세스들중 victim을 선택해서 강제종료를 시킴으로써 메모리를 확보합니다. 물론 이런 상황의 근본은 workload가 너무 크게걸려있기때문이죠. 첫번째로는 workload를 잘 조절해야하겠지만, victim page를 선택하는것도 역시 중요합니다. 흔히 이런 선택은 프로세스의 행동에 의해서 좌우되기때문에, working set이나 reference string등을 통해서 victim을 선택합니다. 대표적으로 LRU류의 방식들을 많이 사용합니다.

어떤 page replacement 알고리즘에서는 빈페이지의 수가 많은경우에 오히려 fault의 수가 늘어나기도 하는 현상이 벌어지기도 합니다. 이를 Belady's anomaly라고 하는데, FIFO page replacement방식에서 나타납니다. 먼저 비교를 위해서 OPT또는 MIN이라고 불리우는 optimal algorithm을 생각해봅니다. 이것은 미래에 가장 오랜시간동안 사용되지 않을 페이지를 선택하는 알고리즘입니다. 당연히 optimal일것입니다만, 현실적으로 구현이 불가능함을 알수 있습니다. 그래서 그 대안으로 LRU방식이 있습니다. OPT에 대한 approximation인셈입니다. Least Recently Used라는 말에서도 알수있듯이 가장오래사용되지 않았던 페이지를 선택하는 방식입니다. 이 방식은 일반적으로 좋다고는 생각되어지지만, 구현이 쉽지 않다는 단점이 있습니다. 스택을 이용해서 구현할수는 있지만, 오버헤드가 꽤들어가기때문에, 역시 approximation을 하는 알고리즘들이 주로 쓰입니다.

OPT나 LRU는 Belady's anomaly가 없습니다. 이처럼 Belady's anomaly가 없는 알고리즘을 스택알고리즘(stack algorithms)라고 부릅니다.

LRU approximation page replacement로는 second chance algorithm이 있습니다. second chance는 기본적으로 FIFO입니다. FIFO가 큐의 앞에서 빼낼때 대신 그 페이지의 reference bit이 0일때만 페이지를 선택합니다. 하지만 1이라면 빼서 뒤쪽으로 보냄으로써 second chance를 주고 다음페이지로 넘어갑니다. 이때 reference bit은 clear합니다. 그래서 circular queue로도 생각할수 있습니다. 만약 모든 페이지들이 reference bit이 켜있다면, 즉 첫번째 페이지가 두번째로 마주치게 되었다면 이 페이지가 선택되게 됩니다. circular queue 로 생각하고 구현하면 페이지를 뒤쪽으로 보내는 push가 없어져서 좀더 수월한 구현이 되고, 이를 clock algorithm이라고합니다. 사실상 같은거죠. 이를 좀더 확장해서 dirty bit까지 동원할수 있습니다. (reference, dirty)형태로 4가지 경우가 가능합니다. 첫째 (0,0)이 가장 좋은 경우이고, 둘째 (0,1)은 더티페이지라서 DISK IO가 필요하며, 셋째 (1,0)은 다시 사용될 확률이 있고, 넷째 (1,1)은 가장 안좋은 선택입니다. 따라서 (clock algorithm을 쓰되 위의 4경우중 가장 낮은경우의 페이지를 만나면 그것을 선택합니다. 큐를 여러번 돌수도 있다는점은 있습니다. 이 방식은 매킨토시에서 사용되었습니다. clock과의 차이점은 dirty페이지보다 clean페이지를 먼저 선택한다는것이죠. 여전히 dirty함보다도 최근 reference 되었느냐를 더 중요시한다는점을 볼수 있습니다.

그외에도 카운터를 이용하는 방식이 있습니다. reference된 수를세어두는 방식입니다. 먼저 LFU(least frequently used)는 가장 적은 수를 가진 페이지를 선택합니다. 이방식은 과거에 많이 사용되었지만 현재는 더이상 안쓰이는 페이지들을 구별할수 없다는것이 단점이죠. 이를 위해 주기적으로 카운터를 리셋해줄수 있습니다. MFU(Most frequently used)의 생각은, 적은 카운트를 가진 페이지는 방금 막 올라온 페이지라서 아직 사용되지 않았을것이라는 점이죠. MFU나 LFU는 둘다 자주 사용되지 않습니다. 비싸고 OPT에 별로 가깝지 않기때문이죠.

카운터방식은 시간정보를 안가지기때문에, 또다른 방식으로 Ageing algorithm이 있습니다. 예를들어 8비트의 history register를 사용하는데, 주기적으로 timer가 이 비트들을 shift해주고 low order bit을 버리고, reference bit를 high order bit으로 넣습니다. 그렇게해서 전체 바이트는 history를 기록하게됩니다. 예를들어 계속적인 접근을 했던 페이지는 11111111 일테고, 11000100은 01110111보다 최근에 접근되었다는것을 알수 있습니다. 이를 unsigned int로 해석할때 가장 작은숫자를 가진 페이지를 선택하면 됩니다.

Global Page Reclamation

메모리는 각 subsystem에서 다른 용도로 사용하기때문에 일괄적으로 각 페이지들의 중요도를 나타내기 힘듭니다. 따라서 전체적인 관리가 어려운데, 대표적으로 page reclaim을 위해서 어느곳에서 어느만큼의 페이지를 가져와야할지와 같은 선택의 문제가 있습니다. 가장 먼저 버퍼캐시에서 페이지를 가져오지만, 그 이후엔 어떤 페이지가 가장 적은 성능저하를 가져올수 있는지가 불분명하기때문에 memory pressure가 있는 이런 경우에 선택이 어렵습니다. 예를들어 여러 프로세스가 돌고있고 각 프로세스가 메모리를 사용하는데 어떤 기준으로 어느 프로세스에서 페이지를 뺏어올까요? 각 프로세스에게 같은양의 메모리를 주는것은 분명 전체 성능을 위해서 좋지 않습니다. 따라서 비례적으로 할당하는 방식을 생각할수 있을겁니다. 예를들어 각 프로세스의 virtual memory의 사용량을 구해서 그 비율대로 페이지들을 할당해줄수도 있습니다. 또는 page fault rate를 생각해서 페이지들을 배분할수도 있겠습니다. 또는 프로세스의 priority에 따라서 이러한 페이지 배분을 조절할수도 있습니다. TODO...

Working Set and Thrashing

워크로드가 너무 적은 메모리에서 실행될때는 실제 작업보다도 메모리부족으로 인한 Paging이 더 활발해지는 경우가 있을수 있습니다. 일단 메모리가 부족해지면 버퍼캐시 히트율이 떨어지면서 성능이 떨어지고 결국 swapping까지 되면 디스크가 병목지점이 되면서 다른 프로세스들까지 모두 다같이 느려지는 현상이 발생합니다. 이것이 심해지면 프로세스들은 계속적으로 page fault를 경험하게되고 이것은 모두 디스크IO로 나타나게 됩니다. 그러면서 실제 작업보다도 디스크IO에 더많은 시간과 CPU가 사용되게 되는데 이를 thrashing이라고 부릅니다. 그래프상에서는 performance혹은 CPU utilization이 워크로드가 커질수록 증가하다가 어느시점부터는 급격한 하락을 하게되는 지점이 나타납니다. 바로 thrashing입니다. 즉 workload가 과도하게 걸려 메모리가 부족해지면 CPU가 실제 작업보다 페이징과 swapping같은 작업에 더많이 사용되게되고 전체 성능은 심각하게 떨어지게됩니다.

이런 thrashing을 피하기 위해서는 각 프로세스에 어느정도만큼의,최소한의,혹은 thrashing을 피할수있는만큼의 메모리는 최소한 할당해줘야하고, 이를 위해 working set을 정의합니다. 어떤 reference string에 대해서 working set을 정의할수 있습니다. 두가지로 정의할수 있는 일반적으로 쓰는것은 시간에 대한것으로 지나간 얼마간의 시간내에 접근된적이 있었던 reference의 집합으로 정의됩니다. 또는 크기로도 정의할수 있겠죠. 정해진 얼만큼의 양의 reference를 LRU방식으로 정의하면 정해진양의 working set이 됩니다. 물론 이런 working set model의 근거는 locality입니다. 하지만 이런 working set을 추적하는 일은 쉽지 않습니다. 주기적으로 reference bit을 조사해서 얻을수 있습니다.

locality는 매우 중요한 특성이고 매우 많은 부분에 의해서 영향을 받습니다. 소스코드에서 2차원배열에 row로 접근할지 혹은 column으로 접근할지와 같은 data placement에 의해서 크게 영향을 받고, 또한 data structure에 의해서 영향을 받죠. 스택은 locality가 매우 강한반면 해쉬와 같은 구조는 낮은 locality를 가집니다. 포인터를 많이 쓰는 코드는 역시 locality가 낮아지며 컴파일러와 링커, 로더에 의해서 로컬러티가 변화합니다. 최근의 몇몇 연구에 의하면 OOP언어는 기존의 imperative언어보다 낮은 locality를 보인다고 하는군요.

물론 어떤 페이지들은 IO때문에 lock이 되기때문에 이런페이지들은 replacement정책의 victim으로 고를수 없습니다. 또한 커널페이지들은 보통 메모리에서 내리지 않습니다. 커널에 의한 페이지폴트는 그래서 대개 버그로 처리됩니다. 즉 커널도 paging될수 있는가? 라는 문제입니다. 커널을 paging하기에는 너무 복잡한 문제들이 많으므로 피해가는것이죠.

이런 경우도 있을수 있습니다. 낮은 우선순위 프로세스가 페이지를 읽어들이고나서 런큐에서 기다릴때 높은 우선순위의 프로세스가 먼저 실행됩니다. 그리고 읽어들인 바로 그 페이지를 가져가버립니다. reference되지도 않았고 modify되지도 않았기에 최적의 candidate이니까요. 높은 우선순위의 프로세스가 그외의 프로세스의 페이지를 뺏어올수 있는 경우의 이야기입니다. 결국 낮은 우선순위의 프로세스가 손해봤습니다. 이런 경우에 방금 들어온 페이지를 locked된 상태로 시작하게 해서 첫번째 폴트가 나서 처리될때까지 묶어놓을수 있겠습니다.

그외에 page fault frequency를 이용할수 있습니다.폴트가 너무 자주일어나면 메모리가 부족한것이니 더 할당해주고, 그렇지 않다면 메모리를 줄여주는것이죠.

Windows NT의 경우 폴트가 나면 그 주변의 여러페이지들까지 함께 불러옵니다. 그리고 최초 프로세스가 만들어지면 working set minimum만큼의 페이지는 메모리에 가지고있음을 보장해줍니다. 또한 working-set maximum이 있습니다. 최고치만큼의 메모리를 이미 가지고있다면 새로운 페이지를 할당하진않고 local pagereplacement policy를 적용해서 victim을 선택합니다. 또한 빈페이지의 수가 너무 내려가면 automatic working-set trimming을 통해서 너무 많은 페이지를 가진 프로세스의 메모리는 줄이고 빈페이지가 더있다면 minimum에 도달한 프로세스의 경우엔 더 늘려주기도 합니다. x86 UP환경에서는 clock알고리즘의 변종을 사용하고, x86 SMP환경이나 Alpha에서는 reference bit을 클리어하는것이 다른 프로세서의 TLB엔트리를 invalidate할수있기때문에 FIFO의 변종알고리즘을 씁니다.


Address Binding

Late binding

메모리는 CPU와 함께 시스템에서 가장 중심에 있는 기둥입니다. 폰노이만 병목현상에서도 알수 있듯이 성능에서도 가장 중요한 요소가 바로 메모리입니다. 이에 따라 최초의 간단했던 메모리구조가 CPU와 함께 VM의 도입등 점점 복잡해지는데, 그 중심에 있는것이 address binding입니다. 즉 프로그래머가 소스에서 변수하나를 지칭할때부터 시작해서 최종적인 physical address가 생성되기까지의 path가 여러단계를 걸쳐서 진화하게 되는데, 이 변화가 시스템의 진화라고도 볼수 있을만큼 변화의 핵심을 담당해왔습니다. 이와같이 가장 높은레벨의 변수에서부터 최종 physical address까지의 변환과정을 address binding이라고 합니다. 첫번째 binding은 컴파일러가 심볼을 상대적인 주소로 변환하는것입니다. 즉 한모듈안에서의 오프셋으로 바인딩하는데, "이모듈안에서 200번째바이트"와같은 식으로 연결해두는것입니다. .o화일단계에서 이런식으로 처리됩니다. 차후에 실제 메모리에 올라오기 위해서 다시한번 변환과정을 거쳐야하기에 (relocation) 이를 relocatable address라고도 합니다. 이제 linker가 이런 상대주소를 절대주소로 바꿉니다. 즉 4기가의 주소공간 안에 매핑하는것입니다. 이와같이 compile time과 link time에 바인딩이 일어날수 있습니다. 그리고 사실 compile time에도 절대주소를 쓸수 있습니다. 코드나 데이터가 주소공간의 어느부분에 존재할것인지를 미리 알게되면 그주소를 그대로 써도 동작할것입니다. OS개발과정에서 커널로딩부분이나 임베디드환경, 혹은 DOS시절의 코드등이 대표적인 예가 되겠습니다. 하드코딩된 주소가 바로 쓰일수도 있는것입니다. 이런경우 가장 이른시간에 바인딩이 일어났다고 할수 있습니다. 이를 early binding이라고 합니다. 물론 대부분의 코드들의 절대주소는 link time에 binding되기때문에 일반적으로 절대주소는 link time에 결정된다고 할수 있습니다. 그러나 리눅스 .so와 같은 경우 PIC코드를 사용하기 때문에 load time에 한번더 주소가 바뀔수 있습니다. 이런 모델에서는 주소가 실제 가상주소공간에 .so 나 executable을 매핑할때 정해집니다. 요즘의 Address space layout randomization 와 같은것이 그런 경우죠. 따라서 주소가 load time에 결정되게 됩니다. 이제 load time도 아닌 실제 runtime에 바인딩이 결정되는 예는 dynamic library와 OOP언어들의 가상함수가 있습니다. printf와 같은 library의 주소는 런타임에 실제로 함수가 불려질때 비로소 library가 load되면서 결정됩니다. 그래서 load-time이라고도 할수 있을지도 모르겠고 그나마 보통 결정되어서 그 주소가 계속 유지되는 반면, VMT를 이용한 가상함수는 훨씬더 dynamic합니다. 이처럼 OOP언어들에서 가상함수, 즉 특정함수로의 주소를 virtual method table등을 이용해서 바인딩을 늦추는것이 좋은 예입니다. 그래서 indirection branch를 이용한 가상함수로의 호출은 runtime이라고 할만합니다.

지금까지가 가상주소였으므로 이 (가상의) 절대주소는 한번더 변환을 거칩니다. 절대주소란 결국 virtual address입니다. 따라서 OS에 의해서 물리주소가 주어집니다. final time이라고 이름지어봅시다. 이제 CPU에 의해서 physical address로 자동변환되됩니다. 이 과정에는 OS도 참여하기때문에 (page fault handler) HW/SW가 동시에 참여하는 바인딩입니다. 심지어 가상화환경에서는 이 과정이 두번에 걸쳐일어납니다. 가장 늦은시간에 바인딩이 일어나기 때문에 late binding이라고 부릅니다.(?) 또한 OS나 hypervisor차원에서의 migration 이나 swapping등은 한걸음 더 나아가 이러한 바인딩이 쉽게 바뀔수도 있다는 특징을 가집니다. 즉 물리 주소는 쉽게 바뀐다는 것입니다. 심지어 타겟주소는 메모리가 아닌 디스크나 네트워크에 있을수도 있습니다. 그럼 이것까지 포함하면 5단계에 걸쳐서 바인딩이 일어나게됩니다. compile time, link time, load time, runtime, final time. 이러한 변환을 거치면 최종적으로 physical address가 나오게됩니다. rune time 까지 생성된것이 virtual address인데 여기까지는 순수한 유저레벨입니다. final time은 물리주소로의 변환이므로 OS가 참여합니다. 여기서는 HW와 함께 작동하여 최종 주소를 만들어냅니다. 사실 이 이후에도 하드웨어는 더 많은 단계를 가질수 있습니다. NUMA와 같은 시스템은 이제 더 복잡한 메모리구조를 가지기때문입니다. 이단계 이후부터는 순수하게 HW가 처리하게 됩니다. 따라서 final time은 SW가 할수 있는 마지막단계이자, HW가 들어오는 최초의 단계이고, 동시에 HW/SW가 만나는 바인딩 지점인셈입니다.

그림..TODO

dynamic library와 같이 실행시간에 로딩되는 것을 dynamic loading(혹은 lazy binding)이라고 합니다. 즉 실행시간에 binding이 이루어집니다. 그러나 이 dynamic loading은 OS와는 별 상관이 없이 application(dynamic linker)이 하는 일입니다. 아래의 PLT/GOT를 이용한 매커니즘을 참조하세요. 따라서 이는 demand paging 와는 다르다는점을 주의하세요. demand paging 는 OS가 application모르게 처리하는 일임에 비해서, 반대로 dynamic loading은 OS모르게 application이 처리하는 일입니다. (물론 OS는 dynamic linker를 돕기위한 메커니즘을 가지고있습니다.) 간단히 말해 unresolved symbol이 남아있는 상태에서도 일단 링커 ld-linux.so 의 jump table 로 연결해놓고 그래서 이 jump table이 최초의 실행일때는 심볼을 찾아서 library를 올린후에 연결시키는 형식입니다. 따라서 최초의 call의 경우에 library를 연결한후엔 그 이후엔 indirection call이 되는 방식입니다. 실제로 gdb로 확인이 되는데 hello world 두번찍는 c프로그램을 gdb로 돌려보면 instruction레벨에서 추적해보면 첫번째 printf가 ld-linux.so 로 갔다가 돌아오는걸 볼수있고, 두번째 printf는 바로 실행되는것을 볼수 있습니다. 이러한 lazy binding을 쓰지 않는다면 한 실행화일을 실행할때 모든 필요한 shared library를 미리 올려놓을수도 있습니다. 이를 위해 (아마LD_PRELOAD?) LD_BIND_NOW=1 과 같은 옵션이 있습니다. 이렇게하면 최초 로딩시간이 좀 걸리겠지만 일단 로딩된 후엔 statically linking된것과 같이 실행될수 있겠죠.

dynamic libaray로 인해 한시스템에서는 하나의 라이브러리만 있으면 모든 프로세스가 쓸수 있게됩니다. 또한 이 기능으로 인해서 부분적인 라이브러리등의 코드의 update가 가능하게됩니다. 모듈이 새로운 버전으로 교체되더라도 모든 프로그램이 자동적으로 새로운 코드를 쓸수 있게됩니다. 이런 dynamic linking없이는 모든 프로그램이 다시 link되어야 했었겠지요. 그러나 이를 위해서는 또한 각 프로그램과 라이브러리가 버전정보를 가지고 버전관리를 해줘야하는 단점도 있습니다. 새로운 버전의 코드가 구버전과 호환이 되지 않을수 있기때문이죠. 이에따라서 major version과 minor version의 방식이 도입되었습니다. 여러개의 라이브러리가 메모리에 올라올수도 있고 그래서 각 프로그램이 자신이 완하는 버전을 사용할수 있습니다. 이런 시스템을 shared library(dynamic library)라고 합니다.

Dynamic linker

코드를 담는 화일은 크게 세가지가 있습니다. object화일이라 부르는 .o , DSO(Dynamic shared object)혹은 DLL이라고도 불리는 .so 즉 shared library 그리고 실행화일인 executable이죠. 링커/로더를 이해하기 앞서서 이들을 이해해봅시다.

일반적으로 .o 화일은 global(전역변수혹은 함수)로의 접근은 실제 주소가 없이 단지 offset상태로 남아있는 상태입니다. 변수들의 실제 주소가 없기때문이지요. 따라서 .o 는 이상태로 메모리에 올릴수가 없습니다. 메모리에 올리기 위해서 변수에 주소가 생기게 되면 해당 global로의 reference들은 모두 이 주소로 patch (또는 fixup)되어야 합니다. 이 과정을 relocation이라고 합니다. 이를 위해서 .o화일은 어느 지점에서 어느 global로의 reference가 되고있는지를 따로 기록하고 있습니다. 따라서 global들이 주소를 가지게될때 해당 global로의 reference들이 patch될 수 있고 비로소 메모리에 올라갈수 있는 상태가 됩니다. 따라서 이런 relocation이 가능하게 하는 정보들을 가지고있는 화일을 relocatable이라고 부릅니다. 물론 .o는 relocatable이고 때때로 relocatable file이라고 부릅니다. 즉 relocatable하다는 것은 코드의 어디에서 어느 global에 대한 reference가 있는지에 대한 정보를 가지고 있다는 것입니다. .o들을 모아서 executable이나 DSO를 만들게 되면 global들에게 주소를 주는 일(relocation)을 하게 되는데 이를 통해서 이들은 메모리에 올릴수 있는 상태가 됩니다. 이렇게 만들어진 화일은 메모리에 올릴수는 있지만 relocation당시의 고정된 주소에만 올릴수 있다는 단점이 있습니다. 만약 다른주소에 올리고 싶다면? 다시 relocation작업을 해야합니다. 이와 반대로 메모리의 어느 주소에도 올릴수 있는 코드를 PIC이라고 합니다. 예를들어 PC-relative addressing을 쓰면 PIC코드가 됩니다. PIC은 indirect jump나 indirect access를 이용해서 구현하는데 따라서 약간의 성능저하가 있을수 있습니다. 이러한 PIC은 relocatable과는 다르게 relocation이 필요없습니다.

예를들어 윈도우의 DLL은 relocatable인데 약간은 빠를지도 모르지만, 원래의 주소에 올리지 못할때에는 링커가 relocation을 새로 해야하는 부담이 있습니다. 리눅스는 반대로 PIC코드를 쓰기때문에 상관없이 올릴수 있습니다. 또한 DLL은 새롭게 relocation을 하면 코드가 원래 코드와는 달라지기 때문에 하나의 이미지를 공유하지 못하고 추가적인 메모리를 사용해야 합니다. 윈도우는 COW와 유사한 방식으로 원래 코드에서 새롭게 relocate된 코드를 생성해냅니다. PIC코드는 이러한 단점이 없으므로 단일한 코드를 모두가 공유할수 있게됩니다. 즉 코드를 수정하지 않는다는 PIC의 특징으로 인한 장점들입니다.

PIC이 되기 위해서는 모든 접근이 상대주소를 사용해야합니다. global들로의 접근이 상대주소로 접근되어야 하는것입니다. 하지만 x86에서는 이것이 쉽지 않습니다. 따라서 이를 위해서 소프트웨어적인 방식이 요구됩니다. Linux에서는 이를 위해 GOT(Global Offset Table)을 사용합니다. 각 executable이나 DSO는 GOT라는 테이블을 가지는데, 이는 단순히 자신이 접근하고자하는 global들의 주소입니다. dynamic linker가 필요할때마다 이 GOT테이블을 채워주면 그것을 indirect방식으로 이용하는 것입니다. 이를 통해서 실제 바뀔수 있는 주소들은 GOT에만 존재하고 실제 코드는 수정될 필요없이 어느 주소에도 올라갈수 있습니다. 다만 함수의 경우 PLT라는 추가적인 단계를 거쳐서 GOT에 접근합니다. 따라서 PLT는 코드에 속해있고 GOT는 데이터에 속해있습니다. 이때 한가지 문제는 GOT의 주소를 어떻게 코드에서 구할수 있는가입니다. PIC은 절대주소에 의지할수 없죠. 이를 위해서 코드와 데이터가 항상 붙어있다는 점(혹은 그 거리가 상수라는 점)을 이용합니다. 즉 코드의 한 지점에서 GOT까지의 거리는 컴파일시간에 상수로 알려진다는 점을 이용해서 자신의 현재주소(eip)에 이 상수를 더함으로써 GOT의 주소를 알아냅니다. 보통 이 GOT 베이스주소를 ebx레지스터에 넣어서 사용합니다. 이로 인해서 PIC은 하나의 레지스터를 잃게되는 단점이 있습니다.

executable이나 DSO가 relocate되거나 PIC이 되면 relocation들(뒤에볼 .rel.text같은 섹션)은 모두 없어집니다. global들로의 주소는 relocate이나 PIC으로 해결한다고해도 외부모듈로의 접근인 external들은 어떻게 되는걸까요? 이것은 dynamic library의 구현이 PIC이나 relocation과는 또다른 문제임을 보여줍니다. 예를들어 리눅스에서 executable은 보통 PIC이 아닌 relocatable입니다. (물론 PIC으로 만들수도 있습니다. 이럴때는 PIE(position-independent executable)이라고 부릅니다) 이런 경우에도 역시 printf로의 호출과 같은 external들은 해결되어야할 문제입니다. 리눅스에서는 이것들 역시 GOT를 통해서 구현됩니다. (TODO윈도에서는?) 즉 다른 library로의 접근 즉 external들은 PLT나 GOT로의 접근으로 바뀝니다. printf@plt 와 같은 이름으로 바뀌게 되죠. 즉 지금가지의 문제는 결국 두단계의 문제입니다. 이제 global들에 대한 참조를 해결해야했던 첫번째 문제는 PIC으로 해결하였으니 두번째 문제인 printf같은 external의 해결을 위해서 dynamic library를 살펴봅시다.

그 자세한 구현을 살펴보려면 위의 세가지 형식을 담는 ELF 포맷에 익숙해져야 합니다. 이들을 readelf로 살펴봅시다. readelf -h /bin/ls 해보면...

ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Intel 80386
  Version:                           0x1
  Entry point address:               0x8049a80
  Start of program headers:          52 (bytes into file)
  Start of section headers:          91256 (bytes into file)
  Flags:                             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         8
  Size of section headers:           40 (bytes)
  Number of section headers:         28
  Section header string table index: 27

machine과 class에서 32bit x86임을 알수 있고, entry point가 있는것을 알수 있죠. 이 entry는 _start 를 말합니다. 실행화일이 로딩된후에 시작하는 첫번째 주소죠. (사실 dynamic linker가 먼저 실행된다고도 할수 있겠죠?) TODO CRT. 마찬가지로 readelf -h /usr/lib/crt1.o 과 readelf -h /lib/libc-2.11.1.so 도 해보면, 각각 type이 EXEC (Executable file), REL (Relocatable file) , DYN (Shared object file) 임을 알수 있습니다. relocatable들은 처리해야할 relocation들이 남아있고, executable의 경우 relocation들을 처리하고 dynamic library로가는 external들(unresolved symbol)만이 남은 상태입니다. executable은 entry point address가 있고 relocatble file (.o file)은 0으로 되어있음을 알수 있습니다. (Q 근데 .so 의 entry point address 는 무엇? ) 그다음으로 program header와 section header가 있습니다. elf는 기본적으로 섹션들의 모임입니다. readelf -S /bin/ls 로 살펴볼수 있습니다.

There are 28 section headers, starting at offset 0x16478:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .interp           PROGBITS        08048134 000134 000013 00   A  0   0  1
  [ 2] .note.ABI-tag     NOTE            08048148 000148 000020 00   A  0   0  4
  [ 3] .hash             HASH            08048168 000168 00032c 04   A  5   0  4
  [ 4] .gnu.hash         GNU_HASH        08048494 000494 00005c 04   A  5   0  4
  [ 5] .dynsym           DYNSYM          080484f0 0004f0 000680 10   A  6   1  4
  [ 6] .dynstr           STRTAB          08048b70 000b70 000477 00   A  0   0  1
  [ 7] .gnu.version      VERSYM          08048fe8 000fe8 0000d0 02   A  5   0  2
  [ 8] .gnu.version_r    VERNEED         080490b8 0010b8 0000c0 00   A  6   3  4
  [ 9] .rel.dyn          REL             08049178 001178 000028 08   A  5   0  4
  [10] .rel.plt          REL             080491a0 0011a0 0002e0 08   A  5  12  4
  [11] .init             PROGBITS        08049480 001480 000030 00  AX  0   0  4
  [12] .plt              PROGBITS        080494b0 0014b0 0005d0 04  AX  0   0  4
  [13] .text             PROGBITS        08049a80 001a80 0104bc 00  AX  0   0 16
  [14] .fini             PROGBITS        08059f3c 011f3c 00001c 00  AX  0   0  4
  [15] .rodata           PROGBITS        08059f60 011f60 003e4c 00   A  0   0 32
  [16] .eh_frame_hdr     PROGBITS        0805ddac 015dac 00002c 00   A  0   0  4
  [17] .eh_frame         PROGBITS        0805ddd8 015dd8 0000cc 00   A  0   0  4
  [18] .ctors            PROGBITS        0805e000 016000 000008 00  WA  0   0  4
  [19] .dtors            PROGBITS        0805e008 016008 000008 00  WA  0   0  4
  [20] .jcr              PROGBITS        0805e010 016010 000004 00  WA  0   0  4
  [21] .dynamic          DYNAMIC         0805e014 016014 0000e8 08  WA  6   0  4
  [22] .got              PROGBITS        0805e0fc 0160fc 000008 04  WA  0   0  4
  [23] .got.plt          PROGBITS        0805e104 016104 00017c 04  WA  0   0  4
  [24] .data             PROGBITS        0805e280 016280 000110 00  WA  0   0 32
  [25] .bss              NOBITS          0805e3a0 016390 00046c 00  WA  0   0 32
  [26] .gnu_debuglink    PROGBITS        00000000 016390 000008 00      0   0  1
  [27] .shstrtab         STRTAB          00000000 016398 0000df 00      0   0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings)
  I (info), L (link order), G (group), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

섹션은 코드가 들어가는 .text 전역변수들이 있는 .data 상수변수나 문자열이 있는 .rodata 0으로 초기화되는 영역 .bss 같은것들입니다. 그외에 .stab .stabstr과 같은 디버깅정보, .comment .note와 같은 코멘트정보들이 있습니다. 심볼테이블인 .symtab 그리고 문자열테이블인 .strtab등이 있습니다. 이 섹션에 대한 헤더가 섹션헤더이고 그 테이블이 SHT (section header table)입니다. 여기서는 28개의 섹션이 있음을 알수 있죠. .shstrtab은 섹션들의 이름이 담겨있습니다. 실제 섹션헤더의 name부분에는 .shstrtab으로의 오프셋이 담깁니다. .hash , .dynsym, .dynstr 는 dynamic linker가 쓰는 심볼테이블과 스트링테이블입니다. hash는 심볼을 .dynsym에서 빨리 찾기위한 해시이고요. 이것들은 실제 메모리에 올라서 사용됩니다. 반면 나중에 보게될 디버깅 정보인 실제 .symtab과 .strtab은 메모리에 올라오지 않습니다. 주소란이 0이죠. type을 보면 PROGBITS는 프로그램의 내용이란 뜻이고 NOBITS는 실제로 공간을 차지하지 않는다는것이죠. (bss가 그렇죠) 그외 SYMTAB, STRTAB은 심볼테이블과 스트링테이블이고 REL, RELA는 relocation들이 들어있는 섹션이란 뜻입니다. DYNAMIC, HASH는 dynamic linker관련 섹션으로, 아래에서 얘기합니다. flag는 직관적이니 생략합니다. .text .data .rodata .bss등의 flag를 보면 이해가 가죠. .rel.text .rel.data .rel.rodata 등은 relocation 섹션들입니다. 가끔 ALLOC flag가 켜있는 경우가 있습니다. TODO .init과 .fini 는 C에서는 쓰이지 않고 C++이 쓰는 섹션입니다. .interp 는 인터프리터 (주로 dynamic linker)를 지정하기 위한 섹션입니다. 역시 아래에서 설명. 그외의 디버깅용 .debug .line .comment .note 등.

이러한 섹션은 화일상에 있는것이고, 이들이 실제로 메모리에 올라와서 실행될때 그들의 모임을 세그먼트라고 부릅니다. 즉 OS가 보는 세그먼트입니다. 자연스럽게 리눅스에서는 VMA하나를 뜻하게 됩니다. 그래서 세그먼트(VMA)하나에 여러 섹션이 들어갈수 있습니다. 어느 섹션이 어느 세그먼트에 들어갈지를 나타내는것이 PHT(Program header table)입니다. readelf -l /bin/ls 로 확인할수 있습니다. TODO그림. 이처럼 elf는 컴파일러나 링커등은 섹션의 집합체로 처리하고 로더는 세그먼트의 집합으로 보는 경향이 있습니다. 그래서 relocatable의 경우 SHT를 가지고 executable은 PHT를 가지며 shared object file의 경우에는 둘다 가지게 되는 것이죠.

# readelf  -l /bin/ls

Elf file type is EXEC (Executable file)
Entry point 0x8049a80
There are 8 program headers, starting at offset 52

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  PHDR           0x000034 0x08048034 0x08048034 0x00100 0x00100 R E 0x4
  INTERP         0x000134 0x08048134 0x08048134 0x00013 0x00013 R   0x1
      [Requesting program interpreter: /lib/ld-linux.so.2]
  LOAD           0x000000 0x08048000 0x08048000 0x15ea4 0x15ea4 R E 0x1000
  LOAD           0x016000 0x0805e000 0x0805e000 0x00390 0x0080c RW  0x1000
  DYNAMIC        0x016014 0x0805e014 0x0805e014 0x000e8 0x000e8 RW  0x4
  NOTE           0x000148 0x08048148 0x08048148 0x00020 0x00020 R   0x4
  GNU_EH_FRAME   0x015dac 0x0805ddac 0x0805ddac 0x0002c 0x0002c R   0x4
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x4

 Section to Segment mapping:
  Segment Sections...
   00
   01     .interp
   02     .interp .note.ABI-tag .hash .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame_hdr .eh_frame
   03     .ctors .dtors .jcr .dynamic .got .got.plt .data .bss
   04     .dynamic
   05     .note.ABI-tag
   06     .eh_frame_hdr
   07

여기서는 8개의 세그먼트가 있다는것을 볼수 있습니다. 위의 Program Headers에는 각 세그먼트에 대한 정보가, 그리고 아래의 매핑에는 각 세그먼트에 매핑되어있는 섹션들을 볼수 있습니다. 세그먼트는 실제 메모리상에서의 모습이라고 할수 있습니다. 따라서 .o 화일에는 PHT가 없습니다. PHDR는 프로그램헤더 자신을 말합니다. 0x34에서부터 0x100바이트를 차지하고있네요. INTERP는 이 elf를 실행하기 위해서 필요한 로더를 나타냅니다. 단순한 아스키 문자열입니다. 여기서는 /lib/ld-linux.so.2 를 지정하고 있습니다. LOAD 세그먼트가 실제로 메모리상에 올라오는 세그먼트들입니다. Offset부터 시작해서 FileSiz만큼의 바이트를 읽어서 올립니다. 물론 실제로는 on-demand로 읽히죠. 여기서는 두개의 세그먼트를 로드하죠. 첫번째는 코드, 두번째는 데이터. 하지만 두번째에서는 FileSiz와 MemSiz가 다른점을 유의하세요. 뒷부분에는 BSS가 들어가기때문입니다. FileSiz는 화일상의 오프셋에서부터 얼마만큼인지를 나타내고 MemSiz는 메모리상에서 얼마만큼을 차지할지를 나타냅니다. DYNAMIC은 .dynamic섹션에 대한 포인터를 가지는데 이 섹션에 dynamic linking에서 필요한 라이브러리나 relocation entry등에 대한 정보가 들어갑니다. 이 섹션을 살펴보면 NEEDED라는 항목에 실행을 위해 요구되는 library가 적혀져있습니다. NOTE는 프로그래머나 링커의 코멘트. GNU_EH_FRAME은 exception handler에 대한 정보로 C++과 링크될때 쓰입니다. GNU_STACK은 스택을 나타내지만, 리눅스가 알아서 처리하게됩니다. 그래서 align하고 flag빼고는 전부 0이군요. ELF 헤더의 다른 정보들은 다른 헤더의 위치와 사이즈등을 명시하고 있어서 ELF포맷자체는 무척 versatile하고 또 확장이 용이합니다. elf포맷에 대한 자세한 사항은 /usr/include/linux/elf.h 나 usr/include/elf.h등을 참조하세요.

다음과 같은 프로그램으로 실제 심볼처리를 살펴봅시다.

#include <stdio.h> #include <errno.h> int global1 = 100; int global2; int main(int argc, char **argv) { printf("global1 = %d!", global1); printf("global2 = %d!", global2); printf("errno=%d\n", errno); return (0); } gcc -c helloelf.c -o helloelf.o gcc helloelf.c -o helloelf

helloelf.o를 보면 PHT가 없는것을 볼수 있고, 섹션들의 주소값이 0인것을 볼수 있습니다. 메모리로 로딩되지 않기때문이죠. 그리고 REL타입의 .rel.text 섹션이 보이고 다음과 같이 9개의 relocation들이 있는것을 볼수 있죠. .text에 대한 relocation들입니다.

Relocation section '.rel.text' at offset 0x400 contains 9 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
00000012  00000801 R_386_32          00000000   global1
0000001d  00000501 R_386_32          00000000   .rodata
00000022  00000a02 R_386_PC32        00000000   printf
00000027  00000b01 R_386_32          00000004   global2
00000032  00000501 R_386_32          00000000   .rodata
00000037  00000a02 R_386_PC32        00000000   printf
0000003c  00000c02 R_386_PC32        00000000   __errno_location
00000049  00000501 R_386_32          00000000   .rodata
0000004e  00000a02 R_386_PC32        00000000   printf

.text의 offset지점에 Type의 방식으로 fixup하라는 정보입니다. 실제로는 call보다 1씩 큰것을 볼수 있는데, call instruction의 operand를 patch하기때문이죠. TODO R_*_32 R_*_PC32 ( Info?? Sym.value??? ) 이러한 정보들이 relocation을 위해서 쓰입니다. 반면 executable에는 PHT가 있고 .rel.dyn 과 .rel.plt 두개가 있죠. 원래있던 .rel.text는 전부 사라졌음을 알수 있죠. relocation이 된것입니다. 그래서 여기서는 전역변수(global1,global2)가 사라졌음을 볼수 있습니다. 대신 외부 모듈로의 심볼들인 printf나 errno관련된 __errno_location과 같은 심볼들이 reloc으로 들어와있습니다. dynamic linking을 위해서이죠. objdump -d -j .text helloelf.o 를 해보면...

helloelf.o: file format elf32-i386 Disassembly of section .text: 00000000 <main>: 0: 8d 4c 24 04 lea 0x4(%esp),%ecx 4: 83 e4 f0 and $0xfffffff0,%esp 7: ff 71 fc pushl -0x4(%ecx) a: 55 push %ebp b: 89 e5 mov %esp,%ebp d: 51 push %ecx e: 83 ec 14 sub $0x14,%esp 11: a1 00 00 00 00 mov 0x0,%eax 16: 89 44 24 04 mov %eax,0x4(%esp) 1a: c7 04 24 00 00 00 00 movl $0x0,(%esp) 21: e8 fc ff ff ff call 22 <main+0x22> 26: a1 00 00 00 00 mov 0x0,%eax 2b: 89 44 24 04 mov %eax,0x4(%esp) 2f: c7 04 24 0e 00 00 00 movl $0xe,(%esp) 36: e8 fc ff ff ff call 37 <main+0x37> 3b: e8 fc ff ff ff call 3c <main+0x3c> 40: 8b 00 mov (%eax),%eax 42: 89 44 24 04 mov %eax,0x4(%esp) 46: c7 04 24 1c 00 00 00 movl $0x1c,(%esp) 4d: e8 fc ff ff ff call 4e <main+0x4e> 52: b8 00 00 00 00 mov $0x0,%eax 57: 83 c4 14 add $0x14,%esp 5a: 59 pop %ecx 5b: 5d pop %ebp 5c: 8d 61 fc lea -0x4(%ecx),%esp 5f: c3 ret

심볼들이 아직 offset으로 처리된것을 볼수 있습니다. call 4e 와 같은것들이죠. reloc들입니다. 그저 fixup되어야할 지점의 offset으로 남아있군요. 반면 executable에서는 relocation 된후라서 주소가 들어가있는것을 볼수 있습니다.

objdump  -d -j .text helloelf

080483b4 
: 80483b4: 8d 4c 24 04 lea 0x4(%esp),%ecx 80483b8: 83 e4 f0 and $0xfffffff0,%esp 80483bb: ff 71 fc pushl -0x4(%ecx) 80483be: 55 push %ebp 80483bf: 89 e5 mov %esp,%ebp 80483c1: 51 push %ecx 80483c2: 83 ec 14 sub $0x14,%esp 80483c5: a1 1c 96 04 08 mov 0x804961c,%eax 80483ca: 89 44 24 04 mov %eax,0x4(%esp) 80483ce: c7 04 24 e0 84 04 08 movl $0x80484e0,(%esp) 80483d5: e8 3e ff ff ff call 8048318 80483da: a1 24 96 04 08 mov 0x8049624,%eax 80483df: 89 44 24 04 mov %eax,0x4(%esp) 80483e3: c7 04 24 ee 84 04 08 movl $0x80484ee,(%esp) 80483ea: e8 29 ff ff ff call 8048318 80483ef: e8 f4 fe ff ff call 80482e8 <__errno_location@plt> 80483f4: 8b 00 mov (%eax),%eax 80483f6: 89 44 24 04 mov %eax,0x4(%esp) 80483fa: c7 04 24 fc 84 04 08 movl $0x80484fc,(%esp) 8048401: e8 12 ff ff ff call 8048318 8048406: b8 00 00 00 00 mov $0x0,%eax 804840b: 83 c4 14 add $0x14,%esp 804840e: 59 pop %ecx 804840f: 5d pop %ebp 8048410: 8d 61 fc lea -0x4(%ecx),%esp 8048413: c3 ret

printf@plt 나 혹은 __errno_location@plt 처럼 PLT로의 주소로 바뀌었습니다. objdump -d -j .data helloelf.o 해보면 global1에는 0x64라는 값이 들어간것이 보입니다. 반면 objdump -d -j .data helloelf 해보면 이제는 주소까지 들어간것이 보이죠. objdump -d -j .bss test 로 bss를 살펴보면 .o에서는 비어있는 반면 executable에서는 주소가 보입니다. 이제 segment를 보면,

#readelf -l helloelf

Elf file type is EXEC (Executable file)
Entry point 0x8048330
There are 7 program headers, starting at offset 52

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  PHDR           0x000034 0x08048034 0x08048034 0x000e0 0x000e0 R E 0x4
  INTERP         0x000114 0x08048114 0x08048114 0x00013 0x00013 R   0x1
      [Requesting program interpreter: /lib/ld-linux.so.2]
  LOAD           0x000000 0x08048000 0x08048000 0x0050c 0x0050c R E 0x1000
  LOAD           0x00050c 0x0804950c 0x0804950c 0x00114 0x0011c RW  0x1000
  DYNAMIC        0x000520 0x08049520 0x08049520 0x000d0 0x000d0 RW  0x4
  NOTE           0x000128 0x08048128 0x08048128 0x00020 0x00020 R   0x4
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x4

 Section to Segment mapping:
  Segment Sections...
   00
   01     .interp
   02     .interp .note.ABI-tag .hash .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame
   03     .ctors .dtors .jcr .dynamic .got .got.plt .data .bss
   04     .dynamic
   05     .note.ABI-tag
   06

.text가 들어가는 2번 세그먼트를 보면, flag가 R,E로 readable, executable입니다. W는 writable. VirtAddr보면 어디에 붙는지 알수있죠. PhysAddr는 무시합니다. 이제 /proc/pid/maps 로 실제상황봅시다. (gdb로 멈춰놓고 봅니다)

root@workplace:~# cat /proc/30478/maps
08048000-08049000 r-xp 00000000 08:01 249021     /root/helloelf
08049000-0804a000 rw-p 00000000 08:01 249021     /root/helloelf
b7e80000-b7e81000 rw-p b7e80000 00:00 0
b7e81000-b7fd4000 r-xp 00000000 08:01 419455     /lib/tls/i686/cmov/libc-2.11.1.so
b7fd4000-b7fd5000 ---p 00153000 08:01 419455     /lib/tls/i686/cmov/libc-2.11.1.so
b7fd5000-b7fd7000 r--p 00153000 08:01 419455     /lib/tls/i686/cmov/libc-2.11.1.so
b7fd7000-b7fd8000 rw-p 00155000 08:01 419455     /lib/tls/i686/cmov/libc-2.11.1.so
b7fd8000-b7fdb000 rw-p b7fd8000 00:00 0
b7fe0000-b7fe2000 rw-p b7fe0000 00:00 0
b7fe2000-b7fe3000 r-xp b7fe2000 00:00 0          [vdso]
b7fe3000-b7ffe000 r-xp 00000000 08:01 417808     /lib/ld-2.11.1.so
b7ffe000-b7fff000 r--p 0001a000 08:01 417808     /lib/ld-2.11.1.so
b7fff000-b8000000 rw-p 0001b000 08:01 417808     /lib/ld-2.11.1.so
bffeb000-c0000000 rw-p bffeb000 00:00 0          [stack]

LOAD인 두 섹션을 비교해보면 page-aliged되긴했지만 해당 주소에 매핑되는것을 볼수 있습니다. 두개의 매핑이 보이죠. 이 경우 page문제때문에 하나의 내용에 대해서 두개의 매핑이 된것같습니다. 스택은 커널이 알아서 합니다. 흥미로운것은 이 경우 코드와 데이터가 4K한페이지안에 들어있기 때문에 애초에 ELF에서부터 두개의 LOAD segment가 두페이지로 나누어져있는것을 볼수 있습니다. 하나의 페이지에 코드/데이터 둘다 매핑할수는 없기때문이죠. 즉 실제로는 한페이지이지만 가상공간에 두페이지가 각각 코드용read-only와 데이터용read-write로 매핑하고 있습니다. 이제 PLT(Procedure Linkage Table )와 GOT(Global offset table)를 살펴봅시다. printf호출을 보면, 실제로는

call 8048318 <printf@plt>

와 같은 방식으로 PLT로 갑니다. PLT를 살펴봅시다.

# objdump  -d -j .plt helloelf

helloelf:     file format elf32-i386

Disassembly of section .plt:

080482d8 <__errno_location@plt-0x10>:
 80482d8:       ff 35 f8 95 04 08       pushl  0x80495f8
 80482de:       ff 25 fc 95 04 08       jmp    *0x80495fc
 80482e4:       00 00                   add    %al,(%eax)
        ...

080482e8 <__errno_location@plt>:
 80482e8:       ff 25 00 96 04 08       jmp    *0x8049600
 80482ee:       68 00 00 00 00          push   $0x0
 80482f3:       e9 e0 ff ff ff          jmp    80482d8 <_init+0x30>

080482f8 <__gmon_start__@plt>:
 80482f8:       ff 25 04 96 04 08       jmp    *0x8049604
 80482fe:       68 08 00 00 00          push   $0x8
 8048303:       e9 d0 ff ff ff          jmp    80482d8 <_init+0x30>

08048308 <__libc_start_main@plt>:
 8048308:       ff 25 08 96 04 08       jmp    *0x8049608
 804830e:       68 10 00 00 00          push   $0x10
 8048313:       e9 c0 ff ff ff          jmp    80482d8 <_init+0x30>

08048318 :
 8048318:       ff 25 0c 96 04 08       jmp    *0x804960c
 804831e:       68 18 00 00 00          push   $0x18
 8048323:       e9 b0 ff ff ff          jmp    80482d8 <_init+0x30>

보다시피 PLT는 이러한 jump table입니다. 여기서 printf@plt는 GOT의 어떤 엔트리에 기록된 주소로 점프합니다. indirect jump죠. GOT는 그냥 포인터의 배열입니다. 이제 got의 해당 주소(0x804960c)를 보면 0x804831e 가 기록되어있네요. 즉 jmp 문 바로 다음 push명령입니다. 결국 아무한일없이 다음 주소로 넘어갔을뿐입니다. 이제 0x80482d8 로 점프하죠. 여기가 dynamic linker입니다. dynamic linker는 dlopen()/dlclose()등으로 라이브러리를 열고 해당 주소를 찾아내서 앞의 GOT의 해당 엔트리에 저장해넣습니다. 따라서 두번째 부터는 PLT의 첫번째 jmp문이 직접 printf로 점프하게 됩니다. GOT는 처음에는 PLT의 바로 다음 주소를 가지고있다가 dynamic linker가 새로운 주소로 채워넣는 방식입니다. gdb로도 확인하실수 있죠. 물론 dynamic linker는 한번의 push된 0x18이라는 값을 받게 됩니다. 그리고 printf를 직접 한번은 수행해줘야하죠. 이처럼 indirect call로 library call을 구현합니다. C++등의 OOP언어의 polymorphism구현을 위한 VMT(virtual method table)과 함께 이 library call이 indirect call의 주요한 사용처입니다. 이렇게 lazy-binding을 구현하는데, 불필요한 바인딩은 하지 않는다는 장점이 있습니다. 반면 모든 바인딩을 미리 해놓기를 원한다면 LD_BIND_NOW=1 로 환경변수를 설정하면 됩니다. 디버깅을 한다던지할때 유용하겠죠. 이러한 기법으로 .plt와 .text는 read-only가 됩니다. GOT는 물론 함수뿐 아니라 변수에 대한 주소까지 가지고 있습니다. 따라서 그런 변수에 접근할때는 GOT내에서의 오프셋으로 지정합니다. TODO.. .dynamic은 dynamic loader가 직접 사용하는 SHT의 간단한 버전입니다. 프로그램헤더에서 DYNAMIC 세그먼트내에는 바로 이 .dynamic 섹션에 대한 포인터가 포함되어있습니다. 따라서 링커는 SHT는 필요없게됩니다. 위의 dynamic linker의 위치는 .plt 의 위치입니다. plt의 첫번째 엔트리 직전에 0x10바이트만큼이군요. 이부분을 살펴보려면 gdb를 이용해야합니다. (TODO..dynamic linker로의 점프..init에서의 초기화..) 실제 entry point인 _start에서 main까지의 흐름을 보면

08048330 <_start>:
 8048330:       31 ed                   xor    %ebp,%ebp
 8048332:       5e                      pop    %esi
 8048333:       89 e1                   mov    %esp,%ecx
 8048335:       83 e4 f0                and    $0xfffffff0,%esp
 8048338:       50                      push   %eax
 8048339:       54                      push   %esp
 804833a:       52                      push   %edx
 804833b:       68 20 84 04 08          push   $0x8048420
 8048340:       68 30 84 04 08          push   $0x8048430
 8048345:       51                      push   %ecx
 8048346:       56                      push   %esi
 8048347:       68 b4 83 04 08          push   $0x80483b4
 804834c:       e8 b7 ff ff ff          call   8048308 <__libc_start_main@plt>
 8048351:       f4                      hlt

결국엔 __libc_start_main으로 갑니다. TODO 이제 .init을 살펴봅시다.

# objdump  -d -j .init helloelf

helloelf:     file format elf32-i386

Disassembly of section .init:

080482a8 <_init>:
 80482a8:       55                      push   %ebp
 80482a9:       89 e5                   mov    %esp,%ebp
 80482ab:       53                      push   %ebx
 80482ac:       83 ec 04                sub    $0x4,%esp
 80482af:       e8 00 00 00 00          call   80482b4 <_init+0xc>
 80482b4:       5b                      pop    %ebx
 80482b5:       81 c3 40 13 00 00       add    $0x1340,%ebx
 80482bb:       8b 93 fc ff ff ff       mov    -0x4(%ebx),%edx
 80482c1:       85 d2                   test   %edx,%edx
 80482c3:       74 05                   je     80482ca <_init+0x22>
 80482c5:       e8 2e 00 00 00          call   80482f8 <__gmon_start__@plt>
 80482ca:       e8 c1 00 00 00          call   8048390 
 80482cf:       e8 bc 01 00 00          call   8048490 <__do_global_ctors_aux>
 80482d4:       58                      pop    %eax
 80482d5:       5b                      pop    %ebx
 80482d6:       c9                      leave
 80482d7:       c3                      ret

몇가지 초기화를 하네요. TODO. 간단한 shared library를 만들어봅시다. 위의 helloelf.c 적당한곳에 두줄을 추가합니다.

int hellolib(void);
printf("Hellolib=%d\n", hellolib() );

shared library를 호출하는 코드죠. 그리고 실제 코드인 hellolib.c 를 다음처럼 만듭니다.

int hellolib(void)
{
        return 200;
}

초간단버전이로군요. 다음처럼 .o를 만들고 .so를 만들고 helloelf.c를 다시 컴파일한후 hellolib과 묶어서 executable을 만들어냅니다.

        gcc -fpic -c hellolib.c -o hellolib.o
        gcc -shared -o libhellolib.so hellolib.o
        gcc -c helloelf.c
        gcc -o helloelf helloelf.o -L. -lhellolib

간단하죠. 그냥 -fpic으로 컴파일하고 -shared 로 링크해버립니다. -lhellolib을 하면 링커는 libhellolib.so 를 찾게됩니다. -L. 을 주어야 현재 디렉토리에서 검색을 해서 찾게됩니다. 이렇게 하면,

# ./helloelf
./helloelf: error while loading shared libraries: libhellolib.so: cannot open shared object file: No such file or directory

실제 실행할때는 역시 libhellolib.so를 못찾는군요. LD_LIBRARY_PATH가 설정안되어서 그러니

# LD_LIBRARY_PATH=. ./helloelf

로 실행하면 됩니다. 이제 dlopen()/dlclose()를 살펴봅시다. 다음과 같이 dlopen.c 를 만들고

#include <stdio.h> #include <stdlib.h> #include <dlfcn.h> int main(void) { int (*hello)(void); void *lib; lib = dlopen("./libhellolib.so", RTLD_LAZY); if(!lib) exit(0); hello = dlsym(lib, "hellolib"); if (hello) { printf("hellolib=%d\n", (*hello)() ); } dlclose(lib); }
gcc -o dlopen dlopen.c -ldl

로 컴파일해봅시다. dlsym() 은 심볼에 해당하는 주소를 리턴해줍니다. 자세한 사항은 역시 구글에게 물으시길..TODO


Synchronization #1

 

Abstract

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

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

그러나 한편으로는, 여러 쓰레드가 위와같이 공유하는 데이터라 할지라도 한순간에 단 하나의 쓰레드만이 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을 생각해보세요.) (뒤에 나올 RCU같은 경우에 관련해서 생각해 봅시다.)

이제 쓰레드를 생각해보겠습니다. 쓰레드라는 단어 역시 여러 가지 의미를 가질 수 있기 때문에, 이 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문제는 특정 상황이나 용어의 문제가 아닌 논리의 문제입니다.

 

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이 붙은 것과 같다고 생각할 수 있습니다. xchg는 과거의 spinlock을 구현하던 코드가 사용했기때문에 그 호환성을 유지하기 위해서 lock prefix가 붙은것으로 간주됩니다.

 

 

Bounded Buffer producer-consumer problem

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

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

 

#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을 써서 해결합니다. (그러나 구현이 그리 녹록하지 않군요. "semaphore implementation"편에서 얘기하죠.) 결국 웃기게도 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 때문입니다. 단지 busy-waiting을 wait/signal 함수의 짧은 구간에 국한시켰다는것뿐입니다. 비록 그런일이 실제로는 거의 일어나지 않는다고 해도 이론적으로 문제는 있을 수 있습니다. lock contention이 극심한 경우를 생각해봅시다. ...생략...

 

Bakery algorithm

세마포어는 기본적으로 HW지원에 기반하고 있습니다. 요즘같은 시절에 실제로 쓸일은 없겠지만, 과거 사람들이 고민했던, semaphore를 사용하지 않을 때 software만으로 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가지 조건을 만족하게 됩니다. 이를 Dekker's algorithm이라고 합니다. (또는 Peterson's algorithm. Peterson이 원래 Dekker's algorithm을 간단하게 만들었습니다.) 처음으로 critical section문제를 풀어낸 알고리즘이었습니다. 하지만 2개의 쓰레드로 제한된다는 점이 문제죠.

이 세 번째 알고리즘이 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;

 

이 알고리즘은 choosing과 number는 모두 false/0 으로 초기화되어있습니다.

 

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;


이 bakery algorithm에서 choosing이 왜 필요한가? 라는 질문이 있을 수 있습니다. 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같은 변수를 따로 가지고 있어야 할테고요. bakery algorithm은 기본적으로 distributed 환경에서 사용되기 위해서 만들어진 알고리즘이기때문입니다. 로컬에서 쓰기에는 적당하지 않겠네요.

아무래도 S/W적인 방법으로는 critical section문제를 해결하기가 어려워보입니다. 따라서 H/W가 synchronization을 위한 instruction들을 제공해줘야할 이유가 있다고 생각됩니다. 그리고 우리는 이런 HW에 기반해서 세마포어를 동기화 문제를 위한 primitive로 보통 사용합니다. 그러나 세마포어만이 모든 방법은 아니라는것을 보여주는 예를 다음에 보이겠습니다. 즉, HW instruction위에서 바로 구현해봅시다.

waiting[i] = true;
key = true;
while (waiting[i] && key)
    key = TestAndSet(lock);
waiting[i] = false;

    critical section

j = (i+1)%n;
while ((j != i) && !waiting[j])
    j = (j+1) % n;
if (j == i)
    lock = false;
else
    waiting[j] = false;

여기서는 처음에 boolean waiting[n]; 과 boolean lock; 두개의 global variable이 모두 false로 초기화되어있습니다. 세마포어를 쓰지않고 critical section을 구현한 예제입니다.

 

Memory model

좀 이른 감이 있지만 여기서 memory model (memory consistency) 에 대해 이야기해봅시다. 예전의 in-order CPU들은 instruction 이 들어오는 순서대로 (이를 program order라고 합니다. C코드가 아닌 어셈코드에서 instruction이 배열된 순서를 말하죠. 컴파일러가 최적화후에 생성한 코드임에 주의하세요) 메모리 read/write를 수행했습니다. 이를 sequential consistency라고 합니다. 하지만 최근 CPU들은 성능을 위해서 memory reordering을 합니다. 즉 read/write가 마구 뒤섞을수가 있습니다. 이에 따라 각 아키텍쳐는 (relaxed) memory consistency를 가지고 있고, 과거의 sequential consistency는 옛말이 되어버리고 말았습니다. 예를들어 x86은 processor order라고 하는 consistency를 정의하고 있습니다. 이제 그 위에서 각 언어들 Java, C, C++ 역시 자신만의 memory model을 정의하고 있습니다. 그 바탕에는 memory consistency가 있습니다. 이에 따라 과거의 sequential consistency에 기대던 일부 low-level 알고리즘들은 깨어지게 됩니다. 앞서 살펴본 Dekker's algorithm이 그런 경우입니다. 이 알고리즘은 sequential consistency를 가정하고 있기때문에 memory reordering을 하는 많은 현대 CPU에서는 깨어지고 맙니다. Intel이 가진 processor order역시 sequential보다 완화된 consistency이고 이때문에 이 알고리즘은 작동하지 않습니다. 여기에서는 실제로 Dekker's algorithm이 깨어짐을 보여주고 있네요. 따라서 이를 수정하기 위해서는 memory barrier를 쳐야합니다.

그나마 Dekker's algorithm은 교과서에나 나오는 알고리즘이지만 흔히들 lazy initialization를 위해 쓰이는 double checked locking과 같은 기법 역시 sequential consistency를 가정하기 때문에 최근의 CPU에서는 깨질수가 있습니다. 여기여기를 참고하세요.

물론 OS나 언어가 제공하는 primitive들을 사용할때에는 보통 신경쓸 필요가 없습니다. 이러한 memory model에 큰 영향을 받는 코드는 주로 low-level의 코드들입니다. 언어 런타임, 컴파일러, 커널등이죠. (물론 application레벨에서 스스로 lock코드를 작성할때도.) 대표적으로 spinlock과 같은 lock들이 있습니다. 당연히 기존의 이러한 코드를 지켜주기 위해서 기본적으로 spinlock등은 memory fence를 겸하게 됩니다. atomic instruction들, 즉 x86의 lock prefix나 xchg 명령같은 경우가 그렇습니다. (아 그리고 cpuid 명령 역시 fence역할을 합니다.) 따라서 대부분의 코드들이 무리없이 동작할수 있습니다. 물론 앞서의 Dekker's algorithm같은 예외가 있습니다. 그외에도 또한 뒤에서 살펴볼 lock-free code같은 경우가 memory model에 아주 민감한 경우라고 할수 있습니다.

따라서 sequential consistency에 의존하는 그러한 코드들은 memory fence를 쳐서 지켜줘야합니다. Intel에는 3가지 타입의 fence instruction이 있습니다. read/write모두 넘지 못하는 mfence (memory fence), read가 넘지 못하는 lfence(load fence), write가 넘지 못하는 sfence(store fence)이죠. TODO... TAS 와 T&TAS , MCS, 역시 TODO...

물론 그외에도 컴파일러 역시 loop invariant등을 뽑아내는 최적화를 할수 있기때문에 이를 방지하기 위해서 volatile키워드를 넣어주어야 합니다. 물론 C/C++의 volatile은 컴파일러 최적화만을 방지하기 때문에 memory fence역시 직접 쳐줘야합니다. C#이나 Java 의 volatile, 또는 C++0x의 atomic variable은 memory fence역할까지 하지요.

Semaphore implementation

이론적인 내용은 이제 그만하고, 실제적인 사용법을 살펴보면, Application은 OS에게 세마포어(정확히는 SysV방식을 따라서 set of semaphore) 를 요청하게되고(semop system call, semop() library call참고), 이렇게 획득된 세마포어를 이용해서 IPC를 할 수 있게됩니다. 다른 2개의 IPC방식(Shared memory , Message Queue)과 함께 3가지 IPC방식이죠. 이 세마포어는 사용하기 어렵기로 악명높습니다. 누군가의 말을 빌어보자면, "A few general comments. Semaphores are one of the truly evil inventions of computer science. Hard to implement, hard to use. Prone to all kinds of errors."라고 할 수 있겠습니다.

어쨌거나 그 구현을 위해서는 signal()/wait()를 구현하는 것이 핵심인데, 간단하게 sleep()/wakeup() 정도만 커널에서 지원해주는 것으로 user-level에서 구현할 수 있을까요? 쉽지 않겠죠? 네, 스케쥴러에게까지 critical section이 닿아있어야함을 알 수 있습니다. 사실 semaphore는 스케쥴러와 상당히 붙어있는 관계입니다. lost wakeup을 피하기가 쉽지 않다는 것을 아실수 있을 것입니다. 결국 커널이 세마포어를 지원해줘야하겠네요.

 

class semaphore {
    private int count;

    public semaphore (int init)
    {
        count = init;
    }

    public void P ()
    {
        while (1) {
              Disable interrupts;
              if (count > 0) {
                   count--;
                   Enable interrupts;
              } else {
                   Enable interrupts;
              }
        }
    }

    public void V ()
    {
        Disable interrupts;
        count++;
        Enable interrupts;
    }
}
(from  http://pages.cs.wisc.edu/~bart/537/lecturenotes/s10.html)
 

UP환경에서 위의 코드를 생각해봅니다. 당장 cli/sti를 spinlock대용으로 쓰고 있습니다. (팝 퀴즈! UP에서 다른 spinlock을 써도 될까요? 된다면 어떤 영향이 있을까요? ) UP에서는 cli/sti로 충분함을 알 수 있습니다.

 

 

class semaphore {
    private int t;
    private int count;
    private queue q;
        
    public semaphore(int init)
    {
        t = 0;
        count = init;
        q = new queue();
    }

    public void P()
    {
        Disable interrupts;
        while (TAS(t) != 0) { /* just spin */ };
        if (count > 0) {
            count--;
            t = 0;
            Enable interrupts;
            return;
        }
        Add process to q;
        t = 0;
        Enable interrupts;
        Redispatch;
    }

    public V()
    {
        Disable interrupts;
        while (TAS(t) != 0) { /* just spin */ };
        if (q == empty) {
            count++;
        } else {
            Remove first process from q;
            Wake it up;
        }
        t = 0;
        Enable interrupts;
    }
}
(from  http://pages.cs.wisc.edu/~bart/537/lecturenotes/s10.html)

 

MP환경에서의 구현입니다. TAS()는 test_and_set()이죠. int t 라고하는 spinlock하기 위한 변수가 세마포어에 하나 더 추가되었습니다. 이중 locking의 구조가 잘 보이죠. (팝 퀴즈! 이렇게 spinlock을 썼음에도 불구하고 왜 cli/sti를 썼을까요? 안쓴다면 어떤 영향이 있겠습니까?)

 

그럼 이제 Linux에서의 구현을 살펴볼까요?

(생략)

Windows 2000에서는 dispatcher objects를 제공하는데, 이걸 이용해서 쓰레드는 세마포어나 뮤텍스, events 등의 기법들을 통해 동기화를 합니다. events는 condition variables같이 사용되는 동기화 기법입니다. dispatcher object는 signaled상태이거나 nonsignaled상태일수 있습니다. signaled상태는 object가 사용가능하고 취득할때에 블락되지 않을것을 나타냅니다. nonsignaled상태는 object가 가용하지 않으며 취득할때 블락될것을 나타냅니다. 쓰레드가 nonsignaled dispatcher object에 블락될때, 상태가 ready에서 waiting으로 바뀌고 쓰레드는 그 object를 위한 대기큐에 들어갑니다. dispatcher object의 상태가 signaled가 되었을대 커널은 대기중인 쓰레드가 있는지 봐서, 그러면 하나나 또는 여러개의 쓰레드를 waiting에서 ready상태로 놓아서 실행이 될수 있도록 합니다. dispatcher object의 타입에 따라서 몇개의 쓰레드가 깨어날지가 결정됩니다. mutex에 대해서는 하나만, event object에 대해서는 모든 쓰레드를 깨웁니다. 예를들어 한쓰레드가 nonsignaled 상태인 mutex dispatcher object를 취득하려고 하면, 쓰레드는 suspend되서 mutex object에 대한 대기큐에 들어갑니다. mutex가 signaled state가 될때 쓰레드는 ready 상태가 되고 mutex lock을 취득하게 됩니다.

 

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 and 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

고전적인 문제죠. 5명의 철학자가 원탁에 앉아있고 각각 사이에 1개씩의 젓가락이 놓여있고, 철학자들은 가끔씩 먹기위해서 양옆의 젓가락을 집어들고 식사를 한후 내려놓습니다. 젓가락을 집는 행동은 한번에 하나씩밖에 못집고, 다른 철학자가 젓가락을 들고 있을때는 집을수 없습니다. 젓가락은 하나의 resource인데, 각각을 세마포어로 해준다고하면 deadlock이 생길수 있죠. 이를 방지하기위해서 이런것들을 생각해볼수 있습니다. (1) 두개의 chopstick이 모두 사용가능할때만 집어든다. (2) 홀수 철학자는 먼저 왼쪽 젓가락을 집어들고, 짝수 철학자는 먼저 오른쪽 젓가락을 집어든다. 그래도 여전히 문제는, starvation입니다.

 

Critical regions

semaphore는 사용하기가 어렵습니다. wait/signal을 잘못 쓴다든지, 한쪽을 생략하게 된다면 난리나는거죠. 이를 위해서 언어 차원에서 synchronization을 지원하기도 하는데, 여기서는 (conditional) critical region을 살펴봅니다. critical region은 critical section을 language레벨에서 구현해서 컴파일러가 프로그래머대신 세마포어를 사용해주는것입니다.

v: shared T;

region v do S1;

이렇게 v를 shared로 선언하고 region을 만들때 그 변수로 tag를 해둡니다. 컴파일러는 같은 변수로 tag된 모든 region들을 critical section으로 만들어줍니다.

그러나 plain critical region은 세마포어랑 같은게(equivalent) 아닙니다. plain critical region은 critical section과 동일합니다. 즉 critical region은 세마포어가 할수 있는 일들중 일부만을 할수 있는거죠. 세마포어를 좀더 편리하게 만들어보자고 critical region이란걸 만들었더니, 좋긴한데 좀 약하다는거죠. bounded-buffer producer-consumer 문제 같은 경우는 세마포어로는 특정 조건(버퍼가 차거나 비거나)이 만족할때까지 프로세스를 재울수 있지만 critical region으로는 그럴수가 없습니다. 특정조건에 대한 구현을 해주는것이 필요하기에, conditional critical region을 만들었습니다. 좀더 강력하게. 대문(mutex)을 들어왔더라도 한번더 조건을 통과해야하게끔 만들어주는거죠. conditional이란 말이 붙은 것은.. synchronization은 해결을 봤으나 즉, region에는 한번에 하나의 process만이 들어올수 있지만. 이런 경우 먼저 온녀석이 무작정 먼저 들어가버립니다. 우리가 하고싶은것은 먼저 왔더라도 특정 조건이 만족하지 않으면 안들여보내려는 거고요. 어떤 경우에는 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을 피해보겠다는 매커니즘입니다.

 

 

 

여기 요점정리가 된 자료가 있네요. http://www-ist.massey.ac.nz/csnotes/355/lectures/monitors.pdf

(Q) plain은 critical section과 같습니다. conditional region은 그럼 semaphore와 같다고 할 수 있나?

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

 

 

 

Monitors

모니터는 일종의 클래스네요. shared data가 있고 코드들이 있어서 하나로 묶여있습니다. 자바에서의 synchronized 키워드를 연상하시면 되겠습니다. 모니터안에서는 한번에 한 프로세스만이 실행되고 있을수 있습니다. (condition variable을 구현하면 여러 프로세스가 들어올수도 있지만, 그래도 한순간에는 한 프로세스만이 실행됩니다. 앞서서의 conditional critical region과 매우 비슷하네요) 다음과 같은 모습입니다.

      monitor monitor-name
      {
          // shared variable declarations
          procedure P1 (…) { …. }
                … 
          procedure Pn (…) {……} 
          Initialization code ( ….) { … }
                …
      }

모니터안에 정의된 코드들에서는 모니터내의 local변수와 전달받은 parameter밖에 접근할수 없습니다. 마찬가지로 모니터내의 local변수들은 모니터내의 코드에서만 접근가능합니다. 이 방식에도 critical region과 마찬가지로 역시 condition이 빠져있어서 충분히 powerful하지 못합니다. 그래서 나온것이 condition variable입니다. condition x,y; 와 같이 선언하고 x.wait(); 또는 x.signal(); 로 직접 세마포어를 쓸수 있네요. 즉 condition variable은 monitor안에서의 sleep을 돕습니다. 그러나 semaphore와는 조금 다릅니다. 먼저 x.signal()은 정확히 하나의 프로세스를 깨우는데, 만약 자는 프로세스가 없으면 아무것도 하지 않습니다. 예를들어 프로세스 P가 signal()로 Q를 깨웠다면, 모니터 안에서는 하나의 프로세스밖에 있을수 없으므로 P는 잠들어야합니다. 하지만, 개념적으로 Q가 기다리는것도 가능합니다. 두가지 가능성이 있습니다. (1) P가 Q가 모니터를 떠나거나 잠들기 기다린다. (2) Q가 P가 모니터를 떠나거나 잠들기를 기다린다. 첫번째가 Hoare가 선호하던 방식입니다. 두번째 방식은 P가 계속 실행되게 놔두는데, 이경우엔 Q가 실행될때 기다리던 조건이 더이상 맞지 않을수 있기때문에 잠드는 프로세스는 "if not B then wait(c)" 가 아닌 "while not B do delay();" 를 반드시 사용해야합니다. Concurrent C 에서는 그 중간쯤인 immediate resumption방식이 쓰였는데, signal을 한 프로세스는 모니터를 바로 떠납니다.

세마포어와 모니터는 파워가 같다. (세마포어로 구현한다.. simple하면서도 power가 쎄다)

아래에 모니터를 이용한 dining philosopher문제에 대한 deadlock-free 코드입니다.

monitor DP
{
    enum {THINKING, HUNGRY, EATING} state [5] ;
    condition self [5]; 
    void pickup (int i)
     {
           state[i] = HUNGRY;
           test(i);
           if (state[i] != EATING) self[i].wait;
    } 
    void putdown (int i)
    {
           state[i] = THINKING;
           // test left and right neighbors
           test((i + 4) % 5);
           test((i + 1) % 5);
    }
    void test (int i)
    {
           if (state[i] == HUNGRY &&
          state[(i + 4) % 5] != EATING &&     
          state[(i + 1) % 5] != EATING)
            {
                  state[i] = EATING ;
               self[i].signal () ;
            }
     } 
    initialization_code()
    {
           for (int i = 0; i < 5; i++)
           state[i] = THINKING;
    }
} // end of monitor

이 해법에서는 양옆의 두 포크가 모두 사용가능할때만 포크를 집어들고 먹습니다. 따라서 test()에서 양옆의 철학자가 먹고있지 않을때 포크를 집고 먹습니다. condition self[5] 에는 배고파졌지만 포크를 집을수 없는 철학자가 대기하는곳입니다. 각 철학자는 먼저 dp.pickup(i); 로 수저를 집고 적당히 먹은후, dp.putdown(i); 로 내려놓습니다. dead-lock은 없지만, starvation은 아직 있습니다.

이제 모니터의 구현을 얘기해봅시다. 모니터를 위해서 1로 초기화된 세마포어 mutex가 있습니다. 프로세스는 들어오기전에 wait(mutex)를 하고, 나갈때는 signal(mutex)를 합니다. 시그널하는 프로세스는 깨어나는 프로세스가 모니터를 떠나거나 다시 잠들때까지 기다려야하므로 0으로 초기화된 세마포어 next를 하나더 씁니다. 그리고 시그널하는 프로세스는 여기서 대기합니다. next_count라고 하는 정수값은 next에서 대기중인 프로세스의 수를 카운트합니다.

 semaphore mutex;  // (initially  = 1)
 semaphore next;     // (initially  = 0)
 int next_count = 0;

따라서 함수 F의 호출은 다음과 같이 처리됩니다.

   wait(mutex);
         …
       body of F;
          …
   if (next-count > 0) {
       signal(next)
   } else {
       signal(mutex);
   }

이제 condition variable을 구현합니다. 각 condition x마다 세마포어 x_sem과 정수값 x_count 를 둘다 0으로 초기화합니다.

  semaphore x-sem; // (initially  = 0)
  int x-count = 0;

x.wait()는 다음과 같이 구현됩니다.

  x-count++;
  if (next-count > 0)
     signal(next);
  else
     signal(mutex);
  wait(x-sem);
  x-count--;

x.signal() 은 다음과 같이 구현됩니다.

 if (x-count > 0) {
    next-count++;
    signal(x-sem);
    wait(next);
    next-count--;
 }

이 구현은 Hoare와 Brinch-Hansen이 준 두가지 모니터의 정의에 둘다 적용가능합니다. 이제 모니터안에서의 프로세스 재개순서에 대해서 생각해봅니다. 한 condition variable에 여러 프로세스가 대기중일때 어떤 프로세스를 깨워야하는가? 여기선 가장 간단하게 FCFS방식을 쓰지만, 많은 경우에 이런 간단한 방식으로는 부족합니다. 그래서 conditional-wait 가 사용될수 있습니다. x.wait(c); 형태로 쓰이는데, 여기서 c는 priority라고 불리는, wait()가 실행될때 평가되는 정수값입니다. 해당 프로세스와 함께 기록되었다가 가장 작은 priority값이 다음번에 재개됩니다. 이에 대한 예제로 하나의 resource를 할당하는 코드입니다.

      monitor ResourceAllocation
      {
          boolean busy;
          condition x;
          void acquire(int time)
	  { if (busy) x.wait(time); busy = true; } 
          void release()
          { busy = false;   x.signal(); }       
          void init() 
          { busy = false; }
      }

이 자원에 접근하려는 프로세스는 반드시 다음과 같은 코드로 접근해야 합니다. R.acquire(t); 자원을 사용후 R.release(); 사실 이 코드에서 time부분을 빼면 코드 자체가 그대로 semaphore가 되어버립니다. 즉, Monitor는 세마포어로 사용할수가 있는 construct입니다. 따라서, 모니터와 세마포어는 power면에서 같다고 할수 있습니다. 또 하나, 이렇게되면 결국 세마포어로 되돌아오고말았습니다. 한가지 해법은 자원에 대한 모든 접근을 모니터에 포함시키는겁니다. 근데 그래버리면 스케쥴링이 우리가 코딩한 순서대로가 아닌 모니터내부의 스케쥴링을 따라가게됩니다. 세마포어와 같은 문제인 이런 access control problem으로 다시 돌아와버린 꼴입니다.



Synchronization #3

지금까지의 전통적인 synchronization기법들 이외에도 performance가 중요한 특별한 경우들에 대해서 특화된 여러 가지 lock들이 있습니다. 이러한 특별한 종류의 synchronization기법들을 살펴봅시다. 그외에도 아직까지 살펴보지 못한 다른 관련된 문제들을 살펴보도록 하겠습니다.

Spinlock revisited

spinlock with backoff

livelock

application은 event를 기다리기 위해서 보통 다음 세가지정도를 할수 있죠. (a)sleep (b)yield하면서 다시체크하기 (c)spin 이런 경우에 우리는 오버헤드인 system call을 피해야하고, 또한 context switch를 피해야합니다.

 

Futex

 

이와같이 semaphore의 구현은 커널이 끼어들기 때문에 상당히 비쌉니다. 당연히 user-level에서의 구현을 위한 방법들이 도입되는데, Linux 2.5부터 도입된 futex를 살펴보도록 하겠습니다. http://en.wikipedia.org/wiki/Futex 이것은 변수를 share하면서 atomic instruction을 이용해서 구현합니다. 커널은 lock이 contend되는 경우에만 스케쥴링을 위해서 필요하게 됩니다. 따라서 대부분의 경우는 user-level에서 이루어지게됩니다. 전통적으로 UNIX에서는 semaphore, msgqueue, sockets, file locking( flock() ) 가 프로세스들간의 기본적인 synchronization기법들인데, 이들은 lock자체는 커널안에 두고 userlevel에는 핸들만을 제공하는 방식을 사용하기에 모든 lock으로의 access는 시스템콜을 거쳐야하기에 오버헤드가 크게됩니다. 그래서 userlevel에서 lock을 두어서 최적화하는 방식을 씁니다.

futex는 그 자체로는 mutex가 아니라 mutex를 만들기위한 primitive들을 제공하는 interface입니다. futex와 비슷하지만 좀더 쉬운 thin lock이라는것을 먼저 살펴봅시다. http://bartoszmilewski.wordpress.com/2008/07/24/thin-locks-in-d/ 여기에서 D와 자바에서 synchronized 키워드를 어떻게 구현해넣는지를 좀 살펴볼수 있습니다. 여기서는 아이디어만 살펴봅시다. 이야기는, 현재 D의 synchronized 키워드가 당장은 OS의 lock을 쓰는데, (Windows의 CriticalSection , Linux는 pthread mutex) 이를 thin lock으로 optimize하는 얘기입니다. "Thin Locks: Featherweight Synchronization for Java", David F. Bacon, et al. 의 논문에서 밝히는것은, synchronized section에 들어갈때 80%의 경우에 object는 unlock된 상태라는것과, 그 다음으로는 nested locking, 즉 같은 thread가 또 lock을 잡는경우가 많다는거고, 사실상 진짜 contention은 적다는것입니다. 또한 일단 lock contention이 일어나면 또 일어나는 경향이 강하다는것이죠. 이를 이용해서 Java에서 thin lock을 어떻게 구현하는지를 봅니다. object에 기존의 vtable로의 포인터말고도 thin lock이라고 할수 있는 integer하나를 더 추가해넣는군요. 기본적으로 이것에 CAS(compare and swap)과 같은 atomic operation을 가해서 spin lock으로 씁니다. 이렇게해서 unlock상태인 common case들을 처리합니다. 0이면 열려있는거고, 아니면 얻어온값이 thread ID가 됩니다. 이 thread ID를 자신의 ID와 비교해서 그 다음 common case인 recursive lock, 즉 object가 nested lock을 잡는 경우를 처리합니다. 자신이 다시잡은 경우엔 thin lock안에 구겨넣어진 counter값을 증가시키면 끝이죠. thin lock자체는 하나의 integer안에 이 카운터와 thread id와 비트몇개를 구겨넣은거죠. 그 외의 경우가 드디어 진짜 contention입니다. 이제 진짜로 fat lock, 즉 monitor object를 만들어야하는데, 이 과정을 inflate한다고 하는군요. thin lock은 이제부터 이 monitor로의 포인터가 됩니다. 이 경우에도 역시 먼저 thin lock을 잡아야합니다. inflation을 하기 위해서 여러 쓰레드가 racing할수도 있기때문이죠. 여기서는 그냥 spin합니다. 잡은후에 fat lock의 object를 만들어서 넣고, 이 object는 OS레벨에서 제공하는 무거운 lock을 쓰게되는거죠. 그리고 일단 inflate되면 영원히 그대로 monitor를 씁니다. contented lock은 계속 contention된다는 점에서 최적화죠.

http://bartoszmilewski.wordpress.com/2008/08/16/thin-lock-implementation/ 여기에서 D에서의 그 실제적인 구현 내용을 살펴봅니다.

이제 Futex에 대해서 다시 살펴봅시다. "Fuss, Futexes and Furwocks: Fast Userlevel Locking in Linux", Hubertus Franke, et al. http://www.kernel.org/doc/ols/2002/ols2002-pages-479-495.pdf 이 논문에서 Futex에 대해서 설명합니다. 헌데 좀 오래된거 같고, 이후에 나온 "Futexes are Tricky" by Ulrich Drepper 이 논문이 그나마 최신인듯합니다. futex interface를 써서 mutex를 만드는일이 상당히 어려운 작업임을 보여주네요.

http://bartoszmilewski.wordpress.com/2008/09/01/thin-lock-vs-futex/ 이 포스팅에서는 Futex와 한번 비교해봅니다. 비슷하지만, 여러 차이점이 있습니다. 먼저, Futex is not mutex. 즉 futex는 user level에서 mutex를 개발할수 있는 primitive를 제공하는 것이죠. 그리고 thin lock은 한번 contention이 발견되면 그 이후로는 곧장 OS lock을 사용하는데 비해서 futex는 그 이전 단계를 항상 모두 거칩니다.

fcntl 이나 System-V semaphores과 같은 기존의 무거운 커널기반 락을 optimize하자는거죠. semaphores, msgqueues, sockets, flock()같은 IPC들은 커널내의 object에 대한 핸들을 application 에게 주고 이를 통해 IPC를 하는데, lock으로의 각 access가 모두 시스템콜을 요구하므로 무거운거죠. contention이 별로 없는경우엔 불필요한 overhead가 됩니다. 그래서 user-level에서 shared lock을 만들고 atomic operation을 가하고 오직 contention일때만 커널을 호출하는 방식이라는 점은 공통점입니다. 이런 user level방식은 그 직접적인 구현이라는 특징때문에 기존방식과는 다르게 커널이나 다른 쓰레드가 특정 lock이 어느 쓰레드에 의해서 현재 잡혀있는지를 판단할수가 없습니다. lock을 잡은 쓰레드가 죽은경우에 문제가 되죠.

 

 

Read-Copy-Update

RCU에 대해서 공부해봅시다. 이 기법은 read가 write보다 훨씬 많은 data structure에 대해서 synchronization overhead를 write가 모두 또는 거의 모두를 부담하게함으로써 극적으로 성능을 올립니다. 이 기법의 착안점은, 많은 경우에 read가 write보다 훨씬 많다는 것이고, 전통적인 lock-based 기법들에서 read들이 concurrent하게 수행될수 있음에도 불구하고 read들 간에도 불필요하게 heavy-lock을 쓴다는 것입니다. 우리가 lock을 걸 때 그것은 다른 write를 막고자함인데, lock은 너무 강력하게도 모든 read/write를 모두 막게됨으로써 불필요하게 다른 read를 막게됩니다. read가 압도적으로 많은 경우에 이런 상황은 read간의 쓸데없는 locking을 부르기 때문에 비효율적이 됩니다.

synchronization의 핵심적인 문제는 write가 어느 시점에서 안전하게 이루어 질 수 있는지를 찾는것인데, lock-based에서는 writer가 lock을 이라는 직접적인 방법을 통해서 write가 가능한 시점을 찾는 것입니다. 이와 달리 RCU는 writer가 write하는 시점을 간접적인 방법으로 찾아내어서 수행합니다.

한가지 더, RCU는 lock과 같은 general-purposed가 아닙니다. lock은 해당 data structure와는 관련없이 raw data로써 보호하지만, RCU는 특수한 경우로서, data의 structure, 즉 List인지, tree인지, 등에 따라 구현이 달라집니다. 즉 원리는 같지만 data structure에 따라 실제 구현이 달라지는 것입니다. 이와 같이 raw data가 아닌 data structure로써 synchronization을 하게되면, 다음과 같은 이점이 생깁니다. 기존의 lock-based기법에서는 데이터의 구조를 모르므로 단 한 바이트에 대한 write라도 atomic하게 이루어져야합니다. 즉 critical section이 엄격히 정의됩니다. 이에 따라서 일단 write가 끝나면 모든 reader/writer들은 new data를 보게된다는 것이 보장됩니다. 그러나 많은 경우에 있어서 이렇게까지 강력한 synchronization은 필요 없습니다. 즉 old data를 읽더라도 별 문제가 없는 상황이 많습니다. 이것은 주로 포인터(reference라 하겠습니다.)에 대해서인데, RCU에서는 이와같이 이미 한 reader가 이미 old reference를 읽었다면 그대로 진행할 수 있도록 해줍니다.

 

이와 같이 이미 reference를 읽었던 reader는 그대로 예전 자료구조를 보게되는데, 많은 경우에 이것은 문제가 되지 않는다는 것입니다. 따라서 RCU는 이미 reference를 read한 thread에 대해서는 예전의 자료구조를 보여주며, 이후 새로 진입하는 reader들에 대해서는 새로운 자료구조를 보여주는 것입니다. 이제 기존의 예전 자료구조를 보는 reader들이 떠나가면 예전 data는 free해버림으로써 update를 완료합니다. 여기서의 핵심은 예전 reader들이 예전 data를 떠나감으로써 free가 가능해지는 시점을 어떻게 파악하느냐입니다.

 

즉 update가 두단계(혹은 세단계)에 걸쳐서 이루어집니다. 하나는 기존 data structure에 새로운 data structure를 만들어내는 것입니다. 이 구조는 예전 reader는 예전 data structure를 보게되고, 이후의 reader들은 새로운 data structure를 보게되는 이중적인 구조가 됩니다. 두 번째는, 예전 reader들이 모두 예전 data structure를 떠나는, 즉 이들의 이에 대한 reference가 모두 사라지는 시점을 잡아내는 것입니다. 이게 핵심이죠. (예전 reader들에 대해서만입니다. 이후의 reader들은 상관없죠.) 세 번째는 드디어 예전 data structure를 지워내는 것입니다.

이것은 어찌보면 기존의 critical section을 약화시켜서 (즉 write가 이루어진후 모든 reader가 새로운 data를 봐야한다는 조건이 약화됨으로써) (이에 대한 반대급부라면 raw data가 아닌 data structure레벨에서 구현된다는 것이겠죠.) 이중적인 구조로 풀어냈다고 볼 수 있습니다. 이 이중구조란, 결국 예전 reader는 예전의 data structure를 보고, 이후의 reader는새로운 data structure를 보게되는, 그런 구조인거죠.

또 한가지, 이 RCU는 하드웨어적인 atomicity에 기반한다는 것입니다. data structure를 조작하는 부분은 기존의 lock등을 써서(이게 보통 HW로 구현되었죠) 어떻게든 atomic한 동작을 할 수 있다는 가정위에 있는거죠.

 

http://www.rdrop.com/users/paulmck/rclock/

http://www.rdrop.com/users/paulmck/rclock/rclockjrnl_tpds_mathtype.pdf

http://linuxjournal.com/article/6993

http://www.rdrop.com/users/paulmck/RCU/whatisRCU.html

이 문서가 훨씬 마음에 듭니다. 짧은 overview에서 핵심을 깔끔하게 정리했으며, RCU API들을 설명하네요.

 

from Wiki

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

 

http://en.wikipedia.org/wiki/Lock-free_and_wait-free_algorithms

이와같은 lock-free 알고리즘은 List나 Stack같은 data structure단위에서 만들어집니다. (이런 알고리즘도 HW의 지원을 필수로 합니다. instruction level의 atomicity에 기반하는거죠)

Non-blocking synchronization

http://en.wikipedia.org/wiki/Non-blocking_synchronization

 

그외의 locks (TODO)

 

여기서 Robert Love가 Linux에서의 lock들에 대해서 설명합니다.

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

 

커널 lock을 위한 advice들입니다.

http://www.linuxjournal.com/articles/lj/0100/5833/5833s2.html

 

Reader/Writer Locks (rwlock)

보통 read의 경우 동기화 문제가 없으므로 여러 reader가 동시에 수행될수 있게하고, write에 한해서만 하나의 writer만이 한순간에 수행될수 있게하는 lock입니다. synchronization의 overhead를 write쪽으로 거의 몰아주는것입니다. read-only에 가까운 데이터에 효과가 좋고, 이런 경우 세마포어보다 성능이 좋죠.

 

Big-Reader Locks (brlock)

rwlock의 특별한 형태로 reading을 위해 spinlock을 얻을 때는 매우 빠르게 얻지만, writing을 위해서 spinlock을 얻을 때는 극도로 느리게 얻게 됩니다. reader가 매우 많고 writer가 매우 적을 때 적합합니다.

 

Convoy Problem

convoy문제는 deadlock이나 livelock과는 달리 progress는 되는데 가장 느린 쓰레드에 의해서 진행상황이 막히면서 성능저하가 심각해지는 경우입니다. 즉 가장 느린 쓰레드가 병목지점이 되고, CPU는 쓰레드를 깨웠다가 재우는데에 대부분이 쓰이게 됩니다. 대표적으로 큐가 있는데, 예를들어 하나의 producer만이 있고 그외의 많은 consumer가 붙어있을때, producer가 한번 lock/unlock할때마다 다른 모든 consumer들이 깨어났다가 큐가 빈것을 보고 다시 죄다 잠들게 됩니다. (이처럼 기다리는 이벤트때문에 모두들 일어났다가 대부분이 다시 잠들게되는 상황을 thundering herd problem이라고 부릅니다.) 그 사이에 producer는 wait list의 맨 마지막으로 붙기때문에 한번 락을 잡아서 데이터 하나를 넣을때마다 훨씬 많은수의 consumer들이 모두들 한번씩 깨어났다가 잠들게되는 과정을 거친후에야 producer가 다시 락을 잡게됩니다. 이렇게되면 가장 느리다고 할수 있는 producer때문에 전체 성능은 producer에 맞춰지게 됩니다. 그래서 가장 느린 쓰레드에 성능이 맞춰지고 CPU는 허비되게됩니다. 이런 현상은 lock duration이 짧은 대신 빈번하게 일어나는 경우에 잘 나타납니다.

convoy문제는 결국 locking의 fairness와 연관있는데, 들어온 순서대로 lock을 잡게되므로 가장 느린 process에 전체 성능이 맞춰집니다. 그래서 느린 process에게 높은 priority를 주는 방식등으로 언듯 불공평해보이지만 방금 lock을 가졌던 쓰레드에게 다시 lock을 주는 방식으로 해결할수 있습니다. 또는 lock-free알고리즘을 개발하는것도 방법이죠.

결국 release한 lock을 그 다음에 누구에게 먼저 주어야 하는가의 문제인데, 선착순으로 하기때문에 문제가 된다고 할수 있죠. 그래서 보통 해결책으로는 random fairness라고 부르는, 대기중인 프로세스를 모두 깨워서 아무놈이나 잡게하는 방식을 씁니다. 역시 thundering herd problem은 있지만, 만약 첫번째로 깨어나는 프로세스가 다시 스케쥴되거나 선점당하기 전에, 즉 한 퀀텀안에서 락을 release한다면 UP에서는 꽤나 잘 작동합니다. 두번째에게 lock을 넘기고..하는식이죠. SMP에서는 이처럼 잘 작동하진 않을 수 있습니다. 이런 문제를 피하려면 release때 하나의 프로세스만 깨워야합니다. 그래서 release하는 쓰레드가 즉시 다시 lock을 잡을 기회를 가질수 있게 해주는 방식을 greedy라고 하는데 starvation의 위험이 있습니다.

이에 따라서 fair locking, random locking, greedy(or convoy avoidance(ca)) locking정도의 lock을 생각할수 있습니다.

 

 

 

 

 



Lock-free Code

Herb Sutter의 Effective Concurrency 씨리즈를 기반으로 lock-free 코드에 대해서 살펴봅시다
August 2007: The Pillars of Concurrency

September 2007: How Much Scalability Do You Have or Need?

October 2007: Use Critical Sections (Preferably Locks) to Eliminate Races

November 2007: Apply Critical Sections Consistently

December 2007: Avoid Calling Unknown Code While Inside a Critical Section

http://www.ddj.com/hpc-high-performance-computing/204801163
January 2007: Use Lock Hierarchies to Avoid Deadlock
데드락을 피하기 위해서 locking의 일정순서를 유지해줘야하는데 이를 위해서 lock hierarchies를 사용하자는것.

February 2008: Break Amdahl’s Law!

March 2008: Going Superlinear

http://www.ddj.com/hpc-high-performance-computing/206903306
April 2008: Super Linearity and the Bigger Machine
위의 두개는 super linearity 즉 core수가 늘수록 그보다 더 많은 성능향상을 가져오는 (sublinear의 반대) 현상에 대한 얘기

http://www.ddj.com//architect/207100682
May 2008: Interrupt Politely
여러 worker thread들이 있을때 하나의 worker가 목적을 달성(검색등)해서 다른 쓰레드들을 종료시킬필요가 있을때,
어떻게 종료시킬것인가. 결론은 violence is not the answer 즉 강제로 죽이지 말고 협력하는 쓰레드를 작성하라는것.

June 2008: Maximize Locality, Minimize Contention


http://www.ddj.com/hpc-high-performance-computing/208801371
July 2008: Choose Concurrency-Friendly Data Structures


August 2008: The Many Faces of Deadlock


http://www.ddj.com/cpp/184401930
The Trouble with Locks

여기서 Herb Sutter가 lock-based방식의 문제점들에 대해서 얘기합니다. 안보이는 racing, deadlock, 
performance cliffs (priority inversion, convoying따위..) 제대로 짰다고해도 유지보수하기가 너무 어렵습니다!
제대로 짤려면 해당 변수나 데이터에 접근하는 모든 코드들(라이브러리, 등등)이 모두 제대로된 순서로 락을 걸고
풀어야할뿐더러, 그게 된다해도 이 코드들은 nest되지 못한다는 단점이 있죠! 즉 non-composable이네요.
그래서, 락을 들고서 모르는 함수를 함부로 불러서는 안됩니다. deadlock의 위험이 있으니까요.
락을 들고서는 virtual function등을 호출하는 코드들을 쉽게 볼수있는데, 죄다 위험한거죠.

그래서 모니터같은, 스스로를 잠그는 구조가 나오는데, 멤버들간의 동기화를 보장해주는거죠. 
자바의 Vector나 .NET의 SyncHashTable같은거. 그래도 근본적으로 lock을 쓴다는것은 위의 문제들을
여전히 가집니다. 그안에서 알지못하는 다른 코드를 호출하는것과 같은건 여전히 위험하죠.

또 lock-based는 scale하지 못하죠. scale하게 짜기가 어려워요.

lock이 structured programming에서의 goto문과 같은존재라는군요. 적절한 비유네요.

http://www.ddj.com/cpp/210600279?pgno=1
Lock-Free Code: A False Sense of Security

여기에서는 lock-free에 대해서 얘기합니다. 그러나 사실 lock-free방식은 lock-based보다 더 어렵습니다.
두가지 약점이 있는데, 첫째로 범용적이 아니라는거죠. 심지어 doubly-linked list와 같은 자료구조의
lock-free 구현은 지금 알려져있지 않습니다. 둘째로, 제대로 lock-free를 디자인하는것은 매우 어렵습니다.
좋은 저널들조차 버그투성이의 lock-free코드들을 publish했다고 하네요. 이 기사에서는 잘못된
lock-free queue의 예를 분석하면서 그 어려움을 보여주고 있습니다.
이 큐는 하나의 Producer와 하나의 Consumer만이 있는 제한적인 경우에만 해당하는 큐인데,
기본적으로 각각은 끝에서 작업하므로 작업이 겹치지 않죠. 항상 iHead가 가리키는 바로 다음이 다음으로
소모될 요소이고, 항상 iTail바로 직전의 요소가 가장 최근에 추가된 요소입니다.
consumer는 iHead를 증가시킴으로써 하나를 소모했음을 알립니다. 그리고 producer는 iTail을 증가시켜서
새로운 요소가 들어왔음을 알리죠. 여기서 포인트는 producer만이 큐를 조작한다는겁니다. 즉 producer가
insert/deletion을 모두 책임집니다. 그리고 iHead가 가리키는 요소(가장 최근에 소모된 요소)는 해제하지
않습니다. 이걸 해제하면 producer와 consumer가 인접해서 만나게 되니까요.

뭐 아이디어는 좋은데, 구현이 쉽지만은 않습니다. 주요문제는 atomicity와 ordering을 보장하는것인데,
즉 iHead와 iTail이 문제죠. list::iterator 형 변수는 word-size라는 보장이 안되므로 atomicity가
제대로 보장안됩니다.

C++0x의 std::atomic<>가 있는데, atomic는 T가 bit-copyable type임을 요구하는데, STL타입과 iterator들은
그렇지 않아서 여기선 해당사항이 없습니다.

그다음, ordered atomic variable로 만들어야하는데, 즉, C++0x의 std::atomic 이나 Java/.NET의 volatile이죠.
또는 Win32 InterlockedExchange같은 API나 Linux의 mb()같은 메모리펜스/베리어를 써야합니다.

생략..


http://www.ddj.com/hpc-high-performance-computing/210604448?pgno=1
Writing Lock-Free Code: A Corrected Queue

자, 여기서 2부가 시작됩니다. 정답을 구현하기 위해서 처음부터 새로짜기 시작합니다.
lock-free를 위해서 두가지를 명심하세요. 1) 트랜젝션으로 생각하라. 누가 어느 데이터를 소유하고 있는지를
항상 염두에 둘것. 즉, 자료구조에의 접근을 트랜젝션화하라. 그래서 누가 접근하는중인지 확실히 하는거죠.
특히나 소유권을 서로에게 넘기는부분에 특별히 신경써야합니다. single atomic operation으로 다른이에게 넘겨야죠.
2) 우리의 도구는 ordered atomic variable입니다. 이건 C#/.NET에서는 volatile이고,
Java에서는 volatile이나 AtomicInteger같은 Atomic씨리즈들이고, C++0x에서는 atomic이죠.

생략..


http://www.ddj.com/cpp/211601363
Writing a Generalized Concurrent Queue


이번 편에선 multiple consumer/producer로 확장해봅니다. 비록 purely lock-free는 아니고, 두개의 락을 쓰는데,
head/tail에 하나씩 ordered atomic variables (C++0x atomic<>, Java/.NET volatile) 을 이용해서 spinlock을
직접 구현해넣습니다.

생략..


http://www.ddj.com/cpp/211800538
Understanding Parallel Performance (Dec 2008)



http://www.ddj.com/hpc-high-performance-computing/212201163?pgno=1
Measuring Parallel Performance: Optimizing a Concurrent Queue (Jan 2009)


volatile vs. volatile (Feb 2009)
http://www.ddj.com/hpc-high-performance-computing/212701484?pgno=2


Design Patterns으로 유명한 Gang of Four의 한명인 Ralph Johnson의 Parallel Programming Patterns에 대한 talk도 한번 봅시다. Server Concurrency != Client Concurrency 여기에서는 서버환경과 클라이언트 환경에서의 다른점들을 설명하네요. 이사람이 쓴책 More Exceptional C++입니다.

How parallelism demos are useful 여기서는 다른 얘기지만 데모로 자주 쓰이는 Mandelbrot graphics나 ray-tracing에 대해서 얘기합니다.

자연스럽게 이야기는 memory model쪽으로 흘러갑니다. locking을 쓰겠다면 spinlock과 같은 primitive가 barrier역할을 해주지만, 만약 lock-free algorithm 에서와 같이 직접 변수하나하나를 공유하겠다면 먼저 필요한것이 memory model 에 관한 기본가정들이죠. 이것은 아키텍처마다 다르니 아키텍처마다 lock-free code는 조금씩 달라진다고 할수 있겠습니다. 하지만 간단하게 sequential consistency 를 보장하면 되겠죠. 그래서 Java에서는 이와같이 공유되는 변수들에 대해서는 volatile을 붙여서 이를 보장합니다. 이를 하지 않는다면 비록 특정 CPU에서 잘 수행된다고해도 그건 버그가 될수 있겠습니다. 이게 간단히 말해서 java에서의 메모리 모델입니다. C++에서는 비슷하지만 자바의 volatile역할을 atomic library가 하죠. (C/C++의 volatile은 컴파일러의 최적화를 막아주는 역할이죠)

즉 lock-free code는 memory model에 민감하므로 각 아키텍쳐, 혹은 언어가 제공하는 memory model에 따라서 달라지는것이죠. memory fence를 적절히 쳐야 하기때문이죠. 어떤 언어나 OS는 아예 sharing을 포기하는 경우도 있는데, (Erlang언어) 이런경우 메세징을 써서 통신합니다.

http://bartoszmilewski.wordpress.com/2008/08/11/sharing-and-unsharing-of-data-between-threads/ 여기서 SharC라는 논문을 소개하는데 sharing 모드를 5가지로 분류해서 프로그래머가 type qualifier를 붙이면 자동적으로 inconsistency를 찾아주는 논문이군요. (또한 dynamic tool도 있고요) D에서는 각 변수의 sharing 을 지정해주는것과 비슷합니다. D에서는 shared로 지정안하면 thread-private하게 설정되고 다른 쓰레드에는 안보입니다. sharing은 shared와 invariant 두개의 종류가 있습니다. 흥미로운 부분은 sharing mode들간의 변환인데, producer consumer queue같은 경우, 큐를 통해서 object가 전달될텐데 by reference로 전달되면 shared라야할테고 그러다가 일단 consumer가 전달받아서 exclusive access를 가지고싶다면 non-shared로 처리하고 싶을거고. 그런 변환이 필요할수도 있는것입니다.

Erlang에 대한 약간의 소개를 합시다. Wikipedia에서 가져옵니다. Creating and managing processes is trivial in Erlang, whereas threads are considered a complicated and error-prone topic in most languages. Though all concurrency is explicit in Erlang, processes communicate using message passing instead of shared variables, which removes the need for locks. Erlang's main strength is support for concurrency. It has a small but powerful set of primitives to create processes and communicate between them. Processes are the primary means to structure an Erlang application. Erlang processes are neither operating system processes nor operating system threads, but lightweight processes somewhat similar to Java's original “green threads” (the Java Virtual Machine now uses native threads). Like operating system processes (and unlike green threads and operating system threads) they have no shared state between them. from Wikipedia

 

Transactional memory

지금까지 살펴본것들이 전통적인 lock-based synchronization이었다고하면 최근들어 등장하고 있는 것이 Transactional memory 입니다. 근본적으로 lock을 모두 제거할수 있게 해주는 기법으로 기대되고 있습니다. lock은 너무 많은 단점이 있죠. 기본적으로 너무 pessimistic하기 때문에 성능이 안좋을수 있습니다. 또한 critical section이 너무 길면 contention이 커져서 성능을 저해합니다. 심각할 경우엔 SMP머신이 UP와 별반 다르지 않을 지경까지 갈수도 있죠. (Amdahl's law) 반대로 너무 작을경우 그 코드가 빈번하게 수행된다면 실제 하는 일보다 lock/unlock하는데 더 많은 시간을 쓸수도 있습니다. 물론 deadlock/livelock/priority-inversion등의 문제들은 말할것도 없고요. 또한 어렵죠. unlock을 까먹는다던지..코딩하기 어렵죠. 결정적으로 composable하지 않습니다.

따라서 critical section을 transaction으로 정의하는데...TODO

Software TM

STM은 이러한 개념을 SW로 구현하는 것입니다. 크게 lock-free로 구현하거나 혹은 lock-based로 구현합니다. lock-free로 구현하는것은 RCU와 비슷한 구조가 되는데...TODO. lock-based의 경우 언듯 모순되는듯이 보이지만 오히려 전자보다 성능상에서 좋을때가 많습니다.. TODO..

Hardware TM

HTM은 HW로 구현하는 경우죠. 예를 들어서 http://labs.oracle.com/scalable/pubs/ASPLOS2009-RockHTM-slides.pdf ...TODO.

 

Transaction

이 개념은 Database에서 나왔죠. 기본적인 logical unit으로서 atomic하게 수행된다는것이 중요합니다. failure가 있는 컴퓨터 시스템에서 어떻게 atomicity를 달성할지가 관건입니다. (DB책 ch.19참조) 일어날수 있는 여러 failure들을 살펴보면, (1) computer failure(system crash) 트랜젠션 수행도중 HW나 SW 혹은 네트웤에러가 발생한경우. HW failure는 보통 메인메모리같은 media failure입니다. (2) transaction or system error. 트랜젝션안에서 integer overflow나 division by zero같은 에러가 발생할수 있고, 잘못된 파라미터값이나 버그로인해서 발생할수도 있습니다. 일반적으로 트랜젝션은 확실히 점검되어서 버그가 없어야합니다. 또한 유저가 도중에 인터럽트걸수도 있습니다. (3) Local errors. 이건 failure는 아니고 은행잔고가 부족하다던지 해당 데이터가 존재하지 않는다던지 하는 예외상황들입니다. 프로그래머가 트랜젝션을 취소합니다. (4) Concurrency control enforcement. 동시성 제어로 인해서 트랜젝션이 abort될수도 있습니다. (5) Disk failure. 디스크문제죠. (6) physical problems and catastophes. 전원이나 airconditioning실패, 불나거나 도둑이들거나 하는등의 계속되는 문제들이죠. 그외에 또 하나의 이슈는 concurrency control problem인데, 많은 transaction이 동시에 들어올때 어떻게 처리할것인지에 대한 문제입니다. 기본적으로 Transaction자체는 쭉 이어진 read/write들일뿐이고 처음엔 시작을 표시하고 commit이나 abort로 끝맺게 됩니다. commit은 현 transaction을 저장소에 비로소 써넣겠다는것이고, abort는 현 transaction을 취소하겠다는거죠. Transaction을 수행하다가 도중에 실패를 하면(보통은 다른 transaction과의 conflict) rollback을 합니다. Atomicity를 지키기 위함이죠.

DBMS는 일반적으로 메모리에 일종의 버퍼캐시를 두고있습니다. 보통, 언제 이 디스크 블락이 디스크로 쓰여질지는 DBMS의 recovery manager가 OS와 협동하여서 결정합니다.

저장소를 특성에 따라 3가지로 분류해봅니다. (1) Volatile storage 메모리입니다. (2) Nonvolatile storage 디스크죠. (3) Stable storage 실패가 없는 가상적인 디스크입니다. 내용물을 절대 잃어버리지 않는다고 가정합니다. Atomicity를 위한 대표적인 방식이 log방식인데요, stable storage에 데이터에 대한 modification들(즉 로그, 또는 Journal)을 모두 기록해두는것입니다. 보통 write-ahead logging이라고 부릅니다. 실제 데이터에 쓰기전에 log를 먼저 남기기때문이죠. 개개의 로그는 로그레코드라고 불리는데, 트랜젝션의 시작을 알리는 로그레코드, write를 알리는 로그레코드, commit을 알리는 로그레코드, abort를 알리는 로그레코드가 있습니다. 일반적으로 read는 안남깁니다. 대부분의 recovery 방법들이 cascading rollback을 지원하지 않으니까요. 다만 Audit등의 다른 목적을 위해서 read로그를 남기는경우는 있습니다. write에 대한 로그레코드는 하나의 write에 해당하고, 다음과 같은 필드를 가집니다.

(1) Transaction Name
(2)Data Item Name
(3)Old value
(4)New value

트랜젝션 Ti를 실행하기전에 (Ti starts)라는 레코드를 남깁니다. 실행중엔 write에 대해서 수행전에 먼저 해당하는 로그를 남깁니다. Ti가 commit할때또는 abort할때는 (Ti commits)또는 (Ti aborts)를 남깁니다. abort는 사실 기록될 필요가 없겠죠. recovery때 commit이 없는 트랜젝션은 abort로 간주할수 있으니까요. 로그는 데이터를 다시 구성하기위해 쓰이므로 실제 데이터 업데이트 이전에 수행될수 없습니다. 그래서 write로그는 실제 수행전에 반드시 stable storage에 로그를 먼저 남겨야합니다. 따라서 모든 write에 대해서 실제로는 두번의 write가 있어야합니다. 데이터자체와 로그때문에 용량도 두배로 필요합니다. 원칙적으로는 모든 로그는 그때그때 stable storage에 남겨져야 하지만, 실제로는 메모리에 많이 남아있다가 디스크로 쓰여집니다.

트랜젝션은 여러 특징들을 가져야하는데, 보통 ACID properties라고 불립니다. concurrency control과 recovery등으로 보장되는 특성들입니다. (1) Atomicity: atomic합니다. transaction recovery subsystem이 보장합니다. (2) Consistency Preserving : commit되었다면 DB의 상태는 계속 consistent해야합니다. (3) Isolation : 실행이 독립적으로 이루어지듯이 보여야합니다. 즉 서로간에 interfere하지 않아야합니다. concurrency control이 보장합니다. (4) Durability : commit된 변화량은 persistent하게 남아있어야합니다. recovery subsystem이 보장합니다. 이중 Isolation의 경우, 여러 단계를 나누기도 하는데, 만약 모든 트렌젝션이 update를 커밋되는 순간까지 안보이게 만들면,temporary update를 해결하고 cascading rollback을 제거하는 형태의 isolation이 됩니다. 이와같은 isolation의 단계를 정의하는데, level 0는 higher-level transaction의 dirty read를 overwrite하지 않는거고, level 1은 lost updates가 없는거고, level 2는 lost update도 없고 dirty read도 없는것이고, level 3(true isolation)은 추가적으로 repeatable reads도 없애는것입니다. 사실 이렇게보니 첫번째 Atomicity는 하나의 실행 쓰레드안에서만의 atomic함을 얘기하고 있습니다. 위의 synchronization를 논의했던 DB이외의 분야에서는 Atomicity를 Isolation을 포함한 개념으로 생각했었습니다만, DB영역에서는 좀더 약화된 하나의 thread로만 국한시켜서 atomicity를 이야기한다는것을 알수 있습니다. DB쪽에서는 Isolation까지 합쳐서 이름할때는 serial 하다고 이야기 합니다. 즉, Serial = Atomic + Isolated 입니다. 사실 좀더 정확히 얘기하자면 Serial = no interleave + atomic이군요. 용어에 주의합시다.

로그를 이용한 recovery는 예전 데이터를 복구하는 undo(Ti)와 새 데이터를 쓰넣는 redo(Ti)를 씁니다. 이 undo/redo는 반드시 idempotent해야합니다. 즉, 여러번 호출되더라도 한번 호출된것과 같은 효과라는거죠. recovery하다가 문제가 생겨도 괜찮도록 말입니다.

Ti가 abort하면, 단순히 undo(Ti) 하면 롤백됩니다. 시스템실패가 일어나면, 모든 수정된 데이터를 복구하는데, (Ti starts)와 (Ti commits)를 둘다 가진경우엔 redo하고 (Ti starts)만 가진경우엔 undo하면 됩니다. 원칙적으로 전체 로그를 검색해야하니 시간도 걸리고, redo를 해야하는 transaction의 대부분이 사실 이미 해당 데이터를 update한후라서, 불필요한 경우입니다. 이를 위해 check point를 정의합니다. 실행중에 log를 남기는것 외에 주기적으로 다음의 checkpointing을 합니다.

(1) 메모리의 모든 로그를 stable storage로 출력한다 (2) 디스크의 모든 수정된 자료들을 stable storage로 출력한다. (3) (checkpoint)라는 로그를 stable storage에 남긴다.

이렇게되서 (checkpoint)이전에 (Ti commits)가 있다면 Ti는 이 checkpointing이전에 stable storage에 출력되었거나 혹은 이 checkpoint의 일부이게됩니다. 따라서 recovery에 redo(Ti)는 불필요합니다. 그래서 recovery는 다음과 같습니다. 가장 최근 checkpoint전에 실행을 시작한 가장 최근 Ti를 찾습니다. 로그를 거꾸로 검색해서 (checkpoint)를 찾은후에, 그다음 (Ti starts)를 찾습니다. 이렇게 Ti를 찾은후엔 redo/undo를 Ti와 Ti이후에 시작된 모든 Tj 들에게만 적용합니다. 이들을 집합 T라고 할때, T안의 (Tk commits)라는 트랜젝션들은 redo(Tk) 하고, (Tk commits)가 없는 트랜젝션들은 undo(Tk)합니다.

트랜젝션들이 동시에 수행되더라도 그 효과는 임의의 순서로 직렬적으로 수행된것과 같아야합니다. 이런 특성을 serializability라고 합니다. 간단히 critical section에 넣어서 실행하면 되죠. 즉 모든 transaction들이 하나의 세마포어(mutex)를 공유하면됩니다. 그리고 시작할때 wait(mutex)로 시작하고 commit하거나 abort할때 signal(mutex)하면 됩니다. 너무 제한적이므로, serializability를 유지하면서도 트랜젝션들이 overlap되도록 해봅시다. 많은 concurrency-control 알고리즘들이 serializability를 보장합니다.

Race condition

트랜젝션을 통해서 race condition들을 좀더 살펴봅시다. DB의 관점에서 synchronization을 하지 않으면 일어날수 있는 문제점들을 살펴봅시다. (Fundamentals of Database systems 3ed. by Elmasri, Navathe, ch. 19 에서 참조합니다.) 이들은 concurrency control이 왜필요한지를 보여주기도하며, 일반적인 race condition들이기도 합니다.

(1) The Lost Update Problem. 다음과 같은 예에서 overwrite때문에 한번의 write가 사라집니다.

	T1		T2
	read(X);
	X = X-N;
			read(X);
			X = X+N;
	write(X);
	read(Y);
			write(X); <-- T1의 X에대한 update가 사라집니다.
	Y = Y+N;
	write(Y);

(2) The Temporary Update (or Dirty Read) Problem. 한쪽에서 업데이트를 했지만 도중에 롤백하는경우, 다른쪽에서 롤백되기 전에 값을 읽어와서 사용해버린 경우입니다. 임시적인 값을 사용해버린거죠. 이와같은 임시적인 값을 Dirty data라고합니다. 그래서 dirty read problem.이라고도합니다.

	T1		T2
	read(X);
	X = X-N;
	write(X);
			read(X);	<-- 임시적인 값을 읽었음. dirty read
			X = X+M;
			write(X);
	read(Y);
	rollback;

(3) Incorrect Summary Problem. 한쪽에서 합을 구하는데 다른쪽에서 도중에 update를합니다.

	T1		T3
			sum = 0;
			read(A);
			sum += A;
			...
	read(X);
	X=X-N;
	write(X);
			read(X);
			sum+=X;
			read(Y);
			sum+=Y;
	read(Y);
	Y=Y+N;
	write(Y);

(4) Unrepeatable read. 같은값을 두번읽었는데 그사이 다른쪽에서 수정하여 값이 달라진경우.


Schedule

여러 트랜젝션이 실제로 실행될때의 각 operation들의 실행 순서들을 schedule(혹은 history)이라고 부릅니다. 당연하겠지만, 특정 트렌젝션 T에 속한 operation들의 순서는 schedule안에서도 지켜져야합니다. (즉, T안에서의 operation들은 total order를 가집니다. 이론적으로 partial order를 가지게끔 할수도 있습니다.) 그중 interleave되지 않고 atomic하게 수행될때를 serial schedule이라고 합니다. 따라서 n개의 트랜젝션에 대해서 n! 개의 serial schedule이 있습니다. 트랜젝션의 overlap을 허용할때를 non-serial schedule이라고 하는데, 꼭 이것이 실행결과가 incorrect하다는것을 의미하지는 않습니다. 즉 correct할수도 있다는거죠. 이를 위해서 serializable이라는 개념을 정의합니다. 스케쥴 S는 어떤 serial schedule과 equivalent할때 serializable하다고 합니다. 그러나 먼저 스케쥴간의 equivalent를 정의합시다. 두 스케쥴이 동일한 결과를 만들어내면 result equivalent라고 합니다. 하지만 어떤경우엔 우연히 같은 결과를 내는 스케쥴도 있죠! 게다가 이 둘은 다른 트렌젝션을 실행하고 있으니 더더욱 안됩니다. 그래서 좀더 정확히 정의하기 위해서는, 각 데이터에 적용되는 동작들이 같은 순서로 적용되어야 하겠습니다. 그래서 conflict equivalent와 view equivalent가 나옵니다. 만약 충돌하는 두 동작이 두개의 스케쥴에서 다른 순서로 적용된다면 둘의 효과는 다를수 있겠죠, 그래서 충돌하는 동작들이 두 스케쥴에서 같은 순서일때를 conflict equivalent라고 합니다. 이 개념을 적용할때 어느 하나의 serial schedule과 equivalent할때를 conflict-equivalent라고 합니다. 보통 serializable이라고 할때는 이것을 얘기합니다.

from

	T0		T1
	read(A)
	write(A)
	read(B)
	write(B)
			read(A)
			write(A)
			read(B)
			write(B)

(from 공룡책) 이 경우가 serial schedule입니다. schedule자체는 T0, T1구분없이 시간순서대로 위에서 아래로 쭉 읽어내린것이 됩니다.

	T0		T1
	read(A)
	write(A)
			read(A)
			write(A)
	read(B)
	write(B)
			read(B)
			write(B)

(from 공룡책)이 경우는 non-serial schedule입니다. 그러나 사실 이렇게 수행되도 위의 serial schedule과 같은 효과라는것을 알수 있습니다. 실행결과가 correct하다는거죠. 이를 보이기 위해 conflicting operation을 정의합시다. 스케쥴 S안에 두개의 operation Oi, Oj 가 각각 Ti, Tj에 속한 operation이라고 합시다. 만일 이둘이 같은 데이터에 접근하고 최소한 한쪽이 write라면 Oi와 Oj가 conflict한다고 합니다. 위의 경우에서 T0의 write(A) T1의 read(A)와 충돌합니다. 하지만 T1의 write(A)는 T0의 read(B)와는 충돌하지 않습니다. 이때 우리는 충돌하지 않는 Oi, Oj를 서로 swap할수 있습니다. 이렇게 만든 S' 은 S와 equivalent합니다. 위의 non-serial schedule을 이와같은 방법을 거쳐 그 위의 serial schedule로 변환할수 있습니다. 이런 경우를 conflict serializable schedule이라고 합니다.

(Q) conflict가 나면 무조건 잘못된 schedule일까?? conflict==잘못수행 인가??
프로그래머가 트랜젝션을 언제까지 작게 만들어줄수있나..

트랜젝션 T1,T2,...Tn의 스케쥴 S를 다음과 같을때 complete schedule이라고 합니다. 1. S안의 operations들이 정확히 commit/abort를 끝으로 하는 T1,T2,...Tn안의 operations들일때. 2. Ti안의 동작들의 순서가 S안에서도 같을때. 3. 충돌하는 두 동작들에 대해서, 하나는 다른 하나보다 먼저일어날때. (3)번 조건은 충돌하지 않는 동작들이 partial-order를 만들수 있게해줍니다. (물론 보통은 total order로 하지요) 하지만 충돌하는 동작들끼리는 total order라야합니다. 물론 개별 트랜젝션 안에서도 total order죠(2). (1)번 조건은 단순히 트렌젝션의 모든 동작이 있어야한다는거죠. 보통은 이런 complete schedule보다는 commit된 트랜젝션에만 관심이 있기에 commit된 트랜젝션에속한 operation들만 생각합니다.

serial schedule은 간단합니다. 하지만 성능이 안좋죠. 트랜젝션 사이에 I/O가 들어간다거나 하면 모두 기다려야합니다. 어떤 트렌젝션은 너무 길수도 있죠. 그래서 실제론 거의 못씁니다.

(a)	T1		T2
	read(X);
	X = X-N;
	write(X);
	read(Y);
	Y = Y+N;
	write(Y);
			read(X);
			X = X+M;
			write(X);
(b)	T1		T2
			read(X);
			X = X+M;
			write(X);
	read(X);
	X = X-N;
	write(X);
	read(Y);
	Y = Y+N;
	write(Y);
(c)	T1		T2
	read(X);
	X = X-N;
			read(X);
			X = X+M;
	write(X);
	read(Y);
			write(X);
	Y = Y+N;
	write(Y);
(d)	T1		T2
	read(X);
	X = X-N;
	write(X);
			read(X);
			X = X+M;
			write(X);
	read(Y);
	Y = Y+N;
	write(Y);

a,b는 serial 스케쥴입니다. d는 a와 equivalent한데, 두경우 모두 스케쥴상 T1의 write(X)이후에 T2의 read(X)가 오기때문입니다. T1의 Y에 대한 부분은 충돌하지 않으므로, 우리는 이 부분을 T2이전에 놓을수 있습니다. 그러면 (a)와 똑같아집니다.그러나 (c)는 equivalent하지 않습니다. 그래서 non-serializable입니다. 간단히 conflict-serializability를 체크할수 있습니다. 하지만 대부분의 concurrency control이 serializability를 체크하지 않습니다. 실용적이지 않기때문이죠. 실행전에 매번 체크를 할수도 없고(스케쥴은 OS가 처리하니 알기도 어렵고) 안다해도 nonserializable이라고 해서 실행된 스케쥴을 되돌리기도 어렵습니다. 그래서 차라리 모든 트랜젝션이 따라야하는 프로토콜을 만듭니다. 하지만 여기도 문제가 있죠. 시스템에 계속 들어오는 트랜젝션에 대해서, 스케쥴이 언제 시작하고 언제 끝나는지를 결정하기가 어렵습니다. 그래서 오직 commited된 트랜젝션만을 대상으로 합니다. 이론적으로 스케쥴 S는 commited 트랜젝션들만 따졌을때 equivalent 하면 S도 equivalent합니다.

그외에 view equivailence 가 있습니다. (1)S와 S' 엔 같은 트랜젝션들이 참여하고 이들의 같은 동작들이 참여합니다. (2) ...

Concurrency control protocol

가장 많이 쓰이는건 two-phase locking입니다. 그외에 잘 안쓰이지만 각 트랜젝션이 timestamp를 가지는 timestamp ordering, 데이터의 여러버전을 유지하는 multiversion protocols, 트렌젝션이 끝나고 commit직전에 serializability violation을 체크하는 optimistic (also called certification or validation) protocols 들이 있습니다.

각 data에 lock을 연관시켜봅시다. DBMS엔 lock manager subsystem이 있고 lock table이 있어서, lock을 관리합니다. 공간절약을 위해 이 lock table에 없는 lock들은 unlock으로 간주됩니다. 두가지 방식을 사용합니다. (Q) 왜 DB에선 read-lock이 따로 있나?? (1)Shared: Ti가 shared-mode로 락을 얻으면 읽기만 할수 있습니다. (2)Exclusive: Ti가 exclusive모드로 락을 얻으면 읽고쓰기를 다할수있습니다. lock table엔 shared-mode로 열고있는 트랜젝션의 수가 유지됩니다. lock table의 레코드는 다음과 같습니다. (data item name, LOCK, no_of_rads, locking_transactions) LOCK은 write_lock이거나 read_lock인데, write_lock일땐 locking_transactions는 해당 트렌젝션이 되고, read_lock일때는 해당 transaction들의 리스트가 됩니다. 어떤 경우엔 또 lock은 upgrade/downgrade되기도 합니다. lock conversion입니다. read_lock을 한후에 write_lock을 해서 upgrade하거나 write_lock상태에서 read_lock으로 downgrade합니다.

이제 각 Ti는 데이터에 접근전에 적합한 모드로 lock을 얻어야합니다. 예를들어, exclusive lock을 얻으려면 모든 lock이 풀릴때까지 기다려야하고, shared lock을 얻을때 만일 exclusive lock으로 잠겨있다면 기다려야합니다. (reader-writer 알고리즘과 유사) 트랜젝션은 최소한 데이터에 접근할동안만큼은 락을 가지고 있어야합니다. 더구나, 데이터에 대한 마지막 access이후에 바로 unlock하는것은 바람직하지 않습니다. serializability가 보장되지 않을수 있기때문입니다. (??)

serializibility를 보장하는 프로토콜중 하나가 two-phase locking protocol입니다. 각 트랜젝션은 lock/unlock을 2 phase에 걸쳐서 합니다. (1) growing phase: 오직 lock을 걸기만 합니다. (2) shrinking phase: 오직 unlock만 합니다. 처음엔 growing phase에 있다가 필요할때마다 lock을 걸고, 한번 unlock을 시작하면 이제 shrinking phase에 있으므로 계속 unlock만을 합니다. 이 프로토콜은 conflict serializablity를 보장하지만, deadlock은 막지 못합니다. 또한 어떤 트랜젝션들에 대해서는 이 방법으로는 달성할수 없는 conflict-serializable schedule들이 있습니다. 하지만 two-phase locking의 성능을 올리기 위해서는 트랜젝션에 추가적인 정보를 가져야하거나 데이터에 구조나 순서를 가해야합니다.

위의 방식에서는 conflicting transaction들의 실행순서를 결정하는것은 둘다 얻으려고하는 lock을 최소한 한쪽이 exclusive모드로 얻게되는 첫번째 lock에 의해서 결정됩니다. serializability order를 결정하는 다른 방법은 트랜젝션사이에 순서를 미리 정해놓는것입니다. 가장 흔한 방법이 timestamp ordering입니다. 각 Ti에 대해서 unique fixed timestamp인 TS(Ti)를 연관시킵니다. 이후에 들어오는 Tj에 대해서는 더큰값인 TS(Tj)를 줍니다. (1) 시스템클럭을 timestamp로 쓸수있습니다. 트랜젝션이 시스템에 들어올때 값을 줍니다. shared clock이 없는 경우엔 쓰기 힘든방법입니다. (2) logical counter를 사용합니다. 시스템에 transaction이 들어올때마다 증가시키는 카운터를 씁니다.

이 timestamp가 serializability order를 결정합니다. 즉, TS(Ti) < TS(Tj) 면, Ti가 먼저 나오는 schedule이 됩니다. 이걸 위해 각 data Q에 대해서 두개의 timestamp값을 둡니다. W-timestamp(Q)는 write를 했던 트렌젝션중 가장 컸던 timestamp값입니다. R-timestamp(Q)는 read를 했던 트렌젝션중 가장 컸던 timestamp값입니다. 이 값들은 read/write할때마다 업데이트됩니다. 이제 충돌하는 read와 write를 timestamp order로 만듭니다.

Ti가 read(Q)를 했다고 할때, 만약 TS(Ti) < W-timestamp() 라면 Ti가 이미 overwrite된 Q값을 읽겠다는것이므로 Ti는 롤백됩니다. 그외의 경우엔 read가 수행되고 R-timestamp(Q)값은 R-timestamp(Q)와 TS(Ti)중 큰값으로 설정됩니다. 이제 Ti가 write(Q)를 했다고할때 TS(Ti) < R-timestamp()라면, 이미 Q값이 읽혔다는뜻이므로 Ti는 롤백됩니다. 만약 TS(Ti) < W-timestamp(Q) 라면, 이미 다른 트렌젝션이 값을 썼으므로 역시 Ti는 롤백됩니다. 그외에는 write가 수행됩니다. 그리고 R-timestamp()도 업데이트.

롤백된 Ti는 새로운 timestamp를 할당받고 다시 시작됩니다. 다음과 같은 예에서, 트랜젝션은 시작하기 바로전에 timestamp를 받는다고 합시다. 따라서, TS(T2) < TS(T3) 이 됩니다. 이경우는 two-phase locking protocol도 만들어낼수 있는 경우지만, 어떤 schedule들은 two-phase locking은 못만들고 timestamp 에서만 만들수도 있습니다. 또한 반대로 timestamp가 못만들고 two-phase만 만들수 있는경우도 있습니다. timestamp-ordering은 conflict serializabliity를 보장합니다. conflicting operation들이 timestamp 순서대로 수행되니까요. 그리고 트렌젝션이 기다리지않기때문에 deadlock-free입니다.

	T2		T3
	read(B)
			read(B)
			write(B)
	read(A)
			read(A)
			write(A)

 

Deadlock


from http://incredimazing.com/page/Deadlock

보통 OS는 deadlock prevention기능을 제공하지 않습니다. 필요조건은 다음과 같습니다. (1) Mutual exclusion. 최소한 한 자원은 nonsharable mode로 잡혀있습니다. (2) Hold and wait. 한 프로세스가 최소한 한 자원을 잡고 다른 프로세스가 잡고있는 다른 추가적인 자원을 잡기위해서 기다리고 있습니다. (3) No preemption. 자원은 preempted됮 ㅣ않습니다. (4) Cirtcular wait. P0,P1,...Pn 에 대해 P0는 P1을 기다리고, P1은 P2기다리고,...Pn은 P0기다리는 circle이 존재. 4번조건은 2번조건을 뜻하기도 하니, 독립적인 조건들은 아니네. system resource-allocation graph로 좀더 정확히 표현됩니다. 노드는 Px 와 Rx의 두가지 종류의 노드. P는 프로세스, R은 한 type의 자원. R안의 점들은 각 instance. edge Pi->Rj 는 request, Rj->Pi는 assignment. 먼저 request하면 edge가 생기고, 할당이 되면 이 request edge가 곧바로 assignment edge로 바뀝니다. 자원을 놓으면 edge는 사라집니다. cycle이 없으면 데드락없습니다. 싸이클이 있으면, 데드락이 존재할수도 있습니다. 만약 각 R들이 단하나의 instance만 가진다면 cycle은 곧바로 데드락을 의미합니다. 만약 싸이클이 각자 하나의 instance만을 가지는 자원타입들만을 가지고 있다면, 데드락인겁니다. 이런 경우엔 싸이클이 바로 필요충분조건입니다. 일반적으로 이 그래프에서 싸이클은 필요조건입니다. 그림 8.1, 8.2, 8.3 이 예제. (1) deadlock prevention or avoid. 아예 데드락에 안들어가게합니다. (2) detect and recover. 발견후 복구. (3) 걍 무시. 보통쓰는거죠. 단일 호스트에서 데드락은 그리 심각하지 않습니다. 그러나 parallel이나 분산 시스템에서의 traffic jam고ㅓㅏ deadlock은 치명적입니다. 찾아내기도 힘들고 debugging과 monitor하기도 힘들죠. deadlock prevention은 필요조건을 성립못하게 막는거. avoidance는 미리 프로세스가 사용할 자원에 대한 정보를 OS가 받는것. 데드락을 방치하면 가용 리소스가 점점 줄어들고 데드락이 커져서 시스템이 못쓰게 될수. 사실 그외의 다른 이유들(스케쥴링)로도 시스템이 서서 리붓해줘야하니까. mutex조건은 어쩔수없다. nonsharable한 자원들을 어쩔수가 없네. hold-and-wait깨려면, 아예 시작할때 모든 자원을 할당받게 하자. 또는, 자원을 하나도 안가지고있을때만 자원할당받을수 있게하는거. 자원utilization낮다. 그리고 starvation의 가능성이 있다. 인기있는 자원을 기다리다가 starvation. 요청하는것중에 최소한 하나쯤은 계속 할당된 상태일거니까.No Preemption 을 깨려면, (1) 프로세스가 자원을 잡고 다른자원을 요청해서 기다려야할땐 원래 가지고있던 자원들을 preempt해서 즉 일단 release해서 다른애들줬다가, 원래 프로세스는 새로 요청한 자원과 뺐겼던 자원들이 모두 사용가능해질때 비로소 새로시작하자. 또는, 자원요청할때 가용하면, 할당하고, 아니면 그게 다른자원을 기다리는 다른 프로세스가 잡고 있는지를 확인해서 그러면 기다리고 있는 프로세스한테서 뺐어서 요청하는 프로세스에 줘. 그외에는 기다린다. 기다리는동안, 다른 프로세스가 요청해온다면 자신의 자원은 preempt될지도 모른다. 프로세스는 요청하는 자원과 자신이 원래 가지고있던 자원 모두 가지고 있어야만 새로시작된다. 이런 방식은 주로 CPU같이 상태 저장과 복구가 쉬운 장치들에게 적용된다. Circular wait. 한가지 방법은 자원타입들에 total order를 부여하는것. 그리곤 프로세스가 증가하는 순서대로 자원을 가지게 하는거지. 각 자원타입에 번호붙인다. 같은 번호의 R로부터 여러 instance를 할당하고자할때는 반드시 한번의 request만을 해서 한번에 다 받아야한다. 또는, 자원을 할당받고 싶으면 그 번호와 같거나 큰 자원은 모두 반환해야한다는것. prevention은 이와같이 utilization이 낮아진다는것과, 그로인한 low throughput문제. 각 프로세스가 각 타입에 대해서 필요한 최대치를 선언하는 방법. 그렇게하면 데드락을 피할수 있다. 자원할당 상황을 보고 안전할때만 할당한다. 프로세스 (P1,P2,...Pn) 에 대해 각 Pi에 대해, Pi가 아직 더 요청할수 있는 자원들이 현재 가용한 자원+j<i인 모든 Pj들이 가진 자원에 의해서 충족될수 있을때를 안전하다고 합니다. 이를 safe sequence라고 합니다. 이때 Pi가 필요한 자원이 아직 가용하지 않다면 Pj까지의 모든 프로세스가 끝나기를 기다리면 됩니다. 그러면 Pi는 할당받고 일을 끝내고 자원을 모두 반환하고 끝날수 있습니다. Pi가 끝나면 Pi+1이 자원을 받아서 진행할수 있죠. 만일 이러한 safe sequence가 존재하지 않으면 안전하지 못한 상태입니다. 안전한 상태는 데드락이 없습니다. 역으로 데드락 상태는 unsafe상태입니다. 그러나 모든 unsafe상태가 데드락인건 아닙니다. 즉 데드락은 unsafe의 일부분입니다. 예를 들어봅시다.

	Total : 12
	Max needs	Current needs
P0	10		5
P1	4		2
P2	9		2

이때는 안전한 상태입니다. (P1,P0,P2)가 safe sequence니까.그러나 P2가 1개더 요청하여 할당받았다면, 더이상 안전하지 않습니다. P1만이 모든 자원을 할당받을수 있게되고 그들을 반환할때, 전체 4개가 남게됩니다. P0가 5개 더 요청할수가 있고, 그럴때 P0는 기다리게 되고, 마찬가지로 P2가 추가적인 6개를 요청할수도 있고 그러면 또 기다려야하고 그러면 데드락. P2가 다른 프로세스가 끝나기를 기다려야했었다. 그러면 피할수 있었던 데드락. 항상 safe상태에 있도록 알고리즘을 만들면 된다. 이 방식에서는 요청된 자원이 가용하더라도 기다려야할수가 있다. 그래서 utilization이 낮을수 있다.

각 타입에 instance가 하나씩뿐이라면, request/assign edge대신 claim edge를 쓰자. Pi->Rj 는 Pi가 Rj를 미래에 요청할지도 모른다는걸 표시. 점선으로 표시하고, 실제 요청이 들어오면 request edge로 바뀐다.또 자원 R이 해제되면 assignment edge Rj->Pi 가 claim edge Pi->Rj로 바뀐다. 또한 Pi는 시작전에 claim edges들은 그래프에서 먼저 나타난다. 이 조건은 Pi와 관련된 모든 edges들이 claim edges일때만 claim edge Pi->Rj가 추가될수 있다는것으로 좀더 조건을 완화할수 있습니다. Pi가 Rj를 요청할때는, request edge인 Pi->Rj를 assignment edge인 Rj->Pi로 바꾸는것이 싸이클을 만들지 않을때에만 허가됩니다. cycle-detection으로 안전성을 체크하고 있는거죠. 여기에서는 n^2 가 듭니다. n은 프로세스의 수. 그림 8.5에서 R2를 P2에게 준다면 deadlock이 걸릴수 있으므로 R2는 P1에게 먼저 준다는거죠.

Banker's algorithm. 위의 알고리즘은 여러 instance를 가진때는 못쓴다. 이번꺼는 되지만 덜 효율적이다. 새 프로세스가 들어올때 각 자원 타입에 대해서 최고 사용량을 선언합니다. 그러면 OS가 그만큼을 할당해주었을때 시스템전체가 safe state에 있을런지를 계산합니다. 그렇다면, 할당되고 아니면 다른 프로세스가 충분한 자원을 놓을때까지 기다리게 합니다. 구현을 위해서, n을 시스템의 프로세스 수라고 하고, m을 자원타입의 수라고 하면, 다음과 같은 자료구조를 정의할수 있습니다.

Available[m] : Avail[j]=k면 자원타입 R_j 의 k개만큼의 instance가 있음.
Max[n,m] : 각 프로세스의 최대 요구치. Max[i,j]=k 면 P_i가 R_j를 최대 k개만큼 요구.
Allocation[n,m] : 각 프로세스에 현재 할당된량. Allocation[n,m]=k 면 P_i가 R_j를 k개만큼 할당받았음.
need[n,m] = Max[i,j]-Allocation[i,j] 로, 각 프로세스당 아직 더 필요한양.

Allocation_i, Need_i 등을 프로세스 P_i에 대한 위의 벡터라고 하고, x<=Y 를 모든 i에 대해서 X[i] <= Y[i] 일때라고 정의합시다. 시스템이 안전상태에 있음을 판별하기 위해서 다음과 같은 알고리즘을 씁니다.

1. Work와 Finish를 각각 길이 m,n의 벡터라하고, Work=Available 로, Finish는 false로 모두 초기화 2. Finish[i]=false 그리고 Need_i <= Work인 i를 찾습니다. 없으면 4번으로갑니다.
3. Work = Work+Allocation_i , Finish[i] = true, 로하고 2번으로 갑니다.
4. 만약 모든 i에 대해서 Finish[i] = true면 시스템은 safe state에 있습니다.
이 알고리즘은 m*n^2 의 복잡도를 가집니다.

Request_i를 P_i의 request vector라합니다. 즉 Request_i[j]=k 면 P_i가 R_j를 k개 원하는거죠. 이런 요청이 들어왔을때
1. Request_i <= Need_i 면 2번으로 갑니다. 아니면 에러. 프로세스가 최고치를 넘겼기때문이죠
2. Request_i <= Available, 이면 3번으로갑니다. 아니면 P_i는 기다립니다. 자원이 부족하니까요.
3. 다음과 같이 업데이트해서 자원이 할당된척을 합니다. Available = Available - Request_i; Allocation_i = Allocation_i + Request_i; Need_i = Need_i - Request_i;
그렇게했을때 safe state에 있다면, 이제 끝나고 P_i는 할당을 받습니다. 아니라면, P_i는 Request_i를 기다리고 예전 자원 할당 상태를 복구합니다.

예제입니다. 시스템에 5개의 프로세스 P0-4, 그리고 세가지 자원타입 A,B,C가 있고 A는 10개, B는 5개, C는 7개가 있습니다. T0의 시간에 다음과 같을때

	Allocation	Max		Available
	A  B  C		A  B  C		A  B  C
P0	0  1  0		7  5  3		3  3  2
P1	2  0  0		3  2  2
P2	3  0  2		9  0  2
P3	2  1  1		2  2  2
P4	0  0  2		4  3  3

			Need
			A B C
		 P0	7 4 3 
		 P1	1 2 2 
		 P2	6 0 0 
		 P3	0 1 1
		 P4	4 3 1

현재 safe state에 있습니다. P1, P3, P4, P2, P0 순서대로면 안전하게됩니다. 여기서 P1이 1개의 A와 2개의 C를 요청한다면 Request_1 = (1,0,2) 이고, 먼저 Request_1 <= Available인지를 봅니다. 즉 (1,0,2)<=(3,3,2)이죠. 이걸 허가했을때는 다음과 같아집니다.

			Allocation	Need	Available
			A B C	A B C	A B C 
		P0	0 1 0 	7 4 3 	2 3 0
		P1	3 0 2	0 2 0 	
		P2	3 0 1 	6 0 0 
		P3	2 1 1 	0 1 1
		P4	0 0 2 	4 3 1 

이 상태가 safe인지 결정하기 위해서 알고리즘을 돌려보면 P1, P3, P4, P0, P2 순서도 안전함을 알수 있습니다. 그래서 P1의 요청을 즉시 허가할수 있습니다. 하지만 이런상태에서 P4의 (3,3,0) 은 허가될수 없습니다. 자원이 avail하지 않기때문이죠. 또한 P0의 (0,2,0)요청역시 허가되지 못합니다. 자원이 있더라도 그상태가 unsafe해지기때문이죠.

deadlock prevention이나 deadlock avoidance를 사용하지 않아서 데드락이 일어난다면 일어났음을 찾아내서 복구해야겠습니다. 즉 detection-and-recovery죠. 각 자원타입마다 한개의 instance만 있다면, wait-for그래프를 그려서 찾아냅니다. 주기적으로 알고리즘을 돌려서 cycle을 찾아냅니다. cycle을 찾는 알고리즘은 n^2입니다. (n=number of vertices) 앞서와 마찬가지로 deadlock의 존재여부는 cycle유무와 필충조건입니다. (그림 8.7) 이제 여러 instance가 있을때는 이렇게 할수없어서 다시 또 앞서의 banker's algorithm과 유사한방법을 씁니다.

Available[]과 Allocation[], Request[] 를 사용합니다. 이 알고리즘은 할당 순서들을 체크해봅니다. 앞서의 뱅커알고리즘과 비교해봅시다.

1. Init Work:=Available. For each i, if Allocation_i != 0, Finish[i]:=false, otherwise, Finish[i]:=true;
2. Find an index i such that both a.Finish[i]=false; b.Request_i<=Work. 없으면 4번으로.
3. Work := Work+allocation_i
	Finish[i] = true
	goto 2
4. If Finish[i] = false, for some i, 1<=i<=n, then the system is in a deadlock state. Moreover, if Finish[i] = false, then process P_i is deadlocked.

즉 detection을 위해서 mn^2의 복잡도를 가집니다. TODO: page 262-266

 

Interprocess Communication

시스템에서의 entity간의 communication문제는 시스템 전체에서의 주요한 오버헤드중 하나이면서 그 중요성이 간과되고 있는 부분중의 하나입니다. 기본적으로 두가지 방식이 있습니다. 적은양에 유리한 message-passing model과 대량에 유리한 shared-memory model 입니다. UNIX환경에서의 IPC들을 살펴보겠습니다. 전통적인 pipe, signal, 그리고 System V 에서 새로 도입된 shared memory, semaphores, message queues. 그리고 네트워크에 주로 쓰이는 socket입니다.

Pipes and FIFOs

파이프는 결국 쉘에 의해서 리다이렉트된 단방향 스트림입니다. "ls | more" 와 같은 명령에 의해 쉘은 두 프로세스의 표준 입출력을 엮어서 파이프를 만듭니다. 내부적으로 임시 inode가 생성되고 메모리상의 페이지가 할당되며, 이를 가리키는 두개의 file 구조체에 의해서 파이프가 구현됩니다. 물론 named pipe도 있죠. 흔히 FIFO라고 부릅니다.

Signals

이건 프로세스에겐 마치 cpu의 interrupt와 같은 존재죠. 비동기적으로 이벤트를 처리합니다. block가능하지만 SIGSTOP과 SIGKILL은 예외. 커널내의 sigaction 구조체가 이를 표현하며, 기본적으로 우선순위는 없고, (여러개의 시그널이 프로세스에게 전달되는 순서등이 정해져있지 않다는 것이죠) 또한 시그널 하나는 사실은 여러번 발생한 시그널일수도 있는 것입니다. 즉 시그널은 쉽게 lost될수 있습니다. 루트가 아닌 일반 프로세스는 같은 uid와 gid를 가지는 프로세스 혹은 같은 프로세스 그룹내의 프로세스에게만 시그널을 보낼수 있습니다. 리눅스는 시그널 핸들러가 불릴때 사용하게될 시그널 마스크를 지정할수 있습니다. 즉 핸들러내에서는 보통 다른 시그널을 block하게 되는것이죠. 핸들러가 끝나면 원래의 마스크로 되돌려지게 됩니다. 이를 위해 wrapper루틴이 하나 더 들어가서 해당 프로세스의 스택에 넣어둔 원래 마스크값을 복구하게 됩니다. 만일 여러 시그널을 계속 불러야할 경우에는 해당 루틴들을 스택에 쌓아놓아서 순차적으로 수행되도록 합니다. 커널에서 유저로 돌아올때 pending signal이 있는지 검사하고 만약 있다면 시그널을 보내게 됩니다. 또한 많은 시스템콜이 interruptible이기 때문에 시그널을 받게되면 에러를 리턴하게 됩니다. 심지어 어떤 시스템콜은 시그널 핸들러 안에서 불리우는것이 안전하지 않은 경우까지도 있습니다. 또한 핸들러 안에서는 기본적으로 printf등 조차도 함부로 부를수 없죠.

Shared memory

같은 물리페이지를 여러 프로세스가 각자의 주소공간에 매핑합니다. 일단 매핑된 후에는 자유롭게 읽고쓸수 있기때문에 동기화는 아래의 semaphore와 같은 다른 기법들을 사용해야겠지요.

Semaphores

sys_semop() 이죠.

Message queues

먼저 메세지 패싱에 대한 일반적인 이야기를 해봅니다. 메세지 패싱 그 자체는 사실 네트워크, OOP, 등에서도 널리 쓰이는 매우 일반적인 이론입니다. 독립된 chapter가 될수도 있겠네요. TODO

(공룡책 Ch.4 참조) message passing은 기본적으로 두 함수를 제공합니다. send(m), receive(m)이죠. 서로 이름이 있어야 하니, send(P, m), receive(Q, m)이라고도 할수 있습니다. 또는, receive(id, message)와 같은 형태로 아무에게서나 오는 메세지를 받은후 id를 sender의 id로 받게되는 구현도 됩니다. 이와같이 직접 주고받는방식 말고도 mailbox혹은 port라고 불리는 방식은 가운데 공유되는곳을 통해 주고받습니다. 두 프로세스는 여러개의 mailbox를 통해서 communicate할수 있습니다. send(A, m)/receive(A, m)으로 A라는 mailbox를 통해서 주고받습니다. 이런 경우, 예를들어 P1,P2,P3가 A를 공유할때, P1이 보낸 메세지를 P2/P3가 모두 receive할때 누가 받아야할지와같은 문제가 있는것을 알수 있습니다. mailbox를 두 process만이 공유한다던지, 혹은 receive는 한번에 한프로세스만 할수 있다거나, 아무나 받게끔한다거나, 하는 방법이 있겠습니다. 또 mailbox가 프로세스가 소유한 메모리인지, OS가 소유한 메모리인지하는 문제가 있습니다. 프로세스가 가진것이라면, 누가 메세지를 받을것인지하는 문제는 없겠습니다. 다만 프로세스가 죽을때는 메세지가 분실되었다거나 전달될수 없다는것을 다른 프로세스에게 알려야하는 점이 있겠네요. 만약 OS가 mailbox를 가진다면 프로세스가 mailbox를 만들수있게되고, 이 프로세스가 최초의 owner가 되겠죠. 이 owner만이 receive할수 있게하면 되고, 또 필요에 따라 receive권한과 ownership을 다른 프로세스에게 넘길수 있게도 할수있겠죠. 또한 send/receive가 각각 blocking/unblocking일수가 있겠죠. 또한 mailbox의 크기가 (1)0일수. 즉 이경우엔 sender는 block해야만합니다. (2)bounded 메세지가 큐에 쌓입니다. 큐가 꽉차면 sender는 기다려야 하죠. (3)unbounded.

Mach의 경우를 살펴봅시다. mailbox는 port라고 불리웁니다. task가 만들어질때 두개의 특별한 포트가 만들어지는데, kernel port와 notify port입니다. 커널이 task와 얘기할때는 kernel port를 쓰고 event를 알릴때는 Notify port로 보냅니다. 메세지는 보내는 3개의 콜이 있는데, msg_send, msg_receive, RPC를 하는 msg_rpc 입니다. msg_rpc는 메세지를 보내고 정확히 하나의 return message를 기다리는 콜입니다. port_allocate콜은 mailbox를 만들고 할당합니다. 디볼트로 8개 메세지까지 큐할수 있죠. mailbox를 만든 태스크가 주인이 되고, 주인이 receive 권한이 있습니다. 한순간에는 한태스크만이 receive가능하고, 이런 권한은 다른 태스크로 보낼수도 있습니다. 한 sender가 보낸 메세지들은 FIFO순서로 들어가지만, 여러 sender가 보낸 메세지들끼리는 순서가 섞일수 있습니다. 메세지는 고정길이의 헤더와 가변길이 데이터인데, 헤더는 데이터의 길이와 두개의 mailbox이름을 가집니다. 보내는 메일박스와 받는 메일박스라서 받는 태스크는 답변을 보낼때 보내는 메일박스를 return address로 써서 답변을 보낼수 있습니다. 가변길이 부분은 type data items의 리스트인데, 각각 type, size, value로 구성됩니다. send/receive는 유연한 옵션들을 가지고있는데, send는 메세지를 복사한후 태스크가 계속 진행됩니다. mailbox가 꽉찼을때는 네가지 옵션이 있습니다. 1) 기다린다. 2) 정해진시간만큼 기다린다. 3) 바로 return한다. 4) 커널이 임시로 캐시한다. 메세지가 mailbox로 들어갔을때 커널이 태스크에게 Notification을 준다. 단 하나의 메세지만이 이렇게 pending될수 있다. 이 마지막 옵션은 서버태스크를 위한건데, 요청을 처리한후 답변을 client에게 보낼필요가 있는경우, 이 큐가 꽉차있더라도 서버태스크는 계속 다른 요청을 처리해야하므로 이렇게 pending해서라도 보냅니다. receive의 경우 mailbox나 mailbox set에서부터 메세지를 받을수 있는데, mailbox set은 태스크가 정하는 mailbox집합입니다. port_status콜은 mailbox의 메세지의 수를 리턴합니다. 메세지가 없다면 기다릴수도, 정해진만큼 기다릴수도, 그냥 리턴할수도 있습니다. Mach는 distributed systems를 위해서 디자인된것이지만, 단일 machine에서도 쓰일수있는데, 이런 메세지 시스템의 주요 문제는 카피로 인한 poor performance입니다. 이런 double copy를 피하기위해 sender메시지를 received의 주소공간에 바로 매핑해서 실제 카피는 안일어나도록 하는데, 성능에는 좋지만, distributed에선 못쓰는 기법이죠.

여기에서는 Darwin에 대한 자료들을 찾을수 있는데, "Kernel Programming Guide"에서는 Mach에 대해서도 잘 설명이 된듯하니 살펴보면 좋겠습니다. http://developer.apple.com/referencelibrary/API_Fundamentals/Darwin-fund-date.html Darwin에서도 Mach의 메세징 기능은 쓰질 않네요. 수정된 Mach를 사용하고 있습니다. 역시 오버헤드가 만만치 않다는 얘기입니다. 여기에서 타넨바움도 얘기하듯이 진정한 microkernel은 아닙니다.

윈도2000 은 여러 operating environment를 지원하는 subsystem을 가집니다. application은 windows 2000 서브시스템 서버의 client라고 할수 잇습니다. windows2000의 메세징기법은 local procedure call(LPC) facility라고 불리는데, windows2000에 최적화된 RPC라고 할수있습니다. 두 프로세스사이에 통신을 위해서 port object를 사용합니다. subsystem을 부르는 모든 client는 이런 port object 를 이용한 통신채널이 필요합니다. 사용되는 방법에 따라 connection ports와 communication ports라고 나눠부릅니다. connection ports는 objects라고 불리는데, 모든 프로세스들에게 보이는것이죠,이것이 application이 통신채널을 설정할수 있는 길이 됩니다. (1) client가 서브시스템의 connection port object로의 handle을 엽니다. (2) connection request를 보냅니다. (3) 서버는 두개의 private한 통신포트를 열고 그중 하나로의 핸들을 리턴합니다. (3) client와 server는 해당하는 port핸들을 이용해서 메세지나 콜백을 보내고 답변을 listen합니다.

포트를 이용해서 세가지 타입의 메세징기법을 사용하는데, 가장간단한것은 작은 메세지를 위해서 포트의 메세지큐를 임시적 공간으로 사용하고 메세지를 한쪽에서 다른쪽으로 복사하는겁니다. 256바이트까지 지원됩니다. 더큰메세지를 보내려면, section object(or shared memory)를 통해 보냅니다. 채널을 설정할때 client는 큰 메세지를 보낼필요가 있는지 없는지를 결정합니다. 필요하면, section object가 만들어지기를 요청하게됩니다. 마찬가지로, 서버가 답변이 클것을 결정하면, 역시 section object를 만듭니다. 이 section object가 사용될수 있도록 포인터와 사이즈 정보가 담긴 작은 메세지가 보내집니다. 더 복잡하지만 이렇게 data 카피를 줄입니다. 두 경우 모두, client나 server가 당장 반응할수 없을때에는 콜백 기법이 사용될수 있습니다. 콜백으로 asynchronous 하게 메세지를 처리할수 있습니다.

그리고 이제 SysV의 메세지큐에 대해서...TODO

Sockets

주로 네트워크에 쓰이는 소켓. bind, listen, connect, accept등.

 

Remote Procedure Call

이런 IPC(주로 message passing)위에서 RPC를 구현합니다. RPC는 client와 server가 stub이라고 하는 부분을 두어서 마치 local call 인양 추상화를 하고 이 stub이 IPC를 이용해 통신을 하며 call을 하고 결과를 리턴합니다. 파라미터와 데이터를 보내기 위해서 묶는것을 mashalling이라고 하고 받은측에서 다시 풀어서 파라미터를 넘겨받는것을 unmarshalling이라고 합니다. 이기종들 사이에서의 통신일수도 있기때문에 endianness와 같은 문제를 stub이 해결해야합니다. 따라서 데이터를 표현하기위한 machine-independent 방법이 필요해지는데, XDR(external data representation)과 같은것이 그중하나입니다. client가 데이터를 XDR로 바꾸고 server는 그것을 다시 풀어냅니다. local call과는 다르게 네트웤을 통과하기 있기때문에 여러 문제가 발생합니다. 한번만 호출해야한다는것이나, 순서문제등... 또한 RPC서버의 discovery문제도 생깁니다. 어떤 서비스가 어디에 있는지 발견해야 이용할수 있을테니까요. 자바에서는 RMI라고하는 이름으로 제공됩니다. 자바에서는 object가 다른 JVM에 있다면 remote라고 여겨집니다. 따라서 사실은 같은 머신에 있을수도 있죠. RPC와 RMI는 크게 두가지 다른 점을 보이는데, 1)RPC는 procedural programming의 call을 지원하는것이라면,RMI는 object-based라는것입니다. remote object의 메소드를 호출하는것입니다. 2) RPC에서의 parameter들은 일반적인 데이터지만, RMI에서는 object를 넘기는것이 가능하다는것입니다. RMI를 통해서 네트웤에 걸쳐 분산된 자바 어플리케이션을 만들수 있게됩니다. RMI는 stub, parcel, skeleton이라는 용어를 쓰는데, stub은 client쪽에서 RMI를 구현하는 코드이고, 이 stub이 부를 메쏘드의 이름과 마셜링된 파라미터들인 parcel을 만들어서, 보냅니다. 서버쪽에서는 skeleton이 이것을 받아서 unmarshall하고 서버측의 해당 메소드를 호출합니다. 그리고 결과값을 다시 marshall하여 parcel을 보냅니다. stub은 이 결과값을 받아서 client에게 보내죠. 이런것들은 모두 client에게는 transparent합니다만, 그래도 주의할점들이 있습니다. (1) 파라미터가 local object라면 object serialization을 통해 copy를 통해 넘겨집니다. 하지만, parameter가 remote object라면 by reference로 넘겨집니다. (2) 따라서 local object를 넘기려면 그 클래스는 java.io.Serializable interface를 구현해야합니다. Java API의 많은 object들이 Serializable을 구현해놓았습니다.

실제로는 크게 (1) local machine상에서의 RPC를 사용할 경우와 (2) 네트워크를 건너서 사용하는 경우가 있습니다. 전자의 경우 비교적 단순하며, 주로 마이크로커널과 같은 구조에서 protection boundary를 건너기 위해서 구현합니다. 즉 여러 서버(혹은 프로세스)가 각자 자신만의 서비스를 제공할때 해당 서비스를 호출하기 위해서 사용합니다. 예를들어 화일서버가 있다고하면 화일한번 열려면 화일서버에 RPC를 호출합니다. 따라서 local procedure call을 구태여 이런 RPC로 대체하는 이유는, 개개의 서비스를 별개의 protection domain에 넣기위함입니다. 예를들어 리눅스에서 GUI를 담당하는 X서버를 생각해보세요. (마이크로커널 참조) 따라서 이 경우 최대한 local procedure call에 가까운 성능을 내고자하는 연구가 많았습니다. (TODO) 후자의 경우에는 네트워크를 건너 distributed OS를 구현하기 위해서 주로 사용되는데 이런 경우 (1)의 자연스러운 확장이라고 볼수 있죠.

 

Paging

앞에서 본것과 같이 가상메모리는 보통 두가지 방식으로 구현됩니다. segmentation과 paging이 그 두가지이죠. x86의 경우 둘다 사용하는 경우입니다. segmentation을 먼저한후에 다시 paging을 하는 경우입니다. segmentation만 하는 경우(예전 16비트의 경우가 그렇죠)도 있을수있고, paging만 하는 경우(요즘의 64비트 즉 x64)도 있습니다. 사실 x86의 경우에도 하드웨어가 둘다 지원하더라도 보통 OS들(리눅스, 윈도우NT이상)은 segmentation은 꺼버리고 paging만을 사용합니다. 윈도우98은 둘다 사용했습니다. 물론 그래도 segmentation도 유용할 경우들이 있긴합니다만..(TODO) 결론은, 보통 paging만을 이용한다는것입니다. 그리고 OS는 이 page를 메모리관리의 단위로 삼으며, 이 paging을 응용해 다양한 기법들을 구현합니다.

paging을 켜게되면 그때부터 물리 메모리들은 (보통) 4KB크기의 페이지단위로 관리되기 시작합니다. 물론 실제로 그러한 페이지간의 경계가 존재하는것은 아니지만, 가상주소들은 page table을 통해 물리주소로 변환되며, page table은 4KB의 사이즈를 하나의 단위로 취급하게 됩니다.

잘 안쓰이기는 해도, 2MB짜리 페이지크기가 지원되기도 합니다. Superpage라고 불리우는데, ... TODO

Page Fault

만일 process가 정의되지 않은(매핑되지 않은) address를 참조하면 어떻게 될까요? 위의 "Virtual memory"편에서 mapping이 존재하지 않는 구역의 address를 참조하려고 한다면, CPU는 인터럽트를 발생시키게 됩니다. 이것은 page fault라고 합니다. 이렇게 인터럽트가 걸리면 커널의 page fault handler에게 제어가 넘어오고, 커널이 보기에 만일 정말 이 process가 잘못된 주소에 접근하는 것이었다면 이 process는 강제로 종료되게 됩니다. Unix계열에서는 core를 남기고 "segmentation fault"라는 메시지를 남기고 종료되고, 윈도우에서는 흔히보는 "치명적 오류"를 남기고 커널에 의해서 종료됩니다. 즉, 비록 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의 낭비를 피할 수 있게 됩니다.

보통 COW의 특수한경우로 zero page를 들수있습니다. 많은 OS들이 zero page를 사용해서 메모리의 효율성을 높이는데, OS는 zero page 라고하는 0으로 꽉차있는 페이지하나를 read-only로 준비해놓습니다. 그리고 시스템에서 필요로하는 0으로 초기화된 페이지들을 모두 이 페이지로 매핑시켜놓습니다. 즉, 전역변수가 있는 힙영역(BSS)같은 페이지들을 모두 이곳에 매핑시킨후 실제 write가 일어날경우 COW방식으로 새로운 페이지를 할당하게 되는것입니다.

 

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에 의해서 해제됩니다.

Solaris2같은 시스템은 read/write같은 시스템콜조차도 내부적으로 파일을 커널공간에 매핑시킴으로써 사용했습니다. 즉 모든 디스크IO를 memory-mapped file로써 처리한것이죠. 또한 여러프로세스들이 하나의 화일을 매핑하여서 공유를 위한방법으로 사용할수도 있습니다. 또한 mmap()은 자신만의 COW기능도 지원합니다. 즉 다른 프로세스와 write가 공유되지 않게끔 쓰게되면 자신만의 copy가 생기게되죠. 좀더 자세한 사항은 Disk cache편을 살펴보세요.

Page Fault Handler

VM의 핵심적인 부분을 담당하는것이 바로 이 page fault handler입니다. 실제로 시스템이 실행될때 의미있게 자주일어나는 exception은 이 page fault와 system call둘뿐입니다. 나머지는 그다지 자주 일어나지 않기때문이죠. (물론 활발히 IO할때는 외부interrupt도 많이 들어오죠.) OS가 HW로부터 제어를 넘겨받아 메모리에 관한 다양한 일들을 할수 있게되는것이 바로 이 page fault를 통해서입니다. 기본적인 역할은 물론 해당 fault를 해결한후에 실행을 재개해주는것이죠. 즉, demanding page를 구현하는것입니다. 페이지가 존재하지 않을때에는 새로운 페이지를 구해서 연결해주고 protection violation일때에는 즉 read-only에 write를 시도한다든지했다면 적절한 형태의 시그널로 변환하여 전달하거나 심한경우엔 프로세스를 아예 죽이기도 합니다. 바로 segfault가 뜨죠.

사실 하드웨어레벨 (아키텍처레벨)에서 볼때 fault가 뜨는 과정은 그다지 쉽지 않습니다. 즉 부분적으로 수행되었던 instruction이 undo가 되어야하기때문이죠. CISC에서는 이 문제가 더욱 복잡해집니다. 다음과 같은 4개의 메모리 접근을 가지는 하나의 instruction을 살펴보죠.

1. Fetch instruction(ADD)
2. Fetch A
3. Fetch B
4. Add A and B
5. Store the sum in C

만약 C에 대해서 fault가 났다면 handler가 수행후엔 1번 부터 다시 수행됩니다. 만약 handler가 C를 제대로 해결해주지 못했다거나 A에 대한 폴트가 다시 생겼다거나 한다면 같은 지점에서 다시 fault가 실행되며 무한 fault에 빠지게 됩니다. OS를 만들다보면 흔하게 발생하는 버그입니다. 이와 같이 많은 문제가 주로 여러개의 메모리 접근을 가지는 명령어때문에 일어납니다. 예를들어 string을 처리하는 instruction의 경우엔 부분적으로 처리하다가 page boundary를 넘어가면서 폴트가 일어나면 이를 undo하기가 매우 곤란해집니다. 이런 경우 아예 처음부터 지정된 영역전체가 폴트를 내지않는다는것을 확인한후에야 실행을 시작한다거나 임시적인 레지스터를 써서 이전 내용물을 담고있다가 폴트가 발생시에 복구한다던가 할수 있습니다. 이와 유사한 아키텍처상의 많은 문제가 발생할수 있는데, 다음은 PDP-11의 autodecrement/autoincrement mode에서의 문제점입니다. MOV (R2)+,-(R3) 라는 명령어는 R2가 가리키는 메모리의 내용을 R3가 가리키는 메모리로 옮기는데 R2는 사용후에 하나 증가되며, R3는 사용전에 하나가 증가됩니다. 만약 R3가리키는 메모리에서 폴트가 일어났다면, 이를 다시 복구하기 위해서 두 레지스터의 예전값을 모두 복구해야합니다. 해결책으로는 레지스터를 더 도입해서 변화된 레지스터번호와 예전값들을 저장해두었다가 이럴때 복구하는 방법이 있을수 있겠습니다. 이런 방법들은 결국 instruction을 트랜젝션화시키고 싶은것이죠. 그러나 이런 문제들은 instruction의 undo에 대한 일부의 문제들에 불과하며, CISC의 문제점을 잘 보여주는 예들입니다. 이런것들이 CISC의 logic을 복잡하게하고 complexity를 증가시키는 점을 확인할수 있습니다. RISC가 이득을 볼수 있는 부분들이죠. logic이 간단해지고 pipeline이 쉬워진다는점에서 큰 이득을 얻을수 있습니다.

위의 문제는 HW가 처리하는 부분이고, 페이지폴트는 SW가 처리해야할 많은 부분들을 담당합니다. 크게 페이지가 없을때의 문제해결, 즉 demanding page, 그리고 protection violation이죠. 이 과정에서 swapping이나 disk I/O, 버퍼캐시등 많은 요소들이 함께 동작하게되기때문에 좀더 복잡해집니다. 또한 폴트는 꽤나 비싼 동작입니다. 최악의 경우 디스크IO까지 동반하기때문에 심하면 성능을 크게 떨어뜨릴수 있습니다. 일반적으로는 페이지가 메모리에 있을경우 매핑만 새로해주면 되는데 이럴때를 minor fault라고 합니다. 비교적 손쉬운 경우이죠. 반면 DISK에서 가져와야하는경우를 major fault라고 합니다. 이런경우 프로세스를 재운후 DISK IO가 완료된후에야 계속할수 있기때문에 성능에 큰 영향을 끼칩니다.

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

 

 

 



I/O

앞에서 살펴본 CPU의 virtual memory라든지 하는 부분은 CPU가 가진 기능으로 예를들어 x86아키텍쳐라는등의 이름으로 불립니다. 반면 I/O관련 부분은 CPU가 아닌 CPU와 메모리,버스,IO장치들이 연결된 구조를 나타내는 말이라서 보통 PC아키텍처정도로 불립니다. 같은 CPU칩이라도 PC가 아닌 예를들어 맥에서 쓰였다면 맥이 가진 맥의 아키텍쳐에 맞게 OS가 바뀌어야합니다. 이런 시스템에는 PC, Mac외에도 PPC(Power PC)등의 아키텍처가 있습니다. 우리는 여기서 주로 PC아키텍처에 관해서 살펴봅니다.

Memory mapped vs programmed

 

실제 OS의 code에 있어서 I/O를 위한 code가 훨씬 많을만큼 I/O는 다양하고, 그만큼 복잡하기도 한 영역입니다. 리눅스의 경우에도 device driver가 대부분의 코드를 차지합니다. 또한 버그도 보통 driver에 집중되어있습니다. 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를 하는 방식입니다. 실제 CPU의 address bus와 data bus를 쓰긴하지만 동시에 이 주소가 메모리주소가 아닌 포트의 주소라는것을 알리기 위해서 포트제어 신호를 발생시킵니다. 이 IO address space의 각 주소는 각 장비들의 register들에 (버스의 arbitration등에 의해서) hardware적으로 mapping되어 있습니다. 따라서 이 address space에 쓰거나 읽는 것이 그러한 장비들의 register에 쓰거나 읽는 동작이 됩니다. 이때 각 register들에 해당하는 주소들을 port라고 부릅니다. 먼저 CPU는 포트에 값을 씁니다. 예를들어 data register에 한바이트를 쓰고 이제 또다른 포트인 control register의 flag를 켭니다. 그러면 device는 data register의 값을 처리한후 control register의 flag를 끕니다. 이것을 CPU는 polling혹은 interrupt로 확인합니다. 따라서 시리얼포트와 같은 느린 장치에 적합한 방식입니다.

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

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

이러한 PC의 구조에서는 memory mapped I/O와 independent I/O(In/out)방식이 둘다 쓰입니다. 어떤 장치는 in/out명령으로 (포트를 통해) 접근하기도 하고, 어떤 장치는 메인메모리의 (주로) 상위번지에 접근하는 방식으로 접근하기도 하고, 어떤 장치는 둘다를 쓰기도 합니다. 이런 하드웨어에서 주소공간으로의 매핑은 기본적으로 하드웨어가 하고, OS가 재매핑시킬수도 있기도합니다. 오래된 vga의 경우가 좋은 예인데, 예를들어

char *p = 0xB8000;
p[0] = 'A';

라고하면 텍스트화면의 왼쪽 위쪽에 A라는 글자가 나타납니다. p[2] = 'B'; 라고하면 그옆에 B라고 찍히죠. (물론 여기서 0xB8000은 물리주소라서 위으 코드를 그냥 실행해서는 안되고, 이전에 페이지 테이블이 identity-mapping으로 설정되어있어야 하겠습니다. 아래의 /proc/iomem 을 보면 이 주소는 Video RAM area라는것을 볼수 있죠. ) 뭐 이런식입니다. 또한 아마 old user들은 serial port나 parallel port등의 장비들의 COM포트등의 주소를 맞춰주는 등의 설정을 해보신일이 있을 것입니다. 이러한 장비들이 independent I/O방식의 예라고 할 수 있습니다. 독립된 주소공간에 in/out명령을 이용해서 포트에 접근하는데, 예를들어

#define PORT 0x3f8   /* COM1 */

#define inb(port) ({ \
unsigned char _v; \
__asm__ volatile ("inb %%dx,%%al":"=a" (_v):"d" (port)); \
_v; \
})

int serial_received() {
   return inb(PORT + 5) & 1;
}

char read_serial() {
   while (serial_received() == 0);

   return inb(PORT);
}
이 코드는 시리얼포트(COM1)에서 입력을 받는 코드입니다. 여기서 inb()는 in 명령입니다. 이러한 저레벨의 입출력 코드들이 장치 드라이버의 맨 밑바닥에서 실제 입출력을 담당하고 있습니다.

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을 살펴볼 수 있습니다.

 

I/O의 시작을 살펴봅시다. 먼저 CPU가 포트를 통해 장치의 register에 어떤값을 올립니다. 장치는 내용을 살펴보고 행동을 합니다. 작업이 끝나면 인터럽트를 통해 CPU에 알립니다. 여기서 인터럽트를 안쓰고 장치가 flag만을 켠다면, CPU는 polling을 해서 알아내야하겠죠. 폴링의 장점이라고 한다면, 어느 장치가 작업을 끝냈는지가 명확하다는것이죠. 인터럽트의 경우엔 CPU는 어느 장치로부터 온것인지를 가장 먼저 확인해야합니다. 그후엔 해당 device driver는 자신의 I/O queue에서 완료된 작업을 제거하고 다음 작업이 있다면 I/O를 또 시작하겠지요.

Asynchronous I/O

 

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

 

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

 

각 device들은 자신만의 큐를 보통 가지고 있고 거기엔 해당 IO를 요청한후 기다리는 프로세스들의 PCB가 연결되어 있습니다. 이걸 조금 일반화해서, OS내에서의 PCB의 이동경로를 살펴보면 보통 다음과 같이 구현됩니다. PCB는 하나의 프로세스를 대표하기때문에, 프로세스가 뭔가를 기다릴때는 해당하는 큐에 들어가는것이라고 보면 됩니다. 대표적으로 run queue에서 실행을 기다릴때 PCB는 런큐에 있고, 타이머등의 이벤트를 기다릴때도 역시 타이머 큐에 들어갑니다. 마찬가지로 IO가 완료되기를 기다릴때도 큐에 들어가서 대기합니다. 커널내에서는 프로세스는 이처럼 여러 큐들을 옮겨다니며 이벤트를 기다리는거죠.

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라고도 불리웁니다.

사실 디스크도 그러한 작업을 합니다. 디스크의 캐시는 비교적 크기가 작기때문에 캐시역할보다도 이러한 optimization을 통해 seek를 줄여준다는데 더 큰 의의가 있다고 하겠습니다. (제 생각!) 하겠습니다. OS와 디스크 모두 서로 이러한 optimize를 하고 있는 셈입니다.

요즘의 디스크들은 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

 

Direct memory access(DMA)

 

DMA이전에는 I/O장비들이 메모리에 접근하기 위해서는 위에서 살펴본바와 같이 CPU가 그 작업을 해주었습니다. (device가 직접 메모리에 쓸수도 있겠지만, CPU의 허가를 먼저 받아야겠죠? CPU몰래 메모리를 건드리면 안되죠! 사실 이게 DMA인셈입니다.) 이는 CPU에 많은 부하를 주게 되고, 특히 메모리에서의 copy등은 CPU에 많은 부담을 주게 됩니다. 이를 위해 I/O장비가 CPU와 상관없이 메모리에 직접 읽고 쓸 수 있게 하기 위해서 개발된 것이 DMA입니다. CPU는 DMA를 이용한 메모리 접근의 시작과 끝등을 제어하기는 하지만 그외의 실제적인 메모리 접근등은 CPU모르게 이루어지게 됩니다. 이를 통해 CPU는 I/O와 메모리간의 작업에서 해방될 수 있게 됩니다.

DMA controller는 DMA작업이 시작되면 CPU가 메모리를 사용하지 않을 때(예를들어 decode와 execute)를 틈타서 메모리에 접근하여 자신의 일을 계속 수행합니다. 이를 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의 갯수는 제한되게 됩니다. 일반적으로 16개에서 64개의 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)에서와는 달리 여러 가지가 바뀌어야 합니다.

 

가장 먼저 캐시와 관련된 문제를 살펴보면, SMP의 캐시는 MP의 캐시에서와는 다른 여러 가지 문제가 발생합니다. 캐시간에 cache line sharing이 발생할 수 있고, write될 경우 cache coherency protocol을 써서 맞춰줘야합니다. 이것은 interconnect traffic을 더 증가시키게 되죠.

MP의 경우 cache line size가 커질 때 miss rate가 급격히 떨어지는 현상이 있는데, SMP의 경우 이런 현상이 잘 나타나지 않습니다. 이것은 false sharing 때문인데 ("False sharing and Spatial Locality in Multiprocessor Caches"), 이와 같이 cache line size는 spatial locality와 관련깊습니다.

일반적인 miss rate와 cache line size간의 관계는?

 

False (cache line) sharing

SMP환경에서는 MESI와 같은 cache coherency protocol을 쓰는데, 이것은 cache line단위로 이루어지므로, 캐시간 공유되는 하나의 cache line에 대해서 여러 processor가 write를 하게되면 서로의 line을 invalidate시키게 됩니다. 그러나 서로 상관이 없는 변수들에 대해서 write할 때 이들이 같은 line에 있다면 불필요한 cache line invalidation이 이루어지게 되겠죠. 이것이 성능을 크게 저하시킬 수 있습니다. 공유되지 않는 변수들이 같은 캐시라인에 들어가서 활발히 update될 때 문제가 되죠.

http://chooyu.cs.uiuc.edu/iacoma-papers/false_sharing.pdf

 

따라서 SMP에서의 spatial locality가 관련됩니다. 공유되지 않는 자료들은 조심스럽게 서로 다른 cache line에 놓여야합니다.

http://www3.intel.com/cd/ids/developer/asmo-na/eng/dc/threading/knowledgebase/43813.htm

 

SMP환경에서의 이러한 locality문제가 Tornado 논문에서 논의됩니다. OOP개념을 써서 커널을 작성함으로써 SMP환경에서의 locality를 최대화한다는 개념입니다. 이 논문의 [23]번 reference의 경우 OOP를 써서 user space에서의 locality를 올리기 위한 방법을 얘기합니다. clustering/declustering objects라고 하네요. Tornado는 clustered object라는 것과 이들간의 synchronization을 위해서 semi-automatic garbage collection scheme for clustered objects을 씁니다. (이건 RCU와 언뜻 비슷해보입니다.) 이를 위해 사용된 [22]번 SMP용 KMA도...

 

따라서 SMP에서 메모리를 어느 위치에 놓을까는 다음과 같겠죠

1) cache sharing cost을 피하기 위해, 특히 false sharing을 피하기 위해 공유되지 않는 data는 다른 cache line에 놓아야 합니다. user space에서와 kernel space에서 각각 생각해보면, ... 위의 두 논문이 각각에 해당된다고 할 수 있겠죠.

2) cache affinity를 위해서 캐시에 내용이 남아있는 thread를 실행해준다.

 

 

** 이걸 RCU에 적용하면?? RCU가 커널에서 false sharing을 일으키지 않을까?

** 캐시에서 prefetch안하나? 코드일 때말이지.

 

보통 캐시에 있는 한 thread가 최근 사용하는 데이터들을 해당 thread의 footprint라 합니다. 처음 thread가 스케쥴된후엔 많은 cache miss가 발생하죠. "Using Processor-Cache Affinity Information in Shared-Memory Multiprocessor scheduling"에서 이런 cold cache가 성능을 얼마나 저해하는지 나옵니다.

더 심각한 것은 processor가 빨라질수록, cache miss의 penalty가 점점 커진다는 것이죠. 즉 폰노이만 병목현상은 점점 심해지는 것이죠. (뭐 병렬성으로 어느정도 극복한다지만..)

 



File System

 

화일하나는 여러 속성들을 함께 가지고 있습니다. 이름, inode같은 Identification, type, 위치정보(어느 device의 어디에있는지), size, access-control info, time/date. 와 같은 정보들이죠. 이런 정보들을 메타정보라고 하죠. 보통 이중 이름과 ID가 디렉토리에 저장되고 이 ID에 의해서 다른 정보들을 찾아갈수가 있게되어있습니다. 또한 화일에 대한 operation은 create, write, read, seek, delete, truncate 정도가 있죠. 그외에도 append, rename, 속성변경, 등의 명령이 더 필요합니다. 화일을 열면 open file table에 화일이 등록되죠. 여기에 current file pointer 같은정보가 들어가게됩니다. 이런것들은 per-process 자료구조이고, 이들은 다시한번 system-wide한 per-file 자료구조로 연결됩니다. 즉 inode입니다. 여기엔 공유되는 파일별 정보들이 다 있죠. 디스크상의 위치나 파일 사이즈같은것들이죠.

TODO: soft link/hard link

Linux와 같은 현대 OS는 여러가지 file system을 지원하고, 이들을 그 윗단계인 VFS(virtual file system)에서 마운트해서 씁니다. 그래서 사실 여러단계의 layer를 이루고 있는데, 첫번째가 VFS,두번째는 개개의 file system, 그밑에 block device, device driver라고 할수 있겠습니다. TODO:그림 여기여기에 좋은 자료가 있습니다.

OS는 보통 meta data의 일부를 캐쉬하고있습니다. 더 빠른 검색을 위해서지요. 버퍼캐시와는 별도로 존재하는 캐시입니다.

Log-structured File System

Log-structured File System, 혹은 Journaling이라고 부릅니다. Transaction의 아이디어를 File system으로 가져온것이죠. system crash로 인한 inconsistency를 막을수 있고 recovery가 빠르다는 장점이 있습니다. NTFS는 메타데이터의 업데이트를 위해서 log-based-recovery기법을 사용합니다. 기본적으로 모든 메타데이터의 변화들은 로그로 시퀀셜하게 쓰여집니다. 한가지 작업을 하는 여러동작들의 집합이 하나의 트랜젝션이 됩니다. 한번 이것들이 로그로 쓰여지면 commit된것으로 간주하고 프로세스는 수행을 계속합니다. 그동안 이 로그엔트리들은 실제 화일 구조체들에 replay됩니다. 변화가 수행될때마다 포인터가 업데이트되어서 어느 것들이 완료되었고 아닌지를 나타냅니다. 전체 트랜젝션이 완료되었을때 로그화일에서 제거됩니다. 이 로그화일은 환형버퍼인셈입니다. 로그는 화일시스템의 특별한 구역에 있을수도 있고, 심지어 다른 디스크상에 있을수도 있습니다. 다른 디스크상에 있다면 좀더 복잡하겠지만 더 효율적이 될겁니다. head contention이 줄어들고 seek time이 줄어드니까요.

시스템이 crash되면 로그화일에는 트렌젝션이 남아있을텐데 이것들은 OS가 commit하였지만 아직 완료되지 못한것들입니다. 그래서 이것들을 반드시 완료해야합니다. 파일시스템은 여전히 consistent함을 유지하게 됩니다. 유일한 문제는 트렌젝션이 abort된때입니다. 즉, crash이전에 commit되지 않은것이죠. 파일시스템에 적용된, 이 트렌젝션으로부터의 모든 변화들은 undo를 해야합니다. 그러면 다시 consistency를 유지하게 됩니다. 메타데이터에 로깅을 사용하는것의 또다른 잇점은 훨씬 빠르게 수행된것처럼 보인다는 것입니다. sequential이 빠르다는 점때문에 그런거죠. 비싼 synchronous random metadata write가 훨씬 싼 synchronous sequential write로 변화한겁니다. 이들은 결국 asynchronous하게 random write도 replay되게 됩니다. 결과적으로는 file 생성이나 삭제같은 metadata-oriented operations에있어 큰 이득을 보게됩니다.

Distributed File System

NFS는 구현이기도하면서 동시에 spec이기도 합니다. 다른 머신의 화일시스템을 local fs에 mount해서 씁니다. 특히 이미 다른 머신의 fs을 mount해서 쓰고있는 다른 머신의 fs를 local에 mount하는, 즉 이중으로 마운트되는 경우를 cascading mount라고 합니다. NFS spec은 mount기법으로 제공되는 서비스와 실제 remote-file-access 서비스를 구분하고 있습니다. 이에 따라 두개의 프로토콜이 정의되는데, mount protocol과 NFS protocol입니다. 이들은 RPC들의 집합으로 정의됩니다. 이러한 RPC들이 building block입니다.

file sharing을 위해서는 consistency semantics가 필요합니다. 즉 여러 사용자가 하나의 화일에 접근할때 어떤 sematic을 가지는지를 나타냅니다. 특히 데이터의 수정이 가해질때 다른 사용자에게 언제 보여질지가 관건이죠. file session을 다음과 같이 정의합니다. open뒤에 read/write등의 접근이 오고(혹은 없고) close가 따라나오는 시퀀스. UNIX Semantics란 다음과 같습니다. *한사용자에 의한 write는 즉시 이 화일을 동시에 열고있는 다른사용자에게 보여진다. *file pointer를 공유하는 모드가 있습니다.즉,한사용자가 포인터를 움직일때 다른사용자도 영향을 받습니다. 이러한 UNIX semantics에서는 화일이 exclusive resource처럼 취급되기때문에 공유하는 프로세스들은 기다리게됩니다. AFS(Andrew File System) 은 다음과 같은 시멘틱을 사용합니다. Session Semantics라고 합니다. *write가 즉시에 다른 open하고있는 사용자에게 보이는것은 아니다. *화일이 닫히면 그 파일에 이루어진 변화들은 그 이후에 시작되는 세션에서만 보이게된다. 이미 열린 세션들에는 반영되지 않는다. 이런 시멘틱에 따르면, 화일은 임시적으로 여러 다른 이미지와 연관될수가 있습니다. 결과적으로 여러 사용자가 동시에 읽고쓸수있죠. 또다른것으로 Immutable-Shared-Files Semantics가 있습니다. 여기서는 일단 화일을 생성하는자가 shared라고 선언되면 수정될수가 없습니다. 이런 immutable file은 그 이름이 재사용될수 없고 내용이 바뀔수없게됩니다. 따라서 화일의 이름이 데이터의 저장고라기보다 내용물 이 고정되었다는것을 의미하게 됩니다. (TODO) 따라서 그 구현이 간단해집니다.

reliability는 보통 redundancy에 의해서 제공되죠.

access control을 위해서 access-control list를 만들수 있습니다. 허가권을 가진 사용자와 그 권한을 나타내는 리스트죠. 상세한 컨트롤을 제공할수 있다는 장점이 있지만, 문제는 그 길이가 너무 길어질수가있다는것이죠. 이 정보를 디렉토리에 넣기에는 문제가 생깁니다. 사용자가 새로생길때마다 전체를 업데이트할수도 없고요. 그래서 더 간단히 owner/group/other 의 구조를 씁니다. Solaris 2.6이후와 같은 경우처럼 때로는 이 두가지를 함께 사용하기도 합니다. 이때 'ls'명령은 -rw-r--r--+ 와 같이 뒤에 +를 붙여서 optional한 ACL이 더 있음을 표시합니다. 그리고 setfacl, getfacl명령이 제공되어 ACL을 관리하게됩니다. 이런경우에 권한간의 우선순위를 정하는것도 결정할일입니다.

 

각 태스크입장에서 보면, 주요한 필드로는 task_struct안의 두개의 필드가 있습니다. umask (새로운 파일을 만들때의 기본 모드이죠), 루트와 pwd에 대한 정보등 파일시스템에 대한 정보인 fs_struct *fs; 필드와 file descriptor등 열린 화일에 대한 정보인 files_struct *files; 필드죠. TODO


Shared Memory Machine

Shared-memory machine, NUMA ,...

 

 

Clustered Systems

일반적으로 여러 머신들을 LAN등으로 가깝게 묶고 storage정도를 공유하게하여 사용하는 방식을 클러스터링이라고 합니다. 하나쯤이 죽어도 시스템이 살수있으니 availability가 높다고 얘기합니다. 클러스터 컴퓨팅.

RAID와 SAN(Storage Area networks)...

 

 

Distributed Systems

어떤 시스템들은 메모리를 공유하지 않으면서 network등을 통해 정보를 교환하며 시스템을 구성합니다. loosely-coupled system이라고 부르죠. 이런 시스템에선 OS가 다른 방식으로도 구현되는데,한가지는 distributed OS라고 하는, 전체 시스템이 하나의 OS로 돌고있는것처럼 보이게하는 구성법이라면, 더 loose한 경우로는 file sharing등을 구현하고 각 프로세스들이 서로 대화할수있게끔해주는 network OS라고 부릅니다.

 

 

 

Real Time

최근의 hand-held device들에는 보통 realtime 개념이 쓰입니다. 로봇 컨트롤이나 과학실험 혹은 산업체의 기계들과 같은 반드시 정해진 시간요구를 지켜야하는 환경에서 쓰이죠. 보통 hard/soft로 나눠서 얘기하기도하지만 hard가 진짜 RT환경인거죠. 이런 환경에서는 디스크도 잘 안쓰고 ROM에 넣어놓고 쓰며, Virtual memory같은 고급기능같은것도 없습니다. time sharing system은 real time과는 안어울리니 VM같은것도 제대로 안쓰입니다. embedded환경중에서도 특수한 경우들이라고 할수 있습니다. DOS같은것이 이런 영역에선 잘 쓰일수 있겠죠. 이런 엄격한 환경에서는 보통 process가 스케쥴러에게 자신이 일을 끝마치기 위해서 필요한 CPU time과 deadline을 알려줍니다. 그러면 스케쥴러는 가능할때에만 수행해주고, 불가능하다면 reject해버립니다. 이렇게 자원을 미리 예약해서 deadline을 맞추는데, resource reservation이라고 합니다. 이를 위해서는 스케쥴러는 각각 타입의 일들이 얼마의 시간이 걸릴지 예측가능해야 하죠. 그래도 soft-RTOS같은게 조금은 OS답죠. 제한적으로 RT영역에서 쓰입니다. 최신 기능들을 필요로 하는 환경들입니다.

http://www.drdobbs.com/184402031참고해서 리눅스에서 리얼타임 프로그래밍을 해봅시다. 먼저 타이머의 resolution. 그중 nanosleep()은 timespec구조체가 nanosec단위이긴 하지만 커널은 매 jiffie마다 타이머를 체크할뿐이라서 절대 nanosec단위의 resolution이 아님을 유의하세요. nanosleep()이 기존의 sleep()/usleep()보다 좋은점중 하나는 시그널때문에 -1을 리턴 하는 경우 (errno==EINTR) 두번째 인자에 깨어날 시간까지의 남은 시간이 들어간다는 점입니다. 그래서, while(nanosleep(&t, &t)); 하면 시그널과 상관없이 최소한 지정된 시간을 기다릴수 있다는 것이죠. 더 resolution이 높은것을 위해서는 /dev/rtc를 씁니다. 2HZ부터 8192HZ까지 2배수 단위로 설정될수 있는데, read()명령으로 장치에서 오는 인터럽트를 기다릴수 있습니다. 단점은 주기가 2배수라는 것과 이 장치는 한번에 하나의 프로세스만이 쓸수 있다는점이죠. 스와핑에 대해서는 물론 기능자체를 아예 꺼버릴수도 있겠지만, mlock()으로 주소범위내의 페이지들을 메모리에 묶어놓을수 있습니다. 프로세스가 끝나거나 munlock()이 불릴때까지죠. 전체 프로세스를 묶고 싶으면 mlockall()함수를 씁니다. ( MCL_CURRENT|MCL_FUTURE 플래그로 현재의 공간과 미래의 할당을 모두 묶으면 됩니다. CAP_IPC_LOCK capability 필요.) 그외에도 realtime 프로그래밍을 위해서는 mutex등의 동기화와 thread관리를 좀더 공부해야겠습니다.

지금은 이름이 바뀌었지만 RTLinux라는것이 있는데, 이 프로젝트 역시 virtualization과 비슷하게 linux를 thread로서 돌리면서 real-time task는 리눅스와 따로 우선적으로 실행시키는 구조이군요. 자신들만의 새로운 API들을 제공하여 그를 이용해서 프로그래밍하게 되어있습니다.

기존의 리눅스 타이머는 jiffies기반의 타이머이기 때문에 resolution이 millisec단위였으며 부정확한면이 많았습니다. 여기에 자세한 설명이 있군요. 이를 해결하기 위해 HRT(High Resolution Timers)가 도입됩니다. jiffies값과는 별개로 좀더 정확한 하드웨어를 사용해서 타이머를 구현합니다. 그렇다고 커널 전체가 HRT를 쓰는것은 아니고 CONFIG_HIGH_RES_TIMERS 가 설정된 이후에도 itimers, POSIX timers, nanosleep의 세가지만이 micro-sec수준의 high resolution을 가지게 됩니다. (커널 내부엔 hrtimer_init(), hrtimer_start() 가 사용됩니다.) 예를 들어 select()는 HRT를 사용하지 않습니다. TODO  

 

 

User Space

최초의 process는 init이라는 프로세스입니다. 자연히 모든 프로세스들의 부모역할을 하게 됩니다. 커널내에 hardcode되어있는데, 보통 /bin 이나 /sbin 등에 있는 init이라는 화일을 순차적으로 검색해서 실행하죠. 자연히 pid는 1을 가지며, user space를 만들어내는 일을 합니다. 두가지 방식으로 실행되는데, 다음과 같이 자신의 pid를 검사해서 1 이라면 부팅시의 init역할을 하며, 1이 아니라면 이미 부팅된 이후이므로 command line을 통해서 run level을 바꾸는 역할을 합니다.

if (getpid() == 1) {
	maxproclen = strlen(argv[0]) +1;
	for (f=1;f<argc;f++) {
		if (!strcmp(argv[f], "single"))
			dfLevel = 'S';
		else if (!strcmp(argv[f], "-a") ||
			 !strcmp(argv[f], "auto"))
			putenv("AUTOBOOT="YES");
		else if (!strcmp(argv[f], "-b") ||
			 !strcmp(argv[f], "emergency"))
			emerg_shell = 1;
		else if (strchr("0123456789sS", argv[f][0])
			&& strlen(argv[f]) == 1)
			dfLevel = argv[f][0];
		maxproclen += strlen = strlen(argv[f]) + 1;
	}
	maxproclen--;

	argv0 = argv[0];
	argv[1] = ((void *)0);
	setproctitle("init boot");
	InitMain(dfLevel);
}

[from Linux kernel Internals]

이런식인데, init의 pid가 항상 1이라는것에 의존하는것을 볼수 있죠. 이후에 다음과 같은일들을 한다고 하는군요.

    * Run /sbin/initlog
    * Run devfs to generate/manage system devices
    * Run network scripts: /etc/sysconfig/network
    * Start graphical boot (If so configured): rhgb
    * Start console terminals, load keymap, system fonts and print console greeting: mingetty, setsysfonts
      The various virtual console sessions can be viewed with the key-stroke: ctrl-alt-F1 through F6. F7 is reserved for the GUI screen invoked in run level 5.
    * Mount /proc and start device controllers.
    * Done with boot configuration for root drive. (initrd) Unmount root drive.
    * Re-mount root file system as read/write
    * Direct kernel to load kernel parameters and modules: sysctl, depmod, modprobe
    * Set up clock: /etc/sysconfig/clock
    * Perform disk operations based on fsck configuration
    * Check/mount/check/enable quotas non-root file systems: fsck, mount, quotacheck, quotaon
    * Initialize logical volume management: vgscan, /etc/lvmtab
    * Activate syslog, write to log files: dmesg
    * Configure sound: sndconfig
    * Activate PAM
    * Activate swapping: swapon

[from http://www.yolinux.com/TUTORIALS/LinuxTutorialInitProcess.html]

따라서 runlevel이라든가 getty등을 실행하여 사용자의 입력을 받는것등은 모두 커널밖의 user level에서의 일들임을 알수 있습니다. 보통 SysV의 init을 따라하는 것들이죠. 실제로 유저가 보는 많은 부분들, 예컨대 GUI나 시스템 administration등은 사실상 커널과는 별 관련이 없습니다. 리눅스기반이라 하더라도 안드로이드나 우분투의 경우에서와 같이 상당히 다른 user experience를 주는것은 사실상 user level에서의 변화들이죠. 예를들어 최근 upstart가 ubuntu를 비롯한많은 배포판에서 이 init을 대체하기 시작했죠. 즉, 유저공간을 어떻게 디자인하는지가 커널 못지 않게 중요하다는 것을 알수 있습니다. GUI는 말할것도 없이, 우분투에서는 X를 버리고 wayland로 바꾸려는것 같더군요. Gnome, KDE등의 데스크탑 환경은 또 말할필요가 없겠죠.

흥미로운 시스템콜을 살펴봅시다. ioperm / iopl 과같은 io관련 syscall로는 process가 포트에 직접 접근가능합니다. 하지만 Linux에선 현재 둘다 제거된 상태죠. http://tldp.org/HOWTO/IO-Port-Programming-2.html 를 참고하시기 바랍니다. Q) 하지만 process가 인터럽트는 어떻게 받나요?? 또 modify_ldt 라는 시스콜은 WINE구현중에 필요에 의해서 추가된 경우입니다.

 

GUI

역사적인 혁명중의 하나였던 GUI는 보통 event-driven방식의 프로그래밍으로 구현됩니다. 이와 같은 모델에서는 이벤트(혹은 메세지)를 처리하는 loop를 두고 받아온 이벤트에 따라서 반응하는 구조로 되어있습니다. Windows API를 이용해 메세지 프로그래밍이 그 좋은 예를 보여주죠. 아래는 WinMain() 에 있는 전형적인 메세지 루프입니다.

int APIENTRY _tWinMain(HINSTANCE hInstance,
                     HINSTANCE hPrevInstance,
                     LPTSTR    lpCmdLine,
                     int       nCmdShow)
{

	...

        while(GetMessage(&msg, NULL, 0, 0))
        {
            TranslateMessage(&msg);
            DispatchMessage(&msg);
        }
        return msg.wParam;
}


LRESULT CALLBACK WndProc(HWND hWnd,UINT iMessage,WPARAM wParam,LPARAM lParam)
{
	switch(iMessage) {
	case WM_DESTROY:
		PostQuitMessage(0);
		return 0;
	}
	return(DefWindowProc(hWnd,iMessage,wParam,lParam));
}

물론 Xlib을 이용한 GUI 프로그래밍에서도 역시 이벤트 기반의 프로그래밍을 하며 아래와 같이 루프에서 이벤트를 처리합니다.

    /*  Enter event loop  */

    while ( 1 ) {
	static char * message = "Hello, X Window System!";
	static int    length;
	static int    font_height;
	static int    msg_x, msg_y;

	XNextEvent(display, &report);

	switch ( report.type ) {

	case Expose:
		...
	    break;

	case ConfigureNotify:
	    break;
		...
	case ButtonPress:            /*  Fall through  */
	case KeyPress:
	    /*  Clean up and exit  */
		...
	    exit(EXIT_SUCCESS);
	}
    }

이러한 이벤트는 시그널(혹은 인터럽트)와는 비슷하기도하지만 실제로는 무척 다릅니다. 가장 먼저 asynchronous인 시그널과는 달리 synchronous 하다는점이 가장 큰 차이점이죠.

 

Part II

OS Revisited

이번에는 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마다 다를 수 있다는 것입니다.

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

전통적으로 unix가 monolithic kernel이라면 CMU에서 개발한 Mach가 Microkernel의 첫 대표주자라고 할수 있습니다. 이후 차세대의 microkernel인 L4가 등장합니다. 마이크로커널의 주요 기능은 시스템보호와 서버들간의 communication입니다. 사실상 이 두가지가 전부입니다. mechanism과 policy의 철저한 분리라고 할수 있겠습니다. 이중 communication은 message passing을 통해 이루어집니다. 예를들어 화일을 열고싶다면 client는 file server에 커널을 통해 메세지를 보내게됩니다. 따라서 커널이 아주 작기 때문에 시스템이 extensible해집니다. 새로운 기능을 추가하거나 고칠때 user space에서 작업하면 되니까요. 커널이 작다는 사실 또한 매우 큰이득입니다. 주요한 이점은 아무래도 서버들이 죽더라도 시스템 전체는 살아있다는점이죠. Mach kernel은 UNIX시스템콜 interface를 메세지로서 구현해서 유닉스 서버에게 전달합니다. MacOS X 같은 경우가 대표적으로 Mach위에서 구현된 경우죠. 즉 유닉스를 어플리케이션으로서 돌리는거죠. (다만 MacOS는 실제적으로는 마이크로커널이 아닙니다.)

Hurd는 GNU Mach에서 돌고있는 서버들의 집합이라고 할수 있죠. http://lwn.net/Articles/395150/

QNX같은 마이크로커널로 구현된 RTOS도 있습니다. Windows NT가 자신들은 hybrid kernel이라고 하는데, 잘 모르겠네요. Win32와 OS/2, 그리고 POSIX를 돌릴수 있다고 하는데, 각각을 실행할수 있는 서버가 있다고 합니다.

최근 타넨바움의 Minix3 와 관련 페이지들을 살펴보시면 마이크로커널에 대해서 더 잘 알수 있을겁니다. 물론 Linus와의 논쟁도 빼놓을수 없죠. Debate1 Debate2 그중 Can We Make Operating Systems Reliable and Secure? 라는 페이퍼가 괜찮은 overview를 보여주고 있군요.

생략...

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

이 외에도 exokernel과 같은 형식의 커널이 있는데 이것 역시 커널의 기능을 극도로 최소화 시키는 형식중 하나입니다.

이것은 virtual machine과 함께 다룰수 있기때문에 다음에 논의하겠습니다.

 

 


Threads, layers, and boundary

 

시스템의 기본 개념인 threads와 layering에 대해서 다시한번 생각해보겠습니다. 각 thread들은 한 시점에 여러 수준의 layer위에서 돌고 있고, 여러개의 thread가 여러 수준에서의 공유를 하면서 돌고 있습니다. 즉, 제일 밑바닥이 하드웨어 라면, 그 위에서 hypervisor, 또 그위에서는 OS kernel이 공유되고 있고, 다시 library가 공유되고 있습니다. OS는 전통적으로 공유되어왔죠. 즉, 프로세스들은 시스템콜을 통해 OS를 공유하고 있는것입니다. 이를 위해 Linux의 경우도 최근들어 MP를 효과적으로 지원하고있고, library도 비교적 최근들어서 thread-safety하게 지원되고 있습니다. thread들이 라이브러리를 공유하는것등은 malloc/free를 생각해보면 알수 있고, 프로세스들이 OS를 공유하는것은 preemptible kernel을 생각해보면 알수 있습니다. 역시 마찬가지로 여러 vm이 hypervisor를 공유하고 있는것 역시 hypercall을 통해 알수 있고요, hypervisor가 하드웨어를 multiplexing하는것 역시 같은 맥락에서 생각할수 있습니다.

즉 이와 같은 구조는 system의 각 layer에서 모두 반복되고 있다는것을 알수 있습니다. 당연하겠지만 이런 모든 구조에서 공통적으로 하위의 공유되는 layer를 사용하기 위해서는 synchronization이라는 비용과 boundary crossing이라는 비용을 지불해야합니다. 각 layer를 넘나드는 boundary crossing은 시스템 전체에서의 주요한 오버헤드중의 하나입니다. 라이브러리 콜이나, 시스템콜, hypercall등이 모두 이러한 오버헤드들이죠. 비단 이러한 software stack에서의 layer간의 boundary뿐만 아니라, protection domain간의 이동 (process간의 context switching같은것)들이 대표적인 boundary crossing의 예입니다. 따라서 이러한 비용을 최대한 줄이기 위해서는 layer간의 이동이나 protection domain간의 이동을 최대한 피해야합니다. 간단한 동작같은 경우는 직접하거나 바로 밑의 layer에만 내려가면 되겠지만, 어떤경우는 깊이 내려가야하는 경우도 생깁니다. 예를들어 memory management같은 서비스는 가장 아래까지 내려가야하는것이죠. 심지어 I/O같은 경우는 가장 밑바닥인 H/W까지 내려갔다가 오는 것입니다. CPU-bound job은 결국 layer를 내려가지 않는다는 얘기고, 그래서 수행이 빠르겠죠. IO-bound job이란 결국 layer를 많이 내려가고 있다는 얘기입니다.

또 하나 생각해볼수 있는것은 이러한 layer들 간에는 속도차이등의 차이/이질성이 존재할수 있다는 것입니다. register보다 D-ram이 느리고, Dram보다 디스크가 느리듯이 커널혹은 hypervisor는 HW가 일을 마치기를 기다려야하고, application 은 마찬가지로 kernel이 일을 마치기를 기다려야합니다. 각 경우에 따라서 이러한 latency는 천차만별일수 있습니다. 디스크는 power-up을 위해 때로는 긴 시간을 기다릴수도 있고, 만약 마침 같은 track위에 헤드가 있다면 짧은 시간이 걸릴수도 있을것이고, kernel은 버퍼캐시에 hit이 되면 빠르게 요청에 응답할수도 있겠지만, 아니라면 HW까지 가야해서 더 시간이 걸릴수도 있을것입니다. 따라서 디자이너는 layer와 protection domain결정에 있어서 이러한 사항들을 고려해야합니다.

여기에 덧붙여 synchronization의 문제가 발생합니다. 각 컴포넌트들의 공유자원을 최소화해야지만 이 overhead를 최소화할수 있습니다. 이를 위해서는 시스템 전체에 대한 모듈화가 필수적입니다. 따라서 시스템 디자이너는 application의 특성에 따라 이러한 비용과 tradeoff들을 잘 고려하여 시스템을 설계해야하는거겠죠. 이에 따라서 몇가지의 시스템 철학이 나오게 됩니다.

성능의 관점에서는 최대한 이러한 boundary crossing을 피해야합니다. 즉, 하위 layer로의 call을 최소화하며, 최대한 자신의 protection domain을 넓게 유지해야할것입니다. monolithic kernel 과 같이말이죠. 이것에 대한 비용은 안정성이나, 유지보수등이 됩니다. 반대로 microkernel과 같은 경우가 바로 protection domain을 최대한 작게해서 시스템을 운영한다는 철학이 되겠습니다. 이에 따라 communication overhead, 즉 boundary crossing의 부하가 발생합니다. RPC등의 연구가 이에 해당하는 연구라고 할수 있습니다. 이러한 overhead를 최소화하면서 최소한의 protection domain을 추구하는것입니다. 이러한 철학들이 시스템의 구조를 결정하게 됩니다.

 

 


Scalability

커널이 scale하지 않다는 걱정이 많습니다. 'An Analysis of Linux Scalability to Many Cores (OSDI2010)'를 통해서 scalability에 대해서 생각해봅시다. scalability를 해치는 주요한 요인들은 이정도가 되겠군요. Amdahl's law에 따라 serial portion의 중요성이 강조되는 부분입니다. 예를 들어 25%가 serial하다면 코어가 아무리 많아봐야 성능은 4배를 넘지못하게 됩니다.
1) Locks (serialization)
이를 해결하기 위해서 lock-free algorithm이라든지 RCU등을 쓰게되죠.

2) Cache coherency
Atomic instruction등이 일으키는 cache coherency traffic이 증가합니다.

3) False sharing

4) NIC (network queue)
하드웨어 자체가 scale하지 않는 경우도 있습니다.

5) Limited cache resources
캐시가 작은것은 어쩔수가 없군요.

6) idle cores
코어가 많아도 다 쓰질 못하는군요.
TODO 주요한 해결책들로 제시하는것들이 결국은 per-core data structure를 통해 sharing을 줄여나가는것인데요.. TODO Corey나 Barrelfish등의 해법도 살펴봅시다.

 


Linux

Unix계열의 대표주자 Linux가 걸어온 길을 보면 상당한 공부가 됩니다. 이 책 전체에서 Linux를 염두에 두고있지만, 이번엔 2.6버전에서 들어온 많은 변화들을 살펴봅시다. 지금 되돌아보니 2.4버전까지는 꽤나 단순한 커널이었다는 생각이 드는군요. 아래의 리스트만 봐도 알수있듯이 2.6버전은 획기적인 변화가 많았습니다. 여기의 기사를 통해 간략하게 되짚어 봅시다.

(1) O(1) scheduler
O(1)스케쥴러는 그 알고리즘보다도 per-CPU runqueue로 바뀌었다는점 등이 큰 변화인것 같습니다. 자연히 load-balancer도 도입되었습니다. 이를 통해 runqueue contention도 없애고, cpu간에 task가 ping-pong하는것도 없어지죠. 즉 affinity가 좋아졌다는 것입니다. 이는 곧 캐시를 보다 효율적으로 쓴다는 것이죠.
(2) preemptive kernel
커널이 드디어 preemptive해졌습니다. SMP지원과 관련해서 꼭 필요하던 기능이었는데, 큰 변화입니다. SMP spinlock을 preemption marker로 씁니다. lock이 걸려있을때는 preemption이 disabled되는것이죠.
(3) latency improvements
lock도 훨씬 작게 쪼개졌습니다.
(4) redesigned block layer
I/O스케쥴러도 새로 도입하고, buffer_head 구조체를 bio구조체로 바꿨습니다.
(5) improved VM subsystem

(1) rmap이 도입됩니다. 주어진 physical page에 대한 virtual page를 빠르게 찾을수 있습니다.
(2) VM algorithm이 새롭게 디자인되었습니다.
(3) VM과 VFS가 통합됩니다. 즉, 화일과 페이지가 통합되었다는것인데, 버퍼캐시가 page cache로 통합된것이죠.

(6) improved threading support
리눅스는 그전까지 pthread를 지원하지 않았습니다. 자신만의 독특한 쓰레드모델을 가지고 있습니다. clone() 시스템콜과 같은 것이죠. 그동안 pthread를 지원하기위한 노력이 있어왔는데, NPTL(Native POSIX Thread Library)이 승자로 결정나면서 새로운 user-space pthread library인 NPTL 이 들어옵니다. (물론 커널 변화도 함께.) 커널쓰레드와는 1:1 모델이고, 다음과 같은 여러가지들이 새롭게 지원되거나 도입되었습니다.

      thread local storage support
      O(1) exit() system call
      improved PID allocator
      clone() system call threading enhancements
      thread-aware code dump support
      threaded signal enhancements
      a new fast user-space locking primitive (called futexes)
(7) new sound layer
ALSA가 통합되었습니다.

 


L4

Microkernel의 2세대라고 할수 있는 L4에 대해서..(역시 백만년-_-;;)

 


Plan9

Unix의 다음 버전이라고 할수 있는 Plan9에 대해서...(이건 한 2백만년?)

 


 

Part III

Virtual machine

VM은 사실 아주 오래된 개념입니다. 70년대에 값이 비싼 HW를 효율적으로 쓰기 위해서 연구되었던 VM은 HW의 발전이 가속화되면서 그 이후로는 연구되지 않다가 다시 주목받기 시작한 개념입니다. 최근들어 주목받기 시작하는 이유로는 주로, 과거와는 달리 충분한 성능의 HW가 나오면서 이러한 HW의 utilization을 높이기 위한 방법으로 제시됩니다. SMP나 ccNUMA등의 시스템이 발전하고 있는 것에 비해 이러한 HW의 발전을 OS가 따라가지 못하고 있습니다. 즉 scalability가 문제가 되고, 이에 대한 대안으로 VM을 내놓습니다. VM을 통해서 SMP나 ccNUMA 시스템을 십분 활용할 수 있게한다는 것입니다. (Disco논문이죠. 좋은 논문입니다. 꼭 읽어보세요 ^^ 7.3절에 micro-kernel과 exokernel과 VMM간의 이야기가 나오네요.) 또한 관리의 이점을 들수 있습니다. Server consolidation이나 migration, high availability등에 유리하고 백업등의 유지관리가 무척 편해지기 때문이죠. 이러한 이점들을 OS수준에서는 서비스하기 힘든데, TODO



Para-virtualization vs full-virtualization

가상화는 구현 모델이 크게 두가지가 있습니다. 어떤 경우에는 기존의 OS 전체를 아무런 수정없이 그대로 실행할수 있는 경우 full virtualization이라고 부릅니다. 어떤 경우에는 OS에 수정을 가해야할때가 있기때문이죠. 처음 x86등의 아키텍쳐는 virtualizable하지 않기때문에 full-virtualization에 어려움이 많았습니다. 이를 위한 다른 기법으로 para-virtualization이 도입되는데, 간단히 말해 guest가 hypervisor의 존재를 인식하고 hypervisor에게 privileged operation의 수행을 요청하는 모델입니다. 이러한 요청은 hypercall이라고 불리웁니다. 즉, hypervisor는 OS의 OS인 셈입니다. 따라서 성능에 유리합니다. Xen이 대표적인 경우입니다.

Paravirtualization이라는 방식을 들고 나왔던 Xen은 사실상 microkernel이라고도 볼수 있어서 혹자는 microkernel의 귀환이라고도 할정도입니다. 기존의 커널(Linux) 는 ring 1으로 밀려나고 ring 0 에서는 xen이 실행됩니다. 마치 application이 system call을 통해 OS에게 요청을 하듯이 OS가 이번엔 hypercall을 통해서 xen에게 요청을 하게됩니다. 따라서 이 경우 linux는 hypercall 을 이용해서 실행되도록 수정을 해줘야합니다. 따라서 뒤에 살펴볼 full virtualization의 경우와 달리 OS의 수정이 필요 합니다. 예를들어 IDT table이나 메모리의 추가적 할당, page table의 설치등을 위해서는 xen에게 부탁을 해야합니다. 이런 이유로 이러한 주요한 privileged instruction들은 hypercall로 대체됩니다. x86에서 hypercall은 int $0x82로 부릅니다.

따라서 Linux 를 xen에서 돌리기 위해서 linux의 machine-dependent한 코드들을 대체하는데, linux는 portability를 높이기 위해서 architecture-dependent한 코드들을 따로 모아두고 OS쪽에 이에 대한 인터페이스를 제공하는 전략을 씁니다. 이에 따라 명확하게 machine-dependent code와 그렇지 않은 코드들을 구분해두고있기 때문에 다른 ISA로의 포팅이 손쉬워지는 것입니다. 즉 이렇게 정의된 interface는 사실상 가상의 ISA라고도 할수 있습니다. paravirtualization은 바로 이 얇은 interface층을 잘 찾아내어 별개의 protection domain으로 독립시켜낸것이라고 할수 있습니다. 따라서 linux의 Xen이라고하는 (가상의) ISA로의 포팅작업은 arch와 같은 한 디렉토리에 집중되게 됩니다. 작업을 훨씬 수월하게 만드는 이러한점들은 Linux의 디자인이 잘 되어있다는것을 보여주기도 합니다. 이것은 바로 ISA를 가상화하는 효과가 있기때문에 ISA의 가상화라는 의미에서 paravirtualization 이라고 부르는것같습니다.

TODO: 그림 32bit의 경우 상위 64MB를 Xen이 공통적으로 차지하고 나머지를 guest가 쓰게됩니다. 리눅스와 같은 방식이죠. 이처럼 Xen을 위해 예약된 address space를 보호하기 위해서 segmentation을 사용하네요. IA32의 쓸모없는것같던 segmentation이 유용하게 쓰이는경우입니다. (SFI에서 잘 쓰인것 이후로는 처음인듯?) x86-64에서는 segmentation이 버려지는 바람에 (정확히는 ring 1,2가 사라졌어요!) 이 과정이 page-table기반의 방식으로 바뀌었습니다. 따라서 syscall때마다 overhead가 있게되었습니다. 보통 32비트에서 guest OS가 ring 1에서 돌고 application은 ring 3에서 돌지만 64bit에서는 guest OS가 ring 3에서 돌게 됩니다. guest kernel공간의 보호는 page-table로 대체합니다.

paravirtualization 의 보다 자세한 과정을 http://www.linuxjournal.com/article/8909를 바탕으로 살펴보겠습니다. IA-32의 대략 250개정도 되는 instruction중에서 17개가 ring1에서 돌때 문제가 됩니다. 예를들어 HLT명령과 같은 부류들은 #GP를 일으키죠. CLI/STI도 특정조건 (CPL>IOPL)에서는 #GP를 일으킵니다. 둘째로 어떤것들은 #GP를 일으키지도 않고 실패합니다. "fail silently"라는 것들이죠. POPF는 EFLAGS의 실제값과는 IF(interrupt flag)에서 다른값을 린턴합니다. 이를 위해서 HLT는 hypercall로 대체하죠. process.c 의 cpu_idle()은 대신에 HYPERVISOR_sched_op(SCHEDOP_block, 0) 를 호출합니다. 또 어떤경우에는 에뮬레이션을 직접해주는데, emulate_privilged_op() 과 같은 함수죠. CLTS명령은 ring 1에서 실행되면 #GP를 일으키는데 do_general_protection() 까지와서는 do_fpu_taskwtich()를 실행합니다. 이 함수는 결국 CLTS를 직접 실행해주게됩니다.

인터럽트의 경우 APIC에서부터 do_IRQ()으로 넘어온후에 타이머와 serial interrupt만을 처리하고 나머지는 __do_IRQ_guest()를 통해서 게스트로 넘겨집니다. send_guest_pirq()를 부르고 evtchn_set_pending()함수가 해당 비트를 셋해줍니다. 이후에 Xen이 asynchronous하게 guest에게 notify해주게됩니다.

physical frame (메모리) 관리는 Linux와 매우 유사합니다. node, zone, order 개념이 있으며 binary buddy 알고리즘과 frame table을 씁니다. (common/page_alloc.c 안에 [init,alloc,free]_heap_pages() 함수들 참조. domheap과 xenheap을 위한 상위레벨의 함수들은 이들 위에서 정의됩니다. 각 아키텍처별로 살펴보고자한다면 arch/x86/xen/mm.c 등을 보면 초기화 함수와 boot-time allocator등을 볼수 있습니다.

반면 full-virtualization은 OS의 수정없이 그대로 수행할수 있는 모델로, OS는 hypervisor의 존재를 전혀 모르기때문에 hypervisor는 완벽한 CPU와 PC의 환경을 에뮬레이션해주어야 합니다. 따라서 이 경우 성능저하가 상당부분 있게됩니다. Vmware등이 대표적입니다. xen 역시 최근 harward support를 이용하여 full virtualization을 지원하고 있습니다. HVM domain이라고 부릅니다.

각 모델에 따라 장점이 있기때문에 요즘에는 두 모델 모두 지원하는 경우가 흔합니다. Xen역시 HVM domain이라는 이름으로 full-virtualization을 지원하며, Vmware역시 guest os tools라는 등의 이름으로 paravirtualization을 도입하고 있습니다.


Software vs hardware

또한 구현방법에 따라서 두가지로 분류할수 있습니다. Software approach와 hardware approach가 그것입니다. Software방식중 하나인 binary translation(BT) 을 살펴봅시다. 이 binary translation은 실행하고자하는 target code를 실시간으로 host code로 변환해내는 기술입니다. 이 기법의 대표적인 경우가 qemu와 vmware라고 할수 있습니다. 특히 qemu같은 경우가 매우 흥미로운데, 먼저 실행하고자 하는 코드(target code)를 basic block으로 나누고 각 block들을 host code로 변환합니다. 물론 이 과정에서 privileged instruction등은 모두 적절하게 가상 cpu를 이용한 코드로 변환됩니다. 이렇게 변환된 block들을 연결한후 실행해줍니다. 이렇게 함으로써 변환된 코드를 실행합니다. 속도의 향상을 위해 변환된 block들을 캐쉬해놓습니다. 일반적으로 이러한 binary translation은 host ISA와 target ISA에 따라 매우 machine-dependent하지만, qemu는 micro operation등을 적절히 이용하여 이 binary translation을 매우 일반적으로 구현한 경우로 processor emulator라고도 불리웁니다. 즉 x86 코드를 ARM에서 실행한다던가 하는 것이 가능한것입니다. 반면, VMware같은 경우는 x86에 대한 특수한 경우로 x86-to-x86 binary translation 이라고 할수 있습니다. VMware의 BT는 다른 방식들 (Intel Itanium (x86 to IA64), Transmeta (x86 to VLIW), Digital FX!32 (Alpha to x86) 등에 비해서 훨씬 가벼운 방식입니다. x86-to-x86이기때문이죠. User mode code는 거의 그대로 쓸수 있다는 것만 봐도 훨씬 가볍다는 것을 알수 있습니다. 이런 translation은 보통 PC시스템 에뮬레이션 코드와 합쳐져서 기존의 OS를 수정없이 그대로 실행될수 있는 full virtualization의 구현에 사용됩니다.

이러한 기법은 JIT(just in time) compilation이라고도 불리며 JVM 역시 대표적인 예가 되겠습니다.

이제 hardware방식을 좀 살펴보겠습니다. Intel과 AMD가 각각 VT와 SVM(Pacifica)라는 이름의 x86용 하드웨어 가상화 instruction set을 도입했습니다. (x86이 한층 더 지저분해졌네요. :-D x86이 애초에 virtualizable하지 않았기때문에 긴 여정을 왔습니다.) 기본적인 아이디어는 기존과 마찬가지로 CPU trapping입니다. Intel의 경우 VMCS라고하는 VM의 상태를 저장하는 구조들을 정의하고 기존의 4개의 ring외에 root mode등의 모드를 정의합니다. xen/arch/x86/vmx*.c 와 xen/include/asm-x86/vmx*.h, xen/arch/x86/x86_32/entry.S 를 통해 살펴볼수 있습니다. vmcs_struct 구조체가 중심인데, guest-state area, host-state area, 그리고 vm-execution control fields, vm-exit control fields, vm-entry control fields, vm-exit information fields의 여섯구역이 있습니다. 그리고 10개의 새로운 opcode를 추가했습니다.

   1. VMCALL: (VMCALL_OPCODE in vmx.h) This simply calls the VM monitor, causing the VM to exit.
   2. VMCLEAR: (VMCLEAR_OPCODE in vmx.h) copies VMCS data to memory in case it is written there. wrapper: _vmpclear (u64 addr) in vmx.h.
   3. VMLAUNCH: (VMLAUNCH_OPCODE in vmx.h) launches a virtual machine, and changes the launch state of the VMCS to be launched, if it is clear.
   4. VMPTRLD: (VMPTRLD_OPCODE in vmx.h) loads a pointer to the VMCS. wrapper: _vmptrld (u64 addr) in vmx.h
   5. VMPTRST: (VMPTRST_OPCODE in vmx.h) stores a pointer to the VMCS. wrapper: _vmptrst (u64 addr) in vmx.h.
   6. VMREAD: (VMREAD_OPCODE in vmx.h) read specified field from VMCS. wrapper: _vmread(x, ptr) in vmx.h
   7. VMRESUME: (VMRESUME_OPCODE in vmx.h) resumes a virtual machine. In order it to resume the VM, the launch state of the VMCS should be "clear".
   8. VMWRITE: (VMWRITE_OPCODE in vmx.h) write specified field in VMCS. wrapper _vmwrite (field, value).
   9. VMXOFF: (VMXOFF_OPCODE in vmx.h) terminates VMX operation. wrapper: _vmxoff (void) in vmx.h.
  10. VMXON: (VMXON_OPCODE in vmx.h) starts VMX operation. wrapper: _vmxon (u64 addr) in vmx.h.
Xen이 root mode에서 돌고 guest는 non-root mode에서 돌게됩니다. 특정 동작들은 vm exit을 일으키게됩니다. Xen은 arch/x86/cpu/intel.c의 init_intel()함수에서 start_vmx()함수를 불러서 VMX로 들어갑니다. CPUID를 체크한이후에 _vmxon을 통해서 VMX를 시작합니다. cpuid, invd, mov from cr3, rdmsr, wrmsr 그리고 위의 새로운 vtx명령들은 무조건적으로 vmexit을 일으킵니다. hlt, invplg, mwait등의 명령들은 vm-execution control부분에서 설정되어있을때만 vmexit합니다. 그외에 vmexit을 하게될지 결정하는 두개의 비트맵이 있습니다. vm-execution controls 영역에 첫재 vmx_vmcs.h의 EXCEPTION_BITMAP이 있습니다. 32bit으로 각 exception에 대에 대해서 vmexit을 설정합니다. default설정은 #PF, #GP 입니다. (MONITOR_DEFAULT_EXCEPTION_BITMAP in vmx.h) 두번째로는 I/O 비트맵입니다. 4KB I/O비트맵으로 A와 B가 있는데 A는 0000-7FFF 까지, B는 8000-FFFF 까지의 포트를 담당합니다. (IO_BITMAP_A , IO_BITMAP_B in vmcs_field )
vmexit이 되면 vmx_vmexit_handler()로 가는데 이유가 vmcs에 설정됩니다. 43가지 기본적인 이유가 있습니다. EXIT_REASON_* 들이죠. guest는 real mode가 없기때문에 vmxloader라는 특별한기능을 씁니다. vmxloader가 ROMBIOS를 0xF0000, VGABIOS를 0xC0000 그리고 VMXAssist를 D000:0000 에 올립니다. VMXAssist는 IA32의 virtual-8086을 쓰는 real mode emulator입니다. virtual-8086모드를 세팅후에 vmxloader는 16비트에서 실행됩니다.
SVM에서는 각각 host mode와 guest모드라고 부릅니다. VMCB 구조체라고 부르고요. 8개의 새로운 VM계열명령을 추가했습니다. VMRUN/VMLOAD/VMSAVE등등. 이처럼 서로 비슷하므로 HVM이라는 층을 놓는데, 이것이 VT와 AMD SVM을 위한 wrapper입니다. vmxloader가 이제는 hvmloader가 되는것이죠. AMD SVM의 경우에는 VTX와 다르게 paged real mode를 가지기때문에 real mode로 설정할수 있습니다. VMX의 경우에는 VMXAssist를 썼죠. hvm_function_table 이 VTX와 SVM에 공통적인 함수들을 모아놓은 구조체입니다. 반면 둘사이에 차이점도 있는데, AMD SVM은 tagged TLB를 쓴다는것이고, VMX에서는 guest가 real-mode가 없기때문에 hvmloader가 필요하다는것.
AMD는 IOMMU가 있는데..TODO http://www.amd.com/us-en/assets/content_type/white_papers_and_tech_docs/34434.pdf


Other models

이 외에도 UML (User Mode Linux)와 같은 경우가 있습니다. linux를 user mode에서 실행한다는 것인데, 역시 full virtualization은 아니고, Linux를 컴파일할때 x86이 아닌 UML이라는 가상적인 아키텍처에 맞춰서 컴파일하면 linux라는 실행화일을 얻을수 있습니다. 이것을 실행하는것이죠. 이 경우는 ptrace를 이용해서 application이 호출하는 system call들을 가로채서 가상화시키는것 입니다.

KVM도 있습니다. hardware support를 이용한 경우로 최근 linux에 추가된 KVM(Kernel-based VM)을 들수가 있습니다. 이 경우 역시 흥미로운 방식을 쓰는데...TODO

Java역시 이 논의에서 빠질수 없습니다. 자바는 JVM specification을 제공하는데, JVM은 class loader, class verifier, 그리고 class화일(바이트코드)을 실행하는 Java interpreter로 구성됩니다. class loader가 클래스를 로드하면, verifier가 valid한지, 스택 overflow/underflow는 없는지, 그리고 포인터 연산을 하지 않는지등(illegal memory access를 일으킬수 있으니 금지됩니다.) 을 검사한후에 interpreter에게 넘깁니다. 보통은 JIT compiler로 구현되는데, ...생략.

Fluke에 대해서. Recursive virtual machine과 nested process model.

Microkernels Meet Recursive Virtual Machines. Bryan Ford, Mike Hibler, Jay Lepreau, Patrick Tullmann, Godmar Back, Stephen Clawson. In Proc. of the Second Symposium on Operating Systems Design and Implementation (OSDI'96), October 1996

 


Two physical memory

가상머신은 가상메모리에 한단계의 translation을 더 추가하기 때문에, 이를 위한 장치가 필요합니다. 즉 기존에는 가상주소 10번지가 물리주소 100번지로 변환되었다면 이제는 100번지가 다시 200번지로 변환되는 과정이 추가됩니다. 처음 10번지를 가상주소라고 하고, 두번째 100번지를 보통 guest-physical address, 세번째 최종 200번지 주소를 host-physical address라고 부릅니다. 처음에는 두번째 100번지는 physical address, 세번째 200번지는 machine address라고도 불렀죠.

M2P,P2M table

이제 물리주소가 Guest-physical과 host-physical(machine address)으로 한번의 변환을 더 거치게 됩니다. 이를 위해 Xen에서는 간단한 테이블 두개를 도입하는데, m2p (machine-to-physical) table과 p2m (physical-to-machine) table이 그것입니다. m2p엔트리수는 당연히 호스트 메모리 전체의 물리적인 페이지수와 같게 되고 p2m table의 엔트리수는 guest가 가진 guest-physical 페이지수와 같게 됩니다. 예를들어 host-physical이 4기가면 4G/4K=1M개의 m2p엔트리가 있고 그중 guest가 예를들어 1기가를 할당받았다면 해당guest의 p2m엔트리수는 256K개겠죠. 대부분의 guest메모리는 host메모리에 1:1로 매핑되므로 이러한 테이블로 매핑을 해결할수 있습니다만, 일부 페이지들의 경우 domain간에 shared되는 경우에는 해당 엔트리는 -1등의 invalid한 값으로 채워집니다. 즉 도메인(VM)간에 공유될수 있는 페이지가 존재할수 있다는 뜻이죠. 이런 경우는 테이블내의 특수한 값을 넣어서 표시한 후에 xen이 특별히 처리해주게 됩니다.

이러한 p2m/m2p테이블은 페이지 테이블들과는 상관없음에 유의하세요. 하나의 페이지 테이블은 각 주소공간마다 있는 실제적인 매핑이지만 p2m/m2p 테이블은 그 이전에 도메인에 할당되는 메모리일뿐입니다. 즉 할당이지 실제 페이지테이블을 통한 매핑은 아니지요.

Shadow page table

실제 매핑은 물론 페이지 테이블을 통해서 하게 됩니다. guest physical에서 host physical로의 추가적인 주소변환과정이 생기게 됩니다. 이를 위해서 software가 실제 page table을 shadow하게되는데, 이를 shadow page table이라고 합니다. 즉 실제 virtual에서 host-physical로의 변환은 guest가 보지 못하는 shadow page table을 통해서 이루어지지만 guest는 자신만의 physical memory와 page table을 가지고 있다는 환상을 가지게되는 기법입니다. 이를 software로 구현하려면 물론 오버헤드가 상당합니다. 구체적인 구현은..TODO

PV방식은 OS가 직접 hypercall을 통해서 이러한 page table조작을 할수 있기때문에 이러한 shadow page table을 쓰지 않게되고 자연히 좀더 성능이 좋습니다. p2m변환 마저도 Xen이 제공하는 p2m테이블을 직접 참조하여 사용하므로 사실상 이러한 추가적인 주소변환은 없는것과 같습니다. 이는 당연히 성능의 향상으로 이어지죠. guest가 직접 p2m변환을 처리하긴 하지만, guest kernel대부분의 코드는 물론 contiguous한 guest-physical memory를 가정합니다. 다만 페이지 테이블에 들어가는 값만이 실제 p2m변환을 거친 값이 들어갈 뿐입니다. 하드웨어로의 interface에 해당하는 최소한의 코드, 즉 page table로의 접근만이 수정된다는 원칙에 따르게 되는것이죠.

자연히 이를 하드웨어가 직접 지원해주기 시작했는데요, 물론 하드웨어로도 이러한 추가적인 변환과정은 overhead가 있습니다만, TLB가 효과적이어서 그런지 큰 장애가 되지는 않는것같군요.

from http://www.vmware.com/pdf/Perf_ESX_Intel-EPT-eval.pdf

이와같은 기존의 변환 과정이 다음과 같이 한단계 더 추가됩니다.

from http://www.vmware.com/pdf/Perf_ESX_Intel-EPT-eval.pdf

가상화 기법에 관계없이 CPU의 실제 TLB안에는 항상 최종 host-physical 주소만이 들어간다는 것을 잊지 마세요.

paravirtualization의 경우에...TODO...

이러한 추가적인 변환을 위해서 처음에는 (software) shadow page table이 사용됩니다. 즉 가상머신안에서 page table을 조작할때마다 그에 대응하는 host에서의 실제 page table을 적절히 조작하여 가상머신으로서는 자신이 실제 page table을 건드리고 있다는 환상을 가지게 해주는 기법입니다. 즉 가상머신안에서는 virtual에서 guest-physical로의 변환테이블이 있는 반면, 실제 CPU는 virtual에서 host-physical로의 테이블을 따로 가지는 것입니다. 위의 그림에서 Shadow Page Table Entry라는 화살표를 눈여겨 보세요. 이를 위해서는... TODO.. 물론 이러한 방법은 오버헤드가 상당히 크기때문에, 이후로는 잘 쓰이지 않습니다. 따라서 이를 하드웨어적으로 구현해주기 시작했는데, 인텔에서는 이를 EPT(Extended page table)이라고 부르고 AMD에서는 NPT(nested page table)이라고 부릅니다.

from http://www.vmware.com/pdf/Perf_ESX_Intel-EPT-eval.pdf

하드웨어가 실제로 두단계를 거치기때문에 각 단계가 M-level, N-level의 페이지 테이블을 유지한다면 전체적으로 한번의 주소변환을 위해서 M*N의 메모리 접근이 필요하게 됩니다. 예를 들어...TODO



System and I/O

물론 이것은 매우 간단히 CPU와 메모리의 가상화를 살펴본 것이고, 실제로는 그외 시스템과 I/O시스템의 가상화 역시 함께 이루어집니다. 두 모델에 따라서 이들의 가상화역시 조금씩 차이를 보이고 있습니다.

xen은 처음에는 드라이버들을 모두 가지고 있었지만 이는 guest에서의 드라이버와 중복될뿐더러 복잡성을 증가시키는 요인이 되어왔기때문에 split driver 모델을 선택하게 됩니다.

Paravirtualization에서는 주로 Xen과 같이 split driver model을 구현하는데, 이는 Dom0와 같은 driver domain이 실제 드라이버를 가지고 장치에 접근하며, (이를 backend라고 합니다) 나머지 가상머신들은 간단한 더미 드라이버를 가지는데 (이를 frontend라고 합니다) 이 더미 드라이버가 앞의 backend driver와 통신하며 request를 보내는 방식입니다. 예를들어 network backend driver가 dom0에서 실제로 패킷등을 보내며, 그외의 가상머신은 network frontend driver를 가지는데 이들이 ring buffer를 통해 연결되어 있습니다. 또한 event channel을 통해서 서로 시그널을 주고받습니다. 이 버퍼와 시그널을 이용해 frontend driver는 가상의 네트워크 카드 (NIC)을 구현하게 되고, 실제 패킷을 보낼때는 해당 요청을 backend로 보내어서 실제 네트워크 카드를 통해 데이터를 전송하게 됩니다.

이를 구현하기 위해서 event channel과 shared memory가 사용되는데, 예를들어 netfront.c 에서는 np->tx 와 np->rx 가 각각 공유메모리페이지를 가리킵니다. send_interface_connect() 함수가 backend에게 장치를 올리게하고 메세지가 event channel을 통해서 interface.c의 netif_connect() 함수까지 가게됩니다. 그리곤 get_vm_area()함수로 커널의 가상주소를 할당하게되죠. blkif (block interfcace)의 경우에는 blkif_connect()가 불리웁니다. 이런 가상장치들에겐 virtual interrupt가 연결되는데 /proc/interrupts 에 보이는 256보다 큰 숫자들이 Dynamic-irq라고 되어있는것들이 이것들이죠.

반면 full-virtualization의 경우에는 OS를 수정할수 없기때문에 주로 장치들을 모두 에뮬레이션 해버립니다. 따라서 성능상의 저하가 있습니다. 예를들어 하드디스크나 네트워크 카드등을 모두 소프트웨어적으로 에뮬레이션합니다. 따라서 OS는 자신이 가진 드라이버를 그대로 이용하며 이러한 가상 장치들을 이용합니다. Vmware나 qemu등이(Xen HVM 역시 qemu의 io에뮬레이션 코드를 ioemu라는 이름으로 가져다 씁니다.) 이처럼 장치들을 모두 에뮬레이션합니다.


Virtual Machine Scheduling

VMM에서의 스케쥴링은 기본적으로 OS스케쥴링과는 같지만 다른 몇가지 고려사항들이 더 생깁니다. 일단 Xen에서의 스케쥴링중 하나인 credit scheduler를 간단히 살펴봅니다. 간단한 proportional-fair 스케쥴러입니다.

Credit scheduler

우선 간단한 round-robin스케쥴러를 살펴봅니다. 각 cpu는 런큐의 가장 앞에 있는 vcpu를 실행하고 실행후엔 큐의 마지막으로 보냅니다.

이제 priority를 추가하는데, credit을 다 소모한 vcpu는 over라는 priority를 가지게되고 큐의 뒤쪽으로 갑니다. credit을 아직 가진 vcpu는 under priority를 가지게되고 cpu를 사용후엔 자신의 priority영역의 뒤쪽으로 갑니다. 따라서 자연스럽게 over priority는 under priority가 없거나 자신이 under priority로 올라가야만 실행될 기회를 가질수 있게됩니다. 구현에서는 실제로는 큐하나이지만 실제적으로는 큐의 영역에 따라서 priority를 구현하는 방식으로 구현되어있습니다. 논리적으로는 여러개의 큐인셈이죠. cpu를 사용할때마다 credit을 소모하게 되고, 30ms마다 주기적으로 credit을 다시 할당받습니다. 이때 각 domain마다 주어진 weight값에 비례해서 credit들을 나눠주게되는데 그래서 proportional하게 전체 cpu를 domain들에게 나누어지게 됩니다. 예를들어 dom0와 dom1이 각각 256,512의 weight값을 가진다면 시스템 전체의 credit은 둘에게 1:2의 비율로 계속 나눠지게되므로 자연히 전체 cpu의 33%와 66%를 나눠 가지게 됩니다.

하지만 이상태로는 기본적으로 round-robin이라서 문제가 생기는데, 즉 모든 domain이 비슷한정도의 scheduling latency를 경험하게 됩니다. 그래서 domain0와 같이 시스템전체의 I/O를 담당하고있는 부분이 다른 domain과 같이 취급되기때문에 I/O에 심각한 성능저하가 생깁니다. 이를 위해서 boost priority가 도입됩니다. 잠에서 깨어나서 런큐에 새로 들어오는 vcpu의 경우엔 boost priority가 되는데, 이때만은 예외적으로 preemption을 합니다. 즉 즉각적으로 CPU를 차지하게됩니다. 그러나 이런 boost는 한번의 time slice만큼만 허용됩니다. 즉 최대 한번의 time slice만큼 실행된 후에는 바로 under priority로 떨어지게 됩니다. 이 optimization은 domain0의 I/O특성을 지원하기 위한것인데 자주 cpu를 차지하지만 짧은 timeslice만을 쓰는 I/O처리의 특성을 지원하기 위한것입니다. 이를 통해서 Domain0의 I/O처리가 빨라지는것이죠.

load balancing은 어떻게 되는지 봅시다. 즉 한 vcpu가 어떤경우에 다른 cpu들로 옮겨가는지를 봅시다. 첫째로는, 스케쥴러 함수가 불릴때마다 load balancing을 시도하는데, 자신의 런큐의 첫번째 vcpu를 선택하기전에 만약 그 vcpu가 over거나 idle한 vcpu라면 다른 peer들의 런큐를 한번씩 살펴봅니다. 이때 그쪽에 자신의 것보다 더 높은 priority의 vcpu가 있다면 그것을 빼서 자신이 수행하게 됩니다. 물론 그 이후에는 자신의 런큐에 집어넣게 되므로 해당 vcpu는 peer로부터 자신으로 옮겨오게 된것이죠. 코드에서는 steal한다고 표현되어있네요. 자연적으로 시스템 전체에서 높은 priority를 가진 vcpu가 먼저 수행되게 됩니다. 두번째 경우는 pick_cpu()와 같은 함수인데, 깨어나는 vcpu가 (즉 애초에 런큐에 없던 vcpu) 어느 cpu로 가야할지를 결정하는 것이죠. 이 경우 cpu들간의 topology (hyperthread,core,socket등)가 고려되어서 결정하네요.

 


VM migration

가상머신의 또다른 중요한 기능은 migration입니다. 간단히 말해 한쪽 머신에서 가상머신을 통째로 다른쪽 머신으로 옮겨오는 기능입니다. 가상머신을 멈추고 옮겨가는 offline migration이 있고, 가상머신이 실행중인 상태에서 옮겨가는 live migration이 있습니다. Xen을 기준으로 살펴보면, 현재로서는 root file system과 같은 디스크의 경우는 옮겨가지 못하고 nfs등으로 묶여있어야 하며, 옮겨가는것은 메모리상태와 CPU상태등이 옮겨갑니다. 또한 네트워크의 IP주소등의 이전을 위해서 같은 Lan안에 (TODO) 서만 자동적으로 TCP connection 등의 네트워크가 옮겨집니다. 그러지 않을경우에는 사용자가 직접 수동으로 IP주소등을 옮겨야합니다.

offline migration의 경우 비교적 간단하지만, live migration의 경우 좀 복잡해집니다. Shadow page table의 도움을 받는데, 먼저 모든 메모리 페이지를 dirty로 표시하고 전체 메모리를 옮기기 시작합니다. 물론 이때 가상머신은 그대로 실행되고 있고 background에서 처리됩니다. copy하여 다른 머신으로 옮긴 페이지는 clean한 상태로 표시합니다. 한번 이 과정이 끝난후에 보면 그 사이에 가상머신이 write하여 dirty한 상태로 변한 페이지들이 있으므로 이러한 페이지들만을 대상으로 다시 한번 이러한 과정을 거칩니다. 이러한 round를 몇번 거친후 이런 dirty page의 수가 매우 작거나 특정 threshold의 round를 거쳤을때에는 가상머신을 suspend한후에 나머지 페이지들을 전부 옮겨간후 그 옮겨간 머신에서 가상머신을 시작시키면 됩니다. 이와 같이 가상머신의 이미지를 다른곳으로 옮겨놓는것이므로 checkpointing이라고도 할수 있습니다. 2005 NSDI "Live migration of Virtual Machines" 참조.

최근 Xen 4.0에 Remus project가 들어갔는데, 이 Remus는 기본적으로 위의 check-pointing을 계속적으로 하는것입니다. constantly live-migrating하는것이라고 할수 있는데, 다만 각각의 check-pointing때마다 네트워크등으로의 output을 버퍼링해야할 필요가 있습니다. TODO. 이것을 통해서 high availability를 추구하는데, 해당 가상머신이 power-off등으로 죽게되면 기존에 backup을 받고있던 가상머신이 대신 실행을 계속함으로써 시스템 전체가 계속 available하게 된다는 원리입니다.



 

Part IV

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입니다.

역사적으로 볼 때 IBM 370이 computer architecture의 개념을 만들어낸... TODO

 

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...TODO

 

 


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. 좀 오래되었다는 것을 감안하고 보세요. 초보자에게 강력추천.

 

Linux Device Drivers, 3rd Edition

-> LDD. 실제로 커널코딩이 어떻게 되어야할지, 잘 설명되어있네요. 통째로 봐야할 책.

 

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

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

 

Understanding the Linux Kernel

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

 

Definitive guide to xen

-> Xen내부에 대한 설명이 괜찮게 되어있습니다. 현재 Xen내부에 대한 책으로는 유일하네요. 비록 그다지 자세하지는 않지만 overview를 잘 보여줍니다.

 

What Every Programmer Should Know About Memory

-> Ulrich Drepper가 쓴 필독서죠. 메모리와 캐시에 대한 심도있는 설명들이 이어집니다. 꼭 읽읍시다.

 

리눅스 커널 2.6 구조와 원리

-> 일본책을 번역한것이군요. 그런대로 읽을만한것 같습니다. 아주 자세히 설명이 되어있다는점이 장점인것같고, 또 그런만큼 지루해지기 쉬운면이 있긴하지만, 굉장히 좋은 길잡이가 될것같네요.

 

Programming Windows, 5th Edition, by Charles Petzold

-> 유명한 책이죠. Win32 API를 이용한 기초적인 GUI 프로그래밍을 친절하게 설명하는군요. Message-driven 방식의 프로그래밍을 잘 배울수 있는 책이었습니다.

 

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 에서 꼭 받으시길.

 

http://pages.cs.wisc.edu/~bart/537/lecturenotes/

-> 핵심적인 부분들이 잘 정리되어있습니다.



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. :-) 리눅스는 이렇게 시작되서 현재까지 이르고 있습니다. 최근들어 Linus도 말했듯이 Linux가 너무 크고 비대해졌다고(bloated) 하는데, 그만큼 복잡해지기도 했습니다. 하지만 그 기본 뼈대는 많은 다른 OS설계에 영향을 줄만큼 괜찮은 설계임에는 분명합니다.

 

Linux는 수많은 개발자에 의해서 개발되고 있지요. 현재 2.6.x.y대의 개발이 이루어지고 있는데, 이런 개발과정 다음을 통해 봅시다. 3.0버전은 major버전업임에도 불구하고 큰 변화가 없습니다. 리누스에 따르자면 2.6의 하위버전이 너무 높아져서 그냥 바꾼다는군요. 사실 2.6버전때 3.0으로 해야한다는 의견이 있었는데 그만큼 그때 큰틀이 완성되었었죠.

 

이하 내용은 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할수 없습니다.

x86_64에서의 주소공간을 보시죠. Documentation/x86_64/mm.txt 에서 간다히 설명됩니다. (오래된 내용이군요)

Virtual memory map with 4 level page tables:

0000000000000000 - 00007fffffffffff (=47bits) user space, different per mm
hole caused by [48:63] sign extension
ffff800000000000 - ffff80ffffffffff (=40bits) guard hole
ffff810000000000 - ffffc0ffffffffff (=46bits) direct mapping of all phys. memory
ffffc10000000000 - ffffc1ffffffffff (=40bits) hole
ffffc20000000000 - ffffe1ffffffffff (=45bits) vmalloc/ioremap space
... unused hole ...
ffffffff80000000 - ffffffff82800000 (=40MB)   kernel text mapping, from phys 0
... unused hole ...
ffffffff88000000 - fffffffffff00000 (=1919MB) module mapping space

 

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에서 말입니다.

 

 


Appendix B - Linux Network

 

 

 

Linux 에서는 sk_buff 구조체를 써서 모든 layer에서 공유하는 형식을 쓴다.

 

 

앞부분에는 리스트구조를 위한 포인터들이 있고, 이후 각 레이어에 대한 헤더를 가리키는 포인터가 있습니다. 이후에 패킷의 type과 protocol을 나타내는 변수가 있죠. union으로 묶여져있는 것을 볼 수 있습니다. 다음에는 목적지 주소에 대한 포인터가 있고, 실제 데이터가 기록되는 cb[48]이 있습니다. 패킷의 길이를 나타내는 len변수와 truesize가 있죠. len은 데이터의 시작 *data부터 끝점 *tail까지의 길이를 나타내며 truesize는 실제 패킷의 시작 *head부터 패킷의 끝점 *end까지의 길이입니다.

 

(좀더 .... 82)

 

 

 


 

branching과 performance

 

http://minjang.egloos.com/503419#919943

 

컴퓨터에게 있어서 branching은 본질적인 문제입니다. code가 branch없이 한번의 실행으로 끝이 난다면 문제는 훨씬 간단했겠지만, 그다지 별 의미가 없겠죠. 결국 code란 루프로 이루어져있다는 얘기입니다. 따라서 loop를 어떻게 최적화하느냐가 성능향상에 중요한 포인트가 되기도 하며 혹은 어떻게 loop의 종료조건등을 증명해낼 것인가등이 program의 correctness를 보장하는 중요한 문제가 됩니다. 이 loop는 사실 locality를 만들어내는 기본 이유이기도 합니다.

 

 


 

 

 

SPIN

application이 필요로하는 기능과 커널이 제공하는 기능은 차이가 있을 수 있고, 이것이 문제가 됩니다. 예를 들어 커널의 disk buffering과 paging 알고리즘은 DB에게는 맞지 않고 성능의 저하로 이어집니다. 이렇게 application의 다양한 need에 부응하기 위해서 SPIN OS는 extension이라는 개념으로 application이 OS의 interface와 implementation을 확장할 수 있도록 해줍니다.

이를 위해 특이하게도 Modula-3 라는 언어로 쓰여집니다.

address space는 fine-grained protection에는 적합하지 않습니다. 너무 비싸고 무거운 것이죠. (SPIN)