Python fcntl로 파일 잠금(advisory lock)과 바이트 범위 잠금 다루기

여러 프로세스가 같은 파일을 동시에 읽고 쓰면 레이스 컨디션이 생긴다. “파일에서 값을 읽고, 계산하고, 다시 쓴다”는 흔한 패턴도 두 프로세스가 동시에 끼어들면 한쪽의 갱신이 그냥 사라질 수 있다. Python의 fcntl 모듈은 이런 상황을 막는 두 가지 잠금 방식(전체 파일 단위 flock, 바이트 범위 단위 POSIX record lock)을 제공한다. 이 글에서는 레이스 컨디션을 실제로 재현하고, 두 방식으로 각각 막아본다.

flock으로 레이스 컨디션 막기

카운터 파일을 여러 프로세스가 동시에 읽고, 1 증가시켜 다시 쓰는 코드다. 잠금이 없으면 어떻게 되는지부터 본다.

import sys, time

def increment(path, n):
    for _ in range(n):
        with open(path, "r+") as f:
            val = int(f.read().strip())
            time.sleep(0.001)          # 레이스 창을 넓혀 재현 확률을 높임
            f.seek(0)
            f.write(str(val + 1))
            f.truncate()

if __name__ == "__main__":
    increment(sys.argv[1], int(sys.argv[2]))
echo 0 > counter.txt
for i in $(seq 1 10); do python3 worker_nolock.py counter.txt 20 & done
wait
echo "expected: 200, actual: $(cat counter.txt)"
expected: 200, actual: 24

10개 프로세스가 20번씩 증가시켰으니 200이 나와야 하는데 24밖에 안 됐다. 대부분의 갱신이 서로를 덮어쓰며 사라졌다. fcntl.flock()으로 읽기~쓰기 구간 전체를 잠그면 해결된다.

import sys, time, fcntl

def increment(path, n):
    for _ in range(n):
        with open(path, "r+") as f:
            fcntl.flock(f, fcntl.LOCK_EX)
            val = int(f.read().strip())
            time.sleep(0.001)
            f.seek(0)
            f.write(str(val + 1))
            f.truncate()
            fcntl.flock(f, fcntl.LOCK_UN)

if __name__ == "__main__":
    increment(sys.argv[1], int(sys.argv[2]))
expected: 200, actual: 200

LOCK_NB로 즉시 실패시키기

기본 flock은 잠금이 풀릴 때까지 블로킹한다. LOCK_NB를 더하면 이미 잠겨 있을 때 기다리지 않고 즉시 BlockingIOError를 던진다 — “지금 처리 중이면 건너뛰기” 같은 패턴에 쓴다.

import fcntl, os, sys

def log(msg):
    print(msg)
    sys.stdout.flush()

f = open("counter.txt", "r+")
fcntl.flock(f, fcntl.LOCK_EX)
log(f"[parent pid={os.getpid()}] 락 획득")

pid = os.fork()
if pid == 0:
    f2 = open("counter.txt", "r+")
    try:
        fcntl.flock(f2, fcntl.LOCK_EX | fcntl.LOCK_NB)
        log(f"[child pid={os.getpid()}] 락 획득 성공 (있으면 안 됨)")
    except BlockingIOError as e:
        log(f"[child pid={os.getpid()}] 즉시 실패: {e}")
    os._exit(0)
else:
    os.waitpid(pid, 0)
    fcntl.flock(f, fcntl.LOCK_UN)
    log(f"[parent pid={os.getpid()}] 락 반환")
[parent pid=14640] 락 획득
[child pid=14641] 즉시 실패: [Errno 11] Resource temporarily unavailable
[parent pid=14640] 락 반환

fcntl로 바이트 범위만 잠그기

flock은 파일 전체를 잠근다. 파일 하나에 여러 레코드가 들어 있어 서로 다른 구간을 동시에 건드려도 되는 경우엔 fcntl.fcntl(fd, F_SETLK, ...)로 바이트 범위 잠금을 쓴다. 부모가 자식과 별도 프로세스로 나뉘어야 진짜 충돌을 볼 수 있어 fork()로 두 프로세스를 만들었다.

import fcntl, os, struct, time

def log(msg):
    print(msg, flush=True)

def try_lock(path, start, length):
    fd = os.open(path, os.O_RDWR)
    lockdata = struct.pack("hhllhh", fcntl.F_WRLCK, 0, start, length, 0, 0)
    try:
        fcntl.fcntl(fd, fcntl.F_SETLK, lockdata)
        return True
    except OSError:
        return False

with open("range.bin", "wb") as f:
    f.write(b"\0" * 100)

pid = os.fork()
if pid == 0:
    # 자식 A: 0~49바이트 잠그고 2초 보유
    log(f"[A pid={os.getpid()}] 0~49바이트 잠금 -> {try_lock('range.bin', 0, 50)}")
    time.sleep(2)
    os._exit(0)
else:
    time.sleep(0.3)  # A가 먼저 잠그도록 대기
    log(f"[B pid={os.getpid()}] 0~49바이트 잠금 시도(A와 겹침) -> {try_lock('range.bin', 0, 50)}")
    log(f"[B pid={os.getpid()}] 50~99바이트 잠금 시도(겹치지 않음) -> {try_lock('range.bin', 50, 50)}")
    os.waitpid(pid, 0)
[A pid=14686] 0~49바이트 잠금 -> True
[B pid=14685] 0~49바이트 잠금 시도(A와 겹침) -> False
[B pid=14685] 50~99바이트 잠금 시도(겹치지 않음) -> True

겹치는 구간은 막히고, 겹치지 않는 구간은 동시에 잠글 수 있다는 게 그대로 확인된다.

주의사항

  • advisory(권고적) 잠금이다. 커널이 강제로 접근을 막는 게 아니라, 잠금을 확인하는 프로세스끼리만 서로 존중한다. 잠금 로직 없이 그냥 write()하는 프로세스가 하나라도 있으면 아무 의미가 없다.
  • 같은 프로세스 안에서는 POSIX record lock끼리 충돌하지 않는다. 위 바이트 범위 예제를 fork() 없이 같은 프로세스에서 서로 다른 파일 디스크립터로 시도하면, 겹치는 구간도 그냥 잠겨버린다 — 실제로 그렇게 짰다가 “왜 안 막히지”로 한 번 헤맸다. fcntl 락은 프로세스 단위로 추적되기 때문이다.
  • flockfcntl record lock은 커널 내부에서 서로 다른 메커니즘이다. 한쪽으로 잠근 걸 다른 쪽 API로는 감지하지 못하니, 같은 파일에 두 방식을 섞어 쓰지 않는다.
  • NFS 같은 네트워크 파일시스템에서는 잠금 신뢰성이 로컬 파일시스템과 다르게 동작할 수 있다(문서 기준 사실이며, 이 글에서 NFS 환경으로 직접 검증하지는 않았다). 네트워크 스토리지 위라면 별도로 확인이 필요하다.

마무리

레이스 컨디션은 실제로 재현해보지 않으면 “그럴 수도 있겠다” 정도로 넘어가기 쉽다. 10개 프로세스, 20번씩 증가라는 단순한 조건에서도 200 중 176번의 갱신이 사라졌다는 걸 직접 보면 잠금이 왜 필요한지 체감이 다르다. 파일 전체를 잠글지, 구간만 잠글지는 워크로드에 맞춰 고르면 되고, 어느 쪽이든 advisory lock이라 잠금을 지키지 않는 코드 경로가 하나라도 있으면 무력화된다는 점만 기억하면 된다.

참고

답글 남기기