전통적으로 동시 프로그래밍은 여러 개의 쓰레드를 활용해 이루어졌다.
쓰레드를 이용해 직접 코딩을 해보면 thread safe한 프로그램을 작성하는것은 쉬운 일이 아니다.
싱글 코어 프로세서에서 이런 프로그램을 돌리면, 기대했던 동시 처리에 따른 성능 향상은 미미하거나 심지어 성능 저하도 된다.
이런 이유로 하나의 쓰레드로 동시 처리를 하는 비동기 프로그래밍이 더욱 주목 받는다.
웹 서버와 같은 애플리케이션을 생각해보면 CPU 연산 시간 대비 DB나 API와 연동 과정에서 발생하는 대기 시간이 훨씬 길다는 걸 알 수 있다. 비동기 프로그래밍은 이런 대기 시간을 낭비하지 않고 그 시간에 CPU가 다른 처리를 할 수 있도록 하는데 이를 흔히 non-blocking하다고 한다.
JS 같이 애초에 비동기 방식으로 설계된 언어에서는 익숙한 개념이지만 Python같이 기본적으로 동기 방식으로 동작하는 언어에서는 이 개념이 생소하게 느껴질 수 있다.
하지만 Python 3.4에서 asyncio 가 표준 라이브러리로 추가되고 Python 3.5에서 async/await 키워드가 문법으로 채택 되며, Python도 이제 언어 자체적으로 비동기 프로그래밍이 가능해졌다.
def 키워드로 선언하는 모든 함수는 Python에서 기본적으로 동기 방식으로 동작한다고 생각하면 된다.
예를 들어 다음과 같이 선언된 함수는 동기함수다.
def do_sync():
pass
기존 def 키워드 앞에 async 키워드까지 붙이면 이 함수는 비동기 처리되며, 이러한 비동기 함수를 Python에서는 coroutine 라고 부른다.
async def do_async():
pass
이런 비동기 함수는 일반 동기 함수가 호출하듯이 호출하면 coroutine 객체가 리턴된다
do_async() # <coroutine object do_async at 0x1038de710>
따라서 비동기 함수는 일반적으로 async
로 선언된 다른 비동기 함수 내에서 await
키워드를 붙여서 호출해야 한다.
async def main_async():
await do_async()
async
로 선언되지 않은 일반 동기 함수 내에서 비동기 함수를 호출하려면 asyncio
라이브러리의 이벤트 루프를 이용해야한다.
loop = asyncio.get_event_loop()
loop.run_until_complete(main_async())
loop.close()
Python 3.7 이상에서는 다음과 같이 한 줄로 간단히 비동기 함수 호출 할 수도 있다.
asyncio.run(main_async())
사용자 관리 애플리케이션을 흉내내는 실습 코드 작성하며 동기 처리하는 코드, 비동기 처리 하는 코드 비교
다음과 같은 가정
먼저 사용자 데이터의 조회를 전통적인 동기 방식으로 처리해주는 find_users_sync
함수를 작성한다. 의도적으로 1초의 지연 시간을 발생시키기 위해서 time.sleep
함수를 사용한다.
import time
def find_users_sync(n):
for i in range(1, n + 1):
print(f'{n}명 중 {i}번 째 사용자 조회 중...')
time.sleep(1)
print(f'> 총 {n} 명 사용자 동기 조회 완료!')
그 다음, 애플리케이션에 들어온 3개 요청 동기 처리하는 process_sync
함수를 작성
def process_sync():
start = time.time()
find_users_sync(3)
find_users_syne(2)
find_users_sync(1)
end = time.time()
print(f'>>> 동기 처리 총 소요 시간: {end - start}`)
if __name__ == '__main__':
process_sync()
이 함수 호출해보면 find_users_sync
함수 총 6초 동안 3번 순차적으로 실행됨을 알 수 있다.
3명 중 1번 째 사용자 조회 중 ...
3명 중 2번 째 사용자 조회 중 ...
3명 중 3번 째 사용자 조회 중 ...
> 총 3 명 사용자 동기 조회 완료!
2명 중 1번 째 사용자 조회 중 ...
2명 중 2번 째 사용자 조회 중 ...
> 총 2 명 사용자 동기 조회 완료!
1명 중 1번 째 사용자 조회 중 ...
> 총 1 명 사용자 동기 조회 완료!
>>> 동기 처리 총 소요 시간: 6.020448923110962
만약 싱글 thread의 웹 서버가 이러한 방식으로 동작한다면 실제 사용자는 얼마나 오랫동안 지연을 경험하게 된다. 동기 처리에서는 첫 번째 함수의 실행이 끝나야 두 번째 함수가 실행되고, 마찬가지로 두 번째 함수가 끝나야 세 번째 함수가 실행된다. 즉, 첫 번째 요처이 처리되는데는 3초, 두 번째 요청은 5초 (3+2), 세 번째 요청은 6초 (3+2+1)가 걸릴 것이다.
위에서 동기 처리되도록 작성된 코드를 파이썬의 async/await
키워드를 사용해서 한 번 비동기 처리될 수 있도록 개선해보도록 한다. 기존의 함수 선언에 async
키워드를 붙여서 일반 동기 함수가 아닌 비동기 함수(coroutine)로 변경했으며, time.sleep 함수 대신 asyncio.sleep
함수 사용해 1초의 지연 발생
time.sleep
함수는 기다리는 동안 CPU를 그냥 놀리는 반면, asyncio.sleep
함수는 CPU가 놀지 않고 다른 처리를 할 수 있도록 해준다. 여기서 주의할 점은 asyncio.sleep
자체도 비동기 함수이기 때문에 호출할 때 반드시 await
키워드를 붙여야 한다는 것이다.
import time
import asyncio
async def find_users_async(n):
for i in range(1, n+1):
print(f'{n}명 중 {i}번 째 사용자 조회 중 ...')
await asyncio.sleep(1)
print(f'> 총 {n}명 사용자 비동기 조회 완료!')
Python asyncio
라이브러리를 사용해 위에서 작성한 함수를 비동기로 실행해본다.
먼저 이벤트 루프가 3개의 함수 호출을 알아서 스케줄해 비동기로 호출할 수 있도록
asyncio.wait
함수의 배열 인자로 3개의 함수 리턴값, 즉 coroutine 객체를 넘겨주도록 수정한다. 그리고 이렇게 수정된 process_async
비동기 함수를 호출할 때도, 함수의 리턴값인 coroutine 객체를 asyncio.run
함수에 넘겨준다.
async def process_async():
start = time.time()
await asyncio.wait([
find_users_async(3),
find_users_async(2),
find_users_async(1),
])
end = time.time()
print(f'>>> 비동기 처리 총 소요 시간: {end - start}`)
if __name__ == '__main__':
asyncio.run(process_async())
비동기 처리되도록 재작성된 코드 실행해보면 호출 순서와 무방하게 실행 시간이 짧을 수록 먼저 처리되는 것을 알 수 있다. 게다가 총 소요시간도 6초에서 3초로 100% 단축되었음을 알 수 있다.
실제 사용자 관점에서 생각해보면 3초가 걸리는 요청을 기다리지 않고, 1초가 걸리는 요청은 1초만에 응답이 오고, 2초가 걸리는 요청은 2초 만에 응답이 올테니 매우 이상적이다.
1명 중 1번 째 사용자 조회 중 ...
2명 중 1번 째 사용자 조회 중 ...
3명 중 1번 째 사용자 조회 중 ...
> 총 1 명 사용자 비동기 조회 완료!
2명 중 2번 째 사용자 조회 중 ...
3명 중 2번 째 사용자 조회 중 ...
> 총 2 명 사용자 비동기 조회 완료!
3명 중 3번 째 사용자 조회 중 ...
> 총 3 명 사용자 비동기 조회 완료!
>>> 비동기 처리 총 소요 시간: 3.0041661262512207
기본적으로 비동기 처리는 정확히 실행 순서가 보장되지 않기 때문에, 실행순서가 PC마다 다를 수 있다. 비록 동일한 실행 순서 보장받지 못하더라도, 여기서 중요한 점은 CPU 놀리지 않고 불필요한 지연없이 3개의 요청이 실행되어야 한다는 것이다.