[Python] 병행성(Concurrency)과 병렬성(Parallelism), 그리고 GIL에 대한 오해
파이썬 병행성(Concurrency)과 병렬성(Parallelism), 그리고 GIL에 대한 오해
파이썬 개발을 하다 보면 '병행성'과 '병렬성'이라는 용어를 자주 접하게 된다. 특히 웹 스크래핑이나 네트워크 통신처럼 I/O 작업이 많은 경우, 이 두 가지 개념을 이해하는 것이 중요하다. 파이썬의 GIL(Global Interpreter Lock) 때문에 혼란을 겪는 경우도 많지만, 이번 글에서는 간단한 예제 코드를 통해 이 개념들을 명확히 이해하고자 한다.
1. 병행성과 병렬성의 차이점
병행성(Concurrency)과 병렬성(Parallelism)은 비슷해 보이지만 중요한 차이가 있다.
- 병행성: 여러 작업을 동시에 "진행하는 것처럼 보이게" 하는 개념이다. 단일 코어에서도 여러 작업을 번갈아 가며 처리하여 동시성을 확보한다. 마치 한 사람이 여러 개의 작업을 왔다 갔다 하며 처리하는 것과 같다.
asyncio
가 대표적인 예이다. - 병렬성: 여러 작업을 실제로 "동시에 처리"하는 개념이다. 멀티 코어 CPU 환경에서 각각의 코어가 다른 작업을 동시에 수행한다. 여러 사람이 각각의 작업을 동시에 처리하는 것과 같다.
ThreadPoolExecutor
를 통한 멀티 스레딩이나multiprocessing
을 통한 멀티 프로세싱이 여기에 해당한다.
2. 파이썬 I/O 바운드 작업에서의 병행성 및 병렬성 예제
아래 코드는 asyncio
와 ThreadPoolExecutor
를 함께 사용하여 웹 페이지를 비동기적으로 가져오는 예제이다.
import asyncio
import timeit
from urllib.request import urlopen
from concurrent.futures import ThreadPoolExecutor
import threading
# urlopen 함수를 래핑하여 실제 실행 스레드 확인
def fetch_url(url):
current_thread = threading.current_thread().getName()
print(f"[{current_thread}] Fetching: {url}")
response = urlopen(url)
return response.read()[0:5]
async def main():
urls = [
'https://www.google.com',
'https://www.apple.com',
'https://www.naver.com',
]
# 스레드 풀 생성 (병렬성)
with ThreadPoolExecutor(max_workers=3) as executor:
loop = asyncio.get_event_loop()
futures = [
loop.run_in_executor(executor, fetch_url, url) # I/O 작업을 스레드 풀에 위임
for url in urls
]
# 비동기적으로 모든 작업 완료 대기 (병행성)
results = await asyncio.gather(*futures)
print(f"Results: {results}")
if __name__ == "__main__":
start_time = timeit.default_timer()
asyncio.run(main()) # Python 3.7+에서 asyncio.run 사용
duration = timeit.default_timer() - start_time
print(f"Total time: {duration:.4f} seconds")
위 코드의 핵심적인 동작은 다음과 같다.
fetch_url_blocking(url)
: 이 함수는 네트워크 통신(I/O 작업)을 수행하는 블로킹 함수이다. 이 함수 내부에서threading.current_thread().getName()
을 통해 실제로urlopen
이 실행되는 스레드의 이름을 출력한다.ThreadPoolExecutor
: 웹 페이지 요청과 같은 I/O 작업을 처리할 스레드 풀을 생성한다.max_workers=3
은 최대 3개의 작업을 동시에 처리할 수 있는 스레드를 만든다는 의미이다. 이는 병렬성을 위한 부분이다.asyncio.get_event_loop().run_in_executor()
:asyncio
의 이벤트 루프는fetch_url_blocking
과 같은 블로킹 I/O 작업을ThreadPoolExecutor
에 위임한다. 이렇게 함으로써 메인 스레드는 블로킹 작업이 완료되기를 기다리지 않고 다른 작업을 처리할 수 있다. 이는 병행성을 위한 핵심적인 기능이다.asyncio.gather(*futures)
: 여러 I/O 작업(퓨처 객체)들이 비동기적으로 완료될 때까지 기다린다.
3. 실행 결과와 GIL에 대한 오해 해소
위 코드를 실행하면 다음과 비슷한 출력을 볼 수 있다.
[ThreadPoolExecutor-0_0] Fetching: https://www.google.com
[ThreadPoolExecutor-0_1] Fetching: https://www.apple.com
[ThreadPoolExecutor-0_2] Fetching: https://www.naver.com
Results: [b'<title', b'<title', b'<title']
Total time: 0.XXX seconds
출력 결과에서 [ThreadPoolExecutor-0_0]
, [ThreadPoolExecutor-0_1]
등 여러 스레드 이름이 동시에 나타나는 것을 볼 수 있다. 이는 fetch_url_blocking
함수가 ThreadPoolExecutor
에 의해 생성된 서로 다른 워커 스레드에서 실제로 동시에 실행되고 있음을 의미한다. 즉, 병렬적으로 웹 페이지를 가져오고 있는 것이다.
처음에 혼란스러울 수 있는 부분은 asyncio
코드가 실행되는 메인 스레드에서 print
문을 작성했을 때, MainThread
로만 찍히는 경우이다. 이는 I/O 작업이 워커 스레드에서 완료된 후, 그 결과가 다시 asyncio
의 메인 이벤트 루프(주로 MainThread
)로 전달되어 await
이후의 파이썬 코드가 실행되기 때문이다. 실제 I/O 대기 및 처리 자체는 워커 스레드에서 병렬로 이루어진다.
import asyncio
import timeit
from urllib.request import urlopen
from concurrent.futures import ThreadPoolExecutor
import threading
async def fetch_url(url, executor):
# 이 print문은 asyncio 이벤트 루프 스레드에서 실행된다.
print(f"[Asyncio Thread: {threading.current_thread().getName()}] Starting fetch for {url}")
# urlopen은 블로킹 I/O 작업이므로, ThreadPoolExecutor의 워커 스레드에서 실행하도록 위임한다.
response = await asyncio.get_event_loop().run_in_executor(executor, urlopen, url)
# urlopen 작업 완료 후, 다시 asyncio 이벤트 루프 스레드에서 실행된다.
print(f"[Asyncio Thread: {threading.current_thread().getName()}] Done fetch for {url}")
return response.read()[0:5]
async def main():
urls = [
'https://www.google.com',
'https://www.apple.com',
'https://www.naver.com',
]
# 스레드 풀 생성 (병렬성)
# max_workers를 통해 동시에 실행될 수 있는 스레드 수를 지정한다.
with ThreadPoolExecutor(max_workers=3) as executor:
futures = [
fetch_url(url, executor) # 각 URL에 대해 비동기 fetch 작업 생성
for url in urls
]
# 비동기적으로 모든 작업 완료를 기다린다. (병행성)
results = await asyncio.gather(*futures)
print(f"Results: {results}")
if __name__ == "__main__":
start_time = timeit.default_timer()
asyncio.run(main()) # Python 3.7+에서 asyncio.run 사용
duration = timeit.default_timer() - start_time
print(f"Total time: {duration:.4f} seconds")
이러한 방식은 파이썬의 GIL(Global Interpreter Lock)의 제약을 우회하여 효율적인 I/O 처리를 가능하게 한다. GIL은 한 번에 하나의 스레드만 파이썬 바이트코드를 실행할 수 있도록 제한하지만, 네트워크 통신과 같은 I/O 작업 중에는 GIL이 잠시 해제되어 다른 스레드가 실행될 기회를 얻는다. 이것이 바로 파이썬에서 asyncio
와 ThreadPoolExecutor
를 함께 사용할 때 I/O 바운드 작업에 대해 병렬성을 얻을 수 있는 이유이다.