오늘은 Python을 좋아하지 않는 분들이 자주 Python(정확히는 CPython)을 공격하는 이유 중 하나인 GIL에 대해서 이야기해보려고 합니다. GIL을 이해하기 위해서는 먼저 thread, process와 같은 병렬처리에 대한 기본적인 이해가 필요합니다. 이번 포스트에서는 thread와 process가 무엇인지는 알고 계신다고 가정하고 이야기를 진행해 보겠습니다.
※ GIL은 CPython 만이 가지는 특징입니다. Jython, IronPython, PyPy 같은 다른 Python 구현은 GIL을 사용하지 않습니다.
GIL이 풀려고 하는 문제는 무엇일까?
먼저 GIL이 풀려고 하는 문제부터 살펴보겠습니다. GIL이 풀려고 하는 것은 Multi-thread 프로그래밍에서 아주 골치 아픈 문제인 race condition입니다. Thread들은 하나의 Process 안에서 실행되기 때문에 같은 자원(예를 들어 변수)에 동시에 여러 Thread가 접근하고 값을 바꿀 수 있습니다. 이 문제를 해결하기 위한 전통적인 방법이 뮤텍스나 세마포어 같은 lock입니다. 다른 언어에서 Multi-thread 프로그래밍을 해보신 분들은 아시겠지만 이 lock이라는 녀석이 아주 골치 아픕니다. 촘촘하게 조건을 잘 따져서 lock을 걸고 풀지 않으면 race condition이 발생하고, 그렇다고 너무 촘촘하게 lock을 걸면 성능 저하가 발생하죠. 특히 디버깅은 지옥 같은 난이도로 잘 알려져 있습니다. 이 때문에 실전에서 Multi-thread 프로그래밍을 잘하기란 여간 어려운 일이 아닙니다.
Python은 이 문제를 아주 단순하게 접근합니다. Thread가 자기 차례가 될 때 Python Interpreter 자체에 lock을 겁니다. 자기 차례가 끝나면 Python Interpreter에 걸려있던 lock을 풉니다. 한 Process에는 하나의 Python Interpreter만 있을 수 있고요. 이렇게 하면 자기가 실행되고 있는 동안 다른 Thread는 실행되지 않는다는 보장이 되기 때문에 race condition 문제가 사라집니다. 즉, Thread가 줄을 서서 차근차근 실행되는 셈입니다. 아주 간단한 해결책이죠.
GIL의 문제
하지만 여기에는 치명적인 문제가 있습니다. 애초에 Multi-thread를 사용하는 이유는 동시에 여러 Thread를 실행시켜서 최종적으로 실행속도를 높이려는 것입니다. 그런데 GIL을 사용하면 이런 장점이 사라집니다. Interpreter 자체에 lock을 걸었다 풀었다 하면서 한 번에 Thread 하나씩만 실행이 되기 때문이죠. 어렵게 Multi-thread API를 사용해서 코딩은 했지만 결국 실행 시간은 큰 차이가 나지 않게 됩니다.
실제 코드로 살펴봅시다.
좀 더 피부에 와닿는 느낌을 가지기 위해서 실제 코드를 살펴보겠습니다.
import time
import sys
import threading
N_JOBS = 100000000
def do_task(n_jobs):
y = 0
for i in range(n_jobs):
y += 1
print(y)
if __name__ == '__main__':
N_THREADS = int(sys.argv[1])
start_time = time.perf_counter()
threads = []
for i in range(N_THREADS):
t = threading.Thread(target=do_task, args=(N_JOBS // N_THREADS,))
t.start()
threads.append(t)
for t in threads:
t.join()
end_time = time.perf_counter()
print(f'{end_time - start_time:.2f}')
do_task 함수는 오래 걸리는 작업입니다. 여기에서는 단순히 1을 계속 더하는 일을 하지만 실제 프로그램에서는 이미지 처리를 한다거나, 딥러닝의 input feature를 만든다거나, 데이터 전처리를 한다거나, 웹 페이지의 내용을 긁어온다거나 하는 작업이 될 수 있습니다.
명령행 인자로 만들 Thread 수를 받습니다. 이 수만큼 Thread를 만들고 전체 작업을 공평하게 각 Thread에게 나누어줍니다.
python3 multi_thread.py 1
100000000
2.46
python3 multi_thread.py 2
50000000
50000000
2.46
python3 multi_thread.py 5
20000000
20000000
20000000
20000000
20000000
2.47
Thread를 1개를 쓰든, 2개를 쓰든, 5개를 쓰든 최종 실행 시간에는 차이가 없습니다. 앞에 이야기한 대로 한 번에 하나의 Thread만 실행되기 때문입니다. 보통 Thread를 통해 얻으려고 하는 병렬처리의 장점이 없는 것이죠.
왜 Python은 GIL을 사용하기 시작했을까요?
Python이 GIL을 사용하는 이유를 알려면 Python의 과거를 알아야 합니다. Python이 널리 쓰이기 시작한 초기에는 Python 뿐 아니라 프로그래밍 전반적으로 Multi-thread 프로그래밍이라는 개념 자체가 거의 없었습니다. 자연스럽게 모든 프로그램은 Single-thread 프로그램이었죠. 그러다가 어느 순간부터 프로그래밍 세상에 Multi-thread가 대세로 등장합니다. Python도 Multi-thread를 지원해야 하는 상황이 됐습니다. 그런데 문제는 Python 생태계에는 이미 수많은 C 코드 기반의 extension들이 있었고, 이 C 기반의 extension들은 thread safe 하지 않았습니다. thread safe 하다는 것은 Multi-thread 환경에서도 이 코드가 문제없이 작동한다는 뜻입니다.
이런 상황에서 선택권은 두 가지입니다. 첫 번째 방법은 모든 thread safe 하지 않은 C 기반의 extension(파이썬 라이브러리)들을 thread safe 하도록 재구현하는 것입니다. 그러나 이 방법은 거의 실현 가능성이 없습니다. extension 개발자 입장에서는 모든 코드 베이스를 thread safe 하도록 고쳐야 하고 (앞에 말한 대로 매우 난이도가 높은 작업입니다), extension을 쓰는 입장에서는 이 작업이 끝날 때까지 해당 extension들을 Multi-thread 환경에서는 쓸 수가 없습니다.
두 번째 방법이 바로 GIL입니다. GIL을 도입하면 Python Interpreter 단계에서 Thread safety를 보장하기 때문에 기존 extension 들을 수정하지 않고도 Multi-thread 환경에서 그대로 extension 들을 사용할 수 있게 됩니다. Python의 장점 중 하나가 폭넓은 라이브러리 생태계라는 점을 감안했을 때, GIL은 아주 빠르고 간단하게 Python에 Multi-thread를 제공할 수 있는 효율적인 방법이었습니다.
하지만 GIL 때문에 Thread를 쓰는 의미가 퇴색되지 않나요?
Yes and No입니다. CPU를 많이 사용하는 CPU-bound task에서는 Thread로 얻는 이득이 매우 적습니다. 앞에 보여드린 코드 예시가 CPU-bound task의 예입니다. 하지만 IO-bound task에서는 Multi-thread로 인한 성능 향상이 있습니다. IO-bound task는 어차피 IO를 기다리면서 많은 시간을 보내기 때문에 CPU가 노는 시간이 많고 그로 인해 GIL의 단점이 최소화됩니다.
과거에는 그랬지만 이제 GIL을 없애도 되지 않을까요?
시도가 없었던 것은 아닙니다. 예전 일이기는 하지만 실제로 그런 시도들이 있었습니다. https://www.artima.com/weblogs/viewpost.jsp?thread=214235
문제는 GIL을 제거했지만 Single-thread 프로그램들의 성능이 대폭 떨어졌습니다. 대부분의 Python 프로그램이 Single-thread라는 점을 감안하면 받아들이기 힘든 구현이었죠. Guido van Rossum은 Single-thread 프로그램의 성능(과 IO-bound인 Multi-thread 프로그램)을 저하시키지 않고 GIL을 제거할 수 있다면 패치를 받아들일 의향이 있다고 밝혔습니다. 하지만 아직까지 이런 패치가 만들어지지는 않고 있습니다.
대안: Multi-processing
CPU-bound task에서 병렬처리가 필요하다면 대안은 무엇이 있을까요? 가장 권장하는 방법은 Multi-processing을 사용하는 것입니다. 각 Python Interpreter는 별도의 Process에서 실행되기 때문에 GIL의 영향을 받지 않습니다. 별도의 Process이기 때문에 자원 공유 문제에서도 자유롭습니다. (물론 Process 간에 공유하는 자원이 있다면 동기화가 필요합니다.)
실제로 Python은 Multi-thread와 똑같은 형태의 Multi-process API를 제공합니다. 앞의 코드를 Multi-processing으로 다시 구현해 보겠습니다.
import time
import sys
import multiprocessing
N_JOBS = 100000000
def do_task(n_jobs):
y = 0
for i in range(n_jobs):
y += 1
print(y)
if __name__ == '__main__':
N_PROCESSES = int(sys.argv[1])
start_time = time.perf_counter()
processes = []
for i in range(N_PROCESSES):
t = multiprocessing.Process(target=do_task, args=(N_JOBS // N_PROCESSES,))
t.start()
processes.append(t)
for t in processes:
t.join()
end_time = time.perf_counter()
print(f'{end_time - start_time:.2f}')
보시는 것처럼 threading 관련 키워드만 processing으로 바꾼 수준입니다. 동일하게 Process 수를 조정하며 실행해 보겠습니다.
python3 multi_process.py 1
100000000
2.58
python3 multi_process.py 2
50000000
50000000
1.35
multi_process.py 5
20000000
20000000
20000000
20000000
20000000
0.68
Multi-thread 와는 달리 동시에 실행하는 Process를 늘릴수록 전체 실행 시간이 줄어드는 것을 볼 수 있습니다.
마무리
Python이 GIL을 최초에 선택했고 지금까지 유지하고 있는 데에는 역사적, 실리적인 다양한 이유가 있습니다. 그리고 이를 극복하기 위한 대안도 제공하고 있습니다. Python처럼 거대한 소프트웨어를 설계하고 개발할 때는 여러 요소를 고려해 의사결정이 필요하고, GIL은 그런 면에서 현명한 결정이었다고 생각합니다. 누군가 Python은 GIL 때문에 비효율적이라고 할 때 이 포스트가 도움이 되었으면 합니다.
'Python' 카테고리의 다른 글
itertools: iterator를 위한 도구 모음 (2) | 2023.02.19 |
---|---|
if __name__ == '__main__': 이 뭔가요? (0) | 2023.01.15 |
Python Callable (0) | 2022.07.11 |
Python 메소드의 첫번째 인자인 `self`는 무엇인가? (0) | 2022.03.24 |
Iterator, Generator (0) | 2021.05.08 |