I/O bound API를 구현할 때 multithreading/multiprocessing 중 어떤게 더 효율적일까?

Created
Nov 8, 2020
Created by
Tags
Python
Property
 
슬랙채널에서 dialog를 통해 사용자가 추천하는 도서정보를 입력받으면 노션 페이지에 정보를 넣어주는 슬랙봇을 만들고 있다. 슬랙봇이 기능은 아래와 같다.
 
 
💡
1. slack dialog에서 도서 정보를 입력받는다. 2. slack API에 요청한다. 3. opengraph.io API에 요청한다. 4. opengraph.io API에 요청한 결과를 노션 페이지에 업로드한다. 이 때 1~4번까지 blocking으로 처리하고 사용자에게 응답하면 타임아웃이 발생하기 때문에 2번이 끝나면 사용자에게 바로 응답하고 3, 4번은 백그라운드로 처리할 것이다.
 
대충 보기만해도 I/O bound 하다. 위 기능을 하는 슬랙봇을 파이썬으로 구현하려고 할 때 3,4번은 쓰레드나 프로세스를 하나 생성해서 병렬로 처리할 수 있다. 그렇다면 쓰레드와 프로세스 중에 어떤 것으로 처리하는 게 더 나을까? 또 구현하려는 기능이 I/O-bound 하지 않고 막대한 CPU연산을 요구했더라면 어떨까?
 
 
 
 

1. multi-threading

 
 

a. CPU-bound인 경우

 
파이썬에서 멀티쓰레드를 사용한다고 할 때 먼저 떠오르는 것은 GIL(Global Interpreter Lock)이다.
GIL이란 cpython이 컴파일한 바이트코드를 인터프리터가 한 순간에 딱 한 번만 실행하도록, 즉 어떤 쓰레드가 바이트코드를 실행중일 때 다른 쓰레드는 실행하지 못하도록 하는 Lock을 가리킨다.
(cpython은 .py 확장자인 파이썬코드를 .pyc 확장자인 바이트코드로 컴파일하는 역할을 하면서 컴파일된 바이트코드를 실행하는 인터프리터 역할도 한다.)
한 쓰레드가 Lock을 놓을 때까지 다른 쓰레드는 기다려야한다면 멀티쓰레드로 구현하더라도 속도 면에서 효율을 누리지 못할 것이다. 한 가지 예시를 보자.
 
count_up()은 CPU-bound(CPU에 종속적인) 함수로, limit 횟수만큼 반복문을 실행한다. single_thread()count_up()를 싱글쓰레드로 실행하는 데 걸리는 시간을 출력한다. limit50000000으로 줬을 때 싱글쓰레드에서 소요되는 시간은 약 2.39초다.
 
import time from threading import Thread def count_up(limit): cnt = 0 for _ in range(limit): cnt += 1 def single_thread(): start = time.time() job(50000000) end = time.time() print(f'running time in single thread : {end - start}') if __name__ == '__main__': single_thread() # running time in single thread : 2.390449285507202
 
그렇다면 멀티쓰레드에서는 어떨까? 2개의 쓰레드가 각각 50000000 / 2 = 25000000 만큼의 limit으로 count_up()을 실행하면 소요시간이 1/2 으로 줄어들 것 같지만 그렇지 않다.
macOS 환경에서 $ sysctl -n hw.ncpu 명령어로 CPU 코어가 12개인 것을 확인한 후 multi_thread()에서 2개의 쓰레드 t1, t2를 생성하여 t1, t2를 동시에 실행시킬 때, 두 쓰레드가 종료되기까지의 시간은 싱글쓰레드가 종료되기까지의 시간과 비슷하다.
하나의 쓰레드가 GIL을 얻어서 놓을 때까지 다른 쓰레드는 기다려야 하기 때문이다. 즉 두 쓰레드는 동시에 실행되는게 아니라 Lock의 take/drop을 반복하면서 번갈아 실행된다.
 
