이야기박스
Python. 스레드 이야기 with GIL 본문
# 개요
Python3에서의 GIL(Global Interpreter Lock)이란 어떤 것인지, 그리고 이러한 환경에서 Thread, Process는 어떻게 사용해야 할지 이야기해보도록 하겠습니다.
# Process & Thread
[POSIX; 프로세스와 스레드]에 작성된 문서를 바탕으로 정리를 해보았습니다. Python Thread 영역은 CPython과 밀접한 연관이 있고, 프로세스와 스레드의 개념을 잘 설명해주어서 복습한다는 느낌으로 읽어보았습니다.
`프로세스`란? 컴퓨터에서 연속적으로 실행되고 있는 프로그램, 즉 작업(Task)들을 일컷는 말.
`스레드`란? 프로세스 내부에 실행되는 작은 작업 단위.
스레드는 프로세스의 `code`, `data`, `heap` 영역을 공유하고, `stack` 영역을 독립적으로 사용하게 됩니다. 여기서 스레드를 사용할 때는 위 공유 영역에 대한 'Race Condition' 그리고 'Clobbering'을 항상 고려해주어야 합니다.
C언어의 pthreads 라이브러리는 CS에서 이야기하는 프로세스, 스레드 구조를 그대로 구현해두었습니다. 그렇기 때문에, 사용자가 위와 같은 공유자원의 처리를 직접 관리해주어야만 'Thread-Safe'한 구현을 할 수 있습니다.
# GIL (Global Interpreter Lock)
GIL은 Python의 thread 구현체인 CPython에 있는 개념입니다. [Python; GlobalInterpreterLock] 공식 문서에서 보면, 다음과 같이 GIL을 설명하고 있습니다.
a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecodes at once
CPython은 C의 pthreads를 참조해 구성했기 때문에, 그대로 사용하려고 하면 Thread-safe 이슈가 발생하게 됩니다.
Note. 구현체별 GIL 존재 여부
- Jython(Java 구현체), IronPython(C# 구현체) 에는 GIL이 없습니다.
- PyPy(Python 자체 구현체)에는 CPython처럼 GIL이 있습니다.
- Cython(CPython 확장)에도 GIL은 있지만, "with" 구문을 사용하면 GIL을 해제할 수 있습니다.
## Reference counting for memory management
GIL에서 mutex를 적용하는 방법을 알아보기 전에 먼저 파이썬의 메모리 관리 방식을 알아보겠습니다.
파이썬은 Object를 참조하는 레퍼런스 숫자를 추적하는 방식으로 메모리를 관리하고 있습니다.
>>> import sys
>>> a = [] # ref 1
>>> b = a # ref 2
>>> sys.getrefcount(a) # ref 3
3
예를들어 위와 같은 경우, `a=[]` 선언을 할 때 1개 참조가 생기고 `b=a` 에서 2번째 참조, `sys.getrefcount(a)`에서 한번 더 참조하여 총 3개의 reference count가 생기게 됩니다.
그리고 reference count가 0이 되면, 메모리에서 할당이 해제된다고 보시면 됩니다.
## GIL mutex
그렇다면 GIL은 mutex을 어디에 생성하는 걸까요?
Python object에 mutex가 있다고 생각을 해보겠습니다. object의 `reference count`을 잠가야 한다면, 모든 object에 대하여 mutex가 필요하게 될 것입니다. 이렇게 많은 mutex를 반복적으로 잠금 및 해제를 하다 보면 성능 저하가 발생하게 됩니다. 또한 Deadlock과 같은 상황이 발생할 수도 있죠.
때문에 GIL은 object가 아닌 인터프리터 자체를 잠그게 됩니다. 이렇게 하면 위의 성능저하 및 Deadlock 문제를 회피할 수 있게 되죠. 단.. 이렇게 하면 하나의 스레드만이 Python Code를 실행하게 됩니다.
## Why?
GIL은 완벽하지 않아 보입니다. JAVA나 C#과 같이 편리한 인터페이스를 제공하는 다른 언어에 비하면 불친절하다고 느껴지기도 하네요.
그렇다면 파이썬 개발자들은 왜 인터프리터에 mutex를 걸게 되었을까요?
[Real Python; python-gil] 문서에 보면 이렇게 적혀 있습니다.
초창기 파이썬 개발 당시에는 스레드 개념이 미흡했다.
이미 많은 C 확장 라이브러리들이 만들어진 상태에서 스레드 문제를 해결하기 위한 현실적인 솔루션이 GIL이었다.
## Single Thread vs Multi Thread Testing
GIL의 동작을 확인할 수 있는 가벼운 테스트 예제입니다.
# 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
# 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
싱글 스레드와 멀티 스레드 모드 약 6초정도 소요되었습니다. 심지어 멀티 스레드가 0.7초 정도 더 오래 걸렸네요.
GIL로 인한 락의 획득, 해제가 0.7초 정도 시간이 더 소요되게 한 것 같습니다.
그렇다면, 파이썬에서의 멀티 스레드 처리는 효과가 없을까요?
꼭 그렇지는 않습니다. Python에서 I/O 작업을 하게 되는 경우, 잠금이 다른 스레드에 공유될 수 있게 되고 하나의 스레드가 I/O 작업을 하는 동안 다른 스레드에서 작업을 할 수 있게 됩니다.
# 요약
- 파이썬에서 스레드를 사용하게 되는 경우, Interpreter 레벨에서 mutex 잠금이 걸리게 됩니다. 단, 스레드가 I/O 작업에 들어가게 되면, 해당 잠금은 다른 스레드에게 공유될 수 있습니다.
- CPU 작업이 많은 환경에서 GIL을 피하고 싶다면 Threading이 아닌, Processing 모듈을 사용하는 것을 권장합니다.
# 참고
## GIL
https://docs.python.org/3/c-api/init.html#thread-state-and-the-global-interpreter-lock
https://wiki.python.org/moin/GlobalInterpreterLock
https://realpython.com/python-gil/#why-was-the-gil-chosen-as-the-solution
## C; Threading
https://computing.llnl.gov/tutorials/pthreads/
## Python; Thread/Process Pool Executor
https://docs.python.org/ko/3/library/concurrent.futures.html
'Programming Language > Python' 카테고리의 다른 글
Python. Subprocess with * (asterisk) (0) | 2020.11.17 |
---|---|
Python. 대용량 파일을 스트림 프로세스로 분할하기 (0) | 2020.10.23 |