본문 바로가기

old drawer/Operating System

[OS] 운영체제를 공부합시다!!

Operating System Inside - General

Preface

 

Part I

1. OS란

2. Computer Model

3. Virtual MemoryPage Table structureReverse Mapping

4. When memory was Not virtualReal mode vs protected mode? Segmented?OverlaySegmentation

5. Address Binding

6. Dynamic Allocation

7. Kernel vs UserKernel mode vs User modeKernel space vs User spaceSystem call and API

8. TLB &Cache

9. InterruptPC에서의 interruptInterrupt vector여러 Interrupt &exceptionCPU Protection

10. Control FlowProcesses and threadsContext switchNested kernel control pathPreemptible kernel (Reentrancy)Bottom half

11. Virtual Address SpaceProcess의 구성Virtual address space managementDynamic library

12. CPU Scheduler

13. Physical Memory ManagementKernel Memory AllocatorSlab AllocatorVmallocDisk Cache - Page cache, buffer cache and unified cacheSwappingPage Replacement PolicyGlobal Page AllocationWorking Set and Thrashing

14. Synchronization #1AbstractAtomicityBounded Buffer producer-consumer problemShort critical section and spinlockLong critical section and mutexspinlock vs mutexBakery algorithmcoarse-grained locking vs fine-grained lockingConclusion

15. Synchronization #2Bounded-buffer problem and reader-writer problemThe dining philosophers problemCritical regionsMonitors

16. Synchronization #3Spinlock revisitedFutexRead-Copy-Update

17. Lock-free Code

18. Transactional memory

19. Transaction

20. Deadlock

21. Interprocess CommunicationPipes and FIFOsSignalsSocketsSystem V IPCSystem V IPCShared memorySemaphoresMessage queues

22. Remote Procedure Call

23. PagingPage faultDemand pagingCOW(Copy on Write)Mapped filesPage Fault Handler

24. I/OMemory mapped vs programmedAsynchronous I/OI/O SchedulerDirect Memory Access (DMA)

25. Symmetric Multiprocessor (SMP)

26. File SystemDistributed File SystemLog-structured File System

27. Shared Memory Machine

28. Clustered Systems

29. Distributed Systems

30. Real Time

31. Userland

32. Part II

33. OS다시보기

34. Threads, layers, and boundary

35. Virtual machine

36. Xen

37. L4

38. Plan9

39. Part III

40. Computer Architecture

41. Microarchitecture

42. Microprogramming

43. Memory model 

44. Biblography and reading list

45. Appendix A - Linux

46. Appendix B - Linux Network

 

Topics

47. branching과 performance

 

Part I

OS란

 

OS란 결국, 하드웨어를 총괄하면서 하드웨어간의 이질성을 끌어 안아 소프트웨어가 좀더 추상적이 될 수 있는 환경을 제공하는 근본 소프트웨어라고 할 수 있습니다. 또는 평상시에는 잠들어 있다가 Application이 필요로 하는 서비스를 제공해주는 데몬(daemon)이라고 볼수도 있습니다. (이것은 정확한 이해는 아닙니다.데몬이라고 할수는 없죠. 하지만 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역시 need에 맞춰져서 디자인됩니다. 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

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

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

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

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

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

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

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

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

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

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

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

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

1) Virtual CPU

2) Virtual Memory

3) Virtual File System

4) Virtual Machine

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

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

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

 

Virtual Memory

 

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

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

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

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

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

 

