그때그때 CS 정리

파이썬 병렬 스레드 연구

필만이 2024. 6. 20. 15:26

배경 및 결론

  • 이전 포스트에서 ProcessPoolExecutor'가 ThreadPoolExecutor보다 압도적인 성능을 보였다. 그렇다면 ThreadPoolExecutor는 언제 사용하는게 좋은가? 결론적으로 ThreadPoolExecutorProcessPoolExecutor보다 유리한 상황은 주로 I/O 바운드 작업이나 덜 CPU 집약적인 작업을 수행할 때이다.

1. I/O 바운드 작업

  • 작업 특성: 파일 읽기/쓰기, 네트워크 요청, 데이터베이스 통신 등의 I/O 바운드 작업은 실제 CPU 계산보다 데이터를 읽거나 쓰는 데 시간이 더 많이 소요됩니다. 이러한 작업에서는 CPU 사용률이 높지 않기 때문에 프로세스 간의 오버헤드가 불필요할 수 있습니다.
  • 효율성: 스레드는 프로세스에 비해 생성 시간과 자원 사용이 적게 듭니다. 따라서 I/O 바운드 작업을 처리할 때 ThreadPoolExecutor를 사용하면 더 낮은 오버헤드로 빠르게 작업을 수행할 수 있습니다.

2. 자원 공유가 필요할 때

  • 메모리 공유: ThreadPoolExecutor에서 스레드들은 같은 메모리 공간을 공유합니다. 이는 공유되어야 하는 데이터나 상태가 있을 때 유용하며, 프로세스 간 통신보다 데이터 접근과 조작이 간단하고 빠릅니다.
  • 데이터 동기화: 공유된 메모리를 통해 여러 스레드가 데이터를 효율적으로 동기화할 수 있습니다. 반면, 프로세스는 독립된 메모리 공간을 가지므로 데이터를 공유하려면 복잡한 IPC(프로세스 간 통신) 메커니즘이 필요합니다.

3. 경량 작업에 적합

  • 빠른 설정과 해제: 스레드는 프로세스보다 생성과 종료가 빠르며 자원 소모가 적습니다. 작업이 많지만 각 작업의 수행 시간이 짧은 경우, 스레드를 이용한 병렬 처리가 더 효율적일 수 있습니다.

4. 확장성과 간단한 구현

  • 구현의 용이성: Python에서 스레드를 사용하는 것은 프로세스를 사용하는 것보다 코드 상에서 간단하게 구현할 수 있습니다. 특히 복잡한 프로그램에서 스레드를 사용하면 프로세스를 사용할 때보다 구현이 간단해지고 오류 발생 가능성이 줄어듭니다.

실제 예(스크래핑에 스레드작업)

실무에서 ThreadPoolExecutor를 사용하는 대표적인 예는 웹 스크래핑, 대량의 API 요청 처리, 파일 처리 등 다양한 I/O 바운드 작업에 있어서 병렬처리를 통해 성능을 향상시키는 경우입니다.

예제: 웹 스크래핑을 위한 ThreadPoolExecutor 사용

import concurrent.futures
import requests
import time

# 웹 페이지의 내용을 다운로드하는 함수
def fetch_url(url):
    response = requests.get(url)
    return response.text

# 주어진 URL 목록에 대해 웹 스크래핑을 수행하고 결과를 출력하는 함수
def run_web_scraping_tasks(urls):
    print("Starting web scraping...")  # 스크래핑 시작을 알림
    start_time = time.time()  # 스크래핑 시작 시간 기록

    # 결과를 저장할 딕셔너리
    results = {}

    # ThreadPoolExecutor를 사용하여 웹 스크래핑을 병렬로 실행
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
        # 각 URL에 대한 Future 객체를 생성하고 딕셔너리에 저장
        future_to_url = {executor.submit(fetch_url, url): url for url in urls}

        # Future 객체가 완료될 때까지 기다리며, 각각의 결과를 처리
        for future in concurrent.futures.as_completed(future_to_url):
            url = future_to_url[future]  # Future 객체에 해당하는 URL을 가져옴
            try:
                # Future 객체로부터 결과를 얻어옴
                data = future.result()
                # 결과 딕셔너리에 URL과 데이터를 저장
                results[url] = data
                print(f"Fetched {url} successfully")  # 성공적으로 데이터를 가져왔음을 출력
            except Exception as exc:
                # 예외 발생 시 예외 메시지를 출력
                print(f'{url} generated an exception: {exc}')

    end_time = time.time()  # 스크래핑 종료 시간 기록
    print(f"All tasks completed in {end_time - start_time} seconds")  # 전체 실행 시간 출력
    return results  # 결과 딕셔너리 반환

