[Python]파이썬 비동기 프로그래밍 동작 원리에 대해서 (feat. 이벤트 루프)
SW개발/Python

[Python]파이썬 비동기 프로그래밍 동작 원리에 대해서 (feat. 이벤트 루프)

안녕하세요, 오늘은 파이썬 비동기 프로그래밍 동작 원리에 대해서 알아보려고 합니다.

 

파이썬의 비동기는 이벤트 루프를 통해 동작하고 있다는 정도의 이해만 한 채로 개발을 하다 보니 문득 내부 동작은 어떻게 이루어지는지가 궁금하여 공부해보게 되었습니다.

 

또한 애초에 파이썬은 동기 방식으로 동작하도록 설계되었기 때문에 어떻게 비동기 프로그래밍을 지원하는지도 궁금했습니다. (Python은 3.4부터 asyncio가 표준 비동기 라이브러리로채택되었습니다.)

 

코루틴이란? (coroutine)

파이썬의 비동기 프로그래밍을 이해하기 위해서는, 먼저 코루틴에 대한 이해가 수반되어야 합니다.

이 글에서는 간단하게 설명하고 넘어가도록 하고, 추후 다른 포스팅에서 코루틴에대해 상세히 다뤄보도록 하겠습니다.

 

간단하게 설명하자면 코루틴은 비동기 함수입니다. 메인 루틴에서 코루틴을 호출하면 메인 루틴의 실행은 잠시 멈춘 뒤 코루틴의 코드를 실행하게 됩니다. 실행이 끝나면 다시 메인 루틴의 코드 실행으로 돌아옵니다.

 

즉, 코루틴은 장기 실행 작업이 있을 때 일시 중지하고 해당 작업이 완료되면 다시 시작할 수 있는 방법입니다.

 

참조: https://dojang.io/mod/page/view.php?id=2418

 

 

퓨쳐 객체란? (Future Object)

코루틴에 이어 퓨쳐 객체에 대한 이해도 필요합니다.

퓨쳐 객체는 비동기 연산의 최종 결과를 나타내는 특별한 저수준의 awaitable 객체입니다. 실행 결과에 따라 퓨쳐 객체는 PENDING, CANCELLED, FINISHED 의 3가지 상태중 하나를 가지게 됩니다.

 

Future 객체 상태 소스코드

 

퓨쳐 객체가 가지고 있는 메소드중 중요하게 봐야할 것은 add_done_callback() 입니다. 해당 메소드는 퓨쳐 객체가 완료될 때 실행할 콜백을 추가하는 역할을 합니다. 이 방법은 추후 이벤트 루프 동작 원리에서 중요하게 이용됩니다.

 

태스크 객체란? (Task Object)

태스크 객체는 코루틴을 실행하는 퓨쳐류 객체입니다. 퓨쳐 클래스를 상속받아 만들어진 클래스입니다.

 

Task 클래스 소스코드

 

따라서 태스크는 기본적으로 비동기 연산의 결과를 저장하는 퓨쳐의 기능도 가지고 있지만, 비동기 연산의 실행을 시작할 수 있는 기능도 가지고 있습니다. 이러한 실행을 위해 필요한 것이 코루틴 객체입니다.

 

실제로 태스크는 생성될 때 코루틴 객체를 넘겨받아 _coro 필드에 저장합니다. 그리고 태스크는 생성되는 즉시 현재 스레드의 이벤트 루프에게 자신의 __step() 메소드를 호출해줄 것을 요청합니다.

 

__step() 소스코드

 

태스크의 __step() 메서드는 위에서 넘겨받은 코루틴 객체를 이용해 해당 코루틴을 실행합니다. __step() 메서드를 호출한다는 뜻은 코루틴이 태스크로서 실행되도록 이벤트 루프에 예약하는 행위입니다. 

 

이벤트 루프란? (Event loop)

이벤트 루프는 간단하게 말하면 루프를 돌면서 태스크를 실행시키는 것을 말합니다. 즉, 태스크의 코루틴 체인들이 모두 실행될 때까지 반복하는 것이라고 볼 수 있습니다.

 

이벤트 루프의 동작 원리

그렇다면, 실제로 이벤트 루프의 실행 흐름과 동작 원리에 대해서 알아보겠습니다.

 

1. 이벤트 루프의 생성 (첫 코루틴 진입점)

