[Python] 클래스 이해하기

클래스를 설명할 때 흔히 쓰는 표현은 ‘클래스는 거푸집에 해당하고 객체는 그 거푸집으로 찍어내는 벽돌에 해당한다.’는 것이다. 물론 완전히 틀린 설명은 아닌데, 이 개념에서 출발해서 클래스를 이해하는 것은 객체와 클래스의 관계와 클래스를 어떻게 다룰 것인지 등 여러 관점을 정립하는데 많은 어려움을 유발한다.

이 글은 파이썬 초보자들이 클래스에 대해 접근하고 이해하는데 도움을 주고자 작성됐다.

모든 것은 객체이다.

파이썬에서 통용되는 가장 중요한 대전제는 모든 것이 객체라는 것이다. 1, 2와 같은 숫자값도 C처럼 원시값이 아니라 int 타입의 객체이다. 함수 역시 객체이고 모듈이나 패키지도 객체처럼 취급된다. 모든 것이 객체라면, 클래스 그 자체도 객체라는 말이된다. 그럼 이 시점에서 다시 한 번 되물어보자. 도대체 객체란 무엇인가?

[Python] 클래스 이해하기 더보기

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() 메소드는 “현재 스레드”가 해당 락을 획득하였는지 여부를 확인할 수 있습니다. 락 획득-해제 구간의 코드가 아닌 다른 함수에서 이미 락을 획득했는지 여부를 알고 싶을 때 사용할 수 있습니다.

ZMQ + Asyncio 적용하기

파이썬에서 ZMQ를 사용할 때, asyncio를 사용할 수 있게 되었다. asyncio에 적용한다고 해서 크게 달라지는 것은 없고 소켓의 사용방법은 대동소이하다. (실제 IO 시점에 작업 전환이 일어날 수 있게 await를 붙이는 것 정도의 차이만 있다. 대략의 사용법을 정리해보면 다음과 같다.

ZMQ + Asyncio 적용하기 더보기

Selector를 사용한 소켓 멀티플렉싱

zmq.Poller는 소켓을 멀티플렉싱할 때 사용하는 클래스입니다. 파이썬에서의 ZMQ Poller 구현은 파이썬의 내부 select 모듈을 사용합니다. 파이썬 3.4부터는 이 모듈을 개선하여 효율적인 I/O 멀티플렉싱에 대한 고수준 API를 제공하는 selectors 모듈이 있습니다.

selectors.BaseSelector는 복수 파일 객체에 대한 I/O 이벤트를 대기하기 위해 사용합니다. 이 클래스는 파일 스트림에 대한 등록, 해제와 대기에 관한 메소드들을 제공하며, selectors 모듈이 제공하는 여러 셀렉터 클래스들의 기반이 됩니다.

Selector를 사용한 소켓 멀티플렉싱 더보기