Lock을 사용하는 스레드 동기화 방법

두 개 이상의 스레드가 하나의 공통 자원에 액세스하려고하면 문제가 발생하는 경우가 있습니다. 만약 해당 자원이 내부 속성이 변하지 않는 객체라면 문제될 것이 없겠습니다만, 내부 속성이 변경이 가능하다면 상황이 달라집니다. 예를 들어 두 개의 스레드 A, B 가 공통된 변수 i 에 접근하는 상황을 가정해봅시다.

i를 상수로 가정하고 그 어떤 스레드에서도 변경하지 않는다면 A, B 스레드는 언제든 i에 접근하여 그 값을 읽을 수 있고, 이 때 i의 정보가 손상되지 않을 것이라 확신할 수 있습니다.

하지만 i 값 자체가 바뀌거나, i가 참조하고 있는 객체의 내부 상태가 바뀔 수 있다면 어떨까요? 스레드 A가 i의 값을 변조하고, 스레드 B가 i의 값을 참조하는 상황을 가정해봅시다. A가 어느시점에 i의 값을 변조하는데, 이와 동시에 스레드 B의 코드 여러 줄에서 i의 값을 반복적으로 참조한다면, B의 입장에서는 아무짓도 하지 않았지만 바로 윗줄에서의 i와 지금 라인에서의 i가 다른 값일 수 있다는 예측할 수 없는 상황에 놓이게 됩니다.

동시성 프로그래밍에서 이런 문제를 해결하기 위해서 여러가지 동기화 수단이 사용되는데, 그 중 가장 기본적이고 대표적인 것이 락(Lock)입니다. 락은 말 그대로 자물쇠에 비교할 수 있습니다. 한 칸짜리 화장실 앞에 여러 스레드가 볼일을 해결하려고 몰려드는 상황을 생각하면 이해가 쉽습니다. 자물쇠를 획득(aquire)한 스레드는 문을 잠그고 볼일을 봅니다. 그러는 동안 화장실을 쓰고 싶은 다른 스레드들은 발을 동동 구르겠죠. 이런식으로 특정한 자원을 선점한 스레드는 편안하게 볼일을 볼 수 있게 됩니다.

물론 락이 만능 열쇠가 될 수는 없습니다. 정해진 자원을 액세스하려는 코드의 앞뒤로 해당 락을 얻고 릴리즈하는 코드를 프로그래머가 알아서 반드시 삽입해야 합니다. 락은 같은 락을 얻으려 하는 스레드들만 차단할 수 있으므로, 만약 제 3의 스레드에서 이 코드가 누락되었다면 그 스레드는 아무 제한 없이 해당 자원에 접근하게 됩니다.

아, 그리고 볼일을 마친 스레드는 꼭 자물쇠를 해제(release)해 주어야 합니다. 해제해주지 않으면 락을 기다리는 다른 스레드들이 모두 멈춰버리는 불상사가 생길 수 있습니다.

다음과 같이 두 개의 스레드가 전역 변수를 참고하고 변조하는 상황을 예로 들어보겠습니다. 개인적으로 이렇게 함수 안에서 전역변수를 변경하는 코드를 아주 싫어하지만, 어쩔 수 없죠.

from threading import Thread, Lock
import sys
import time
import random

aLock = Lock()
res = {1: 0}

참고로 멀티 스레드 프로그램에서 다른 스레드가 동시에 print()를 사용하면 출력 내용이 뒤죽박죽으로 섞이는 문제가 있습니다. 이 경우 올바른 출력을 위해서는 sys.stdout 을 사용하는 것을 권장합니다.

먼저 워커 A를 작성해보겠습니다. 전역 객체인 res의 내부 요소값을 무작위 값으로 변경합니다. 경된 값을 매번 출력해주며, 변경 전후로 일정하지 않은 지연시간을 두겠습니다.

