In CPython, the global interpreter lock or GIL is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecodes at once. The GIL prevents race conditions and ensures thread safety.
CPython에서 global intertreter lock(GIL)은 멀티 스레드가 파이썬 바이트코드들을 동시에 실행시키는 것을 막으며 파이썬 object에 대한 접근을 보호하는 뮤택스이다. GIL은 경쟁상태(race condition)을 예방하고 thread-safety를 보장한다.
https://wiki.python.org/moin/GlobalInterpreterLock
파이썬은 코드를 사전에 컴파일하지 않고, 실행 중 한 줄 한 줄 인터프리터를 통해 코드를 해석하며 작동하는 인터프리터 언어이다.
그리고 GIL, Global Interpreter Lock은 말 그대로 이러한 파이썬 코드를 해석하는 인터프리터를 글로벌하게 락한다는 이야기이다.
다시 말해, 하나의 프로세스 내에서 하나의 스레드가 파이썬의 인터프리터를 실행 중일 때 다른 스레드는 인터프리터를 사용할 수 없다.
따라서, 우리가 어떤 작업을 여러 갈래로 나누어 동시에 처리하기 위해 멀티스레딩을 사용할 때, 해당 작업이 파이썬으로 작동된다면 스레드가 아무리 많아도 인터프리터는 한 순간에 하나만 작동되므로 우리가 기대하는 멀티스레딩의 성능을 기대하기 힘들다.
오히려 스레드 간 컨텍스트 스위칭 비용 때문에 작업 성능이 저하될 우려도 존재한다.
파이썬은 왜 인터프리터에 락을 걸어놓았을까?
GIL의 존재 이유는 파이썬의 메모리 관리 방식이 Thread-Safe하지 않기 떄문이다.
파이썬은 객체가 참조되는 횟수 즉, reference count를 이용해 메모리 관리를 한다.
특정 객체가 참조될 때마다 reference count를 1씩 증가시켜주고, 참조가 해체될 때는 1씩 감소시키며 reference count가 0이 되면 해당 객체는 메모리에서 삭제된다.
이 reference count는 sys의 getrefcount함수를 사용해서 확인할 수 있다.
# reference count 테스트
import sys
class RefTest:
def __init__(self):
self.data = 1
test1 = RefTest()
print(sys.getrefcount(test1))
# 2
# test1을 정의할 때 한 번, getrefcount함수 사용할 때 한 번, 총 2번 참조
그리고, 멀티 스레드 환경에서 각 스레드들은 자신만의 독자적인 스택영역을 제외한 메모리를 다른 스레드들과 공유하는데, reference count 또한 이에 포함된다.
따라서, 만약 두 개 이상의 스레드에서 동일한 객체를 동시에 참조하게 되면 해당 객체의 reference count 값에 대한 Race Condition이 발생하고, 결국엔 부정확한 값이 reference count에 기록될 수 있다.
파이썬은 이러한 위험을 막기 위해 아예 인터프리터가 한 번에 한 개의 스레드에서만 동작하도록 만들게 된 것이다.
그렇다면 파이썬에서는 멀티 스레드를 사용하면 안될까?
파이썬에서 멀티 스레딩을 사용하는 건 무조건 불리하기만할까?
물론 그렇지 않다.
파이썬의 공식문서에 의하면 파이썬 역시 멀티스레드 기반의 병렬처리 모듈을 제공한다.
https://docs.python.org/ko/3.8/library/threading.html
만약 파이썬에서 멀티스레딩이 불리하기만 했다면 이런 모듈 자체를 안 만들지 않았을까 싶다.
GIL은 파이썬의 런타임과의 상호작용 여부에 의해 영향을 받는다.
따라서, 파이썬 런타임과 상호작용을 하는 CPU-Bound 작업들에서는 GIL이 계속 영향을 주기 때문에 멀티 스레드를 이용하더라도 성능 향상을 기대하기 힘든 반면, I/O-Bound 작업에선 극적이진 않더라도 어느 정도의 성능 향상을 기대할 수 있다.
CPU-Bound job
import time
TARGET_COUNT = 100000000
def count_down(n):
while n > 0:
n -= 1
print("카운트 다운 완료")
start_time = time.time()
count_down(TARGET_COUNT)
end_time = time.time()
print("it took ", end_time - start_time)
# 카운트 다운 완료
# it took 4.626717805862427
import time
from threading import Thread
TARGET_COUNT = 100000000
def count_down(n):
while n > 0:
n -= 1
print("카운트 다운 완료")
thread1 = Thread(target=count_down, args=(TARGET_COUNT // 2,))
thread2 = Thread(target=count_down, args=(TARGET_COUNT // 2,))
start_time = time.time()
thread1.start()
thread2.start()
thread1.join()
thread2.join()
end_time = time.time()
print("it took ", end_time - start_time)
# 카운트 다운 완료
# it took 4.598112106323242
1억부터 0까지 카운트다운을 해주는 함수를 실행시켜보자.
while문 안에서 계속해서 연산을 해주는 CPU-Bound한 작업이다.
보다시피, 스레드를 사용하지 않은 경우와 사용한 경우 사이에 성능의 유의미한 차이가 존재하지 않음을 확인할 수 있다.
I/O Bound Job
CPU-Bound 작업과는 다르게, I/O Bound 작업에서는 그래도 단일 스레드보다는 성능의 향상을 조금 더 기대할 수 있다.
물론, 한 번에 하나의 스레드만이 인터프리터를 작동시킨다는 것은 동일하다는 점에서 무슨 성능 향상이 있을까 싶지만, 프로그램 실행 중 I/O 인터럽트 발생 시 파이썬은 GIL을 release한다.
이 때, 단일 스레드라면 I/O 작업이 완료될 때까지 다른 작업들이 block되지만, 멀티 스레드를 사용한다면 기존 실행 중이던 스레드가 GIL을 release하자마자 다른 스레드가 곧바로 GIL을 넘겨받게 되므로, I/O 작업 처리 시 발생하는 딜레이들을 줄일 수 있다.
그 외에도, 파이썬의 Numpy 라이브러리를 이용한 number crunching 작업도 GIL 밖에서 처리된다고 한다.
이로써 Numpy를 이용한 연산이 일반적인 파이썬 코드와 비교해 어떻게 이렇게 빠를 수 있는지도 이해할 수 있게 되었다.
import numpy as np
# datas의 분산을 구하는 코드
def variance(datas):
x_ = np.mean(datas)
var = 0
for xi in datas:
var += (xi - x_) ** 2
return var / len(datas)
물론 Numpy 라이브러리에 구현된 코드는 내가 짠 코드와 무언가 다르겠지만, 동일한 크기의 데이터에 대하여 3.91초 vs 21.7마이크로초로 미친듯한 차이를 보여준다.
'언어 > Python' 카테고리의 다른 글
[Python] 파이썬의 매직 메소드 (Magic Method) (0) | 2022.01.26 |
---|---|
[Python] 아스키코드 사용하기 (0) | 2022.01.19 |
[Python] heapq(우선순위 큐) 사용법 (0) | 2021.09.19 |
[Python] 집합 자료형 다루기 (0) | 2021.08.04 |
[Python] collections 모듈의 Counter 함수 (2) | 2021.04.04 |