(From intel manual)

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

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

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

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

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

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

 

 

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

 

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

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

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

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

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

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

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

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

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

 

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

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

 

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가 이런방식을 사용합니다. (TOIDO:그림 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인 것입니다.

 

Address Binding

메모리는 CPU와 함께 시스템에서 가장 중심에 있는 기둥입니다. 폰노이만 병목현상에서도 알수 있듯이 성능에서도 가장 중요한 요소가 바로 메모리입니다. 이에 따라 최초의 간단했던 메모리구조가 CPU와 함께 VM의 도입등 점점 복잡해지는데, 그 중심에 있는것이 address binding입니다. 즉 프로그래머가 소스에서 변수하나를 지칭할때부터 시작해서 최종적인 physical address가 생성되기까지의 path가 여러단계를 걸쳐서 진화하게 되는데, 이 변화가 시스템의 진화라고도 볼수 있을만큼 변화의 핵심을 담당해왔습니다. 이와같이 가장 높은레벨의 변수에서부터 최종 physical address까지의 변환과정을 address binding이라고 합니다. 첫번째 binding은 컴파일러가 심볼을 상대적인 주소로 변환하는것입니다. 즉 한모듈안에서의 오프셋으로 바인딩하는데, "이모듈안에서 200번째바이트"와같은 식으로 연결해두는것입니다. 차후에 이런 주소는 다시한번 변환과정을 거쳐야하기에 (relocation) 이를 relocatable address라고도 합니다. 이제 로더가 이런 상대주소를 절대주소로 바꿉니다. 즉 4기가의 주소공간안에 매핑하는것입니다. 이와같이 compile time과 load time에 바인딩이 일어날수 있습니다. 그리고 사실 compile time에도 절대주소를 쓸수 있습니다. 코드나 데이터가 주소공간의 어느부분에 존재할것인지를 미리 알게되면 그주소를 그대로 써도 동작할것입니다. OS개발과정에서 커널로딩부분이나 임베디드환경, 혹은 DOS시절의 코드등이 대표적인 예가 되겠습니다. 하드코딩된 주소가 바로 쓰일수도 있는것입니다. 이런경우 가장 이른시간에 바인딩이 일어났다고 할수 있습니다. 이를 early binding이라고 합니다. 물론 대부분의 코드들의 절대주소는 load time에 binding되기때문에 일반적으로 절대주소는 load time에 결정된다고 할수 있습니다. 그러나 VM의 등장으로 이 절대주소는 한번더 execution time에 변환을 거칩니다. 절대주소란 결국 virtual address입니다. 따라서 VM에 의해서 physical address로 변환되는것이 execution time에 수행되는 바인딩입니다. 이 과정에는 OS도 참여하기때문에 (page fault handler) HW/SW가 동시에 참여하는 바인딩입니다. 가장 늦은시간에 바인딩이 일어나기 때문에 late binding이라고 부릅니다. 

실행시간에 바인딩이 결정되는 예는 dynamic library가 있습니다. OOP언어들에서 가상함수, 즉 특정함수로의 주소를 virtual method table등을 이용해서 바인딩을 늦추는것이 좋은 예입니다. 실행시간에 dynamic library의 주소는 보통 결정되어서 계속 유지되는 반면, indirection branch를 이용한 가상함수로의 호출이나 OS차원에서의 migration등은 한걸음 더 나아가 바인딩이 쉽게 바뀔수도 있다는 특징을 가집니다. 이는 즉 indirection branch와 VM에 의해서 얼마든지 타겟주소가 바뀐다는것입니다. final-binding time이라고 이름붙여봅시다. 그럼 이것까지 포함하면 4단계에 걸쳐서 바인딩이 일어나게됩니다. compile time, load time, execution(run) time, final-binding time. 이러한 변환을 거치면 최종적으로 physical address가 나오게됩니다. execution-time 까지 생성된것이 virtual address인데 여기까지는 순수한 SW입니다. 이것이 실행되면서 HW와 함께 작동하여 final-binding time에서는 HW와 SW가 함께 동작하여 최종 주소를 만들어냅니다. 사실 이 이후에도 더 많은 단계가 있을수 있습니다. NUMA와 같은 시스템은 이제 더 복잡한 메모리구조를 가지기때문입니다. 이단계 이후부터는 순수하게 HW가 처리하게 됩니다. 따라서 final-binding time은 SW가 할수 있는 마지막단계이자, HW가 들어오는 최초의 단계이고, 동시에 HW/SW가 만나는 최초의 바인딩 지점인셈입니다.

그림..

dynamic library와 같이 실행시간에 로딩되는 것을 dynamic loading이라고 합니다. 즉 실행시간에 binding이 이루어집니다. 그러나 이 dynamic loading은 OS와는 별 상관이 없이 application이 하는 일입니다. 또한 이는 demand paging 와는 다르다는점을 주의하세요. demand paging 는 OS가 application모르게 처리하는 일임에 비해서, 반대로 dynamic loading은 OS모르게 application이 처리하는 일입니다. (물론 OS는 application을 돕기위한 시그널과 같은 메커니즘을 가지고있습니다.) 이와 유사하게 dynamic linking이라는 용어가 있습니다. 이는 로딩이 아니라 링킹이 실행시간에 일어남을 뜻합니다. static link와 반대로 각 라이브러리 모듈이 stub이라는 작은코드를 가지고 있어서 이 모듈이 존재하지 않는 라이브러리를 로딩하거나 심볼의 위치를 찾아내는 역할을 하게됩니다. 이를 통해서 dynamic library가 성립합니다. 이 stub은 먼저 필요한 코드가 있는지를 보고, 아니면 메모리로 로딩을 합니다. 이렇게해서 주소가 나오면 stub은 자기자신을 해당 주소로 바꾸고서는 코드를 계속 실행하게 됩니다. (gdb로 살펴보기...) 이 기능과 VM의 shared-page기능으로 인해서 한시스템에서는 하나의 라이브러리만 있으면 모든 프로세스가 쓸수 있게되는것입니다. 또한 이 기능으로 인해서 부분적인 라이브러리등의 코드의 update가 가능하게됩니다. 모듈이 새로운 버전으로 교체되더라도 모든 프로그램이 자동적으로 새로운 코드를 쓸수 있게됩니다. 이런 dynamic linking없이는 모든 프로그램이 다시 link되어야 했었겠지요. 그러나 이를 위해서는 또한 각 프로그램과 라이브러리가 버전정보를 가지고 버전관리를 해줘야하는 단점도 있습니다. 새로운 버전의 코드가 구버전과 호환이 되지 않을수 있기때문이죠. 이에따라서 major version과 minor version의 방식이 도입되었습니다. 여러개의 라이브러리가 메모리에 올라올수도 있고 그래서 각 프로그램이 자신이 완하는 버전을 사용할수 있습니다. 이런 시스템을 shared library(dynamic library)라고 합니다.

dynamic loading과는 다르게 dynamic linking은 OS의 도움을 필요로합니다. 필요한 코드가 다른 프로세스에 의해서 사용중인지, shared-page설정등은 OS만이 할수 있는일이기 때문이죠.

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을 해결하기 위해서는 VM이 되입됩니다. 페이징을 써서 contiguous를 virtual/physical로 나눌수 있기때문이죠. 물론 이런경우에도 internal fragmentation이 있겠지만 보통 절반의 페이지, 즉 2K가량의 단편화를 예상할수 있습니다. 하지만 이건 시스템에서 사용하는 기법이고, malloc에서는 여전히 dynamic allocation문제가 존재합니다. 작은양의 메모리의 할당/해제가 서비스되어야하기때문이죠.  

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과 같은 memory protection에 의해서 보호받게 됩니다.

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사이의 복사가 이루어지는 것입니다. (부연 설명)

 

System call and API

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

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

 

 

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

API

System call

FILE structure

fd (file descriptor)

stdin

STDIN_FILENO

stdout

STDOUT_FILENO

fread()/fwrite()

read()/write()

malloc()/free()

brk()

Buffered I/O

Unbuffered I/O

 

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

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

 

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해야합니다.

 

 

 

Interrupt

 

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

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

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

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

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

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

 

Asynchronous interrupt

Maskable interrupt

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

NMI

NMI pin으로 들어옴.

Synchronous interrupt

exception

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

 

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

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

48. fault : fault란 발생한 사건을 복구하고 다시 재시작할 수 있는 상황들입니다. 따라서 이 경우 스택에 저장된 EIP에는 fault를 발생시킨 해당 instruction을 가리키고 있습니다. 따라서 fault handler가 끝나고 복귀할 때는 해당 instruction을 다시 실행하게 됩니다. 현대의 CPU들은 이러한 이유로 실행하다가 중지된 instruction을 undo 하는 기능을 가지고 있습니다. (나중에 더 자세히 살펴볼 기회가 있을지...) 대표적으로 page fault를 생각할 수 있습니다.

49. trap : trap은 해당 instruction이 종료되어서 다시 실행될 필요가 없는 경우, 그 다음 instruction의 주소를 스택에 넣게 됩니다. 따라서 이 trap을 처리한후 돌아와서는 그 다음 instruction을 실행하는 것입니다. 대표적인 용도로 디버깅을 들 수 있습니다. 매 instruction이 끝나고나서 그 결과를 보기 위해서 사용될 수 있습니다. 또는 breakpoint의 설정등에 사용됩니다.

50. abort : 이것은 심각한 에러로 인하여 더 이상 진행이 될 수 없는 상황에서 발생합니다. 이때는 스택의 eip에는 의미없는 값이 저장될 수도 있고, 프로세스가 종료되어야만 하는 상황입니다.

 

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

 

PC에서의 interrupt

 

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

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

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

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

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

 

51. ISR, IRR, IMR 이 모두 0입니다.

52. IRQ3에 신호가 실립니다.

53. IRR의 3번째 bit가 '1'이 됩니다.

54. IMR의 3번째 bit가 0이므로, IRR의 3번째 bit는 다음 회로의 input으로 들어갑니다.

55. ISR의 모든 bit가 0이므로, 즉, 처리중인 더 높은 우선순위의 interrupt가 없으므로, INT선에 신호를 줍니다. 즉, CPU의 INTR선에 신호가 들어갑니다.

56. CPU는 INTR의 신호를 감지하고 INTA신호를 줍니다.

57. IRR중 가장 높은 우선순위가 3번 bit이므로 ISR의 3번째 bit를 set합니다.

58. CPU가 두 번째 INTA신호를 줍니다.

59. ISR의 가장 높은 3번 bit에 해당하는 interrupt vector를 IO공간에 씁니다.

60. INT신호를 끄고, IRR의 3번째 bit는 0으로 reset합니다.

61. 이후에, CPU는 처리를 마친후 EOI 신호를 주고, 이것은 ISR의 3번째 bit를 reset합니다.

 

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

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

 

Interrupt vector

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

 

 

(from intel manual)

 

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

 

여러 Interrupt &exception

 

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

 

Vector no.

Mnemonic

Linux의 handler

설명

Signal

0

#DE

divide_error()

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

SIGFPE

1

#DB

debug()

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

SIGTRAP

2

 

nmi()

NMI

 

3

#BP

int3()

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

SIGTRAP

4

#OF

overflow()

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

SIGSEGV

5

#BR

bounds()

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

SIGSEGV

6

#UD

invalid_op()

잘못된 op-code일때.

SIGILL

7

#NM

device_not_available()

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

SIGSEGV

8

#DF

double_fault()

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

SIGSEGV

9

 

coprocessor_segment_overrun()

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

SIGFPE

10

#TS

invalid_tss()

TSS가 잘못되었을 때.

SIGSEGV

11

#NP

segment_not_present

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

SIGBUS

12

#SS

stack_segment()

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

SIGBUS

13

#GP

general_protection()

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

SIGSEGV

14

#PF

page_fault()

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

SIGSEGV

15

 

 

(인텔이 예약)

 

16

#MF

coprocessor_error()

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

SIGFPE

17

#AC

alignment_check()

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

SIGSEGV

18 - 31

 

 

(인텔이 예약)

 

 

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

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

CPU Protection

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

 

 

 

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

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

 

 

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중에 하나의 경우에 놓여있게 됩니다.

• 커널모드, process context에서 해당 process의 요청에 의한 수행 (시스템콜)

• 커널모드, interrupt context에서 process와는 상관없이 인터럽트 처리중 (인터럽트)

• 유저모드, process 의 코드를 수행중

("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되는 경우를 살펴보기 위해서, 먼저 두가지 사항을 알아야 합니다.

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

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

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

 

Virtual Address Space

Process의 구성

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

 

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

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

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

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

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

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

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

 

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의 구성"편에서 보았던 일반적인 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에 대한 정보를 이용합니다.

 

Dynamic library

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

여기서 볼 수 있는 것은, init이 libc-2.3.2.so 라는 파일을 이용하고 있다는 점입니다. 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 library의 특징으로는 PIC이어야 한다는 것인데, Position independent code (PIC)는 주소공간내의 어디에도 붙을수 있게끔 코드내의 absolute address가 없는 코드를 뜻합니다. executable의 경우도 PIC일수도 있는데 이때 PIE(Position independent executable) 이라고 불립니다. function이나 global variable들의 주소가 포함되지 않는 이런 PIC코드는 이들에 접근하기 위해서 GOT(global offset table)을 통해서 접근하게 됩니다. GOT는 이러한 주소들의 테이블로 dynamic loader가 DSO를 매핑할때 GOT를 채워넣게됩니다.

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

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

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

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

 

 

CPU Scheduler

 

스케쥴링은 여러 level에서 여러 factor들을 생각할수 있지만, 일단 여기서는 short-term CPU scheduling을 얘기해봅시다. (long-term scheduler혹은 job scheduler라고 불리는 녀석은 과거의 유물이라서 별 의미가 없습니다.) 스케쥴러는 크게 두부분으로 이루어집니다. 런큐에서 어떤녀석을 골라낼지를 정하는 schedule()함수, 그리고 실제로 그 프로세스(혹은 쓰레드)를 CPU에 올린후 실행시키는 dispatcher죠. dispatcher는 architecture-specific할수도 있지만 대부분 비슷비슷합니다. schedule()함수가 보통 핵심입니다. 가장 단순하게 생각할수 있는것이 런큐에서 한녀석씩 계속 차례로 실행해주는 Round-robin 방식입니다. 가장 간단하죠. 한가지 약점중 하나는 무한fork와 같은 경우를 주의해야한다는것이죠. 부모가 무한fork를 할때 자식이 큐에서 부모다음에 오게 된다면 이제 시스템 전체가 서게되겠죠. 물론 자식을 큐의 맨끝에 놓으면 됩니다. 이 문제는 Linux 2.4스케쥴러에서도 나타나는데, 2.4스케줄러에서는 CPU에서 실행될수 있는 양을 credit이라는 숫자로 각 프로세스에게 분배해준후 이들이 모두 credit을 소모했을때 새롭게 다시 credit을 분배해줍니다. 이 한 주기를 epoch이라고 하는데, 이런 credit방식에서는 부모가 fork를 하면 자식은 부모의 credit을 절반 나눠서 가져오게되는데, 그래야만 전체 프로세스들의 credit양이 일정하게 유지되기 때문입니다. 자식이 credit을 새롭게 생성해서 가지게 되면 무한fork의 경우 epoch이 끝나지 않고 다른 모든 프로세스 들은 실행되지 못하게되어 시스템 전체가 서게됩니다. 만약 credit방식을 사용한다면 반드시 주의해야할 점입니다.

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

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

따라서 몇가지 이러한 고려요소들을 나타내는 metric들을 살펴보면, 첫째로 throuhgput 과 latency가 있습니다. throughput을 위해서는 최대한 스케쥴링(context switching)을 줄여야 하겠지만, 각 프로세스가 경험하는 latency를 줄이기 위해서는 최대한 빨리 스케쥴링해줘야하는것이죠. 그래서 보통 process들을 CPU-bound와 IO-bound로 나눠서 얘기합니다. latency time은 다른 각도에서 response time이라고도 부르고, IO-bound는 interactive job이라고도 합니다. 즉 GUI같은 사람이 느끼는 작업등에서는 response time이 중요시되죠. 그래서 평균 response time을 줄이려고 노력하지만, average못지 않게 중요한것이 (혹은 더 중요한것이) response time의 variance입니다. 즉 편차를 줄이는것이 사람의 관점에서 predictable하게 느껴지기때문이죠. 그리고 빼놓을수 없는것이 fairness입니다. 이 fairness는 무척이나 논란거리인 개념이지만, 기본적으로 starvation이 발생해서는 안됩니다. 그리고 특수한 경우일수 있지만, embedded 환경에서 강조되는 Realtime이 있습니다. (일단 realtime은 생략..) 어떤 스케쥴러는 fairness를 강조하고, 어떤것은 throughput을, 혹은 latency를, realtime을 강조하며 다양한 스케쥴러가 존재합니다.

또하나 생각할것이 스케쥴러 자체의 overhead입니다. 스케쥴러의 overhead는 첫째로 얼마나 자주 스케쥴러가 불리는지와 스케쥴러의 효율성(O(1)인지 O(n)인지), 에의해서 주로 결정되죠. (또 생략..)

앞서의 RR은 I/O-bound 프로세스에겐 나쁜 response time을 주게됩니다. 따라서 이런종류의 프로세스에게 우선권을 주는 방식이 도입되는데, 이게 priority입니다. priority가 높은 작업이 큐에 들어오게되면 즉시 이작업에게 CPU를 내줍니다. 이런 단순한 발상은 과거의 job scheduling시절의 유물인데, 아직까지도 스케쥴링에선 적잖게 이 개념이 쓰이고 있습니다. 현대에서는 발전된 CPU속도와 preemptive scheduling으로 priority의 의미가 퇴색되어 가고있습니다만, 아직까지 스케쥴링 얘기에서는 빠지지 않는것 같더군요. long-term scheduler에서는 아직까진 유용하겠습니다만, short-term scheduler에서는 이런 priority는 많은 부작용을 가진 애매한 policy입니다. 화성에 갔던 path finder를 무한 리부팅으로 몰고갔었던 priority inversion 문제도 그렇지만, 원래의 priority의 의미보단 요즘엔 dynamic priority라는 헷갈리는 이름으로 사람들을 더욱 혼란스럽게 하고 있습니다. 이쯤되면 credit과 priority의 중간쯤되는 의미가 되겠습니다. 그래도 I/O bound process에게 즉시 우선권을 줘서 response time을 높이겠다는 의미정도는 여전히 유효합니다.

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

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

몇가지 스케쥴러를 살펴보겠습니다. 먼저 리눅스 2.4에서 쓰였던 스케쥴러(생략..앞서서 좀 설명했죠?), 그리고 2.6 초기에 쓰인 Ingo Molnar's O(1) scheduler. priority들을 bitmap으로 펼치는 편법으로 O(1)을 달성합니다. 제 생각에는 그보다 흥미로운것은 sleepness를 계산하는것인데, process가 자신에게 주어진 퀀텀을 다 소모했는지 아니면 중간에 yield했는지 를 따져서 sleepness를 계산합니다. 즉 퀀텀을 다 쓴다면 CPU-bound 라고 해석하고, yield를 한다면 I/O-bound라고 해석합니다. 헌데 이 계산법이 꽤나 정교해서 쓸만한것 같더군요. 어쨌든 O(1)이라고 해서 히트쳤던 스케쥴러입니다. http://en.wikipedia.org/wiki/O(1)_scheduler 그리고 2.6.23정도에서는 CFS(complete fair scheduler)로 바뀝니다. http://en.wikipedia.org/wiki/Completely_Fair_Scheduler 윈도우는 multilevel feedback queue를 사용합니다. 여러개의 큐를 프로세스가 옮겨다니는 방식이죠. (개별 스케쥴러의 자세한 사항은 google님께-_-;물어보시기 바라며, 쿨럭...)

이제 SMP의 경우를 생각해봅시다. 스케쥴러를 구현하려할때 먼저 고려할 사항은 런큐입니다. 개별큐를 쓸것인지, global queue를 쓸것인지. 물론 global queue를 쓰면 synchronization문제가 발생하여 performance에 안좋죠. 즉 scale하지 못합니다. 따라서 개별큐를 쓰는것이 좋지만, 그렇다고 모든것이 해결되는것이 아니죠. 프로세스는 어느큐로 가야할지, 어느 CPU에서 수행되어야할지, 또 수행중에 load balancing은 어떻게 해야할것인지 무척 많은 질문들이 나옵니다.. (이쯤에서 생략-_-;; 제가 연구하는 분야이기도해서 일단 나중에..)

VMM에서의 스케쥴리은 또다른 문제입니다. 일단 Xen에서의 스케쥴링중 하나인 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는 한번의 quantum만큼만 허용됩니다. 즉 다음 quantum boundary에서는 바로 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등)가 고려되어서 결정하네요.

 

Physical Memory Management

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

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를 참고하세요. 생략...

 

 

Slab Allocator

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

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

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

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

커널이 자주 쓰는 복잡한 객체를 할당할 때는 메모리 할당보다 construction과 destroy에 비용이 더 든다.

이것을 줄여보자는 것이 기본 idea이다. 자주 쓰이는 object들은 object cache에 넣어서 유지하면서 필요시

할당되고 반환되지만 constructor와 destructor는 다시 불리우지 않는다. 이 object cache는 전역적인

메모리 압박에 dynamic하게 반응하며, object coloring을 사용하여 시스템의 전체 cache성능과 bus balance를

향상시킵니다. 또한 시스템의 여러 가지 문제를 해결할 때 유용할수 있는 여러 가지 통계치 디버깅 기능도

가지고 있습니다.

1. 서론

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

2. Object caching

Idea는 construction이 된 초기상태의 불변(invariant)부분을 보존하자는 것이다.

예를 들어, mutex를 포함하는 객체는 객체가 생성될 때 단 한번만 mutex_init()이 불려지게 된다.

이후 캐쉬안에 있으면서 여러번 재사용될 것이다. object에 포함되어있는 locks와 condition variables,

reference counts, 다른 객체의 리스트, read-only data등은 모두 일반적으로 초기상태로서 간주한다.

이런 cache는 특히 멀티쓰레드 환경에서 매우 유용하다. 자주 쓰이는 객체들이 대부분 하나 이상의 내장된

locks나 condition variables등을 가지고 있기 때문이다. object cache의 구현은 간단하게, object가

요구되면 cache에서 꺼내주고, 없다면 새로이 만들어서 주면되고, object가 반환되면 단순히 cache에

되돌려줄뿐이다. 캐쉬가 전역 메모리 할당자에 의해서 메모리를 반환할 것을 요구받게되면, 객체들을

destroy하고 메모리를 반환하게 된다. 객체는 캐쉬에 들어올 때 한번만 초기화되며, 그 이후로는 객체의

할당과 반환은 trivial하다.

물론 이런 object cache는 중앙 할당자와는 별개로 독립적으로 구현될수 있으나 다음과 같은 한계가 있다.

1) 중앙할당자와의 메모리에 대한 tension이 있는데, 이것에 대처할수 없다. 즉, 중앙할당자가 페이지들을

