파이썬에서 GIL이란 무엇일까?

SeungHyuk Shin·2022년 5월 5일
0
post-thumbnail

파이썬 Global Interperter Lock(GIL)은 간단히 말해서 하나의 스레드만 파이썬 인터프리터의 제어를 유지할 수 있도록 하는 뮤텍스(또는 잠금)이다.

즉, 한 시점에 하나의 스레드만 실행 상태에 있을 수 있다. GIL의 영향은 단일 스레드 프로그램을 실행하는 개발자에게 보이지 않지만 CPU 바인딩 및 다중 스레드 코드에서 성능 병목 현상이 발생할 수 있다.

GIL은 두 개 이상의 CPU 코어가 있는 멀티 스레드 아키텍처에서도 한 번에 하나의 스레드만 실행할 수 있기 때문에 파이썬의 인기 없는 기능으로 유명해졌다

GIL이 Python 프로그램의 성능에 어떤 영향을 미치는지, 그리고 GIL이 코드에 미치는 영향을 어떻게 완화시킬 수 있는지에 대해 알아보자


GIL이 Python을 위해 해결한 문제는 무엇일까?

파이썬은 메모리 관리를 위해 참조 카운팅을 사용한다. 이것은 파이썬에서 생성된 객체가 객체를 가리키는 참조 수를 추적하는 참조 카운트 변수를 가지고 있다는 것을 의미한다. 이 카운트가 0에 도달하면 개체가 점유한 메모리가 해제된다.

참조 카운팅의 작동 방식을 설명하기 위해 간단한 코드 예를 살펴보자.

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

##### output #####
3

위의 예에서 빈 목록 개체 []에 대한 참조 카운트는 3이다. 목록 개체가 a, b에 의해 참조되었으며 인수가 sys.getrefcount()에 전달되었다.

GIL로 돌아가기:

문제는 이 기준 카운트 변수가 두 개의 스레드가 동시에 값을 증가시키거나 감소시키는 경쟁 조건으로부터 보호가 필요하다는 것이었다. 이렇게 되면 해당 개체에 대한 참조가 존재하는 동안 해제되지 않는 메모리 누수가 발생하거나, 더 안좋은 상황은 해당 메모리를 잘못 해제하는 원인이 될 수 있다. 이로 인해 Python 프로그램에서 충돌 또는 "이상한" 버그가 발생할 수 있다.

이 참조 카운트 변수는 스레드 간에 공유되는 모든 데이터 구조에 잠금을 추가하여 일관되게 수정되지 않도록 함으로써 안전하게 유지할 수 있다.

그러나 각 개체 또는 개체 그룹에 잠금을 추가하면 여러 개의 잠금이 존재하여 데드락(데드락은 두 개 이상의 잠금이 있는 경우에만 발생할 수 있습니다.) 같은 또 다른 문제가 발생할 수 있다. 또 다른 부작용으로는 잠금 장치의 반복적인 획득과 해제로 인한 성능 저하가 있다.

GIL은 파이썬 바이트코드의 실행이 인터프리터 잠금을 획득해야 한다는 규칙을 추가해주는 인터프리터의 단일 잠금이다. 이렇게 하면 교착 상태(잠금이 하나뿐이므로)가 방지되고 성능 오버헤드가 크게 발생하지 않는다. 그러나 CPU 바인딩된 파이썬 프로그램을 효과적으로 단일 스레드로 만듭니다.

GIL은 Ruby와 같은 다른 언어의 인터프리터에서 사용되지만 이 문제에 대한 유일한 해결책은 아니다. 일부 언어는 가비지 수집과 같은 참조 계산 이외의 접근 방식을 사용하여 스레드로부터 안전한 메모리 관리를 위한 GIL과 같은 요구사항을 구현하지 않는다.

반면에 이는 해당 언어가 JIT 컴파일러와 같은 다른 성능 향상 기능을 추가하여 GIL의 단일 스레드 성능 이점의 손실을 보상해야 하는 경우가 많다는 것을 의미한다.


GIL이 선택된 이유는 무엇일까?

그렇다면 왜 그렇게 방해가 되는 것처럼 보이는 접근 방식이 Python에서 사용되었을까? 파이썬 개발자들의 잘못된 결정이었을까?

Larry Hastings의 말에 따르면 GIL의 설계 결정은 Python을 오늘날과 같이 인기 있게 만든 이유 중 하나이다.