def workerA():
  while True:
    time.sleep(random.randrange(1, 5) / 10)
    res[1] = random.randrange(1, 100)
    sys.stdout.write(f'A->{res[1]}\n')
    sys.stdout.flush()
    time.sleep(random.randrange(1, 5) / 10)

다음은 워커 B 입니다. 실행 후 약간의 딜레이를 두고, res[1] 값을 1씩 증가시키며 출력합니다.

def workerB():
  time.sleep(1)
  for _ in range(10):
    res[1] += 1
    sys.stdout.write(f'B---->{res[1]}\n')
    sys.stdout.flush()
    time.sleep(0.5)
  time.sleep(1)

위 두 워커를 각각의 스레드에서 실행하는 코드입니다. 워커A는 데몬으로 돌려놓고, 워커B를 실행한 후 join() 합니다. 메인 스레드는 워커 B의 실행이 완료되면 종료되며 이 때 스레드 A를 종료시킵니다.

join()을 사용하면 현재 스레드가 해당 스레드의 실행이 끝날 때까지 대기하게 됩니다. 멀티 스레드에서 이를 사용하지 않으면 개별 워커 스레드를 시작시킨 직후에 메인스레드가 끝나면서 프로그램이 바로 종료되어 버립니다.

def main():
  Thread(target=workerA, daemon=True)
  t = Thread(target=workerB)
  t.start()
  t.join()

if __name__ == '__main__':
  main()

실행 결과는 다음과 같습니다. 워커 B의 매 반복 사이에 A에 의해서 값이 변경되기 때문에 결과가 제멋대로 나오고 있는 것이 확인됩니다.

A-> 29
A-> 98
A-> 26
B------> 27
A-> 21
B------> 22
A-> 73
A-> 29
B------> 30
A-> 48
B------> 49
A-> 98
B------> 99
A-> 13
B------> 14
A-> 10
B------> 11
A-> 44
B------> 45
A-> 45
B------> 46
A-> 86
B-----

여기서 중요한 사실은 B에서 값이 출력되는 그 시점에도 이미 이전 라인에서 변경한 값이 아닌 값으로 나올 수 있는 확률이 있다는 것입니다. 즉 프로세스 전체의 관점에서 봤을 때, time.sleep()으로 블럭하고 있지 않은 연속한 코드가 반드시 연속해서 실행되고 있다는 보장은 없습니다.

이제 락을 걸어서 res에 대한 접근을 한 스레드가 독점하도록 하겠습니다. 먼저 워커 B의 코드에서 락을 걸고 해제하는 코드를 삽입합니다. 루프를 전체에 대해서 락을 걸어야 합니다.

def workerB():
    time.sleep(1)
    aLock.acquire()
    for _ in range(10):
        res[1] += 1
        sys.stdout.write(f'B------> {res[1]}\n')
        sys.stdout.flush()
        time.sleep(0.5)
    aLock.release()
    time.sleep(1)

워커 B는 시작 후 1초 동안을 멈추게 됩니다. 이 사이에 워커 A는 한 번 혹은 그 이상 res 값에 접근하여 랜덤한 값으로 업데이트를 할 것입니다. 여기까지만 수정하고 실행하면 락이 제대로 동작하지 않을 것입니다. (워커A가 아직 아무런 제한이 걸리지 않기 때문입니다.)

그런데, Lock 클래스는 컨텍스트 매니저 프로토콜을 지원합니다. 이는 with aLock: 구문을 사용할 수 있다는 것입니다. 이 내부의 블럭의 시작과 끝에서 acquire()/releass()가 각각 호출되는데, 시각적으로도 락을 얻은 후 화장실 칸에 들어가서 하는 동작이 구분되어 보입니다. 다시 코드를 수정하겠습니다.

def workerB():
    time.sleep(1)
    with aLock:
        for _ in range(10):
            res[1] += 1
            sys.stdout.write(f'B------> {res[1]}\n')
            sys.stdout.flush()
            time.sleep(0.5)
    time.sleep(1)

