Mutex: 상호배제를 보장하는 락(lock) 기반 동기화 프리미티브
Mutex는 여러 스레드가 공유 자원에 접근할 때 동시에 하나의 스레드만 임계 구역(critical section)에 진입하도록 강제하는 동기화 도구다. 락을 획득한 스레드만 자원을 사용할 수 있고, 나머지 스레드는 락이 반환될 때까지 대기한다. 세마포어(semaphore)와 혼동하기 쉽지만 소유권(ownership) 개념이 있다는 점에서 다르다.
1. 개념
Mutex(Mutual Exclusion, 상호배제)는 여러 스레드(thread)가 공유 자원에 접근할 때 단 하나의 스레드만 해당 자원을 독점적으로 사용하도록 보장하는 동기화(synchronization) 메커니즘이다.
핵심은 락(lock)이라는 이진 상태(잠김/풀림)를 가진 객체다. 스레드는 임계 구역(critical section)에 진입하기 전에 락을 획득(acquire, lock)하고, 작업이 끝나면 락을 해제(release, unlock)한다. 락을 이미 다른 스레드가 가지고 있다면 나머지 스레드는 블로킹(blocking) 상태로 대기한다.
Mutex는 세마포어(semaphore)와 자주 비교된다. 세마포어는 카운터 기반으로 N개의 스레드가 동시에 접근을 허용할 수 있지만, Mutex는 카운터가 1로 고정되어 있고 소유권(ownership) 개념이 있다. 즉 락을 획득한 스레드만 해제할 수 있다. 세마포어는 이 소유권 제약이 없어서 다른 스레드가 대신 signal을 보낼 수도 있다.
2. 왜 사용하는가
여러 스레드가 같은 메모리 영역(변수, 자료구조, 파일 등)을 동시에 읽고 쓸 때 경쟁 상태(race condition)가 발생한다. 예를 들어 count += 1 같은 단순한 연산도 내부적으로는 읽기 → 더하기 → 쓰기 세 단계로 나뉘는데, 이 사이에 다른 스레드가 끼어들면 값이 유실될 수 있다.
Mutex는 이런 임계 구역을 원자적(atomic)으로 보이게 만들어 데이터 무결성(data integrity)을 보장한다. 락이 없으면 한 번에 하나만 실행되어야 하는 코드가 여러 스레드에 의해 인터리빙(interleaving)되면서 예측 불가능한 결과를 낳는다.
3. 동작 원리
Mutex 구현은 크게 두 계층으로 나뉜다.
- 사용자 공간(user space)에서 락 획득 시도: CPU가 제공하는 원자적 명령어(atomic instruction), 예를 들어
test-and-set이나compare-and-swap(CAS)을 이용해 락 상태를 확인하고 바꾼다. 이 연산은 하드웨어 수준에서 원자성을 보장하므로 중간에 다른 스레드가 끼어들 수 없다. - 락 획득 실패 시 커널 개입: 락이 이미 잠겨 있으면 스레드는 무한정 CPU를 소모하며 기다리는 대신(스핀락, spinlock), 운영체제 커널에게 요청해 대기 큐(wait queue)에 들어가고 스케줄러가 해당 스레드를 잠재운다(sleep). 락이 풀리면 커널이 대기 중인 스레드 중 하나를 깨워(wake up) 다시 스케줄링한다.
리눅스의 futex(fast userspace mutex)가 대표적인 하이브리드 방식이다. 락 경합(contention)이 없을 때는 사용자 공간에서 원자적 연산만으로 빠르게 처리하고, 경합이 발생했을 때만 커널 시스템 콜을 호출해서 컨텍스트 스위칭(context switching) 비용을 최소화한다.
4. 코드 예제
import threading
import time
counter = 0
lock = threading.Lock()
def increment_without_lock():
global counter
for _ in range(100000):
counter += 1 # 원자적이지 않은 연산 -> race condition 발생 가능
def increment_with_lock():
global counter
for _ in range(100000):
with lock: # acquire -> critical section -> release
counter += 1
def run_test(target):
global counter
counter = 0
threads = [threading.Thread(target=target) for _ in range(4)]
start = time.time()
for t in threads:
t.start()
for t in threads:
t.join()
elapsed = time.time() - start
print(f"{target.__name__}: counter={counter}, elapsed={elapsed:.4f}s")
if __name__ == "__main__":
run_test(increment_without_lock) # 결과값이 400000보다 작게 나올 수 있음
run_test(increment_with_lock) # 항상 정확히 400000# 데드락(deadlock) 예시와 방지 방법
import threading
lock_a = threading.Lock()
lock_b = threading.Lock()
def task_1():
with lock_a:
with lock_b: # lock_a -> lock_b 순서
print("task_1 완료")
def task_2():
with lock_b:
with lock_a: # lock_b -> lock_a 순서 (역순이라 데드락 위험)
print("task_2 완료")
# 해결책: 모든 스레드가 동일한 순서로 락을 획득하도록 강제한다
def task_2_fixed():
with lock_a: # task_1과 동일한 순서로 통일
with lock_b:
print("task_2_fixed 완료")5. 시간 복잡도 또는 성능 특성
Mutex 자체의 락 획득/해제 연산은 O(1)이다. 다만 실질적인 비용은 경합(contention) 여부에 따라 크게 달라진다.
- 경합이 없는 경우: 원자적 CPU 명령어(CAS 등) 한 번으로 처리되므로 수 나노초(ns) 수준의 오버헤드만 발생한다.
- 경합이 있는 경우: 대기 중인 스레드는 커널 대기 큐에 들어가고, 락이 풀리면 컨텍스트 스위칭이 발생한다. 컨텍스트 스위칭 비용은 환경마다 다르지만 일반적으로 수 마이크로초(μs) 수준으로, 원자적 연산보다 수백~수천 배 느리다.
- 스핀락 vs Mutex: 스핀락은 대기 시간이 매우 짧을 것으로 예상될 때(예: 커널 내부의 짧은 임계 구역) CPU를 계속 점유하며 기다리는 방식이다. 컨텍스트 스위칭 비용을 피할 수 있지만, 대기 시간이 길어지면 CPU 자원을 낭비한다. 일반 애플리케이션 수준에서는 대부분 Mutex(sleep 방식)를 사용한다.
6. 실무 사용 사례
- 애플리케이션 코드: 멀티스레드 환경에서 공유 캐시, 커넥션 풀(connection pool), 전역 카운터 등을 보호할 때 언어별 Mutex API(
threading.Lockin Python,std::mutexin C++,synchronizedin Java)를 사용한다. - 데이터베이스: PostgreSQL 같은 RDBMS는 내부적으로 행 단위 락(row lock), 테이블 락을 사용해 동시 트랜잭션의 데이터 일관성을 보장한다.
-- 특정 행에 대해 명시적으로 락을 걸어 동시 수정 방지 (Pessimistic Locking)
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- 다른 트랜잭션은 이 행 수정 시 대기
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;- 분산 시스템: 단일 프로세스 내 Mutex로는 여러 서버 인스턴스 간 동시성 문제를 해결할 수 없다. 이때는 Redis의
SETNX나 Redlock 알고리즘 같은 분산 락(distributed lock)을 사용한다.
7. 주의할 점
- 데드락(deadlock): 두 개 이상의 스레드가 서로 상대방이 가진 락을 기다리며 영원히 멈추는 상황이다. 락을 획득하는 순서를 항상 일관되게 유지하거나, 타임아웃(timeout)을 설정해 방지한다.
- 락 범위 최소화: 임계 구역이 너무 넓으면 병렬성이 떨어지고 성능 저하가 발생한다. 실제로 보호가 필요한 최소한의 코드만 락으로 감싸야 한다.
- 재진입(reentrant) 여부 확인: 일반 Mutex는 같은 스레드가 이미 획득한 락을 다시 획득하려 하면 자기 자신과 데드락에 빠진다. 재귀 함수 등에서는 재진입 가능한 락(
RLockin Python)을 사용해야 한다. - 우선순위 역전(priority inversion): 낮은 우선순위 스레드가 락을 쥐고 있으면 높은 우선순위 스레드가 대기해야 하는 상황이 생길 수 있다. 실시간 시스템에서는 우선순위 상속(priority inheritance) 프로토콜로 완화한다.
- 락 해제 누락: 예외가 발생했을 때 락을 해제하지 않으면 다른 모든 스레드가 영구히 대기하게 된다.
with구문(Python)이나 RAII 패턴(C++)처럼 스코프를 벗어날 때 자동으로 해제되는 방식을 사용하는 것이 안전하다.
8. 핵심 정리
Mutex는 공유 자원에 대한 접근을 한 번에 하나의 스레드로 제한해 데이터 무결성을 보장하는 동기화 도구다. 내부적으로는 원자적 CPU 명령어와 커널 대기 큐를 조합해 구현되며, 경합이 없을 때는 매우 저렴하지만 경합이 발생하면 컨텍스트 스위칭 비용이 든다. 세마포어와 달리 소유권 개념이 있어 락을 획득한 스레드만 해제할 수 있다는 점이 특징이며, 데드락과 락 범위 관리가 실무에서 가장 흔한 함정이다.