본문 바로가기

Python

Python: threading.Lock VS threading.RLock

728x90
반응형

병렬 처리를 위한 threading 사용시, DB 접근이 필요한 로직이 있을 경우,

데이터 무결성 등을 위해 DB 접근이 동시에 되는 것을 막도록 Lock을 걸어야 한다.

 

근데 이때, Lock을 건 동일한 프로세스에서 Lock을 풀기 전에 다시 DB에 접근해야 하는 경우가 있다.

(이러지 않는게 좋겠지만, 비동기 처리를 고려하지 않은 코드를 비동기 코드로 바꾸면서,

  한 프로세스에서 여러 차례의 DB 접근이 있을 때

  Lock, Release, Lock, Relase를 일일히 하지 않고 처리하고 싶을 때 등)

 

이를 가능하게 하는 것이 RLock이다, (Reentrant Lock: 재입장 가능한 잠금)

일반 Lock은 Lock을 걸었냐 안 걸었냐를 통해서만 접근을 막는다면

RLock은 누가 Lock을 걸었는지,

그리고 건 프로세스가 다시 접근한다면 허용해주고, 몇번의 DB 접근을 했는지를 count해서,

최종적으로 Lock을 풀 때는 count가 다시 역으로 0이 되었을 때 풀어지는 원리이다.

 

단, 코드 분석이 복잡해져서 사용하지 않는 것이 권장된다.

 

아래는 Gemini 설명 추가

더보기

파이썬 스레딩: Lock과 RLock의 차이점 🔑

파이썬의 threading 모듈에서 **Lock**과 **RLock**은 모두 여러 스레드가 공유 자원에 동시에 접근하는 것을 막아 **경쟁 상태(Race Condition)**를 방지하는 동기화 도구입니다. 하지만 동작 방식과 사용 사례에 중요한 차이가 있습니다.

결론부터 말하면, RLock은 한 스레드가 동일한 락을 여러 번 획득(acquire)할 수 있도록 허용하는 반면, Lock은 그렇지 않습니다.


## threading.Lock: 가장 기본적인 잠금

Lock은 가장 간단한 형태의 잠금 메커니즘입니다. '잠김(locked)'과 '열림(unlocked)' 두 가지 상태만 존재합니다.

  • 동작 방식
    1. 한 스레드가 lock.acquire()를 호출하여 락을 획득하면, 락은 '잠김' 상태가 됩니다.
    2. 다른 스레드가 acquire()를 호출하면, 락이 release()되어 '열림' 상태가 될 때까지 **블로킹(대기)**합니다.
    3. 락을 획득했던 스레드가 lock.release()를 호출하면, 락은 '열림' 상태가 되고 대기하던 다른 스레드 중 하나가 락을 획득할 수 있게 됩니다.
  • ⚠️ 주의할 점 이미 락을 획득한 스레드가 또다시 acquire()를 호출하면, 자기 자신을 영원히 기다리는 **교착 상태(Deadlock)**에 빠지게 됩니다. release() 코드가 acquire() 뒤에 있기 때문입니다.

### Lock 예제 코드

Python
 
import threading

shared_number = 0
lock = threading.Lock()

def worker():
    global shared_number
    for _ in range(100000):
        # with 문을 사용하면 acquire()와 release()가 자동으로 처리됩니다.
        with lock:
            shared_number += 1

threads = [threading.Thread(target=worker) for _ in range(5)]

for t in threads:
    t.start()

for t in threads:
    t.join()

print(f"최종 결과: {shared_number}") # lock이 없다면 500000이 보장되지 않음

## threading.RLock: 재진입 가능한 잠금 (Re-entrant Lock)

RLock은 재진입 가능한(Re-entrant) 락입니다. 이름 그대로 한 스레드가 자기 자신이 이미 보유한 락을 중복해서 획득할 수 있습니다.

  • 동작 방식
    1. RLock은 내부적으로 "락을 소유한 스레드"와 "획득 횟수(recursion level)"를 기록합니다.
    2. 어떤 스레드가 rlock.acquire()를 처음 호출하면, 해당 스레드가 락의 소유자가 되고 획득 횟수는 1이 됩니다.
    3. 동일한 스레드가 acquire()를 다시 호출하면, 블로킹되지 않고 획득 횟수만 1 증가합니다.
    4. 다른 스레드가 acquire()를 호출하면, 기존 Lock과 동일하게 블로킹됩니다.
    5. 락을 획득한 스레드는 release()를 호출할 때마다 획득 횟수가 1 감소하며, 획득 횟수가 0이 되어야만 락이 완전히 해제되어 다른 스레드가 획득할 수 있습니다.
  • 💡 주요 사용 사례 주로 재귀 함수나 복잡한 함수 호출 구조에서 여러 단계에 걸쳐 동일한 락을 필요로 할 때 유용합니다.

### RLock 예제 코드 (재귀 함수)

Lock을 사용하면 교착 상태에 빠지는 재귀 함수 예제입니다.

Python
 
import threading

# rlock = threading.Lock() # -> 이 코드를 사용하면 바로 교착 상태에 빠짐!
rlock = threading.RLock()

def recursive_function(n):
    with rlock:
        print(f"스레드 {threading.current_thread().name}이(가) 락 획득 (n={n})")
        if n > 0:
            recursive_function(n - 1)
    # with 블록을 벗어나면 release()가 호출됨

# RLock을 사용하면 재귀 호출에서 락을 다시 획득해도 문제가 없습니다.
recursive_thread = threading.Thread(target=recursive_function, args=(3,))
recursive_thread.start()
recursive_thread.join()

print("작업 완료")
 

위 코드에서 Lock을 사용하면, n=3에서 락을 획득한 후 n=2를 위해 재귀 호출했을 때 자기 자신이 만든 락을 다시 획득하려다 영원히 대기하게 됩니다. 하지만 RLock은 이를 허용하므로 코드가 정상적으로 실행됩니다.


## Lock vs. RLock 핵심 비교

기능 threading.Lock threading.RLock
재진입 ❌ 불가능 (교착 상태 발생) 가능
소유권 개념 없음 (누구나 release 가능)¹ 있음 (락을 획득한 스레드만 release 가능)
내부 구조 단순한 플래그 (잠김/열림) 소유 스레드와 획득 횟수 카운터
사용 사례 간단하고 명확한 임계 영역 보호 재귀 함수, 복잡한 호출 구조의 임계 영역 보호
성능 약간 더 가볍고 빠름 약간 더 복잡하고 느림

¹ 이론적으로는 다른 스레드가 release()를 호출할 수 있지만, 이는 매우 위험하며 버그를 유발하는 잘못된 설계입니다.

## 언제 무엇을 써야 할까?

  • Lock을 사용하세요 (기본): 대부분의 경우 Lock으로 충분합니다. 코드가 단순하고, 한 스레드가 특정 락을 중복으로 획득할 가능성이 없다면 Lock을 사용하는 것이 좋습니다. 더 간단하고 성능상 이점이 있습니다.
  • RLock을 사용하세요 (특수한 경우): 작성하려는 코드가 재귀적으로 락을 필요로 하거나, 하나의 클래스 내에서 여러 메서드가 서로를 호출하며 동일한 락을 공유해야 하는 복잡한 상황에서만 RLock을 고려하세요. 필요하지 않은데 RLock을 남용하면 코드를 이해하기 어렵게 만들 수 있습니다.
728x90
반응형