[Python] concurrent.futures: GIL, 동시성, 병렬성 완전 정복
Python concurrent.futures: GIL, 동시성, 병렬성 완전 정복
Python에서 멀티스레딩과 멀티프로세싱을 다룰 때 가장 혼란스러운 개념 중 하나가 바로 GIL, 동시성, 병렬성이다. 특히 concurrent.futures 모듈의 ThreadPoolExecutor와 ProcessPoolExecutor를 언제 사용해야 하는지 판단하기 어려워하는 개발자들이 많다. 이 글에서는 이러한 개념들을 명확히 정리하고, 실무에서 어떻게 적용해야 하는지 알아보겠다.
GIL(Global Interpreter Lock)이란 무엇인가
GIL은 Python 인터프리터의 핵심 메커니즘 중 하나로, 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있도록 제한하는 뮤텍스이다. 이는 Python의 메모리 관리 방식, 특히 레퍼런스 카운팅 시스템의 스레드 안전성을 보장하기 위해 존재한다.
GIL의 존재 이유
Python에서 모든 객체는 레퍼런스 카운트를 가지고 있다. 이 카운트가 0이 되면 해당 객체는 가비지 컬렉션의 대상이 된다. 멀티스레드 환경에서 여러 스레드가 동시에 같은 객체의 레퍼런스 카운트를 변경한다면, 레이스 컨디션이 발생하여 메모리 누수나 프로그램 크래시가 일어날 수 있다.
# 레퍼런스 카운팅 예시
x = [1, 2, 3] # 레퍼런스 카운트 = 1
y = x # 레퍼런스 카운트 = 2
del x # 레퍼런스 카운트 = 1
GIL은 이러한 문제를 해결하기 위해 도입되었으며, Python 인터프리터의 안정성을 보장하는 핵심 역할을 담당한다.
GIL이 해제되는 경우
GIL이 항상 활성화되어 있는 것은 아니다. 다음과 같은 상황에서는 GIL이 해제된다:
- I/O 작업 수행 시 (파일 읽기/쓰기, 네트워크 통신)
- time.sleep()과 같은 블로킹 작업 실행 시
- NumPy, pandas와 같은 C 확장 모듈 사용 시
- 일정한 바이트코드 명령어 실행 후 (약 100개마다 자동 해제)
이러한 특성 때문에 Python에서도 멀티스레딩이 완전히 무의미하지는 않다.
동시성(Concurrency)의 개념
동시성은 여러 작업이 동시에 실행되는 것처럼 보이지만, 실제로는 빠르게 번갈아가며 실행되는 것을 의미한다. 이는 논리적인 동시성이라고 할 수 있으며, 단일 코어 시스템에서도 구현 가능하다.
동시성의 특징
동시성 모델에서는 시간 분할(time slicing) 기법을 사용하여 여러 작업을 빠르게 전환하면서 실행한다. 각 작업은 짧은 시간 동안 실행되고, 다른 작업으로 컨텍스트가 전환된다. 이러한 전환이 매우 빠르게 일어나기 때문에 사용자는 마치 여러 작업이 동시에 실행되는 것처럼 느끼게 된다.
import threading
import time
def task_a():
for i in range(5):
print(f"Task A - {i}")
time.sleep(0.1)
def task_b():
for i in range(5):
print(f"Task B - {i}")
time.sleep(0.1)
# 동시성 실행 예시
thread1 = threading.Thread(target=task_a)
thread2 = threading.Thread(target=task_b)
thread1.start()
thread2.start()
위 코드를 실행하면 Task A와 Task B가 번갈아가며 출력되는 것을 확인할 수 있다. 이것이 바로 동시성의 실제 모습이다.
동시성이 효과적인 경우
동시성은 주로 I/O 바운드 작업에서 큰 효과를 발휘한다. 파일을 읽거나 네트워크 요청을 보낼 때, 프로그램은 응답을 기다리는 동안 다른 작업을 수행할 수 있다. 이때 GIL이 해제되므로 멀티스레딩의 이점을 충분히 활용할 수 있다.
병렬성(Parallelism)의 개념
병렬성은 여러 작업이 물리적으로 동시에 실행되는 것을 의미한다. 이는 실제로 여러 개의 CPU 코어나 프로세서를 사용하여 각각 독립적인 작업을 수행하는 것이다.
병렬성의 특징
병렬성을 구현하기 위해서는 멀티코어 하드웨어가 필수이다. 각 코어는 완전히 독립적으로 작업을 수행하며, 서로 간섭하지 않는다. Python에서는 multiprocessing 모듈을 통해 병렬성을 구현할 수 있으며, 각 프로세스는 독립적인 메모리 공간과 GIL을 가진다.
import multiprocessing
import time
def cpu_intensive_task(n):
result = 0
for i in range(n):
result += i ** 2
return result
def compare_execution_times():
# 순차 실행
start_time = time.time()
results = []
for i in range(4):
result = cpu_intensive_task(1000000)
results.append(result)
sequential_time = time.time() - start_time
# 병렬 실행
start_time = time.time()
with multiprocessing.Pool(processes=4) as pool:
results = pool.map(cpu_intensive_task, [1000000] * 4)
parallel_time = time.time() - start_time
print(f"순차 실행 시간: {sequential_time:.2f}초")
print(f"병렬 실행 시간: {parallel_time:.2f}초")
print(f"성능 향상: {sequential_time/parallel_time:.2f}배")
병렬성이 효과적인 경우
병렬성은 CPU 집약적인 작업에서 진가를 발휘한다. 수학적 계산, 이미지 처리, 데이터 분석과 같은 작업들은 병렬 처리를 통해 성능을 크게 향상시킬 수 있다.
concurrent.futures를 활용한 실제 구현
concurrent.futures 모듈은 ThreadPoolExecutor와 ProcessPoolExecutor를 제공하여 동시성과 병렬성을 쉽게 구현할 수 있게 해준다.
ThreadPoolExecutor - 동시성 구현
ThreadPoolExecutor는 스레드 풀을 사용하여 동시성을 제공한다. GIL의 제약으로 인해 실제 병렬 처리는 불가능하지만, I/O 바운드 작업에서는 뛰어난 성능을 보여준다.
import concurrent.futures
import requests
import time
def fetch_url(url):
response = requests.get(url)
return f"{url}: {response.status_code}"
urls = [
'https://httpbin.org/delay/1',
'https://httpbin.org/delay/1',
'https://httpbin.org/delay/1',
'https://httpbin.org/delay/1'
]
# ThreadPoolExecutor를 사용한 동시성 구현
start_time = time.time()
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(fetch_url, urls))
execution_time = time.time() - start_time
print(f"동시성 실행 시간: {execution_time:.2f}초")
위 예제에서 4개의 HTTP 요청이 동시에 처리되어 약 1초 만에 완료된다. 만약 순차적으로 실행했다면 4초가 걸렸을 것이다.
ProcessPoolExecutor - 병렬성 구현
ProcessPoolExecutor는 별도의 프로세스를 생성하여 병렬성을 제공한다. 각 프로세스는 독립적인 GIL을 가지므로 진정한 병렬 처리가 가능하다.
import concurrent.futures
import time
def cpu_bound_work(n):
total = 0
for i in range(n):
total += i ** 2
return total
numbers = [1000000, 1000000, 1000000, 1000000]
# ProcessPoolExecutor를 사용한 병렬성 구현
start_time = time.time()
with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:
results = list(executor.map(cpu_bound_work, numbers))
execution_time = time.time() - start_time
print(f"병렬성 실행 시간: {execution_time:.2f}초")
멀티코어 환경에서 이 코드는 순차 실행 대비 현저한 성능 향상을 보여준다.
언제 무엇을 사용할 것인가
올바른 선택을 위해서는 작업의 특성을 정확히 파악하는 것이 중요하다.
ThreadPoolExecutor를 선택해야 하는 경우
- 파일 입출력 작업
- 네트워크 통신 (HTTP 요청, 데이터베이스 쿼리)
- 웹 스크래핑
- API 호출이 많은 작업
- 대기 시간이 길지만 CPU 사용량이 적은 작업
ProcessPoolExecutor를 선택해야 하는 경우
- 수학적 계산이 많은 작업
- 이미지, 비디오 처리
- 데이터 분석 및 머신러닝
- 암호화/복호화 작업
- CPU 집약적인 알고리즘 실행
성능 최적화를 위한 고려사항
워커 수를 결정할 때는 다음 원칙을 따르는 것이 좋다:
- ThreadPoolExecutor: I/O 대기 시간에 따라 조절. 일반적으로 CPU 코어 수의 2-4배
- ProcessPoolExecutor: CPU 코어 수와 동일하거나 약간 적게 설정
import os
# CPU 코어 수 확인
cpu_count = os.cpu_count()
print(f"사용 가능한 CPU 코어 수: {cpu_count}")
# 권장 워커 수
thread_workers = cpu_count * 2 # I/O 바운드 작업용
process_workers = cpu_count # CPU 바운드 작업용
실무 적용 사례
실제 프로젝트에서 이러한 개념들을 어떻게 적용할 수 있는지 살펴보자.
웹 크롤링 프로젝트
import concurrent.futures
import requests
from bs4 import BeautifulSoup
def crawl_page(url):
try:
response = requests.get(url, timeout=10)
soup = BeautifulSoup(response.text, 'html.parser')
title = soup.find('title').text.strip()
return {'url': url, 'title': title, 'status': 'success'}
except Exception as e:
return {'url': url, 'error': str(e), 'status': 'failed'}
urls = ['https://example.com'] * 100
# I/O 바운드 작업이므로 ThreadPoolExecutor 사용
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor:
results = list(executor.map(crawl_page, urls))
success_count = sum(1 for r in results if r['status'] == 'success')
print(f"성공: {success_count}/{len(urls)}")
데이터 처리 프로젝트
import concurrent.futures
import pandas as pd
import numpy as np
def process_data_chunk(data_chunk):
# CPU 집약적인 데이터 처리 작업
result = data_chunk.apply(lambda x: x**2 + np.sin(x) + np.log(x+1))
return result.sum()
# 큰 데이터를 청크로 분할
large_data = pd.Series(range(1000000))
chunk_size = 100000
chunks = [large_data[i:i+chunk_size] for i in range(0, len(large_data), chunk_size)]
# CPU 바운드 작업이므로 ProcessPoolExecutor 사용
with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:
results = list(executor.map(process_data_chunk, chunks))
total_result = sum(results)
print(f"처리 결과: {total_result}")
주의사항과 최적화 팁
메모리 사용량 고려
ProcessPoolExecutor는 각 프로세스가 독립적인 메모리 공간을 가지므로 메모리 사용량이 크게 증가할 수 있다. 대용량 데이터를 처리할 때는 특히 주의해야 한다.
오버헤드 고려
작은 작업들을 병렬 처리할 때는 프로세스 생성과 통신 오버헤드가 실제 작업 시간보다 클 수 있다. 이런 경우에는 순차 처리가 더 효율적일 수 있다.
디버깅의 어려움
멀티프로세싱 환경에서는 디버깅이 어려워진다. 로깅을 적극 활용하고, 작은 단위로 테스트하는 것이 중요하다.
결론
Python에서 concurrent.futures를 효과적으로 사용하기 위해서는 GIL, 동시성, 병렬성의 개념을 정확히 이해하는 것이 필수이다. ThreadPoolExecutor는 I/O 바운드 작업에서 동시성을 제공하고, ProcessPoolExecutor는 CPU 바운드 작업에서 병렬성을 제공한다는 핵심 원칙을 기억하자.
작업의 특성을 정확히 파악하고 적절한 도구를 선택한다면, Python에서도 멀티코어 시스템의 성능을 최대한 활용할 수 있다. 성능 최적화는 항상 측정과 분석을 바탕으로 이루어져야 하며, 맹목적인 병렬화보다는 병목 지점을 정확히 파악하여 해결하는 것이 중요하다.