GIL

newhyork·2023년 1월 23일
0

GIL


Global Interpreter Lock


보편적으로 Python 을 사용한다면, C 로 구현된 CPython 을 이용하게 된다.

이 CPython 에서는, GIL 이라는 것을 이용한다.
이는, multi thread 환경에서, thread 들이 parallel 하게 실행되는 것을 막기 위한 장치이다.
( 그래서, Python에 있어서, 한 process 내에서 interpreter 는,
결국 single threaded 처럼 동작하게 된다는 말이, 바로 이를 두고 하는 말이다. )

이를 가능하게 하기 위해, CPython 에서는 Python interpreter 자체에 대한 lock 을 한다.

그래서 이름도 Global Interpreter Lock 이다.


그럼, GIL 이 왜 필요할까?

그 이유는, CPython 의 메모리 관리 방식에 있다.


CPython 는 메모리 관리 방식으로, reference counting 을 사용한다.
( 여기서 memory management 는, garbage collector 가 하는 일을 의미한다. )

( 하지만 이는, 순환 참조 시의 memory leak 문제를 해결하지 못하기 때문에,
메모리 관리 방식에 generational garbage collection 이란 것도 따로 두어,
reference counting 으로만 메모리를 관리했을 때의 문제점을 보완한다.

여기서는, GIL이 필요한 이유에 대해서 알아보는 것이기 때문에,
reference counting 에 대해서만 알아보겠다. )


reference counting 에 대해, 핵심적인 개념만 얕게 언급하고 넘어가겠다.

어떤 객체에 대해서, 선언 또는 참조를 하게 되면 그만큼 count 가 증가하고
더 이상 쓰이지 않으면 그 만큼 감소한다.
( sys.getrefcount(object) 로 객체의 reference count 를 확인할 수 있다.)

그렇게, count 가 0 이 되면, 해당 객체에 대한 memory allocation 은 free ( release ) 된다.


이 reference counting 과정에 있어서, 만약 multi thread 환경이라고 했을 때,

여러 thread 가 parallel 하게 동작하여 같은 시점에 reference count 를 증가 혹은 감소하게 되면
thread 간에 서로 다른 reference count 값을 가지게 될 것이다.

즉, race condition 이 발생한다는 것이다.
( 이런 상황을 두고, thread-safe 하지 않다고 한다. )

이렇게 되면, reference counting 이 잘못 되었기 때문에,
메모리 할당이 해제되어야 할 객체가 해제되지 않아서, memory leak 이 발생한다.


요약하면, 이러한 상황을 방지하기 위해 GIL이 존재하는 것이다.

GIL 이, multi thread 환경에서, thread 들의 parallel 한 실행을 막아주기 때문에,
임의의 Python object 의 reference count 에서 비롯되는 memory leak 이 발생하지 않는다.


사실 위와 같은 상황을 두고,
GIL 이 아닌 reference count 에 대한 lock 을 두는 것을 선택할 수도 있었을 것이다.

하지만 이는, 당장의 성능에 있어서도 문제이고, 사용하기에 따라선 deadlock 의 여지도 있다.


또한, 당시, GIL 방식으로 해야 했던 결정적인 배경은 따로 있다.

Python 은, OS 에 thread 사용을 크게 고려하지 않았던 시절부터 존재해왔다.

많은 사람들이 Python 을 사용하게 됨에 따라,
Python 에서 필요로 하는 기능을 가진 C 라이브러리들에 대해 수 많은 extension 들이 작성되었다.

( 하지만 이런 와중에 thread 도입이 성행하였고 ? )
이미 작성된 extension 들에 일관성 없는 변경을 가할 수는 없는 노릇이었기 때문에,
GIL 과 같은 방식을 통한 thread-safe 한 메모리 관리가 필요했다.

결과적으로, thread-safe 하지 않은 C 라이브러리들을
( extension 들에 ) 통합하는 데에도 어려움이 덜하게 되었고, 이러한 C extension 들은,
Python 이 다른 커뮤니티에 의해 쉽게 채택될 수 있었던 이유 중 하나가 되었다.


( C 에서 thread 를 사용할 때,
thread-safe 를 보장하기 위해 race condition 이 일어나지 않도록 하는 건, 오로지 사용자의 몫이다. )


다행히도, I/O 나 이미지 처리, Numpy 모듈의 number crunching 과 같이,
잠재적으로 blocking 이 많거나 긴 시간 동안 실행되는 연산의 경우 GIL 바깥에서 이루어진다고 한다.

따라서 GIL 은, 오직 multi threaded program 에서만이 병목이 된다고 소개된다.




여기선, 내가 헷갈렸던 점들에 대해 덧붙여 보겠다.

(궁금하지 않다면 안 봐도 좋다. 위에서 내린 결론으로 충분하긴 하다.
오히려, 괜히 더 헷갈리게 하는 것일 수도 있다.)

아래의 예시는 threading 모듈이 shared resource에 대해 thread-unsafe 함을 나타낸다.

import threading

shared_resource = 0

def increase():
    global shared_resource
    for _ in range(1000000):
        shared_resource += 1

def decrease():
    global shared_resource
    for _ in range(1000000):
        shared_resource -= 1

t1 = threading.Thread(target=increase)
t2 = threading.Thread(target=decrease)
t1.start()
t2.start()
t1.join()
t2.join()

