
이번 포스팅에서는 Python의 GIL(Global Interpreter Lock)에 대해 알아보겠습니다. GIL은 Python의 멀티 스레딩 성능에 어떤 영향을 미치는지, 그리고 GIL이 왜 존재하는지에 대해 쉽게 설명합니다. 또한, GIL의 한계와 Python에서 동시성 프로그래밍을 효율적으로 구현하기 위한 대안인 asyncio와 multiprocessing 모듈도 함께 살펴봅니다. 이 글을 통해 Python의 언어적 특징과 멀티 스레딩 및 비동기 프로그래밍의 이해를 높일 수 있을 것입니다.
추가로 Python 버전별로 asyncio 라이브러리의 주요 기능 변화와 그 차이도 살펴보도록 하겠습니다.
GIL(Global Interpreter Lock)은 Python의 CPython 인터프리터에서 사용되는 메커니즘으로, 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있도록 제한합니다. GIL은 멀티 스레드 환경에서 데이터의 일관성을 보장하기 위해 존재하지만, 그 결과로 CPU 바운드 작업에서 멀티 스레딩의 성능이 제한될 수 있습니다. 이는 여러 스레드가 병렬로 실행되더라도, GIL 때문에 실제로는 하나의 스레드만 실행되는 상황이 발생하기 때문입니다.
쉬운 동시성 처리: Python의 threading 모듈을 사용하여 멀티 스레딩을 쉽게 구현할 수 있지만, GIL이 활성화되어 있기 때문에 CPU 집약적인 작업에서는 성능이 크게 향상되지 않습니다.
I/O 바운드 작업: GIL은 I/O 작업에서는 상대적으로 덜 영향을 미칩니다. 이 경우, 스레드가 I/O 작업(예: 파일 읽기/쓰기, 네트워크 요청 등)을 기다리는 동안 다른 스레드가 실행될 수 있기 때문에 멀티 스레딩이 여전히 유효합니다.
멀티프로세싱 대안: Python은 GIL의 제약을 우회하기 위해 multiprocessing 모듈을 제공하여, 각 프로세스가 별도의 Python 인터프리터를 사용하도록 하여 멀티코어 CPU의 병렬성 성능을 활용할 수 있게 합니다.
Multi-processing: 각 프로세스가 독립적인 메모리 공간을 사용하며, 서로 간섭 없이 병렬로 실행됩니다. 이는 멀티코어 CPU의 성능을 최대로 활용할 수 있지만, 프로세스 간 통신이 비용이 많이 듭니다.
Multi-threading: 같은 프로세스 내에서 여러 스레드가 메모리를 공유하며 실행됩니다. 메모리 공유로 인해 통신이 빠르지만, Python에서는 GIL로 인해 한 번에 하나의 스레드만 실행되므로 성능 향상이 제한적입니다.
GIL은 Python이 스레드 안전성을 쉽게 유지할 수 있도록 도와주지만, 그로 인해 Python이 CPU 집약적인 작업에서 다중 스레드를 사용하여 병렬 처리를 수행하는 데 어려움이 있습니다. 이러한 이유로, CPU 집약적인 작업에서는 multiprocessing 모듈을 사용하여 병렬 처리를 구현하는 것이 더 적합할 수 있습니다.
요약하자면, GIL은 Python의 멀티 스레드 환경에서 데이터 일관성(메모리 관리의 일관성)을 보장하지만, 그로 인해 멀티 스레딩의 성능 향상이 제한되며, 특히 CPU 바운드 작업에서 이러한 문제가 두드러집니다. I/O 바운드 작업에서는 GIL의 영향을 덜 받기 때문에 여전히 멀티 스레딩이 유효하게 사용될 수 있습니다.
Python은 멀티 스레드로 동작할 수 있습니다. Python에서 멀티 스레딩은 threading 모듈을 사용하여 구현할 수 있으며, 이를 통해 여러 스레드를 생성하여 동시에 실행할 수 있습니다. 그러나 Python의 GIL(Global Interpreter Lock)로 인해 동일한 프로세스 내에서 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있습니다. 이 때문에 CPU 바운드 작업에서는 멀티 스레딩의 성능 이점이 제한적일 수 있지만, I/O 바운드 작업에서는 여전히 유용할 수 있습니다.
import threading
def worker(num):
print(f'Worker: {num}')
threads = []
for i in range(5):
t = threading.Thread(target=worker, args=(i,))
threads.append(t)
t.start()
위 코드에서 5개의 스레드가 동시에 실행되어 "Worker" 메시지를 출력합니다. 이처럼 Python에서 멀티 스레드를 사용할 수 있지만, GIL로 인해 그 성능은 특정 상황에 따라 제한적일 수 있습니다. CPU 집약적인 작업에서는 멀티프로세싱(multiprocessing 모듈)이 더 적합할 수 있습니다.
asyncio를 Python에서 사용하는 이유는 Python의 언어적 특징과 비동기 프로그래밍의 요구사항을 충족시키기 위해서입니다. Python은 기본적으로 싱글 스레드로 동작하며, CPU 연산보다는 I/O 바운드 작업에서 효율성을 높이기 위해 비동기 처리가 중요합니다. asyncio는 이러한 비동기 작업을 지원하기 위해 설계된 라이브러리로, 동시에 여러 작업을 효율적으로 처리할 수 있게 해줍니다. 이를 통해 네트워크 통신, 파일 입출력, 데이터베이스 접근 등 I/O 바운드 작업에서 성능을 극대화할 수 있습니다.
싱글 스레드 기반의 동시성: Python의 GIL(Global Interpreter Lock) 때문에 멀티스레딩이 한계가 있는데, asyncio는 싱글 스레드 내에서 동시성을 구현하여 이 문제를 회피할 수 있습니다.
I/O 바운드 작업 최적화: 웹 서버, 크롤러, 네트워크 애플리케이션 등에서 많은 I/O 작업이 필요한 경우, asyncio를 사용하면 대기 시간을 최소화하면서 더 많은 작업을 동시에 처리할 수 있습니다.
코루틴을 통한 비동기 코드 작성: asyncio는 await와 async 키워드를 활용해 비동기 코드를 직관적이고 명확하게 작성할 수 있게 해줍니다. 이는 기존의 콜백 기반 비동기 처리보다 코드의 가독성과 유지보수성을 크게 향상시킵니다.
이벤트 루프 관리: asyncio는 이벤트 루프를 사용하여 여러 비동기 작업을 효율적으로 스케줄링하고 관리할 수 있습니다. 이는 여러 작업을 병렬적으로 수행할 때 효율적인 자원 관리와 성능 최적화를 가능하게 합니다.
import asyncio
async def fetch_data():
print("Fetching data...")
await asyncio.sleep(2)
print("Data fetched")
async def main():
await asyncio.gather(fetch_data(), fetch_data())
asyncio.run(main())
위 코드에서 fetch_data() 함수는 asyncio.sleep()을 사용하여 비동기적으로 대기합니다. asyncio.gather()는 여러 비동기 작업을 동시에 실행하며, asyncio.run()은 이벤트 루프를 관리해줍니다. 이와 같이 asyncio를 사용하면 Python에서 비동기 프로그래밍을 효율적으로 구현할 수 있습니다.
Python Official 문서의 asyncio를 보면 version 별 지원가능한 기능에 대해 정리가 되어있습니다. 아래 내용은 해당 링크의 내용을 정리한 것입니다.
asyncio.run() 도입 전후: 이전에는 비동기 함수 실행을 위해 명시적으로 이벤트 루프를 생성하고 관리해야 했습니다. asyncio.run()이 도입되면서 이 과정이 단순화되어, 루프를 자동으로 생성하고 종료하는 편리한 방법을 제공하게 되었습니다.# 이전 방식 (3.6 및 그 이전)
import asyncio
async def main():
print("Hello, asyncio!")
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()
# 3.7 이후
asyncio.run(main())
Task.get_coro() 도입 전후: 이전에는 Task 객체가 실행하는 코루틴을 직접 접근할 수 없었습니다. Python 3.8에서 Task.get_coro() 메서드가 추가되어, 해당 Task가 실행 중인 코루틴을 손쉽게 확인할 수 있게 되었습니다.import asyncio
async def foo():
await asyncio.sleep(1)
task = asyncio.create_task(foo())
print(task.get_coro()) # 3.8 이전에는 불가능했던 기능
asyncio.to_thread() 도입 전후: 이전에는 블로킹 작업을 별도의 스레드에서 실행하려면 concurrent.futures나 loop.run_in_executor를 사용해야 했습니다. asyncio.to_thread()가 도입되면서 더 직관적이고 간결한 코드로 블로킹 작업을 처리할 수 있게 되었습니다.import asyncio
import time
def blocking_io():
time.sleep(2)
print("Blocking IO finished")
async def main():
# 이전 방식
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, blocking_io)
# 3.9 이후 간편한 방식
await asyncio.to_thread(blocking_io)
loop.shutdown_default_executor() 도입 전후: 이전에는 기본 실행자(executor)가 명시적으로 종료되지 않아 리소스 누수가 발생할 가능성이 있었습니다. 이 기능이 도입되면서, 이벤트 루프의 기본 실행자를 안전하게 종료할 수 있게 되어 리소스 관리가 개선되었습니다.import asyncio
async def main():
loop = asyncio.get_running_loop()
# 3.10 이전에는 명시적으로 종료할 필요가 없었으나, 리소스 누수 위험 존재
await loop.shutdown_default_executor()
asyncio.run(main())
TaskGroup 도입 전후: 이전에는 여러 태스크를 관리할 때 개별적으로 태스크를 생성하고 관리해야 했습니다. TaskGroup이 도입되면서 태스크들을 그룹으로 묶어 구조화된 방식으로 관리할 수 있게 되었고, 태스크 간의 오류 처리 및 취소도 간편해졌습니다.import asyncio
async def worker(name):
await asyncio.sleep(1)
print(f"Worker {name} done")
async def main():
# 3.11 이전
task1 = asyncio.create_task(worker('A'))
task2 = asyncio.create_task(worker('B'))
await asyncio.gather(task1, task2)
# 3.11 이후 TaskGroup 사용
async with asyncio.TaskGroup() as tg:
tg.create_task(worker('A'))
tg.create_task(worker('B'))
asyncio.run(main())
eager_task_factory 도입 전후: 이전에는 태스크가 생성되었을 때, 이벤트 루프에 의해 실행 순서가 정해졌습니다. eager_task_factory를 사용하면 태스크가 생성될 때 즉시 실행이 가능해져, 실행 순서를 세밀하게 제어할 수 있게 되었습니다.import asyncio
async def foo():
print("Foo started")
await asyncio.sleep(1)
# 3.12 이후 eager_task_factory 사용
loop = asyncio.get_event_loop()
loop.set_task_factory(lambda loop, coro: asyncio.Task(coro, eager_start=True))
asyncio.run(foo())
이처럼 Python의 asyncio 라이브러리는 버전별로 비동기 작업 관리를 더욱 간편하고 효율적으로 만들기 위해 발전해 왔습니다. 각 기능의 도입은 이전 방식의 불편함이나 한계를 극복하기 위한 개선이라고 볼 수 있습니다.
Reference