콘텐츠로 건너뛰기
Home » 파이썬에서 스레드 사용하기 – threading

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

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

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

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

정해진 분량의 작업을 쪼개어 분산처리하고 그 결과를 취합하는데에는 직접 스레드의 실행과 동기화를 관리하는 것 보다 concurrent.futures 모듈을 사용하는 것이 유리하니 참고하시길.

Thread 클래스

threading 모듈은 OS스레드를 객체화한 threaing.Thread 클래스를 제공한다. 기본적으로 스레드를 통해서 실행하는 작업은 파이썬 함수로 작성하게 되는데, Thread 인스턴스를 만들 때 함수와 필요한 인자를 각각 넘겨준 다음 스레드 객체의 .start()를 호출하여 스레드를 시작할 수 있다.

스레드 만들기

스레드 객체의 생성자가 받는 인자는 다음과 같다.

Thread(name=, target=, kwargs=, *, daemon=False)
  • name : 스레드의 이름. 로깅등을 위한 용도로 쓰며 주지 않아도 무방하다.
  • target : 스레드에서 실행할 함수
  • args : target에 넘겨질 인자, 튜플 형식이다.
  • kargs : target이 키워드 인자를 받을 때 사전으로 넘겨준다.
  • daemon : 데몬 실행 여부. 데몬으로 실행되는 스레드는 프로세스가 종료될 때 즉각 중단된다.

스레드 객체의 속성

스레드 객체를 생성했다하더라도 해당 스레드가 바로 시작되지는 않는다. .start()를 호출하면 스레드가 그 때부터 시작된다. 그외에 스레드 객체는 다음과 같은 몇몇 속성을 사용할 수 있다.

  • start() : 스레드를 시작한다.
  • join() : 해당 스레드에서 실행되는 함수가 종료될때까지 기다린다. timeout= 인자를 주어 특정 시간까지만 기다리게 할 수 있다. 타임아웃을 초과해도 예외를 일으키지 않고 None을 리턴하므로 이 경우 is_alive()를 호출하여 스레드가 실행 중인지를 파악할 필요가 있다.
  • is_alive() : 해당 스레드가 동작 중인지 확인한다.
  • name : 스레드의 이름
  • ident : 스레드 식별자. 정수값
  • native_id : 스레드 고유 식별자. ident는 종료된 스레드 이후에 새로 만들어진 다른 스레드에 재활용될 수 있다.
  • daemon : 데몬 스레드 여부

간단한 예제를 통해서 여러 스레드를 동시에 처리하는 예를 살펴보자. URL을 받아서 해당 리소스를 파일에 저장하는 함수를 작성하고 이를 여러 개 한꺼번에 실행한다.

스레드를 사용할 때 주의할 점은 Thread.start()는 즉시 리턴하기 때문에 워커 스레드들이 동작하고 있는 중일 때 메인 스레드가 적절히 기다려주지 않는다면 프로그램이 중간에 끝나버릴 수 있다는 점이다. 프로세스의 종료 시점은 메인 스레드가 종료 지점에 도달했을 때이며, 다른 워커 스레드의 실행 여부는 고려되지 않는다. 따라서 중도 종료를 막기 위해서는 메인 스레드가 워커 스레드들을 적절히 기다려줘야 한다. 이 때 .join() 메소드가 사용된다.

from threading import Thread
from urllib.request import urlopen
urls = ('https://..', ...)
def download_contents(url):
  '''주어진 URL을 다운로드 받고, 그 내용을 파일로 기록한다.'''
  res = urlopen(url)
  if res.code == 200:
    filename = url.rsplit('/', 1)[1]
    with open(filename, 'wb') as f:
      f.write(res.read())
def main():
  ts = [Thread(target=download_contents, args=(url,))\
        for url in urls]
  # 모든 스레드를 각각 시작한다.
  for t in ts:
    t.start()
  # 모든 스레드를 join 한다.
  for t in ts:
    t.join()
if __name__ == '__main__':
  main()

하나의 루프 안에서 start()join()을 순서대로 각각 호출하면 어떻게 될까? 첫번째 스레드를 시작하고 바로 join 하기 때문에 두 번째 스레드가 시작되지 못하고 기다릴 것이다. 그것은 실질적으로 스레드는 쓰지만 병렬처리가 아니므로 주의해야 한다. 또한 스레드를 만들 때, args= 인자에 넘겨주는 인자가 한 개 밖에 없는 경우에도 무조건 튜플을 만들어야 하기 때문에 (value,) 와 같은 식으로 튜플을 만들어야 하는 부분을 눈여겨 봐두자.

