슬랙채널에서 dialog를 통해 사용자가 추천하는 도서정보를 입력받으면 노션 페이지에 정보를 넣어주는 슬랙봇을 만들고 있다. 슬랙봇이 기능은 아래와 같다.
대충 보기만해도 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()
를 싱글쓰레드로 실행하는 데 걸리는 시간을 출력한다. limit
을 50000000
으로 줬을 때 싱글쓰레드에서 소요되는 시간은 약 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.org 에 limit
횟수 만큼 요청을 날리는 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. 메모리 누수
- 쓰레드 A가
obj
를 처음으로 생성해서ob_refcnt += 1
를 하면ob_refcnt
는 1이 된다. - 쓰레드 B가
obj
를 참조하는 포인터를 생성해서ob_refcnt += 1
를 하면ob_refcnt
는 2가 된다. - 쓰레드 A, B가 동시에
obj
를 참조하는 포인터를 없앰으로써 동시에ob_refcnt -= 1
을 한다. 이 때ob_refcnt
는 1이 된다. - 두 쓰레드는 더 이상
obj
를 참조하지 않으나ob_refcnt
는 1 이므로 cpython은obj
를 메모리에서 없애지 않는다. 이 때 메모리 누수(memory leak)가 발생한다.
B. 참조 에러
- 쓰레드 A, B가 동시에
obj
를 참조하는 포인터를 생성하여ob_refcnt += 1
을 한다. 이 때ob_refcnt
는 1 이다. - 쓰레드 A가
obj
를 참조하는 포인터를 먼저 없앰으로써ob_refcnt -= 1
을 한다. 이 때ob_refcnt
는 0 이므로 cpython이 메모리에서obj
를 제거한다. - 쓰레드 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 처리 둘 다 하는 작업을 병렬로 실행해야 할 때가 많으므로 왠만하면 멀티프로세싱으로 처리하는 게 낫다.
참고자료