주요 개념
- 병렬처리
- GIL(Global Interpreter Lock)
- Garbage Collection(GC)
- Thread
파이썬은 일반적으로 컴파일 언어보다 속도가 느리다. 이는 GIL(Global Interpreter Lock) 때문인데 이는 파이썬 객체에 대한 다중 접근을 보호하기 위한 Mutex(Mutual Exclusion, 상호 배제)로서 여러 쓰레드가 동시에 병렬적(Parallel)으로 실행하지 못하도록 하는 것이다. 따라서 한 파이썬 프로세스는 파이썬 인터프리터에 의해 한 쓰레드만이 작업 공간을 점유할 수 있다. 즉 파이썬에서 멀티 쓰레딩을 사용하게 되면 시분할 방식으로 프로세스들이 돌아가며 작업을 수행한다.
병렬 처리, 분산 처리를 통해 속도를 향상시키는 여러 기술이 나왔지만 파이썬은 해당 기능을 막음으로써 속도가 저하되는 결과를 초래한다. 이는 파이썬의 Garbage Collection(GC) 방식과 연관이 있다. GC란 메모리 관리 기법인데 동적으로 메모리를 할당했던 영역이 있다면 해당 메모리 공간이 필요 없어졌을 때 해제해주는 것이다. C나 C++ 같은 경우 malloc과 free를 통해 동적 메모리 할당 및 해제를 수동으로 수행할 수 있다. 이를 통해 메모리 공간을 제어해 메모리 누수를 방지하고 유효하지 않은 포인터에 접근하는 버그를 사전에 방지하는 등의 장점이 있다. 물론 익숙하지 않으면 쉽지는 않다.
하지만 Java나 C# 그리고 대부분의 스크립트 언어 등 객체지향 언어들은 대부분 이 GC을 사전에 염두에 두고 설계되어 사용자가 지정해주지 않아도 자동으로 GC가 수행된다. 파이썬은 GC의 방법 중 참조 횟수(Reference Count)를 이용한 방식을 채택했다. Reference Count 방식은 특정 객체를 가리키는 참조가 몇 개 존재하는지에 카운트하고 이 카운트 횟수가 0이 되면 메모리에서 해당 객체를 삭제시킨다. 또한 파이썬은 모든 것이 객체이다. 파이썬에서 참조 횟수를 보는 함수는 아래와 같다.
import sys
# x 참조 횟수 : 1
x = []
print(sys.getrefcount(x)) # 1+1
# x 참조 횟수 : 2
y = x
print(sys.getrefcount(x)) # 2+1
# x 참조 횟수 : 2
print(sys.getrefcount(x)) # 2+1
# sys.getrefcount(x)에 의한 참조가 +1
위의 코드의 결과를 보면 알 수 있듯, 실제로 소스코드를 실행할 때 다중 쓰레드가 파이썬 인터프리터를 동시에 실행하면 공유 자원에 대한 관리가 제대로 이루어지지 않는 Race Condition(경쟁 상태)이 발생할 수 있다. 이를 방지하기 위해 GIL을 통해 상호 배제(Mutex)를 수행해주어야 한다. 또한 멀티 쓰레딩을 사용하게 되면 어차피 병렬 처리가 불가능한 상황인데 페이징(Paging) 수행 시 발생하는 문맥 교환(Context Switching)이라는 코스트가 큰 연산을 수행해야 하므로 속도가 더 느려지게 될 수 있다.
import time
import threading
def loop():
for i in range(10000):
pass
# Single Thread
start = time.time()
loop()
loop()
print('Single Thread time : ', (time.time() - start))
# Multi Thread
start = time.time()
thread1 = threading.Thread(target=loop)
thread2 = threading.Thread(target=loop)
thread1.start()
thread2.start()
thread1.join()
thread2.join()
end = time.time()
print('Multi Thread time : ', (time.time() - start))
# Single Thread time : 0.00048804283142089844
# Multi Thread time : 0.0006620883941650391
원래는 Multi Thread 시간이 더 오래 걸려야 정상이다. 하지만 숫자를 높이면 Multi Thread가 더 빠를 때도 있는데 해당 3.8 버전에서 멀티 쓰레딩 성능 향상에 대한 업데이트가 있었던지, 내가 모르고 있는 부분이 더 있던지 해당 부분은 더 공부해야 할 듯하다.
위의 경우 이외에 멀티 쓰레딩이 확실히 더 빠른 경우도 있다. 만약 외부 연산(I/O, Sleep 등)을 수행 중 CPU가 기다리기만 할 때 다른 쓰레드로의 전환되게 되어 있다. 이때는 다른 쓰레드가 실행되어도 공유 자원의 Race Condition 문제가 발생하지 않기 때문이다.
import time
import threading
def sleep_loop():
time.sleep(5)
# Single Thread
start = time.time()
sleep_loop()
sleep_loop()
end = time.time()
print('Single Thread time : ', time.time() - start)
# Multi Thread
start = time.time()
thread1 = threading.Thread(target=sleep_loop)
thread2 = threading.Thread(target=sleep_loop)
thread1.start()
thread2.start()
thread1.join()
thread2.join()
print('Multi Thread] time : ', time.time() - start)
# Single Thread time : 10.010138988494873
# Multi Thread] time : 5.0063769817352295
위와 같이 외부 연산이 빈번하다면 멀티 쓰레딩으로 성능 향상을 기대해볼 수 있으므로 무조건적인 배제보다는 적절한 배치 방식을 익히는 것이 더 좋을 듯하다.