if __name__ == "__main__":
    # 스크래핑할 URL 목록
    urls = [
        "http://example.com",
        "http://example.org",
        "http://example.net"
    ]
    # 스크래핑 작업 실행
    run_web_scraping_tasks(urls)

코드 설명

  1. fetch_url 함수: 주어진 URL에서 웹 페이지의 콘텐츠를 다운로드합니다. requests 라이브러리를 사용하여 HTTP 요청을 수행합니다.
  2. run_web_scraping_tasks 함수: 주어진 URL 리스트에 대해 ThreadPoolExecutor를 사용하여 fetch_url 함수를 병렬로 실행합니다. 각 URL에 대한 요청이 완료되는 대로 결과를 수집하고, 성공적으로 데이터를 가져온 경우와 예외가 발생한 경우를 출력합니다.
  3. 성능 측정: 작업 시작 시간과 종료 시간을 기록하여, 모든 작업의 완료 시간을 계산합니다.

여기서 ThreadPoolExecutor가 더 효과적인 이유

  1. 리소스 사용과 관리
  • 오버헤드: ThreadPoolExecutor는 스레드를 사용하여 작업을 수행하기 때문에 프로세스를 생성하고 관리하는 데 드는 오버헤드가 훨씬 적습니다. 스레드는 프로세스보다 메모리와 시작 시간이 적게 소모됩니다.
  • 메모리 공유: 스레드는 같은 메모리 공간을 공유하기 때문에, 작업 간 메모리 접근이 더 빠르고 효율적입니다. 프로세스는 각각 독립된 메모리 공간을 가지므로, 데이터를 공유하려면 상대적으로 복잡하고 비용이 많이 드는 통신 방법이 필요합니다.
  1. I/O 바운드 작업의 특성
  • CPU 사용률: 웹 스크래핑과 같은 I/O 바운드 작업은 CPU 자원을 많이 사용하지 않고, 대부분 네트워크 요청이나 디스크 I/O에 시간을 소모합니다. 이러한 작업에서는 스레드가 I/O 작업 완료를 기다리는 동안 CPU가 다른 스레드로 전환할 수 있어 효율적입니다.
  • 병렬성: 스레드를 사용하면 여러 I/O 작업을 병렬로 처리할 수 있으며, 각 스레드가 I/O 작업을 기다리는 동안 다른 스레드가 CPU를 사용할 수 있어 자원 활용도가 높습니다.
  1. 실용성과 간편성
  • 구현의 용이성: 스레드를 사용하는 코드는 프로세스를 사용하는 코드보다 일반적으로 더 간단하고 이해하기 쉽습니다. Python의 스레드는 특히 글로벌 인터프리터 락(GIL) 때문에 CPU 바운드 작업에는 적합하지 않지만, I/O 바운드 작업에서는 GIL의 영향을 크게 받지 않습니다.

결론

  • 웹 스크래핑과 같은 I/O 바운드 작업에서는 ThreadPoolExecutorProcessPoolExecutor보다 일반적으로 더 효과적입니다. 리소스 사용이 적고, 구현이 간단하며, I/O 작업의 병렬 처리에 있어서 높은 효율을 제공합니다. 프로세스 기반의 병렬 처리는 독립된 메모리 공간을 요구하고 오버헤드가 더 크기 때문에, 주로 CPU 바운드 작업에 더 적합합니다.

예제2: 공유 카운터 업데이트

import concurrent.futures
import threading

# 공유 리소스
counter = 0
lock = threading.Lock()

def increment_counter():
    global counter
    with lock:  # 락을 사용하여 스레드간 안전하게 접근
        for _ in range(10000):
            counter += 1

def run_shared_counter_tasks():
    workers = 5
    print(f"Starting counter with {workers} workers...")
    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
        # 모든 스레드에서 카운터 증가 작업을 실행
        executor.map(increment_counter, range(workers))
    print(f"Final counter value: {counter}")

if __name__ == "__main__":
    run_shared_counter_tasks()

