그냥 변수를 읽고 쓰는 것만으로 상호 배제를 만들 수 있을까?
역사적으로 원자적 명령 없이도 가능하다는 것을 증명한 것에 의미가 있다.
2개 프로세스(P0, P1)를 위한 해법이다. 공유 변수 두 개를 쓴다.
// 프로세스 i 의 코드 (상대는 j)\
flag[i] = true; // ① 나 들어가고 싶어
turn = j; // ② 근데 너 먼저 해 (양보)
while (flag[j] && turn == j) { } // ③ 상대가 원하고 + 상대 차례면 대기
// ── 임계구역 ──
flag[i] = false; // ④ 나감
핵심 아이디어는 ②의 양보(turn = j) 다. 두 프로세스가 동시에 진입을 시도해도, turn에는 마지막에 쓴 값 하나만 남는다. 따라서 둘 중 정확히 하나만 while 조건을 빠져나가고, 다른 하나는 대기한다.
개념적으로 완전하지만 실무에서 쓰지 않는다.
flag[i]=true와 turn=j의 순서가 다른 코어에는 뒤바뀌어 보일 수 있다. 그 결과 메모리 배리어 없이는 알고리즘이 깨진다.순서 보장을 하드웨어가 해주지 않는 한 안전하지도, 효율적이지도 않다.
레이스 컨디션의 발생 원인은 읽기-수정-쓰기 가 중간에 끼어들 수 있기 때문이다.
그렇다면 읽고 → 수정하고 → 쓰기를 CPU가 한 번에, 중간에 아무도 못 끼어들게 실행하는 단일 기계어 명령을 제공하면 된다.
이것이 원자적 명령어이며, 모든 고수준 동기화 도구의 토대다.
대표적으로 TAS와 CAS가 있다.
메모리의 값을 읽어서 반환함과 동시에 true(1)로 설정하는 동작을 하나의 원자적 명령으로 수행한다.
// 아래 전체가 '원자적으로' 실행된다
bool test_and_set(bool *target) {
bool old = *target;
*target = true;
return old;
}
이걸로 스핀락을 만든다.
while (test_and_set(&lock)) { }
// lock이 false면 true로 바꾸고 false 반환 → 진입
// ── 임계구역 ──
lock = false;
읽기와 쓰기가 원자적으로 묶여 있어, 두 스레드가 동시에 lock을 false로 보고 둘 다 진입하는 일이 불가능하다.
원자적 명령으로 상호 배제는 보장되지만, 스핀락은 바쁜 대기라 두 가지 비용이 있다.
해결로 TTAS (Test-and-Test-and-Set) 를 쓴다
먼저 평범한 읽기로 락이 비어 보이는지 확인하고 비어 보일 때만 TAS를 시도한다.
현재 값이 예상 값과 같을 경우에만 새 값으로 업데이트한다. 만약 다른 스레드에서 값을 이미 변경했으면 업데이트를 하지 않으므로 동시성을 보장한다.
이 변수가 expected 값일 때에만 new 값으로 변경 를 원자적으로 수행하고, 성공 여부를 돌려준다.
// 아래 전체가 원자적으로 실행된다
int compare_and_swap(int *addr, int expected, int new_val) {
int old = *addr;
if (old == expected) *addr = new_val;
return old; // old == expected 였으면 성공
}
락을 거는 대신 값을 바꿔보고 그사이 누가 건드렸으면 실패 → 재시도 하는 패턴을 만든다.
do {
old = counter;
} while (compare_and_swap(&counter, old, old + 1) != old);
이 방식은 락을 잡지 않으므로 lock-free라 부르며, 데드락이 원천적으로 없다.
TAS보다 일반적이고 강력해 lock-free 자료구조의 기반이 된다.
값이 A → B → A로 바뀌어 원래대로 돌아오면, CAS는 안 바뀌었다 라고 판단해 그 사이의 변경을 놓친다.
해결은 버전 스탬프를 함께 비교하는 것이다. 값에 카운터를 붙여 (값, 버전) 쌍으로 CAS하면 A(v1) → A(v3) 처럼 버전이 달라져 변경을 탐지할 수 있다.
(JPA @Version 기능이 이 발상이다.)
그래서 이 원자적 명령어 위에, 블로킹까지 지원하고 의미가 풍부한 고수준 추상화 (뮤텍스, 세마포, 모니터)를 세운다.