필요로 할때 자신의 남는 페이지들을 반환해줄수가 없다.

2) 중앙 할당자를 우회해가기(bypass) 때문에 유용할수 있는 통계치나 디버깅 기능을 가지지 못한다.

3) 공통된 할당 문제에 대해서 이렇게 독립적으로 구현된 여러 cache들은 커널의 크기를 증가시키고

유지비용을 크게한다.

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

인터페이스 설계를 위해서 다음을 생각해보자.

(A) 객체를 서술하는 내용들(이름,사이즈,정렬,생성자,소멸자등...)은 할당자가 아닌 client에 속한다.

(B) 메모리 관리는 중앙 할당자에게 속한다. 즉, client는 메모리의 할당과 해제에 대해서는 신경쓰지 않는다.

(A)에 의해서 객체 생성은 client-driven이어야하며 client가 객체에 대한 모든 정보와 spec을 가지고 있어야

함을 알수 있다. 이에 따른 인터페이스를 보면,

(1) struct kmem_cache *kmem_cache_create(char *name, size_t size,int align,

void (*constructor)(void *,size_t),

void (*destructor)(void *, size_t) );

object cache를 생성한다. 이름과 생성자와 소멸자를 받음을 알수 있다.

(B)에 의해서 client는 단순히 빈 객체를 할당/해제받는 함수만이 필요함을 알수 있다.

