Lock의 critical section은 최소한으로 작게 유지해야 한다
·수정 2026.05.23·수정 2회
요약
- Lock으로 보호하는 critical section은 정말 보호가 필요한 코드만 포함해야 한다.
- Lock 범위가 너무 넓으면 정확성은 유지되지만 사실상 직렬 실행이 되어 멀티스레드의 의미가 사라진다.
- 원칙: "공유 변수 접근만 lock 안에, 계산/IO는 lock 밖에."
본문
같은 정확성, 다른 동시성
100개 스레드가 각각 1000번 카운터를 증가시키는 코드. 두 버전 모두 결과는 100,000으로 정확하지만 동시성이 완전히 다르다.
바깥에 lock — coarse-grained
def increment():
with lock: # 1000번 시작 전에 잠금
for _ in range(1000):
tmp = counter
counter = tmp + 1
# 1000번 끝난 후 해제
한 스레드가 lock을 잡으면 1000번 다 끝낼 때까지 다른 스레드는 모두 대기. 100개 스레드가 사실상 한 줄로 순서대로 실행됨.
안쪽에 lock — fine-grained
def increment():
for _ in range(1000):
with lock: # 매 반복마다 잠금/해제
tmp = counter
counter = tmp + 1
매 1번 증가 단위로만 lock을 잡음. 다른 스레드가 사이사이 끼어들어 자기 차례에 1번 증가 가능.
비교표
| 바깥에 lock | 안쪽에 lock | |
|---|---|---|
| 정확성 | ✓ | ✓ |
| 동시성 | 사실상 없음 (직렬화) | 짧은 critical section만 직렬화 |
| Lock 비용 | 1번만 acquire/release | N번 acquire/release |
| 다른 스레드의 다른 작업 | 다 끝날 때까지 블로킹 | 사이사이 진행 가능 |
핵심 원칙
Critical section은 최소한으로 작게 유지하라.
- 진짜로 보호해야 하는 코드(공유 변수 접근)만 lock 안에 넣기
- 계산, IO, 로깅 등 lock이 필요 없는 코드는 lock 밖으로 빼기
- Lock 잡는 시간이 길수록 다른 스레드들이 노는 시간이 길어짐
실전에서 차이가 큰 경우
위 예시는 작업이 너무 짧아서 두 버전의 시간 차이가 작다. 하지만 critical section에 IO나 무거운 연산이 섞여있으면 lock 위치에 따라 성능이 수십 배 차이 난다.
# 나쁜 예 — IO가 lock 안에
with lock:
data = fetch_from_api() # 수백ms 동안 다른 스레드 모두 대기
cache[key] = data
# 좋은 예 — IO를 lock 밖으로
data = fetch_from_api() # IO는 동시 실행 가능
with lock:
cache[key] = data # 진짜 짧은 critical section
트레이드오프
극단적으로 잘게 쪼개는 것도 능사는 아니다:
- Lock acquire/release 자체에 약간의 오버헤드 있음
- 너무 잘게 쪼개면 atomicity가 깨질 수 있음 (한 번에 처리되어야 하는 두 변수 변경 등)
→ "한 번에 일관성 있게 처리되어야 하는 단위"가 critical section의 자연스러운 경계.
참고
- 동시성 카운터 업데이트는 Fanout 패턴으로 row lock 경합을 분산한다 — 같은 원리를 DB row lock 레벨에서 적용한 사례