Virtual Memory : Demand Paging


하드디스크의 일부를 마치 메인 메모리처럼 사용할 수 있도록 하는 기술이다. 이는 물리적 메모리(Physical Memory)의 한계를 극복하기 위한 기술이다. 이 기법은 하나의 프로그램을 실행할 때 프로그램 전체가 메모리로 올라와 실행되는 것이 아닌, 필요한 부분만을 불러와 실행하는 것을 기본으로 한다.

 

즉, 커널은 실제 메모리(RAM)에 올라와 있는 블록들 중에, 쓰이지 않는 것을 디스크에 저장한다. 이를 통해서 사용 가능한 메모리 영역을 늘린다. 만일 디스크에 저장되었던 메모리 블록이 다시 필요하게 되면 실제 메모리 안으로 올려지며 대신에 다른 블록이 메모리로 내려간다. 이런 과정이 일어나고 있다는 것은 사용자가 알 수 없고, 그저 많은 양의 메모리가 있는 것처럼 보일 뿐이어서 점유하고 있는 메모리가 디스크에 있는 실제 메모리에 있는 지는 신경쓰지 않아도 된다.

 

다만, 디스크를 읽고 쓰는 시간은 메모리를 읽고 쓰는 시간보다 훨씬 느리기 때문에 프로그램의 실행은 그만큼 느려지게 된다. 이렇게 가상메모리로 쓰이는 하드디스크 영역을 스왑 영역(Swap Space)이라고 한다.

 

 

출처 : attiadmin.guyweb.co.kr/linux/swap.html

 

 

 

 

Swap Space


먼저, 페이지를 이동할 수 있도록 디스크의 공간을 확보해야 한다. 운영체제에서는 이를 위해 스왑 공간(Swap Space)를 참조하는데, 이는 메모리에서 페이지를 스왑 공간(디스크)으로 옮기고 스왑 공간으로부터 페이지를 메모리로 옮기기 위해서다. 따라서, 운영체제가 스왑 공간에서 페이지 크기의 단위로 읽고 쓴다고 가정했을 때, 운영체제는 지정된 페이지의 디스크 주소를 기억해야 한다.

 

스왑 공간의 크기는 궁극적으로 특정 시간에 시스템에서 사용할 수 있는 최대 메모리 페이지의 수를 결정하기 때문에 중요하다.

 

 

 

 

The Present Bit


이처럼 디스크에 공간을 확보했으니, 디스크와 페이지를 스왑(swap)하는 것을 지원하기 위해 시스템위에 기계(machinery)를 추가해야 한다.

 

Without Swap Space

먼저, 하드웨어 기반으로 관리되는 TLB가 있다고 가정했을 때, 메모리를 참조할 때 어떠한 일이 벌어지는지 떠올려보자. 실행 중인 프로세스는 가상 메모리 참조(Virtual Memory References)를 생성하며, 하드웨어는 원하는 데이터를 메모리에서 가져오기 전에 해당 참조를 물리적 주소로 변환한다.

 

하드웨어는 먼저 가상 주소에서 VPN을 추출하고 TLB에서 일치(TLB Hit)를 확인하고, 적중 시 해당하는 물리적 주소를 가져와 메모리로부터 데이터를 가져온다. 이 경우에는 추가적인 메모리 액세스가 필요하지 않으므로 속도가 빠르며 일반적인 경우다.

 

TLB에서 메모리를 찾을 수 없을 때 (TLB Miss), 하드웨어는 메모리에 있는 페이지 테이블로부터 VPN을 인덱스로 사용하여 해당 페이지에 대한 페이지 테이블 항목(PTE)을 조회한다. 페이지가 유효하고 물리적 메모리에 있는 경우 하드웨어는 PTE에서 PFN(Page Frame Number)을 추출하여 TLB에 설치한 후 해당 명령을 다시 시도한다. 이번에는 TLB Hit가 발생한다.

 

 

With Swap Space

만약 페이지(pages)가 disk와 스왑(swap) 되기를 원한다면, 즉 가상 메모리를 사용한다면 더 많은 기계를 추가해야 한다. 특히, 하드웨어 및 운영체제가 PTE를 확인할 때 페이지가 물리적 메모리에 존재하지 않는다는 것을 발견할 수 있다. 이처럼 하드웨어 및 운영체제가 페이지의 물리적 메모리 상의 존재 여부를 판단하기 위해 사용하는 새로운 정보는 각각의 PTE 항목에 존재하는 present bit이다.

 

Present bit이 1이라면, 페이지가 물리적 메모리에 존재한다는 것을 의미하고, 위와 같이 진행하면 된다.

Present bit이 0이라면, 페이지는 메모리가 아닌 디스크에 존재한다는 것을 의미한다. 물리적 메모리(physical memory)에 존재하지 않는 페이지에 접근하는 상황을 page fault라고 한다. 

 

페이지 폴트가 발생하면 OS가 호출되어 페이지 폴트를 처리한다. Page fault Handler가 실행되고 페이지 폴트를 해결한다.

 

 

 

The Page Fault


페이지가 메모리에 존재하지 않고 디스크로 스왑되어 있을 경우 운영체제는 page fault를 해결하기 위해 해당 페이지를 메모리로 스왑해야 한다. 그렇다면, 운영체제는 어떻게 해당 페이지의 위치를 디스크로부터 찾을까? 보통, 이러한 정보는 페이지 테이블에 저장되어 있다. 따라서, 운영체제는 PTE의 일부 비트에 해당하는 데이터를 해당 페이지 disk address의 PFN처럼 사용한다. 운영체제는 페이지에 대한 page fault를 수신하면 PTE를 조회하여 disk address를 찾고 디스크에 해당 페이지를 메모리에 불러오도록 요청을 전송한다.

 

Disk I/O(디스크 입출력)가 완료되면, 운영체제는 페이지 테이블을 업데이트하여 해당 페이지의 present bit을 1로 변경하고 PTE의 PFN에 새롭게 가져온 페이지의 in-memoroy location을 기록한다. 그리고 해당 명령을 재시도한다.

 

이번 재시도에서는 TLB miss가 발생할 수 있다. 발생 시 위의 변환된 정보로 TLB를 업데이트하고, 서비스할 것이다. 이와 같은 단계를 피하기 위해서 page fault를 해결할 때 TLB를 업데이트할 수도 있다.

 

최종적으로, 변환 정보를 TLB에서 찾아 변환된 물리적 주소(physical address)에 존재하는 원하는 데이터 또는 명령을 메모리로부터 가져온다(fetch). 

 

I/O가 발생한 동안에 해당 프로세스는 block상태가 된다. 따라서 운영체제는 page fault를 처리하는 동안에 다른 ready상태의 프로세스를 실행할 수 있다. I/O는 고비용 작업이기 때문에 다른 프로세스와 실행을 overlap(겹침)하는 것은 멀티프로그래밍 시스템이 하드웨어를 가장 효과적으로 사용할 수 있는 방법 중 하나이다.

 

 

 

What if Memory is Full?


위에서 설명한 절차는 페이지를 Swap Space(스왑 공간)으로부터 메모리로 가져오기에 충분한 사용 가능 메모리 용량이 확보되었다는 것을 가정한다. 물론 그렇지 않은 경우도 있다. 메모리가 꽉 찬(full) 상태 또는 거의 꽉 찬 상태일 때이다. 이 때, 운영체제는 가져오려는 새 페이지의 공간을 확보하기 위해 하나 이상의 페이지를 내보낼 수 있다. 내보내거나 교체할 페이지를 선정하는 절차를 Page-Replacement Policy라고 한다.

 

메모리에 존재하는 페이지를 잘못 내보낼 경우 프로그램 성능에 있어 엄청난 비용이 발생하기 때문에 좋은 페이지 교체 정책을 만드는 것이 중요하다. 잘못된 결정을 내린다면 프로그램이 메모리 기반 실행 속도가 아닌 디스크 기반 실행 속도로 실행하게 되며 이는 프로그램이 10,000배 또는 100,000배 느려질 수 있다는 것을 의미한다.

 

 

 

 

 

Page Fault Control Flow