이번에는 워커 A의 코드를 수정하겠습니다. 수정 후 출력하는 코드까지 포함하여 락을 겁니다. 락이 걸려있는 동안에는 워커 B의 with aLock: 구문으로 진입이 불가하며, 반대로 B에서 with aLock: 내부 구문이 실행중일 때에는 워커 A가 블럭됩니다. 이제 실행한 결과는 다음과 같게 됩니다.

A-> 81
B------> 82
B------> 83
B------> 84
B------> 85
B------> 86
B------> 87
B------> 88
B------> 89
B------> 90
B------> 91
A-> 28
A-> 62
A-> 61

워커 B의 for 문 전체가 락을 사용하고 있기 때문에 해당 반복문이 도는 동안에는 A가 res 값을 변경하는 동작이 블럭됩니다.

Lock을 사용할 때의 유의할 점

락을 사용하면 이처럼 특정한 리소스를 액세스할 때, 동시에 접근하려는 다른 스레드를 차단할 수 있게 됩니다. 다만 이는 해당 리소스를 아토믹하게 액세스하는 것은 아니며, 같은 락을 들고 있는 다른 스레드들의 접근을 무조건 막게 됩니다.

실질적으로는 값의 업데이트가 발생하지 않는 상황에서는 여러 스레드에서 동시에 같은 값을 액세스하는 것은 문제가 되지 않기 때문에, 락을 남발하는 것은 멀티스레드 프로그램에서 성능을 저하시키는 원인이 되기도 합니다.

또한 두 개의 스레드가 서로 다른 락 P, Q를 갖고 있는 상황에서 스레드 A는 스레드 B가 갖고 있는 락을 기다리고, 동시에 스레드 B가 스레드 A의 락을 기다리는 순환 대기의 상황이 만들어질 수 있습니다. 이런 상황을 교착상태(데드락)이라 부르며, 이 상황에서 두 스레드는 더 이상 진행이 불가한 상황에 빠지게 됩니다.

다른 문제 상황으로는 어떤 함수가 락을 잠그고 자신을 재귀호출 하는 경우를 생각해 볼 수 있습니다. 재귀 호출된 상황에서 다시 해당 락을 얻으려 한다면 이미 잠긴 락이 다시 풀릴 수 있는 방법이 없기 때문에 단일 스레드 상에서 교착이 발생할 수도 있습니다. 이 문제는 재귀락이라는 RLock 을 사용하면 해결할 수 있습니다.

더 깊이 – 논블럭 락

Lock()을 생성할 때 blocking=False 옵션을 줄 수 있습니다. 이 파라미터 값이 False로 주어지면, 이미 선점된 락을 다른 스레드에서 acquire()할 때, 블럭하는 대신에 False 값을 리턴합니다. 따라서 그 시점 이후에라도 해당 자원을 꼭 사용하는 것이 아니라 다음에 다시 사용하겠다…라는 결정을 할 수 있습니다.

Lock()은 기본적으로 1초동안만 블럭합니다. (디폴트 타임아웃 값이 1입니다.) 그 보다 더 긴 시간의 대기시간이 필요하다면 timeout= 옵션을 주어 생성합니다. 이 값을 -1로 주면 타임아웃 없이 영원히 대기하게 됩니다.

이 글에서 소개한 예제에서는 락을 전역 객체로 두었습니다. 실제 코드를 작성할 때에는 스레드 워커가 사용할 락을 인자로 받도록 디자인하는 것이 좋습니다. 그렇게 코드를 작성하면 동일한 코드를 multiprocessing 모듈로 이전하기가 매우 편합니다. 멀티프로세스 환경에서는 전역적으로 동일한 객체에 접근할 수 있는 방법이 없기 때문입니다. 물론 멀티프로세스로 작업하려면 락과 같은 동기화 primitives를 사용하는 것 보다는 큐나 파이프와 같은 수단을 사용해서 데이터를 전달하는 것이 나은 선택이며, 가능한한 프로세스간의 통신을 줄이는 것이 좋습니다.