(2) void *kmem_cache_alloc(struct kmem_cache *cp, int flags);

캐쉬에서 객체를 얻는다. 물론 객체는 미리 만들어져있는 상태다. flags는 KM_SLEEP이나

KM_NOSLEEP이다. 이는 만일 현재 사용가능한 객체가 없다면 메모리를 할당받을때까지

기다릴지 아닐지를 나타낸다.

(3) void kmem_cache_free(struct kmem_cache *cp, void *buf);

캐쉬에 객체가 반환된다. 객체는 반드시 initial state에 있어야한다.

(4) void kmem_cache_destroy(struct kmem_cache *cp);

캐쉬를 제거하고 모든 메모리/자원을 반환한다. 모든 할당된 객체들은 캐쉬에 돌아와 있어야만 한다.

이러한 인터페이스를 써서 client의 요구에 부응하는 할당자를 구현할수 있다. 이런 의미에서

"맞춤형"할당자라고도 할수 있다. 이러한 맞춤은 client가 실행시간에 필요할 때에 할당자에게

알려서 사용할수 있게끔 한다.

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

않는다는 것이다.

3. 슬랩할당자의 구현

back end front end

-------- ---------

---------------

kmem_cache_grow() --> | | --> kmem_cache_alloc()

| cache |

kmem_cache_reap() <-- | | <-- kmem_cache_free()