지금까지 배운 지식들을 총집합하여 메모리 액세스의 전체 제어 흐름(comple control flow of memory access)을 스케치할 수 있다. 누군가가 메모리에서 어떠한 데이터를 가져오고자 할 때 무슨 일이 발생하는가?라는 질문을 한다면 발생할 수 있는 모든 다른 가능성들에 대해서 숙지하고 있어야 한다.

 

출처 : https://pages.cs.wisc.edu/~remzi/OSTEP/

 

위 Hardware Control Flow Algorithm을 통해서 알아둬야 할 대표적인 세 가지 사례가 있다.

 

첫 번째는, 페이지가 존재하고 유효한 경우다(valid and present, 18-21). 이 경우 TLB Miss Handler는 PTE로부터 PFN을 획득하여 명령을 다시 시도하면 TLB hit가 발생하고 여러번 계속할 수 있다.

 

두 번째는,  Page fault handler를 실행해야 하는 경우다(Valid but not present, 22-23). 페이지가 프로세스가 접근할 수 있는 유효한 상태이지만 물리적 메모리에 존재하지 않는 상황이다. Page fault handling(스왑공간에서 페이지 읽도록 I/O요청 및 페이지 테이블 업데이트) -> 재시도(TLB Miss) -> 재시도(TLB Hit) 순서다.

 

세 번째는 프로그램의 버그로 인해 유효하지 않은 페이지에 접근한 경우다(not valid, 22-23). 이 경우 PTE의 나머지 비트들은 관여하지 않고, 하드웨어가 invalid access를 trap하고 운영체제는 trap handler를 실행하여 해당 프로세스가 종료될 수 있다.

 

 

 

 

 

When Replacements Really Occur


지금까지 설명한 대체 방법은 운영체제가 메모리가 완전히 가득 찰 때까지 기다렸다가 다른 페이지를 위한 공간을 확보하기 위해 페이지를 교체하는 것으로 가정했다. 이는 다소 비현실적이며, 이보다는 운영체제가 메모리의 일정 작은 부분을 능동적으로 사용가능한 상태로 유지하는 것이 여러 측면에서 유리하다.

 

메모리의 일부 작은 공간을 확보(free)하기 위해서 대부분의 운영체제는 일종의 high watermark(HW)와 low watermark(LW)를 가지고 있다. 사용가능한 페이지 수가 LW보다 적어질 때 메모리 확보를 담당하는 백그라운드 스레드가 실행된다. 해당 스레드는 사용가능한 페이지 수가 HW에 다다를 때 까지 페이지를 제거한다. Swap daemon 또는 page daemon1이라고 불리는 해당 스레드는 운영체제가 프로세스를 실행하는동안 사용할 여유 메모리를 확보한 후 sleep상태가 된다.

 

여러 교체 작업을 한번에 수행하여 성능 최적화를 할 수 있다. 예를 들어, 많은 시스템은 여러 페이지를 클러스터링하거나 그룹화하여 한 번에 스왑 파티션에 기록하므로 디스크의 효율성이 향상된다. 이러한 클러스터링은 disk의 검색 및 회전 오버헤드를 줄여 성능을 현저하게 향상시킨다.

 

백그라운드 페이징 스레드를 이용하면 직접 교체를 수행하는 대신 사용 가능한 여유 페이지 공간이 있는지 확인할 수 있다. 여유 페이지 공간이 존재하지 않을 경우 백그라운드 페이징 스레드에 페이지가 필요함을 알린다. 사용 가능한 페이지가 존재하게 되면 백그라운드 페이징 스레드는 원래 스레드를 깨워 원하는 페이지로 이동할 수 있다.

 

해야할 일이 있을 때, 작업의 그룹화를 허용하고 효율성을 높이기 위해서 백그라운드에서 수행하는 것이 종종 좋다. 운영체제는 때때로 백그라운드에서 일을 수행한다. 예를 들어, 실제로 디스크에 데이터를 쓸 때(write), 많은 시스템 버퍼 파일이 메모리에 쓰인다(write). 이렇게 하면 디스크에 한 번에 많은 쓰기(write)를 수행할 수 있어 디스크 효율 향상, 쓰기 지연 시간 단축(improved latency of writes)이 가능하다. 또한 백그라운드 작업은 시스템이 idle 상태일 때도 수행될 수 있어 하드웨어를 더 효과적으로 사용할 수 있다.

 

 

 


 

 

출처 : pages.cs.wisc.edu/~remzi/OSTEP/ 

 

Operating Systems: Three Easy Pieces

Blog: Why Textbooks Should Be Free Quick: Free Book Chapters - Hardcover - Softcover (Lulu) - Softcover (Amazon) - Buy PDF - EU (Lulu) - Buy in India - Buy Stuff - Donate - For Teachers - Homework - Projects - News - Acknowledgements - Other Books Welcome

pages.cs.wisc.edu

 

Intro


CPU 가상화를 위해서, 운영체제는 physical CPU를 여러 작업 간에 공유하여 마치 동시에 실행되는 것처럼 만들어야 한다. 기본 아이디어는 간단하다. 하나의 프로세스를 잠시 실행했다가 다른 프로세스를 실행하는 방식으로 진행한다. 이와 같은 방식으로 CPU 공유하면 가상화가 이루어지며 이를 time sharing of CPU라고 한다.

 

그러나 이러한 가상화 머신을 구축하는데는 가지 과제가 있다.

번째는 성능(performance)이다. 시스템에 과도한 오버헤드를 부가하지 않고 어떻게 가상화를 구현할 수 있을까?

두 번째는 제어(control)다. 운영체제가 CPU에 대한 제어를 유지하면서 프로세스를 효율적으로 실행하는 방법은 무엇이 있을까?

 

 

 

 

Basic Technique: Limited Direct Execution


프로그램을 기대하는 만큼 빠르게 실행하기 위해서 OS 개발자는 Limited Direct Execution을 고안해냈다.

Direct Execution은 프로그램을 CPU에서 직접 실행한다는 것이다. 그러므로, 운영체제가 프로그램을 실행하고자 할 때, Process list에 Process Entry를 생성하고, 메모리를 할당하고, 프로그램 코드를 디스크에서 메모리로 로드하고, 진입점(entry point)을 찾아서 jump하여 사용자의 코드를 실행하기 시작한다. 보다 정확한 direct execution protocol은 아래와 같다.

 

출처 : https://pages.cs.wisc.edu/~remzi/OSTEP/

 

Direct Execution 만으로 해결할 수 없는 문제들이 있다.

 

첫 번째로, 프로그램을 실행하기만 한다면 OS가 어떻게 프로그램을 효율적으로 실행하면서 동시에 원하지 않는 작업을 수행하지 않도록 할 수 있을까?

두 번째로, 프로그램을 실행할 때 OS는 어떻게 프로세스를 중지하고 다른 프로세스로 전환하는 time sharing(CPU 가상화)을 구현할 수 있을까?

 

해당 질문들을 풀어나가는 과정에서, 우리는 CPU 가상화를 위한 필요 사항들을 더 잘 이해할 수 있다.

OS는 이러한 문제들을 해결하기 위해 프로그램 실행(direct execution)을 통제 및 제한(limited)한다.

 

 

 

 

Problem #1 : Restricted Operations


direct execution 은 분명 빠르다는 장점이 있지만 만약 프로그램이 CPU에서 실행 도중에 disk I/O을 요청하거나 CPU나 Memory의 시스템 리소스에 대한 추가적인 액세스를 요청하는 등 제한된 작업(restricted operations)에 대해서 어떻게 처리해야할까?

 

한 가지 접근법은 제한된 작업들(restricted operations)에 대해서 프로세스가 원하는 것을 모두 허용하는 것이다. 물론 이는 바람직하지 못하다. 예를 들어 I/O(입출력)의 경우, 프로세스가 전체 디스크를 읽거나 쓸 수 있다면 모든 보호(protection) 기능이 손실된다.

 

따라서, 일반적으로 취하는 접근법은 user mode(사용자 모드)라고 불리는 새로운 프로세서 모드를 도입하는 것이다. 사용자 모드에서 실행하는 코드는 할 수 있는 것이 제한적이다. 예를 들어, 사용자 모드에서 실행할 때 프로세스는 I/O request를 할 수 없으며 요청 시 프로세스가 exception을 던지고 운영체제는 프로세스를 종료(kill)할 것이다.

 

