[Python] 비동기 프로그래밍 정리 3 (이벤트루프)

hodu·2022년 11월 1일
1

asyncio

목록 보기
3/7
post-thumbnail

아래 링크를 읽고 정리한 내용입니다.

🚨 더 구체적인 내용은 아래 링크에 아주 친절하게 잘 정리되어 있으니 꼭 링크가서 읽어주세요!!
https://it-eldorado.tistory.com/159?category=749661


이번에는 이벤트 루프의 정의와 실행 흐름 및 동작 원리를 알아보자

1. 이벤트 루프란?

이벤트 루프는 모든 asyncio 응용 프로그램의 핵심이다. 이벤트루프는 비동기 태스크 및 콜백을 실행하고 네트워크 I/O 연산을 수행하며 자식 프로세스를 실행한다.

예시

쉽게 이해하기 위해 식당과 관련하여 예제를 들어보겠다.

식당은 들어오는 손님들을 맞이해야 하고, 메뉴판을 전달하고, 주문을 받고, 음식을 조리하고, 서빙을 한 다음, 결제를 해야한다.

멀티 프로세스, 멀티 쓰레드

멀티프로세스, 멀티쓰레드는 이 일을 할 직원들을 얼마나 고용할건지에 대한 내용이다.
👉 홀에 직원들을 여러명 두고, 손님이 올 때마다 첫번째 손님을 첫번째 직원이 처리하고, 두번째 손님을 두번째 직원이 처리하고..

이렇게 되면 분산이 가능하긴 하지만 직원에게 줘야하는 급여 등 비용이 매우 커진다.

asyncio

반면 asyncio는 총괄 책임자를 고용한다.
손님이 오면 "어떤 일"을 해야하는지 작성하고 총괄 책임자가 어떤 일이 있는지 파악하고 주문을 받을 직원을 연결해준다.
여기서 총괄 책임자는 "이벤트 루프"이며 이벤트(작업)을 처리하는 컨트롤 타워이다.

즉, 이벤트 루프는 무한 루프를 돌며 매 루프마다 작업(Task)을 하나씩 실행시키는 로직을 의미한다.

2. 이벤트 루프 들어가기

이전 게시글에서 코루틴을 호출하고 코루틴 객체가 생성 및 반환된다고 하여 해당 코루틴이 바로 실행되지는 않는다는 말을 기억하고 있을 것이다.
그렇다면 이 코루틴 객체를 실행하기 위해서는 어떻게 해야할까?

코루틴을 실행하는 방법은 다음과 같이 세 가지이다.

  1. await
  2. asyncio.run()
  3. asyncio.create_task()

이 중에서 await 키워드는 코루틴 내에서만 사용할 수 있다. 그래서 맨 처음 코루틴을 실행할 때에는 사용할 수 없다.

나머지 2, 3번은 코루틴 바깥에서 처음으로 코루틴을 실행할 수 있는, 즉 코루틴 체인으로 들어가는 일종의 엔트리 포인트이다.

일반적인 경우에서는 2번이 비동기 프로그래밍의 시작점이기 때문에 asycnio.run에 대해 자세히 설명하도록 하겠다.
3번은 보통 태스크를 동시적으로 실행하고 싶은 경우에 사용한다. 다음 게시글에서 설명하도록 하겠다.

asyncio.run()

이 함수는 현재의 쓰레드에 새 이벤트 루프를 설정하고 해당 이벤트 루프에서 인자로 넘어오는 코루틴을 태스크로 예약하여 실행시킨 뒤 해당 태스크의 실행이 완료되면 이벤트 루프를 닫는 역할을 수행한다.

3.7 이전 버전에서는 아래와 같이 사용하였는데, 이 코드가 훨씬 직관적이기 떄문에 이를 토대로 동작원리를 설명하도록 하겠다.

loop = asyncio.get_event_loop()
loop.run_until_complete(first_coroutine())
loop.close()

1. loop = asyncio.get_event_loop()

현재 쓰레드에 설정된 이벤트 루프를 가져오는 함수이다. 현재 쓰레드에 설정되어 있는 이벤트 루프가 없다면 이벤트 루프를 새로 생성하여 이를 현재 쓰레드에 설정한 뒤 해당 이벤트 루프를 반환한다. 즉 이 함수의 호출은 코루틴의 실행을 위해 이벤트 루프를 준비하는 과정으로 볼 수 있다.

아래 이미지는 이벤트 루프가 실행되는 흐름을 아주 간단하게 표현한 코드이다.
이벤트 루프 객체를 이용하여 실제로 이벤트 루프를 실행시키면 대략 이러한 코드가 실행되는 것으로 상상하면 된다.

2. loop.run_until_complete(first_coroutine())

앞서 생성한 이벤트 루프 객체를 이용하여 실제로 이벤트 루프를 실행시키는 함수이다.

2.1) 태스크의 실행(코루틴 체인의 형성)

인자로 넘어오는 코루틴 객체를 이용하여 태스크 객체를 생성하고, 그 과정에서 해당 태스크 객체가 나타내는 태스크의 실행이 이벤트 루프에 의해 즉시 예약된다. 처음에는 실행이 예약된 다른 태스크가 없으므로 이벤트 루프는 이 태스크를 바로 실행할 것이다.
이때 태스크의 실행이란 해당 태스크 객체의 __step() 메서드를 호출하는 것을 의미한다.

이 메서드는 코루틴 객체의 send() 메서드를 호출함으로써 해당 코루틴을 실행하는 역할을 수행한다. 그러면 이 코루틴을 시작으로 await를 마주할 때마다 연쇄적으로 코루틴을 호출하여 코루틴 체인을 형성하게 된다.

