async with : 비동기 컨텍스트 매니저

파이썬의 컨텍스트 매니저는 with 블럭을 적용할 수 있는 객체를 말한다. with 문에서 쓰이는 컨텍스트 매니저 객체는 코드 블럭에 대한 데코레이터처럼 동작한다. 가장 흔한 예가 open() 함수로 생성하는 파일 디스크립터이다.

with open('data.txt') as f:
  for line in f:
    print(line)

컨텍스트 매니저 객체는 __enter__(), __exit__() 두 개의 내장 메소드를 가지고 있는 것으로 간주된다. 위 코드에서는 with 다음에 나오는 open('data.txt') 라는 코드는 파일 디스크립터를 반환한다. 그런데, 파일 디스크립터는 그 자체로 이미 컨텍스트 매니저이다. 따라서 생성된 파일 디스크립터에 대해서 __enter__() 가 호출되고 (아마 자기 자신을 리턴할 것으로 예상된다.) 그 결과가 f에 바인딩된다.

이후 with문에 속하는 코드 블럭이 실행되다가, 블럭의 끝에 다다르면 with 블럭을 탈출하게 되는데, 이 시점에 f.__exit__()가 호출된다. 파일디스크립터는 이 구문내에서 자기 자신을 닫도록 하는 동작이 정의되어 있다. 이 때 with문을 탈출하는 경우는, 블럭의 코드 실행을 완료하는 경우 외에도, 함수 내부에서 실행중인 with 구문 중간에 return을 만나서 중간에 빠져가나거나, 혹은 예외가 발생하여 with 문 중간에서 프로그램의 실행이 중단되는 경우등이 있을 수 있다. 이런 모든 경우가 with문을 빠져나가는 것으로 간주되기 때문에, with 문을 쓰는 경우에 파일은 어떤 경우든 안전하게 닫히는 것이 보장된다.

async with

파이썬 3.5에서부터 비동기 코루틴을 사용하는 문법이 async def 로 추가되는 등, non-block I/O에 관련된 기능들이 적극적으로 언어차원에서 도입되고 있다. 이 중 async with 문은 이른바 비동기 컨텍스트 매니저로 소개되는데, 이는 비동기 코루틴 내에서 사용되는 것을 상정한다.

  • 비동기 코루틴내에서 with 문이 사용되는 경우, __enter__()의 호출이 I/O와 관련있는 전처리 작업을 수반하는 경우에, 기존의 문법으로는 이는 바운드 메소드이므로 이 지점에서 병목이 발생할 수 있다.
  • with 문을 빠져나올 때 실행되는 __exit__()의 경우에도 동일하게 I/O 관련 작업인 경우 병목이 발생할 수 있다.

비동기 코루틴은 I/O와 관련하여 CPU가 입출력의 완료를 기다리는 동안 다른 코루틴 작업으로 전환하여 전체 수행 시간을 단축하는 것을 목표로 하고 있기 때문에 이러한 부분들에서도 비동기적인 처리가 필요해진다. 이에 비동기 컨텍스트 매니저가 추가되었다.

비동기 컨텍스트 매니저는 아직 contextlib에는 추가되지 않은 듯 하고, 다음과 같이 클래스를 만들어서 사용할 수 있다. 극적(?)인 효과를 위해서 주어진 메시지를 로깅하는 코루틴을 하나 작성하고 시작하자.

async def log(msg, l=10, f='.'):
  for i in range(l*2+1):
    if i == l:
      for c in msg:
        sys.stdout.write(c)
        sys.stdout.flush()
        await asyncio.sleep(0.05)
    else:
      sys.stdout.write(f)
      sys.stdout.flush()
    await asyncio.sleep(0.2)
  sys.stdout.write('\n')
  sys.stdout.flush()

위 코루틴은 메시지를 넣어주면 지정된 길이만큼의 장식 문자를 앞뒤로 출력한다. 이 때 한글자 한글자씩 출력하면서 약간의 딜레이를 주기 때문에 한글자씩 화면에 찍히는 것을 눈으로 따라갈 수 있을 수준으로 표현한다.

비동기 컨텍스트 매니저