먼저 코루틴의 첫 진입점을 위해서 asyncio.run() 메서드의 실행이 필요합니다.

asyncio.run() 메서드는 항상 새로운 이벤트 루프를 생성하고 실행이 완료되면 이벤트 루프를 닫습니다. 비동기 프로그램의 주요 진입점이고, 이상적으로는 한번만 호출되어야 합니다.

 

asyncio.run() 소스코드

 

asyncio.run() 을 통해 새로운 이벤트 루프를 만들고 코루틴의 첫 진입점을 만드는 것으로부터 동작이 시작됩니다.

 

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

인자로 넘어오는 main 코루틴 객체(첫 진입 코루틴)를 이용해서 태스크 객체를 생성합니다. 이 때 해당 태스크의 실행이 이벤트 루프에 의해서 즉시 예약됩니다. 처음에는 예약된 태스크가 없기에 생성된 태스크 객체가 바로 실행됩니다.

 

실행은 태스트 객체의 __step() 메서드 호출을 의미하고 __step() 메서드가 코루틴(main 코루틴)의 send() 메서드를 호출하면서 코루틴을 실행하게 됩니다.

 

이 실행을 시작으로 await 키워드를 만날 때마다 코루틴이 계속 실행되면서 코루틴 체인이 형성됩니다.

 

3. 코루틴 체인의 종착점

코루틴 체인을 형성하면서 코루틴을 실행하다보면 언젠가 Sleep 혹은 I/O 관련 코루틴을 await 하는 코드를 마주치게 될수도 있습니다.

 

I/O 코루틴을 만나는 경우에는 지금 실행하고 있는 태스크 객체의 실행을 중지하고 이벤트 루프에게 자신의 태스크의 실행을 예약한 뒤 이벤트루프에게 제어권을 넘깁니다. Sleep 코루틴을 만나는 경우에도 동일합니다.

 

이 때 I/O 작업이 바로 수행가능한 것이 아니라면 select() 메서드를 이용해 해당 소켓을 등록하고, 소켓에 바인딩 된 퓨처 객체를 새로 생성하고 await 합니다. 시간이 흘러서 작업이 가능한 상태가 되면 다시 중단된 태스크가 실행됩니다.

 

4. 태스크 객체의 퓨쳐 객체 처리

태스크 객체가 3번에서 생성된 퓨쳐 객체를 받게되면 자신의 _fut_waiter 필드에 저장합니다. 그리고 퓨쳐 객체의 add_done_callback() 메서드를 호출하여, 해당 퓨쳐 객체가 완료될 때 콜백 함수를 등록합니다. (3번에서 멈춘 태스크의 실행을 등록)

 

이후에 실행 중인 태스크 객체의 실행을 중지하고 이벤트 루프에게 제어권을 넘깁니다. 이벤트 루프는 나머지 예약된 태스크들 중 우선순위가 높은 것을 선택하여 이를 실행시킵니다. 이렇게 이벤트 루프는 Concurrent 하게 동작합니다.

 

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

만약 더이상 예약된 태스크가 없다면 이벤트 루프는 select() 메서드(Unix의 select() 메서드를 매핑한 것)를 이용하여 I/O 작업이 완료가 가능한 소켓을 찾습니다.

 

가능한 소켓이 있다면 3번에서 생성된 퓨쳐 객체의 결과 값을 업데이트 합니다. 그러면 이 순간에 4번에서 add_done_callback() 메서드로 등록했던 콜백 함수의 실행을 이벤트 루프에 예약하게 됩니다.

 

6. 태스크 객체의 실행 재개

태스크의 실행은 __step() 메서드의 호출을 의미합니다. __step() 메서드는 태스크 객체인 자기 자신과 퓨쳐 객체의 바인딩을 해제하면서, 더 이상 기다리는 퓨쳐 객체가 없도록 합니다. 그리고 자신의 코루틴 객체의 send() 메서드를 호출하면서 코루틴의 실행을 재개 합니다.

 

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

이러한 과정들을 반복하면서 모든 코루틴 체인의 실행이 끝나면 최초 코루틴이 return 하는 시점에 도달하게 됩니다. 

이 시점에서 __step() 메서드는 StopIteration 예외를 발생시키고, 그 객체의 value를 결과 값으로 업데이트 하면서 태스크의 실행이 종료됩니다.

 

8. 이벤트 루프 Close

