파이썬의 비동기 프로그래밍-코루틴

Hyeon Soo·2024년 5월 30일

코루틴을 다루기에 앞서 : 동기와 비동기

흔히 혼용해서 사용하는 개념들이지만, 동기와 비동기, 그리고 동시성과 병렬성은 명확히 구별이 가능한 컨셉입니다. 프로그램의 동작은 실행-처리-결과 반환으로 분류할 때, 동기와 비동기는 실행 순서와 결과의 순서와 관련된 개념이고, 동시성과 병렬성은 둘 모두가 비동기를 구현하기 위한 각각 다른 처리 방식이라고 볼 수 있겠네요.

우선 동기와 비동기부터 설명하면, 두 개념을 가르는 차이는 싱크로의 여부라고 할 수 있습니다. 작업이 실행되는 순서가 존재할 것이고, 실제로 결과가 나오는 순서의 기준에서, 두 순서가 완전히 동일하면 동기, 그렇지 않으면 비동기입니다. 즉, 동기는 먼저 실행한 작업이 먼저 결과를 받고, 그 다음에 실행한 작업이 결과를 받는 방식입니다. 반면 비동기는 작업이 실행된 순서에 관련 없이 결과가 반환되는 것을 의미합니다.

비동기를 구현하는 방법론-동시성과 병렬성

한편, 동시성과 병렬성은 앞서 언급한 실행, 결과와 관련 없이 모두 내부 처리와 관련된 개념이고, 비동기를 구현하는 두 방법론이라고 할 수 있습니다.

우선 병렬성은, 정말로 한 순간에 실제로 여러 작업이 실행되는 것을 의미합니다. 즉, 실행된 여러 작업이 각각 다른 프로세서에 할당되어 처리되고, 알아서 끝나는대로 작업 결과물을 반환하는 것이라고 생각하면 됩니다.

비유하자면, 샌드위치 주문과 샐러드 주문이 들어왔을 때, 2명의 요리사가 존재하여 한 사람은 샌드위치를 만들고, 한 사람은 샐러드를 만들어서 완료되는대로 주는 것이라고 생각하면 됩니다.

반면 동시성은 한 순간에 처리되는 작업은 하나입니다. 병렬성의 예시와 다르게 요리사가 1명뿐인거죠.

그렇다면 동시성은 어떻게 비동기를 구현할까요? 동시성은 한번에 하는 작업은 하나이지만, 여러개의 작업을 순간적으로 바꿔가며 진행하다가 완료되는 순서에 맞추어 반환합니다. 이는 하나의 요리사가 작업을 효율적으로 분할해서, 한 요리 작업이 대기중인동안 다른 작업을 처리하는 방식이죠. 앞서처럼 샌드위치와 샐러드가 주문이 들어왔을 때, 요리사는 우선 빵을 토스터에 넣고, 구워지는 동안 샐러드를 만들어서 내보낸 후, 빵이 다 구워지면 그때 다시 샌드위치를 조립하여 처리하는 방식입니다.

즉, 한 작업의 사이에 필연적으로 존재하기 마련인 대기시간에 다른 작업을 처리하는 방식으로 비동기를 구현하는 것입니다. 특히 프로세서의 집적도와 성능이 크게 향상된 현재는 병렬성은 물론, 동시성을 가지고도 사람의 입장에선 한번에 여러 작업이 실행순서와 상관없이 동시에 처리되는 것처럼, 즉 비동기적으로 처리되는 것처럼 보이죠.

파이썬은 역사가 오래된 언어인만큼, 최초에는 동기적으로 동작함이 기본이었습니다. 하지만 비동기적 처리를 위한 병렬성, 동시성에 대한 수요가 생기면서 이에 대한 지원을 추가했습니다.
비록 GIL의 존재로 인해 일반적인 개념과 다르게 동작하는 부분이 존재하지만, 이 부분은 간략히 넘어가고, 개별 방식들의 특징, 장단점, 쓰임의 예시를 중점으로 다뤄보도록 하겠습니다.

파이썬의 병렬성-멀티 프로세스

앞서 설명에서 본 것처럼, 비동기를 구현하기 위한 가장 쉬운 방법은 프로세스를 여럿 두어 각기 다른 프로세서에 할당하는 것입니다. 파이썬은 multiprocessing 모듈과 subprocess 모듈을 이용하여 다른 프로세스를 생성, 처리를 맡기는 작업이 가능합니다.

이 방식의 가장 큰 장점은 편리함입니다. 올바르게 실행하기만 한다면 실행전에 코드 레벨에서 고려해야할 사항들이 거의 없습니다.