---------------

front end는 client와 할당자와의 인터페이스이다. 이것은 객체들을 캐쉬에서 꺼내거나

집어넣게된다. back end는 캐쉬와 중앙 할당자와의 인터페이스로서, 캐쉬로의 메모리의

유입을 제어한다. kmem_cache_grow()는 VM시스템에서 메모리를 가져온다. 그리고

kmem_cache_reap()은 VM이 메모리를 필요로 할때 불려져서 쓰이지 않는 캐쉬의 메모리를

VM에게 반환한다. 이러한 back end의 활동은 오로지 메모리의 압박에 의해서만 호출됨을

유의하라. 캐쉬가 더 많은 객체가 필요할때 메모리는 캐쉬로 유입되고, 나머지 시스템이

더 많은 페이지를 필요로 한다면 캐쉬에서 메모리는 방출된다. 거기에는 어떤 제한이나

watermarks도 없다. 이러한 이력(hysteresis)에 의한 제어는 working-set 알고리즘에

의해서 제공된다.

슬랩 할당자는 어떠한 단일체라기보다는 독립된 object 캐쉬들의 느슨한 연합체라고 할수

있다. 이 캐쉬는 공통되는 상태(state)라는것이 없기때문에 각각의 캐쉬들은 자신들만의

locks를 가질수 있고, 각 캐쉬들은 동시에 접근될수 있다. 각 캐쉬들은 자신만의 통계치

