파이썬 GIL (Global Interpreter Lock)

Global Interpreter Lock (GIL)

악명 높은 GIL?

파이썬 버전 2 시절에 병렬 처리를 위해 이런저런 문서들을 많이 보았습니다. 수년이 흐른 지금 파이썬 재단에서 이 GIL을 어떻게 처리했을까? 궁금하여 관련 정보를 찾아보니 아직도 현재 진행형이었습니다. 그리고 신기하게도 GIL을 설명할 때 같이 보이는 단어가 있었는데 그것은 바로 "악명 높은"이라는 수식어입니다.

 

그렇다면 이 악명 높은 GIL이 과연 무엇일까요? 일단 GIL을 뜻하는 "Global Interpreter Lock"을 직역하자면 "글로벌 인터프리터 잠금" 정도로 해석될 것 같습니다. 의미를 뜻 그대로 생각해 본다면 전반적으로 인터프리터가 락을 건다??라는 의미일까요?

 

파이썬 용어를 알아보는데 가장 좋은 방법은 바로 공식 사이트를 확인하는 것입니다. 다음은 파이썬 공식 사이트에서 제공하는 문서 중 GIL을 설명하는 원문 캡처입니다. 

 

(링크 : docs.python.org/3/glossary.html#term-global-interpreter-lock )

파이썬 GIL 설명 (https://docs.python.org/3/glossary.html#term-global-interpreter-lock)

 

원문을 요약해보면 파이썬 인터프리터는 스레드가 여러 개라 할 지라도 오직 하나의 스레드에서만 바이트 코드를 실행하도록 제한을 둔 메커니즘을 말합니다. 하지만 모든 상황에서 제한을 두지 않으며 일부 압축 해시와 같은 계산만 집중적으로 작업을 할 때 그리고 I/O 처리할 때에는 예외적으로 GIL이 적용되지 않습니다.

 

이 말은 결국 요즘 같이 일반 PC에서도 멀티코어 프로세서가 탑재되어 있는 고성능 PC임에도 그 성능을 제대로 활용하지 못하게 되는 문제에 봉착하게 됩니다. (사실은 파이썬 만의 문제는 아닙니다. 오해하지 마세요.) 이는 파이썬 2 시절에도 인지하고 있었던 문제이며 파이썬 3으로 넘어오면서도 여전히 갖고 있는 문제입니다. 이 문제를 해결하는 데 있어 파이썬의 재단인 PSF의 개발자 분들이 결코 게을러서도 아니며 실력이 없어서도 아닙니다. 이는 인터프리터의 근본을 흔드는 정말 큰 문제 이기 때문에 쉽게 정하거나 수정할 수 없는 부분이기 때문입니다. 그리고 컴퓨터 메커니즘을 잘 알고 계시는 프로그래머라면 그 어떤 프로그램을 병렬 처리하더라도 결국에는 병목 현상이 발생하고 연산 처리 능력이 비약적으로 늘지 않는다는 것은 충분히 아실 수 있을 겁니다. 

 

그래서 GIL이 무엇인데?라는 답변을 한 줄로 간단히 요약해 보면 "파이썬 인터프리터가 스레드 동기화 문제를 해결하기 위해 사용하는 방식이 GIL이다"라고 말할 수 있습니다.

 

그렇다면 과연 GIL의 수식어인 "악명 높은"은 무슨 뜻일까요? 딱히 왜 악명 높다는 설명은 없지만 제 생각에는 GIL에 대한 방식이 구식이라는 것을 알고 GIL이 있어야 하는 이유도 알고 있지만 오랫동안 GIL을 더 효율적으로 바꾸기 위해 여러 논의도 하고 기획안도 제시되지만 결국에는 그 어떠한 답도 찾지 못했기 때문에 "악명 높은"이라는 수식어가 붙은 것 같습니다. 이는 마치 자동차에서 사골 엔진의 성능을 높이려 하니 연비가 안 좋아지고 연비를 좋게 하자니 성능이 나빠지는 딜레마에 빠진 것과 비슷하다 할 수 있습니다.

 

즉, 뾰족한 묘책이 없다. 그래서 BDFL(Benevolent Dictator for Life)은 GIL에 대한 개선을 하고 싶은 사람들에게 이렇게 말했습니다. (BDFL은 파이썬 창시자 귀도 반 로섬을 가리키기도 합니다. 단, 2018년 7월 귀도 반 로섬은 BDFL을 사임)

 

I'd welcome a set of patches py3k only if the performance for a single-threaded program (and for a multi-threaded but I/O-bound program) does not decrease.
단일 스레드 프로그램의 성능이 저하되지 않는 경우에만 개선안을 받아 들을 것이다.

(원문 링크 : www.artima.com/weblogs/viewpost.jsp?thread=214235 )

 

혹시나 지금까지 GIL에 대해 설명을 드렸는데 아직도 잘 모르시는 분들이 있을까요? 제가 그분들을 위해 이 글을 작성하면서 문득 생각난 예시로 아주 쉽게 설명해 보겠습니다. 

 

아래 예제는 순전이 저의 머릿속에서 떠오른 예시임을 다시 한번 밝힙니다. ^^;

 

 

GIL에 대해 예시를 들은 아주 쉬운 설명

즐거운 방학을 마음껏 만끽하던 손오공이 방학이 끝나갈 무렵 방학 숙제인 일기를 빨리 써야겠다는 생각에 손오공은 분신술을 사용해야겠다고 생각합니다. 분신술을 사용한 손오공은 가장 먼저 생긴 분신이 책상 위에 있는 펜을 집어 들고 일기를 쓰는 것을 보고 흐뭇해했습니다. 그런데 기쁨도 잠시 복제된 또 다른 손오공이 일기를 쓰던 손오공의 펜과 일기장을 뺏어 다른 날짜의 일기를 쓰기 시작했습니다. 그러자 다른 손오공 분신들이 서로 자기들도 쓰겠다고 하나뿐인 펜과 일기장을 뺏는 바람에 제대로 일기를 쓰지 못하는 상황이 발생하게 되었습니다.

 

일기를 써야 하는 손오공이 바로 스레드입니다. 그리고 분신술로 생긴 손오공들이 즉, 여러 개의 스레드들이 하나뿐인 펜과 일기장을 두고 자기들끼리 서로 점령하기 위해 싸우고 있습니다. 이 상황을 race condition 다시 말해 스레드 경합 상태라고 합니다. 

 

그럼 다시 예제를 이어 가보도록 하겠습니다.

 

이를 지켜보던 삼장법사가 손오공들을 부릅니다. 그리고 삼장 법사는 손오공들에게 일기장이 하나밖에 없으니 줄을 서서 차례대로 일기를 쓰라고 했습니다. 하지만 이 말을 들을 손오공들이 아녔기에 삼장 법사는 손오공들의 모든 행동을 하지 못하도록 꼼짝 못 하게 하는 주문을 외웁니다. 그리고 가장 가까이에 있던 손오공의 주문을 풀어 일기를 쓰게 했습니다. 일기를 막힘없이 써내려 가는 손오공을 보며 삼장 법사는 흐뭇해합니다. 그리고 어느 정도 일기를 썼으니 다른 손오공에게 기회를 주기 위해 일기를 쓰던 손오공을 다른 손오공들 맨 뒤에 줄을 서게 한 뒤 다시 주문을 외워 꼼짝 못 하게 만들고 그다음 가까이에 있던 손오공의 주문을 풀어 일기를 쓰게 했습니다. 이렇게 삼장 법사는 일기를 다 쓸 동안 모든 손오공들에게 차례차례 공평하게 기회를 주었습니다.

 

여기서 나오는 삼장 법사가 무엇을 의미하는지 아시겠죠? 이 삼장 법사가 바로 이 포스팅 주제인 GIL입니다. 일기를 써야 하는 여러 손오공들이 하나밖에 없는 일기장에 문제없이 일기를 쓰기 위해 삼장법사가 손오공들을 컨트롤합니다. 다시 말하면 GIL이 스레드 들을 제어 합니다. 그것도 순차적으로 하나씩 말이죠. 차례대로 줄을 세운다고 했는데 이는 마치 스케줄링이 하는 역할과 비슷합니다.

 

그리고 GIL의 주제와 살짝 빗나가기는 했지만 일기를 쓰기 위해 손오공을 바꾸는 행위 즉, 연산 중인 스레드를 중지시키고 다른 스레드를 수행시키기 위한 행위를 콘텍스트 스위치(context switch) 일명 문맥 교환이라 합니다. 사실 이 문맥 교환으로 인해 스레드 처리 속도가 저하됩니다. 파이썬뿐만이 아닌 모든 프로그램에서 말이죠. 

 

그러면 이제 스레드가 하나의 객체를 두고 경합을 벌이는 코드를 살펴보겠습니다.

 

 

스레드 경합 상태 직접 확인해 보기

일단 위의 예시에서 삼장 법사는 과연 손오공에게 얼마만큼 일기를 쓸 수 있도록 시간을 주었을 까요? GIL이 스케줄러 같이 행동한다고 말씀드렸습니다. 그렇게 생각하게 된 것은 바로 아래 코드로 컨택스트 스위치 시간을 확인할 수 있기 때문입니다.

 

>>> import sys
>>> sys.getswitchinterval()
0.005

즉, 삼장 법사는 하나의 손오공에게 0.005초 동안 일기를 쓸 수 있도록 시간을 주었습니다. 

 

참고로 파이썬은 모든 것이 객체라 하였습니다. 그렇다면 객체의 레퍼런스를 참조하는 객체는 얼마나 될까요?

 

import sys
cap = "captainBIN"
bin = cap
blog = bin
sys.getrefcount(cap)

# 결과
4

"captainBIN"이라는 객체는 cap, bin, blog, getrefcount()로 총 4개의 객체에서 참조한다는 카운터가 발생했습니다. 이 카운터가 0이 되면 객체는 메모리에서 소멸됩니다.

 

 

그럼 이제 본격적으로 스레드 경합에 대해 알아보겠습니다. 본 코드를 작성하고 테스트한 파이썬 버전은 3.7.4입니다. 현재 3.9 버전까지 배포는 되어 있지만 그냥 PC에 설치되어 있는 버전을 활용하겠습니다.

 

아래 코드는 하나의 객체(변수)에 두 개의 스레드가 서로 경합을 하는 코드입니다.

from threading import Thread as thd

diary = 0
count = 10

def mr_son_1 ():
    global diary
    
    for i in range(count): 
    	diary += 1

def mr_son_2 ():
    global diary
    
    for i in range(count): 
    	diary -= 1

son_1 = thd(target=mr_son_1)
son_2 = thd(target=mr_son_2)

son_1.start()
son_2.start()

if not(son_1.join()) and not(son_2.join()):
    print(diary)

 

위의 코드를 잘 보시면 각 mr_son_x 함수들은 전역 변수인 diary변수에 1을 더하거나 1을 빼주는 기능을 수행합니다. 이 함수를 하나씩 수행한다면 같은 count 만큼 수행하므로 어느 함수를 먼저 수행하더라도 제대로 수행이 되었다면 diary의 값은 항상 0 이여야 합니다. 

 

위 코드는 count = 10으로 각각 10번씩 diary 객체에 접근하여 값을 변경합니다. 위 코드를 직접 실행하신다면 기대하신 0이 제대로 출력이 됩니다. 아.. 혹시 0이 아닌 값이 나오는 걸 기대하셨나요? ^^;

 

그럼 이제 이 count 변수의 값을 점점 증가시켜 직접 결과를 확인해 보시기 바랍니다. 플랫폼에 따라 0이 아닌 값이 나오게 되는 순간이 발생합니다. 저의 pc의 경우 count = 100000(일십만)을 주고 실행했을 때 상황에 따라 0 값 혹은 0이 아닌 값이 출력됩니다. 연속 5번은 0 값 연속 2번은 0이 아닌 값으로 출력됩니다.

 

이 상황이 바로 스레드 경합으로 인해 발생되는 상황으로 생각지도 못했던 값이 튀어나오게 됩니다. 또한 이러한 상황이 발생한다고 에러가 발생하지도 않습니다. 게다가 실행 횟수가 작다면 결괏값 조차도 제대로 수행되는 것처럼 보입니다. 언제 어느 상황에 이상한 값이 출력될지 모르는 마치 시한폭탄과 같은 상황이 연출됩니다. 혹여나 상황을 인지 하더라도 이 경합 상태를 디버깅 하기가 참으로 어렵습니다.

 

그러므로 만약 병렬 처리를 해야 하는 상황이 생기면 반드시 스레드 동기화를 어떻게 해야 할지 항상 염두에 두어야 합니다. 다행히 위와 같은 상황에서는 간단히 처리할 수 있습니다.

 

바로 각 함수에서 공통으로 참조하는 변수에 값을 수정할 때 일종의 플래그 값을 주어 다른 스레드에서 작업 중인 변수에 접근을 할 수 없도록 제한을 거는 것입니다. 제한을 한 후 변수 값 변경 작업이 완료되었으면 제한을 풀어 작업이 완료되었다는 것을 알립니다. 이를 적용한 코드는 다음과 같습니다.

 

from threading import Thread as thd, Lock # Lock 추가

diary = 0
count = 1000000000
diary_flag = Lock() # 추가

def mr_son_1 ():
    global diary
    
    for i in range(count): 
        diary_flag.acquire() # 추가
        diary += 1
        diary_flag.release() # 추가

def mr_son_2 ():
    global diary
    
    for i in range(count): 
        diary_flag.acquire() # 추가
        diary -= 1
        diary_flag.release() # 추가

son_1 = thd(target=mr_son_1)
son_2 = thd(target=mr_son_2)

son_1.start()
son_2.start()

if not(son_1.join()) and not(son_2.join()):
    print(diary)

 

# 추가라고 주석 처리한 부분을 잘 살펴보면 threading에서 제공하는 Lock 메서드를 사용하여 각 함수에서 공통으로 사용하는 변수에 값을 변경하기 전 acquire() 하여 다른 스레드에서 접근하지 못하도록 Lock을 걸어줍니다. 변경 작업을 완료한 후에는 release()를 하여 다른 스레드에서 접근할 수 있도록 Lock을 해제합니다.

 

참고로 이 Lock과 해제를 정말 잘 사용하여야 합니다. 코드가 길어지게 될 경우 자칫 Lock 설정이 꼬여 스레드들 모두 변수에 접근을 못하고 모든 스레드가 락이 해제될 때까지 기다리는 경우가 발생하게 됩니다. 참고로 이런 상태를 데드 락 다시 말해 스레드 교착 상태라고 말합니다.

 

그럼 이상으로 파이썬 GIL에 관련 포스팅을 마치겠습니다.

 

긴 글 읽어 주셔서 감사합니다.

MORE