멀티 프로세스/스레드 환경에서 동기화를 위한 여러 전략과 차이
동기화의 전략의 구체적인 동작 방식은 OS와 프로그래밍 언어에 따라 차이가 있을 수 있으니, 한번 개념을 정리했어도, 꼭 관련 문서를 통해 검토하는 습관을 가져야 한다.
동기화란?
여러 프로세스/스레드가 동시에 공유 데이터를 실행, 조작을 해도, 공유 데이터의 일관성을 유지하는 것.
동기화가 필요한 이유
여러 프로세스 혹은 스레드가 동시에 같은 데이터를 조작할 때, 각각의 타이밍, 순서에 따라 결과가 달라질 수 있는 상황(경쟁 조건 발생)이 발생하는데, 이 상황에서 공유 데이터의 일관성을 보장하기 위해선 하나의 프로세스/스레드만 진입해서 실행 가능한 영역(임계 영역)을 구분하고 통제해야할 필요가 있다.
즉, 요약하자면, 경쟁 조건 속에서 임계 영역을 구분하여 상호배제(mutual exclution)를 보장하여 동기화 유지할 필요가 있다. 이러한 동기화를 유지하는 방법은 바로 락(Lock)을 사용하는 것이다.
Lock
do {
acquire lock
critical section
release lock
} while(true)
락을 사용하는 요령은 위의 예시처럼 임계 영역(critical section)에 진입하기 전에 락을 획득하고, 임계 영역의 로직을 한번에 하나의 스레드만을 허용하여, 로직을 처리한 다음 락을 풀어서, 다른 스레드가 임계 영역에 진입할 수 있도록 하는 행위를 런타임 동안에 계속 유지하는것이다.
예상되는 문제점은 락을 얻는 것 역시 하나의 함수인데, 락을_얻는_함수()
에 두개 이상의 스레드가 동시에 접근했을 때, 해당 스레드가 동시에 락을 얻게되는 게 아닐까 하는 문제점이 있을 수 있지만, 다행히, 이 과정에서 락을 얻는 함수는 CPU의 atomic 명령어의 도움을 받는다. 따라서, 락을 얻는 함수는
- 실행 중간에 간섭받거나 중단되지 않으며,
- 같은 메모리 영역에서 동시에 실행되지 않는다.
- 즉 두개 이상의 프로세스/스레드가 실행을 해도, CPU가 동기화를 진행하여, 두 개 이상의 스레드가 동시에 락을 얻는 문제를 방지해준다.
- 멀티코어 환경에서도 예외는 아니다.
- 단 순서는 보장하지 않는다.
동기화의 전략이란 이러한 락을 얻고 푸는 방식을 다양한 방식으로 구체화하는 방식을 이야기하는 것이라 할 수 있다.
동기화 전략의 종류
스핀락(spinlock)
- 먼저 락을 획득한 스레드가 임계영역을 처리하고 락을 풀때까지, 다른 스레드들이 락을 가질 수 있을 때까지 락을 얻으려고 하는 행위를 반복하는 전략.
- 두명의 개발자가 컴퓨터 하나를 놓고 업무를 진행해야할 때, 놀고 있는 개발자가 옆에서 계속 '다끝났냐? 나 좀 써도 되겠냐?'라고 계속 묻는 상황을 상상하면 될 것 같다.
- 당연하게도, 이 방식은 CPU를 낭비한다는 단점을 가지고 있다.
- 그러나, 멀티코어 환경에서, 임계영역의 작업 시간이 컨텍스트 스위칭보다 더 낮다면, 스핀락 방식의 동기화가 더 효율적일 수 있다.
- 그러나 단일코어 환경에서는 한번에 하나의 스레드만 사용할 수 있기 때문에, 어떠한 경우에도 컨텍스트 스위칭이 발생하므로, 단일 코어 환경에서는 바로 위와 같은 이점은 확보할 수 없다.
뮤텍스(mutex)
- 스핀락처럼 락의 획득을 대기하는 와중에서 cpu를 바쁘게 하는 상황을 '바쁜 대기'라고 하는데, 뮤텍스는 이러한 '바쁜 대기'로 인한 리소스 낭비를 줄일 수 있는 동기화 전략이다.
- 특정 스레드가 락을 얻고, 임계 구역의 작업을 진행하기 위해서, 뮤텍스에 의해 value를 얻어야 한다.
- 이미 다른 스레드가 value를 얻고 락을 획득한 상황이라면, 락을 얻지 못한 스레드들을 큐에 넣어서, 대기시킨다.
- 그리고 나서, 임계 구역의 작업을 끝낸 스레드가 락을 방출하면, 큐에 들어있던 스레드 하나를 꺼내서 깨워서, 락을 획득시켜서, 임계 구역의 작업을 진행 시킨다.
- 이러한 방식을 통해, 대기하는 스레드는 쉬고 있기 때문에, 스핀락과는 다르게 CPU 소모량을 줄일 수 있다.
- 스레드를 재우고 깨우는 행위에서 컨텍스트 스위칭이 발생하는데, 이러한 요소가 경우에 따라서 스핀락보다 효율이 더 안좋을 수 있다.
세마포
wait(); //value++
critical section... // value가 0이 아니라면, 임계 구역에 스레드 접근 허용.
signal(); //value--
- signal mechanism을 바탕으로, 프로세스/스레드의 임계 구역으로의 접근을 통제하는 전략이다.
- 이 경우, wait와 signal을 통해 통제를 하는데, 매커니즘 방식 자체는 뮤텍스와 거의 동일하다.
- 다만 차이점은 뮤텍스는 value를 얻은 하나의 스레드만 임계구역에 접근을 허용하고, 세마포의 경우, 주어진 value에 -1을 하여, value가 0이 되기 전까지는 하나 이상의 스레드가 임계구역에 접근하는 것을 허용한다.
- 세마포의 경우 value의 값에 따라, 카운팅, 이진 세마포로 구분한다.
- 이진 세마포의 경우, 뮤텍스와 거의 다를 게 없어보이지만, 뮤텍스와 달리 락을 해제할 수 있는 스레드가 고정되어있지 않다.
- 위의 특징을 활용하여, 세마포는 task의 순서를 정해줄 때도 사용이 가능하다.
-
위와 같은 상황에서 task1의 작업이 끝나서class Semaphore { int value = 0; }
signal
을 보내고, task2가 임계 구역에 진입하여 작업을 끝내면wait
을 보내서, task3를 진행시킨다. - 그리하여, 결과적으로는
task1 -> task2 -> task3
의 순서로 작업을 처리할 수 있게 한다.
-
뮤텍스 vs 세마포
- 뮤텍스는 priority inheritance와 같은 스케줄링 속성을 가졌지만, 세마포는 그렇지 않다.
- priority inheritance란, 우선순위가 낮은 프로세스가 먼저 락을 획득한 경우, 상대적으로 우선순위가 높은 프로세스와 같은 수치만큼 우선순위를 높여서, 락을 획득한 프로세스를 최대한 빨리 락을 방출하도록 한다.
- 따라서, 프로세스 혹은 스레드의 우선순위가 정해졌음에도, 해당 우선순위의 순서대로, 무조건 처리된다고 보장할 수는 없는 것이다.
- 하지만, 세마포는 락을 해제하는 주체가 정해져있지 않기 때문에, priority inheritance에 의해 프로세스/스레드의 처리 순서가 꼬이는 경우를 방지할 수 있다. (물론 절대적인건 아니다.)