를 가지는데, 이것으로 종합적 시스템의 동작상황을 알수 있다. 어떤 부분이 어느만큼의

메모리를 소모하고 있는지, 또는 memory leak현상이 있는지등을 알수 있다. 즉, 각 subsystem

의 activity level을 알수 있게된다.

슬랩 할당자는 일종의 customized segregated storage allocator이다. 이런 류의 할당자는

각 크기마다의 freelist를 유지한다. CustoMalloc할당자나 QuickFit할당자, Zone할당자들이

그러한 할당자들이다. 이들은 보통 space나 time에서 있어서 optimal이다. 이들은 미리

잘 쓰이는 할당크기들에 대한 정보를 가지고 있다. 슬랩 할당자도 이들과 같은 유형이다.

그러나 차별적인 점은 컴파일시간이 아닌 런타임에 client에 의해서 맞춰지는 client-driven

방식이라는 점이다. (이것은 Zone할당자도 마찬가지다.)

표준 kmem_alloc과 kmem_free는 내부적으로 이 캐쉬를 쓴다. 시작시에 8바이트에서 9K까지

대략 10-20%씩 증가하는 크기의 30개의 캐쉬를 유지한다. kmem_alloc()은 가장 가까운 크기의

캐쉬로부터 kmem_cache_alloc()를 수행한다. 9K보다 큰 할당은, 드물지만, 직접 중앙 할당자에

의해서 이루어진다.

슬랩은 캐쉬의 구성단위이다. 캐쉬가 늘어날 때 슬랩단위로 늘어난다. 여러개의 가상주소에서

연속된 페이지들로 구성된며 간단한 reference count를 가진다. 이 count는 이 슬랩에 속한 객체중

얼마나 많은 객체들이 할당되었는지를 나타낸다. 이 count가 0이어야지만 이 슬랩은 소멸될수 있다.

이런 간단한 구조에 의해서,

(1) 메모리수거가 편리하다. reference count가 0이면 그냥 반환될수 있다. 간단한 reference count

에 의해서 다른 할당자들이 쓰는 복잡한 비트맵, tree, coalescing 알고리즘등을 대체한다.

(2) 객체의 할당과 해제가 편리하다. 단순히 객체를 옮긴후 reference count만 바꿔주면 된다.

(3) 심각한 외부 단편화가 일어나지 않는다.

(4) 내부 단편화가 최소다.

- 하나의 슬랩이 n 개의 객체를 가질수 있다면, 단편화는 최대 1/n 이다. 따라서

이 조절은 슬랩의 크기에 의존한다. 그러나 너무 크면 외부단편화가 일어나게 된다.

이 사이엔 tradeoff가 있으므로,SunOS 5.4에서는 내부단편화를 12.5% (1/8) 로 제한하였다.

슬랩의 논리적 구조

--------

| kmem |

| slab |

--------

|

|

V

-------- -------- --------

| kmem | -----> | kmem | -----> | kmem |

|bufctl| |bufctl| |bufctl|

-------- -------- --------

| | |

| | |

V V V

--------------------------------------------------------

| | | | |

| buf | buf | buf |unused|

| | | | |

--------------------------------------------------------

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

kmem_slab 자료구조는 캐쉬에서의 슬랩의 연결을 관리하고, reference count를 가지고,

free list를 가진다. 이제, 각 버퍼(객체)는 kmem_bufctl에 의해서 제어되는데, freelist

linkage와, 버퍼의 주소, slab으로의 back pointer를 가진다. (그림에서 back pointer는

생략되었다.)

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

------------------------ --------------------------------------

| | | | | | un- | kmem |

| buf | buf | ... | buf | buf | used | slab |

| | | | | | | data |

------------------------ --------------------------------------

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

여기서 각 버퍼는 freelist에 있는동안 스스로가 bufctl로의 역할을 한다. 다른것들은

모두 계산가능하므로, 실제 필요한것은 linkage뿐이다. freelist linkage는 버퍼의 끝에

위치한다. (이를 위해 버퍼는 생성된 객체보다 한 word가 더 크다.) 이는 디버깅을

