본문 바로가기

Python

비동기 vs 멀티 스레드 vs 멀티 프로세스 (feat. Python)

Intro

Python으로 팀 프로젝트를 하던 도중 병렬 처리가 필요한 상황이 생겼다. 그 과정에서 Python 병렬 처리 방식이 비동기, 멀티 스레드, 멀티 프로세스 3가지로 나뉘고 어떤 상황에서 어떤 방법을 사용해야 할지 헷갈렸다. 또한, 현재 업무에서 멀티 프로세스가 쓰인다고 하여 공부하는 과정에서 멀티 프로세스도 종류가 나뉜다는 것을 알게 되었고 이번 기회에 정리해보고자 한다.

 

하지만 멀티쓰레드, 프로세싱의 차이를 이해하기 위해 CPU 작업, I/O 작업에 대해 이해해야 한다.

 

1. CPU 작업 vs I/O 작업 

CPU 바운드 작업은 주로 CPU 처리 능력에 크게 의존하는 작업으로, 대기 시간이 거의 없고 CPU가 끊임없이 연산을 수행해야 한다. 따라서 처리 속도는 CPU의 성능에 의해 좌우된다. 이러한 작업은 멀티코어 CPU의 장점을 활용해 멀티스레딩이나 병렬 처리를 통해 여러 작업을 동시에 수행함으로써 성능을 극대화할 수 있다.

 

CPU 바운드 작업 예시

  • 수학 계산 : 행렬 연산, 암호화, 해싱
  • 비디오/이미지 처리 : 인코딩/디코딩
  • 데이터 분석 : 머신러닝 모델 훈련

 

2. I/O 작업이란?

I/O 작업은 네트워크나 데이터베이스 같은 입출력 장치에서 대기 시간이 발생하는 작업을 말한다. CPU 연산이 아닌 네트워크 요청, 파일 읽기·쓰기 등 I/O 장치의 응답을 기다리는 과정이 중심이 된다.

 

I/O 작업 예시

  • 네트워크 요청 : API 호출, 파일 다운로드
  • 데이터베이스 : 쿼리 실행 후 결과를 기다리는 작업
  • 파일 I/O : 파일 읽기/쓰기 작업

 

GIL

C, 자바에서는 멀티 스레드를 활용해 병렬 처리를 하지만 Python에서는 멀티 스레드는 잘 사용되지 않는다는 이야기를 들어본 적이 있을 것이다. 그 이유는 파이썬 인터프리터에서 사용되는 GIL 때문이다. GIL은 파이썬의 한 번에 하나의 명령만 실행할 수 있도록 제한하는 잠금 메커니즘으로 인터프리터의 안정성을 보장하기 위해 도입되었다. 파이썬은 메모리 관리를 자동으로 처리해주는 가비지 컬렉션 기능을 제공하는데, 이 기능은 여러 스레드에서 동시에 실행되면 충돌이나 오류가 발생할 수 있기 때문에 GIL은 이를 방지한다. 그러나 이러한 특성 때문에 CPU 바운드 작업처럼 연산 위주의 작업에서는 GIL이 병목 현상을 일으켜 성능에 부정적인 영향을 줄 수 있다.

 

 

비동기 

단일 쓰레드에서 여러 작업을 동시성 있게 처리하는 방식으로 CPU 자원을 소모하지 않고 외부 자원 응답을 기다리는 I/O 바운드 작업을 처리할 때, 적합하다. 작업의 순서와 대기 시간을 효율적으로 관리하는 방식이다.

 

async / await

비동기 프로그래밍을 위한 키워드로써, async로 선언된 함수는 코루틴)으로 동작하고 await를 사용하면 오래 걸리는 I/O 작업을 기다리는 동안 다른 작업을 처리하여 효율성을 높힐 수 있다.

import asyncio

async def fetch_data():
    await asyncio.sleep(1)  # 네트워크 대기 시뮬레이션
    return "data"

async def main():
    result = await fetch_data()
    print(result)

asyncio.run(main())

 

멀티쓰레드

멀티쓰레드는 하나의 프로세스 내에서 여러 쓰레드가 동시에 실행되는 방식으로 Python에서는 I/O 바운드 작업에 효과적이다. 왜냐하면 Python은 GIL 때문에 CPU 바운드 작업은 여러 쓰레드가 실행되지 못하기 때문이다. I/O 작업에서 비동기 처리가 일반적으로 더 적합하지만 멀티스레드는 비동기보다 동시성이 필요할 때 유리하다. 

 

Threading

멀티쓰레드를 구현할 때 사용하는 대표 모듈로써, 비동기와 달리 쓰레드가 실제로 동시에 실행된다.

import threading
import time

def download(name):
    time.sleep(1)  # 블로킹 I/O
    print(f"{name} 완료")

threads = []
for i in range(3):
    t = threading.Thread(target=download, args=(f"작업{i}",))
    t.start()
    threads.append(t)

for t in threads:
    t.join()

 

멀티 프로세스

멀티 프로세스는 여러 개의 독립적인 프로세스를 실행하여 병렬 처리하는 방식으로, 각 프로세스가 독립된 메모리 공간을 가지기에 GIL 제약을 받지 않는다. 그래서 CPU 바운드 작업에 적합하지만 프로세스간 통신(IPC)가 필요하고 자원 공유가 어렵다

 

 

1. Pool

동일한 작업을 반복해서 수행할 때 사용한다. 같은 함수를 반복 호출하여, 작업을 자동으로 pool 에 분배하여 병렬처리 할 수 있고 코드가 간결하다. 

import time
from multiprocessing import Pool

def heavy_work(name):
    result = 0
    for i in range(20000000):
        result += i

if __name__ == '__main__':
    start = time.time()
    
    with Pool(processes=4) as pool:
        pool.map(heavy_work, range(4))

    end = time.time()
    print("수행시간: %f 초" % (end - start))

 

2. Process

서로 다른 함수, 작업을 병렬 처리하고 싶을 때 사용한다. 개발자가 프로세스를 수동으로 생성하여 각 프로세스에 서로 다른 작업을 지정 및 제어할 수 있다. 하나의 프로세스에 하나의 함수를 할당하여 실행하는 방식이다.

import time
from multiprocessing import Process

def heavy_work(name):
    result = 0
    for i in range(20000000):
        result += i

if __name__ == '__main__':
    start = time.time()
    procs = []
    for i in range(4):
        p = Process(target=heavy_work, args=(i, ))
        p.start()
        procs.append(p)

    for p in procs:
        p.join()  # 프로세스가 모두 종료될 때까지 대기

    end = time.time()
    print("수행시간: %f 초" % (end - start))

'Python' 카테고리의 다른 글

[Anaconda Prompt] 지정된 경로를 찾을 수 없습니다  (0) 2024.11.17