사용자 모드와 달리, 운영체제가 실행되는 kernel mode(커널 모드)에서 실행하는 코드는 I/O 요청 및 restricted operations을 포함하여 권한이 필요한 모든 작업을 수행할 수 있다.

 

여전히 문제가 남아있다. 만약 유저 프로세스가 디스크 입출력과 같은 권한이 필요한 작업을 수행하고자 한다면 어떻게 할까? 이를 위해 현대의 거의 모든 최신 하드웨어는 유저 프로그램이 시스템 호출(system call)을 수행할 수 있는 기능을 제공한다. 시스템 호출은 커널이 파일 시스템(file system)에 액세스하고, 프로세스를 생성 및 파괴하고, 다른 프로세스와 통신하고, 더 많은 메모리를 할당하는 것과 같은 특정한 핵심 기능들을 사용자 프로그램(user program)에 노출한다.

 

system call을 수행하기 위해 프로그램은 special trap instruction을 실행한다. 해당 명령과 동시에 커널로 jump하고 privilege level(권한 수준)을 kernel mode로 올린다. system call의 목적이었던 권한이 필요한 작업을 수행하고 완료되면 OS에서 return-from-trap 명령을 호출하며 호출된 사용자 프로그램을 다시 user mode로 privvilege level을 낮춘다. 

 

