PEP 3153 – 비동기 IO 지원
- Author:
- Laurens Van Houtven <_ at lvh.cc>
- Status:
- Superseded
- Type:
- Standards Track
- Created:
- 29-May-2011
- Post-History:
- Superseded-By:
- 3156
Table of Contents
번역·라이선스 안내
이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판
초록
이 PEP는 Python 표준 라이브러리를 위한 비동기 IO 추상화를 설명합니다.
목표는 다양한 비동기 IO 백엔드가 구현할 수 있고, 라이브러리 개발자가 그 여러 백엔드 사이에서 이식 가능한 코드를 작성할 수 있도록 기준을 제공하는 추상화에 도달하는 것입니다.
근거
현재 Python으로 비동기 코드를 작성하려는 사람들에게는 몇 가지 선택지가 있습니다:
안타깝게도 이 선택지들에는 각각 단점이 있으며, 이 PEP는 그 단점들을 해결하고자 합니다.
asyncore 모듈은 오랫동안 Python 표준 라이브러리의 일부였음에도 불구하고, 현대적인 비동기 네트워킹 모듈에 대한 기대를 충족하지 못하는 경직된 API에서 비롯된 근본적인 결함을 안고 있습니다.
게다가 그 접근 방식은 너무 단순해서, 개발자가 비동기 네트워킹의 잠재력을 온전히 활용하는 데 필요한 모든 도구를 제공하지 못합니다.
현재 프로덕션 환경에서 가장 널리 쓰이는 해결책은 서드파티 라이브러리를 사용하는 것입니다. 이러한 라이브러리는 대체로 만족스러운 해결책을 제공하지만, 라이브러리 간 호환성이 부족하여 코드베이스가 사용하는 라이브러리에 매우 강하게 결합되는 경향이 있습니다.
현재 여러 비동기 IO 라이브러리 간의 이식성 부족으로 인해 서드파티 라이브러리 개발자들이 많은 노력을 중복해서 들이고 있습니다. 충분히 강력한 추상화가 있다면 비동기 코드를 한 번만 작성하고 어디서나 사용할 수 있을 것입니다.
궁극적으로 추가할 목표는, 표준 라이브러리에 있는 와이어 프로토콜 및 네트워크 프로토콜 구현이 recv()를 블로킹 방식으로 호출하는 것까지 모든 일을 스스로 처리하는 독립형 라이브러리에서 벗어나, 진정한 프로토콜 구현으로 발전하는 것입니다. 이렇게 되면 이 구현들을 동기 코드와 비동기 코드 모두에서 쉽게 재사용할 수 있습니다.
통신 추상화
전송
전송은 다양한 종류의 연결에서 바이트를 읽고 쓰기 위한 통일된 API를 제공합니다. 이 PEP에서 전송은 항상 순서가 보장되고, 신뢰할 수 있으며, 양방향이고, 스트림 지향적인 두 종단점 간의 연결입니다. TCP 소켓, SSL 연결, 파이프(이름 있는 파이프든 아니든), 시리얼 포트 등이 이에 해당할 수 있습니다. 전송은 POSIX 플랫폼의 파일 디스크립터나 Windows의 Handle, 또는 특정 플랫폼에 적합한 다른 자료 구조를 추상화할 수 있습니다. 전송은 해당 플랫폼의 자료 구조를 사용하는 데 필요한 모든 구현 세부 사항을 캡슐화하고, 애플리케이션 개발자에게 통일된 인터페이스를 제공합니다.
전송은 두 대상과 통신합니다. 한쪽은 연결의 반대편이고, 다른 한쪽은 프로토콜입니다. 트랜스포트는 구체적인 하위 전송 메커니즘과 프로토콜 사이의 다리 역할을 합니다. 트랜스포트의 역할은 프로토콜이 그저 바이트를 보내고 받기만 하면 되도록 하면서, 그 바이트가 최종적으로 회선을 통해 전송되기까지 일어나야 하는 모든 복잡한 처리를 도맡는 것이라고 설명할 수 있습니다.
전송의 주된 기능은 프로토콜로 바이트를 보내고 하위 프로토콜로부터 바이트를 받는 것입니다. 전송에 쓰기는 write와 write_sequence 메서드를 사용하여 수행합니다. 후자의 메서드는 성능 최적화를 위한 것으로, 소프트웨어가 일부 전송 메커니즘의 특정 기능을 활용할 수 있게 합니다. 구체적으로, 이를 통해 전송은 write 나 send 대신 scatter/gather IO라고도 하는 writev 를 사용할 수 있습니다.
전송은 일시 중지했다가 재개할 수 있습니다. 일시 중지된 트랜스포트는 프로토콜에서 오는 데이터를 버퍼링하고, 수신한 데이터를 프로토콜로 보내는 작업을 중지합니다.
전송은 닫거나, 반쯤 닫거나, 중단할 수도 있습니다. 닫힌 전송은 자신에게 큐로 쌓인 모든 데이터를 하위 메커니즘에 다 쓴 뒤, 데이터 읽기와 쓰기를 멈춥니다. 전송을 중단하면 전송이 멈추며, 아직 큐에 남아 있는 데이터를 보내지 않고 연결을 닫습니다.
그 이후에 쓰기를 시도하면 예외가 발생합니다. 반쯤 닫힌 전송에는 더 이상 쓸 수 없지만, 들어오는 데이터는 계속 받아들입니다.
프로토콜
새로운 사용자에게는 아마 프로토콜이 더 익숙할 것입니다. 이 용어는 프로토콜이라는 이름에서 기대할 수 있는 의미와 일치합니다. HTTP, IRC, SMTP 등 대부분의 사람이 가장 먼저 떠올리는 프로토콜은 모두 프로토콜로 구현될 만한 것의 예입니다.
프로토콜을 가장 짧으면서도 유용하게 정의하자면, 전송과 나머지 애플리케이션 로직 사이의 (보통 양방향) 다리라고 할 수 있습니다. 프로토콜은 전송으로부터 바이트를 받아 그 정보를 어떤 동작으로 변환하며, 이는 일반적으로 객체에 대한 몇몇 메서드 호출로 이어집니다. 마찬가지로, 애플리케이션 로직은 프로토콜의 몇몇 메서드를 호출하고, 프로토콜은 이를 바이트로 변환하여 전송에 전달합니다.
가장 간단한 프로토콜 중 하나는 데이터가 \r\n으로 구분되는 줄 기반 프로토콜입니다. 프로토콜은 전송으로부터 바이트를 받아 완전한 줄이 적어도 하나 생길 때까지 버퍼에 저장합니다. 그렇게 되면 이 줄을 어떤 객체에 넘겨줍니다. 이상적으로는 호출 가능 객체를 사용하거나, 프로토콜이 합성한 완전히 별개의 객체를 사용하여 이를 처리하겠지만, (Twisted의 LineReceiver처럼) 서브클래싱으로 구현할 수도 있습니다. 반대 방향의 경우, 프로토콜에 write_line 메서드를 둘 수 있으며, 이 메서드는 필요한 \r\n을 덧붙이고 새 바이트 버퍼를 전송에 넘깁니다.
이 PEP는 LineReceiver를 일반화한 ChunkProtocol을 제안합니다. 여기서 “청크”는 스트림 안에서 지정된 구분자로 구분되는 메시지입니다. 인스턴스는 구분자와, 데이터 청크를 받았을 때 그 청크를 인자로 하여 호출될 호출 가능 객체를 받습니다(Twisted의 서브클래싱 방식과는 다릅니다). ChunkProtocol에는 앞서 설명한 write_line 메서드와 비슷한 write_chunk 메서드도 있습니다.
왜 프로토콜과 전송을 분리합니까?
프로토콜과 전송의 이러한 분리는 처음 접하는 사람들을 종종 혼란스럽게 합니다. 사실 표준 라이브러리조차 많은 경우에 이를 구분하지 않으며, 특히 사용자에게 제공하는 API에서는 구분하지 않습니다.
그럼에도 이는 매우 유용한 구분입니다. 최악의 경우라도 관심사를 명확하게 분리하여 구현을 단순하게 만들어 줍니다. 하지만 이 분리는 서로 다른 전송에서 프로토콜을 재사용할 수 있게 한다는, 훨씬 더 유용한 목적을 달성하는 경우가 많습니다.
간단한 RPC 프로토콜을 생각해 봅시다. 같은 바이트가 파이프나 소켓 같은 여러 트랜스포트를 통해 전송될 수 있습니다. 이를 돕기 위해, 우리는 프로토콜을 전송에서 분리합니다. 프로토콜은 바이트를 읽고 쓸 뿐이며, 그 바이트가 최종적으로 어떤 메커니즘으로 전송되는지는 사실상 신경 쓰지 않습니다.
이렇게 하면 프로토콜을 쉽게 쌓거나 중첩할 수 있으므로 코드를 훨씬 더 많이 재사용할 수 있습니다. 흔한 예로 JSON-RPC가 있습니다. 명세에 따르면 JSON-RPC는 소켓과 HTTP 모두에서 사용할 수 있습니다 [1]. 실제로는 주로 HTTP에 캡슐화되어 사용되는 경향이 있습니다. 프로토콜-전송 추상화를 통해 HTTP를 마치 전송처럼 사용할 수 있게 해 주는 프로토콜과 전송의 스택을 구성할 수 있습니다. JSON-RPC의 경우 대략 다음과 같은 스택을 구성할 수 있습니다.
- TCP 소켓 전송
- HTTP 프로토콜
- HTTP 기반 전송
- JSON-RPC 프로토콜
- 애플리케이션 코드
흐름 제어
소비자
소비자는 생산자가 생산한 바이트를 소비합니다. 소비자는 생산자와 함께 흐름 제어를 가능하게 합니다.
소비자는 흐름 제어에서 주로 수동적인 역할을 합니다. 소비자는 생산자에게 사용 가능한 데이터가 있을 때마다 호출됩니다. 그런 다음 소비자는 그 데이터를 처리하고, 일반적으로 제어권을 생산자에게 다시 넘깁니다.
소비자는 일반적으로 어떤 형태로든 버퍼를 구현합니다. 소비자는 그 버퍼의 현재 상태를 생산자에게 알려 흐름 제어를 가능하게 합니다. 소비자는 생산자에게 생산을 완전히 중단하거나, 일시적으로 중단하거나, 이전에 일시 중지하라는 지시를 받았다면 생산을 재개하도록 지시할 수 있습니다.
생산자는 register 메서드를 사용하여 소비자에 등록됩니다.
생산자
소비자가 바이트를 소비한다면, 생산자는 바이트를 생산합니다.
생산자는 Twisted의 IPushProducer 인터페이스를 본떠 만들어졌습니다. IPullProducer도 있지만, 전반적으로 훨씬 덜 흥미로우므로 아마 이 PEP의 범위를 벗어날 것입니다.
생산자에게 생산을 완전히 중단하라고 지시할 수도 있지만, 생산자가 가진 가장 흥미로운 두 메서드는 pause와 resume입니다. 이 메서드들은 보통 소비자가 호출하며, 더 많은 데이터를 처리(“소비”)할 준비가 되었는지 여부를 알리는 데 쓰입니다. 소비자와 생산자는 협력하여 흐름 제어를 가능하게 합니다.
Twisted IPushProducer 인터페이스에 더해, 생산자에는 half_register 메서드가 있습니다. 이 메서드는 소비자가 해당 생산자를 등록하려 할 때 그 소비자를 인자로 하여 호출됩니다. 대부분의 경우 이는 단지 self.consumer = consumer를 설정하는 것에 불과하지만, 어떤 생산자는 소비자가 등록될 때 더 복잡한 사전 조건이나 동작을 요구할 수 있습니다. 최종 사용자는 이 메서드를 직접 호출해서는 안 됩니다.
검토한 API 대안
생산자로서의 제너레이터
제너레이터를 생산자 구현 방법으로 쓰자는 제안이 있었습니다. 하지만 여기에는 몇 가지 문제가 있는 것으로 보입니다.
우선, 개념적인 문제가 있습니다. 제너레이터는 어떤 의미에서 “수동적”입니다. 제너레이터는 메서드 호출을 통해 동작하라는 지시를 받아야 합니다. 생산자는 “능동적”입니다. 즉, 생산자가 그러한 메서드 호출을 시작합니다. 실제 생산자는 소비자와 대칭적인 관계를 맺습니다. 제너레이터를 생산자로 바꾼 경우에는 소비자만 참조를 가지게 되며, 생산자는 소비자가 있다는 사실을 전혀 알지 못합니다.
이 개념적인 문제는 몇 가지 기술적인 문제로도 이어집니다. 소비자에 대한 write 메서드 호출이 성공하고 나면, (푸시) 생산자는 다시 자유롭게 동작할 수 있습니다. 제너레이터의 경우에는 이터레이션 프로토콜을 통해 다음 객체를 요청하거나(이 과정은 무한정 블록될 수 있습니다), 아니면 제너레이터 안으로 일종의 신호 예외를 던지는 방식으로 지시를 받아야 할 것입니다.
이러한 신호 방식은 기술적으로 실현 가능한 해결책이 될 수는 있지만, 여전히 만족스럽지 않습니다. 우선, 이 방식은 소비자에게 불필요한 복잡성을 더합니다. 이제 소비자는 데이터를 받아 처리하는 방법뿐만 아니라, 새 데이터를 요청하는 방법과 사용할 수 있는 새 데이터가 없는 경우를 처리하는 방법까지 알아야 합니다.
새 데이터가 없는 이 경우가 특히 문제가 됩니다. 전체 작업이 블록되어서는 안 되므로, 이 경우를 반드시 처리해야 합니다. 하지만 제너레이터는 종료하지 않고서는 이터레이션 중에 예외를 발생시킬 수 없으며, 예외를 발생시키면 종료되면서 제너레이터의 상태를 잃게 됩니다. 결과적으로, 사용 가능한 데이터가 없음을 알리는 일은 예외 메커니즘 대신 센티널 값을 사용하여 처리해야 합니다.
마지막으로 중요한 점은, 제너레이터를 어떻게 사용할 수 있는지 보여 주는 실제로 동작하는 코드를 아무도 내놓지 않았다는 것입니다.
참고 문헌
Copyright
This document has been placed in the public domain.