Python은 운영 체제에 스레드 개념이 없던 시절부터 존재해 왔다. Python은 개발을 더 빠르게 하기 위해 사용하기 쉽도록 설계되었으며 점점 더 많은 개발자가 Python을 사용하기 시작했다.

Python에서 기능이 필요한 기존 C 라이브러리를 위해 많은 확장이 작성되었다. 일관성 없는 변경을 방지하기 위해 이러한 C 확장에는 GIL이 제공하는 스레드로부터 안전한 메모리 관리가 필요했다.

GIL은 구현하기 쉽고 Python에 쉽게 추가되었다. 하나의 잠금만 관리하면 되므로 단일 스레드 프로그램의 성능이 향상된다.

스레드로부터 안전하지 않은 C 라이브러리는 통합하기가 더 쉬워졌다. 그리고 이러한 C 확장은 Python이 다른 커뮤니티에서 쉽게 채택된 이유 중 하나가 되었다.

따라서 GIL은 CPython 개발자가 Python 초기에 직면했던 어려운 문제에 대한 실용적인 솔루션이었다.


멀티 스레딩이 파이썬 프로그램에 미치는 영향

일반적인 Python 프로그램이나 컴퓨터 프로그램을 살펴보면 CPU 성능에 제약이 있는 프로그램과 I/O에 제약이 있는 프로그램 사이에는 차이가 있다.

CPU 바인딩된 프로그램은 CPU를 한계로 밀어넣는 프로그램이다. 여기에는 행렬 곱셈, 검색, 이미지 처리 등과 같은 수학적 계산을 하는 프로그램들이 포함된다.

I/O 바인딩된 프로그램은 사용자, 파일, 데이터베이스, 네트워크 등에서 얻을 수 있는 입출력을 기다리는 데 시간을 보내는 프로그램이다. I/O 바인딩된 프로그램들은 때때로 소스로부터 그들이 필요로 하는 것을 얻을 때까지 상당한 시간을 기다려야 한다.

카운트다운을 수행하는 간단한 CPU 바인딩 프로그램을 살펴보자.

# 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)

코어가 4개인 내 시스템에서 이 코드를 실행한 결과 다음과 같은 결과가 나왔다.

$ python single_threaded.py
Time taken in seconds: 6.20024037361145

이제 두 개의 스레드를 병렬로 사용하여 동일한 카운트다운에 대한 코드를 약간 수정했다.

# 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

보시다시피 두 버전 모두 완료하는 데 거의 동일한 시간이 걸린다. 멀티 스레드 버전에서 GIL은 CPU 바인딩된 스레드가 병렬로 실행되는 것을 막았다.

GIL가 I/O를 기다리는 동안은 스레드 간에 잠금이 공유되기 때문에 I/O 바인딩된 다중 스레드 프로그램의 성능에 큰 영향을 미치지 않는다.

그러나 스레드가 완전히 CPU에 묶인 프로그램, 예를 들어 스레드를 사용하여 이미지를 처리하는 프로그램은 잠금으로 인해 싱글 스레드가 될 뿐만 아니라 실행 시간이 완전히 싱글 스레드로 작성된 시나리오에 비해 증가하게 된다.

이러한 실행 시간의 증가는 잠금에 의해 추가된 획득 및 해제 오버헤드의 결과이다.


왜 아직 GIL을 사용하는 걸까?

파이썬의 개발자들은 이에 대해 많은 불만을 받지만 파이썬처럼 인기 있는 언어는 역호환성 문제를 일으키지 않고는 GIL 제거만큼 중요한 변화를 가져올 수 없다.

GIL은 분명히 제거될 수 있으며 이는 개발자들에 의해 과거에 여러 번 수행되었지만 이러한 모든 시도들은 GIL이 제공하는 솔루션에 크게 의존하는 기존의 C 확장을 깨뜨렸다.

물론 GIL이 문제를 해결하는 다른 솔루션도 있지만, 그 중 일부는 싱글 스레드 및 멀티 스레드 I/O 바인딩 프로그램의 성능을 저하시키고 일부는 너무 어렵다. 새로운 버전이 나온 후 기존 Python 프로그램이 더 느리게 실행되는 것을 원하지 않을 것이다.

파이썬의 제작자이자 BDFL인 Guido van Rossum은 2007년 9월 자신의 글에서 커뮤니티에 대한 답변을 했다:

"싱글 스레드 프로그램(및 멀티 스레드이지만 I/O 바인딩된 프로그램의 경우)의 성능이 저하되지 않는 경우에만 Py3k에 패치 세트를 도입하는 것을 환영합니다."

