ABOUT ME

-

Total
-
  • Python: Global Interpreter Lock (GIL)
    컴퓨터/파이썬 2023. 1. 5. 21:49
    728x90
    반응형

    GIL

     

    Python Wiki

    In CPython, the global interpreter lock, or GIL, is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecodes at once. The GIL prevents race conditions and ensures thread safety.

    wiki.python.org

     

    Introduction

    Global Interpreter Lock (GIL)

    GIL은 여러 네이티브 스레드가 동시에 파이썬 바이트 코드를 실행하는 것을 방지하는 메커니즘이다.

    파이썬의 reference implementation인 CPython 인터프리터에서 구현되며, 인터프리터의 내부 데이터 구조를 동시 액세스로부터 보호하는 데 사용된다.

    GIL은 한 번에 하나의 네이티브 스레드만 파이썬 바이트 코드를 실행할 수 있도록 하기 때문에 C에서 thread-safe 파이썬 확장 프로그램을 쉽게 작성할 수 있다.

    그러나 파이썬 스레드는 사용 가능한 코어가 여러 개 있더라도 단일 CPU 코어만 사용하기 때문에 CPU 바인딩 작업에 적합하지 않다.

    GIL이 아직까지 존재하는 주요 이유 중 하나는 thread-safe인 파이썬 extension을 쉽게 작성하기 위한 것이다.

    GIL은 한 번에 하나의 네이티브 스레드만 Python 바이트 코드를 실행할 수 있도록 하기 때문에

    동시 액세스로부터 확장의 데이터 구조를 보호하는 것에 대해 걱정할 필요가 없으니 데이터베이스나 네트워크 소켓과 같은 외부 리소스와 상호 작용할 때만 스레드 안전에 대해 걱정하면 되기 때문에 이를 통해 C에서 thread-safe 한 확장을 작성하는 것이 훨씬 간단해질 수 있다.

    그러나 GIL은 또한 몇 가지 단점을 가지고 있다.

    한 번에 하나의 네이티브 스레드에서만 파이썬 바이트 코드를 실행할 수 있기 때문에 여러 CPU 코어가 있는 시스템에서 CPU 바인딩 파이썬 프로그램의 성능을 제한할 수 있다.

    또한 다중 처리나 동시 처리와 같은 외부 라이브러리를 사용해야 하기 때문에 여러 CPU 코어를 활용해야 하는 고성능 파이썬 프로그램을 작성하는 것이 더 어려워질 수 있다.

    이러한 한계에도 불구하고 GIL은 CPython 코드의 중요한 부분이며 제거하기가 쉽지 않다.

    이는 GIL이 CPython 인터프리터에 긴밀하게 통합되어 있기 때문이며, GIL을 제거하려면 인터프리터의 내부 데이터 구조와 API를 크게 변경해야 한다.

    또한 GIL을 제거하면 C에서 thread-safe한 파이썬 확장을 쓰기가 훨씬 어려워져서 우리의 프로그램의 데이터 구조에 대한 동시 액세스를 걱정해야 한다. (마치 Rust처럼)

     


    Advanced

     

    pseudo-code

    이해한 방식으로 내 마음대로 pseudo code를 짜보면?

    GIL은 어떻게 보면 single mutex spin lock 방식이다.

    (임계 구역에 진입이 불가능할 때 진입이 가능할 때까지 루프를 돌면서 재시도하는 방식)

    # A global variable to track the current thread holding the GIL
    gil_holder = None
    
    # A lock to protect the gil_holder variable
    gil_lock = Lock()
    
    # A function to acquire the GIL
    def acquire_gil():
        gil_lock.acquire()
        if gil_holder is not None:
            # The GIL is already held by another thread, so wait until it is released
            while gil_holder is not None:
                sleep(0.1)
        # Set the current thread as the GIL holder
        gil_holder = current_thread()
        gil_lock.release()
    
    # A function to release the GIL
    def release_gil():
        gil_lock.acquire()
        # Only the current GIL holder can release the GIL
        if gil_holder == current_thread():
            gil_holder = None
        gil_lock.release()
    
    # A function to execute some Python bytecode
    def execute(bytecode):
        acquire_gil()
        try:
            # Execute the bytecode
            result = eval(bytecode)
        finally:
            # Release the GIL when we are done
            release_gil()
        return result

     

    두 개의 쓰레드가 실행될 때 내부 상황

    스레드가 하나만 있다면, 확인 없이 영원히 실행될 수 있고 GIL을 해제할 수 있다.

    하지만 두 개 이상의 스레드가 있는 경우, 현재 GIL에 의해 차단되는 스레드는 타임아웃 기간(sys.getswitchinterval)을 기다렸다가 gil_drop_request를 1로 설정하고, 계속 대기한다.

    현재 GIL을 유지하고 있는 스레드는 GIL을 해제하고 동일한 시간의 타임아웃 시간을 기다린다.

    만약 gil_drop_request가 1로 설정되어 있다면, 현재 차단 중인 스레드는 신호를 보내고 gil을 다시 acquire 할 수 있다.

    sys.getswitchinterval() on Py 3.10.6

    multiprocessing module

    파이썬 멀티프로세싱 모듈을 사용할 때 GIL은 기본적으로 bypass되는데, 모듈에 의해 생성된 각 프로세스는 자체 GIL과 함께 자체 파이썬 인터프리터를 실행하기 때문이다.

    이를 통해 Python 프로그램은 GIL의 제약을 받지 않고 여러 CPU 코어를 병렬로 사용할 수 있다.

    그러나 멀티프로세싱 모듈은 별도의 프로세스 생성 및 관리로 인해 추가적인 오버헤드를 초래하기 때문에 GIL의 한계에 대한 완전한 해결책이 아니라는 점에 유의해야 한다.

    추가적으로 pickle이 불가능하거나 공유 메모리에 의존하는 특정 유형의 파이썬 객체는 멀티프로세싱 모듈과 함께 사용하기에 적합하지 않을 수 있다.

    * pickle: Python 객체를 serialize/deserialize 위한 바이너리 프로토콜

     

    파이썬 표준 라이브러리의 concurrent.futures.ThreadPoolExecutor 클래스 (TPE)는 파이썬 프로그램이 여러 CPU 코어를 병렬로 사용할 수 있다는 점에서 멀티프로세싱 모듈과 유사하다.

    그러나 멀티프로세싱 모듈과 달리 TPE 클래스는 별도의 프로세스가 아닌 native한 thread pool을 생성하고 관리한다. (OS과 관리하는 thread, 하지만 네이티브 스레드는 Python 인터프리터 접근 불가하여 Python 코드를 실행할 수 없다.

    멀티프로세싱 모듈과 마찬가지로 TPE 클래스는 기본적으로 GIL을 bypass하므로 파이썬 프로그램이 여러 CPU 코어를 병렬로 사용할 수 있다.

    그러나 멀티프로세싱 모듈의 경우와 같이 네이티브 스레드를 생성하고 관리하는 오버헤드가 별도의 프로세스를 생성하고 관리하는 오버헤드보다 높을 수 있다는 점에 유의해야 한다.

    또한 스레드 세이프가 아닌 특정 유형의 Python 개체는 TPE 클래스와 함께 사용하기에 적합하지 않을 수 있다.

     

    Test

    타임아웃 시간, switch interval을 바꾸면 성능 효과가 있을까?

    결과부터 보면 크게 차이는 없다. 살짝 빨라지는 경우도 있겠지만 다른 라이브러들이나 일부 코드에서 문제가 발생할 수 있으니 안바꾸는 것을 추천한다고 한다.

     

    Python 3.10.6 결과

    Tick interval: 0.001
    Time: 5.085535299993353
    
    Tick interval: 0.01
    Time: 5.372263900004327
    
    Tick interval: 0.09999999999999999
    Time: 5.238755300000776
    
    Tick interval: 9.999999999999999e-05
    Time: 5.723175900027854
    
    Tick interval: 0.005  # 기본 값
    Time: 5.313613800011808

     

    Python No GIL 3.9.10

    nogil을 구현한 멀티쓰레딩 방식 파이썬 버전이다. 조금 더 빠른 것 같다.

     

    colesbury/nogil: Multithreaded Python without the GIL

    Multithreaded Python without the GIL.

    github.com

    Tick interval: 0.001
    Time: 4.483764600000001
    
    Tick interval: 0.01
    Time: 5.0197190240000005
    
    Tick interval: 0.09999999999999999
    Time: 4.678176593999996
    
    Tick interval: 9.999999999999999e-05
    Time: 4.212949922
    
    Tick interval: 0.005
    Time: 4.591929678000007

     

    benchmark code

    import sys
    import timeit
    
    # The loop that we will be measuring
    def test_loop(n):
        result = 0
        for i in range(n):
            result += i
        return result
    
    
    def main():
        # Set the tick interval to 1 millisecond
        sys.setswitchinterval(0.001)
        print(f"Tick interval: {sys.getswitchinterval()}")
        print(f"Time: {timeit.timeit(lambda: test_loop(100))}")
    
        # Set the tick interval to 10 milliseconds
        sys.setswitchinterval(0.01)
        print(f"Tick interval: {sys.getswitchinterval()}")
        print(f"Time: {timeit.timeit(lambda: test_loop(100))}")
    
        # Set the tick interval to 100 milliseconds
        sys.setswitchinterval(0.1)
        print(f"Tick interval: {sys.getswitchinterval()}")
        print(f"Time: {timeit.timeit(lambda: test_loop(100))}")
    
        # Reset the tick interval to the default value
        sys.setswitchinterval(0.005)
        print(f"Tick interval: {sys.getswitchinterval()}")
        print(f"Time: {timeit.timeit(lambda: test_loop(100))}")
    
    
    if __name__ == "__main__":
        main()

     

    Alternatives

    어떠한 방법으로 성능을 개선할 수 있을지 궁금해서 대안을 찾아보았다.

    1. Fine-grained locks: GIL의 성능을 향상하는 세분화된 잠금, 이는 동일한 데이터에 액세스 하지 않는 한 여러 스레드가 동시에 파이썬 바이트 코드를 실행할 수 있게 한다. 이 접근법은 A.M. Kuchling와 G. van Rossum (파이썬 창시자)에 의해 논문이 나왔는데 CPython에 도입될 복잡성과 오버헤드 때문에 채택되지 않았다.
    2. Multi-threaded Python: 멀티 쓰레드된 버전의 파이썬, 각각의 스레드가 개인 인터프리터와 GIL을 갖는다. 이 방법은 PyPy 인터프리터에서 볼 수 있어 멀티 쓰레딩 프로그램에서 꽤 훌륭한 성능 향상을 보여주지만 모든 파이썬 라이브러리와 호환되지는 않는다.
    3. Asynchronous programming: 멀티코어 시스템에서 파이썬의 성능을 향상시킬 수 있는 조금 최근 방법은 비동기 프로그래밍을 하는 것이다. 이 방법은 GIL이나 lock 없이 스레드나 프로세스가 동시 다발적으로 실행할 수 있는 코드를 작성할 수 있게 해 준다. (concurrent), asyncio, Twisted, Tornado와 같은 라이브러리가 비동기 프로그래밍을 도울 수 있다.
    4. Cython: Cython은 파이썬 코드로부터 C/C++ 코드를 생성해주는 static 컴파일러다.Cython으로 작성하면 전반적으로 성능 향상과 특히 CPU 작업 성능은 많이 올라간다. Cython은 C 나 C++ 라이브러리도 포함할 수 있고, 일반 파이썬 문법도 섞어 쓸 수 있어서 꽤 괜찮으나 특정 파이썬 라이브러리와의 비호환과 어려운 문법, 타입 정의가 너무 많아서 속도가 더 느릴 때도 있다.
    5. PyPy: 앞서말한 PyPy는 CPython보다 멀티 쓰레딩 프로그램에서 더 나은 성능을 보여주는 파이썬 인터프리터이다. PyPy는 Just-In-Time (JIT) 컴파일러를 써서 Python 코드 실행을 최적화해서 기본 파이썬보다 속도가 빠를 수 있다. 그렇지만, PyPy도 모든 파이썬 라이브러리와 호환되지는 않고 특정 목적에는 비적 합할 수 있다.

    Rust PyO3

    PyO3는 Rust로 Python 라이브러리를 작성할 수 있게 해주는 라이브러리이다.

    아래 코드처럼 allow_threads를 통해 GIL을 release하고 scope를 벗어나면 알아서 acquire 해준다.

    하지만 python object를 사용하는 함수에서는 쓸 수 없고 순수 rust로 된 함수를 이용하면 concurrent를 짤 수 있다.

    다시 acquire를 안 하면 그대로 코드는 그 부분에서 멈춰버리니 조심하자.

    use pyo3::prelude::*;
    
    fn long_running_function() -> usize {
        // Perform a long-running computation
        42
    }
    
    #[pyfunction]
    fn call_long_running_function(py: Python) -> usize {
        py.allow_threads(|| long_running_function())
    }

     

    acquire_gil

    이 예에서는 file 개체에서 tell 메서드를 호출하기 전에 GIL을 획득한다.

    이는 tell 메서드 호출이 Rust 코드에서 이루어지므로 thread-safe를 보장하기 위함이다.

    GIL을 획득하지 않고 여러 Rust 스레드가 동시에 Python 코드를 호출하는 것은 가능하며, 이는 race 상태와 다른 유형의 thread-safe 문제를 야기할 수 있다.

    tell 메서드를 호출하기 전에 GIL을 획득함으로써 PyO3 코드는 한 번에 하나의 Rust 스레드만 Python 코드를 실행하도록 보장한다.

    #![allow(unused)]
    fn main() {
        use pyo3::import_exception;
        use pyo3::prelude::*;
    
        import_exception!(io, UnsupportedOperation);
    
        fn tell(file: PyObject) -> PyResult<u64>;
        {
            use pyo3::exceptions::*;
    
            let gil = Python::acquire_gil();
            let py = gil.python();
    
            match file.call_method0(py, "tell") {
                Err(_) => Err(UnsupportedOperation::py_err("not supported: tell")),
                Ok(x) => x.extract::<u64>(py),
            }
        }
    }

     

    Fine-grained locking

    세분화된 잠금 (fine-grained)은 매우 짧은 시간 동안 유지되는 잠금을 사용하여 여러 스레드가 동시에 코드를 실행할 수 있도록 하는 방법이다.

    이것은 더 많은 스레드가 동시에 진행할 수 있게 해 주기 때문에 단일 글로벌 잠금(예: CPython의 GIL)을 사용하는 것보다 더 효율적일 수 있다.

    그러나 많은 잠금을 신중하게 관리해야 하기 때문에 구현 및 디버그가 더 복잡할 수도 있다.

    세분화된 잠금은 단일 프로세스 내에서 여러 개의 잠금을 허용해 전체 프로그램을 보호하는 단일 잠금을 갖는 대신 다른 스레드가 프로그램의 다른 부분에서 동시에 작동하도록 허용함으로써 동시성을 향상하는 데 도움이 될 수 있다.

    그러나 세분화된 잠금을 구현하는 것은 더 복잡할 수 있으며 성능에 부정적인 영향을 미칠 수 있으므로 항상 최선의 해결책은 아니다.

     

    pseudo code

    세분화된 잠금의 구현은 일반적으로 파이썬 인터프리터에서 각 객체에 대한 잠금을 만드는 것을 포함한다. 

    스레드가 개체에 액세스 하려면 진행하기 전에 해당 개체에 대한 잠금을 획득해야 한다.

    이렇게 하면 여러 스레드가 개체에 액세스 하기 전에 모든 스레드가 하나의 잠금을 획득해야 하는 대신 여러 스레드가 동시에 다른 개체에 액세스 할 수 있다.

    class FineGrainedLock:
        def __init__(self):
            self.locks = {}
        
        def acquire(self, obj):
            if obj not in self.locks:
                self.locks[obj] = Lock()
            self.locks[obj].acquire()
        
        def release(self, obj):
            self.locks[obj].release()
    
    # To use the fine-grained lock:
    lock = FineGrainedLock()
    
    # To acquire the lock for an object:
    lock.acquire(obj)
    
    # To release the lock for an object:
    lock.release(obj)

     

    Future

    Global Interpreter Lock은 수년간 파이썬 커뮤니티 내에서 논란이 된 주제였다.

    어떤 이들은 언어를 단순하고 사용하기 쉽게 유지하는 것이 필요악이라고 믿는 반면, 다른 이들은 파이썬이 진정한 동시 실행을 달성하는 것을 막는 주요한 제한이라고 주장한다.

    이러한 지속적인 논쟁에도 불구하고, GIL은 가까운 미래에 CPython의 특징으로 남을 것이다.

    대체에서 봤듯이 GIL을 개선하거나 대체하려는 여러 시도가 있었는데, 이를테면 PyPy의 "Green Thread" 구현과 stackless 파이썬의 "microthread" 구현이 있었다.

    그러나 이러한 대안은 널리 채택되지 않았으며 GIL은 대부분의 파이썬 사용자에게 기본 옵션으로 남아 있다.

    장기적으로, GIL은 비동기 프로그래밍이나 세분화된 잠금에 대한 언어 수준 지원과 같은 동시성에 대한 다른 접근법으로 대체될 수 있다.

    그러나 GIL에 대한 주요 변경 사항은 상당한 노력이 필요하며 기존 파이썬 코드를 손상시킬 수 있으므로 이러한 변경 사항이 구현될지는 불확실하다.

    결론적으로, GIL은 이점과 단점을 모두 가진 파이썬 프로그래밍 언어의 중요하고 복잡한 측면이라고 본다.

    향후 GIL을 개선하거나 대체하는 것이 가능할 수 있지만, 가까운 미래에도 CPython의 핵심 기능으로 남을 가능성이 높다.

     

    참조

     

    GitHub - python/cpython: The Python programming language

    The Python programming language. Contribute to python/cpython development by creating an account on GitHub.

    github.com

     

     

    GitHub - python/cpython: The Python programming language

    The Python programming language. Contribute to python/cpython development by creating an account on GitHub.

    github.com

     

     

    When Python can’t thread: a deep-dive into the GIL’s impact

    Python’s Global Interpreter Lock (GIL) stops threads from running in parallel or concurrently. Learn how to determine impact of the GIL on your code.

    pythonspeed.com

     

    728x90

    댓글