누군가는 파이썬에서 스레드는 GIL때문에 실질적으로 동시에 실행되지 못하고 한 번에 하나의 스레드만 진행되고 따라서 전체 수행 시간이 줄어들지 않을 것이라 생각할 수 있는데, 위 코드는 그렇지 않다. 물론 CPU를 많이 쓰는 계산을 처리했다면 그럴 수 있지만 위 코드에서는 네트워크를 통해 데이터를 읽는 IO 작업 위주이기 때문에 GIL과 상관없이 수행 시간이 대폭 줄어들 것이다.


다만 한가지 주의해야 할 점은 위 예와 같은 구성에서 URL의 개수가 너무 많으면 안된다는 것이다. 위 코드는 데이터 개수만큼 스레드를 생성해서 각각의 스레드가 하나의 데이터를 처리하게 된다. 따라서 데이터가 많으면 그만큼 많은 스레드가 생성되는데, 스레드가 너무 많으면 성능을 향상 시키기는 커녕 역효과가 날 수 있다. 심지어 GIL이 없는 구현체에서도 그럴 수 있는데 그것은 스레드가 많으면 컨텍스트 스위치에 많은 비용이 들기 때문이다.


위 예는 여러 스레드가 똑같은 작업을 병렬로 처리하는 코드였다. 이번에는 다른 예를 들어보자. 소켓 서버의 예를 생각해보자. socket.socket.accept()를 호출하여 접속을 허용하고 커넥션을 만들게 되면 (conn: socket.socket, addr: Tuple[str, int]) 형식의 튜플이 리턴된다. 이 때 conn을 사용해서 클라이언트와 번갈아 데이터를 주고 받을 수 있는데, 보통 이 과정을 루프로 돌다보면 다른 클라이언트가 접속하려는 때에 연결 수립을 할 수 없는 (서버가 소켓을 듣는 listen()을 호출하지 않으므로) 상태에 빠진다.

물론 소켓 통신에 대한 다중 접속 부분은 싱글스레드에서 셀렉터를 사용해서 멀티플렉싱할 수 있는 방법이 있다. 또한 asyncio 역시 다중접속을 지원하는 소켓 서버를 생성할 수 있다.

한가지 간단한 방법은 연결 수립 후 해당 클라이언트와의 통신을 별도의 스레드가 처리하도록 하는 것이다. 즉 연결을 받아들인 후 메인 스레드는 포트에 바인딩한 주 소켓과, 개별 접속과 통신하기 위한 소켓을 모두 가지게 되는데, 개별 접속 소켓을 스레드로 넘겨주고 메인 스레드는 계속해서 주 소켓을 듣는 구조로 만들면 된다.

각 클라이언트와 교신하는 처리를 수행하는 코드를 함수 connection_handler에 정의했다고 하면 아래와 같은 식으로 접속된 소켓을 별도의 스레드가 처리하게끔 할 수 있다.

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('localhost', 7777))
while True:
  sock.listen(1)
  conn, addr = sock.accept()  
  t = Thread(target=manage_connection, args=(conn, addr))
  t.start()
...

독립된 네임스페이스 사용하기

병렬처리를 위해 멀티 프로세스를 사용하는 것보다, 스레드를 사용하는 가장 주된 이유는 스레드가 가볍기 때문이다. 각각의 프로세스는 코드와 메모리를 독립적으로 가지고 실행되며, 어떤 프로세스는 다른 프로세스의 메모리를 들여다보는 것이 일반적으로 불가능하다. 따라서 프로세스를 새로 만들 때에는 필요한 데이터를 모두 복사하여 새 프로세스에게 전달해야 하는 등 많은 비용이 발생하게 되고, 프로세스 간에 데이터 교환 IPC 혹은 별도의 공유 메모리를 사용하는 등 어려움이 많다. 반면 스레드는 하나의 프로세스 내에서 움직이면서 코드와 메모리 영역을 공유하게 되기 때문에 데이터 교환 측면에서는 매우 간단하다.