그리고 그 이후로 이루어진 어떤 시도도 이 조건을 충족하지 못했다.


Python 3에서 제거되지 않은 이유는 무엇일까?

파이썬 3은 수많은 기능들을 처음부터 시작할 기회를 얻었고, 그 과정에서 기존의 C 확장 기능 중 일부를 없앴으며, 파이썬 3과 함께 작동하기 위해 변경 사항을 업데이트하고 포팅해야 했다. 이것이 파이썬 3의 초기 버전들이 커뮤니티에 의해 더 느리게 채택된 이유이다.

하지만 왜 GIL은 함께 제거되지 않았을까?

GIL을 제거하면 Python 3이 싱글 스레드 성능에서 Python 2에 비해 느려질 수 있으며, 그 결과를 상상할 수 있다. GIL의 단일 스레드 성능 이점에 대해 반박할 수 없다. 따라서 결과는 Python 3이 여전히 GIL을 보유하고 있다는 것이다.

그러나 Python 3은 기존 GIL을 크게 개선했다.

GIL이 CPU에만 바인딩 경우와 I/O에만 바인딩 경우에 멀티 스레드 프로그램에 미치는 영향에 대해 논의했지만 일부 스레드는 I/O 바인딩되고 일부는 CPU 바인딩된 프로그램은 어떨까?

이러한 프로그램에서 파이썬의 GIL은 I/O 바인딩 스레드가 CPU 바인딩 스레드로부터 GIL을 획득할 기회를 받지를 못하는것으로 알려져 있었다.

이는 고정된 연속 사용 간격 후에 스레드가 GIL을 해제하도록 강제하는 Python에 내장된 메커니즘과 다른 스레드가 GIL을 획득하지 않은 경우 동일한 스레드가 계속 사용할 수 있었기 때문이였다.

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

### output ###
100

이 메커니즘의 문제는 CPU 바인딩된 스레드가 다른 스레드가 GIL을 획득하기 전에 GIL 자체를 재취득한다는 것이었다. 이 문제는 David Beazley라는 사람에 의해 연구되었고 시각화 자료는 여기서 볼 수 있다.

2009년 Python 3.2에서 Antoine Pitrou에 의해 해결되었으며, 그는 다른 스레드가 실행되기 전에 현재 스레드가 GIL을 다시 획득하지 못하도록 하는 메커니즘을 추가하였다.


Python의 GIL을 다루는 방법

GIL로 인해 문제가 발생할 경우 몇 가지 방법을 시도해 볼 수 있다.

멀티 프로세싱 vs 멀티 스레드

가장 일반적인 방법은 스레드 대신 여러 프로세스를 사용하는 다중 처리 방식을 사용하는 것이다. 각 Python 프로세스에는 자체 Python 인터프리터와 메모리 공간이 있으므로 GIL은 문제가 되지 않는다. Python에는 멀티프로세싱 모듈이 있어 다음과 같은 프로세스를 쉽게 만들 수 있다.

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)
    
    
#### output ####

Time taken in seconds - 4.060242414474487

멀티 쓰레드 버전에 비해 성능이 꽤 향상됐다.

프로세스 관리에는 자체 오버헤드가 있기 때문에 시간은 위에서 본 것의 절반으로 떨어지지 않았다. 다중 프로세스는 다중 스레드보다 무겁기 때문에 확장 병목 현상이 발생할 수 있다.

대체 Python 인터프리터

Python에는 여러 인터프리터 구현이 있다. C, Java, C# 및 Python으로 각각 작성된 CPython, Jython, IronPython 및 PyPy가 가장 인기 있는 인터프리터들이다. GIL은 CPython인 원래 Python 구현에만 존재한다. 라이브러리와 함께 프로그램을 다른 구현 중 하나에서 사용할 수 있는 경우 해당 프로그램도 사용해 볼 수 있다.

기다리기

많은 Python 사용자가 GIL의 단일 스레드 성능 이점을 활용하고 있다. 다중 스레딩 프로그래머는 Python 커뮤니티에서 가장 똑똑한 사람들이 CPython에서 GIL을 제거하기 위해 노력하고 있기 때문에 초조해할 필요가 없다. 그러한 시도 중 하나는 Gilectomy로 알려져 있다.

Python GIL은 종종 신비하고 어려운 주제로 간주된다. 그러나 Pythonista로서 일반적으로 C 확장을 작성하거나 프로그램에서 CPU 바운드 다중 스레딩을 사용하는 경우에만 영향을 받는다는 점을 명심하자.

0개의 댓글