Writings/Operating System2017. 10. 18. 15:31

클락업을 통한 싱글 코어의 발전이 power wall에 가로막힌 이후, CPU의 발전 방향은 코어의 갯수를 늘리는 쪽으로 선회하였다. 그에 따라 멀티코어, 매니코어에 이르는 다양한 변종들이 등장하게 되었으며, 이로 인해 애플리케이션 및 운영체제의 확장성(scalability)이 중요한 문제로 대두되게 되었다. 


확장성이란 사전적인 의미로 보았을 때 늘어난 수요에 맞춰 얼마나 유연하게 대응할 수 있는지를 나타내는 척도라고 할 수 있다. 애플리케이션을 뒷 받침 하는 시스템의 입장에서 확장성이란 단어를 협의적으로 해석하면 늘어난 자원에 맞추어(e.g., 코어 수) 성능이 얼마나 향상되는 지를 나타내는 척도라고 할 수 있다. 자원이 늘어남에 따라서 성능이 비례하여 향상되면 확장성이 좋다 할 수 있고, 성능이 그대로이거나 혹은 더 떨어지게 된다면 확장성이 나쁘다라고 할 수 있다.


이 포스팅에서는 운영체제를 주로 다룰 것이므로 운영체제와 관련된 문제만을 살펴보도록 할 것이다. 확장성을 저해하는 요소는 운영체제 내에 다양하게 산재해 있지만, 그 중에서도 주된 요인을 꼽아보자면 단연 동기화(synchronization) 메커니즘이라고 할 수 있다.


비단 멀티코어 환경이 아니더라도 CPU의 시분할을 통한 멀티 프로그램 기법으로 인해 하나의 공유 자원에 대해 여러 프로세스가 동시에 접근할 수 있게 되었다. 각 프로세스는 특정 코드를 실행시키는 동안 자신이 접근하는 자원이 최소한 그 코드 내에서는 독점적으로 사용할 것을 요구하며, 이 요구사항이 지켜지지 않을 경우 일관성(consistency) 문제가 발생하게 된다. 


다음의 예시를 보자.


int a = 0, b = 0;

void func (void) {
    b = a + 1;  
    a = b; 
}

void proc_a (void) {
    func();
}

void proc_b (void) {
    func();
}


8번째 줄의 proc_a와 12번째 줄의 proc_b가 각각 별개의 프로세스에서 실행되는 코드라고 가정하자. proc_aproc_b가 동시에 실행된 뒤 a와 b를 출력하면 어떤 결과가 나올까? 총 두 가지의 결과를 예측해 볼 수 있다. 첫째로는 (a = 2, b = 2), 둘째로는 (a = 1, b = 1).  func()이 진행 중일 때 타이머 인터럽트가 발생하여 5번째 줄을 실행하기 전에 컨텍스트 스위치가 발생할 경우 두 번째의 결과가 발생하게 된다. 이러한 결과를 얻어내기 까지 발생할 수 있는 절차의 가지 수는 더욱 많다. 


  • proc_a -> proc_b
  • proc_b -> proc_a
  • proc_a -> (scheduling) proc_b -> proc_a
  • proc_b -> (scheduling) proc_a -> proc_b


위와 같은 상황이 발생할 경우, 사용자 혹은 애플리케이션의 입장에서는 같은 코드를 실행시켰음에도 불구하고 나오는 결과가 매번 달라질 수 있게된다. 따라서, 운영체제에서는 위와 같은 상황을 방지하기 위해 동기화 메커니즘을 제공한다. 대부분의 경우 동기화 메커니즘은 특정 영역(이를 critical section이라 부른다)에 대한 접근을 직렬화(serialize)하는 것으로 이러한 문제를 해결한다. 


void proc_a (void) {
    synch_region {
        func();
    }
}

void proc_b (void) {
    sync_region {
        func();
    }
}

<caller-side synchronization>

직렬화란, 동시에 오직 한 프로세스만 특정 영역에 접근시키도록 하는 것을 의미한다. 동시에 여러 프로세스가 접근할 경우, 뒤늦게 접근하는 프로세스는 앞서 진입한 프로세스가 작업을 마칠 때 까지 기다려야만 한다. 코드에서 synch_region으로 표시된 영역이 바로 이러한 영역이 되겠다.

위와 같이 호출하는 코드에서 동기화 영역을 표시하는 경우도 있으며, 반대로 호출되는 코드에서 동기화를 시키는 경우도 있을 것이다. 

void func (void) {
    synch_region {
        b = a + 1;  
        a = b; 
    }
}

<callee-side synchronization>

코드에 표시된 synch_region은 동기화 메커니즘의 일종의 예시인 것을 기억하자. 여러 논문 및 구현들을 통하여 다양한 방식의 동기화 메커니즘이 제안되어 왔으며, 위와 비슷한 형태를 취할 수도, 혹은 다른 형태를 취하게 될 수도 있다.


운영체제는 일반적으로 여러 형태의 locking primitive들을 통하여 동기화 기능을 제공한다. 대표적으로 spinlock과 같은 busy-loop 기반의 locking primitive가 있으며, 이는 효과적인 동기화 방법을 제공하지만 확장성을 저해시킬 수도 있다. 예를 들어 여러 코어에서 동작하는 프로세스들이 임계 영역(critical section - 동기화 메커니즘에 의해 보호받는 영역)을 만날 경우, 앞서 진입한 프로세스가 필요한 작업을 다 수행할 때 까지 기다려야만 한다. 이 임계 영역이 길어질 경우, 그만큼 대기 시간이 길어지게 되므로 자연스럽게 대기중인 프로세스가 점유하고 있는 코어의 시간을 낭비하게 된다. 


따라서 spinlock과 같은 busy-loop 기반의 동기화 방식들은 임계 영역의 길이를 최대한 줄일 것을 전제로 하고 있으며, 여의치 않을 경우 mutexsemaphore등의 block-based locking primitive들을 쓸 것을 추천한다. 이러한 lock들은 기다리는 프로세스를 sleep 상태로 전환함으로써, 해당 프로세스가 점유하고 있는 컴퓨팅 자원을 다른 프로세스에게 양도할 수 있도록 한다. 이를 통해 시스템은 낭비되는 컴퓨팅 자원을 효율적으로 활용할 수 있게 된다.


위와 같은 방식 외에도, 보다 확장성을 고려한 형태의 locking primitive들도 많이 고안되었다. reader-writer lock, ticket lock, queue spinlock, mcs lock, seqlock 등이 바로 확장성을 고려한 locking primitive 들이다.


앞으로 이와 같은 locking primitive들에 대해 한번 알아보려고 한다.

Posted by 곰푼