프로세스가 점유한 메모리가 모든 스레드에 의해 공유되는 상황은 데이터 교환 측면에서는 잇점이 있지만 반사적으로 모든 전역 이름들이 같은 객체를 참조하기 때문에 발생하는 문제들도 있다. (자원선점이라든지 동기화 등등) 그 중 하나가 ‘같은 이름이면서 스레드-로컬’이어야 하는 자원에 관한 것이다.

threading.local() 함수는 스레드별로 구분되는 네임스페이스를 제공한다. 사용법은 다음과 같다.

  1. 전역 레벨에서 ns = threading.local() 과 같이 네임스페이스 인스턴스를 생성한다.
  2. 각각의 스레드에서 전역 이름인 ns를 참조하여 ns.x = 2 와 같은 식으로 데이터를 바인딩한다.

threading.local() 함수에 의해 생성되는 객체는 스레드별로 독립적인 네임스페이스로 동작한다. 또한 싱글턴을 생성하는 함수가 아니기 때문에 함수 내에서 생성하는 것은 별 의미가 없다. 전역 이름 공간에 네임스페이스를 설정해두면 스레드별로 다른 값을 참조하도록 사용할 수 있다.

동기화 수단들

스레드는 프로세스 내의 자원을 모두 공유하고 있다. 따라서 어떤 스레드에 있든 구애받지 않고 자유롭게 공유되는 자원에 접근가능하다. 이것은 양날의 검으로, 매우 편리한 동시에 위험한 것이기도 하다. 아무런 안전 장치 없이 두 개 이상의 스레드가 같은 자원을 액세스하려는 것은 자원 선점 문제를 일으킨다. 또한 스레드는 생성되고 시작되는 순간 다른 무엇의 눈치를 보지 않고 알아서 진행하며, 다른 스레드의 진행 지점이 어디인지 알 수 없기도 하다. 따라서 두 스레드가 데이터를 교환하려고 하거나 특정 동작을 정해진 순서에 맞춰서 실행하기 위해서는 별도의 수단이 필요하다. 이것을 동기화라 한다.

스레드의 실행 순서를 제어하는데 사용되는 동기화 수단으로는 락(Lock)이나 세마포어, 이벤트 등이 사용된다. 아래는 threading에서 제공하는 동기화 수단들과 그에 대한 간략한 설명이다.

  • 락(Lock), R락(RLock) : 말 그대로 자물쇠처럼 동작한다. 한 번에 하나의 스레드만 사용할 수 있는 자원의 액세스 구간에 락을 설치하면, 락을 획득한 스레드 1개만 실행되고 나머지는 기다리게 된다.
  • 세마포어(Semaphore) : 동시에 사용 가능한 수가 2개 이상인 경우 락 대신 세마포어를 사용할 수 있다.
  • 이벤트(Event) : 달리기의 출발점을 상상하면 된다. 하나 이상의 스레드가 이벤트를 기다리고 있다가, 누군가가 시그널을 주면 일제히 동작을 시작한다.
  • 컨디션(Condition) : 이벤트와 락이 결합한 형태로 일종의 조건부 락이라고 볼 수 있다. 달리기 출발점이 있고, 그 바로 앞에 자물쇠가 하나 있다. 누군가가 하나 혹은 전체 스레드에게 시그널을 주면 앞에 있는 Lock을 획득한 순서대로 진행을 시작한다.
  • 타이머(Timer) : 특정 시간만큼 지연 후 실행한다.
  • 배리어(Barrier) : 정해진 압력 이상의 스레드가 대기하면 무너지는 출발선. 이벤트나 컨디션과 달리 제3자가 시그널을 보내지 않고 미리 정한 수 이상의 스레드가 모이면 자동으로 해제된다.

락에 대한 가장 명료한 비유는 칸이 하나 밖에 없는 화장실이다. 말 그대로 자물쇠처럼 이를 선점한 스레드가 락을 획득하면, 자물쇠가 잠긴다. 이후에 접근하는 스레드들은 락이 열릴 때까지 그 앞에서 멈춰기다렸다가, 락을 선점한 스레드가 락을 풀어주면 차례로 획득 > 해제를 반복하면서 순차적으로 볼일을 보게 된다.