trap 명령을 실행할 때,  확실하게 호출자의 레지스터들을 저장해야 한다. OS가 return-from-trap 명령을 실행 후 호출자의 레지스터들(caller's registers)이 올바르게 return(반환)되게 하기 위해서다. 예를 들어 x86 프로세서는 Program Counter, flags, 그리고 다른 레지스터들을 프로세스당 커널 스택에 push한다. return-from-trap 명령은 그 스택으로부터 values을 pop하여 사용자 모드 프로그램 실행을 재개한다.

 

운영체제 내부(커널 모드)에서 실행할 코드를 트랩이 알게 하기 위해서 커널은 시스템 부팅 시 트랩 테이블(trap table)을 구성한다. 시스템은 커널 모드로 부팅되므로 필요에 따라 시스템 하드웨어를 자유롭게 구성할 수 있다. OS가 가장 먼저 수행하는 작업 중 하나가 특정 예외 이벤트 발생 시 실행할 코드를 하드웨어에 알려주는 것이다. 운영체제는 이러한 트랩 핸들러(특수 명령)의 위치를 하드웨어에 알려준다. 하드웨어에 정보가 입력되면 이러한 핸들러의 위치는 다음 재부팅 전까지 기억하므로 시스템 호출 및 기타 예외 이벤트 발생 시 무엇을 해야 하는 지 알고 있다.

 

시스템 호출(system call)을 특정하기 위해 각 시스템 호출마다 번호를 할당한다. OS는 트랩 핸들러 내부에서 시스템 호출을 처리할 때 이 번호를 검사하고 유효한지 확인하고, 만약 그렇다면 해당 코드를 실행한다. 이러한 indirection(트랩 핸들러를 거쳐서 간접 실행)은 일종의 보호(protection) 기능을 한다. 사용자 코드(user code)는 jump할 exact address를 specify할 수 없다. 대신에 번호를 통해 특정 서비스를 요청해야 한다. 하드웨어에 trap table의 위치를 지정하는 명령의 실행도 강력한 기능이자 특권화된 작업이므로 사용자 모드에서 허용하지 않는다.

 

요약해보면 LDE(Limited Direct Execution) Protocol의 두 단계는 아래와 같다.

 

1 단계 - (부팅 시) 커널은 트랩 테이블을 초기화하며 CPU는 이후 사용하기 위해 위치를 기억한다. 이러한 과정은 커널의 privileged instruction을 통해 이루어진다.

 

2 단계 - (프로세스 실행 시) 커널은 프로세스를 실행하기 전 몇 가지(이전 내용 참고) 사항을 설정하고, CPU를 사용자 모드로 전환하여 프로세스를 실행하기 시작한다. 프로세스가 시스템 호출을 하면 운영체제는 trap에 걸려들어 이를 처리하고 return-from-trap을 통해 프로세스 제어 권환을 반환한다. 그 후 프로세스가 작업을 완료하면 main()에서 return(반환)된다. 이를 통해 프로그램이 정상적으로 종료(exit)되고운영체제가 정리한 후(clean up) 최종적으로 완료된다.

 

 

 

Problem #2: Switching Between Processes


Direct Execution에 있어서 다음 문제는 어떻게 OS가 CPU에 대한 제어권을 가지고 switch between processes를 수행할 수 있는 가이다. Switching Process는 겉보기에는 그저 OS가 실행 중인 프로세스를 멈추고 다음 프로세스를 실행하면 되는 간단한 문제처럼 보이지만 실제로는 그렇게 단순하지 않다. 왜냐하면, 프로세스를 수행하는 주체는 CPU이지 OS가 아니기 때문이다. 따라서 OS는 switch between process(프로세스 간 전환)을 수행하기 위해 CPU에 대한 제어권을 얻어야(regain control) 한다.

 

A Cooperative Approach: Wait For System Calls

과거에 일부 시스템에서 취했던 한 가지 접근 방식은 협력 접근 방식이다. 이와 같은 방식에서 운영체제는 시스템의 프로세스들이 합리적으로 동작할 것이라고 신뢰한다. 너무 오래 실행되는 프로세스는 주기적으로 CPU를 포기하며 OS가 다른 작업을 수행할 수 있도록 한다고 가정한다. 따라서 프로세스는 종종 시스템 호출을 통해서 CPU의 제어 권한을 OS에 전송한다. 예를 들어, 파일을 열고 읽거나, 다른 컴퓨터에 메시지를 보내거나 새로운 프로세스를 만들 때, 허용되지 않는 불법적인 작업을 할 때 OS가 CPU에 대한 제어 권한을 얻게 되며 이러한 사안에 관해 처리할 수 있게 된다.

 

따라서, 협력적 스케줄링 시스템에선 OS가 시스템 호출이나  불법적인 작동(illegal operation) 발생 시 CPU의 제어권을 얻는 수동적인 방식이다. 만약, 프로세스에 문제가 생겨 무한 루프(infinite loop) 상태에 빠지거나, 시스템 호출을 하지 않는다면 운영체제는 할 수 있는 것이 없다는 문제가 발생한다.

 

 

A Non-Cooperative Approach: The OS Takes Control

위와 같은 방식은 프로세스가 시스템 호출을 하지 않고 CPU에 대한 제어권을 운영체제에 전달하기를 거부할 때 추가적인 하드웨어의 도움 없이는 운영체제가 할 수 있는 것이 별로 없다. 프로세스가 무한 루프에 빠졌을 때 우리가 할 수 있는 유일한 방법은 재부팅이다. 따라서, 협력적 접근 방식은 우리가 해결하고자 하는 문제를 명확히 해결하긴 힘들다.

 

이를 위한 다른 해답은 timer interrupt 이다. 타이머 장치는 milliseconds 단위로 interrupt를 발생시킨다. 인터럽트가 발생하면 현재 실행 중인 프로세스가 중지되고 OS에서 미리 구성한 인터럽트 핸들러가 실행된다. 해당 시점에서 OS는 CPU를 제어할 수 있고 현재 프로세스를 중지하고 다른 프로세스를 실행할 수 있다.

 

앞서 시스템 호출에서 논의했듯이, OS는 타이머 인터럽트가 발생할 때 실행할 코드를 하드웨어에 미리 알려주어야 한다. 따라서 부팅시 OS는 하드웨어에 알려준다. 다음으로 부팅 작업 중에도 OS는 해당 타이머 장치를 실행하며 타이머가 시작되면 OS는 제어에 대한 안전성을 확보하며 자유롭게 사용자 프로그램을 실행할 수 있다. 타이머를 끌 수도 있으며 이는 동시성(concurrency)에 대한 이해와 함께 추후에 자세히 논의한다. 하드웨어는 시스템 호출 트랩 시 동작과 유사하게 인터럽트가 발생했을 때도 실행 중인 프로그램의 상태(state)를 충분히 저장하고 return-from-trap 명령이 실행될 때 실행중이었던 프로그램을 올바르게 재개할 수 있도록 해야하는 책임이 있다.

 

 

 

Saving and Restoring Context


이제 OS가 시스템 호출 또는 타이머 인터럽트를 통해서 제어권을 되찾았으므로 프로세스를 계속 실행할 지 아니면 다르 프로세스로 전환할지 결정해야 한다. 해당 결정은 OS의 일부분인 스케줄러에 의해 내려진다. 이에 대한 자세한 내용은 추후에 논의한다.

 

전환하도록 결정이 내려지면 OS는 Context Switch라고 하는 low-level code를 실행한다. Context switch 시  OS는 현재 실행중인 프로세스의 몇몇 레지스터 값들을 저장하고 전환할 프로세스의 몇몇 레지스터 값들을 복원한다. 따라서 OS는 실행중이던 프로세스로 돌아가는 대신 return-from-trap 명령이 실행될 때 시스템이 다른 프로세스를 실행하도록 보장한다.

 

현재 실행중인 프로세스의 컨텍스트(context)를 저장하기 위해서 OS는 현재 실행 중인 프로세스의 범용 레지스터, PC 및 커널 스택 포인터를 저장한 다음 실행할 프로세스의 레지스터, PC를 복원하고 실행할 프로세스의 커널 스택으로 전환한다. 스택을 전환함으로써 커널은 인터럽트된 실행중이었던 프로세스의 컨텍스트에서 switch 코드에 대한 호출을 입력하고 곧 실행 될 프로세스의 컨텍스트에서 반환한다. 그런 다음 OS가 최종적으로 Return-from-trap 명령을 실행할 때, 곧 실행될 프로세스가 현재 실행 중인 프로세스가 되며 컨텍스트 전환이 완료된다.

 

이 프로토콜 중 발생하는 레지스터 저장/복원에는 두 가지 유형이 있다.

 

첫 번째는 타이머 인터럽트가 발생할 때이다. 이 경우엔 실행중인 프로세스의 사용자 레지스터는 해당 프로세스의 커널 스택을 사용하여, 하드웨어에 의해 암시적으로(implicitly) 저장된다.

 

두 번째는 OS가 A에서 B로 Context Switch 할 때이다. 이 경우 커널 레지스터들은 소프트웨어(OS)에 의해 명시적으로(explicitly) 저장되는데, 이번에는 해당 프로세스의 프로세스 구조에서 메모리에 저장된다. 해당 동작은 시스템 커널이 A를 트랩한 상태에서 B를 트랩한 상태로 이동시킨다.

 

 

 

Worried About Concurrency?


그렇다면, 이런 의문이 생길 수도 있다. 

만약 시스템 호출중에 타이머 인터럽트가 발생한다면 어떻게 되는가?

하나의 인터럽트를 처리 중일 때 또 다른 인터럽트가 발생하면 어떻게 되는가?

 

실제로, OS는 인터럽트나 트랩 핸들링 도중에 또 다른 인터럽트가 발생할 때를 주의 깊게 고려할 필요가 있다. 해당 이슈에 대한 논의는 추후에 Concurrency 파트에서 하도록 한다.

 

 

 


 

 

출처 : pages.cs.wisc.edu/~remzi/OSTEP/ 

 

Operating Systems: Three Easy Pieces

Blog: Why Textbooks Should Be Free Quick: Free Book Chapters - Hardcover - Softcover (Lulu) - Softcover (Amazon) - Buy PDF - EU (Lulu) - Buy in India - Buy Stuff - Donate - For Teachers - Homework - Projects - News - Acknowledgements - Other Books Welcome

pages.cs.wisc.edu

 

Requirement Engineering(요구공학)이란?


Requirements engineering(요구공학) 고객이 시스템에 요구하는 서비스가 무엇인지 확립하고 시스템을 개발하고 운영하는 동안 충족해야하는 제약사항(constraint) 무엇인가를 정립하는 과정이다.

 

실질적인 개발 과정에서 가장 먼저 진행되는 작업이다. 이 작업의 결과를 바탕으로 이후의 설계(design), 구현(implementation), 테스트(test)가 진행되기 때문에 굉장히 중요한 단계라고 볼 수 있다.

 

요구사항(requirements)으로서 정립되는 것에는 개발하고자 하는 시스템이 제공하는 기능, 서비스, 시스템이 반드시 충족해야하는 제약사항(constraint) 등이 있다. 이러한 요구사항을 도출, 정리, 분석, 검증해나가는 일련의 과정을 Requirement Engineering이라고 한다.

 

시스템에 대한 요구사항을 완전하게(complete), 그리고 요구사항 간의 충돌이 없도록 일관성 있게(consistent) 정립하는 것을 추구한다.

 

 

 

 

 

User and System Requirements


User requirements

시스템이 제공하는 서비스와 운영에 있어서의 제약사항들을 우리가 쓰는 언어인 자연어(natural language)와 추가적으로 이해를 높이기 위한 그림이나 표와 같은 diagram을 통해 보완하여 서술한다. 기술적 배경지식이 없어도 이해하기 쉽도록 하며, 주로 고객을 위해 쓰여진다. 

 

 

 

System requirements

User requirements보다 더 상세하며, 구조화된 기술적인 정보를 포함하는 문서이다. 무엇이 구현되어야 하는지 정의한다. 주로 클라이언트(client)와 계약자(contractor) 사이의 계약 또는 개발자를 위해서 쓰여진다. 

 

 

 

 

 

Functional, non-functional and domain requirements


functional requirements

시스템이 제공해야하는 서비스에 대한 기능, 시스템이 특정 입력값(input)에 대해서 어떻게 반응해야 하는지, 특정 상황에서 어떻게 동작해야 하는지 기능적인 요구사항을 기술한다. 이에 추가하여 시스템이 하지 말하야 하는 것에 대해서도 기술할 수 있다.

 

non-functional requirements

시스템에 의해 제공되어지는 서비스 및 기능들에 대한 각종 제약사항들(constraints)과 시스템 전체로서의 특성 및 안정성(safety), 성능(performance)에 대한 System Properties(+ emergent property) 대해 기술한다.

process requirements(개발 환경 - IDE, Programming Language, Framework, Development Method)도 이에 포함된다.

전반적인 시스템 구조에 치명적인 영향을 줄 수 있으므로 중요하다. 또한, 하나의 non-functional requirements로부터 여러 개의 functional requirements가 파생될 수 있다. (ex. security -> 각종 보안을 위한 functional requirements)

 

domain requirements

시스템의 운영 환경에서 사용되는 도메인의 여러 가지 속성들, 주제, 방법, 절차들에 대해 기술한 것으로, 개발자가 해당 시스템의 도메인에 대해 학습하고 내재되어있는 해당 requirements를 도출하도록 노력해야 한다. 전문성이 요구된다.

 

 

 

 

Requirement Imprecision


요구사항에 대한 유저의 이해와 개발자의 해석은 다를 수 있다.

고객은 보다 크게 생각하고, 개발자는 이를 작게 나누어 생각한다는 특성이 있다.

이러한 부분을 명확히 하지 않으면 갈등의 소지가 존재하게 된다.

 

 

 

 

Software Requirement Document


소프트웨어 개발자를 위해서 시스템이 무엇을 해야하는지 기술한 공식 문서이다.

User Requirements의 정의와 System Requirements의 명세를 모두 포함해야 한다.

Design Document가 아니기 때문에 How(How System should do)가 아닌 What(What System should do)에 초점을 둔다.

 

 

 

 

Agile Methods and requirements


많은 애자일 방법론에서 요구 문서(requirements document)를 작성하는 것은 시간 낭비라고 여기기도 한다. requirements는 끊임없이 변화하기 때문이다. 따라서 XP(Extreme Programming)와 같은 메소드에선 incremental requirement engineering 또는 requirements를 user stories로써 기술하기도 한다.

 

이러한 관점은 실질적인 비즈니스 시스템에서 충분히 제기될 수 있는 관점이지만, 여러 팀에서 함께 개발하는 대규모 시스템 또는 충분한 사전 분석이 필요한 핵심적인 시스템을 개발할 때는 문제가 될 수 있다.

프로세스 API란?


운영체제가 프로세스의 생성 및 제어를 위해서 제공하는 API다.

 

 

 

 

 

fork() System Call


fork() System Call(시스템 호출)은 새로운 프로세스를 생성하기 위해 사용된다.

동일 프로그램에서 부모 프로세스로부터 자식 프로세스가 생성된다.

 

 

 

 

 

wait() System Call


wait() System Call은 부모 프로세스가 자식 프로세스의 종료까지 기다리도록 하기 위해 사용된다.

 

 

 

 

 

 

exec() System Call


이 시스템 호출은 호출자 프로그램(the calling program)으로부터 다른 프로그램을 실행할 때 사용된다.

fork()의 경우는 동일 프로그램의 복제 프로세스를 생성하는데 반해, 다른 프로그램을 실행하고자 한다면 exec()를 사용할 수 있다. 

 

exec()(+ execvp())를 실행할 경우, executable(실행 가능한 파일)의 이름과 몇몇 arguments(인자)를 고려하여 코드 및 정적 데이터를 로드하고, 현재 코드 세그먼트에 덮어쓴다. 또한 힙과 스택 및 프로그램의 메모리 공간의 다른 부분들은 다시 초기화된다(re-initialized). 그런다음 운영체제는 해당 프로그램을 실행하면 해당 프로세스의 argv로 모든 인수를 전달한다.

 

따라서, 새로운 프로세스를 생성한다기보다는 현재 실행중인 프로그램을 해당 프로그램으로 변환한다고 볼 수 있다. exec()가 성공적으로 실행된다면, 이전 프로그램은 거의 실행되지 않는다고 볼 수 있으며, 성공적인 exec() 호출은 반환되지 않는다. (a successful call to exec() never returns)

 

 

 

 

 

Unix Shell 과 Process API


프로세스를 생성하는 단순한 메커니즘과 다르게 이렇게 이상한 exec()와 fork()와 같은 인터페이스를 만든 것일까?

 

왜냐하면, 이와 같은 fork()와 exec()의 분리가 Unix 쉘을 만드는 데 필수적이기 때문이다.

쉘에 적용하였을 때, fork()는 exec()와는 다르게 호출 후에 계속 코드를 실행하는 것이 가능하다. 그리고 이를 통해서 실행되는 프로그램의 환경을 변경하는 것이 가능하다.

 

쉘은 사용자 프로그램(user program)에 불과하며, 사용자에게 프롬프트를 출력하고 다음 입력을 기다린다. 사용자가 명령을 입력하면, 쉘은 실행 파일이 있는 파일 시스템의 위치를 파악하고, fork()를 호출하여 커맨드(명령)를 실행하기 위한 새로운 프로세스를 생성한 뒤에, 일부 변형된 exec()를 호출하여 커맨드를 실행한다. 그리고 wait()를 호출하여 명령이 완료되기까지 기다린다. 자식 프로세스가 완료되면, 쉘은 wait()로부터 return(반환)되고, 프롬프트에 다시 출력한 뒤, 유저의 다음 명령(커맨드)를 기다린다.

 

이처럼, fork()와 exec()의 분리는 쉘이 유용한 여러 가지 작업을 쉽게 수행할 수 있도록 한다.

 

 

 

 

 

Process Control and Users


fork(), exec(), wait() 외에도 유닉스 시스템의 프로세스와 상호 작용하기 위한 많은 인터페이스들이 있다. 예를 들어, kill() 호출은 프로세스에 pause, die, 그리고 기타 유용한 명령들에 대한 신호를 보내는데 사용된다. 대부분의 UNIX Shell에서 특정 키 스트로크 조합은 현재 실행 중인 프로세스에 특정 신호를 전달하기 위해서 쓰인다. 예를 들어 control+c는 SIGINT(인터럽트)를 프로세스(일반적으로 종료)로 보내고 control+z는 SIGTSTP(중지) 신호를 전송하여 프로세스의 실행 중간에 일시 중지할 수 있다.(나중에 재개할 수 있다.)

 

The entire signals subsystem(전체 시그널 하위 시스템)은 개별 프로세스 내에서 신호를 수신 및 처리하는 방법, 개별 프로세스 및 전체 프로세스 그룹에 신호를 보내는 방법 등 외부 이벤트를 프로세스에 전달하는 풍부한 인프라를 제공한다. 이러한 형태의 통신을 사용하려면, 프로세스가 signal() 시스템 호출을 사용하여 다양한 신호를 캐치(catch)해야 한다. 이렇게 하면 특정 신호가 프로세스에 전달되었을 때 해당 프로세스가 정상적인 실행을 미루고(suspend) 신호에 응답하여 특정 코드들을 실행하도록 할 수 있다.

 

그렇다면, 누가 프로세스에 신호를 보낼 수 있고, 누가 신호를 보내지 못하도록 해야할까? 일반적으로, 우리가 사용하는 시스템은 동시에 여러 사용자가 사용할 수 있다. 만약 아무나 SIGINT와 같은 신호를 임의로 전송할 수 있다면 시스템의 사용성(usability)과 보안성(security)이 손상될 것이다.

 

따라서, 현대 시스템은 유저에 대한 강력한 개념을 포함한다. 사용자는 자격 증명을 설정하기 위해서 암호를 입력한 후 로그인하여 시스템 리소스에 액세스할 수 있다. 그런 다음에 사용자는 하나 이상의 프로세스를 실행하고 프로세스를 제어할 수 있다.

 

 

 


 

 

출처 : pages.cs.wisc.edu/~remzi/OSTEP/

 

Operating Systems: Three Easy Pieces

Blog: Why Textbooks Should Be Free Quick: Free Book Chapters - Hardcover - Softcover (Lulu) - Softcover (Amazon) - Buy PDF - EU (Lulu) - Buy in India - Buy Stuff - Donate - For Teachers - Homework - Projects - News - Acknowledgements - Other Books Welcome

pages.cs.wisc.edu

 

아이템 1. 생성자 대신 정적 팩터리 메서드를 고려하기


클라이언트가 클래스의 인스턴스를 얻는 전통적인 수단은 public 생성자(constructor)다.

 

이에 추가하여, 클래스는 정적 팩터리 메서드(static factory method)를 생성자와 별도로 제공할 수 있다.

정적 팩터리 메서드는 해당 클래스의 인스턴스를 반환하는 단순한 정적 메서드다.

 

따라서, 클래스는 클라이언트에 public 생성자와 정적 팩터리 메소드 둘 다 제공할 수 있고, 각각 장단점이 있다.

 

 

정적 팩터리 메소드의 장점

1. 이름을 가질 수 있으므로 반환될 객체의 특성을 묘사할 수 있다.

따라서, 한 클래스에 시그니처가 같은 생성자가 여러 개가 필요하다면, 생성자를 정적 팩터리 메서드로 바꾸고 각각의 차이를 잘 드러내는 이름을 지으면 된다.

 

2. 호출될 때마다 인스턴스를 새로 생성하지는 않아도 된다. 따라서, 불변 클래스는 인스턴스를 미리 만들어 놓거나 새로 생성한 인스턴스를 캐싱하여 재활용하여 불필요한 객체 생성을 피할 수 있다. 생성 비용이 큰 같은 객체가 자주 요청되는 상황이라면 성능을 상당히 끌어올릴 수 있다. 정적 팩터리 방식의 클래스는 언제 어느 인스턴스를 살아 있게 할지를 철저히 통제할 수 있는데 이러한 클래스를 인스턴스 통제 클래스라고 한다.

 

3. 반환 타입의 하위 타입 객체를 반환할 수 있다. 즉, 반환할 객체의 클래스를 자유롭게 선택할 수 있는 유연성을 가질 수 있다. API를 만들 때 이를 응용하면 구현 클래스를 공개하지 않고 그 객체를 반환할 수 있어 API를 작게 유지할 수 있다. 이는 인터페이스를 정적 팩터리 메서드의 반환 타입으로 사용하는 인터페이스 기반 프레임워크의 핵심 기술이기도 하다.

 

4. 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.

 

5. 정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다. 이런 유연함은 서비스 제공자 프레임워크를 만드는 근간이 된다.

 

 

정적 팩터리 메소드의 단점

1. 상속을 하려면 public이나 protected 생성자가 필요하니 정적 팩터리 메서드만 제공하면 하위 클래스를 만들 수 없다. 이는 상속보다 컴포지션을 사용하도록 유도하고 불변 타입으로 만들 때 제약을 지켜야 한다는 점에서 오히려 장점이 될 수도 있다.

 

2. 정적 팩터리 메서드는 프로그래머가 찾기 어렵다. 생성자처럼 API 설명에 드러나지 않기 때문에 사용자는 정적 팩터리 메서드 방식 클래스를 인스턴스화할 방법을 알아내야 한다. 또한, API 문서를 잘 써놓고 메서드 이름을 널리 알려진 규약을 따라 짓는 식으로 문제를 완화해줘야 한다.

 

 

 

 

아이템 2. 생성자에 매개변수가 많다면 빌더를 고려하라


정적 팩터리와 생성자 모두는 선택적 매개변수가 많을 때 적절히 대응하기가 어렵다.

 

이렇게, 매개변수 개수가 많아지면 첫 번째 대안으로 점층적 생성자 패턴도 가능하긴 하지만, 클라이언트 코드를 작성하거나 읽기 어렵다. 클라이언트가 실수로 매개변수의 순서를 바꾸어도 컴파일러는 알아채지 못하고, 런타임에 엉뚱한 동작을 하게 된다.

 

선택 매개변수가 많을 때 활용할 수 있는 두 번째 대안으로 자바빈즈 패턴이 있다. 매개변수가 없는 생성자로 객체를 만들고, 세터 메서드를 호출해 원하는 매개변수의 값을 설정하는 방식이다. 이 또한 심각한 단점을 가지고 있다. 자바빈즈 패턴에서는 객체 하나를 만들려면 메서드를 여러개 호출해야 하고, 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태에 놓이게 된다. 일관성이 깨진 객체가 만들어지면, 버그를 심은 코드와 그 버그 때문에 런타임에 문제를 겪는 코드가 물리적으로 떨어져 있어 디버깅이 어렵다. 일관성이 무너지기 때문에 자바빈즈 패턴에서는 클래스를 불변으로 만들 수 없다.

 

세 번째 대안이 점층적 생성자 패턴의 안전성과 자바빈즈 패턴의 가독성을 겸비한 빌더 패턴이다. 클라이언트는 객체를 직접 만드는 대신, 필수 매개변수만으로 생성자를 호출해 빌더 객체를 얻는다. 그 다음 빌더 객체가 제공하는 일종의 세터 메서들들로 원하는 선택 매개변수들을 설정한다. 마지막으로 매개변수가 없는 build메서드를 호출해 우리에게 필요한 객체를 얻는다. 

 

따라서, 생성자나 정적 팩터리가 처리해야 할 매개변수가 많다면 빌더 패턴을 선택하는 게 더 낫다. 매개변수 중 다수가 필수가 아니거나 같은 타입이면 특히 더 그런데, 빌더는 점층적 생성자보다 클라이언트 코드를 읽고 쓰기가 훨씬 간결하고, 자바빈즈보다 훨씬 안전하다.

 

 

아이템 3. private 생성자나 열거 타입으로 싱글턴임을 보증하라


싱글턴(singleton)이란 인스턴스를 오직 하나만 생성할 수 있는 클래스를 말한다. 싱글턴의 대표적인 예로 함수와 같은 무상태 객체나 설계상 유일해야 하는 시스템 컴포넌트를 들 수 있다.

 

싱글턴을 만드는 방식은 보통 둘 중 하나다. 두 방식 모두 생성자는 private으로 감춰두고, 유일한 인스턴스에 접근할 수 있는 수단으로 public static 멤버를 하나 마련해둔다.

 

세 가지 방법을 소개한다.

첫 번째는 public static 멤버가 final 필드인 방식, 두 번째는 정적 팩터리 메서드를 public static 멤버로 제공하는 방식, 세 번째는 원소가 하나인 열거 타입을 선언하는 것이다.

 

대부분 상황에서는 원소가 하나뿐인 열거 타입이 싱글턴을 만드는 가장 좋은 방법이다. 단, 만들려는 싱글턴이 Enum 외의 클래스를 상속해야 한다면 이 방법은 사용할 수 없다. (열거 타입이 다른 인터페이스를 구현하도록 선언할 수는 있다.)

 

 

아이템 4. 인스턴스화를 막으려거든 private 생성자를 사용하라


정적 메서드와 정적 필드만을 담은 클래스를 만들고 싶을 때 (ex. java.lang.Math, java.utio.Arrays),

특정 인터페이스를 구현하는 객체를 생성해주는 정적 메서드를 모아놓을 때 (ex. java.util.Collections),

final 클래스와 관련한 메서드들을 모아놓을 때, 즉 final 클래스를 상속해서 하위 클래스에 메서드를 넣는 것을 막을 때,

 

private 생성자를 추가하면 클래스의 인스턴스화를 막을 수 있다. 추가로, 상속을 불가능하게 하는 효과도 있다.

 

컴파일러가 기본 생성자를 만드는 경우는 오직 명시된 생성자가 없을 때뿐이니, 명시적 생성자가 private이라면 클래스 바깥에서는 접근할 수 없게 된다. private 생성자에 'throw new AssertionError();' 코드를 추가한다면 Assertion Error를 던져 클래스 안에서 실수로라도 생성자를 호출하지 않도록 할 수 있다.

 

 

 

아이템 5. 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라


많은 클래스가 하나 이상의 자원에 의존한다.

 

이 때, 정적 유틸리티 클래스로 구현하거나, 싱글턴을 잘못 사용한다면, 유연하지 않고 테스트하기 어렵다.

사용하는 자원에 따라 동작이 달라지는 클래스에는 정적 유틸리티 클래스나 싱글턴 방식이 적합하지 않다.

 

대신, 클래스가 여러 자원 인스턴스를 지원해야 하며, 클라이언트가 원하는 자원을 사용해야 하는 조건을 만족하는 패턴이 존재한다.

이는 의존 객체 주입의 한 형태로, 인스턴스를 생성할 때 생성자에 필요한 자원을 넘겨주는 방식이다.

 

의존 객체 주입은 자원이 몇 개든 의존 관계가 어떻든 상관없이 잘 작동하며, 불변을 보장하여 여러 클라이언트가 의존 객체들을 안시하고 공유할 수 있게 한다. 또한, 생성자, 정적 팩터리, 빌더 모두에 똑같이 응용할 수 있다.

 

이 패턴의 쓸만한 변형으로, 생성자에 자원 팩터리를 넘겨주는 방식이 있다. 팩터리란 호출할 때마다 특정 타입의 인스턴스를 반복해서 만들어주는 객체이다. 즉, 팩터리 메서드 패턴을 구현한 것인데, 대표적으로 Supplier<T> 인터페이스가 팩터리를 표현한 예시이다.

 

의존 객체 주입이 유연성과 테스트 용이성을 개선해주긴 하지만, 의존성이 수천 개가 되는 큰 프로젝트에서는 코드를 어지럽게 만들기도 한다. 이는 스프링(Spring) 같은 의존 객체 주입 프레임워크를 사용하면 이러한 어질러짐을 해소할 수 있다.

 

 

 

아이템 6. 불필요한 객체 생성을 피하라


똑같은 기능의 객체를 매번 생성하기보다는 객체 하나를 재사용하는 편이 나을 때가 많다.

 

생성자 대신 정적 팩터리 메서드를 제공하는 불변 클래스에서는 정적 팩터리 메서드를 사용해 불필요한 객체 생성을 피할 수 있다.

생성 비용이 비싼 객체가 반복해서 필요할 때는 캐싱하여 재사용하길 권장한다. 성능 개선을 위해서 불변인 인스턴스를 클래스 초기화(정적 초기화) 과정에서 직접 생성해 캐싱해두고, 나중에 이 인스턴스를 재사용한다.

 

하지만, 불필요한 객체를 만들어내는 오토박싱을 피하자. 오토박싱은 프로그래머가 기본 타입과 박싱된 기본 타입을 섞어 쓸 때 자동으로 상호 변환해주는 기술인데, 성능 부분에서 큰 저하가 발생할 수 있다.

 

 

 

아이템 7. 다 쓴 객체 참조를 해제하라


프로그램에서 특정 객체들을 더이상 사용하지 않더라도 스택이 그 객체들의 다 쓴 참조를 여전히 가지고 있다면 가비지 컬렉터가 회수하지 않는다. 따라서 메모리 누수가 발생한다. 가비지 컬렉션 언어에서는 의도치 않게 객체를 살려두어 발생하는 메모리 누수를 찾기가 까다롭다. 객체 참조 하나를 살려두면 가비지 컬렉터는 그 객체뿐 아니라 객체가 참조하는 모든 객체와 그 객체들이 참조하는 모든 객체까지도 회수해가지 못한다. 그래서 단 몇 개의 객체가 매우 많은 객체를 회수되지 못하게 할 수 있고 잠재적으로 성능에 악영향을 줄 수 있다.

 

특히, 자기 메모리를 직접 관리하는 클래스(ex. stack)라면 프로그래머는 항시 메모리 누수에 주의해야 한다. 원소를 다 사용한 즉시 그 원소가 참조한 객체들을 다 null 처리해줘야 한다.

 

캐시, 리스너 혹은 콜백 또한 메모리 누수의 주범인데, 이는 WeakHashMap을 사용하여 캐시를 만들거나, 콜백을 약한 참조로 저장하면 가비지 컬렉트가 적절히 수거해갈 수 있다.

 

 

 

아이템 8. finalizer와 cleaner 사용을 피하라


자바에서 제공하는 두 객체 소멸자 finalizer와 cleaner는 사용을 지양하자.

파일이나 스레드 등 종료해야 할 자원을 담고 있는 객체의 클래스에서 finalizer나 cleaner를 사용하는 대신에, AutoCloseable을 구현하고, 클라이언트에서 인스턴스를 다 쓰고 나면 close 메서드를 호출하면 된다.

 

 

 

 

아이템 9. try-finally 보다는 try-with-resources를 사용하라


자바 라이브러리에는 InputStream, OutputStream, java.sql.Connection 등과 같이 close 메서드를 호출해 직접 닫아줘야 하는 자원이 많다. 자원 닫기는 클라이언트가 놓치기 쉬워서 예측할 수 없는 성능 문제로 이어지기도 하므로, 자원이 제대로 닫힘을 보장하는 수단으로 try-finally가 쓰인다.

 

하지만, try-finally 문을 사용한 방식에는 결점이 존재한다. 따라서, 자바 7에서 도입된 try-with-resources를 사용하자.

이 구조를 사용하려면 해당 자원이 AutoCloseable 인터페이스를 구현해야 한다. 따라서, 닫아야하는 자원을 뜻하는 클래스를 작성한다면 AutoCloseable을 반드시 구현하도록 하자. try-with-resources 버전이 코드가 짧고 읽기 수월하며 만들어지는 예외 정보도 유용하여 문제를 진단하기도 좋고 정확하고 쉽게 자원을 회수할 수 있다.

 

그러므로, 꼭 회수해야 하는 자원을 다룰 때는 모든 케이스에 대해서 try-finally 말고, try-with-resources를 사용하자.

'Java' 카테고리의 다른 글

[Java] static 메모리의 생명주기  (0) 2021.05.03
[Java] 정적 변수와 정적 메소드 (static)  (0) 2021.03.30

프로세스의 정의


 

프로세스는 운영체제(os)가 사용자에게 제공하는 가장 근본적인 추상화 (fundamental abstraction)다.

프로세스의 정의는 단순하다. 실행중인 프로그램(running program)이 프로세스의 정의다.

 

프로그램은 그 자체로는 생명이 없다. 그저 디스크에 상주하는 명령어 묶음이며, 이는 실행이 되기를 기다리고 있다.

운영체제(OS)가 이들을 실행시킬 때 비로소 프로그램은 제 역할을 한다고 볼 수 있다.

 

 

 

CPU 가상화 (virtualizing CPU)


우리가 컴퓨터를 사용할 때, 마치 여러 개의 프로세스들이 동시에 실행되고 있다고 느낀다.

또한, 우린 CPU가 어떠한 프로세스를 실행시키고 있는지 고려할 필요가 없다.

 

하나의 CPU로 여러 개의 프로세스를 동시에 실행시키고 싶지만, 이는 불가능하다.

대신에, CPU time sharing을 통해서 여러 개의 프로세스를 번갈아가며 실행하여 마치 하나의 CPU가 여러 개의 프로세스를 동시에 실행하는 것처럼 보이게 할 수 있다.

 

이와 같이,  운영체제는 단일 physical CPU로 여러개의 virtualizing CPUs를 사용하는 것처럼 환상(illusion)을 만들어내며, 이를 CPU 가상화라고 한다.

 

CPU time sharing은 유저가 여러 개의 프로세스를 동시에 실행할 수 있도록 제공하지만, CPU가 공유되므로 각각의 프로세스는 더 느리게 실행될 것이며, 잠재적인 성능에 대한 trade-off가 있다. 

 

time sharing 메카니즘에서 context-switching이 쓰이며, context-switching은 주어진 CPU에서 하나의 프로그램을 종료시키고 다른 또 하나의 프로그램을 실행시킬 수 있는 운영체제의 ability이다.

 

 

 

프로세스와 Machine State


위에서 말했듯이, 프로세스는 OS가 실행중인 프로그램이다. 그리고 프로그램은 OS가 실행하기 전까진 디스크에 상주하는 instructions(명령어) 묶음이다.

 

그렇다면, 프로세스를 구성하는 것은 무엇일까? 이를 이해하기 위해서, 프로세스의 machine state에 대해 알 필요가 있다.

 

Memory

프로세스를 구성하는 핵심 machine state 구성 요소 중 하나는 메모리(memory)다.

실행하는 프로그램의 명령어들과 읽고 쓰는 데이터들 모두 메모리에 적재되어 있다.

 

 

 

Register

프로세스의 machine state 구성 요소 중 또 다른 하나는 레지스터다.

많은 명령어들이 명시적으로 레지스터를 읽고, 업데이트한다. 이는 프로세스의 실행에 있어 필수적이다.

 

프로세스의 machine state를 구성하는 특수한 레지스터들을 몇 개 소개한다.

 

Program Counter, PC (=instruction pointer, IP)

- 다음에 실행할 프로그램 명령어를 지시한다.

 

Stack Pointer 와 Frame Pointer

- 함수 파라미터(function parameters), local variables(지역 변수)가 담긴 스택을 관리하고 주소(address)를 반환(return)하기 위해 사용된다.

 

 

Persistent Storage Device

Persistent Storage Device는 hard disk drive와 같이 영구적으로 데이터가 저장되는 저장소를 뜻한다.

프로세스는 때때로 persistent storage device에 접근(access)한다. (ex. file open 및 I/O)

 

 

 

 

대표적인 Process API


 

Create : 운영체제가 새로운 프로세스를 생성하는 메소드다. 쉘(shell)에 명령어(command)를 입력하거나, 어플리케이션 아이콘을 더블 클릭할 때, 운영체제는 사용자가 지시한 프로그램을 실행하기 위한 새로운 프로세스를 생성한다.

 

Destroy : 운영체제가 프로세스를 강제적으로 종료하도록 하는 메소드다. 물론 많은 프로세스들이 실행이 완료된 후 스스로 종료되겠지만, 그렇지 않을 경우, 사용자가 프로세스를 kill(강제 종료)하고 싶을 때 사용한다.

 

Wait : 프로세스의 실행이 종료될 때까지 기다린다.

 

Miscellaneous Control : 프로세스를 kill 또는 wait하는 것과는 또 다른 control(제어)가 가능하다. Miscellaneous Control은 프로세스를 연기(suspend)한다. 즉, 잠깐동안 실행을 멈추었다가 다시 실행을 재개한다.

 

 

 

 

Process Creation Detail, 프로세스 생성에 대한 고찰


프로그램들이 프로세스로 변화(transform)하는 과정을 좀 더 살펴보자. 운영체제는 어떻게 프로그램을 가져와 실행하고, 프로세스를 생성할까?

 

첫 번째로, 운영체제는 프로그램 코드와 정적 데이터(static data)를 메모리로 로드한다. 프로그램은 처음엔 실행가능한 형식으로 디스크(disk, 현대에 와서는 flash기반 ssd)에 상주하므로 이러한 과정이 필요하다.

 

초기 운영체제는 프로세스 로딩을 eager하게 수행(프로그램이 실행되기 전에 한 번에 모든 로딩 처리)했지만,

현대의 운영체제는 lazy-loading을 채택하고 있다. 즉, 프로그램을 실행하는 동안, 필요한 코드, 데이터만을 부분적으로 가져온다. 

lazy-loading을 깊게 이해하기 위해서, paging(페이징)과 swapping에 대한 이해가 필요하다. 이 부분은 메모리 가상화에서 다룬다.

지금은, 운영체제가 프로그램을 실행하기 위해 필요한 프로그램 비트들을 디스크로부터 메모리로 가져온다고만 알아두자.

 

두 번째로, 운영체제는 프로그램이 실행될 때 메모리의 일부를 런타임 스택으로 할당하고 프로세스에게 넘겨준다. 예를 들어, C 프로그램에서는 스택을 지역 변수(local variable), 함수 파라미터(function parameters), 주소 반환(return address)을 위해 사용한다. OS는 일반적으로 arguments(ex. main() 함수의 argc, argv)를 담은 스택을 초기화할 것이다.

 

세 번째로, 운영체제는 프로그램이 실행될 때 메모리의 일부를 프로그램의 힙(heap)으로 할당하고 프로세스에게 넘겨준다. C 프로그램에서, 힙은 명시적으로 요청받은 동적 할당 데이터들을 위해 사용된다. 예를 들어 malloc()으로 할당하고, free()로 할당 해제한다. 힙은 연결 리스트(linked list), 해쉬 테이블(hash table), 트리(trees) 등의 자료 구조 사용에 필요하다. 힙은 처음엔 작을지 모르지만, 프로그램이 실행되고, malloc()을 통해 많은 요청이 발생하면 OS는 이러한 요청을 받아들여 더 많은 힙 메모리를 할당할 것이다.

 

이 뿐만 아니라, 운영체제는 다른 초기화 작업들도 수행한다. 특히, I/O(Input/Output, 입출력) 관련 초기화 작업도 포함하는데, 예를 들어, UNIX 시스템에서는 각각의 프로세스는 디폴트하게 3 개의 open 파일 디스크립터(file descriptor)를 가진다. 3개는 각각 표준 입력, 표준 출력, 그리고 오류를 의미한다. 이 디스크립터들은 프로그램이 터미널로부터 쉽게 입력을 읽고(read input), 스크린에 출력을 프린트할 수 있도록 한다(print output).

 

이처럼, 코드와 정적 데이터를 메모리에 로드하고, 스택과 힙을 생성, 초기화 및 할당하고, I/O관련 셋업과 같은 다른 작업들을 수행한 후 OS는 비로소 프로그램 실행을 위한 준비를 거의 마친다. 마지막으로, main() 시작 포인트(entry point)에서 프로그램을 실행시키기 위한 작업을 수행한다. 운영체제는 CPU에 대한 제어권을 새롭게 생성된 프로세스에 넘겨준다. 그리고 프로그램이 실행된다.

 

 

 

Process State (프로세스 상태)


이제 우린 프로세스가 무엇인지, 어떻게 생성되는 지 어느 정도 파악했다. 이제, 프로세스가 주어진 시간 동안 어떠한 상태로 존재할 수 있는지 process states에 대해 알아보자. 단순화했을 때, 프로세스는 아래 세 가지 상태 중 하나로 존재한다.

 

Running : 프로세스가 프로세스에서 실행되고 있는 상태, 즉 명령어가 실행되고 있는 상태를 의미한다.

 

Ready : ready 상태는 프로세스가 실행될 준비가 되었지만, 어떠한 이유에서인지 운영체제가 지금은 해당 프로세스를 실행하지 않는 상태를 의미한다.

 

Blocked : blocked 상태는 프로세스가, 특정 이벤트(event)가 발생하기 전까지는 실행할 수 없는 연산을 수행하고 있는 상태이다. 흔한 예시로, 프로세스가 disk I/O request를 시작했을 때 blocked 상태가 되며, 그 동안 다른 프로세스가 프로세서를 사용할 수 있다. 

 

 

 

 

Operating System의 Data Structure


운영체제도 프로그램이며, 다른 프로그램들과 마찬가지로 다양한 관련 정보를 추적하는 주요 데이터 구조를 가지고 있다.

 

예를 들어, 각각 프로세스의 상태를 추적하기 위해 운영체제는 프로세스 리스트(process list)를 가지고 있다. 프로세스 리스트는 ready상태의 모든 프로세스를 포함하며, 어떠한 프로세스가 실행되고 있는 지를 추적하기 위한 정보들을 포함한다.

운영체제는 또한 blocked 상태의 프로세스 또한 추적해야 한다. 예를 들어, I/O event가 완료되었을 때, 운영체제는 올바른 프로세스를 깨워(wake) 다시 실행하기 위해 ready상태로 전환해야 한다.

 

이처럼, 운영체제는 프로세스를 추적하기 위해 stopped process(중지된 프로세스)의 레지스터 값들을 register context에 저장한다. 프로세스가 중지되면, 중지된 프로세스의 레지스터 값들은 레지스터 컨텍스트에 저장되며, 이 레지스터들을 복구함으로써 운영체제는 다시 프로세스를 재개(resume)할 수 있다. 이와 같은 테크닉은 context switch에서 자세하게 배운다.

 

실제로, process state는 위의 running, ready, blocked가 아닌 다른 상태로도 존재할 수 있다.

 

예를 들어, 프로세스가 생성중일 때는 initial state, 프로세스가 종료(exit)되었지만 회수(cleaned up)되지 않았을 때 final state(unix 시스템에서는 이를 zombie state라고 칭한다)가 있다.

 

final state는, 프로세스를 생성한 부모 프로세스(parent)가 종료된 자식 프로세스의 반환 코드(return code)를 검사하고, 종료된 자식 프로세스가 성공적으로 실행되었는 지 확인할 수 있도록 한다.(일반적으로 작업이 성공적으로 수행되었으면 0을, 그렇지 않으면 0이 아닌 값을 반환한다).

 

프로세스가 종료될 때, 자식 프로세스가 완료되기까지 기다리기 위한 final call(최종 호출, ex. wait())을 하고, 운영체제에게 이제 사라지는 프로세스와 관련된 자료 구조들을 정리(cleaned up)해도 된다고 명시한다.

 

 

 


 

 

출처 : pages.cs.wisc.edu/~remzi/OSTEP/

 

Operating Systems: Three Easy Pieces

Blog: Why Textbooks Should Be Free Quick: Free Book Chapters - Hardcover - Softcover (Lulu) - Softcover (Amazon) - Buy PDF - EU (Lulu) - Buy in India - Buy Stuff - Donate - For Teachers - Homework - Projects - News - Acknowledgements - Other Books Welcome

pages.cs.wisc.edu

 

static


static은 "정적인" , "고정적인" 이라는 뜻을 가지고 있다.

변수와 메소드의 이름 앞에 static을 붙이면 그것은 정적 변수 또는 정적 메소드가 된다.

 

이렇게 만들어진 정적 변수와 정적 메소드는 객체의 인스턴스에 소속되는 것이 아닌, 클래스에 고정되었다고 할 수 있다.

따라서, 클래스 로더가 클래스를 로딩하여 메모리 영역에 적재할때 클래스별로 관리된다. 그러므로 클래스의 로딩이 끝난 후부터 바로 사용이 가능하다.

 

static 멤버(static 변수 + static 메소드)는 static 메모리에서 별도로 관리되며, 이 메모리는 모든 객체가 공유한다.

이는 객체들이 주로 할당하며 GC(Garbage Collector)가 관리해주는 Heap 메모리와는 다르게, 클래스들이 할당하며, GC(Garbage Collector)가 관리하지 않는다는 특징이 있다.

 

그렇기 때문에, static을 불필요하게 많이 사용할 경우 해당 프로그램이 종료될 때 까지 static 메모리가 그대로 유지되므로 시스템의 성능에 악영향을 줄 수 있으므로  이 부분에 대해서 주의해야 한다. 

 

 

 

 

메모리 측면에서의 이점


static을 붙이게 되면 해당 변수 또는 메소드에 대한 메모리 할당을 한 번만 하게되며, 이는 메모리 사용에 있어서 이점을 가져온다.

 

 

공유 측면에서의 이점


static을 붙이게 되면 해당 변수 또는 메소드는 하나의 주소에 위치하게 되며, 이를 전체에서 공유하게 된다.

+ Recent posts