코드 설명

  1. 메모리 공유: 모든 스레드가 counter 변수를 공유합니다. 이 변수는 모든 스레드에 의해 동시에 접근되고 수정될 수 있으므로, 스레드 안전한 방식으로 접근해야 합니다.
  2. 락 사용: threading.Lock()을 사용하여 counter 변수에 대한 접근을 동기화합니다. 이는 레이스 컨디션을 방지하고 데이터 일관성을 유지
  3. ThreadPoolExecutor 사용: 스레드 풀을 사용하여 정의된 수의 스레드에서 동시에 카운터 증가 함수를 실행합니다. 이를 통해 병렬성을 제공하면서도 공유 메모리에 안전하게 접근할 수 있습니다.

공유 카운터 응용

  1. 리소스 액세스 제한
    웹 서버나 애플리케이션에서 동시에 접근할 수 있는 최대 사용자 수를 제한하기 위해 사용될 수 있습니다. 예를 들어, 특정 서비스나 데이터베이스에 대한 동시 접근 수를 제한하려고 할 때, 공유 카운터를 통해 현재 접근 중인 사용자 수를 추적하고, 이 수치가 특정 임계값을 초과하지 않도록 관리할 수 있습니다.
  2. 트랜잭션 관리
    데이터베이스 관리 시스템에서 트랜잭션의 숫자를 관리하는 데 사용될 수 있습니다. 여러 트랜잭션이 동시에 발생할 때 각 트랜잭션의 진행 상태를 카운트하여 시스템이 오버로드되는 것을 방지합니다.
  3. 작업 분배
    분산 시스템에서 작업을 여러 노드에 균등하게 분배하기 위해 사용됩니다. 공유 카운터를 활용하여 각 노드가 처리하는 작업 수를 모니터링하고, 이를 기반으로 새로운 작업을 할당함으로써 부하를 균형 있게 조절할 수 있습니다.
  4. 멀티스레드 애플리케이션의 성능 모니터링
    복잡한 멀티스레드 애플리케이션에서 각 스레드의 작업 처리 횟수를 카운트하여 애플리케이션의 성능을 모니터링하고 분석합니다. 이 데이터는 시스템의 병목 현상을 식별하고 성능 최적화를 위한 중요한 인사이트를 제공할 수 있습니다.

그외 실무에서 사용하는 예

1. 웹 스크래핑

웹 스크래핑 작업은 일반적으로 많은 수의 HTTP 요청을 병렬로 실행해야 합니다. ThreadPoolExecutor는 여러 웹 페이지의 데이터를 동시에 다운로드하고 처리하는 데 사용되어, 전체 작업의 완료 시간을 대폭 줄일 수 있습니다.

2. 데이터베이스 배치 처리

대용량 데이터를 처리할 때 여러 쿼리를 동시에 실행해야 할 경우가 많습니다. ThreadPoolExecutor를 사용하여 다수의 데이터베이스 쿼리를 병렬로 실행함으로써 응답 시간을 단축하고 시스템의 처리량을 향상시킬 수 있습니다.

3. API 요청 처리

서버가 외부 API로부터 데이터를 가져오는 경우, 여러 API 호출을 동시에 수행하여 네트워크 지연 시간의 영향을 최소화할 수 있습니다. ThreadPoolExecutor는 API 응답을 기다리는 동안 다른 작업을 계속 처리할 수 있어 효율적입니다.

4. 파일 입출력 작업

파일 시스템에서 대량의 파일을 읽고 쓰는 작업을 수행할 때, ThreadPoolExecutor를 이용하여 여러 파일에 대한 입출력 작업을 병렬로 처리할 수 있습니다. 이는 특히 대용량 로그 파일이나 데이터 파일을 처리할 때 유용합니다.

5. 실시간 데이터 처리

실시간으로 수집되는 데이터(예: 센서 데이터, 금융 거래 데이터 등)를 처리하는 시스템에서 ThreadPoolExecutor를 사용하여 데이터를 신속하게 처리하고 분석할 수 있습니다. 이 방법은 데이터의 병목 현상을 방지하고, 시스템의 반응성을 향상시킬 수 있습니다.

결론

  • ThreadPoolExecutor와 ProcessPoolExecutor의 용도의 차이에 대해 알수 있었다.
  • 다음에는 스레드,프로세서가 무슨 구조적 차이가 있길래 이런 차이가 나오는지 알아보기로 한다.