락을 사용하기 위해서는 스레드들의 외부, 대충 메인스레드 쯤에서 락 인스턴스를 생성한다. 그리고 한정된 자원을 액세스하려는 시점에서 락 객체의 acquire() 메소드를 호출한다. 만약 락이 잠겨있지 않았다면 이 메소드는 락 자신의 상태를 변경하면서 즉시 리턴한다. 하지만 만약 다른 스레드가 선점해서 락이 잠겨있는 상태라면 이 메소드는 락을 점유할 수 있을때까지 해당 메소드를 블럭킹할 것이다.

끝으로 리소스에 대한 사용을 마치면 락의 release()를 호출하여 자물쇠를 해제한다. 그러면 같은 락에 대해서 aquire()를 호출한 다른 스레드 중 하나가 다시 락을 점유하고 실행되는 식으로 동작한다.

## main thread
counter_lock = threading.Lock()
counter = 0
...
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
## worker thread A
counter_lock.acquire()
counter += 1
lock.release()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
## worker thread B
counter_lock.acquire()
print(counter)
lock.release()
  1. 락은 어느 시점이든 미리 생성해 놓기만하면 된다. 다만 락에 접근하는 모든 스레드는 락을 참조할 수 있어야 하므로 모두 인자로 락 객체를 받아야 할 것이다.
  2. 스레드A는 변수 counter를 변경하려한다. 다른 어떤 스레드가 counter를 동시에 변경하려할지 모르기 때문에 lock을 걸고 이를 처리한 후 락을 풀어준다.
  3. 스레드B는 반대로 그냥 counter 값을 읽기만 한다. 하지만 역시 읽는 중간에 값이 변경되면서 깨진 값을 참조할 가능성이 있기 때문에 안전한 참조를 위해서는 락을 걸어줄 수 있다.

아래는 간단한 자원 경쟁의 예이다. 두 개의 스레드가 하나의 값을 읽은 후 (아주 잠깐 다른 일을 하고) 거이에 1을 더한다. 하지만 아무런 보호가 없기 때문에 변경한 후의 값을 보면 1이 아닌 2씩 더해지고 있는 것을 확인할 수 있다.

로그를 남기는 문제 역시 자원 경쟁의 상황에 처한다. 만약 동시에 두 개 스레드에서 print() 함수를 실행하면 출력되는 결과가 뒤죽박죽으로 섞일 수 있다. 이를 피하기 위해 logging 모듈을 사용하였다.

from threading import Thread, Lock
import logging
import time
logging.basicConfig(
        level=logging.DEBUG,
        format="%(threadName)s| %(message)s")
res = {'counter': 0}
def worker():
    for _ in range(10):
        logging.debug('About to access counter')
        logging.debug(f"counter= {res['counter']}")
        r = res['counter']
        time.sleep(0.1)
        res['counter'] += 1
        logging.debug(f"counter= {r} -> {res['counter']}")
def main():
    ts = [Thread(name=f'Thread{i+1:02d}',
                 target=worker)
          for i in range(2)]
    for t in ts:
        t.start()
    for t in ts:
        t.join()
if __name__ == '__main__':
    main()

이 문제는 Lock을 사용하면 된다. 주의할 점은 모든 스레드가 하나의 락을 잡아야 한다는 것이며, 따라서 스레드 바깥에서 생성된 락을 스레드에 전달해주거나 전역 범위에 존재해야 한다.

이벤트를 제외한 대부분의 treading 동기화 프리미티브들은 컨텍스트 매니저 문법을 지원한다. 코드 가독성 측면에서도 acquire()release()를 앞뒤로 넣는 것 보다 with 문을 쓰는 편이 들여쓰기에 의해서 화장실 안과 밖의 코드를 구분하기에도 좋을 것이다. Lock외에도 획득~해제의 매커니즘을 따르는 다른 동기화수단들도 모두 컨텍스트 매니저 프로토콜을 따르고 있으니 참고하자.

다음은 Lock을 통해서 정해진 자원을 동시에 액세스하는 스레드가 없도록 수정한 코드이다. 실행해보면 항상 카운터 값은 1씩 증가하는 것이 보장된다.

from threading import Thread, Lock
import logging
import time
logging.basicConfig(
        level=logging.DEBUG,
        format="%(threadName)s| %(message)s")
