언어·프레임워크/Python

[Python] 병행성(Concurrency)과 병렬성(Parallelism), 그리고 GIL에 대한 오해

DandyNow 2025. 6. 11. 14:26
728x90
반응형

파이썬 병행성(Concurrency)과 병렬성(Parallelism), 그리고 GIL에 대한 오해

파이썬 개발을 하다 보면 '병행성'과 '병렬성'이라는 용어를 자주 접하게 된다. 특히 웹 스크래핑이나 네트워크 통신처럼 I/O 작업이 많은 경우, 이 두 가지 개념을 이해하는 것이 중요하다. 파이썬의 GIL(Global Interpreter Lock) 때문에 혼란을 겪는 경우도 많지만, 이번 글에서는 간단한 예제 코드를 통해 이 개념들을 명확히 이해하고자 한다.

1. 병행성과 병렬성의 차이점

병행성(Concurrency)과 병렬성(Parallelism)은 비슷해 보이지만 중요한 차이가 있다.

  • 병행성: 여러 작업을 동시에 "진행하는 것처럼 보이게" 하는 개념이다. 단일 코어에서도 여러 작업을 번갈아 가며 처리하여 동시성을 확보한다. 마치 한 사람이 여러 개의 작업을 왔다 갔다 하며 처리하는 것과 같다. asyncio가 대표적인 예이다.
  • 병렬성: 여러 작업을 실제로 "동시에 처리"하는 개념이다. 멀티 코어 CPU 환경에서 각각의 코어가 다른 작업을 동시에 수행한다. 여러 사람이 각각의 작업을 동시에 처리하는 것과 같다. ThreadPoolExecutor를 통한 멀티 스레딩이나 multiprocessing을 통한 멀티 프로세싱이 여기에 해당한다.

2. 파이썬 I/O 바운드 작업에서의 병행성 및 병렬성 예제

아래 코드는 asyncioThreadPoolExecutor를 함께 사용하여 웹 페이지를 비동기적으로 가져오는 예제이다.

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이 잠시 해제되어 다른 스레드가 실행될 기회를 얻는다. 이것이 바로 파이썬에서 asyncioThreadPoolExecutor를 함께 사용할 때 I/O 바운드 작업에 대해 병렬성을 얻을 수 있는 이유이다.

728x90
반응형