| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- node.js
- NestJS
- 개발공부
- 코드스테이츠
- algorithm
- Python
- 자료구조
- java
- Zerobase
- 컴퓨터공학
- 테스트코드
- react 기초
- useState
- typeScript
- 글또
- Computer Science
- OS
- python algorithm
- 자바스크립트
- 파이썬
- 자바
- 비동기
- codestates
- execution context
- 파이썬 알고리즘 인터뷰
- 알고리즘
- REACT
- 운영체제
- Operating System
- JavaScript
- Today
- Total
Back to the Basics
[python] Python의 비동기 동작과 event loop 본문
파이썬의 event loop에 대해 알아보자. Nodejs의 event loop 기반의 논블로킹 싱글 스레드 기반의 환경과 어떻게 다른 지도 간단하게 정리하였다
1. Python에서 비동기를 작성하는 방법(문법 레벨)
NodeJs나 Python이나 async await이라는 문법이 존재한다. 두 언어에서 각각 비동기 함수를 정의하고 실행하는 역할을 한다는 공통점이 있다. 문법은 같은데 무엇이 다를까?
일단 NodeJs는 non-blocking이 기본 실행 환경이고 Python은 기본적으로 synchronous 실행 모델 이라는 실행 철학에 차이가 있다.
1.1 async/ await 문법
python의 async/await은 구문은 파이썬 "언어 차원"에서 제공되는 문법이며(PEP492) 이를 기반으로 동시성 코드를 작성할 수 있도록 지원한다. 이를 지원하는 대표적인 라이브러리가 asyncio 모듈이다 Python의 asyncio 공식문서
async await은 python 3.5부터 도입되었다.
async
async def 를 사용하여 함수를 coroutine function(코루틴 함수)으로 정의한다. async def로 정의한 코루틴 함수를 실행하면 실제 실행이 즉시 호출되는 것이 아니라 coroutine object라는 것이 생성된다. 이 객체는 await 가능한 객체(awaitable)의 한 종류이다.
여기서 coroutine이란 실행 도중 await 지점에서 잠시 멈췄다가, event loop가 다른 작업을 처리한 뒤(event loop에 제어권을 넘기는 행위) 다시 이어서 실행될 수 있는 함수 실행 단위이다. 코루틴에 대해서는 다음 세션에서 조금 더 확인해 보자.
await
await 키워드(6.4. Await expression)는 awaitable 객체가 완료될 때까지 현재 coroutine의 실행을 일시 중단하며, coroutine function 내부에서만 사용할 수 있다.
await을 만나면 현재 실행 중인 coroutine은 중단되고, 실행 제어권이 event loop로 넘어간다. event loop는 실행 가능한 다른 작업을 스케줄링하여 실행한다. 이후 await 대상 작업이 완료되면, 중단되었던 coroutine은 다시 실행 가능한 상태가 되어 event loop에 의해 재개된다.
NodeJs와의 비교해 보자
NodeJs에서 async 키워드는 해당 함수가 Promise를 반환하는 비동기 함수임을 의미한다. await 키워드는 Promise가 resolve 될 때까지 현재 async 함수의 실행을 일시 중단한다. 문법적인 형태는 매우 유사하다.
하지만 실행 모델에는 차이가 있다.
NodeJs에서는 async 함수가 호출되는 순간 Promise가 생성되고 함수 실행이 바로 시작된다(eager execution) 된다. 즉, 함수 호출 자체가 실행의 시작이다.
반면 Python에서는 coroutine 함수를 호출해도 실제 실행은 바로 시작되지 않는다. 대신 couroutine object만 생성될 뿐이며, 이 객체는 await 되거나 event loop에 의해 스케줄링되기 전까지는 실행되지 않는다.
아래 예시 코드를 확인해 보자
NodeJs
async function hello() {
console.log("start"); // 함수 호출 즉시 실행됨
await Promise.resolve();
console.log("end");
}
hello(); // 호출하는 순간 "start"가 바로 출력됨
// await은 실행을 시작하는 역할이 아니라 Promise 결과를 기다리는 역할
NodeJs에서는 async 함수를 호출하는 순간 함수 body가 실행되며, await을 만나면 Promise가 resolve 될 때까지 실행이 잠시 중단된다.
Python
import asyncio
async def hello():
print("start") # 아직 실행되지 않음
await asyncio.sleep(0)
print("end")
coro = hello() # hello() 호출 시 coroutine object만 생성된다
# 실제 실행은 event loop에서 시작됨
asyncio.run(hello())
Python에서는 coroutine 함수 호출 시 실행이 시작되는 것이 아니라 coroutine object만 생성된다.
실제 실행은 await 하거나 asyncio.create_task() 등을 통해 event loop에 등록될 때 시작된다.
1.2 coroutine은?
coroutine(코루틴)은 실행 도중 중단되었다가 이후 다시 실행될 수 있는 함수 실행 단위이다.
일반적인 함수는 호출되면 끝까지 실행되지만, coroutine은 await 지점에서 실행을 잠시 멈추고 event loop에게 제어권을 넘길 수 있다.
coroutine의 특성을 정리해 보면 아래와 같다
1. coroutine은 "중단 가능한 함수"이다.
2. coroutine function은 호출하면 바로 실행되지 않는다.
3. 호출 결과로 coroutine object가 생성된다.
4. 이 coroutine object는 await 되거나 event loop에 의해 스케줄링될 때 실행이 시작된다.
async def fetch_data():
print("start")
return "data"
coro = fetch_data() # 아직 실행되지 않음 (coroutine object 생성)
await coro # 이 시점에 실제 실행 시작
즉, coroutine은 "실행 가능한 작업을 표현하는 객체"에 가깝다.
1.3 coroutine 실행 등록 (Task, gather)
앞서 설명한 것처럼 Python의 coroutine은 함수를 호출한다고 바로 실행되지 않는다. coroutine 함수 호출 시 coroutine object만 생성되며, 실제 실행을 위해서는 event loop에 의해 스케줄링되어야 한다.
asyncio.run()
asyncio.run()은 coorutine을 실행하기 위해 가장 기본적인 진입점이다. 내부적으로 event loop를 생성하고 coroutine을 실행한 뒤 event loop를 종료한다.
import asyncio
async def hello():
print("start")
await asyncio.sleep(1)
print("end")
asyncio.run(hello())
위의 코드에서 hello()는 coorutine object를 생성하고, asyncio.run()이 이를 event loop에서 실행하도록 한다.
일반적으로 Python asyncio 프로그램의 최상위 실행 지점에서 사용된다.
asyncio.create_task()
create_task()는 coroutine을 Task로 감싸고 event loop에 등록하여 실행을 시작하게 한다.
Task는 event loop가 스케줄링할 수 있는 실행 단위이다.
import asyncio
async def work():
print("start")
await asyncio.sleep(5)
print("end")
async def main():
task = asyncio.create_task(work()) # coroutine을 task로 만들어 event loop에 등록
await task
asyncio.run(main())
create_task()를 사용하면 coroutine이 event loop의 스케줄링 대상이 된다.
asyncio.gather
gather를 사용하여 여러 coroutine을 동시에 실행(concurrent execution) 하고 결과를 모으기 위해 사용할 수 있다.
"""
여러 coroutine을 동시에 실행하여 결과를 모으기 위해 aynsio.gather()를 사용할 수 있다.
work(1),work(2),work(3)을 동시에 실행하고 모든 작업이 완료되면 리스트로 반환
"""
import asyncio
async def work(n):
await asyncio.sleep(2)
return n
async def main():
results = await asyncio.gather(
work(1),
work(2),
work(3)
)
print(results)
asyncio.run(main())
즉, Python에서는 아래와 같은 흐름을 가져간다.
async def → coroutine 생성
asyncio.run → event loop 시작
create_task / gather → coroutine을 실행 대상으로 등록
event loop → scheduling
cooperative scheduling
python의 asyncio는 cooperative scheduling 방식으로 동작한다.
작업이 강제로 중단되는 것이 아니라 coroutine이 await 지점에서 스스로 제어권을 event loop에 반환한다
실행의 흐름은 아래와 같다
coroutine 실행
↓
await 만남
↓
event loop에게 제어권 반환
↓
다른 coroutine 실행
↓
await 작업 완료
↓
다시 실행(resume)
이 방식 덕분에 하나의 thread에서도 여러 작업을 번갈아서 실행하면서 I/O작업을 효율적으로 처리할 수 있다.
이런 동작의 내부적인 흐름은 아래에서 조금 더 자세히 정리하였다.
2. Python 비동기의 내부 동작 (런타임 레벨)
앞에서는 Python에서 비동기 코드를 작성하는 문법적인 방법을 다뤘다. 이번에는 실제고 asyncio가 런타임에서 어떻게 동작하는지 간단히 알아보자.
이벤트 루프(Event Loop)란?
event loop는 비동기 작업을 관리하고 실행 순서를 조정하는 스케줄러이다.
(참고로 아래에 있는 설명은 단순화한 버전이다)
event loop는 개념적으로 while loop 형태의 스케줄러라고 볼 수 있다. while loop를 돌면서 task를 실행한다.
while tasks:
for task in tasks:
execute(task)
Task queue(Ready Queue)
task queue는 실행될 준비가 된 task들이 들어있다. 이 task들은 실행될 준비가 되면 이 queue에 추가된다.

