비동기 프로그래밍은 동기적 프로그래밍의 반대말인데요, 그럼 동기적 프로그래밍은 뭘까요?
일반적으로 우리가 작성한 코드는 프로세스가 진행됨에 따라 코드상 작성된 작업들을 순차적으로 처리하게 되는데, 하나의 작업을 시작하면 그 작업이 끝날 때 까지 다른 작업을 시작하지 않습니다. 이를 동기적 프로그래밍이라 합니다.
그러나 비동기 프로그래밍은 응답을 기다려야 일이 발생했을 때 기다리지 않고 바로 다음 작업에 착수합니다. 따라서 데이터를 요청하고 응답을 기다리는 스타일의 코드에서 큰 성능을 보일 수 있습니다.
비동기 프로그래밍에 대한 설명을 시작하기 전, 용어들에 대한 정리를 먼저 확실하게 해두겠습니다.
- 이벤트 루프: 이벤트 루프는 여러 작업들을 돌아가며 하나씩 실행시키는 메인 작업 관리자의 역할을 합니다. 만약 한 작업을 수행하는 중 불필요한 대기 시간이 발생하였을 시, 이벤트 루프는 다른 작업을 실행시켜 전반적으로 코드가 비동기적으로 돌아가게 합니다.
- 코루틴: 이벤트 루프가 실행하는 작업들을 이루는 함수입니다. 이 함수는 비동기적 성향을 띄고 있어 작동 중 응답이 지연되면 이벤트 루프에게 통제권을 넘겨주고, 응답이 완료되면 전에 멈추었을 때의 상태를 유지한 채로 남은 작업을 완료합니다.
파이썬에서는 asyncio라는 모듈을 이용해 비동기 프로그래밍을 구현할 수 있습니다. asyncio 모듈은 파이썬 버전에 따라 문법이라든지 사용 트렌드가 많이 다르기 때문에 이 글을 작성하는 시점에서 가장 최신 버전인 파이썬 3.10.0의 공식문서를 참조하여 비동기 사용 방법을 소개해드리겠습니다.
코루틴 생성 및 실행
import asyncio
async def main():
await asyncio.sleep(1)
print('hello')
asyncio.run(main()) # hello
함수 선언 앞에 async를 붙이면 코루틴을 만들 수 있습니다.
그리고 asyncio.run() 함수를 통해서 이벤트 루프를 생성해 코루틴을 작동시킬 수 있습니다. 모든 작업이 끝나면 이 이벤트 루프는 자동으로 종료됩니다. 만약 동일 스레드 내에 다른 이벤트 루프가 이미 돌아가고 있다면, 이 함수는 호출할 수 없습니다.
asyncio.sleep()는 코드를 몇 초간 대기시키는 함수로, time.sleep()과 역할이 같습니다. 코루틴이 아닌 함수는 비동기에서 사용할 수 없기 때문에 asyncio.sleep() 함수를 사용해준 것입니다.
태스크
async 키워드로 코루틴이 된 함수는 일반적인 방법으로는 실행시킬 수 없습니다. 위 코드처럼 asyncio.run()로 실행시키거나 asyncio.create_task() 함수를 이용해 태스크를 만든 후 await 키워드를 통해 실행시켜야 합니다. 이렇게 await를 통해서 작동시킬 수 있는 객체(코루틴, 태스크 등)를 awaitable 객체라고 합니다.
async def is_prime(value):
divisor = value // 2
await asyncio.sleep(10 - value)
for i in range(2, divisor + 1):
if value % i == 0:
print(f"{value} is not a prime")
return
print(f"{value} is a prime")
# 잘못된 코루틴 호출
is_prime(5)
# 올바른 코루틴 호출
async def main():
task1 = asyncio.create_task(is_prime(5))
task2 = asyncio.create_task(is_prime(7))
await task1
await task2
asyncio.run(main())
asyncio.create_task() 함수를 통해 태스크가 생성된 순간, 그 태스크는 이벤트 루프에 의해 미래에 작동될 것이라 스케쥴링되고, await같은 키워드를 통해 호출되는 순간 비동기 루프 속에 참여하게 되는 것입니다.
그렇다면 위의 코드가 실행되는데 소요되는 시간은 총 몇 초일까요? 파라미터로 넘겨주는 value값에 따라 태스크 당 (10-value)초가 걸리니, task1은 5초, task2는 3초가 걸립니다. 동기적 프로그래밍이라면 총 8초가 걸리겠지만, task1 에서 asyncio.sleep()를 만나게 되는 순간 이벤트 루프는 task2로 넘어가 새로운 태스크를 수행하게 되고, task2가 먼저 작업이 끝나니 task2의 결과를 먼저 출력할 것입니다. 총 소요 시간은 task1이 결과를 출력하는데 걸리는 시간인 5초가 될 것입니다.
# 7 is a prime
# 5 is a prime
복수의 태스크에 대한 비동기적 처리
위에서는 태스크를 하나씩 만들어서 await 키워드를 통해 작동시켰습니다. asyncio.gather() 함수를 사용하면 여러개의 태스크를 만들어 한번에 스케쥴링 할 수 있습니다.
async def factorial(name, number):
result = 1
for i in range(2, number + 1):
print(f"Task [{name}]: Currently on process {result} X {i}")
await asyncio.sleep(1)
result *= i
print(f"Task [{name}]: Result = {result}")
async def main():
await asyncio.gather(
factorial("A", 3), # 2초
factorial("B", 4), # 3초
factorial("C", 5), # 4초
)
asyncio.run(main())
이처럼 asyncio.gather()의 인자로 코루틴을 여러개 호출해주기만 하면 됩니다. 이 코드는 Task A, B, C 순서대로 결과값이 출력되고 총 소요시간은 4초가 될 것입니다.
# Result
"""
Task [A]: Currently on process 1 X 2
Task [B]: Currently on process 1 X 2
Task [C]: Currently on process 1 X 2
Task [A]: Currently on process 2 X 3
Task [B]: Currently on process 2 X 3
Task [C]: Currently on process 2 X 3
Task [A]: Result = 6
Task [B]: Currently on process 6 X 4
Task [C]: Currently on process 6 X 4
Task [B]: Result = 24
Task [C]: Currently on process 24 X 5
Task [C]: Result = 120
"""
위 코드를 다음과 같은 방법으로 작성할 수도 있습니다.
async def main():
task_name = ["A", "B", "C"]
tasks = [factorial(i, j) for i, j in zip(task_name, range(3, 6)]
await asyncio.gather(*tasks)
asyncio.run(main())
시간 제한
asyncio.wait_for() 함수를 사용하면 코루틴이 작동될 시간을 제한할 수 있습니다. 함수 내 timeout 인자를 사용하여 제한시간을 설정할 수 있고, 시간 초과시 TimeoutError가 발생하며 코루틴이 종료됩니다.
async def five_seconds():
await asyncio.sleep(5)
print("Coroutine ends")
async def main():
task_deprecate = asyncio.wait_for(five_seconds(), timeout=3)
try:
await task_deprecate
except asyncio.TimeoutError:
print("Timeout has occured")
asyncio.run(main())
위 코드는 5초가 소요되는 five_seconds()라는 비동기 함수를 실행하는 코드입니다. 그러나 timeout=3으로 3초의 시간 제한을 주었기 때문에, "Coroutine ends"라는 텍스트는 출력되지 않고 코드 시작 3초만에 "Timeout has occured"라는 메세지를 띄우고 프로그램은 종료될 것입니다.
이터러블 코루틴
asyncio.as_completed() 함수를 이용해 다수의 코루틴들을 이터레이터로 만든 후 루프문에 넣어 각각의 코루틴에 접근할 수 있습니다. 바로 코드를 한번 봅시다.
async def return_value(value):
await asyncio.sleep(5 - value)
return value
async def main():
aws = [return_value(x) for x in range(1, 4)]
for coro in asyncio.as_completed(aws):
result = await coro
print(f"{result} has returned")
asycnio.run(main())
이 코드는 return_value(1), return_value(2), return_value(3) 코루틴을 연달아 실행시키는 코드랑 동일합니다, 결과는 아래와 같이 나오고 총 소요시간은 4초입니다.
3 has returned
2 has returned
1 has returned
참고로, asyncio.as_completed() 함수도 timeout 인자를 가지고 있어 (디폴트값 None), 모든 코루틴이 완료되기 전 timeout이 생기면 TimeoutError가 발생하게 됩니다.
참고 자료
'프로그래밍 언어 > Python' 카테고리의 다른 글
[Python] 디버깅과 로그 (0) | 2021.12.16 |
---|---|
[Python] 데코레이터 (0) | 2021.11.21 |
[Python] 클래스 구조 (0) | 2021.11.16 |
[Python] 제너레이터 (0) | 2021.11.11 |
[Python] 유용한 데이터 구조 (0) | 2021.11.07 |