하드웨어 동기화와 뮤텍스
인터럽트를 끄는 방법의 한계에서 출발해, CPU가 제공하는 Test-and-Set과 Compare-and-Swap이 어떻게 상호 배제의 바탕이 되는지 살펴봅니다. CAS 재시도 루프와 ABA 문제를 모델로 따라가고, 스핀락과 뮤텍스의 차이, pthread와 Python의 잠금 사용법, Linux의 futex를 정리합니다.
앞 글에서 본 피터슨 알고리즘은 메모리 접근이 쓰인 순서대로 보인다는 가정 위에서만 올바르고, 일반 변수의 읽기와 쓰기만으로는 그 가정을 지킬 수 없었습니다. 문제의 뿌리는 값을 읽고 고치고 쓰는 과정이 여러 단계로 나뉘어, 그 사이에 다른 스레드가 끼어들 수 있다는 데 있습니다. 그렇다면 읽고 고치고 쓰는 과정을 하드웨어가 한 덩어리로 처리해 준다면 어떨까요?
오늘날의 CPU는 바로 그런 원자적(atomic) 명령어를 제공하고, 뮤텍스와 세마포어 같은 동기화 도구는 모두 이 명령어 위에 만들어집니다. 이 글에서는 원자적 명령어가 무엇을 해 주는지, 그것으로 잠금을 어떻게 만드는지, 그리고 잠금을 기다리는 방법에 따라 스핀락과 뮤텍스가 어떻게 다른지 살펴봅니다.
인터럽트를 끄면 될까
가장 오래된 방법은 임계 영역에 들어가기 전에 인터럽트를 끄는 것입니다. 타이머 인터럽트가 오지 않으면 운영체제가 실행 중인 코드를 선점할 기회도 없으므로, CPU가 하나뿐인 컴퓨터라면 임계 영역을 끝까지 방해 없이 실행할 수 있습니다.
disable_interrupts();
/* 임계 영역 */
enable_interrupts();하지만 이 방법은 커널 안의 아주 짧은 구간에서만 씁니다. 첫째, 인터럽트를 끄는 것은 그 명령을 실행한 CPU 하나에만 적용됩니다. 다른 코어는 계속 실행되면서 같은 데이터에 접근할 수 있으므로, 멀티코어에서는 이것만으로 상호 배제를 만들 수 없습니다. 둘째, 인터럽트를 끈 동안에는 장치의 인터럽트 처리가 늦어지므로, 구간이 길면 시스템 전체의 응답이 나빠집니다. 셋째, 사용자 프로그램에 인터럽트를 끌 권한을 주면, 인터럽트를 끈 채 무한 루프를 돌아 CPU를 영원히 차지할 수 있습니다. 그래서 인터럽트를 끄는 명령은 커널 모드에서만 실행할 수 있는 특권 명령이며, 커널도 원래의 인터럽트 상태를 저장했다가 되돌리는 방식으로 조심스럽게 씁니다.
하드웨어의 원자적 명령어
CPU의 원자적 명령어는 메모리의 값을 읽고 새 값을 쓰는 과정을, 다른 CPU의 접근이 그 사이에 끼어들 수 없는 하나의 동작으로 처리합니다. 명령어 하나로 하기도 하고, 읽기와 쓰기 명령 한 쌍이 그사이에 다른 접근이 없었을 때만 성공하도록 하기도 합니다. 원자적이라는 것은 그 메모리 위치 하나에 대한 읽기와 쓰기가 쪼개지지 않는다는 뜻이며, 다른 코어를 모두 멈추게 하거나 주변의 다른 메모리 접근의 순서까지 저절로 맞춰 준다는 뜻은 아닙니다.
Test-and-Set
Test-and-Set(TAS)은 메모리의 값을 읽어 돌려주는 동시에 그 자리에 참(1)을 씁니다. 아래 의사 코드는 그 동작을 C 함수 모양으로 풀어 쓴 것입니다. 핵심은 함수 안의 세 줄을 하드웨어가 명령 하나로 처리해 그 사이에 다른 CPU가 끼어들 수 없다는 점이며, 이 코드를 보통의 C 함수로 컴파일하면 그런 성질은 생기지 않습니다.
bool test_and_set(bool *target) /* 하드웨어가 한 동작으로 처리한다. */
{
bool old = *target;
*target = true;
return old;
}잠금 변수가 거짓(0)이면 아무도 잠그지 않은 상태입니다. 잠그려는 스레드가 TAS를 실행해 거짓을 돌려받았다면, 이전에는 잠겨 있지 않았고 방금 자기가 참을 써서 잠근 것이므로 잠금을 얻은 것입니다. 참을 돌려받았다면 이미 다른 스레드가 잠가 둔 것이므로 얻지 못한 것입니다. 두 스레드가 동시에 TAS를 실행해도 하드웨어가 순서를 정해 하나씩 처리하므로, 거짓을 돌려받는 스레드는 하나뿐입니다. x86에서는 메모리를 대상으로 하는 XCHG 명령이 이 일을 합니다.
Compare-and-Swap
Compare-and-Swap(CAS)은 TAS보다 쓰임새가 넓습니다. 메모리의 현재 값이 기대한 값과 같을 때만 새 값으로 바꾸고, 바꾸었는지를 돌려줍니다.
bool compare_and_swap(int *value, int expected, int new_value) /* 한 동작으로 처리한다. */
{
if (*value == expected) {
*value = new_value;
return true;
}
return false;
}CAS를 쓰면 잠금 없이도 값을 안전하게 고칠 수 있습니다. 현재 값을 읽어 새 값을 계산한 뒤, "내가 읽은 뒤로 아무도 바꾸지 않았다면 새 값으로 바꿔라"라고 CAS를 실행하는 것입니다. 실패했다면 그사이 누군가 값을 바꾼 것이므로, 다시 읽어서 다시 계산하고 다시 시도합니다. 이를 CAS 루프라고 합니다. x86에서는 LOCK CMPXCHG가, ARM에서는 배타적 읽기와 쓰기 명령 쌍(LDXR, STXR)이나 ARMv8.1의 CAS 명령이 이 일을 합니다. 프로그래머는 보통 Java의 AtomicInteger.compareAndSet()이나 C++의 std::atomic::compare_exchange_weak()처럼 언어가 제공하는 함수로 씁니다.
다음 Python 코드는 CAS 루프로 값을 1씩 올리는 두 스레드의 실행 순서를 직접 정해 따라갑니다. 실제 하드웨어가 아니라, cas 한 번은 쪼개지지 않는다고 가정한 모델입니다.
class AtomicCell:
"""cas 한 번은 쪼개지지 않는다고 가정한 모델"""
def __init__(self, value):
self.value = value
def cas(self, expected, new):
if self.value == expected:
self.value = new
return True
return False
def cas_increment(cell, name, log):
while True:
old = cell.value # 1. 현재 값을 읽는다
yield
if cell.cas(old, old + 1): # 2. 그사이 바뀌지 않았으면 바꾼다
log.append(f"{name}: {old} -> {old + 1} 성공")
return
log.append(f"{name}: 기대 {old}, 현재 {cell.value} -> 실패, 다시 읽기")
yield
cell, log = AtomicCell(100), []
threads = {"A": cas_increment(cell, "A", log), "B": cas_increment(cell, "B", log)}
for name in "ABBAAA":
next(threads[name], None)
print("\n".join(log))
print("최종 값:", cell.value)B: 100 -> 101 성공
A: 기대 100, 현재 101 -> 실패, 다시 읽기
A: 101 -> 102 성공
최종 값: 102A와 B가 모두 100을 읽은 뒤 B가 먼저 CAS를 실행해 101로 바꿉니다. A의 CAS는 기대한 100과 현재의 101이 다르므로 실패하고, 값을 바꾸지 않습니다. 앞 글에서는 이 순서에서 A가 낡은 값으로 덮어써 증가가 사라졌지만, CAS는 덮어쓰기 전에 값이 그대로인지 확인하므로 그런 일이 생기지 않습니다. A는 다시 101을 읽어 102로 바꾸는 데 성공해, 두 증가가 모두 반영됩니다. 재시도할 때 처음 계산한 101을 그대로 쓰지 않고 다시 읽어 계산한다는 점이 중요합니다.
CAS가 실패했다는 것은 비교하는 순간 현재 값이 기대한 값과 달랐다는 뜻일 뿐입니다. 그 사이에 몇 번 바뀌었는지, 누가 바꾸었는지는 알려 주지 않습니다. 또 compare_exchange_weak 같은 약한 CAS는 값이 같아도 하드웨어 사정으로 실패할 수 있으므로, 늘 루프 안에서 써야 합니다.
잠금 없는 프로그래밍과 그 함정
CAS 루프를 쓰면 잠금 없이도 여러 스레드가 같은 데이터를 안전하게 고칠 수 있습니다. 이런 방식을 잠금 없는(lock-free) 프로그래밍이라고 합니다. 잠금을 쓰면 잠금을 쥔 스레드가 선점되거나 느려졌을 때 다른 모든 스레드가 기다려야 하지만, 잠금 없는 알고리즘에서는 어떤 스레드가 멈추어도 다른 스레드는 계속 진행할 수 있습니다. 정확히 말하면, 잠금 없는 알고리즘은 시스템 전체로 보아 어떤 스레드든 하나는 반드시 진척한다는 것을 보장할 뿐, 각 스레드가 유한한 시간 안에 끝난다는 것까지 보장하지는 않습니다. 위의 A처럼 운이 나쁜 스레드는 계속 실패할 수 있습니다. 또 CAS를 쓴다고 해서 알고리즘 전체가 저절로 잠금 없는 것이 되지도 않습니다.
잠금 없는 자료구조에는 ABA 문제라는 함정이 있습니다. 값이 A에서 B로 바뀌었다가 다시 A로 돌아오면, 값만 비교하는 CAS는 아무 일도 없었던 것으로 판단합니다. 숫자 카운터라면 상관없지만, 연결 리스트로 만든 스택처럼 값이 노드의 주소인 경우에는 심각한 문제가 됩니다.
값이 A로 돌아오면 값만 비교하는 CAS는 중간 변경을 알 수 없습니다
시작 값을 예상값으로 보관한다. 현재 값과 보관한 예상값을 비교한다. 각 단계의 값과 전환 이유를 비교합니다.
CAS · 현재 값과 변경 이력
값이 A로 돌아오면 값만 비교하는 CAS는 중간 변경을 알 수 없습니다
스레드 T1이 A를 읽고 계산하는 동안 T2가 A→B→A로 바꾼 개념 예입니다. 오른쪽의 버전 1·2·3은 비교용 표식이며 실제 포인터 표현이나 메모리 회수 구현을 뜻하지 않습니다.
1 · T1이 시작 상태를 읽음
현재 상태: 값 A / 버전 1
T1은 나중에 A를 C로 바꾸려고 예상 값 A를 저장합니다. 표식도 함께 비교한다면 예상 쌍은 (A, 1)입니다.
2 · T2가 중간에 A → B → A로 변경
상태 변화: (A, 1) → (B, 2) → (A, 3)
값은 다시 A지만 시작 상태와 같은 변경 이력은 아닙니다.
3 · T1의 비교 기준에 따른 결과
값만 비교: 현재 A = 예상 A → CAS가 성공할 수 있음
값·버전 쌍 비교: 현재 (A, 3) ≠ 예상 (A, 1) → CAS 실패
두 필드를 따로 검사하는 것이 아니라 쌍 전체의 원자적 비교가 필요합니다. 버전 표식의 순환과 객체 수명·메모리 회수 문제는 별도로 해결해야 합니다. CAS 명령의 성공이 알고리즘의 안전성이나 스레드별 진행 보장을 뜻하지 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 T1 읽기부터 원자 비교까지 진행합니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| T1 읽기 시작 값을 예상값으로 보관한다. |
|
|
| T2 첫 변경 중간 상태로 바꾼다. |
|
|
| T2 다음 변경 값은 A로 돌아오지만 표식은 다르다. |
|
|
| 원자 비교 현재 값과 보관한 예상값을 비교한다. |
|
|
값이 A로 돌아오면 값만 비교하는 CAS는 중간 변경을 알 수 없습니다
시작 값을 예상값으로 보관한다. 현재 값과 보관한 예상값을 비교한다. 각 단계의 값과 전환 이유를 비교합니다.
CAS · 현재 값과 변경 이력
값이 A로 돌아오면 값만 비교하는 CAS는 중간 변경을 알 수 없습니다
스레드 T1이 A를 읽고 계산하는 동안 T2가 A→B→A로 바꾼 개념 예입니다. 오른쪽의 버전 1·2·3은 비교용 표식이며 실제 포인터 표현이나 메모리 회수 구현을 뜻하지 않습니다.
1 · T1이 시작 상태를 읽음
현재 상태: 값 A / 버전 1
T1은 나중에 A를 C로 바꾸려고 예상 값 A를 저장합니다. 표식도 함께 비교한다면 예상 쌍은 (A, 1)입니다.
2 · T2가 중간에 A → B → A로 변경
상태 변화: (A, 1) → (B, 2) → (A, 3)
값은 다시 A지만 시작 상태와 같은 변경 이력은 아닙니다.
3 · T1의 비교 기준에 따른 결과
값만 비교: 현재 A = 예상 A → CAS가 성공할 수 있음
값·버전 쌍 비교: 현재 (A, 3) ≠ 예상 (A, 1) → CAS 실패
두 필드를 따로 검사하는 것이 아니라 쌍 전체의 원자적 비교가 필요합니다. 버전 표식의 순환과 객체 수명·메모리 회수 문제는 별도로 해결해야 합니다. CAS 명령의 성공이 알고리즘의 안전성이나 스레드별 진행 보장을 뜻하지 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 T1 읽기부터 원자 비교까지 진행합니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| T1 읽기 시작 값을 예상값으로 보관한다. |
|
|
| T2 첫 변경 중간 상태로 바꾼다. |
|
|
| T2 다음 변경 값은 A로 돌아오지만 표식은 다르다. |
|
|
| 원자 비교 현재 값과 보관한 예상값을 비교한다. |
|
|
단계 개념도처럼 T1이 값 A와 함께 버전 1을 기억해 두었다면, T2가 A를 B로 바꾸었다가 다시 A로 돌려놓아도 버전은 3이 되어 있으므로 값과 버전을 함께 비교하는 CAS는 실패합니다. 다음 코드는 스택의 맨 위를 CAS로 바꾸는 상황에서 이 차이를 보여 줍니다. 스택은 맨 위부터 A, B, C로 쌓여 있습니다.
nodes = {} # 노드 -> 아래 노드
top = {} # 스택 맨 위 노드와 버전
def reset():
nodes.update({"A": "B", "B": "C", "C": None})
top.update({"node": "A", "version": 0})
def pop(): # 다른 스레드가 방해 없이 꺼낸다
node = top["node"]
top["node"], top["version"] = nodes[node], top["version"] + 1
return node
def push(node):
nodes[node] = top["node"]
top["node"], top["version"] = node, top["version"] + 1
def cas(expected_node, expected_version, new_node, use_version):
same = top["node"] == expected_node
if use_version:
same = same and top["version"] == expected_version
if same:
top["node"], top["version"] = new_node, top["version"] + 1
return same
for use_version in (False, True):
reset()
seen_node, seen_version = top["node"], top["version"] # T1: 맨 위 A를 읽는다
seen_next = nodes[seen_node] # T1: A 아래는 B
pop(); pop(); push("A") # T2: A, B를 꺼내고 A만 다시 넣는다
ok = cas(seen_node, seen_version, seen_next, use_version) # T1: 맨 위가 아직 A면 B로 바꾼다
label = "값과 버전 비교" if use_version else "값만 비교"
print(f"{label}: CAS {'성공' if ok else '실패'}, 맨 위 {top['node']}")값만 비교: CAS 성공, 맨 위 B
값과 버전 비교: CAS 실패, 맨 위 AT1은 맨 위의 A를 꺼내려고, A와 그 아래 노드 B를 읽어 둡니다. 그 사이 T2가 A와 B를 꺼내고 A만 다시 넣으면, 스택은 A, C가 됩니다. T1이 "맨 위가 아직 A라면 B로 바꿔라"라는 CAS를 실행하면, 값만 비교할 때는 맨 위가 A이므로 성공하고 맨 위가 B가 됩니다. 그런데 B는 이미 T2가 꺼낸 노드이므로, 꺼낸 노드가 스택에 되살아난 것입니다. C 같은 언어라면 T2가 B의 메모리를 이미 해제했을 수 있어 더 심각한 문제가 됩니다. 값과 함께 바뀔 때마다 하나씩 늘어나는 버전을 비교하면, 맨 위가 A이긴 해도 버전이 다르므로 CAS가 실패하고 T1은 다시 읽어 시도합니다. Java의 AtomicStampedReference가 이 방식을 씁니다. 다만 버전 번호도 언젠가는 한 바퀴 돌아 같은 값이 될 수 있고, 꺼낸 노드의 메모리를 언제 해제해도 안전한지는 별도로 풀어야 하는 문제입니다.
더 깊이: 읽기와 쓰기 한 쌍으로 만드는 원자적 갱신
원자적 갱신을 명령 한 쌍으로 하는 방식을 LL/SC(load-linked/store-conditional)라고 합니다. RISC-V의 lr.w는 값을 읽으면서 그 주소를 포함하는 예약 집합(reservation set)을 등록하고, 짝이 되는 sc.w는 그 예약이 아직 유효할 때만 값을 쓰고 성공을 알립니다. 그 사이에 다른 코어가 같은 예약 집합에 쓰면 sc.w가 반드시 실패하므로, 소프트웨어는 다시 읽어서 다시 시도합니다. ARM의 LDXR과 STXR도 같은 생각으로 만든 명령 쌍입니다.
RISC-V 규격의 LR/SC 장은 CAS 대신 이 방식을 고른 이유를 적어 두었습니다. CAS는 값이 바뀌었는지만 확인하므로 앞에서 본 ABA 문제를 겪지만, LR/SC는 그 주소에 대한 모든 쓰기를 감시하므로 값이 A로 돌아와도 실패합니다. 또 CAS는 계산에 쓸 값을 읽을 때 한 번, CAS 안에서 값이 그대로인지 확인할 때 한 번 해서 읽기가 두 번 들지만, LR/SC는 한 번이면 됩니다. 대신 여러 코어가 서로의 예약을 계속 깨뜨려 아무도 진척하지 못하는 라이브락(livelock)의 위험이 있어서, 규격은 16개 이하의 명령으로 이루어지고 그 사이에 메모리 접근이나 뒤로 가는 분기를 두지 않는 제한된 루프라면, 다른 쪽이 그 예약 집합에 일반 쓰기를 계속하지 않는 한 적어도 한 코어는 결국 루프를 빠져나간다고 정해 둡니다.
이 방식에서는 값이 그대로여도 쓰기가 실패할 수 있습니다. 예를 들어 RISC-V 규격은 선점형 컨텍스트 스위칭 때 예약을 강제로 무효화하라고 권하므로, 그 사이에 아무도 값을 바꾸지 않았어도 다시 실행된 sc.w는 실패합니다. C++ 표준이 compare_exchange_weak의 거짓 실패(spurious failure)를 허용하는 것도 이 때문이며, 표준은 그 덕분에 LL/SC를 쓰는 기계에서도 CAS를 구현할 수 있다고 설명합니다. 그래서 어차피 루프 안에서 쓸 때는 약한 버전이 일부 플랫폼에서 더 빠르고, 루프가 필요 없는 곳에서는 강한 버전이 낫습니다.
스핀락: 풀릴 때까지 돌며 기다리기
TAS로 만들 수 있는 가장 단순한 잠금이 스핀락(spinlock)입니다. 아래 코드에서 잠금의 상태는 bool 하나뿐입니다. spin_lock은 앞의 test_and_set을 거짓이 나올 때까지 되풀이하고, spin_unlock은 그 값을 거짓으로 되돌리기만 합니다.
typedef struct {
bool locked;
} spinlock_t;
void spin_lock(spinlock_t *lock)
{
while (test_and_set(&lock->locked))
; /* 잠금이 풀릴 때까지 반복한다. */
}
void spin_unlock(spinlock_t *lock)
{
lock->locked = false;
}spin_lock은 TAS가 거짓을 돌려줄 때까지, 곧 자기가 잠금을 얻을 때까지 루프를 돕니다. 이렇게 CPU를 쓰며 기다리는 것을 바쁜 대기(busy waiting) 또는 스피닝(spinning)이라고 합니다. A가 잠금을 쥐고 있는 동안 B의 TAS는 계속 참을 돌려받으므로 B는 들어가지 못하고, A가 spin_unlock으로 거짓을 쓴 뒤에야 B의 다음 TAS가 거짓을 돌려받아 B가 들어갑니다. 실제 구현에서는 해제하는 쓰기와 다음 스레드의 획득 사이에 임계 영역 안의 쓰기가 모두 보이도록 메모리 순서도 맞추어야 하는데, 언어의 원자 연산이나 커널의 스핀락 함수가 이를 처리해 줍니다.
스핀락이 늘 나쁜 것은 아닙니다. 멀티코어에서 잠금을 쥔 스레드가 다른 코어에서 실행 중이고 임계 영역이 수십 나노초 정도로 아주 짧다면, 잠시 돌며 기다리는 편이 스레드를 재웠다가 깨우는 것보다 빠를 수 있습니다. 재우고 깨우려면 커널에 들어가 컨텍스트 스위칭까지 해야 하기 때문입니다. 그래서 Linux 커널은 짧은 임계 영역에 스핀락을 많이 쓰며, 특히 인터럽트 처리 코드처럼 잠들 수 없는 곳에서는 스핀락밖에 쓸 수 없습니다.
반대로 잠금을 쥔 스레드가 실행되지 못하는 상황에서는 스핀락이 낭비가 됩니다. CPU가 하나뿐인 컴퓨터의 사용자 프로그램에서, 잠금을 쥔 스레드가 선점된 뒤 다른 스레드가 같은 잠금을 돌며 기다린다고 해 봅시다. 잠금을 풀어 줄 스레드는 CPU를 받아야 실행될 수 있는데, 돌며 기다리는 스레드가 CPU를 차지하고 있으므로, 타이머 인터럽트로 선점될 때까지 기다림은 아무 소용이 없습니다. 커널의 스핀락이 잠금을 쥐는 동안 선점을 막는 것도 이런 일을 피하기 위해서입니다.
뮤텍스: 기다리는 동안 잠들기
뮤텍스(mutex)는 상호 배제(mutual exclusion)를 줄인 이름으로, 잠금을 얻지 못하면 돌며 기다리는 대신 스레드를 잠재워 CPU를 다른 스레드에 넘겨줍니다. 잠금이 풀리면 기다리던 스레드를 깨워 다시 잠금을 얻게 합니다. 기다리는 동안 CPU를 쓰지 않으므로 임계 영역이 길거나 기다림이 길 때 알맞습니다.
pthread의 뮤텍스
#include <pthread.h>
#include <stdio.h>
static int counter = 0;
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static void *increment(void *arg)
{
(void) arg;
for (int i = 0; i < 1000000; i++) {
pthread_mutex_lock(&lock); /* 잠금을 얻을 때까지 기다린다. */
counter++; /* 임계 영역 */
pthread_mutex_unlock(&lock);
}
return NULL;
}
int main(void)
{
pthread_t t1, t2;
pthread_create(&t1, NULL, increment, NULL);
pthread_create(&t2, NULL, increment, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("counter: %d\n", counter);
return 0;
}두 스레드가 각각 100만 번 더하고, 모든 증가가 같은 뮤텍스로 보호되므로 마지막 줄은 2,000,000을 출력합니다. 어느 스레드가 먼저 끝나는지는 결과와 상관없습니다. main이 counter를 읽는 것은 두 스레드를 모두 join한 뒤이므로, 두 스레드가 쓴 값이 모두 보입니다. 이 예제는 짧게 쓰려고 반환값 검사를 생략했는데, 실제 코드에서는 pthread_create와 잠금 함수의 반환값도 확인해야 합니다.
pthread_mutex_lock()은 뮤텍스를 얻을 때까지 기다립니다. 잠깐 돌며 기다리다가 잠들지, 바로 잠들지는 구현에 따라 다릅니다. pthread_mutex_unlock()은 뮤텍스를 풀어 기다리던 스레드가 얻을 수 있게 하는데, 기다린 순서대로 얻는다는 보장은 없습니다.
Python의 threading.Lock
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100_000):
with lock: # 들어갈 때 acquire(), 나올 때 release()
counter += 1
threads = [threading.Thread(target=increment) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
print("counter:", counter)counter: 200000with lock: 블록은 들어갈 때 lock.acquire()를 부르고, 블록을 벗어날 때 예외가 나더라도 lock.release()를 부릅니다. C에서는 함수 중간에 오류로 반환하는 경로마다 unlock을 빠뜨리지 않도록 직접 챙겨야 하는데, with 문은 이 실수를 구조적으로 막아 줍니다.
뮤텍스를 쓸 때 주의할 점
같은 스레드가 이미 쥔 뮤텍스를 다시 잠그면 어떻게 될까요? POSIX 뮤텍스는 종류에 따라 다릅니다. PTHREAD_MUTEX_NORMAL은 영원히 기다리는 교착 상태에 빠지고, PTHREAD_MUTEX_ERRORCHECK는 오류를 돌려주며, PTHREAD_MUTEX_RECURSIVE는 잠근 횟수를 세어 같은 횟수만큼 풀어야 해제됩니다. 기본 종류는 동작이 정의되어 있지 않으므로 기대지 않아야 합니다. Python의 Lock도 다시 잠그면 영원히 기다리며, 같은 스레드가 여러 번 잠가야 한다면 RLock을 씁니다.
잠금을 쥐는 구간은 짧을수록 좋습니다. 잠금 안에서 파일을 읽거나 네트워크 응답을 기다리면, 그 시간 내내 다른 스레드들이 줄을 서서 기다립니다. 공유 데이터를 고치는 부분만 잠금 안에 두고, 오래 걸리는 일은 잠금 밖에서 합니다.
뮤텍스를 여러 개 쓸 때는 언제나 같은 순서로 잠가야 합니다. 한 스레드는 A 다음 B를, 다른 스레드는 B 다음 A를 잠그면, 각자 하나씩 쥔 채 상대의 것을 기다리는 교착 상태에 빠질 수 있습니다. 이 문제는 7장에서 자세히 다룹니다.
스핀락과 뮤텍스 비교
| 도구 | 기다리는 방법 | 알맞은 곳 | 주의할 점 |
|---|---|---|---|
| 스핀락 | CPU를 쓰며 반복 확인 | 아주 짧은 임계 영역, 잠들 수 없는 커널 코드 | 잠금을 쥔 스레드가 실행되지 못하면 CPU 낭비 |
| 뮤텍스 | 잠들었다가 깨어남 | 사용자 프로그램, 길어질 수 있는 임계 영역 | 경쟁이 있을 때 재우고 깨우는 비용 |
Linux에서 실시간 응답을 위한 PREEMPT_RT 설정을 켜면, 커널의 일반 스핀락도 잠들 수 있는 잠금으로 바뀌고 정말로 돌며 기다려야 하는 곳에만 raw_spinlock_t를 씁니다. 같은 이름의 잠금이라도 설정에 따라 동작이 다를 수 있다는 예입니다.
Futex: 경쟁이 없을 때는 커널에 가지 않는다
뮤텍스가 기다리는 스레드를 재우려면 커널의 도움이 필요합니다. 그런데 잠금을 얻으려 할 때마다 시스템 콜을 부른다면, 경쟁이 전혀 없을 때도 매번 커널에 들어가는 비용을 치러야 합니다. 실제 프로그램에서는 대부분의 잠금이 경쟁 없이 바로 얻어지므로 이것은 큰 낭비입니다.
Linux의 futex(fast userspace mutex)는 이 문제를 풉니다. 잠금 상태는 사용자 공간의 정수 하나로 두고, 경쟁이 없으면 CAS 같은 원자적 명령어만으로 사용자 공간에서 잠금을 얻고 풉니다. 커널에 들어가는 것은 잠금을 얻지 못해 잠들어야 할 때와, 잠든 스레드를 깨워야 할 때뿐입니다. 잠들 때 쓰는 FUTEX_WAIT는 "이 정수가 아직 이 값이라면 재워 달라"는 요청인데, 커널이 값을 확인하는 것과 재우는 것을 한 번에 처리하므로, 확인한 직후 다른 스레드가 잠금을 풀고 깨우기 신호를 보내 그 신호를 놓치는 일이 생기지 않습니다. 깨어난 스레드가 곧바로 잠금을 얻는 것은 아니므로, 깨어나면 다시 원자적 명령어로 잠금을 시도합니다.
futex는 그 자체로 잠금이 아니라 잠금을 만들기 위한 커널의 재우기와 깨우기 기능입니다. Linux의 glibc는 pthread 뮤텍스와 조건 변수를 futex로 구현하며, 잠들기 전에 잠깐 돌며 기다릴지는 뮤텍스 종류와 구현에 따라 정합니다. 자세한 내용은 futex(2)에 있습니다.
정리
- 인터럽트를 끄는 방법은 CPU 하나에만 효과가 있고 특권이 필요하므로, 커널 안의 아주 짧은 구간에서만 씁니다.
- CPU의 원자적 명령어는 메모리 한 곳의 읽기와 쓰기를 쪼개지지 않게 처리합니다. Test-and-Set은 값을 읽으며 참을 쓰고, Compare-and-Swap은 현재 값이 기대한 값일 때만 바꿉니다.
- CAS 루프는 읽고 계산한 뒤 값이 그대로일 때만 바꾸고, 실패하면 다시 읽어 시도합니다. 값이 A에서 B를 거쳐 A로 돌아오는 ABA 문제는 버전을 함께 비교해 막습니다.
- 스핀락은 CPU를 쓰며 기다리므로 아주 짧은 임계 영역이나 잠들 수 없는 커널 코드에 알맞고, 뮤텍스는 잠들어 기다리므로 사용자 프로그램의 일반적인 잠금에 알맞습니다.
- 뮤텍스는 다시 잠글 때의 동작, 잠금 구간의 길이, 여러 잠금의 순서를 주의해서 써야 합니다.
- Linux의 futex는 경쟁이 없을 때는 사용자 공간의 원자적 명령어로 끝내고, 잠들고 깨울 때만 커널을 부릅니다.
연습 문제
1. 8에서 시작한 CAS 증가
공유 값이 8입니다. 스레드 A가 8을 읽어 9를 계산했는데, A가 CAS를 실행하기 전에 스레드 B가 값을 9로 바꾸었습니다. A의 첫 CAS는 어떻게 되며, 다른 변경이 없다면 재시도 뒤의 최종 값은 얼마인가요? A가 재시도할 때 처음 계산한 9를 그대로 쓰면 어떻게 될까요?
해설 보기
A의 첫 CAS는 기대한 8과 현재 값 9가 다르므로 실패하고, 값은 9로 남습니다. A가 다시 읽으면 9를 얻어 10을 계산하고, 기대값 9로 CAS를 실행해 성공하므로 최종 값은 10입니다. 두 증가가 모두 반영되었습니다. 재시도할 때 처음 계산한 9를 그대로 쓰면, 다시 읽은 9를 기대값으로 9를 쓰게 되어 CAS는 성공하지만 값은 9 그대로이므로 A의 증가가 사라집니다. CAS 루프에서는 실패할 때마다 현재 값을 다시 읽고 새 값을 다시 계산해야 합니다.
2. 버전을 붙인 CAS
스레드 T1이 맨 위의 값과 버전으로 (A, 7)을 읽어 두었습니다. 그 뒤 T2가 맨 위를 (B, 8)로, 이어서 (A, 9)로 바꾸었습니다. T1이 값만 비교하는 CAS를 쓸 때와, 값과 버전을 한꺼번에 비교하는 CAS를 쓸 때의 결과를 각각 설명하세요.
해설 보기
값만 비교하면 현재 값 A가 기대한 A와 같으므로 CAS가 성공합니다. T1은 그 사이에 맨 위가 B로 바뀌었다가 돌아왔다는 사실을 모른 채, 처음 읽었을 때의 정보로 스택을 고치게 됩니다. 값과 버전을 함께 비교하면 현재 (A, 9)와 기대한 (A, 7)이 다르므로 CAS가 실패하고, T1은 다시 읽어 시도합니다. 이렇게 하려면 값과 버전을 한 번의 원자적 연산으로 비교해야 하며, 두 칸을 따로 읽어 비교해서는 그 사이의 변경을 놓칠 수 있습니다. 버전 번호가 한 바퀴 돌아 같은 값이 되는 경우와, 꺼낸 노드의 메모리를 언제 해제할지는 이 방법으로 해결되지 않는 별도의 문제입니다.
3. 스핀락과 뮤텍스 고르기
다음 두 상황에서 스핀락과 뮤텍스 가운데 무엇이 알맞은지 이유와 함께 답하세요. 첫째, 커널의 네트워크 드라이버가 인터럽트 처리 코드에서 수신 큐에 패킷을 넣습니다. 둘째, 웹 서버의 여러 스레드가 공유 캐시를 갱신하는데, 갱신할 때 디스크에서 데이터를 읽어 오기도 합니다.
해설 보기
첫째 상황에는 스핀락이 알맞습니다. 인터럽트 처리 코드는 잠들 수 없으므로, 잠들어 기다리는 뮤텍스는 쓸 수 없습니다. 큐에 패킷을 넣는 작업은 아주 짧으므로 다른 코어가 잠금을 쥐고 있어도 금방 풀립니다. 다만 큐에서 패킷을 꺼내는 쪽처럼 인터럽트 밖에서 같은 잠금을 쓰는 코드는 인터럽트를 끄는 스핀락 함수(spin_lock_irqsave())를 써야 합니다. 그 코드가 잠금을 쥔 사이에 같은 CPU에서 인터럽트가 들어와 같은 잠금을 기다리면, 잠금을 쥔 쪽은 인터럽트 처리가 끝나야 이어서 실행되므로 둘 다 멈추기 때문입니다. 둘째 상황에는 뮤텍스가 알맞습니다. 사용자 프로그램이고, 디스크를 읽는 동안 잠금을 쥐고 있을 수 있으므로 기다림이 길어질 수 있습니다. 이때 스핀락을 쓰면 기다리는 스레드들이 CPU를 태우며 낭비합니다. 더 나아가 디스크 읽기를 잠금 밖으로 빼서, 데이터를 먼저 읽어 온 뒤 잠금 안에서는 캐시만 고치도록 바꾸면 잠금을 쥐는 시간을 크게 줄일 수 있습니다.
다음 글 세마포어에서는 뮤텍스보다 일반적인 동기화 도구인 세마포어로 자원의 개수를 세고, 생산자와 소비자 사이의 순서를 맞추는 방법을 살펴봅니다.