아래는 어떤 “counter”라는 자원을 두 스레드가 동시에 사용하려할 때, Lock을 사용하는 상황을 시각적으로 묘사한 것입니다. 두 워커 스레드 A, B 는 자원에 접근하기 전에 Lock을 획득하려고 시도합니다. 두 스레드 모두 락 객체의 .acquire()
를 호출합니다. 이 때 (아마도 간발의 차이로) A 가 락을 획득하게 되었다고 가정하면, A에서 호출한 .acquire()
는 즉시 리턴되어 A는 다음 코드를 진행하게 되고 여기서 counter를 사용합니다. 반면 B의 .acquire()
호출은 락을 획득할 때까지 대기하기 때문에 B의 진행 흐름은 여기서 멈추게 되고, A가 자원을 쓰는 동안 . . . 으로 묘사됩니다.
Worker A B
|.acquire() |.acquire()
\ .
|- use counter .
|.release() .
/ \
|.acquire() |-use counter
. |.release()
. /
\ |.acquire()
따라서 Lock을 올바르게 사용하기 위해서는 스레드 관점이 아닌 자원 관점에서 생각하는 것이 맞습니다. 자원 경쟁을 피하고 스레드 안전하게 처리해야 할 자원을 액세스하는 시점에 모든 스레드는 다음과 같이 코드를 작성합니다.
- 자원을 액세스하기 직전에
lock.acquire()
를 호출합니다. - 해당 자원을 사용합니다.
- 처리를 마치면
lock.release()
를 호출하여 다른 스레드가 사용할 수 있도록 합니다.
획득-해제의 매커니즘이 두 개의 메소드 호출을 통해서 시작하고 끝나기 때문에 파이썬에서는 락 구간을 시각적으로 구분하기 힘듭니다. 이 때 lock.acquire()
는 실질적으로는 락 획득 여부를 리턴하기 때문에 if
문으로 블럭을 구분해주는 것이 좋습니다. 물론 블럭의 끝에서 lock.release()
를 호출하는 것을 잊으면 곤란하겠죠.
락을 쓰는 구간 앞뒤로 빠짐없이 넣어야 하는 코드 때문에 획득-해제 구간이 많으면 많을수록 코드 작성이 번거롭습니다. 하지만 threading
모듈이 제공하는 획득-해제식 동기화 수단들은 모두 with
문을 사용할 수 있습니다.
from threading import Thread, Lock, Barrier
import logging
logging.basicConfig(
level=logging.DEBUG,
format='%(threadName)s %(message)s')
res = {'counter': 0}
lock = Lock()
bar = Barrier(11)
def worker():
for _ in range(10):
with lock:
r = res['counter']
res['counter'] += 1
logging.debug(f"counter: {r} -> {res['counter']}")
bar.wait()
def main():
ts = [Thread(name=f'WORKER{i+1}', target=worker)
for i in range(10)]
for t in ts:
t.start()
bar.wait()
if __name__ == '__main__':
main()
몇가지 궁금증
항상 락을 사용해야 하나?
일반적으로 외부 세계에 대한 핸들(파일 등)이 아닌 메모리 내 값이나 객체에 대해서 그것이 불변이라면 여러 스레드에서 동시에 참조해도 안전하다고 간주할 수 있습니다. 왜냐하면 변하지 않을 것이 약속되어 있다면 언제 어디서 참조하든 똑같은 값일 것을 기대할 수 있기 때문입니다. 따라서 상수를 참조하는 경우는 락을 생각하지 않아도 좋습니다.
‘쓰기’에서만 락을 적용하면 되나?
여러 스레드에서 공유하는 객체가 mutable 하다면, 쓰기 뿐만 아니라 이 객체를 읽는 동작까지 Lock을 수반해야 합니다. 최악의 경우 읽기와 쓰기가 동시에 진행될 수 있기 때문입니다. 참고로 특정한 스레드끼리만 락을 사용해서도 안됩니다. 현재 스레드가 락을 획득했다 하더라도 다른 스레드에서 해당 락을 얻는 과정을 생략해버린다면 해당 리소스에 대해 스레드 안전한 접근이 보장되지 않습니다.
blocking 과 timeout 옵션
락을 획득하기 위해 acquire()
를 호출할 때 두 가지 옵션이 있습니다. 하나는 blocking=True
이고 다른 하나는 timeout=-1
입니다. 타임아웃은 주어진 시간(초)까지만 락을 얻기 위해 대기하다가 락을 얻거나 얻지 못하고 타임아웃이 지났을 때 리턴하게 됩니다. 결국 acquire()
는 락 획득 여부를 True/False
로 리턴해주게 됩니다. 타임아웃의 기본 값은 -1이며, 이 경우 acquire()
는 락을 획득할 때까지 무기한 기다리게 됩니다.
타임 아웃 대신 blocking=False
옵션을 사용하는 방법이 있습니다. 이 옵션을 사용하면 락을 획득하려 시도하고 즉시 결과를 리턴합니다. 논블럭 락을 사용하거나 타임아웃을 적용하는 경우, 항상 if 문을 사용하여 락을 획득하였을 때에만 선점된 리소스에 접근하도록 해야 합니다.
locked()
락 객체에 대해서 locked()
메소드는 “현재 스레드”가 해당 락을 획득하였는지 여부를 확인할 수 있습니다. 락 획득-해제 구간의 코드가 아닌 다른 함수에서 이미 락을 획득했는지 여부를 알고 싶을 때 사용할 수 있습니다.