import time from threading import Thread def job(limit): cnt = 0 for _ in range(limit): cnt += 1 def multi_thread(): start = time.time() t1 = Thread(target=job, args=(50000000 // 2,)) t2 = Thread(target=job, args=(50000000 // 2,)) t1.start() t2.start() # join() # 한번 시작한 쓰레드가 종료될 때 까지 다른 곳에서 쓰레드를 실행하지 못하도록 blocking 하고 # 쓰레드가 종료될 때까지 기다리도록 한다. t1.join() t2.join() end = time.time() print(f'running time in multi thread : {end - start}') if __name__ == '__main__': multi_thread() # running time in multi thread : 2.399947166442871
 
cpython의 PyEval_InitThreads()는 메인쓰레드에서 다른 쓰레드를 새로 생성하기 전에 호출되는 C API로, create_gil()으로 GIL을 생성하고 생성한 쓰레드가 GIL을 점유하도록 take_gil()을 호출한다.
PyEval_ReleaseThread()는 쓰레드를 종료하기 전에 쓰레드가 점유했던 GIL을 풀도록 drop_gil()을 호출한다. drop_gil()을 호출하려면 GIL을 drop하려는 쓰레드가 GIL을 점유하고 있어야 한다.
 
 

b. IO-bound인 경우

 
그럼 파이썬의 멀티쓰레드는 IO-bound 프로세스에서도 장점이 없는걸까?
http://www.python.orglimit 횟수 만큼 요청을 날리는 open_url()을 싱글쓰레드로 호출하는 single_thread()와 2개의 쓰레드에서 각각 limit / 2 횟수 만큼 호출하는 multi_thread()를 아래와 같이 구현했다.
 
import time from threading import Thread from urllib import request, error def open_url(limit): for p in range(limit): try: request.urlopen('http://www.python.org') except error.URLError: pass def single_thread(): start = time.time() open_url(50) end = time.time() print(f'running time in single thread: {end - start}') def multi_thread(): start = time.time() t1 = Thread(target=open_url, args=(50 // 2,)) t2 = Thread(target=open_url, args=(50 // 2,)) t1.start() t2.start() t1.join() t2.join() end = time.time() print(f'running time in multi thread: {end - start}')
 
그리고 두 함수를 실행하는 데 걸린 시간은 아래와 같았다.
 
if __name__ == '__main__': single_thread() # running time in single thread: 25.162996768951416
if __name__ == '__main__': multi_thread() # running time in multi thread: 13.440611839294434
 
2개의 쓰레드로 실행했을 때 싱글쓰레드에 비해 1/2 정도로 시간이 단축되었다. 네트워킹 또는 디스크 접근과 같이 메모리와 연관이 없는 blocking I/O를 해야할 때는 쓰레드가 GIL을 풀게 되므로 다른 쓰레드가 GIL을 획득하여 메모리 접근이 필요한 일을 할 수 있기 때문이다.
 
 
 

 

2. multi-processing

 
 

a. CPU-bound인 경우

 
cpython이 GIL을 채택한 이유는 cpython이 thread-safe하지 않게 메모리를 관리하기 때문이다.
thread-safe 하지 않다는 것은 여러 쓰레드가 하나의 메모리 공간에 동시에 접근함으로써 race condition이 발생함을 뜻한다.
cpython은 ob_refcnt(객체를 참조하는 포인터의 갯수를 나타내는 값)이 0인 PyObject객체를 대상으로 가비지 콜렉팅(garbage collecting)을 하는데, ob_refcnt는 모든 쓰레드들이 공유한다.
어떤 객체의 ob_refcnt를 확인하려면 sys모듈의 getrefcount()를 사용하면 된다.
 
import sys a = [1, 2, 3] print(sys.getrefcount(a)) # 2 # 'a'가 [1,2,3]을 참조하고 getrefcount()의 호출로 인해 참조 갯수가 하나 더 늘어서 # [1,2,3]의 ob_refcnt는 2다.
 
ob_refcnt는 모든 쓰레드들이 공유하는 값이므로 쓰레드 간의 상호 배제가 없다면 race condition이 발생하게 되고 메모리 누수, 참조에러를 일으키는 원인이 된다. 쓰레드 A, B가 공유하는 객체 obj가 있다고 하자.
 
A. 메모리 누수
  1. 쓰레드 A가 obj를 처음으로 생성해서 ob_refcnt += 1를 하면 ob_refcnt는 1이 된다.
  1. 쓰레드 B가 obj를 참조하는 포인터를 생성해서 ob_refcnt += 1를 하면 ob_refcnt는 2가 된다.
  1. 쓰레드 A, B가 동시에 obj를 참조하는 포인터를 없앰으로써 동시에 ob_refcnt -= 1 을 한다. 이 때 ob_refcnt는 1이 된다.
  1. 두 쓰레드는 더 이상 obj를 참조하지 않으나 ob_refcnt는 1 이므로 cpython은 obj를 메모리에서 없애지 않는다. 이 때 메모리 누수(memory leak)가 발생한다.
 
B. 참조 에러
  1. 쓰레드 A, B가 동시에 obj를 참조하는 포인터를 생성하여 ob_refcnt += 1 을 한다. 이 때 ob_refcnt는 1 이다.
  1. 쓰레드 A가 obj를 참조하는 포인터를 먼저 없앰으로써 ob_refcnt -= 1 을 한다. 이 때 ob_refcnt는 0 이므로 cpython이 메모리에서 obj를 제거한다.
  1. 쓰레드 B가 1번에서 생성했던 포인터로 obj에 접근하려고 할 때, 없는 메모리에 접근하게 되므로 참조 에러가 발생한다.
 
위와 같은 사고를 방지하기 위해 cpython은 GIL을 채택했고 메모리 접근이 잦은 CPU-bound 프로세스에서 멀티쓰레드를 사용해도 별 이득이 없는 것이다.
그렇다면 멀티쓰레딩이 아닌 멀티프로세싱에서 CPU-bound 프로세스를 처리한다면 어떨까? 프로세스 내 메모리를 공유하는 쓰레드가 아니라 서로 독립된 메모리 공간을 갖는 프로세스들을 동시에 실행하는 것이라 멀티쓰레딩보다 빠르지 않을까?
 
멀티쓰레드로 CPU-bound 프로세스를 처리하는 경우에서 작성한 single_thread(), multi_thread()와 같은 일을 하는 single_process(), multi_process()를 아래와 같이 작성했다. multi_process()는 쓰레드 대신에 프로세스 p1, p2를 생성하여 동시에 실행하도록 했다.
import time from multiprocessing import Process def job(limit): cnt = 0 for _ in range(limit): cnt += 1 def single_process(): start = time.time() job(50000000) end = time.time() print(f'running time in single process : {end - start}') def multi_process(): start = time.time() p1 = Process(target=job, args=(50000000 // 2,)) p2 = Process(target=job, args=(50000000 // 2,)) p1.start() p2.start() p1.join() p2.join() end = time.time() print(f'running time in multi process : {end - start}')
 
결과는 예상대로 multi_process()의 실행시간이 single_process()의 실행시간보다 1/2 정도로 짧았다.
 
if __name__ == '__main__': single_process() # running time in single process : 2.4586586952209473
if __name__ == '__main__': multi_process() # running time in multi process : 1.2835493087768555
 
 

b. IO-bound인 경우

 
CPU-bound와 마찬가지로 프로세스 별로 독립적인 메모리 공간을 갖기 때문에 GIL에 의한 비용이 발생하지 않아서 단일 프로세스에 비해 시간적으로 더 효율적일 것이다.
 
 

3. 결론

 
CPU-bound 프로세스를 동시에 처리하려면 멀티쓰레딩이 아닌 멀티프로세싱을 사용하는 게 속도 면에서 효율적이고, IO-bound 프로세스를 동시에 처리하려면 두 가지 모두 사용해도 무방할 듯 하다. 하지만 복잡한 도메인을 구현하다 보면 CPU 연산과 IO 처리 둘 다 하는 작업을 병렬로 실행해야 할 때가 많으므로 왠만하면 멀티프로세싱으로 처리하는 게 낫다.
 
 
 
 
참고자료