본문 바로가기
IT/Internals

Global Interpreter Lock (GIL)

by 물통꿀꿀이 2019. 8. 11.

해당 글은 아래 글을 번역 및 의역한 것이다. (보다 자세한 부분은 첨부된 페이지를 참조)

https://realpython.com/python-gil/#why-wasnt-it-removed-in-python-3


Python의 Global Interpreter Lock (GIL)은 mutex (또는 lock)으로 "오직" 하나의 Thread가 Python Interpreter의 제어권을 가지고 있는 것이다. 즉, 한 시점에 하나의 Thread만 실행 상태에 있을 수 있다는 의미이다. 


GIL은 Single-threaded 프로그램에서 확인할 수는 없다. 대신에 CPU-Bound, Multi-Threaded 코드에서는 Bottlenect이 발생하여 성능에 악영향을 주는 것을 확인할 수 있다. 그 이유는 GIL은 멀티 코어를 사용하는 Multi-threaded 아키텍처 상에서도 오직 하나의 Thread만 허용하기 때문이다. (그래서 Python에서 악명이 높다.)


해당 글에서는 GIL이 Python 프로그램 성능에 얼마나 영향을 끼치는지 알아볼 것이다. 그리고 GIL의 영향을 최소화 할 수 있는 방안에 대해서도 다루어 볼 것이다.


Python에서 GIL은 어떤 문제를 해결 했을까?

Python은 메모리 관리를 위해 Reference Count을 사용한다. 즉, Python에서 생성된 객체는 Reference Count을 가지는데 이를 통해 객체에 연결되어 있는 Reference 개수를 확인 할 수 있다. 때문에 Reference Count이 0이라면 해당 객체는 메모리에서 Released (메모리를 사용하지 않음) 된다.


간단한 예시를 확인해보자.

>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a) 3 


위 예제에서 볼 수 있듯이, 빈 리스트에 대한 Reference Count는 3이다. 리스트 객체는 a, b에 의해 참조되고 sys.getrefcount()에 인자로 전달되었다.


다시 GIL로 돌아와서, Reference Count 변수는 Race Condition* 상황에서 보호가 필요하다는 것이다. 만약 그렇지 않으면 Memory Leak 및 더 심각하게는 존재하는 객체에 대해 메모리 Release가 발생할 수 있다. 이로 인해 Python 프로그램은 Crash 및 버그가 발생할 것이다. 때문에 Reference Count 변수는 Thread 간에 공유하는 자료 구조에 Lock을 추가하여 안전하게 보호해야 한다. 

(그러나 각 객체에 Lock을 추가하는 것은 Deadlock과 같은 또 다른 문제를 야기시킬 수도 있다. 더욱이 반복되는 Lock 할당/해제로 성능이 낮아질 수 있다.)

*2개의 Thread가 특정 값을 동시에 접근하여 변경하는 것


GIL은 Interpreter 자체적인 Lock으로 Python 바이트 코드를 실행하려면 Interpreter의 Lock을 획득해야 한다. 이렇게 함으로써 Deadlock을 방지하고 성능 Overhead가 많이 발생하지 않는다. 다만, 모든 CPU-Bound 프로그램을 단일 Thread로 만든다.


더군다나 GIL은 Python 외에도 Ruby 같은 여러 언어의 Interpreter에서 사용되고 있다. 이 중 몇몇 언어는 Garbage Collection과 같은 Thread-safe 메모리 관리를 위한 GIL의 요구사항 따르지 않고 몇몇 언어는 단일 Thread 성능 손실을 JIT 컴파일러와 같이 Boosting 기능을 붙여 보완하고 있다.


GIL은 왜 해결책으로 선택되었는가?

Python은 OS에서 Thread의 개념이 없을 때부터 있었다. Python은 개발을 빠르게 할 수 있도록 쉽게 사용 할 수 있게 설계되었다.


Python에 필요한 기능 확장은 기존 C 라이브러리에 의해 작성되었다. 그리고 일관성을 유지하기 위해 C 확장은 GIL에서 제공하는 Thread-safe 메모리 관리가 필요했다.

Lock만 관리하면 되기 때문에 단일 Thread 프로그램에서 성능을 높일 수 있다. (Thread-safe 하지 않은 C 라이브러리는 통합하기 쉬워졌다.)


그러므로 GIL은 CPython 개발자가 Python 초기에 직면했던 어려운 문제에 대한 실용적인 해결책이었다.


Multi-threaded 프로그램에서 영향

일반적인 Python 프로그램에서 본 것처럼 CPU-bound와 I/O-bound의 성능상 차이가 있다.


CPU-bound 프로그램은 Image Processing, Searching 등과 같은 수학적 계산이 필요한 프로그램으로 CPU를 많이 사용한다. 반면 I/O-bound 프로그램은 File, Database, Network 등등과 같이 대부분의 시간을 Input/Ouput을 기다리는데 사용한다.


그럼 CPU-bound 프로그램의 예를 살펴보자.

# single_threaded.py
import time
from threading import Thread

COUNT = 50000000

def countdown(n):
while n>0:
n -= 1

start = time.time()
countdown(COUNT)
end = time.time()

print('Time taken in seconds -', end - start)


$ python single_threaded.py
Time taken in seconds - 6.20024037361145