이제 비동기 컨텍스트 매니저를 하나 만들어보자. 비동기 컨텍스트 매니저는 __enter__(), __exit__() 대신에 “a”가 붙은 __aenter__(), __aexit__()를 구현해야 한다. 이 둘은 모두 async 키워드를 써서 비동기 코루틴으로 작성되어야 함에 유의하자. 여기서 작성하고자 하는 컨텍스트 매니저 클래스는 별로 하는 일은 없고 그저 with 블럭 진입시와 탈출시에 메시지를 출력한다. 단, 위에서 작성한 log() 코루틴을 사용하기 때문에 진입과 탈출에 제법 많은 시간이 걸릴 것이다.

class AsyncCM:
  def __init__(self, i):
    self.i = i

  async def __aenter__(self):
    await log('Entering Context')
    return self

  async def __aexit__(self, *args):
    await log('Exiting Context')
    return self

이 객체는 async with 구문에 쓸 수 있다. 주의할 것은 async with 구문은 비동기 구문이며, 따라서 비동기 코루틴 내에서만 사용할 수 있다는 점이다. 일반 구문에서 이 코드를 사용하면 어이없게도 구문 오류(Syntax Error)가 뜨는데, 이는 파이썬 해석기에서 개선해야 할 부분으로 보인다. 비동기 컨텍스트 매니저를 사용하는 예제를 다음과 같이 만들고 테스트해본다. for 구문을 돌면서 나오는 숫자의 앞뒤로 매우 느긋하게 출력되는 문자들이 보일 것이다.

async def main1():
  '''Test Async Context Manager'''
  async with AsyncCM(10) as c:
    for i in range(c.i):
      print(i)

## 실행
loop = asyncio.get_event_loop()
loop.run_until_complete(main1())

여느 비동기, 동시성 예제들이 그렇듯이 이렇게 한가지 작업만 하면 대체 time.sleep()으로 지연시켜서 출력하는 거랑은 뭐가 다르냐고 할 수 있겠다. 그래서 하는 김에 비동기 코드 관련한 내용을 조금 더 다뤄보도록 하자.

특히 대부분의 asyncio 관련 예제들은 asyncio.sleep()만 주구장창 쓰기 때문에, 이걸 지연효과를 보려고 만든 것인가? 싶은 예제들이 많을 것이다. 아니다, 이들은 IO를 기다리기 때문에 이만큼 시간이 오래 걸릴 것이라는 것을 묘사하는 셈이다. 그리고 그 시점에 런루프에 등록된 다른 코루틴이 있으면, 이 대기시간 동안 다른 작업을 처리할 수 있다는 것을 의미한다.

async for

파이썬 3.5에서는 비동기 컨텍스트 매니저 외에 비동기 이터레이터도 도입되었다. 이터레이터__iter__() 메소드와 __next__() 메소드를 가지는 객체이다. (사실 엄밀하게 따지면 __iter__() 메소드는 이터레이터를 리턴하고, 이 이터레이터가 __next__() 메소드를 구현하는 것인데, 대부분의 반복가능 객체들은 자기 스스로가 이터레이터인 경우가 많다.)

이터레이터는 FOR … IN 구문에서 매 반복마다 next()에 넘겨져 순회에 쓰일 원소값을 생성한다. next() 함수는 블럭킹함수인데, 만약 매 원소를 얻기 위해서 DB 액세스를 해야하거나 하는 상황이라면 각 반복의 사이사이에 I/O를 기다리는 상황이 발생한다. 역시나 비동기 코루틴 내에서 이런 상황에서 효율을 높이기 위해서는 다음 값을 기다리는 시간 동안 다른 코루틴의 작업을 수행할 수 있도록 전환하는 매커니즘이 필요할 것이고, 그것을 적용한 것이 비동기 이터레이터이다.

컨텍스트 매니저와 마찬가지로 이터레이터 역시 두 개의 메소드 __iter__(), __next__()를 가지는 것으로 간주되며, 비동기 이터레이터는 여기에 각각 a를 붙인 __aiter__(), __anext__()를 갖는 것으로 간주된다. (이 중 __anext__()는 이 맥락상 비동기 코루틴이 되어야 함을 알 수 있다.)

다음은 일반적인 이터레이터를 감싼, 비동기 지연 이터레이터의 간단한 구현이다.

class AsyncIterator:
  def __init__(self, iterable):
    self.data =  iterable
    self.iterator

  def __aiter__(self):
    self.iterator = iter(self.data)
    return self

  async def __anext__(self):
    try:
      await asyncio.sleep(0.5)
      return next(self.iterator)
    except StopIteration:  
      raise StopAsyncIteration  
      ## 비동기 이터레이터는 반복이 끝나면 StopAsyncIteration 예외를 일으킨다.

