GIL이란

viroovr·2024년 10월 17일
post-thumbnail

파이썬으로 영상 라벨링 툴도 제작하고 코테도 보고 django도 다뤄봤지만 GIL은 완전 처음 듣는다.
파이썬에는 어떤 기능으로 GIL이 존재할까?

GIL, 즉 Global Interpreter Lock은 파이썬 인터프리터에서 동시성 제어를 위해 사용되는 매커니즘이다. 파이썬은 기본적으로 멀티스레딩을 지원하지만, GIL은 동시에 하나의 스레드만이 파이썬 바이트코드를 실행할 수 있도록 제한을 둔다. 이를 통해 메모리 관리를 안전하게 수행하고, 스레드 간의 충돌을 방지하는 역할을 한다.

하나의 스레드만이 동작하도록 해서 데드락 등의 동시성 문제를 해결할 수 있는 기능이다.

GIL은 파이썬 CPython 인터프리터에서 주로 사용된다. 파이썬은 스레드 기반의 병렬 처리를 지원하지만, GIL이 존재하는 한 실제로는 멀티스레드 환경에서도 한 번에 하나의 스레드만 CPU를 사용하게 된다. 즉, 여러 스레드가 동시에 실행되는 것처럼 보이지만, GIL이 스레드마다 잠금을 걸고 해제하면서 순차적으로 스레드가 실행되는 방식이다.

파이썬의 기본적인 멀티스레드 환경을 제어해서 결국 순차적으로 스레드가 실행되게 한다. 마치 한 개의 스레드만 실행되는 것처럼 말이다.

그러면 GIL의 장점은 무엇일까? 장점은 뚜렷해보인다.

파이썬은 GIL 덕분에 메모리 관리를 매우 쉽게 할 수 있다. 특히, 파이썬은 참조 카운팅 방식으로 메모리를 관리하는데, GIL은 여러 스레드에서 참조 카운트가 안전하게 변경될 수 있도록 보장해준다. 이를 통해 개발자가 스레드 안정성을 따로 고려하지 않아도 된다.

여기서 살짝 참조 카운팅에 대해 무지하므로 잠깐 알아본다.

파이썬의 참조 카운팅(reference counting)방식은 메모리 관리를 위한 기법으로, 객체가 몇 개의 변수나 다른 객체에서 참조되고 있는지를 추적하는 방식이다. 객체의 참조 횟수가 0이 되면, 해당 객체는 더 이상 사용되지 않으므로 자동으로 메모리에서 해제된다.
파이썬에서 모든 객체는 생성될 때 참조 카운트라는 속성을 가진다. 이는 해당 객체를 참조하는 변수가 몇개인지를 나타내는 숫자이다.

메모리에서 참조 카운팅은 스레드들에게 공유자원으로 존재하므로 이를 안전하게 변경되게끔 GIL이 작동되는 것이다.

하지만 GIL도 단점이 존재한다. 바로 성능 문제이다.

파이썬에서 멀티스레딩을 사용해도 진정한 병렬 처리를 할 수 없다는 한계를 가진다. CPU를 많이 사용하는 작업, 특히 CPU 바운드 작업에서는 GIL이 병목현상을 일으켜 멀티스레드 성능이 오히려 떨어질 수 있다.
예를 들어, 다중 코어 시스템에서 파이썬이 모든 코어를 활용하지 못하고, GIL때문에 오직 하나의 코어만 사용되는 경우가 발생한다. 결과적으로 멀티스레딩의 이점이 줄어들어 성능이 기대에 미치지 못할 수 있다.

그렇다면 GIL의 문제를 극복할 방법은 없을까?
1. GIL은 프로세스마다 독립적으로 존재하므로, 멀티프로세싱을 사용하면 각 프로세스가 자신의 GIL을 가지게 되어 병렬 처리가 가능하다. 파이썬의 multiprocessing 모듈을 사용하면 CPU 코어를 모두 활용할 수 있다.

