asyncio의 동기화수단들

asyncio는 단일 스레드에서 비동기 코루틴을 사용하여 동시성 처리를 한다. 따라서 asyncio의 세계에서는 적어도 멀티 스레드에서 발생할 수 있는 자원 선점문제가 없을 것이라 생각할 수 있다. 전적으로 틀린 것은 아니다. 스레드가 1개밖에 없기 때문에 메모리 내의 특정한 객체를 동시에 액세스하는 일은 없을 것이다. 그러나 그외의 IO와 관련된 자원은 여전히 선점 문제가 발생할 수 있다. 이러한 문제를 피하기 위해서 asyncio는 threading과 유사한 동기화 수단들을 제공하고 있으며, 이들의 사용 방법 또한 거의 유사하다. asyncio에서 제공하는 동기화 수단에는 다음과 같은 것들이 있다.

  • 락(Lock)
  • 이벤트(Event)
  • 컨디션(Condition)
  • 세마포어(Semaphore)
  • 바운디드세마포어(BoundedSemaphore)
asyncio의 동기화수단들 더보기

컨디션을 통한 스레드 동기화 예제

동시성을 다룰 때에는 특정한 자원을 동시에 액세스하지 못하도록 관리하거나 여러 작업들이 시작되는 시점을 맞추는 동기화 수단이 필요할 수 있다. Lock은 특정 코드 영역을 동시에 여러 스레드가 실행하지 못하도록 보호할 때 사용하며, 이벤트는 여러 스레드들이 특정 이벤트가 발생할 때까지 기다리다가 동시에 시작될 수 있도록 한다. 컨디션(Condition)은 락과 이벤트가 결합되어 있는 동기화 수단이다.

컨디션은 락을 내재하고 있는 이벤트라 할 수 있다. 락과 마찬가지로 acquire() ~ release() 구간이 있어 한 번에 하나의 스레드/프로세스가 실행되는 영역을 만들 수 있는데, 그 사이에 wait()를 통해서 이벤트를 기다릴 수 있다. 이때 한 스레드가 락을 잠근 상태에서 wait()를 호출하여 이벤트를 기다리게 되면, 같은 컨디션 객체를 점유하고자 하는 스레드가 다시 락을 얻어서 크리티컬 영역에 진입할 수 있다. 이와 같은 방식으로 여러 스레드가 크리티컬 영역에서 이벤트를 기다리는 상태가 될 때, 누군가가 해당 컨디션 이벤트를 set()하게 되면 대기 중인 모든 스레드가 깨어나게 된다. 하지만 이들은 모두 같은 크리티컬 영역에서 대기 중이었기 때문에 일반 이벤트와 달리 한꺼번에 동시에 시작하지 않고, 한 번에 하나씩 크리티컬 영역의 코드를 실행한다. 깨어난 스레드가 락을 릴리즈하는 시점에 wait()를 끝낸 다른 스레드가 실행되는 식으로 순차적으로 크리티컬 구간을 지나게 된다.

컨디션을 통한 스레드 동기화 예제 더보기

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

아래는 어떤 “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을 올바르게 사용하기 위해서는 스레드 관점이 아닌 자원 관점에서 생각하는 것이 맞습니다. 자원 경쟁을 피하고 스레드 안전하게 처리해야 할 자원을 액세스하는 시점에 모든 스레드는 다음과 같이 코드를 작성합니다.

  1. 자원을 액세스하기 직전에 lock.acquire()를 호출합니다.
  2. 해당 자원을 사용합니다.
  3. 처리를 마치면 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() 메소드는 “현재 스레드”가 해당 락을 획득하였는지 여부를 확인할 수 있습니다. 락 획득-해제 구간의 코드가 아닌 다른 함수에서 이미 락을 획득했는지 여부를 알고 싶을 때 사용할 수 있습니다.

Barrier를 사용한 동기화

Barrier는 동시성 프로그래밍에서 사용되는 동기화 수단 중 하나로 여러 스레드를 특정한 시점까지 기다린 후 한꺼번에 재개하는 방법이다. 비슷한  방식의 동기화 프리미티브로 이벤트(Event)가 있는데, 이벤트는 재개 시점을 판단하는 제 3의 스레드가 재개를 위한 시그널을 set해주어야 한다. 배리어는 그와 달리 미리 정해진 개수만큼의 스레드가 모이면 자동으로 해제된다.

따라서 배리어는 여러 워커 스레드로 데이터를 분산시켜 병렬로 처리하고 최종적인 작업 완료 이전에 모든 워커 스레드가 작업을 완료했는지를 기다리는 용도로 사용할 수 있다.

Barrier를 사용한 동기화 더보기

파이썬에서 스레드 사용하기 – threading

스레드는 프로그램이 실행되는 실행 흐름의 최소 단위이다. 어떤 프로그램이 실행되면 기본적으로 해당 프로그램을 위한 프로세스가 생성된다. 그리고 다시 이 프로세스는 하나의 스레드를 만들고 (이것이 해당 프로세스의 메인 스레드가 된다.) 이 스레드를 따라 코드가 실행된다.

하나의 프로세는 한 개 이상의 스레드를 동시에 실행시킬 수 있다. 이 말은 메인 루틴이 진행하는 동안 병렬적으로 다른 함수들이 같이 실행될 수 있다는 말이다. 스레드는 프로세스에 종속되므로 프로세스 내에서 스레드가 추가로 만들어질 때 이 새로운 스레드는 프로세스의 코드와 메모리를 공유한다. (반대로 멀티프로세스는 각각 독립된 코드 및 메모리 영역을 가지고 돌아간다.) 스레드는 이처럼 동시에 같은 작업들을 처리하여 전체적인 성능을 향상시키거나 루틴의 흐름을 중단시키지 않고 별개의 작업 흐름이 서브 루틴을 실행하여 서로 다른 작업을 함께 진행할 때 사용한다.

파이썬에서 스레드를 사용할 수 있도록 해주는 모듈로 _threadthreading이 있다. _thread 모듈은 저수준의 API를 제공하고 있고, 이를 기반으로 고수준 API를 제공하는 threading 모듈이 있다. 이 글에서는 threading 모듈을 사용하여 스레드를 생성, 실행하고 락, 세마포어 등의 동기화 수단을 사용하여 실행 흐름을 제어하는 방법을 살펴보겠다.

파이썬에서 스레드 사용하기 – threading 더보기