위의 예제는 단일 Thread에서 실행한 결과이다. 그렇다면 2개의 Thread가 병렬적으로 동작할 수 있도록 코드를 약간 수정해보면 다음과 같다.


# multi_threaded.py
import time
from threading import Thread

COUNT = 50000000

def countdown(n):
while n>0:
n -= 1

t1 = Thread(target=countdown, args=(COUNT//2,))
t2 = Thread(target=countdown, args=(COUNT//2,))

start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()

print('Time taken in seconds -', end - start)


$ python multi_threaded.py
Time taken in seconds - 6.924342632293701

양쪽 버전은 프로그램을 실행하는데 거의 비슷한 시간이 소요되었다. (Multi-threaded 버전에서는 GIL이 CPU-bound Thread가 병렬적으로 실행하는 것을 막기 때문)


GIL은 I/O-bound Multi-threaded 프로그램의 성능에 많은 영향을 끼치진 않는다. (I/O를 기다리는 동안 Lock이 Thread 간에 공유되기 때문에)

그러나 CPU-bound 프로그램에서는 Lock으로 인해 단일 Thread가 될 뿐만 아니라 실행 시간이 증가한다. 위 예제에서 본 것처럼 Lock의 할당, 해제로 인한 Overhead로 인해 실행 시간이 차이가 있다.


왜 GIL을 제거하지 않는가?

GIL은 분명 제거될 수 있다. 그러나 과거, 여러번 GIL을 제거하려는 시도는 GIL에 의존하는 C 확장성을 끊어놓았다.


물론 GIL을 해결할 수 있는 여러 방안이 있지만 Single-Threaded 및 Multi-Threaded I/O-bound 프로그램의 성능을 낮출 수 있다. (새버전에서 기존 Python 프로그램의 성능이 낮아 질 수 있다는 점을 받아들일 수 있을까?)


Guido van Rossum은 "It isn't Easy to remove the GIL"의 글에 다음과 같은 대답을 했다. 

"Single-Threaded 프로그램 (또는 Multi-Threaded)의 성능이 낮아지지 않다면 GIL을 제거하는 패치를 환영한다."

그리고 이 조건은 이후에 시도된 어떤 것으로도 충족되지 않았다.


GIL을 제거하는 것은 Python 2와 비교했을 때 Python 3의 Single-Threaded 성능을 느리게 만들 수 있다. 결과적으로 Python 3에서는 GIL이 존재한다. 그러나 Python 3은 GIL을 크게 개선했다.


"only CPU-bound", "only I/O-bound" Multi-threaded 프로그램에서 GIL의 영향에 대해 알아보았다. 그런데 일부는 I/O-bound, 일부는 CPU-bound 상황에 대해서는 어떨까?

보통 해당 상황에서는 GIL이 CPU-bound Thread에서 GIL을 얻을 기회를 주지 않아 I/O-bound Thread를 Starving 하는 것으로 알려져 있다.

이러한 이유는 고정된 연속 사용 간격 (a fixed interval of continuous use) 이후 GIL을 해제하도록하는 Python에 내장된 메커니즘 때문이다. 


>>> import sys
>>> # The interval is set to 100 instructions:
>>> sys.getcheckinterval()
100


이 메커니즘의 문제는 대부분의 경우 CPU-bound Thread가 다른 Thread가 GIL을 획득하기 전에 다시 GIL을 획득한다는 것이다. 해당 문제는 Python 3.2에서 해결되었다. (삭제된 다른 Thread의 GIL을 획득하기 위한 요청 수를 확인하고 다른 Thread가 실행되기 전에 현재 Thread가 GIL을 획득 할 수 없도록 하는 메커니즘 추가)


Python GIL를 다루는 방법

GIL이 프로그램에 문제를 발생시킨 취할 수 있는 몇몇 방법 있다.


Multi-Processing vs Multi-Threading: 가장 인기있는 방법은 Thread 대신에 Multi-Processing을 사용하는 것이다. 각각의 Python 프로세스는 자신만의 메모리가 존재하기 때문에 GIL이 문제가 되지 않는다. Python의 Multiprocessing 모듈을 프로세스를 쉽게 만들 수 있게 한다. (아래 참조)


from multiprocessing import Pool
import time

COUNT = 50000000
def countdown(n):
while n>0:
n -= 1

if __name__ == '__main__':
pool = Pool(processes=2)
start = time.time()
r1 = pool.apply_async(countdown, [COUNT//2])
r2 = pool.apply_async(countdown, [COUNT//2])
pool.close()
pool.join()
end = time.time()
print('Time taken in seconds -', end - start) 


$ python multiprocess.py
Time taken in seconds - 4.060242414474487

물론 프로세스 관리에 대한 기본 Overhead가 있기 때문에 위에서 첨부한 시간의 절반으로 줄어들진 않는다. 또한 프로세스가 여러 스레드보다 무겁기 때문에 Scaling bottlenect이 발생 할 수 있다.


Alternative Python Interpreters: Python은 여러 개의 Interpreter를 가지고 있다. (CPython, Jython, IronPython, PyPy 등등) GIL은 오직 CPython인 오리지날 Python에서만 존재한다. 


Just wait it out: Python 커뮤니티에서 CPython에서 GIL을 제거하기 위해 노력하고 있다.

댓글