반면 가장 큰 단점은 코스트입니다. 아예 새로운 프로세스를 생성하여 할당하는 것이기 때문에, 다른 방법을 이용한다면 필요하지 않은 추가 메모리와 문맥 전환 비용이 더 큽니다. 이에 더하여, 만약 한 프로세서에서 작업한 내용을 다른 프로세서에서 사용해야하는 경우, 프로세스간 데이터의 교환이 필요하다면 더더욱 코스트가 큽니다.

그렇기 때문에, 멀티 프로세스를 통한 병렬성을 이용해야하는 경우는 두 작업이 연관이 있더라도 최소한의 연관이라 통신도, 한쪽의 결과가 나올 때 까지 대기할 필요도 없는 케이스입니다. 한 작업을 실행했을 때만 실행해야 하는 부수 작업이며, 두 작업이 동기화될 필요가 전혀 없는, 먼저 한쪽이 끝내버려도 상관없는 그런 작업들일 때 병렬성을 기반으로 구성을 하는 것이 바람직합니다.

예를 들자면 특정 상황이 발생했을 때 사용자 알림이 가야하는 등을 들 수 있습니다.

파이썬의 동시성-멀티 스레드

파이썬은 인터프리터의 한계상, 멀티 스레드를 통한 병렬성의 구현은 어렵습니다. GIL의 존재때문에 인터프리터에 의해 메모리로 올라가 있는 바이트 코드는 오직 한 제어 흐름만이 접근 가능하며, 스레드 또한 예외가 아닙니다. 일부 언어들과 멀티 코어 프로세서가 조합되면 멀티 스레드로도 병렬성을 구현할 수 있으나, 파이썬은 해당사항 없다고 생각해도 무방합니다.

다만 병렬성은 힘들지언정 일부 사레들에 한하여 동시성은 스레드를 통해서 구현할 수 있습니다. I/O 위주의 작업 상당수는 자체적으로 락을 반환하기 때문에 해당 작업을 멀티 스레드로 구현하면, I/O를 대기하는 동안 작업을 수행할 수 있습니다.

코루틴의 등장

앞에서 살펴본 멀티 프로세스와 멀티 스레드를 이용한 비동기 처리방식은 크건 작건 문맥 교환 작업이 동반되기 때문에 오버헤드로 인한 시간 소요가 있을 수 밖에 없었습니다. 특히나 동시성을 구현하는 코드인 경우 많은 작업을 순간적으로 바꿔가며 작업이 이루어지기 때문에, 작업이 늘어나면 늘어날수록 비용이 커지는 아쉬움이 있습니다.

코루틴의 개념은 이에 대응하기 위한 해결책으로서 등장하였습니다. 코루틴은 '시작'과 '종료 후 반환' 두 상태만 존재하는 다른 함수들과 달리, '정지' 및 '재개'상태가 기본적으로 가능한 특수 함수라고 할 수 있으며, 다른 표현으로 경량 스레드(light weight thread)라고도 합니다.

경량 스레드라는 다른 표현에서 암시하고 있듯이, 오직 싱글 스레드 기반으로 동작하기 때문에 병렬처리는 불가능하지만, 하드웨어 수준의 문맥 교환 비용이 아예 들지 않는다는 특징이 있는, 동시성에 최적화된 방식입니다.

코루틴의 동작과 두 구현방식: Stack-full & Stack-less

코루틴은 어떤 구현 방식이라기 보다는 개념에 가깝기에, 개별 언어들이 이를 구현하는 방식은 천차만별입니다. 기본적으로 실행중에 특정 부분에서 '정지'하고, 다시 그 부분부터 '재개'할 수 있도록 하면 구현이야 어쨌든 모두 코루틴이라고 할 수 있습니다.

코루틴들은 마치 프로세스와 스레드가 운영체제에 의해 실행되었다가 해제되었다가 다시 적재되는 것처럼, 한 스레드 안에서 코루틴 함수가 정지되면 다른 코루틴, 내지는 흐름으로 넘어가서 실행하다가, 필요시에 다시 코루틴을 재개하여 작업을 처리하는 방식으로 동작합니다. 즉, 하나의 스레드에서 작업이 비동기적으로, 동시성 방식에 의해 처리되는 것입니다. 이 방식은 하나의 스레드 안에서만 돌아가기 때문에 동시성을 구현하면서도 운영체제 수준의 문맥 교환은 아예 이루어지지 않기 때문에 동시성에 따라오던 비용을 거의 신경쓰지 않아도 되게 되었습니다.

다만, 구현 방식이 가지는 특징에 따라 stack-full 코루틴인지, stack-less 코루틴인지 구별할 수 있습니다.