2.2) 코루틴 체인의 종착점(await)

await 키워드를 통해 코루틴 체인을 형성하여 실행하다 보면 sleep, I/O 관련 코루틴을 await하는 코드를 마주치게 된다. 그런데 이러한 종류의 코루틴들은 퓨쳐 객체를 await하도록 구현되어 있다.

예를들어 I/O 관련 코루틴일 경우 이 코루틴은 특정 소켓에 대해 데이터를 읽거나 쓰기 위해 해당 소켓의 상태를 검사한다. 만약 당장 읽거나 쓸 수 있는 데이터가 있다면 단순히 yield 키워드만을 이용하여 태스크 객체의 __step()메서드로 제어를 넘긴다. 그러면 태스크 객체는 바로 다시 자신의 실행을 이벤트 루프에게 예약하고 지금의 실행은 중단한 뒤 이벤트 루프에게 제어를 넘긴다.
이때 태스크의 실행을 예약한다 함은 곧 해당 태스크의 객체의 __step() 메서드를 이벤트 루프의 콜백 큐에 등록하는 것을 의미한다는 것을 알아두자.

그러나 보통은 당장 읽거나 쓸 수 있는 데이터가 있지 않다. 따라서 보통의 경우에는 select()함수를 이용하여 해당 소켓을 등록해두고, 해당 소켓에 바인딩된 퓨처 객체를 새로 생성하여 await한다. 퓨쳐 객체의 __await__() 메서드는 자기 자신(퓨쳐 객체)을 yield 하도록 구현되어 있기 때문에 이로 인해 해당 퓨쳐 객체는 코루틴 체인을 따라 태스크 객체의 __step() 메서드로 전달될 것이다.

2.3) 태스크 객체의 퓨쳐 객체 처리

태스크 객체는 yield된 퓨쳐 객체를 받으면 이것을 자신의 __fut_waiter 필드에 저장한다(바인딩한다). 그리고 퓨처 객체의 add_done_callback() 메서드를 호출하여 해당 퓨처 객체가 완료 상태가 될 때 이벤트 루프에게 실행을 예약할 콜백 함수를 등록한다.

그러고 나면 이제 태스크 객체는 자신의 실행을 중단하고 제어를 이벤트 루프에게 넘긴다. 이벤트 루프는 다시 자신에게 실행을 예약해둔 태스크(콜백함수)들 중 우선순위가 높은 것을 적절히 선택하여 이를 실행시킨다.

이벤트 루프는 이러한 과정을 반복하여 태스크들을 동시적으로 실행하는 역할을 맡는다.

2.4) 이벤트 루프의 Polling(I/O 소켓 검사)

더이상 자신에게 실행을 예약해둔 태스크가 없다면 이벤트 루프는 그시간을 낭비하지 않고 select() 함수를 이용하여 데이터를 읽거나 쓸 준비가 된 소켓을 계속 찾는다.

데이터를 읽거나 쓸 수 있는 소켓을 찾게 된다면 그 소켓에 바인딩되어 있는 퓨처 객체의 결과 값을 업데이트하고 이로 인해 이 순간 아까 등록해두었던 콜백 함수의 실행이 이벤트 루프에서 예약될 것이다.

다시 강조하지만, 콜백 함수의 실행을 예약한다는 것은 곧 해당 태스크의 실행을 예약한다는 말이다.

2.5) 태스크 객체의 실행 재개(__step() 메서드 재실행)

이벤트 루프가 실행이 예약된 태스크를 실제로 실행시키는 과정을 한번 살펴보자. 태스크의 실행이란 곧 해당 태스크 객체의 __step() 메소드가 호출되는 것을 의미한다. 이 메서드는 먼저 자신(태스크 객체)와 퓨처 객체의 바인딩을 해제함으로써 더이상 기다리는 퓨처 객체가 없음을 나타내도록 하고, 다시 자신의 코루틴 객체에 대해 send() 메서드를 호출함으로써 해당 코루틴의 실행을 재개하도록 한다.(2.1 반복)

2.6) 최초 코루틴의 Return(태스크 실행의 종료)

이러한 과정을 반복하다보면 태스크가 실행한 최초의 코루틴이 return해야 하는 시점에 도달하게 될 것이다.
이로 인해 해당 태스크 객체의 __step() 메서드에서 StopIteration 예외가 발생하고 자기 자신을 업데이트하고 실행을 종료한다.

이 태스크는 이제 더이상 이벤트 루프에 의해 실행이 예약되지 않고 버려지며 loop.run_until_complete() 함수의 실행이 끝나는 시점이 바로 이때이다.
태스크 객체의 결과값은 loop.run_until_complete()함수의 반환 값이다.

3. loop.close()

loop.run_until_complete()는 더이상 해당 이벤트 루프가 실행되지 않는다는 것을 뜻하며 이벤트 루프를 닫아주어야 한다. 이 역할을 수행하는 것이 loop.close()함수인데, 이는 이벤트 루프에 남아있는 모든 데이터(아직 종료되지 않은 태스크)들을 제거한다.

만약 루프가 닫히는 시점에 여전히 실행이 완료되지 않은 태스크가 남아있다면 "Task was destroyed but it is pending!" 라는 경고 메세지가 출력될 것이다.


다음 게시글에서는 asyncio.create_task() 함수에 대해 작성해보도록 하겠다.

결국 같은 말이 반복되는 것 같은데 아직도 잘 이해가 안간다.. 엄청 많이 읽어보고 체화해야 할 듯..

profile
안녕 세계!

0개의 댓글