본문 바로가기
CS

[Python] 파이썬 동시성 모델

by 왕밤빵도라에몽 2025. 5. 4.
728x90

한 번에 여러 가지 일을 처리하게 하는 방법... -> 동시성 or 병렬 프로그래밍

간단한 용어 정리

  • 동시성: 대기 중인 여러 태스크를 한 번에 하나씩(가능하면 병렬로) 진행해서 모든 태스크를 처리하는 능력
  • 병렬성: 여러 계산을 동시에 실행하는 능력
  • 실행 유닛: 코드를 동시에 실행하는 객체를 일컫는 포괄적 용어.
  • 프로세스 실행 중인 프로그램의 인스턴스이며, 메모리와 CPU 시간 슬라이스 사용. 메모리 분리된 독립 실행 단위. 선점형 멀티태스킹 허용
  • 스레드: 프로세스 안에 있는 하나의 실행 유닛. 프로세스 내에서 동일 메모리 공유
  • 코루틴: 멈췄다가 나중에 다시 실행 재개할 수 있는 함수. 협업형 멀티태스킹 지원.
  • : 선입 선출 방식의 데이터 구조. 안전하게 데이터 교환
  • : 실행 유닛이 작업을 동기화하고 데이터 훼손 방지하는 데에 사용하는 객체
  • 경쟁: 락 없이 여러 스레드 / 코루틴이 제한된 자원에 접근하려고 할 때 발생

파이썬에 적용

  • 동시성: async / await, 코루틴, 이벤트 루프로 구현함 (asyncio)
  • 병렬성: multiprocessing 모듈로 CPU 여러 개를 써서 동시에 실행
  • 실행 유닛: 보통 "코어", multiprocessing은 각 코어에서 병렬 실행
  • 프로세스: multiprocessing.Process - 메모리 분리된 독립 실행 단위
  • 스레드: threading.Thread - 같은 메모리 공유, GIL(Global Interpreter Lock) 영향 받음
  • 코루틴: async def 함수로 정의, await로 실행 시점 제어
  • : queue.Queue (스레드용), multiprocessing.Queue (프로세스용) - 안전하게 데이터 주고받기
  • : threading.Lock, asyncio.Lock - 동시에 접근하지 못하게 막음
  • 경쟁: 락 없이 여러 스레드 / 코루틴이 같은 변수를 동시에 바꾸면 발생

GIL (Global Interperter Lock)

파이썬 인터프리터가 한 번에 하나의 스레드만 실행하게 하는 락

  • 왜 만듦?
    • 파이썬의 메모리 관리(특히 reference counting)가 스레드 안전하지 않기 때문
    • 단순성과 안정성을 위해 만든 설계
  • 문제점
    • threading.Thread로 스레드를 여러 개 만들어도 CPU 연산은 항상 하나의 스레드만 실행됨
    • 그래서 멀티코어 성능 못 씀 -> 병렬 계산에는 부적합
import threading
import time

def count():
    x = 0
    for _ in range(10**7):
        x += 1

start = time.time()
threads = [threading.Thread(target=count) for _ in range(2)]
[t.start() for t in threads]
[t.join() for t in threads]
print(f"time: {time.time() - start:.2f} sec")  # 거의 단일 스레드 속도
  • GIL 때문에 2배 빠르지 X

  • 해결 방안

    • CPU 바운드 작업: multiprocessing, numba, C/C++ 연동
    • I/O 바운드: threading, asyncio

요약

  • 스레드는 가볍지만 GIL 때문에 병렬 CPU 사용이 안 됨
  • 프로세스는 무겁지만 GIL의 영향을 받지 않음
  • CPU 연산에는 multiprocessing, I/O에는 threading/asyncio

3대 동시성 프로그래밍 모델을 이용한 스피너 구현해보기

slow()라는 느린 작업 도중 띄우는 스피너 가정

스레드 기반

import threading, time, sys

def spinner():
    while not done:
        for ch in '|/-\\':
            print(f'\r{ch}', end='', flush=True)
            time.sleep(0.1)

def slow():
    time.sleep(3)  # 무거운 I/O 작업이라 가정

done = False
t = threading.Thread(target=spinner)
t.start()
slow()
done = True
t.join()
print('\rDone!')
  • 간단하고 I/O 작업엔 적합
  • GIL 때문에 병렬 처리 불가

프로세스 기반

from multiprocessing import Process, Value
import time, sys

def spinner(shared_flag):
    while not shared_flag.value:
        for ch in '|/-\\':
            print(f'\r{ch}', end='', flush=True)
            time.sleep(0.1)

def slow():
    time.sleep(3)

if __name__ == '__main__':
    flag = Value('b', False)  # 공유 변수
    p = Process(target=spinner, args=(flag,))
    p.start()
    slow()
    flag.value = True
    p.join()
    print('\rDone!')
  • GIL 우회 가능, CPU 연산도 병렬 가능
  • 자원 더 많이 씀, 구조 복잡함

비동기 코루틴 기반

import asyncio

async def spinner():
    while not done:
        for ch in '|/-\\':
            print(f'\r{ch}', end='', flush=True)
            await asyncio.sleep(0.1)

async def slow():
    await asyncio.sleep(3)

done = False

async def main():
    global done
    spin = asyncio.create_task(spinner())
    await slow()
    done = True
    await spin
    print('\rDone!')

asyncio.run(main())
  • 싱글 스레드로 동시성 가능, 깔끔한 비동기 처리
  • CPU 연산 병렬 불가, await 가능한 작업만 사용 가능

프로세스 풀

여러 개의 작업을 병렬로 처리할 때 일일이 Process 객체를 만들고 관리하는 번거로움을 줄여주는 자동화된 병렬 처리 도구

  • multiprocessing.Process를 직접 여러 개 만드는 대신
  • 프로세스를 미리 만들어 풀(pool)로 관리
  • 거기에 작업 나눠서 던져주면 자동으로 분배해서 병렬 실행
from concurrent.futures import ProcessPoolExecutor
import time

def slow_task(n):
    time.sleep(1)
    return n * n

if __name__ == '__main__':
    with ProcessPoolExecutor(max_workers=4) as executor:
        results = executor.map(slow_task, range(10))

    print(list(results))
  • max_workers=4 최대 4개 프로세스 동시에 사용
  • range(10) 10개의 작업 자동 분배
  • excutor.map()은 병렬 for라고 보면 됨

References

-『 전문가를 위한 파이썬(2판) 』, 루시아누 하말류, 한빛미디어, Part 1 - Chapter 19

728x90