7번에서의 실행이 끝나면 이벤트 루프가 더는 실행되지 않아 이벤트 루프를 닫아주어야 합니다.

 

loop.close() 메서드를 통해 이벤트 루프를 닫을 수 있고 이벤트 루프에 남아있는 모든 데이터를 제거합니다. (만약 완료되지 않은 태스크가 있다면 "Task was destroyed but it is pending!" 메시지가 출력됩니다)

 

동작 원리 정리

위의 과정들을 다이어그램으로 나타내면 다음과 같습니다.

참조 : https://it-eldorado.tistory.com/159#recentEntries

 

 

태스크의 동시 실행

지금까지 설명한 시나리오에서, asyncio.run() 메서드의 경우 기본적으로는 1개의 태스크만을 생성하여 실행합니다. 따라서 코루틴 체인에서 추가적인 태스크를 생성하지 않았다면 1개의 태스크만 실행되는 것입니다.

 

ayncio.create_task() 메서드를 호출하면 코루틴 객체들을 인자로 받아 여러개의 태스크 객체를 만들어 반환해줍니다. 그리고 해당 태스크들이 이벤트 루프에 예약 됩니다.

 

asyncio.create_task() 소스코드

 

이 태스크들이 모두 완료상태가 될 때까지 기다리는 메서드는 asyncio.gather() 입니다. 그리고 반환 값으로는 실행한 결과들을 돌려줍니다. 위 두개의 메서드를 이용해 태스크를 동시 실행할 수 있습니다.

 

파이썬의 동기함수를 비동기로?

글의 도입부분에서 언급했듯이 파이썬의 대부분의 API는 동기 방식으로 동작합니다. requests와 같은 라이브러리도 전형적으로 동기로 작동하는 API 입니다.

 

그렇다면 이러한 API는 비동기로 바꾸거나 사용할 수 없을까요?

위에서 공부한 바에 따르면 코루틴처럼 requests.get(), requests.post() 와 같은 작업들의 실행을 다른곳에 맡겨 두고 await 하면 되지 않을까? 하는 추측을 해볼 수 있습니다.

 

이러한 상황에서는 loop.run_in_executor() 메서드의 도움을 받을 수 있습니다.

 

run_in_executor() 소스코드

 

run_in_executor() 메서드는 완전하게 코루틴을 만들어 주지는 않지만, 별도의 쓰레드 풀(혹은 포로세스 풀)을 이용함으로써 비동기처럼 동작하게 할 수 있습니다.  즉, 해당 작업을 별도의 쓰레드 풀에서 실행하는 원리입니다.

 


지금까지 파이썬의 이벤트 루프 동작 원리에 대해서 알아보았습니다. 지금까지 고수준의 레벨에서만 비동기 프로그래밍을 해오곤 했는데 어떻게 동작하지? 라는 단순한 질문에도 답을 하지 못해서 동작 원리를 찾아보게 되었습니다.

 

비동기 프로그래밍 자체가 어려운 부분들이라 공식 문서, 많은 자료 그리고 실제 코드를 따라가 보는 것을 반복하면서 조금씩 이해도를 높일 수 있었습니다.

 

만약 잘못된 정보를 발견하시거나 궁금한 부분이 있다면 댓글 남겨주시면 감사하겠습니다!

 

 

참조 자료 

공부하는데 많은 도움이 된 자료들을 소개합니다 :) 

https://it-eldorado.tistory.com/159

 

[Python] 비동기 프로그래밍 동작 원리 (asyncio)

JavaScript와 달리 Python은 비동기 프로그래밍에 어색하다. 애초에 JavaScript는 비동기 방식으로 동작하도록 설계된 언어인 반면, Python은 동기 방식으로 동작하도록 설계된 언어이기 때문이다. 그래

it-eldorado.tistory.com

https://docs.python.org/ko/3/library/asyncio-task.html#coroutines-and-tasks

 

Coroutines and Tasks

This section outlines high-level asyncio APIs to work with coroutines and Tasks. Coroutines, Awaitables, Creating Tasks, Task Cancellation, Task Groups, Sleeping, Running Tasks Concurrently, Eager ...

docs.python.org

https://docs.python.org/ko/3/library/asyncio.html

 

asyncio — Asynchronous I/O

Hello World!: asyncio is a library to write concurrent code using the async/await syntax. asyncio is used as a foundation for multiple Python asynchronous frameworks that provide high-performance n...

docs.python.org

 

728x90