이를 다음과 같이 테스트 해볼 수 있다.

async def main2():
  a = AsyncItrator(range(10))
  async for i in a:
    await log(i, 3, '#')

loop.run_until_complete(main2())

기대했던 것과 같이 약간의 지연 후에 숫자가 하나씩 출력된다. 그렇다면 앞서 만든 비동기 컨텍스트 매니저와 함께 실행시켜보면 어떨까?

async def main():
  fs = {asyncio.ensure_future(x()) for x in (main1, main2)}
  await asyncio.wait(fs)

loop.run_until_complete(main())

매번 출력되는 글자들이 섞여서 출력되는 광경을 볼 수 있다. 즉 각각의 작업이 await sleep()으로 지연이 발생하게 되면 다른 코루틴의 작업이 진행되어 거시적으로는 동시에 진행되는 것 처럼 보인다.

비동기 제너레이터

제너레이터 문법으로 정의된 함수가 제너레이터 함수이고, 제너레이터는 일종의 코루틴인데, 비동기 제너레이터는 제너레이터처럼 동작하는 비동기 코루틴을 말한다. (???) 무슨 말인고 하니, 비동기 코루틴은 async def를 사용할 뿐, yield와 같은 전통적인 코루틴 문법을 쓰지는 않는다. 제너레이터는 yield 문법을 사용하지만 async 하게 동작하지는 않는다. 비동기 이터레이터를 만드는 방법이 제법 귀찮기 때문에 파이썬 3.61에서는 비동기 제너레이터가 추가되었다. 일반 제너레이터와 동일하나, async def로 선언하며, async for 구문에서 사용가능하다.

## 비동기 제너레이터
async def delayed_range(a, b=None, step=1, delay=0.5):
  if b is not None:
    start, end = a, b
  else:
    start, end = 0, a
  for i in range(start, end, step):
    await asyncio.sleep(delay)
    yield i

async def main3():
  async for i in delayed_range(10, delay=1):
    print(i)

loop.run_until_complete(main3())

비동기 축약

async for 는 파이썬 3.6에서 비동기 축약문에서도 사용이 가능해졌다. 이는 리스트, 사전, 세트, 제너레이터 축약에서 모두 사용할 수 있으며, 제너레이터 축약을 제외한 집합타입 축약에서는 집합 객체를 생성하는 동안 다른 작업으로의 전환이 허용된다.

참고로 제너레이터 축약 문법에서 async for 를 쓰게되면 그 결과는 일반 제너레이터가 아닌 비동기 제너레이터가 된다.

비동기 제너레이터는 일반적인 반복가능(iterable) 규약을 따르지 않기 때문에 async for 가 아닌 일반 for 문에서는 사용할 수 없으며, 그외에 iterable을 받는 함수들 (sum()등…)에서도 사용할 수 없다.

async def main4():
  aList = [x * 2 + 1 async for x in delayed_range(1, 9)]
  for i in aList:
    print(i)

위 코루틴의 실행 결과는 약 5초간 조용하다가 9개의 숫자를 한 번에 출력하는 것이 될 것이다. 하지만 이 코드의 진정한 의미는 “이 리스트의 원소를 수집해서 리스트를 만드는 데는 제법 긴 시간이 걸릴 것이니, 그 동안 다른 코루틴을 수행하렴”이라는 것이다. 따라서 async for를 이용한 비동기 축약 문법 역시 비동기 코루틴 내에서만 쓰일 수 있다.

정리

이상으로 비동기 컨텍스트 매니저에서부터 시작하여 제너레이터, for 반복문, 축약 표현에 이르기 까지 비동기처리가 가능함을 살펴보았다. 사실 asyncio를 사용하지 않는다면 이런 기능들을 알 필요도 없겠지만, 기왕 이쪽으로 방향을 틀었다면 가능한 작은 부분에 이르기 까지 비동기처리를 넣을 수 있는 요소가 언어차원에서 많이 추가되었음을 알 수 있다.

 

참고자료


  1. 주목할 점이다. 비동기 이터레이터의 구현은 실제로 async def 문법과 함께 파이썬 3.5에서 구현되었고, 비동기 제너레이터는 다음 메이저버전인 3.6에서 등장하였다. 그래서 한동안은 비동기 반복문을 구현하기가 지독히 귀찮은 작업이었다.