경쟁 상태는 여러 프로세스들이 동시에 데이터에 접근하는 상황에서, 어떤 순서로 접근하느냐에 따라 결과 값이 달라질 수 있는 상황
멀티 프로세서 환경에서 공유 데이터의 동시 접근은 데이터 불일치 문제를 발생시킬 수 있다.
따라서 Race Condition을 막고 일관성을 유지하기 위해서 협력 프로세스 간의 실행 순서를 정해주는 메커니즘은 동기화가 필요하다.
💡 싱글 프로세서 환경에서는 동시성 문제가 발생하지 않는가?
- 그렇지 않다. 일반적으로 두 프로세스의 주소 공간에서는 데이터를 공유하지 않지만, 시스템 콜을 수행하는 동안에는 둘 다 커널 주소 공간의 데이터를 접근한다. 커널 주소 공간은 공유되므로 이때 경쟁 조건이 발생할 수 있다.
아래 직접적인 예시로 설명하겠다.
프로세스가 시스템 콜을 호출해서 커널 모드로 수행 중인데 Context Switch가 발생하는 경우
코드상에서 경쟁 상태가 발생할 수 있는 특정 부분을 critical section이라고 한다
임계 구역으로 인해 발생하는 문제들을 해결하기 위해선 3가지 조건을 만족해야 한다.
1. Mutual Exclusion(상호 배제)
이미 한 프로세스가 Critical Section에서 작업 중이면 다른 모든 프로세스는 Critical Section에 진입해서는 안된다.
2. Progress(진행)
Critical Section에서 작업 중인 프로세스가 없는 상태에서, Critical Section에 진입하고자 하는 프로세스가 존재하는 경우 진입할 수 있어야 한다.
3. Bounded Waiting(한정 대기)
프로세스가 Critical Section에 들어가기 위해 요청한 후부터 그 요청이 허용될 때까지 다른 프로세스들이 Critical Section에 들어가는 횟수가 한계가 있어야 한다.
💡 Non-preemptive kernels
임계 영역 문제가 발생하지 않는다. 하지만 비선점 스케줄링은 반응성이 떨어지기 때문에 슈퍼 컴퓨터가 아니고선 잘 사용하지 않는다.
Critical Section Problem을 해결하기 위한 방법으로 크게 3가지가 존재함
소프트웨어적 접근 방법은 동기화 알고리즘을 사용하거나, 뮤텍스나 세마포어 같은 도구를 사용하여 문제를 해결할 수 있다.
가정 : 두 프로세스 Pi, Pj가 있고 공유하는 공통 변수가 존재
Mutual Exclusion은 만족하지만 Progress는 만족하지 못함
do {
while(turn != i);
>>> critical section
turn = j;
>>> remainder section
} while(1);
Mutual Exclusion은 만족하지만 Progress는 만족하지 못함
flag = true
를 수행하고 나면 두 프로세스 모두 무한히 Critical Section에 진입하지 못하고 기다리는 상황이 발생하게 됨do {
flag[i] == true
while (flag[j]);
>>> critical section
flag[i] = false;
>>> remainder section
} while(1);
3. Algorithm 3(Peterson’s Algorithm)
Mutual Exclusion, Progress, Bounded Waiting을 모두 만족한다.
하지만 Busy Waiting 문제가 발생
do {
flag[i] == true
turn = j;
while (flag[j] && turn == j);
>>> critical section
flag[i] = false;
>>> remainder section
} while(1);
- 위 코드에서 flag[i] == true이면 프로세스 x가 임계 영역에 들어갈 준비가 되었음을 의미하고 turn의 최종 값이 어떤 프로세스가 임계 영역에 진입할지 결정한다.
- Pi는 Critical Section에 진입하기 위해 flag[i] = true로 바꾼다.(들어갈 준비가 되었다). 그리고 turn = j를 수행하여 상대방이 먼저 들어갈 수 있도록 함
- 상대방이 만약 들어가고 싶은 상태이고(flag[j] = true), 현재 상대방이 Critical Section에 들어가 있으면 기다린다.
- 그렇지 않으면 Pi가 들어감
- 이후에 Critical Section을 빠져나오면 flag[i] = false로 바꿔줌
💡 하지만 Peterson’s Algorithm은 항상 정상적으로 작동하는 것은 아니다.
- 최신 컴파일러 또는 프로세서는 서로 연관 없는 명령의 순서를 최적화 과정에서 바꿀 수 있기 때문이다.
- 싱글 스레드 프로그램에서 코드의 결과만 고려한다면 문제가 없겠지만, 멀티 스레드 프로그램에서는 최종 결과가 예상과 다를 수 있다.
💡 Busy Waiting
이미 프로세스가 Critical Section에 존재할 때, 다른 프로세스들은 Critical Section에 계속 진입하려고 시도하여 CPU 낭비를 하게 됨
Dekker's Algorithm
두 개의 프로세스가 상호배제를 위해 공유 자원에 접근할 때 사용
Peterson's Algorithm
두 개의 프로세스가 상호배제를 위해 공유 자원에 접근할 때 사용
Bakery Algorithm(Lamport의 제과점)
이 알고리즘은 여러 개의 프로세스가 공유 자원에 접근할 때 사용
Critical Section Problem을 해결하기 위한 소프트웨어 도구 중 가장 대표적인 방법으로 Mutex(Mutual Exclusion) locks이 있다.
Lock이 하나만 존재할 수 있는 locking 메커니즘을 따름
이미 하나의 프로세스가 Critical Section에서 작업 중이면 다른 프로세스들을 Critical Section에 들어갈 수 없도록 함
락을 걸고 푸는 동작은 하드웨어 방식처럼 atomic하게 수행됨
Busy Waiting의 단점이 발생한다.
💡 Spin Lock
Lock이 반환될 때까지 계속확인하면서 프로세스가 기다리는 것을 이라고 함
- 임계영역에 진입을 위한 대기 시간이 짧을 때, 즉 Context Switching하는 비용보다 기다리는게 더 효율적인 특수한 상황을 위해 고안된 것
- 단일 CPU 시스템에서는 어차피 Lock을 갖고 있는 스레드를 풀어주기 위해서 Context Switching이 일어나야 하기 때문에 유용하지 않고, 멀티 프로세서 시스템에서 종종 사용
세마포어는 Busy Waiting이 필요없는 동기화 도구이며, 여러 프로세스나 스레드가 Critical Section에 진입할 수 있는 Signaling 메커니즘
카운터를 이용하여 동시에 자원에 접근할 수 있는 프로세스를 제한한다.
세마포어에서 진입 구역과 퇴출 구역은 wait() 함수와
signal() 함수가 담당한다.
Block을 수행하면, 커널은 Block을 호출한 프로세스를 suspend 시키고 해당 PCB를 wait queue에 넣어줌
Wakeup을 수행하면 block된 프로세스 P를 깨운다음 이 프로세스의 PCB를 Ready Queue로 이동
signal 함수에서 S.value가 0 이하인 이유는 이전에 S.value++를 통해 자원을 내놓았는데 아직 0 이하라는 의미가 기다리고 있는 프로세스가 존재한다는 의미
wait나 signal 함수는 같은 세마포어에 대해 두 프로세스가 동시에 실행할 수 없으며, 만약 wait나 signal을 수행하는 동중에 preempt 된다면 Critical Section Problem에 빠지게 됨
S와 Q는 각각 1로 초기화 된 바이너리 세마포어일 때, 위와 같은 순서로 바이너리 세마포어를 호출할 때 S와 Q가 각각 0이됨
이후에 두 프로세스가 모두 블록되어 데드락이 발생함
해결 1. Priority Inheritance Protocol
해결 2. Priority Ceiling Protocol
스레드가 세마포어(뮤텍스) 소유하는 동안 지정된 우선순위로 올림
모든 프로세스의 우선순위와 각 프로세스가 요구하는 공유자원을 미리 알고 있다는 가정 하에 가능
세마포어의 문제점은 코딩하기가 힘들고 실수하기 쉬우며 정확성을 입증하기 어렵다는 것이다. ( V와 P의 순서에 따라 데드락이 생기거나 Mutual Exclusion이 깨질 수 있음 )
반면 Monitor는 동시 수행 중인 프로세스 사이에서 추상 데이터의 안전한 공유를 보장하기 위한 High-level 동기화 구조이다.
모니터 사용을 통해서 다음과 같은 문제를 해결할 수 있다.
💡 Java에서는 synchronized에 구현되어 있다.
소프트웨어적 방식은 데이터를 읽고 쓰는 것을 하나의 명령어로 처리할 수 없어 복잡해진 반면 하드웨어적으로 메모리에서 데이터를 읽으면서 쓰는 것까지 하나의 명령어로 동시에 수행이 가능하다면 코드가 간결해진다.
하드웨어적으로 현재 상태를 확인하고 변경하는 Test & modify를 atomic하게 수행할 수 있도록 지원하면 임계영역 문제를 해결할 수 있다.
컴퓨터 아키텍처가 애플리케이션 프로그램에게 어떠한 메모리적인 보장을 할 것인지 정하는 것을 memory model이라고 한다.
이러한 메모리 모델은 프로세서마다 다르기 때문에 커널 개발자들은 공유 메모리를 사용하는 멀티 프로세서에서 메모리 변경에 대한 가시성을 예측하기 어렵다.
대신 이 문제를 해결하기 위해 컴퓨터 아키텍처는 메모리의 변경을 다른 프로세서로 전달하는 명령을 제공한다.
memory barriers
또는 memory fence
라 불린다.해당 명령어가 수행되면 이전의 load, store 명령어가 이후의 load, store가 수행되기 전에 완료되는 것을 보장한다.
💡 Memory barrier는 굉장히 저수준의 연산이기 때문에 일반적으로 커널 개발자들이 사용한다.
boolean test_and_set(boolean *target) {
boolean rv = *target;
*target = true;
return rv;
}
test_and_set()
의 가장 중요한 점은 Atomic하게 실행된다는 것이다.test_and_set()
을 지원하는 하드웨어에서 두 개의 test_and_set() 명령이 다른 코어에서 동시에 실행된다면, 순차적으로 실행되는 것이 보장된다. (순서는 알 수 없다.)test_and_set()
은 아래 코드처럼 false로 선언된 lock
변수를 이용해 사용한다.do {
while (test_and_set(&lock)) ;
/* critical section */
lock = false;
/* remainder section */
} while (true);
다만 위와 같은 하드웨어적 명령어는 Bounded Waiting 조건을 만족시키지 못함
int compare_and_swap(int *value, int expected, int new_value) {
int temp = *value;
if (*value == expected)
*value = new_value;
return temp;
}
Compare-and-swap 연산은 멀티스레드 환경에서 데이터 일관성 문제를 해결하기 위한 동기화 기법 중 하나로
변경하기 전에 공유 자원에 대한 값을 변경하지 않았는지 확인하여, 변경 사항이 없는 경우에만 값을 변경한다.
💡 락프리 알고리즘
- 대표적으로 CAS(compare-and-swap)를 이용한 알고리즘으로 공유 자원에 대한 접근을 동시에 처리하면서도 경쟁 조건을 방지하는 방법
- 락프리 알고리즘은 뮤텍스나 세마포어 등의 락 기반 알고리즘과 달리 락을 사용하지 않고 스레드 간의 경쟁을 해결
- 락프리 알고리즘은 락 기반 알고리즘에 비해 구현이 복잡하고 성능도 상대적으로 떨어지는 단점
test_and_set()
과 마찬가지로 Atomic하게 실행되며, 다른 코어에서 동시에 실행되는 경우, 결과적으로는 순차적으로 실행된다.
언제나 전달받은 value
변수의 값을 그대로 반환하며, value
값이 expected
와 다른 경우에만 expected
값을 새로 대입한다.