편리하게 하기 위함이다. 자료구조의 끝보다는 앞이 active하기 때문이다. 만일 버퍼가

해제된후에 수정되었다면, freelist linkage가 변하지 않고 있을때 디버깅이 편하기때문이다.

큰 객체에 있어서는 슬랩의 구조는 그 논리적 구조와 동일하게 된다. 필요한 slab data와

bufctl data는 그들 스스로가 작은 객체이므로 자신들의 캐쉬에서 나오게 된다.

Freelist management

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

할당된 slab)이 먼저오고, 부분적으로 쓰인 슬랩이 다음에, complete 슬랩(ref count = 0인

slab)이 뒤에 온다. 캐쉬의 freelist포인터는 이중 첫번째 non-empty슬랩을 가리키고, 이

슬랩은 이제 자신의 버퍼에 대한 freelist를 가진다. 이런 이중 구조는 메모리의 해제를 쉽게

해준다. 메모리를 반환할 때, 버퍼들을 unlink하는게 아니라 단순히 slab을 unlink한다.

kmem_cache_free()가 reference count 가 0인 슬랩을 보면, 리스트의 끝으로 보낸다. 이렇게

해서 complete slab이 partial slab이 있음에도 사용되는일이 없도록 한다. 메모리가 부족하여

VM이 메모리 해제를 요청해올땐, thrashing방지를 위한 최근 사용된 15초 working set만 남기고

해제한다.

4. 하드웨어 캐쉬 효과

buffer address 의 분포는 성능에 많은 영향을 준다. 그래서 2^n의 주소에 정렬하는 알고리즘은

안좋은 영향을 준다. 구조체에서 자주 쓰이는 필드가 앞부분에 몰려있다. 이것 역시 좋지 않다.

예전엔 신경쓰지 못하던 부분들이지만 이제 중요하다. 슬랩 할당자는 간단한 slab coloring이라는

개념으로 buffer address를 캐쉬에 고루 분포시키고 있다. 새로운 슬랩이 만들어질때, 버퍼의

주소는 슬랩의 base로부터 약간씩 다른 offset(color)에서부터 시작한다. 이렇게 해서 좋은점중의

하나는 2^n의 중간 사이즈 버퍼는 최대의 coloring을 가진다는것이다. 이는 kmem_slab데이터 때문에

worst fit이 되기 때문이다.

작거나 midsize 버퍼에 대해서 또 좋은점은 이들이 한 페이지안에있기 때문에, 단일 TLB entry가

대부분의 action을 커버할수 있다는 점이다.

 

 

Vmalloc

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

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

 

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가 여전히 중심적인 역할을 하고있기 때문이며 버퍼를 유지하는한 버퍼캐시가 없다는것은 말이 안되기 때문이죠.

시스템콜을 통한 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의 방식으로써 곧잘 쓰이기도 합니다.

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

(생략)

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

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

O_NONBLOCK.. 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를 줄인다던지 메모리를 증가시키는법을 선호하죠.

 

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 Allocation

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

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의 변종알고리즘을 씁니다.

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                            readincrement            incrementwrite                           write

 

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

 

CPU A                CPU B

readincrementwrite                        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이 붙은 것과 같다고 생각할 수 있습니다.

 

 

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 = counterregister1 = register1 + 1counter = register1

--의 경우도 역시