우선 Stack-full 코루틴은 코루틴 함수가 자체적으로 스택을 형성하도록 구현한 방식입니다. 코루틴이 호출되면 그 자체로 메모리의 stack 영역에 할당되어 동작하는데, '정지'상태가 되면 stack에 존재하는 모든 데이터 및 상태를 heap영역에 적재한 후 해제됩니다. '재개'상태가 되면 heap에 저장했던 stack의 정보를 기반으로 다시 stack에 코루틴 함수를 적재하고 이전의 정지 상태에서부터 실행합니다.

이 방식의 가장 큰 장점은 코루틴 자체적으로 stack에 할당되기 때문에, 코루틴에서 또 다른 코루틴들을 호출하고 동시성 제어가 가능하다는 점입니다. 메인 함수에 의해 코루틴으로 호출되어 정지, 재개가 관리되는 한편, 코루틴의 내부에서 또 다른 동시성 처리를 주도적으로 처리할 수 있습니다. 다만, 코루틴이 stack하나를 온전히 형성하고, 이 모든 내용을 개별적으로 heap에 할당해야하기 때문에, 더 많은 데이터를 다루어야하고 전환 비용 또한 stack-less 방식보다 큽니다.

한편, Stack-less 코루틴은 코루틴으로 선언된 코드가 실행될 때, 별도의 stack을 형성하지 않도록 구현한 것입니다. 이 경우 일반적으로 코루틴은 객체로 선언되어 호출했던 함수의 stack에 종속된 상태가 되고, 개별 객체들을 바꿔가며 실행하는 방식으로 동시성을 구현한 것입니다.

이 방식의 장단점은 stack-full한 방식과 정 반대라고 생각하면 됩니다. 코루틴은 최초의 함수에 종속되었다고 볼 수 있기 때문에, 코루틴의 내부에서 새로운 코루틴 루프를 형성할 수 없습니다. 대신에 구현 방식과 다루기가 쉽고, 비용도 더 가볍습니다.

파이썬의 코루틴

파이썬은 코루틴을 generator 기반으로 구현하고 있습니다. generator object는 내부 코드를 실행하다가 yield 에서 멈추고 값을 반환한 뒤, next() 메서드로 다시 호출하면 다시 yield를 발견하거나 종료될 때 까지 진행합니다.

즉, generator를 호출한 함수에서 generator 객체를 실행했다가 다른 코드를 실행했다가 다시 재개하는 동작이 가능하며, 코루틴의 요구사항과 일치합니다. 코루틴을 쉽게 사용하기 위한 asyncio 라이브러리도 내부 구현은 이렇게 되어 있습니다. 외부 인터페이스는 사용하기 쉽게 async, await으로 구성되있을지라도요.

코루틴은 동시성의 구현을 위해 존재하는 개념이기 때문에, 동시성을 적용해야할 필요가 있는 작업에 이용할 수 있습니다. 웹 서버의 API 엔드포인트들에 적용하면, 개별 api requests들을 처리할 때 대기 시간이 있는 경우는 그 상태에서 멈추고 다른 요청을 처리하다가, 다시 돌아와서 처리하는 방식으로 응답 효율성을 크게 높일 수 있고, 그 외의 I/O bound가 여럿 존재하는 기능에서 적용하여도 좋습니다.

하지만 코루틴은 어디까지나 동시성을 구현했을뿐, 병렬성은 구현이 불가능합니다. 코루틴을 적용한 기능은 코루틴의 동작이 모두 끝나야만 종료가 되기 때문에, 의존없이 평행하게 던져두어야할 작업은 코루틴으로 구현할 수 없습니다. 또한, 코루틴을 통한 동시성을 구현하고 싶다면 반드시 코루틴 내부의 작업은 non-blocking 기반으로 동작해야하기 때문에, 적절하게 사용하기 위해서는 이해해야할 내용이 많습니다.

예를 들어, mysql 데이터베이스와의 커넥션을 연결해주는 pymysql 라이브러리와 mysqldb 기반 mysqlclient를 비교해볼 수 있습니다. 두 라이브러리는 거의 동일한 인터페이스를 제공하고, db와의 커넥션을 이용해야하는 작업은 대개 대표적안 I/O bound 작업이라 할 수 있습니다.

https://blog.naver.com/parkjy76/222135119765

하지만 위의 글에서 살펴볼 수 있는 것처럼, 두 라이브러리를 이용한 작업을 asyncio 라이브러리를 이용하여 비동기적으로 처리하고자 하면, pymysql은 의도한대로 비동기적으로 동작하지만 mysqlclient 기반의 코드는 동기적으로 작동합니다. 후자의 코드는 C기반으로 작성된 부분이 있는데, 비동기 처리를 위한 작업을 따로 해두지 않았기 때문에 반드시 blocking 방식으로만 동작하여 동기적으로 작동한 것입니다.

0개의 댓글