다중 코어, 다중 스레드의 작동 방식을 고려해보면 이러한 일들이 빈번하게 일어날 수 있다고 예상할 수 있습니다. 때문에 반드시 동시에 실행되는 프로세스들 간에는 동기화 처리가 되어있어야 합니다.
Critical Section (임계구역) : 코드 중에 다른 프로세스와의 공유하고 있는 변수를 변경하거나, 파일을 쓰는 등, Race Condition이 발생할 수 있는 부분
하나의 프로세스가 이미 Critical section 안에 들어가 있다면, 다른 프로세스들은 Critical section에 접근해서는 안됩니다. 두 프로세스가 동시에 하나의 Critical section안에 들어가게 되면 바로 위에서 봤던 것과 같이 타이밍의 문제로 올바르지 않은 값이 변수에 저장될 수 있기 때문입니다.
→ 따라서 하나의 Critical section에는 하나의 프로세스만 접근할 수 있는 프로토콜을 설계하는 것이 중요합니다.
다음 세 가지가 모두 지켜질 때 Critical section이 보호될 수 있습니다
1. Mutual Exclusion (상호 배제)
의미 : Mutual Exclusion은 한 번에 하나의 프로세스만 Critical section에 들어갈 수 있다
프로세스 A가 한 Critical section에서 실행되고 있다면, 다른 프로세스들은 이 Critical section을 실행할 수 없습니다.
2. Progress (진행)
의미 : Progress는 한 Critical section에서 실행되고 있는 프로세스가 없는 상황에서, 어떤 프로세스가 그 Critical section에 들어가고 싶어 한다면, 들어갈 수 있어야 한다
비어있는 Critical section이 있다면, Remainder section에서 실행 중인 프로세스들을 제외한 다른 프로세스들은 Critical section으로의 진입을 요청할 수 있고, 진입을 요청한 프로세스들 중에서 알고리즘에 의해 다음으로 Critical section에 들어가게 될 프로세스가 결정되게 됩니다.
3. Bounded Waiting (한정된 대기)
의미 : Critical Section으로 들어가고자 하는 프로세스는 비록 지금은 알고리즘에 의해 우선순위가 밀려서 못 들어갈 수 있을지언정, 언젠가는 해당 Critical section에 진입할 수 있어야 한다
프로세스가 진입을 요청하게 되면 그 요청이 허용되어 Critical section에 들어가기까지, 그 사이에 다른 프로세스들은 정해진 횟수만큼만 들어갈 수 있어야 합니다.
대표 예시 : Strict Alternation 방법
그림의 방법을 Strict Alternation이라고 합니다. 프로세스가 2개만 존재한다는 가정 하에 만들어 진 방법입니다.
프로세스 i는 j가 turn을 i로 변경할 때까지 기다리고, j가 변경한 후 Critical section으로 들어가 작업을 수행합니다. 작업이 완료되면 j는 turn을 j로 변경하고 Remainder section을 실행합니다. 그리고 i는 Critical section에서 빠져나와서 turn을 j로 변경합니다. 이때 j는 while문 안에 갇혀있기 때문에 두 개의 프로세스가 동시에 Critical section을 실행하지 않습니다..
i가 Critical section에서 빠져나오면서 turn을 j로 바꿔줍니다. 이 때 프로세스 j는 while문에 갇혀있거나, Remainder section을 실행하고 있을 것입니다.
j가 Remainder section을 실행하고 있다고 생각해 봅시다. 마침 j가 Remainder section에서 시간이 오래 걸려서 i가 먼저 Remainder section을 모두 수행하고 다시 while문에 들어가 Critical section에 들어가고 싶어 하는 상황이 생겼습니다.
이때 Critical section은 비어있고, i는 진입을 요청했기 때문에, Progress를 만족하려면, i는 Critical section에 들어갈 수 있어야 합니다.
하지만 j는 아직 Remainder section을 수행하고 있고, turn을 i로 바꿔줄 수가 없는 상황입니다. 이렇게 되면 Progress를 만족하지 못하게 됩니다.
즉, 상대방이 준비가 안되어 있는 상황에서 turn을 넘겨주게 되면 상황이 꼬일 수 있습니다.
Strict Alternation이 문제점을 가지고 있었기 때문에 다음으로는 Peterson's Solution이라는 것이 등장합니다.
(그림) : Peterson's Solution
여기서는 두 가지 변수를 사용합니다.
상대방의 flag가 true여서, 상대방이 Critical section에 들어갈 준비가 되어있고, turn도 상대방에게 있는 상황에서만 while문에서 Critical section으로 들어가기 위해 대기할 수 있습니다.
코드 상으로 Peterson's Solution은 세 가지 조건을 모두 만족합니다.
flag [i] = true;
, while(flag [j] && turn ==j);
줄의 순서가 바뀌면 제대로 동작하지 않을 수 있게 됩니다.Synchronization Hardware(동기화 하드웨어)는 시스템이나 장치 간에 동기화를 위해 사용되는 하드웨어 구성 요소를 가리킵니다. 이러한 동기화는 여러 개의 장치나 시스템이 함께 작동하고 데이터를 교환하거나 특정 작업을 동시에 수행할 때 필요한 시간적 조정을 의미합니다.
위에서 Race Condition을 SW로 해결하는 것에 한계가 있었습니다(컴파일러). 이러한 문제를 해결하기위해 atomic 명령어를 지원합니다.
test_and_set(TS)와 compare_and_swap(CAS)라는 instruction(명령어)들 입니다. 만일 두 개의 CPU들이 test_and_set이나 compare_and_swap을 각각 동시에 실행하여 같은 메모리 주소의 값을 변경하게 되면, 하드웨어는 알아서 하나의 CPU만 실행을 하도록 허용을 해줍니다.
bool test_and_set(T* address)
address
: 값을 설정할 주소(메모리 위치)입니다. test_and_set 연산은 다음과 같은 과정을 수행합니다.address
에 있는 값을 읽어옵니다.address
의 loack의 값을 1(true)로 설정합니다.test_and_set은 말 그대로 lock의 값이 true인지 false인지 확인해 보고 true이면 누군가 Critical Section에 들어가 있다는 뜻이므로 false를 리턴해주고 lock의 값이 false면 lock의 값을 true로 바꾼 뒤에 true를 리턴해주는 명령어입니다. test_and_set을 소프트웨어적으로 구현해보면 다음과 같이 코드를 작성해 볼 수 있습니다.
그런데 이렇게 소프트웨어적으로 구현하여 함수를 호출하는 식으로 사용하면 만일 어떤 프로세스가 lock = true;
까지 실행하고 context switching을 당하는 경우가 발생할 수 있습니다. 이러한 경우에 lock이 true가 된 상태여서 아무도 Critical section에 접근할 수 없기 때문에 다른 프로세스들이 Critical section을 실행하지 못하는 문제가 생기게 됩니다.
test_and_set() 명령어는 다음과 같이 사용될 수 있습니다.
bool compare_and_swap(T* address, T expected, T desired)
address
: 값을 비교하고 변경할 주소(메모리 위치)입니다.expected
: 현재 주소에 있는 값을 비교할 값입니다.desired
: 주소에 저장할 값을 나타내는 값입니다.compare_and_swap은 다음과 같은 과정을 수행합니다:
address
에 있는 값을 읽어와 expected
와 비교합니다.address
의 값이 expected
와 같다면, desired
값을 address
에 저장합니다.address
의 값이 expected
와 다르다면, 아무 작업도 수행하지 않습니다.lock의 값에 따라 0이면 lock의 값을 desired
인 1로 바꿔주고 0을 리턴해주기 때문에 while문을 탈출할 수 있고, lock의 값이 1이라면 그대로 원래 lock 값인 1을 리턴해 주는 형태입니다.
세 가지의 조건 (Mutual exclusion, Progress, Bounded waiting)중 Bounded waiting을 만족하지 않는다는 것입니다.
lock이라는 변수를 여러 Process가 공유하며 경쟁하므로 Bounded waiting을 만족하지 못하는 상황이 발생할 수 있습니다. 즉 얼마나 오랜 시간을 기다렸는지와는 전혀 상관없이 while문을 계속 돌며 경쟁적으로 lock을 가지려고 하기 때문입니다.
하드웨어를 통한 동기화 기법인 test_and_set이나 compare_and_swap의 경우에도 문제점이 있었습니다. 그래서 운영체제의 설계자들은 이러한 문제를 해결하기 위해 다시 소프트웨어 툴을 개발합니다. 그리고 이 중에 가장 간단한 도구가 바로 Mutex Lock입니다.
Mutex(Mutual Exclusion)
acquire() 함수를 통해 lock을 획득
release() 함수를 통해 lock을 반납
모든 프로세스는 Critical section에 들어가기 위해 lock을 획득해야만 하며, 빠져나올 때 반환해야 합니다.
단점 : 바쁜 대기 (Busy Waiting).
lock을 얻지 못하여 Critical section에 들어가지 못하는 동안 acquire 함수 내에서 while문을 돌고 있어야 한다는 점이 있습니다.
현재 Critical section에 들어가지 못하여 어차피 프로세스는 할 일이 없음에도 불구하고 계속 while문을 통해 available 변수의 상태를 확인해야 합니다. 이것을 보고 프로세스가 Lock을 기다리면서 계속 회전하고 있다고 해서 Mutex Lock을 Spinlock이라고도 부릅니다.
장점 : 바쁜 대기 (?)
프로세스가 계속 일을 하고 있기 때문에 Context Switching을 할 필요가 없다는 점이 장점으로 작용하기도 합니다.
결론 : 프로세스들이 짧은 시간 동안 Lock을 소유하는 경우라면 Mutex가 유용하게 사용될 수 있습니다.
Mutex에서의 Lock은 boolean 값으로써 true나 false의 값만 가집니다. 하지만 Semaphore에서는 boolean 대신 integer 값의 변수를 이용합니다.
Semaphore
def P(S):
while S<=0:
pass
S--
def V(S):
S++
wait와 signal도 원자적으로 실행이 되며, busy waiting 방식(P의 while문)을 사용합니다. S 값을 이용하여 현재 Critical section에 들어갈 수 있는지 없는지 판단을 하게 됩니다.
# Process P
while 1:
P(mutex)
critical section
V(mutex)
remainer section
Semaphore와 Mutex Lock 모두 busy waiting이라는 단점을 가지고 있습니다.
그래서 이를 극복하기 위해 Linked list를 이용하면서 변경된 wait와 signal 함수를 를 사용합니다.
구조
class S: # Semaphore
def __init__(self):
self.value = 0
self.L = [] # 리스트처럼 표현했지만 링크드리스트
def P(S):
S.value-=1
if S.value < 0:
S.L.append(now_process)
block()
def V(S):
S.value+=1
if S.value<=0:
S.L.del(S)
wakeup(process)
동작 방식
Semaphore에는 두 가지 방식이 존재합니다
Waiting queue를 이용한 Semaphore 구현에서는 둘 이상의 프로세스가 서로의 signal 함수를 기다리면서 서로 더 이상 무한정 실행이 되지 못하는 상황이 발생할 수도 있습니다. 이러한 상황을 Deadlock (교착상태)이라고 합니다.
Wait queue가 후입선출 (Last-In-First-Out)의 방법으로 구현된다면 여기서도 starvation 문제가 발생할 수 있습니다. 따라서 이러한 점들을 고려하여 알고리즘을 설계해야 합니다.