register1 = counterregister1 = register1 - 1counter = 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 sectionexit 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 sectionenable_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 sectionsignal(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 sectionlock = 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 sectionlock = 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 sectionsignal(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 sectionturn=1-i;

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

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

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

    critical section

flag[i] = false;

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

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

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

    critical section

flag[i] = false;

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

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

공유되는 데이터로는

boolean choosing[n];

int number[n];

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

 

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

critical section

number[i] = 0;

 

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

 

repeatchoosing[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 sectionnumber[i]:=0;                                                                                             remainder sectionuntil 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을 구현한 예제입니다.

 

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 0the 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을 모두 제거할수 있게 해주는 기법으로... (또 백만년...)  

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가 사라집니다.

T1T2

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.이라고도합니다.

T1T2

read(X);

X = X-N;

write(X);

read(X);<-- 임시적인 값을 읽었음. dirty read

X = X+M;

write(X);

read(Y);

rollback;

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

T1T3

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

T0T1

read(A)

write(A)

read(B)

write(B)

read(A)

write(A)

read(B)

write(B)

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

T0T1

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)T1T2

read(X);

X = X-N;

write(X);

read(Y);

Y = Y+N;

write(Y);

read(X);

X = X+M;

write(X);

(b)T1T2

read(X);

X = X+M;

write(X);

read(X);

X = X-N;

write(X);

read(Y);

Y = Y+N;

write(Y);

(c)T1T2

read(X);

X = X-N;

read(X);

X = X+M;

write(X);

read(Y);

write(X);

Y = Y+N;

write(Y);

(d)T1T2

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

T2T3

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 needsCurrent needs

P0105

P142

P292

이때는 안전한 상태입니다. (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의 시간에 다음과 같을때

AllocationMaxAvailable

A B CA B CA B C

P00 1 07 5 33 3 2

P12 0 03 2 2

P23 0 29 0 2

P32 1 12 2 2

P40 0 24 3 3

Need

A B C

P07 4 3

P11 2 2

P26 0 0

P30 1 1

P44 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)이죠. 이걸 허가했을때는 다음과 같아집니다.

AllocationNeedAvailable

A B CA B CA B C

P00 1 0 7 4 3 2 3 0

P13 0 20 2 0

P23 0 1 6 0 0

P32 1 1 0 1 1

P40 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들을 살펴보겠습니다.

(공룡책 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를 사용하고 있습니다. 역시 오버헤드가 만만치 않다는 얘기입니다.

윈도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 하게 메세지를 처리할수 있습니다.

Pipes and FIFOs

Signals

Sockets

System V IPC

Shared memory

Semaphores

Message queues

 

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을 구현해놓았습니다.

 

Paging

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가지 경우를 생각할 수 있습니다.

64. VMA정의되고 address mapping이 없는 경우 : page fault에 걸리겠지만, valid한 참조입니다.

65. VMA정의되고 address mapping이 있는 경우 : 정상적인 참조가 이루어집니다.

66. VMA정의되지 않고 address mapping이 없는 경우 : invalid한 참조입니다. 프로세스를 강제로 종료시킵니다.

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

fault가 뜨면 OS가 이를 해결한후엔 instruction이 다시 수행되어야하는데, 이 과정이 쉽지 않습니다. 즉 부분적으로 수행되었던 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의 역할을 좀더 구체적으로 살펴볼 준비가 된 것 같습니다.

 

 

 

I/O

Memory mapped vs programmed

 

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

Independent I/O : Independent I/O에서는 IO instruction이 따로 있습니다. 보통 In/Out 과 같은 instruction을 이용하여 (main memory address space와는 별개로) 따로 독립되어 있는 IO address space에 접근하며 I/O를 하는 방식입니다. 이 IO address space의 각 주소는 각 장비들의 register들에 (버스의 arbitration등에 의해서) hardware적으로 mapping되어 있습니다. 따라서 이 address space에 쓰거나 읽는 것이 그러한 장비들의 register에 쓰거나 읽는 동작이 됩니다. 이때 각 register들에 해당하는 주소들을 port라고 부릅니다. 먼저 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의 예라고 할 수 있습니다. 또한 아마 old user들은 serial port나 parallel port등의 장비들의 COM포트등의 주소를 맞춰주는 등의 설정을 해보신일이 있을 것입니다. 이러한 장비들이 independent I/O방식의 예라고 할 수 있습니다.

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

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

 

 

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

 

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

 

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

요즘의 디스크들은 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입니다. 여기엔 공유되는 파일별 정보들이 다 있죠. 디스크상의 위치나 파일 사이즈같은것들이죠.

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을 관리하게됩니다. 이런경우에 권한간의 우선순위를 정하는것도 결정할일입니다.

 

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영역에서 쓰입니다. 최신 기능들을 필요로 하는 환경들입니다.

 

 

 

Userland

최초의 process는 init이라는 프로세스입니다. 커널내에 hardcode되어있는데, 보통 /bin 이나 /sbin 등에 있는 init이라는 화일을 순차적으로 검색해서 실행하죠. 자연히 pid는 1을 가지며, userland를 만들어내는 일을 합니다. 두가지 방식으로 실행되는데, 다음과 같이 자신의 pid를 검사해서 1 이라면 부팅시의 init역할을 하며, 이후 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]

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

 

Part II

OS다시보기

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

보통 이러한 커널이 제공하는 서비스들은 모두 커널이라는 하나의 실행 이미지안에 모두 들어가있는데 이러한 것을 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위에서 구현된 경우죠. 즉 유닉스를 어플리케이션으로서 돌리는거죠.

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

생략...

윈도우즈같은 경우 최대한 많은 서비스들을 커널에 넣어놓은 경우라고 한다면 (사실 꼭 그렇지는 않습니다만...) 리눅스는 전통적인 영역까지만을 넣고 있는 경우입니다. 만일 이러한 서비스들을 최대한 밖으로 꺼낸다면 어떻게 될까요? 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을 추구하는것입니다. 이러한 철학들이 시스템의 구조를 결정하게 됩니다.

 

 

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간의 이야기가 나오네요.)

가상화는 어떤 경우에는 기존의 OS 혹은 시스템전체를 아무런 수정없이 그대로 실행할수 있는 경우 full virtualization이라고 부릅니다. 어떤 경우에는 OS에 수정을 가해야할때가 있기때문이죠. 먼저 Virtualization기법을 두가지로 분류해보겠습니다. Software approach와 hardware approach가 그것입니다. Software방식중 하나인 binary translation을 살펴봅시다. 이 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 이라고 할수 있습니다. 이런 translation은 기존의 OS를 수정없이 그대로 실행될수 있기때문에 full virtualization 을 구현하고 있습니다.

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

따라서 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 이라고 부르는것같습니다. 마치 UML역시 새로운 ISA를 정의하듯이 말입니다.

Xen의 세부사항...

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

이제 hardware방식을 좀 살펴보겠습니다. Intel과 AMD가 각각 VT와 SVM(Pacifica)라는 이름의 x86용 하드웨어 가상화 instruction set을 도입했습니다. (x86이 한층 더 지저분해졌네요. :-D x86이 애초에 virtualizable하지 않았기때문에 긴 여정을 왔네요..) 기본적인 아이디어는 기존과 마찬가지로 CPU trapping입니다. Intel의 경우 VMCS라고하는 VM의 상태를 저장하는 구조들을 정의하고 기존의 4개의 ring외에 root mode등의 모드를 정의합니다... TODO..

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

Xen 역시 최근 이런 harward support를 이용한 가상화를 지원하고 있습니다. HVM domain이라고 부릅니다.

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

 

Xen

 

Xen에 대해서...(제대로 채우려면 한 백만년쯤걸릴듯...)

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

L4

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

 

Plan9

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

 

 

Part III

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의 개념을 만들어낸...

 

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

 

 

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를 잘 보여줍니다.

 

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대의 개발이 이루어지고 있는데, 이런 개발과정 다음을 통해 봅시다.

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

 

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

 

Linux에서의 가상 메모리

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

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

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

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

 

Linux에서의 MM의 초기화

 

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

용어 ----------------------PGD : page directory tablePGT : page tablePTE : 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)

 

 


<출처>

'old drawer > Operating System' 카테고리의 다른 글

[OS] GeekOS Projects!!  (0) 2011.06.20
[OS] 전역 변수와 지역 변수  (0) 2011.05.29
[OS] Semaphore 란?  (0) 2011.05.24