Zettelkasten

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의 자연스러운 경계.

참고