Sleeping queue(scheduled heap)

timer가 있는 작업이 들어가는 queue도 있다. event loop가 돌 때마다 deadline이 완료된 작업이 있는지 확인한다.
만약 타이머가 다 된 작업이 있다면 ready queue로 옮긴다.
while ready or sleeping:
if not ready:
deadline, task = sleeping.pop()
delta = deadline - time.time()
if delta < 0 # check if deadline is over
ready.append(task)
# execute task
while ready:
task = ready.popleft()
task()
Selector (I/O Multiplexing)
실제 비동기 프로그햄에서는 네트워크 요청, 파일 읽기 등의 I/O작업이 훨씬 많이 발생한다.
이런 I/O작업을 처리하기 위해 asyncio는 I/O multiplexing을 사용한다.
일반적인 blocking I/O 코드를 생각해 보자
data = socket.recv(1024)
이 코드는 데이터가 도착할 때까지 프로그램 실행을 멈춘다. 즉, blocking I/O이다.
이를 해결하기 위해 asyncio는 non-blocking socker과 selctor를 사용한다.
selector의 역할은 특정 socket에 읽기(read) 또는 쓰기(write) 이벤트가 발생하면 이를 감지하는 것이다. 내부적으로는 epoll, kqueue, select 등의 OS 기능을 사용한다.
Pythob에는 이를 selctors 모듈이 제공한다.
import selectors
selector = selectors.DefaultSelector()
selector.register(sock, selectors.EVENT_READ)
event loop는 내부적으로 selctor를 사용하여 I/O 이벤트를 감지한다. 즉, I/O 이벤트가 발생하면 해당 coroutine이 다시 실행될 준비 상태가 된다 (ready queye로 등록)