res = {'counter': 0}
lock = Lock()
def worker():
    for _ in range(10):
        logging.debug('About to access counter')
        with lock:
            logging.debug(f"counter= {res['counter']}")
            r = res['counter']
            time.sleep(0.1)
            res['counter'] += 1
            logging.debug(f"counter= {r} -> {res['counter']}")
def main():
    ts = [Thread(name=f'Thread{i+1:02d}',
                 target=worker)
          for i in range(2)]
    for t in ts:
        t.start()
    for t in ts:
        t.join()
if __name__ == '__main__':
    main()

RLock

락은 내부에 정수 1을 가지고 있다가, 어떤 스레드가 acquire()를 호출하면 그 값을 0으로 바꾼 후 즉시 리턴한다. 그리고 그 스레드가 release()를 호출하면 다시 1로 복구시킨다. 만약 내부 값이 0인 상태에서 다른 스레드가 acquire()를 호출하면 그 값이 1이 될 때까지 기다렸다가 다시 0으로 만들고 리턴하는 식으로 한 번에 하나의 스레드가 acquire() ~ release() 구간을 지나게 하는 것이다.

그런데 재귀식으로 동작하는 함수가 자기 자신이 잠근 락을 다시 획득하려하면 어떻게 될까? acquire()는 영원히 리턴하지 않을 것이기 때문에 스레드가 마비된다. 이 교착을 해결할 수 있는 도구가 R락(Recursive Lock, 재귀락)이다. R락은 자신을 잠근 스레드라면 여러 번 잠그는 것을 허용하는 락이다. 스레드는 락을 획득한 후 반드시 잠근 횟수만큼 release()를 호출하여 락을 완전히 풀어야 한다.

세마포어

락이 한 칸짜리 화장실이라면 세마포어는 칸이 여러개인 화장실에 비유할 수 있다. 세마포어는 생성할 때 동시 접근 가능한 스레드의 수를 지정하며, 포화가 되기 전까지는 여러 스레드가 동시에 획득하는 것을 허용한다. 동시 사용 가능한 수가 2 이상인 자원을 액세스할 때 사용할 수 있다.

pool_sema = threading.Semaphore(3)  ## 카운터가 3인 세마포어 생성
...
with pool_sema:  ## <-- 아래 구간을 사용중인 스레드가 3개인 경우, 대기하게 된다.
  res = urlopen(url)
  with open(filename, 'wb') as f:
    f.write(res.read())

이벤트

이벤트는 임의의 출발선에 해당한다. 워커 스레드가 초반에 어떤 처리를 하다가 중간 어느 시점에서 다른 워커 스레드들을 기다려야 할 때 유용하다. (물론 이런 경우를 쉽게 생각하기는 어렵겠다.) 워커A는 B를 기다리고 B는 A를 기다려야 하는 상황이 있을 때, 서로가 서로를 기다리다가 교착에 빠지는 함정을 만들 수 있다. 이럴 때에도 이벤트를 사용해서 풀어낼 수 있을 것 같다.

이벤트는 내부에 어떤 플래그값을 가지고 있다. 초기 상태는 False이다. 이 때 워커 스레드들이 이벤트 객체에 대해서 wait()를 호출하면 그 지점에서 대기하게 된다. 그러다가 제 3의 스레드에서 set()을 호출하여 이벤트의 플래그를 True로 변경하면 이벤트를 기다리던 모든 스레드들의 wait() 메소드가 리턴하면서 동시에 재개하게 된다.

이미 set()된 이벤트에 대해 wait()을 호출하면 즉시 리턴하고 넘어간다. 이벤트는 한 번 사용한 후에 clear()를 호출하여 재사용할 수 있다.

컨디션

컨디션은 이벤트와 락이 결합된 형태의 동기화 장치이다. 기본적으로 스레드들은 wait()를 통해서 특정한 시그널을 기다린다는 점에서는 이벤트와 비슷하다. 하지만 시그널을 받은 스레드들이 모두 이후 진행을 할 수 있는 것은 아니고, 시그널을 받음과 동시에 락을 얻어서 하나씩 실행한다는 차이가 있다.

