파이썬의 표준 구현을 CPython 이라고 합니다. CPython은 1) 소스 코드를 구문 분석해서 8비트 명령어로 이루어진 바이트코드 (파이썬 3.6 부터는 16비트 명령어를 사용하므로 워드코드) 로 변환하고, 2) 스택 기반 인터프리터를 통해 바이트코드를 실행합니다. 바이트코드 인터프리터에는 파이썬 프로그램이 실행되는 동안 일관성 있게 유지해야 하는 상태가 존재하는데 CPython은 스레드 세이프하지 않은 메모리 관리를 쉽게 하기 위해, GIL (Global Interpreter Lock) 이라는 방법으로 여러 개의 스레드의 메모리 접근을 제한하는 형태로 일관성을 강제로 유지합니다. 즉, Figure 1에서 처럼 여러 개의 스레드가 병렬로 존재한다고 하더라도 실제로는 특정 순간에 하나의 스레드만 동작한다는 것이죠.
GIL은 근본적으로 상호배제 락 (mutual exclusive lock, mutex) 형식이며 한 스레드가 다른 스레드의 실행을 중간에 인터럽트시키고 제어를 가져오지 못하게 방지합니다. 독립적인 메모리 영역을 할당받는 프로세스와 달리 스레드는 Figure 2처럼 프로세스 내에서 stack 메모리만 각각 할당받고 나머지 메모리는 공유하는 방식이기에 스레드간 인터럽트가 발생하면 전역변수, garbage collector 참조변수 등의 인터프리터 상태가 오염될 수 있습니다. 따라서 CPython은 멀티 스레드라 하더라도 GIL을 이용해 하나의 스레드만 자원을 독점하게 하여 인터프리터 상태가 제대로 유지되고 바이트코드 명령어들이 제대로 실행되게 만든 것이죠.
하지만 스레드 세이프를 위한 GIL의 사용은 태생적으로 속도 향상을 위한 멀티 스레딩을 동시에 활용할 수 없다는 말이기도 합니다. 파이썬에서도 "threading" 모듈로 멀티 스레드를 지원하지만 GIL로 인해 Figure 1과 같이 여러 스레드 중 하나만 앞으로 진행할 수 있습니다. CPython 개발이 시작된 1994년에는 CPU가 하나였고 파이썬 구현 자체가 상당 부분이 라이브러리 전역 변수에 의존하고 다양한 곳에서 객체를 참조하기 때문에 이러한 선택은 괜찮았지만 현재 멀티 코어가 당연한 세상에서 하나의 스레드가 자원을 독점하는 방식은 속도 측면에서 굉장히 불리함을 갖습니다.
예를 들어 다음의 인수찾기 알고리즘처럼 계산량이 아주 많은 작업을 수행하고자 합니다.
def factorize(number):
for i in range(1, number):
if number % i == 0:
yield i
여러 수로 이뤄진 집합 내 모든 원소의 인수를 찾으려면 상당히 오랜 시간이 거리고 실행 결과 0.4074초 정도가 소요됩니다.
import time
numbers = [2139079, 1214759, 1516637, 1852285]
start = time.time()
for number in numbers:
list(factorize(number))
end = time.time()
print(end - start)
파이썬의 "threading" 모듈을 활용하여 멀티 스레드로 구현하고 실행시켜 보겠습니다.
from threading import Thread
class FactorizeThread(Thread):
def __init__(self, number):
super().__init__()
self.number = number
def run(self):
self.factors = list(factorize(self.number))
start = time.time()
threads = []
for number in numbers:
thread = FactorizeThread(number)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
end = time.time()
print(end - start)
하지만 결과는 0.4116 초로 멀티 스레드를 사용하지 않았을 때와 거의 결과가 비슷하고 속도가 오히려 약간 더 느려집니다. 즉, 파이썬에서 연산량이 많은 함수에 대해 진정한 병렬성을 통해 속도 향상을 얻기 위해서는 기본적으로 멀티 스레딩이 아닌 멀티 프로세싱을 활용하는 것이 보다 타당합니다.
파이썬에서 GIL은 멀티 스레드가 동작할 때 하나의 스레드가 끝날 때까지 프로그램을 점유하지는 않습니다. 스레드의 바이트코드를 실행시키면서 내부적으로 카운터를 1씩 증가시키고 보통 100의 배수가 되면 GIL을 풀고 다른 스레드로 문맥 전환 (context switch)가 이루어집니다. 즉, 여러 함수를 동시에 실행시키는 동시성을 충족시킬 수 있는 것이죠. (동시성이란 같은 시간에 여러 개의 작업을 동시에 처리하는 병렬성과 달리 컴퓨터가 같은 시간에 여러 다른 작업을 처리하는 것처럼 보이는 것을 말합니다. 여러 프로그램이 번갈아 실행되면서 프로그램이 동시에 수행되는 것 같은 착각을 불러일으키는 것이죠.)
특히, 파이썬에서 멀티 스레드를 지원하는 이유는 파일 쓰기나 읽기, 네트워크와 상호작용, 디스플레이 장치와 통신하기 등의 블로킹 I/O를 다루기 위해서입니다. 블로킹 I/O가 일어날 경우 파이썬 프로그램은 시스템 콜을 사용해 컴퓨터 운영체제가 자기 대신 외부 환경과 상호작용하도록 의뢰합니다. 이런 경우에 해당 스레드는 GIL을 해제하고 시스템 콜 요청에 응답하는데 걸리는 시간 동안 다른 스레드로 문맥 전환됩니다. 그리고 시스템 콜에서 반환되자마자 GIL을 다시 획득합니다. 따라서 이러한 블로킹 I/O를 다룰 경우에는 여러 스레드를 통해 프로그램을 병렬적으로 실행시킬 수 있습니다.
'Computer > Python' 카테고리의 다른 글
변수 영역과 클로저 (0) | 2021.06.27 |
---|---|
스레드 세이프 (Thread-safe) (0) | 2021.06.20 |
제너레이터와 yield (1) (0) | 2021.06.14 |
Numba (1) (0) | 2021.06.02 |
Pycharm - Python Interpreter (0) | 2021.05.11 |