Python의 병렬 처리: Thread vs Multiprocessing 완벽 비교

Python

Python으로 프로그래밍할 때 성능 향상을 위해 병렬 처리 기법은 필수적입니다. 특히 데이터 처리나 계산 집약적인 작업을 수행할 때, 병렬 처리를 통해 상당한 성능 개선을 얻을 수 있습니다. Python에서는 주로 Threading과 Multiprocessing 두 가지 방식으로 병렬 처리를 구현합니다. 이 글에서는 이 두 방식의 차이점과 각각의 성능을 비교해 보겠습니다.

병렬 처리의 필요성

컴퓨터 하드웨어의 발전으로 대부분의 컴퓨터는 여러 개의 코어를 가진 CPU를 탑재하고 있습니다. 이러한 다중 코어를 효율적으로 활용하기 위해 병렬 처리 프로그래밍이 중요해졌습니다. 병렬 처리를 통해 동시에 여러 작업을 실행하면 전체 실행 시간을 크게 단축할 수 있습니다.

Python의 GIL(Global Interpreter Lock)

Python의 병렬 처리를 이해하기 위해서는 GIL(Global Interpreter Lock)에 대한 이해가 필요합니다. GIL은 Python 인터프리터가 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있도록 하는 메커니즘입니다. 이는 다중 스레드 환경에서도 실제로는 한 번에 하나의 스레드만 실행된다는 의미입니다.

GIL은 메모리 관리의 안전성을 보장하기 위해 도입되었지만, CPU 바운드 작업(계산 위주의 작업)에서는 다중 스레드의 이점을 제한합니다. 반면, I/O 바운드 작업(파일 읽기/쓰기, 네트워크 통신 등)에서는 스레드가 I/O를 기다리는 동안 GIL이 해제되므로 여전히 성능 이점을 제공합니다.

실험 설계

다음 네 가지 방식으로 동일한 계산 작업을 실행하여 성능을 비교해 보겠습니다:

  1. 단일 스레드 (순차 실행)
  2. 다중 스레드 (threading 모듈)
  3. 다중 프로세스 (multiprocessing.Process)
  4. 프로세스 풀 (multiprocessing.Pool)

각 방식으로 400만 번의 덧셈 연산을 수행하는 함수를 4번 실행하고, 총 실행 시간을 측정합니다.

단일 스레드 (순차 실행)

먼저 가장 기본적인 방식인 단일 스레드로 작업을 순차적으로 실행해 보겠습니다.

# none.py
import time

def heavy_work(name):
    result = 0
    for i in range(4000000):
        result += i
    print('%s done' % name)

start = time.time()

for i in range(4):
    heavy_work(i)

end = time.time()

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

실행 결과:

0 done
1 done
2 done
3 done
수행시간: 0.600464 초

이 방식에서는 하나의 작업이 완료된 후에 다음 작업이 시작됩니다.

다중 스레드 (threading 모듈)

이번에는 threading 모듈을 사용하여 다중 스레드로 실행해 보겠습니다.

# thread.py
import time

def heavy_work(name):
    result = 0
    for i in range(4000000):
        result += i
    print('%s done' % name)

if __name__ == '__main__':
    import threading

    start = time.time()
    threads = []
    for i in range(4):
        t = threading.Thread(target=heavy_work, args=(i, ))
        t.start()
        threads.append(t)

    for t in threads:
        t.join()  # 스레드가 종료될 때까지 대기

    end = time.time()

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

실행 결과:

0 done
1 done
2 done
3 done
수행시간: 0.600464 초

놀랍게도 다중 스레드 방식의 실행 시간이 단일 스레드와 거의 동일합니다. 이는 Python의 GIL 때문입니다. 계산 작업은 CPU 바운드 작업이므로 GIL로 인해 실제로는 한 번에 하나의 스레드만 실행되어 병렬 처리의 이점을 얻지 못했습니다.

다중 프로세스 (multiprocessing.Process)

이번에는 multiprocessing 모듈의 Process 클래스를 사용해 보겠습니다.

# multiprocess.py
import time

def heavy_work(name):
    result = 0
    for i in range(4000000):
        result += i
    print('%s done' % name)

if __name__ == '__main__':
    import multiprocessing

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

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

    end = time.time()

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

실행 결과:

0 done
1 done
2 done
3 done
수행시간: 0.257215 초