asyncio event loop 내부 흐름

간단하게 정리하자면 event loop는 timer, I/O 이벤트, 실행 가능한 task를 반복적으로 확인하면서 coroutine 실행을 스케줄링한다
while True:
# timer 확인
move_scheduled_tasks_to_ready()
# I/O 이벤트 확인
events = selector.select()
# ready queue 실행
while ready:
task = ready.popleft()
task.run()
vent loop는 이 구조들을 계속 확인하면서 coroutine 실행을 조정한다.
3. 마무리
Python의 비동기 모델은 event loop, task scheduling, selector 기반 I/O 이벤트 감지가 동작하면서 구현된다.
coroutine
↓
Task
↓
event loop
↓
ready queue / scheduled queue
↓
selector (I/O 이벤트 감지)
event loop는 이러한 구조를 반복적으로 확인하면서 실행 가능한 coroutine을 스케줄링하고, I/O 이벤트가 발생하면 해당 coroutine을 다시 실행한다.
이러한 방식 덕분에 Python에서는 NodeJs와 같이 하나의 thread에서도 여러 I/O 작업을 효율적으로 처리할 수 있는 비동기 프로그래밍 모델을 구현할 수 있다.
'Programming Languages > Python' 카테고리의 다른 글
| [Python Data structure ] 배열 - 삭제연산 & 시간 복잡도 & 동적 배열의 크기 (0) | 2021.10.13 |
|---|---|
| [Python Data structure ] 배열 - insertion operation 시간복잡도 분석 (0) | 2021.10.12 |
| [Python Datastructure ] 배열 - appen operation 시간복잡도 with 분할상환분석 (0) | 2021.10.12 |
| [Python Algorithm Interview ] 2.6 - 문자열 조작 (0) | 2021.09.08 |
| [Python Algorithm Interview ] 2.5 - List & Dictionary (0) | 2021.09.08 |