공유 자원을 한 번에 하나만 건드리게 하는 장치들.
왜 필요한가 — counter++의 비밀
counter++ 는 기계어로 세 단계다
- ① 메모리 → 레지스터로 읽는다
- ② 레지스터를 1 증가시킨다
- ③ 레지스터 → 메모리로 쓴다
두 스레드가 ①을 동시에 하면
- 둘 다 같은 값을 읽고 같은 값을 쓴다 → 한 번의 증가가 사라진다
이 '중간에 끼어들 수 있는 구간' 이 임계 구역이다
자물쇠들의 차이
뮤텍스 한 번에 하나. 못 얻으면 '잠들었다가' 깨어난다
→ 대기 중 CPU 를 쓰지 않는다
→ 잠들고 깨는 데 컨텍스트 스위치 비용이 든다
→ 소유자 개념이 있다 (잠근 스레드만 풀 수 있다)
세마포어 N 개까지 허용하는 카운터
→ 자원 풀 · 생산자-소비자에 쓴다
→ 소유자 개념이 없다 (다른 스레드가 풀어도 된다 → 신호 전달에 유용)
→ N=1 이면 뮤텍스와 비슷하지만 소유자가 없다는 차이가 남는다
스핀락 못 얻으면 '계속 확인하며 돈다' (busy-wait)
→ 대기 시간이 매우 짧을 때만 유리하다
→ 단일 코어에서는 절대 쓰면 안 된다 (락 쥔 쪽이 못 도는데 CPU 를 태운다)
→ 커널 내부의 짧은 구간에 주로 쓰인다
원자 연산 락 없이 하드웨어 명령으로 (CAS · fetch-and-add)
→ 가장 빠르지만 단순한 연산에만 쓸 수 있다
무엇을 고르나
- 대기가 길다 (I/O · 복잡한 처리) — → 뮤텍스
- 대기가 매우 짧다 (몇 줄) — → 스핀락 (멀티코어에서만)
- 단순 증감·교체 — → 원자 연산
- 자원 개수 제한 · 신호 전달 — → 세마포어
가시성이라는 별도의 문제
락은 '동시 접근' 을 막는다. 그런데 문제가 하나 더 있다
각 코어에는 자기 캐시가 있다
- 한 코어가 바꾼 값이 다른 코어에 즉시 안 보일 수 있다
- 컴파일러·CPU 가 명령 순서를 바꾸기도 한다 (재정렬)
메모리 배리어가 이를 막는다
- 락의 획득·해제에는 배리어가 포함돼 있다
- 그래서 락을 제대로 쓰면 가시성도 함께 해결된다
락을 쓸 때의 원칙
- 범위를 최대한 좁게 (락 안에서 I/O 를 하지 않는다)
- 항상 같은 순서로 잠근다 (데드락 예방)
- 락을 쥔 채 다른 락을 요구하지 않는다
- 예외가 나도 반드시 풀리게 한다
면접 함정
- ❌ "세마포어(N=1) = 뮤텍스" → 소유자 개념이 없어 다른 스레드가 풀 수 있다.
- ❌ "스핀락이 뮤텍스보다 빠르다" → 대기가 길면 CPU만 태운다.
락 경합 측정
perf lock record ./app && perf lock report
perf stat -e 'syscalls:sys_enter_futex' ./app # 락 대기가 커널로 내려간 횟수
리눅스의 뮤텍스는 futex 위에 구현돼 있다
경합이 없으면 → 유저 공간에서 원자 연산만으로 끝난다 (시스템 콜 없음) 경합이 있으면 → futex 시스템 콜로 커널에 잠들기를 요청한다
"경합이 없으면 락은 거의 공짜" 라는 말의 근거다
읽기가 압도적일 때
읽기-쓰기 락 (RWLock)
- 읽기끼리는 동시에 허용, 쓰기는 배타적
- 읽기가 많으면 처리량이 오른다
- 대신 쓰기 기아가 생길 수 있고, 구현이 복잡해 경합이 낮으면 일반 뮤텍스가 낫다
RCU (Read-Copy-Update)
- 읽기는 락 없이, 쓰기는 새 사본을 만들어 교체
- 리눅스 커널이 널리 쓴다. 읽기 비용이 사실상 0
조건 변수
"조건이 만족될 때까지 기다린다" 를 표현한다
- 뮤텍스와 짝으로 쓴다 (락을 놓고 잠들었다가, 깨어나면 다시 잡는다)
- 깨어난 뒤 조건을 '반드시 다시 확인' 해야 한다 (spurious wakeup)
- while (!condition) wait(cv, mutex); ← if 가 아니라 while