다중 프로세스 방식은 단일 스레드나 다중 스레드 방식보다 훨씬 빠릅니다. 각 프로세스는 자체 Python 인터프리터를 가지므로 GIL의 제약에서 벗어나 실제로 병렬 처리가 가능합니다.

프로세스 풀 (multiprocessing.Pool)

마지막으로 multiprocessing 모듈의 Pool 클래스를 사용해 보겠습니다. Pool은 작업을 미리 정의된 수의 프로세스로 분배하여 실행합니다.

# multipool.py
import time

def heavy_work(name):
    result = 0
    for i in range(4000000):
        result += i
    print('%s done' % name)

if __name__ == '__main__':
    import multiprocessing

    start = time.time()
    pool = multiprocessing.Pool(processes=4)
    pool.map(heavy_work, range(4))
    pool.close()
    pool.join()

    end = time.time()

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

실행 결과:

2 done
0 done
1 done
3 done
수행시간: 0.268522 초

프로세스 풀 방식의 성능은 다중 프로세스 방식과 비슷합니다. 차이점은 Pool이 작업을 좀 더 효율적으로 관리하고 작업 분배를 최적화한다는 점입니다. 출력 순서가 입력 순서와 다른 이유는 작업이 병렬로 실행되어 완료 시간이 다르기 때문입니다.

성능 비교 및 분석

방식실행 시간(초)상대 성능(기준: 단일 스레드)
단일 스레드0.6004641x
다중 스레드0.6004641x
다중 프로세스0.2572152.33x
프로세스 풀0.2685222.24x

이 결과에서 우리는 다음과 같은 통찰을 얻을 수 있습니다:

  1. GIL의 영향: CPU 바운드 작업에서는 다중 스레드가 단일 스레드와 성능 차이가 없다. 이는 GIL로 인해 실제로는 병렬 처리가 되지 않기 때문이다.
  2. 다중 프로세스의 효율성: 다중 프로세스 방식은 단일 스레드 대비 약 2.3배 빠르다. 이는 4개의 코어를 사용했을 때 이론적인 최대 성능 향상인 4배에는 미치지 못하지만, 상당한 개선이다. 프로세스 생성과 관리에 따른 오버헤드가 있기 때문에 이론적 최대치에 도달하지 못한 것으로 보인다.
  3. Process vs Pool: Process와 Pool의 성능은 비슷하지만, 대량의 작업을 처리할 때는 Pool이 더 효율적일 수 있다. Pool은 작업자 프로세스를 재사용하므로 많은 작업을 처리할 때 프로세스 생성 오버헤드를 줄일 수 있다.

각 방식의 적절한 사용 시나리오

다중 스레드가 유리한 경우:

  • I/O 바운드 작업 (파일 읽기/쓰기, 네트워크 통신 등)
  • 공유 메모리가 필요한 경우
  • 스레드 간 통신이 빈번한 경우
  • 가벼운 병렬 처리가 필요한 경우

다중 프로세스가 유리한 경우:

  • CPU 바운드 작업 (계산 위주의 작업)
  • 독립적인 작업을 병렬로 처리해야 하는 경우
  • 안정성이 중요한 경우 (한 프로세스의 충돌이 다른 프로세스에 영향을 주지 않음)

Pool이 유리한 경우:

  • 동일한 함수를 여러 데이터에 대해 반복 실행하는 경우
  • 작업 수가 많아 프로세스 생성 오버헤드를 줄이고 싶은 경우
  • 작업 결과를 수집하여 처리해야 하는 경우

결론

Python에서 병렬 처리를 구현할 때는 작업의 특성에 맞는 방식을 선택하는 것이 중요합니다. CPU 바운드 작업에서는 multiprocessing 모듈을 사용하는 것이 효율적이며, I/O 바운드 작업에서는 threading 모듈로도 충분한 성능 향상을 얻을 수 있습니다.

또한, asyncio와 같은 비동기 프로그래밍 방식도 고려해 볼 만합니다. 특히 I/O 바운드 작업이 많은 경우 asyncio는 threading보다 더 효율적일 수 있습니다.

병렬 처리는 성능 향상의 강력한 도구이지만, 항상 코드 복잡성과 디버깅의 어려움이라는 비용이 따릅니다. 따라서, 실제 요구사항과 시스템 환경을 고려하여 적절한 방식을 선택하는 것이 중요합니다.


참고 자료:

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