GIL: Python 병렬 프로그래밍의 현재와 미래
F-Lab : 상위 1% 개발자들의 멘토링
안녕하세요. F-Lab 'Python Backend' 과정 멘토 Jacob 입니다. 저는 센드버드 출신의 엔지니어링 조직 문화와 생산성에 관심이 많은 개발자입니다. 오늘은 GIL 과 관련된 최근 발표를 소개하고 no-GIL 이 어떤 변화를 가져올지 알아보려고 합니다.
개요
Python 은 Tiobe Index에서 수년째 가장 인기있는 언어로 자리매김하고 있습니다. 단순한 스크립팅과 자동화 뿐만 아니라 데이터 사이언스나 머신러닝, 웹 개발에 이르기 까지 다양한 분야에서 Python 이 사용됩니다.
Python 사용자 커뮤니티가 성장하고 성숙해지면서 병렬 프로그래밍과 같은 고성능 컴퓨팅에 대한 수요가 증가한 데 반해서 CPython 구현체는 병렬 프로그래밍에 있어서 뚜렷한 한계가 있습니다. GIL, 또는 Global Interpreter Lock 이라는 메커니즘 때문에 CPython 은 한번에 하나의 스레드만 실행이 가능한 제약을 가지고 있으며 표준 라이브러리로 멀티스레딩을 지원함에도 멀티코어 CPU의 이점을 충분히 활용하지 못했습니다.
GIL 그리고, no-GIL
https://en.wikipedia.org/wiki/Global_interpreter_lock
GIL은 Python 인터프리터가 데이터 정합성 문제 없이 여러 스레드를 안전하게 실행할 수 있도록 하는 메커니즘입니다. Python이 처음 세상에 선보여진 1991년에는 현재에 비해서 멀티코어 CPU 환경에 대한 고려가 중요하지 않았고 Python이 위와 같이 다양한 분야에서 사용될것이라고 아무도 예상하지 못했습니다. 그렇기 때문에 CPython 구현체의 구현 시점에 GIL을 이용해서 디자인을 단순화하는 선택이 이루어졌습니다. 하지만 이는 동시에 CPU 병렬성을 제한하는 요인이 되었습니다.
Python 커뮤니티 내에서는 GIL로 인한 제약을 제거하는 것에 대한 논의가 꾸준히 진행되어 왔고 결국 PEP703을 통해 공식화되었습니다. PEP703은 GIL로 인해서 발생하는 문제점들을 지적하고 GIL을 사용하는 옵션을 여전히 제공하되 no-GIL 버전의 Python을 기본으로 삼기를 제안하는 문서입니다. 이 문서는 GIL을 제거하기 위해서 필요한 일들을 자세히 설명합니다.
Python의 개발 방향성을 결정하는 Python Steering Council은 2023년 PEP703을 받아들이기로 결정하고 점진적 접근을 이용해서 세 단계에 걸쳐서 변화를 받아들일 수 있도록 계획했습니다.
첫번째 단계에서는 `--disable-gil` 빌드 옵션을 통해 no-GIL 버전의 Python을 제공하고, 두번째 단계에서 no-GIL 버전을 Python 재단의 공식 배포 버전중 하나로 채택하는것을 목표로 합니다. 마지막 단계로는 no-GIL 버전으로 기본 배포 버전을 대체하는 것이 있습니다.
이 계획에 따르면 2~3년 내에 no-GIL 버전을 완전히 지원하고 5년 내에는 no-GIL 버전이 기본이 될 것으로 예상을 하고 있습니다.
이러한 변화는 Python 생태계에 새로운 가능성을 열어줄 것으로 기대됩니다. no-GIL 버전에서는 GIL 이 제거되어 멀티코어 환경에서 병렬성을 최대한 활용할수 있게 됩니다. 하지만 no-GIL 버전으로의 전환을 위해서는 많은 변화가 필요합니다. 특히 기존 라이브러리들이 no-GIL 환경에 맞게 변경되는 것이 가장 큰 장애물일것으로 예상되며 Python 2에서 Python 3으로의 전환 때와 같은 혼란과 불편함이 있을 수 있습니다. Python Steering Council 에서는 이러한 점을 고려하여 전환 전 과정에 있어서 가능한 조심스럽게 접근할 것이라고 밝혔습니다.
GIL 제거의 가장 큰 문제
GIL을 제거하는 데 가장 큰 문제가 되는 것은 Python의 가비지 컬렉션에 있습니다. Python 은 현재 참조 카운팅 기반의 가비지 컬렉터를 사용하고 있으며 이는 멀티스레드 환경을 고려하지 않고 설계 되었기 때문에 순환 참조와 같은 상황에서 메모리 누수를 일으킬 수 있습니다. JVM의 경우에는 Mark and Sweep 방식의 가비지 컬렉터를 지원하며 병렬 실행을 위한 많은 최적화를 진행한 것과는 대조적입니다.
따라서 PEP703에서는 Python 가비지 컬렉터가 멀티스레드 환경에서 동작할 수 있도록 여러 변경 방식을 제안하고 있습니다.
첫째는 '편향 참조 카운팅'과 ‘지연된 참조 카운팅’입니다.
편향 참조 카운팅은 하나의 스레드에 의해서만 접근되는 객체에 대한 카운트는 여러 스레드가 접근하는 객체와는 별도로 처리하는것을 의미합니다. 많은 경우 Python 프로그램에서 객체는 단일 스레드에 의해서만 접근되므로 이 접근 방법을 이용하면 단일 스레드를 이용하는 프로그램의 실행 속도가 느려지는 일을 최소화할 수 있습니다.
지연된 참조 카운팅은 자주 접근되는 객체에 대해서 매번 참조 카운팅을 수행하기 보다는 가비지 컬렉터가 확인하는 시점까지 카운팅을 미루는 것을 의미합니다. 이 방식을 이용하면 자주 접근되는 비교적 생애주기가 긴 객체들에 대해서 불필요하게 자주 참조 카운팅이 일어나는것을 예방할 수 있습니다.
두번째 접근은 ‘Immortalization’ 입니다.
Immortalization은 불변 객체에 대해서 참조 카운팅을 수행하지 않는 것을 의미합니다. 불변 객체의 예시로는 None이나 -5에서 256과 같은 특정 영역의 정수가 있습니다. 이러한 객체들은 CPython이 실행되는 시점에 할당되고 종료시까지 할당을 해제할 필요가 없으므로 참조 카운트도 추적할 필요가 없습니다. 이 접근을 이용하면 참조 카운팅의 대상을 줄임으로써 성능 향상을 얻을 수 있습니다.
세번째는 'Thread-safe 한 메모리 할당기 도입' 입니다.
PEP703 에서는 CPython 에서 현재 사용되는 pymalloc 이 스레드 세이프 하지 않기 때문에 mimalloc 이라는 멀티 스레드 환경에 적합하고 좋은 성능을 보이는 메모리 할당기를 도입하는것을 제안합니다. 또 mimalloc 은 더 저렴하게 객체를 추적할 수 있고 크기가 알려진 객체에 대해서 읽기 연산을 수행하는 상황에서 lock 을 필요로 하지 않아 추가적인 성능 향상을 줄 수 있습니다.
CPython 을 이용한 병렬 프로그래밍
이러한 접근을 거치면 Python 이 보다 병렬 프로그래밍에 적합한 언어로 발전할 것으로 보이며 최근 Python 이 가장 많이 쓰이는 머신러닝 등의 분야에서 더 많은 일들을 가능하게 할 것으로 보입니다. 그렇다면 no-GIL 버전이 공식적으로 출시되기 전까지는 Python 을 이용해서 병렬 프로그래밍을 할 방법이 없는 것일까요? 현재 존재하는 CPython에서도 몇가지 방법을 통해서 병렬 프로그래밍이 가능합니다.
multiprocessing
https://docs.python.org/3/library/multiprocessing.html
대표적인 방법은 multiprocessing 모듈을 사용하는 것입니다. 이 모듈은 프로세스를 fork 나 spawn 과 같은 저수준 인터페이스 뿐만 아니라 Pool 과 Lock 과 같은 고수준의 추상화도 제공합니다. multiprocessing 모듈을 이용해서 워커 프로세스 풀을 만들고 작업을 할당하는 방식은 비교적 큰 프로젝트에서 흔하게 발견할 수 있습니다. 대표적으로 Celery 프로젝트에서 전달받은 task 들을 처리하기 위해서 이러한 접근방식을 사용하고 있습니다.
multiprocessing 모듈을 사용하는 것은 일반적인 멀티스레드 프로그래밍와 비교했을 때 차이점이 있습니다. 우선 일반적으로 프로세스는 생성 비용이 스레드에 비해서 크고, 독립적인 메모리 공간을 점유하기 때문에 더 자원을 많이 사용하게 된다는 점이 있습니다. 또 multiprocessing 모듈을 이용한 경우에는 프로세스 간 데이터 공유가 어렵다는 단점이 있습니다. 같은 프로세스 내의 스레드 간에는 주소 공간이 같아서 데이터 공유가 쉽지만 프로세스 간에는 데이터 공유를 위해서 아래와 같은 몇 가지 기법을 사용해야 합니다.
프로세스 간에 데이터 공유를 위한 기법
- Shared Memory - 여러 프로세스가 동일한 메모리 공간을 공유하여 데이터를 주고받는 방식입니다. multiprocessing.Value 클래스를 이용해서 선언할 수 있습니다. 다만 프로세스 간 동기화 문제에 주의해야 합니다.
- Pipe - Pipe 는 단방향 통신 채널로, 한 프로세스가 데이터를 보내면 다른 프로세스가 해당 데이터를 수신할수 있게 해줍니다. multiprocessing.Pipe 클래스를 이용해서 Pipe 를 생성하고 Pipe의 양쪽 끝에 해당하는 객체들에 접근할 수 있습니다. Pipe 는 여러 프로세스가 동시에 같은 방향의 끝에 쓰기나 읽기를 수행할 경우 데이터의 정합성을 보장하지 않기 때문에 주의해야 합니다.
- Queue - Queue 는 Pipe 에서 언급된 문제 없이 여러 프로세스가 동일한 데이터 구조에 접근할 수 있게 해줍니다. Queue 는 잘 알려진 프로듀서-컨슈머 패턴을 구현하는데 유용합니다.
- Manager - Manager 는 데이터 관리를 위한 별도의 프로세스를 생성하고 그 프로세스에 다른 객체들이 접근할수 있는 객체를 제공합니다. Manager 가 반환한 객체가 가비지 콜렉터에 의해서 제거될 경우 데이터 관리를 위해 생성된 프로세스도 제거되며 값에 접근할 수 없게 됩니다.
마무리
multiprocessing을 활용하면 이미 존재하는 Python 배포 버전에서도 병렬 프로그래밍이 가능합니다. 하지만 위에서 언급한 것처럼 리소스 문제나 데이터 공유를 위해 추가적인 구조가 필요한 문제 등의 한계가 존재하는것도 사실입니다. 이에 비해 no-GIL 버전에서는 스레드 기반의 진정한 병렬 프로그래밍이 가능해질 것으로 기대됩니다.
물론 no-GIL 버전의 도입을 위해서는 아직 많은 과제가 남아 있습니다. 기존 파이썬 라이브러리와 애플리케이션들이 no-GIL 환경에 맞게 수정되어야 하고 GIL 의 동작에 의존하던 코드들이 바뀌어야 합니다. 또한 데이터 정합성 문제나 데드락과 같은 병렬 프로그래밍 환경에서의 버그 발생 가능성도 높아질 수 있습니다.
그럼에도 불구하고 no-GIL 버전의 도입은 Python 생태계 전반에 걸쳐 큰 변화를 가져올 것으로 기대됩니다. CPU 활용도 향상과 성능 개선은 물론이고 병렬 프로그래밍 모델의 도입으로 고성능 컴퓨팅 영역에서 개발자 생산성도 높아질 수 있습니다. 비록 이제 첫 삽을 뜬 상황이지만 더 나은 성능과 생산성 그리고 더 넓어진 생태계를 가진 Python 의 모습을 기대해 보며 글을 마무리하겠습니다.
이 컨텐츠는 F-Lab의 고유 자산으로 상업적인 목적의 복사 및 배포를 금합니다.