락을 통한 자원의 선점에서는 해당 자원이 ‘항상 존재하는’것을 상정한다. 컨디션은 이 자원이 사용가능 여부가 조건으로 들어간다. 따라서 하나의 생산자 스레드와 여러 개의 소비자 스레드가 결합하는 지점에 사용될 수 있다.

  1. 생산자 스레드는 데이터를 준비하는 과정에 있다고 가정한다.
  2. 소비자 스레드들은 사용할 데이터가 마련될 때까지 wait()을 호출하여 데이터를 기다린다.
  3. 생산자는 1개 혹은 2개 이상 혹은 전체 소비자에 대해서 notify()/notify_all()을 통해서 데이터가 준비되었음을 알린다.
  4. 기다리던 소비자 스레드들은 락을 얻어서 차례대로 데이터를 얻어갈 수 있다.

컨디션 객체를 생성할 때 기존에 존재하는 Lock/RLock 인스턴스를 전달할 수 있으며, 생략하는 경우 내부에서 RLock 인스턴스를 생성한다. 소비자 스레드들은 컨디션이 제공하는 락을 얻고 해제하는 과정을 필요로 하므로 with 절 내에서 사용해야 한다.

cv = threaing.Condition() ## RLock이 하나 자동으로 생성된다.
aList = [...]
## 데이터를 갖다 쓰는 스레드B
with cv:
  while not aList:
    cv.wait() ## 1
  p = aList.pop(0)
  ... # process p
## 데이터를 공급하는 스레드A
with cv:
  while True:
    cv.clear()
    ... # 데이터를 수집
    if data:
      aList.append(data)
      cv.notify()  ## 2

위 코드는 간단한 큐를 사용한 스레드간 데이터 교환을 보여준다.

  1. 데이터를 갖다 쓰는 스레드 B는 aList의 원소가 없으면 cv.wait()를 호출하여 컨디션(여기서는 리스트에 새 값이 있음)을 만족할 때까지 블럭한다. 여기서 wait()이 리턴하더라도 데이터가 다른 스레드에 의해 소진되는 경우가 있을 수 있기 때문에 반복해서 체크해야 한다.
  2. 데이터를 생산하는 스레드 A는 새로운 쓸만한 데이터를 찾으면 aList에 추가한 후 notify()를 호출해서, 해당 조건을 기다리는 다른 스레드에게 데이터가 사용 가능함을 알린다.
  3. 스레드 B는 컨디션에 의해 시그널을 받고 데이터를 확인하면 while 루프를 탈출한다.

조건을 체크하는 함수나 메소드가 따로 있다면 while 루프를 돌지 않고 wait_for(predicate=, timeout=None)을 호출하는 것으로 대신할 수 있다.

배리어

배리어는 일종의 묻지마 버스를 생각할 수 있다. 이벤트와도 유사한데, 누군가 시그널을 보내는 이벤트가 아니라, 배리어에 걸리는 압력이 임계값보다 커지면 무너지는 장벽이라 생각하면 된다. 정해진 개수의 스레드가 wait()를 호출하면, 기다리고 있던 모든 스레드가 일제히 동작을 개시하게 된다. 획득-해제 매커니즘을 따르지 않으므로 with 구문을 사용하지 않는다.

타이머

타이머는 단순히 지정한 시간만큼 함수의 실행을 지연시키는 효과를 가진다. 다른 친구들(?)처럼 별도의 클래스가 아니라 threading.Thread의 서브 클래스이다. Timer(interval, function, args=, kwds=) 의 형태로 만들어진 후 .start()를 호출하면 지정한 interval 만큼의 시간이 지난 후 스레드가 시작된다.

정리

이상으로 스레드 사용에 필요한 여러 클래스와 API들을 살펴보았다. 흔히들 파이썬의 스레드는 Global Interpreter Lock이라는 제약때문에 써봐야 아무런 효과가 없는 것으로 오해하기 쉬운데, 일련의 데이터를 병렬적으로 분산처리하여 취합하는 패턴이 아닌 non-block의 형태로 워커 스레드들을 돌리는 패턴은 서버나 데몬 혹은 GUI를 사용하는 앱에서는 꼭 필요한 부분이니 어떻게 사용하는지 정도는 알아둘 필요가 있겠다. 또한 Thread 클래스를 포함한 여러 기본제공 API들은 그 자체로도 충분한 사용성을 보여주고 있으니, 괜히 불필요한 서브클래싱없이 잘 활용할 수 있도록 연습해 두도록 하자.