파이썬 Multi-Thread와 GIL에 대해 알아보기
컴퓨터 공학(Computer Science)을 공부하면, 동시성(Concurrency)과 병렬성(Parallelism)에 대해서 배운다. 동시성은 여러 작업이 순서 상관없이 혼재되어 실행될 수 있다는 의미이고, 병렬성은 여러 작업이 동시에 수행될 수 있다는 의미이다. 우리는 코드를 짤 때, 병렬성을 고려하지 않고도 동시성을 만족시킬 수 있다. 하지만 동시성을 만족시키지 못하고는 병렬성을 구현할 수는 없다.
코드 성능을 개선하는 여러 가지 방법이 있지만, 그중 대표적인 방법이 바로 병렬성을 만족시키는 것이다. 예컨대, 1~100까지의 작업이 있다고 가정해 보자. 만약 1명의 작업자가 100개 작업을 모두 수행한다고 하면, 순서와 상관없이 동일한 시간이 걸린다.(여기서 작업 간 Context Switching 비용은 없다고 가정하자) 즉, 쉬운 작업부터 어려운 작업 순으로 하나, 어려운 작업에서 쉬운 작업 순으로 하나 논리적으로는 동일한 시간 안에 작업이 끝난다.
하지만 같은 작업은 2명이 한다고 하면 어떨까. 처리 속도가 똑같은 2명이 각각 50개씩 작업하면 절반의 시간만에 작업을 끝낼 수 있다. 만약 5명, 10명으로 작업자가 늘어난다면 훨씬 적은 시간 안에 작업을 끝낼 수 있다.
코드도 마찬가지이다. 컴퓨터는 코드를 작성한 대로 수행한다. 구체적으로는 CPU가 메모리로부터 데이터를 가져와서 연산을 수행한다. 작업에 따라서 네트워크 I/O, 블록 I/O 등의 작업을 수행할 수도 있지만, 기본적으로 프로그램을 수행하는 주체는 CPU인 셈이다.
파이썬은 프로세스 하나를 실행하면 기본적으로 1개의 파이썬 인터프리터(Interpreter)가 코드를 실행한다. 파이썬 인터프리터는 코드 바이트를 읽어서 실행하는데 이것은 thread-safe 하지 못하다. 그 이유는 코드를 평가하고 수행하는데 필요한 변수들이 전역적으로 선언되어 있어, 어느 스레드에서나 접근할 수 있기 때문이다. 그래서 파이썬 인터프리터는 GIL(Global Interpreter Lock)을 도입하여 이 문제를 해결했다.
GIL은 파이썬이 코드를 수행하기 위해 잡은 글로벌 락이다. 파이썬 인터프리터가 특정 스레드의 코드를 수행하기 위해서는 이 글로벌 락을 획득해야 한다. 코드를 수행하고 나면 락은 다시 해제되고, 다른 스레드가 락을 획득하여 코드를 수행할 수 있다. GIL 덕분에 파이썬 개발자들은 모든 객체에 대해서 락을 구현하는 복잡한 구조를 피할 수 있었고, Thread-safe 하게 코드를 수행할 수 있게 되었다.
PEP-703 내용에 따르면, 3.13 버전부터 GIL을 비활성화할 수 있는 옵션이 추가될 예정이다. 기본 패키지는 --disable-gil 옵션이 꺼진 상태로 제공되지만, 원한다면 해당 옵션을 켜고 빌드할 수 있다. 아직 완전히 GIL에 대한 의존도를 벗어나기는 어렵다는 평이 많지만, 그래도 이번에는 꽤나 성공적인 사례로 평가받고 있는 부분도 있어 보인다.
https://peps.python.org/pep-0703/
문제는 GIL을 획득해야 코드를 수행할 수 있기 때문에, 결국 하나의 CPU에서 싱글 스레드처럼 동작한다. 즉, 하나의 CPU에서 수행한 프로세스에서 threading 모듈을 활용해 멀티 스레드 코드를 작성하더라도, 실제로는 싱글 스레드로 동작하기 때문에 이점이 없다는 의미이다.
코드를 통해서 속도를 비교해 보면 정확히 알 수 있다. 아래는 CPU를 사용하는 작업을 싱글 스레드로 수행할 때와 멀티 스레드로 수행할 때의 시간을 비교한 코드이다. 스레드는 100개이고, 작업은 천만번 반복한다.
결과를 보면, 다음과 같다. 싱글 스레드로 돌렸을 때 12.81초, 멀티 스레드로 돌렸을 때 12.87초로 거의 비슷한 시간이 걸렸다. 이런 경우라면 굳이 멀티 스레드로 코드를 구현할 필요가 없다.
하지만, 이건 언제까지나 CPU를 주로 사용하는 작업의 경우에만 해당한다. 만약 Network 나 Block I/O 작업이 많다고 하면 분명 멀티 스레드가 도움이 된다. 왜냐하면 I/O 작업은 기본적으로 커널이 작업을 수행하는 동안 CPU가 다른 작업을 수행하기 때문에 대기 시간이 발생한다. 즉 CPU는 I/O 작업이 수행되는 동안 다른 스레드를 수행할 수 있기 때문에, 여러 스레드가 동시에 I/O 작업을 수행할 수 있게 된다.
아래 코드는 Network I/O가 주로 발생하는 작업을 위와 동일하게 비교해 본 코드이다. 네트워크 작업 시간을 고려해서 반복 횟수와 스레드 수는 줄여서 수행했다.
결과를 보면, 싱글 스레드로 돌린 경우 35.69초, 멀티 스레드로 돌린 경우 3.96초 만에 종료되었음을 알 수 있다. 대략 스레드 수만큼 차이가 난 셈이다. 따라서 네트워크 작업이 빈번하거나, 파일 읽기/쓰기 등의 작업이 많다면, 멀티스레드로 구현하는 게 조금이나마 성능을 개선할 수 있는 포인트가 될 수 있다.
요컨대, 파이썬 인터프리터는 코드를 읽고 수행하는데 여러 전역변수들을 사용한다. 따라서 기본적으로 Thread-safe 하지 않은데, 이를 보완하기 위해 GIL이라는 글로벌 락을 도입했다. 파이썬 인터프리터는 특정 스레드의 코드를 수행하기 위해 GIL을 획득한다. 일정량만큼 수행하고 나면 GIL을 해제하고, 다른 스레드가 다시 락을 잡아서 코드를 수행한다. 따라서 CPU-bound 한 작업을 수행할 때는 싱글 스레드로 수행할 때에 비해서 성능 개선 효과가 떨어진다. 하지만 I/O-bound 한 작업을 수행할 때는 CPU 작업과 네트워크 작업이 병렬로 수행될 수 있기 때문에 멀티 스레드로 구현하는 것이 도움이 된다.