하지만 위의 방법은 스레드를 사용하는 장점이 없어지므로 그닥 좋아보이지 않는다.

  1. CPU 바운드 작업의 경우, 파이썬에서 GIL을 피하기 위해 C 언어로 작성된 확장 모듈을 사용할 수 있다. C 확장 모듈에서는 GIL을 잠시 해제하고 병렬처리를 수행할 수 있다.

  2. 파이썬의 GIL은 CPU 바운드 작업에서 문제가 되지만, I/O 바운드 작업에서는 큰 영향을 미치지 않는다. 파이썬의 asyncio나 비동기 I/O를 활용하여 성능을 개선할 수 있다.

그렇다면 한번 GIL에 대해 실습해보자.

두 개의 스레드를 사용하여 CPU 바운드 작업을 수행하지만, GIL 때문에 스레드가 실제로 병렬로 실행되지 않고 순차적으로 실행되는 모습을 확인할 수 있다.

import threading
import time

def cpu_bound_task():
	total = 0
    for _ in range(10**7):
    	total += 1
    return total

start_time = time.time()

threads = []
for _ in range(2):
	thread = threading.Thread(target=cpu_bound_task)
    threads.append(thread)
    thread.start()

for thread in threads:
	thread.join()

end_time = time.time()

print(f"멀티 스레딩 걸린 시간: {end_time - start_time: .2f}초")

같은 작업을 단일 스레드로 실행하며 시간을 비교해 보자.

import time
start_time = time.time()

cpu_bound_task()
cpu_bound_task()

end_time = time.time()
print(f"단일 스레딩 걸린 시간: {end_time - start_time: .2f}초")

첫 번째의 경우 스레드가 여러 개지만, GIL은 CPU 바운드 작업에 대해서는 스레드 간의 실제 병렬 실행을 제하기 때문에, 두 스레드가 동시에 실행되지 않고 교대로 실행된다. 따라서 멀티스레딩으로 작업을 나누더라도 성능차이가 미미할 수 있다.

멀티 스레딩 걸린 시간:  0.73초
단일 스레딩 걸린 시간:  0.74

실행 결과 위와 같이 유의미한 차이를 보이지 않았다.

그렇다면 멀티프로세싱을 활용해 CPU 바운드작업에서 병렬 성능을 얻어보자

import multiprocessing
import time

def cpu_bound_task():
    total = 0
    for _ in range(10**7):
        total += 1
    return total

start_time = time.time()

processes = []

for _ in range(2):
    process = multiprocessing.Process(target=cpu_bound_task)
    processes.append(process)
    process.start()

for process in processes:
    process.join()

end_time = time.time()

print(f"멀티프로세싱 걸린 시간: {end_time - start_time:.2f}초")
멀티프로세싱 걸린 시간: 0.46

멀티프로세싱이 걸린 시간이 유의미하게 작아졌음을 확일할 수 있다.

따라서 멀티스레딩에서는 GIL 때문에 CPU 바운드 작업의 병렬 처리가 제한되며,
멀티프로세싱을 사용하면 GIL을 피하고 진정한 병렬처리가 가능해진다.

그러면 GIL을 끌 수는 없을까?

파이썬에서 GIL은 기본적으로 내장되어 있으며, CPython 인터프리터의 근본적인 구조 때문에 사용자가 GIL을 끄거나 켜는 것을 직접적으로 제어할 수 없다. 즉, GIL은 파이썬 CPython 구현에서 기본설정으로 항상 활성화 되어있다.

아쉽게도 CPython의 참조 카운팅에 의한 메모리 관리 방법때문에 메모리 손상과 충돌을 방지하려면 GIL을 끌 수 없다.

그래도 I/O 작업의 경우, 스레드가 대기하는 동안 다른 작업을 동시에 처리할 수 있으므로 thread가 유용하다.

앞으로 Python에서 CPU 바운드의 병렬 작업을 처리하려면 thread보다 process를 생성해야 되겠다.

"이 글의 내용은 OpenAI의 ChatGPT를 참고하여 작성되었습니다."

profile
성장하는 개발자

0개의 댓글