여러 스레드의 사용이 꼭 성능의 개선을 보장하진 않는다.
Python에서 thread를 사용하려면, GIL(Global Interpreter Lock)을 이해하고 있어야 한다.
Python의 표준 구현인 CPython은 소스 코드를 구문분석해 바이트코드를 생성하고 이 바이트코드를 인터프리터로 실행하는데, 일관성을 강제로 유지하기 위해 GIL을 사옹한다. GIL은 상호 배제 락 (mutex)이고, 한 스레드가 다른 스레드를 인터럽트할 수 없다. 덕분에 인터프리터 상태가 불필요하게 오염되는 것을 방지한다.
연산 성능과 멀티 스레드
하지만, GIL 때문에 멀티 스레드를 사용해 계산량이 많은 여러 작업을 각 스레드에 할당해 실행하더라도 성능이 향상되지 않을 수 있다. (오히려 스레드 추가와 조정에 대한 비용으로 성능이 하락할 수도 있다.)
def factorial(n: int) -> int:
result = 1
while n > 1:
result * n
n -= 1
return result
factorial을 구하는 함수다.
순차적으로 실행해보고 소요시간을 측정해본다.
import time
if __name__ == '__main__':
numbers = [1423231, 982324, 842412]
start = time.time()
for n in numbers:
factorial(n)
end = time.time()
delta = end - start
print(f'duration: {delta} seconds')
# 결과: duration: 0.2546989917755127 seconds
다음으로 멀티스레드를 이용해본다.
from threading import Thread
class FactorialThread(Thread):
def __init__(self, n):
super().__init__()
self.n = n
def run(self):
self.factorial = factorial(self.n)
실행 후 시간을 측정해본다.
if __name__ == '__main__':
numbers = [1423231, 982324, 842412]
threads = []
start = time.time()
for n in numbers:
thread = FactorialThread(n)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
end = time.time()
delta = end - start
print(f'duration: {delta} seconds')
# 결과: duration: 0.2557549476623535 seconds
전혀 빨라지지 않았음을 알 수 있다.
이렇게만 보면 멀티 스레드를 사용하는 것은 불필요해 보인다. 하지만 멀티 스레드가 필요한 경우도 있다. 바로 블로킹(blocking) I/O를 처리하는 경우다.
Blocking I/O를 멀티 스레드로 다루기
디스크 I/O, File I/O, 네트워크 I/O 등, 블로킹 방식으로 작동하는 I/O를 다룰 때 멀티 스레드를 사용하면 분명한 성능 개선을 얻을 수 있다.
만약 서로 다른 5개의 엔드포인트에 대해 GET 요청을 진행한다고 하자. 각 요청은 응답을 받는 데까지 1초의 동일한 시간이 소요된다고 가정한다.
만약 순차적으로 5개의 GET 요청을 처리한다면 5초가 소요될 것이다. 하지만, 이 경우 스레드를 5개 생성해 작업을 진행하면 거의 1/5 수준으로 소요시간을 줄일 수 있다. 즉, 1초에 가까운 시간 만에 5개의 요청을 모두 처리할 수 있다.
어떻게 그럴 수 있을까?
Python 스레드가 시스템 콜을 하기 전 GIL을 해제하기 때문이다. 시스템 콜은 얼마든지 병렬로 실행될 수 있다.
5개의 스레드가 거의 동시에 작업을 시작하고, 응답에는 1초가 걸리므로 약 1초간 블로킹이 발생한다.
즉 블로킹 I/O를 스레드로 나눠 처리하면 성능 개선을 얻을 수 있다.