print(shared_resource)  # 항상 0 이 아닌, -1000000 ~ 1000000 랜덤

전역 변수 shared_resource 를 공유 자원으로 두고,
두 thread 간에 race condition 이 발생한다는 것이다.


이 예시를 소개하는 이유는,

reference count 를 위 예시의 shared_resource 로 생각하고 이해하려 한다면,
다소 혼란이 생길 여지가 있기 때문이다.

reference count 는 shared resource 이면서, GIL 을 통해 mutex 가 보장된다.
하지만 위 예시의 shared_resource 은 두 thread 간에 공유 자원이기는 하지만,
GIL 과는 전혀 관계가 없고, 따로 mutex 를 보장하는 것도 현재로썬 없는 상태이다.

다시 말해,
전역 변수처럼, Python source code 레벨에서 사용하는 shared resource 는 GIL 과 관계가 없다.
GIL 은, 오로지 reference count 를 위함이다.

( 이러한 점을 미루어, threading 모듈 사용도 기본적으로 thread-unsafe 이라고 한다.
그러므로, Lock() 을 통해 .acquire().release() 을 함으로써 mutex 를 보장할 수 있다.
즉, 사용자가 programming 하기에 달려있다.


하지만, 그만큼 또 느려지게 된다.
이런 점에 있어서, 앞서 언급했듯, reference count 에 lock 을 두는 대신
GIL 을 통해 mutex 를 보장했다고 볼 수 있다.
)


아무튼 정리해보자면,
multi thread 환경에 있어서,
두 공유 자원 reference count, shared_resource 각각에 대해 이렇게 얘기할 수 있다.

reference count 는 GIL 을 통해 thread-safe 를 달성할 수 있지만,
shared_resource 는 GIL 과 상관이 없으므로 thread-unsafe 하다는 것이다.

다시 말해서, threading 모듈을 사용하더라도
reference counting 에 대한 race condition 은 발생하지 않기에 memory leak 은 없지만,
shared resource 에 대한 race condition 은 발생할 여지가 있어,
위 예시의 shared_resource 처럼, 기대했던 값이 아닐 수 있다는 것이다.


이런 이유로, Python source code 레벨에서는,
적어도 reference count 에 대해선 걱정할 필요가 없기도 하다.

( 또한, reference counting 은 제어할 수 없으며, CPython 내부에서 자동으로 이루어진다.

다만, 순환 참조 문제는 해결하지 못하므로, 코드 작성 시 이에 유의해야 한다.
generational garbage collection 이 있긴 하지만,
threshold 에 도달하지 않을 때까지는 memory leak 을 안고 가야 한다.

이에 대해, 수동으로 메모리를 관리하거나 ( gc.collect() )
weakref( 약한 참조 ) 를 활용하는 방법도 있긴 하다.
)


또한, Python의 reference counting 을 나타내는 C 코드를 보면,
reference count 에 대한 lock 을 걸지는 않는다.
(초반에, GIL 은 reference count 자체가 아닌 interpreter 에 lock 을 걸었다고 이미 말했다.)


자 그러면, 위 예시에서 shared_resource 의 값이 랜덤인 이유는 왜 때문인지도 알아보자.


Python source code 는 interpreter 에 의해 Python bytecode 로 변환되는데,
source code 한 줄이 bytecode 로는 여러 줄이 될 수 있다.
( 대략적으로, LOAD / ADD / STORE .. 등으로 나뉜다.

참고로, __pycache__ 에 있는 .pyc 파일들이 바로,
해당 디렉터리에 있는 .py 파일의 source code 들을 interpreter 가 bytecode 로 변환한 것이다.
interpreter 입장에서는, 이렇게 미리 변환함으로써 실행 속도를 향상 시킬 수 있다.)

GIL 은 multi thread 환경에서 thread 들이 parallel 하게 동작하지 않도록 하기 위해
interpreter 를 lock 하였다고 하였다.
이로 인해, 한 시점에 오직 하나의 thread 만이 Python bytecode 를 실행할 수 있게 되었다.

코드가 실행되는 와중에,
t1에서 LOAD 를 했는데 바로 context switching 되어, t2에서도 LOAD 를 함으로써
shared_resource 의 값이 순차적으로 변경되지 못한,
즉, 한 thread 에서의 사칙연산이 묻히게 되는,
race condition 이 발생하기 때문이다.




threading 은 대체 어떨 때에 쓰는 게 적합할까?

CPU 보단, I/O bound 작업이 많은 곳에 쓰는 것이 좋다.
어떤 thread 에서의 I/O 대기 시간 동안 다른 thread 가 동작해서,
시간적 효율을 볼 수 있기 때문이다.
( thread 간 context switching 은,
일정 시간 간격 ( sys.getswitchinterval() ) 혹은 I/O bound 시 발생한다. )

하지만 그렇지 않은 경우에 써먹었다가는,
context switching 때문에 single thread 로 작성했을 때보다 더 느려질 수도 있다.


Python parallel programming 에 있어
진정한 multi threaded 는 사용할 수 없음에 대한 대안으로,
multiprocessing 을 사용하거나
async programming 으로써 asyncio 를 이용하곤 한다.

( concurrent.futures 라는 것도 있다. )

0개의 댓글