당신이 음식을 주문하는 상황을 생각해봅시다. 동기적 방식이라면 주문하고 음식이 나올 때까지 그 자리에 서서 기다려야 합니다. 하지만 비동기적 방식에서는 주문 후 진동벨을 받고 테이블에 앉아 다른 일(대화하기, 메뉴 둘러보기)을 할 수 있습니다. 음식이 준비되면 벨이 울려 알려주죠.
Python에서도 같은 개념이 적용됩니다. 동기 코드는 한 작업이 완료될 때까지 기다린 후 다음 작업으로 넘어가는 반면, 비동기 코드는 작업을 시작한 후 완료를 기다리지 않고 다른 작업을 처리할 수 있습니다. 그리고 나중에 완료된 작업의 결과를 받아볼 수 있습니다. 이러한 접근법은 네트워크 요청, 파일 읽기/쓰기, 데이터베이스 쿼리와 같이 대기 시간이 긴 I/O 작업을 처리할 때 특히 유용합니다.
이 글에서는 Python의 비동기 프로그래밍의 기본 개념, 동기 코드와의 비교, 그리고 실제 사용 사례에 대해 살펴보겠습니다.
아래 모든 예제 코드는 아래 GITHUB에서 확인할 수 있습니다.
동기 vs 비동기가 뭔데!!?
비동기 프로그래밍을 제대로 이해하기 위해서는 동시성(Concurrency)과 병렬성(Parallelism)의 차이를 명확히 아는 것이 중요합니다.
동시성과 병렬성의 차이점은 "at the same time" 이냐 아니냐의 차이입니다. 즉, "at the same time" 이란 "한 순간에 여러 개의 작업이 수행되느냐로 이해하면 더 쉽겠죠?
동시성(Concurrency):
- 정의: 여러 작업을 조금씩 번갈아가며 실행하는 것입니다. 한 번에 하나의 작업만 진행되지만, 작업 간 전환이 빠르게 일어나 마치 동시에 실행되는 것처럼 보입니다.
- 비유: 한 명의 요리사가 여러 요리를 번갈아가며 조리하는 것과 같습니다. 파스타의 물이 끓는 동안 샐러드를 준비하고, 그 사이에 고기를 굽는 식으로 작업을 전환합니다.
- 구현: 파이썬에서는 주로 비동기 프로그래밍(asyncio)과 멀티스레딩으로 구현합니다.
병렬성(Parallelism):
- 정의: 여러 작업을 실제로 동시에 실행하는 것입니다. 물리적으로 다른 컴퓨팅 자원(CPU 코어)에서 독립적으로 작업이 수행됩니다.
- 비유: 여러 명의 요리사가 각자 다른 요리를 동시에 준비하는 것과 같습니다.
- 구현: 파이썬에서는 주로 멀티프로세싱으로 구현합니다.
Python의 비동기 프로그래밍 도구
1. 동시성(Concurrency)
Python의 비동기 프로그래밍 도구 - 동시성
- asyncio: Python의 표준 라이브러리로, 비동기 프로그래밍을 위한 프레임워크입니다
- 단일 스레드에서 이벤트 루프가 여러 코루틴 간의 실행을 조율합니다.
- 코루틴이 await 문을 만나면 제어권을 이벤트 루프에 반환하고, 다른 코루틴이 실행됩니다.
- I/O 작업 중 CPU가 놀지 않도록 효율적으로 활용합니다.
- 실제로는 한 번에 하나의 코루틴만 실행되지만, I/O 대기 시간을 다른 작업으로 활용합니다.
- 단일 스레드에서 이벤트 루프가 여러 코루틴 간의 실행을 조율합니다.
- async/await: Python 3.5+에서 도입된 구문으로, 코루틴을 정의하고 실행하는 데 사용됩니다.
- 코루틴(Coroutine): 실행을 일시 중지하고 재개할 수 있는 함수입니다.
- 코루틴의 개념: 코루틴은 실행을 일시 중지하고 재개할 수 있는 특별한 함수입니다. Python에서는 async def로 코루틴 함수를 정의하고, await로 다른 코루틴의 결과를 기다립니다.
- asyncio의 주요 구성 요소
이벤트 루프(Event Loop): 모든 비동기 작업을 관리하는 중앙 제어 장치입니다.
코루틴(Coroutines): async def로 정의된 함수입니다.
Tasks: 이벤트 루프에서 실행되는 코루틴의 래퍼입니다.
Futures: 아직 완료되지 않은 계산의 결과를 나타냅니다.
- 이벤트 루프(Event Loop): 비동기 작업을 관리하고 실행하는 중앙 메커니즘입니다.
- 스레딩 (threading) - 제한적 동시성
- 단일 프로세스 내에서 여러 실행 스레드가 메모리를 공유합니다.
- Python의 GIL(Global Interpreter Lock)로 인해 한 시점에 하나의 스레드만 Python 코드를 실행할 수 있습니다.
- I/O 바운드 작업에서는 스레드가 대기 상태일 때 다른 스레드가 실행될 수 있어 효율성이 있습니다.
- CPU 바운드 작업에서는 GIL로 인해 진정한 병렬 처리가 불가능합니다.
2. 병렬성(Parallelism)
- 여러 작업을 실제로 동시에 실행하는 것입니다. 물리적으로 다른 컴퓨팅 자원(CPU 코어)에서 독립적으로 작업이 수행됩니다.
- 여러 명의 요리사가 각자 다른 요리를 동시에 준비하는 것과 같습니다.
- 파이썬에서는 주로 멀티프로세싱으로 구현합니다.
- 멀티프로세싱: 여러 독립적인 Python 프로세스가 생성되어 각자 다른 CPU 코어에서 실행됩니다.
- 각 프로세스는 자체 GIL을 가지므로 진정한 병렬 처리가 가능합니다.
- 프로세스 간에 메모리가 공유되지 않아 데이터 공유를 위해 특수한 메커니즘이 필요합니다.
이론을 알겠는데, 이제 실제로 코드로 한번 확인을 해봐야 더 와닿겠죠?
내용이 많아서 하나하나 다 예제로 해보죠!
매우 기본적인 싱글 스레드 방식부터 알아봅시다. 싱글 스레드는 기본적으로 동기방식으로 처리가 돼요!
싱글 스레드 방식
import threading
import time
import os
def cpu_bound_work(n):
"""CPU를 사용하는 계산 작업"""
return sum(i * i for i in range(n))
def io_bound_work(n):
"""I/O를 기다리는 작업"""
time.sleep(n) # 네트워크나 디스크 I/O를 시뮬레이션
return f"결과 {n}"
def main():
print(f"프로세스 ID: {os.getpid()}, 스레드 ID: {threading.get_ident()}")
start = time.time()
# 모든 작업을 순차적으로 처리
results = []
for i in range(1, 4):
print(f"작업 {i} 시작 - {time.strftime('%H:%M:%S')}")
print(f"현재 스레드: {threading.get_ident()}")
result = io_bound_work(i)
results.append(result)
end = time.time()
print(f"총 실행 시간: {end - start:.2f}초")
print(f"결과: {results}")
if __name__ == "__main__":
main()
결과:
스레드 ID를 get_ident() 메소드를 통해서 가져왔을때 확인이 가능하죠? 모두 같은 스레드를 사용하고 또한 시간을 확인했을때
13:24:48 -> 13:24:49 -> 13:24:51
1번 작업이 종료하고 다음 작업 그리고 마지막 작업으로 이어지는것도 확인이 가능해요!
이번엔 비동기적 프로그래밍의 예시입니다.
일단 Asyncio부터 알아보죠!
import asyncio
import time
import os
import threading
async def io_bound_work(n):
"""I/O를 기다리는 비동기 작업"""
print(f"작업 {n} 시작 - {time.strftime('%H:%M:%S')}")
print(f"현재 프로세스: {os.getpid()}, 현재 스레드: {threading.get_ident()}")
await asyncio.sleep(n) # 비동기 대기 - 이 시간 동안 다른 작업이 실행될 수 있음
return f"결과 {n}"
async def main():
print(f"메인 함수 - 프로세스 ID: {os.getpid()}, 스레드 ID: {threading.get_ident()}")
start = time.time()
# 모든 작업을 동시에 시작하고 완료될 때까지 기다림
tasks = [io_bound_work(i) for i in range(1, 4)]
results = await asyncio.gather(*tasks)
end = time.time()
print(f"총 실행 시간: {end - start:.2f}초")
print(f"결과: {results}")
if __name__ == "__main__":
asyncio.run(main())
결과:
핵심 포인트: 아래 결과를 보다시피 스레드의 ID는 모두 동일합니다. 그러나 동기적으로 처리했을때와는 다르게 작업의 시작이 모두 동시에 일어난걸 확인 할 수 있죠! 그러나 싱글스레드에서 작동을 했기 때문에 이는 위의 간략하게 설명한 Event Loop라는 친구가 await지점에서 작업을 전환해 동시성으로 처리했다는것까지 파악할 수 있습니다!(아직까진 스레드가 여러개가 아닌 하나를 사용하기 때문에 병렬성은 아니죠!)
이번엔 비동기적 멀티스레딩의 예제입니다.
import threading
import time
import os
def io_bound_work(n, results):
"""I/O를 기다리는 작업"""
print(f"작업 {n} 시작 - {time.strftime('%H:%M:%S')}")
print(f"현재 프로세스: {os.getpid()}, 현재 스레드: {threading.get_ident()}")
time.sleep(n) # 스레드는 블로킹되지만 다른 스레드는 실행될 수 있음
results[n - 1] = f"결과 {n}"
def cpu_bound_work(n, results):
"""CPU를 사용하는 계산 작업"""
print(f"작업 {n} 시작 - {time.strftime('%H:%M:%S')}")
print(f"현재 프로세스: {os.getpid()}, 현재 스레드: {threading.get_ident()}")
# CPU 집약적 계산
result = sum(i * i for i in range(10000000 * n))
results[n - 1] = f"결과 {n}: {result % 10000}"
def main_io_bound():
print(f"메인 스레드 - 프로세스 ID: {os.getpid()}, 스레드 ID: {threading.get_ident()}")
start = time.time()
# 공유 결과 리스트
results = [None] * 3
# 스레드 생성 (I/O 바운드 작업)
threads = []
for i in range(1, 4):
thread = threading.Thread(target=io_bound_work, args=(i, results))
threads.append(thread)
thread.start()
# 모든 스레드가 완료될 때까지 기다림
for thread in threads:
thread.join()
end = time.time()
print(f"총 실행 시간 (I/O 바운드): {end - start:.2f}초")
print(f"결과: {results}")
def main_cpu_bound():
print(f"메인 스레드 - 프로세스 ID: {os.getpid()}, 스레드 ID: {threading.get_ident()}")
start = time.time()
# 공유 결과 리스트
results = [None] * 3
# 스레드 생성 (CPU 바운드 작업)
threads = []
for i in range(1, 4):
thread = threading.Thread(target=cpu_bound_work, args=(i, results))
threads.append(thread)
thread.start()
# 모든 스레드가 완료될 때까지 기다림
for thread in threads:
thread.join()
end = time.time()
print(f"총 실행 시간 (CPU 바운드): {end - start:.2f}초")
print(f"결과: {results}")
if __name__ == '__main__':
main_io_bound()
main_cpu_bound()
결과:
Asyncio와는 다르게 스레드 ID는 여러개를 사용하죠! 하지만 앞에서 얘기했듯 파이썬은 GIL 정책으로인해 멀티 쓰레딩을 활용하긴 했지만 실행 시간 차이가 없는 것을 보니 병렬처리가 되지 않았음을 알 수 있습니다.
마지막으로 대망의 병렬처리가 가능한 멀티 프로세싱 방식을 예제 코드와 함께 확인해보죠!
멀티 프로세싱 예제입니다.
import threading
import time
import os
def cpu_bound_work(n, results):
"""CPU를 사용하는 계산 작업"""
print(f"작업 {n} 시작 - {time.strftime('%H:%M:%S')}")
print(f"현재 프로세스: {os.getpid()}, 현재 스레드: {threading.get_ident()}")
# CPU 집약적 계산
result = sum(i * i for i in range(10000000 * n))
results[n - 1] = f"결과 {n}: {result % 10000}"
def main_cpu_bound():
print(f"메인 스레드 - 프로세스 ID: {os.getpid()}, 스레드 ID: {threading.get_ident()}")
start = time.time()
# 공유 결과 리스트
results = [None] * 3
# 스레드 생성 (CPU 바운드 작업)
threads = []
for i in range(1, 4):
thread = threading.Thread(target=cpu_bound_work, args=(i, results))
threads.append(thread)
thread.start()
# 모든 스레드가 완료될 때까지 기다림
for thread in threads:
thread.join()
end = time.time()
print(f"총 실행 시간 (CPU 바운드): {end - start:.2f}초")
print(f"결과: {results}")
if __name__ == '__main__':
main_cpu_bound()
결과: 모두 다른 프로세스 ID에서 작동하는걸 확인할 수 있습니다. 그리고 앞의 코드 CPU 바운드에서도 무려 0.5초가량 실행속도가 감소한걸 확인 할 수 있습니다.
성능 분석 & 최적화
언제 비동기 프로그래밍을 사용해야 할까?
비동기 프로그래밍은 만능 해결책이 아닙니다. 적절한 상황에서 사용해야 합니다!!
적합한 상황
- I/O 바운드 작업: 네트워크 요청, 파일 읽기/쓰기, 데이터베이스 쿼리 등
- 다수의 독립적인 작업: 서로 의존하지 않는 여러 작업을 병렬로 실행할 때
- 실시간 애플리케이션: 웹소켓, 채팅 애플리케이션 등
부적합한 상황
- CPU 바운드 작업: 복잡한 계산, 데이터 처리 등 (이 경우 멀티프로세싱이 더 적합)
- 간단한 스크립트: 오버헤드가 이점보다 클 수 있음
- 순차적 처리가 필요한 작업: 작업 간 의존성이 높은 경우
주의사항 & 한계점
디버깅의 어려움: 비동기 코드는 실행 흐름이 복잡하여 디버깅이 어려울 수 있습니다.
이벤트 루프 차단: 긴 CPU 작업은 이벤트 루프를 차단하여 다른 비동기 작업을 지연시킬 수 있습니다.
Python의 비동기 프로그래밍은 I/O 바운드 애플리케이션의 성능을 크게 향상시킬 수 있는 강력한 도구입니다. 기본 개념을 이해하고 적절한 상황에서 사용하면, 애플리케이션의 응답성과 처리량을 크게 개선할 수 있습니다.
'프로그래밍 > Python' 카테고리의 다른 글
[Python][generator] 파이썬 제너레이터 정리 (0) | 2022.04.11 |
---|---|
[TDD][Python] unittest - 예제로 익혀보기(3) (0) | 2022.03.16 |
[TDD][Python] unittest - 예제로 익혀보기(2) (0) | 2022.03.16 |
[TDD][Python] unittest - 예제로 익혀보기(1) (0) | 2022.03.16 |
[TDD][Python] 단위 테스팅이란 (0) | 2022.03.16 |