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에서 등장하였다. 그래서 한동안은 비동기 반복문을 구현하기가 지독히 귀찮은 작업이었다. 

일반함수를 비동기 코루틴화하는 데코레이터 만들기

지난 글에서 urlopen()과 같은 표준 라이브러리 함수를 어떻게 비동기 코루틴처럼 asyncio에서 사용할 수 있는지 살펴보았다. aiohttp 등의 비동기 라이브러리를 사용해서 여러 핸들러를 작성해야 할 때, 이와 같은 처리를 많이 해야 한다면 빈번하게 런루프 메소드를 호출하는 것보다, 간단히 데코레이터를 만들어서 활용하는 것이 어떨까? 무엇이 되었든 파이썬 함수는 인자를 받고 결과를 내보내는 구조로 되어 있다. ( (*args, **kwds) -> Result  ) 물론 상황에 따라 인자는 생략되기도 하고 결과는 암시적으로 None 이 될 것이다. 따라서 이러한 함수를 데코레이터와 함께 작성하여 별도의 스레드에서 실행되는 비동기 코루틴으로 만들어보도록 하자.

  1. 런루프 및 executor는 생략되는 경우 기본 런루프와 디폴트 executor를 사용하지만, 명시적으로 넘겨지는 경우 그것을 사용한다.
  2. 위 조건은 데코레이터 선언 자체에 들어가야 한다. 따라서 우리가 작성해야하는 함수는 데코레이터가 아니라 데코레이터 생성함수이다.
  3. run_in_executor() 메소드는 키워드 인자를 넘겨주지는 못한다. 따라서 functools.partial 을 사용해서 키워드인자를 만들어주어야 한다.

여차저차해서 코드는 다음과 같이 작성될 수 있다.

from functools import partial, wraps
import asyncio

def run_async(loop=None, pool=None):
  _loop = loop if loop is not None else\
          asyncio.get_event_loop()
  def decorator(fn):
    @wraps(fn)
    async def wrapped(*args, **kwds):
      _fn = partial(fn, **kwds)
      result = await _loop.run_in_executor(pool, *args)
      return result
    return wrapped
  return decorator

실제 사용은 이런식으로 한다.

import sqlite3 as sql

@run_async()
def get_users(page=1, limit=100):
  conn = sql.connect(database)
  c = conn.execute('''SELECT * FROM users LIMIT = ? OFFSET = ?''', 
                   (limit, (page-1) * limit))
  return c.fetchall()

async def run():
  fs = {get_users(i) for i in range(1, 11)}
  asyncio.

Python 표준 함수를 asyncio에서 비동기로 호출하는 방법

파이썬 3.4에서 asyncio 가 추가되어 I/O 바운드된 작업을 단일 스레드에서 비동기로 처리할 수 있는 방법이 생겼다. 하지만 대부분의 파이썬 내장 라이브러리 함수들은 코루틴이 아닌 일반 함수들이며, 이들은 모두 블럭킹 방식으로 동작한다. 즉 asyncio 의 비동기는 실질적으로는 I/O 액세스처럼 CPU가 관여할 필요가 없는 일들에 대해서 “병렬적으로 기다리는” 식으로 동시다발적인 처리의 전체 수행 시간을 줄이는 식으로 동작하는데, 그 중에 이런 블럭킹 함수로 처리되는 과정이 끼어 있다면 수행 시간 단축이 어렵게 된다.

런루프

이 블로그의 다른 글에서도 몇 번 이야기했듯이, 파이썬에는 GIL(전역 인터프리터 락)이라는 기재가 있어서 멀티 스레드로 병렬 작업을 처리하더라도 실질적으로 여러 스레드가 동시에 진행되지 못하고 CPU는 한 번에 하나의 스레드만 처리한다.

## 흔히 생각하는 멀티스레드

( -- 은 작업을 수행하는 시간,  ..은 스레드가 중단되는 시간을 의미)

메인스레드 ------------------------------> 진행
스레드 1     생성 -----------------------> 진행
스레드 2           생성 -----------------> 진행

## 실제 파이썬의 멀티 스레드

메인스레드 ------....---...---......-----> 진행 
스레드 1    생성 ----.........---........> 진행
스레드 2            생성---......---.....> 진행

위 도식에서 --- 으로 표현된 부분은 스레드가 일을 하는 시간이고, ... 으로 표현된 부분은 다른 스레드가 일하는 동안 해당 스레드가 멈춰있는 시간이다. 즉 아래/위 두 케이스에서 같은 시간 동안 멀티스레드가 실행되었다고 가정하면 처리된 일의 총 량은 - 의 개수와 같다고 볼 수 있으며, 파이썬의 멀티스레드는 단일 스레드에서의 작업량과 다를 바가 없는 일을 처리한다. (오히려 컨텍스트 스위칭에 들어가는 비용이 있기 때문에 더 느리다.)

이 관점에서 생각해볼 것이, asyncio 는 본래 단일 스레드에서 I/O 작업의 대기 시간이 CPU 사용시간에 포함되지 않도록 여러 코루틴을 옮겨가며 실행하는 것이라는 점이다. 그리고 위의 멀티 스레드처리에서 ... 에 해당하는 시간이 그냥 다른 스레드를 위해서 해당 스레드가 쉬는 것이 아니라 해당 스레드가 I/O 작업을 기다리는 시간으로 만들면 어떠냐는 것이다.

즉 멀티스레드로 분기해서 실행되는 블럭킹 함수 콜 자체를 I/O 작업으로 보고, 이를 기다리는동안 중지하는 비동기 코루틴이 있다면 되지 않을까? 이를 위해서 스레드 작업의 상태에 따라서 런루프에 시그널을 보내고, 해당 함수 콜 자체를 비동기 코루틴으로 감싸는 처리를 해줘야 하는데, 직접 구현하기에는 좀 난이도가 있는 작업이다.

하지만 파이썬은 왠만하면 다 있다고 했던가… 이런 기능을 이미 런루프 클래스에서 제공하고 있다. run_in_executor() 함수가 이 용도로 사용된다.

coroutine AbstractEventLoop.run_in_executor(executor, func, *args)

func 에 대해서 지정한 executor에서 실행되도록 조정한다. executor는 Executor 클래스의 인스턴스이며, None을 사용하는 경우 디폴트 executor가 사용된다. 인자들을 넘겨줄 수 있지만, 키워드 인자는 지원하지 않기 때문에 func가 키워드 인자를 요구한다면 functools.partial을 사용하는 것이 좋다. 이 메소드는 코루틴이다.

> https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.AbstractEventLoop.run_in_executor

예제

그렇다면 이게 실제로 효과가 있는지 확인해보자. 기본 라이브러리에서 네트워크 액세스를 처리하는 urllib.request.urlopen 함수를 생각해보자. 이 함수는 비동기 코루틴이 아닌 일반 함수이며, HTTP 요청을 보낸 후 응답을 받을 때까지 블럭되는 함수이다.  이 함수를 호출해서 콘텐츠를 받아오는 함수를 하나 작성해보자.

from urllib.request import urlopen
import time

async def get_url_data(url:str) -> (str, str, str):
  '''특정 URL에 요청을 보내어 HTML 문서를 문자열로 받는다.
  url, 응답텍스트, 포맷팅된 소요시간을 리턴한다.'''
  print(f'Request for: {url}')
  s = time.time()
  res = urlopen(str)
  data = res.read().decode()
  return url, data, f'{time.time() -  s: .3f}'

몇 개의 URL 집합에 대해서 이 함수를 테스트해본다.

import asyncio

urls = ('http:// ..... ') # 예닐곱 개 정도의 URL을 준비한다.

async def test_urls(co, urls):
  s = time.time()
  fs = {co(url) for url in urls}
  for f in asyncio.as_completed(fs):
    url, body, t = await f
    print(f'Resonse from: {url}, {len(body)}Bytes - {f}sec')
  print(f'{time.time() - s:0.3f}sec')

loop = asyncio.get_event_loop()
loop.run_until_complete(test_urls(get_url_data, urls))

이 코드는 비록 코루틴으로 각 URL을 테스트하는 코드를 작성하였지만, urlopen 함수 자체가 블럭킹 함수이므로 동시에 실행되는 것이 아니라 하나씩 실행된다. print() 하는 지점까지는 번갈아가며 실행되지만 HTTP 통신을 하는 동안은 한 번에 하나씩만 실행되고, 실제 출력되는 결과도 요청을 보낸 순서대로 출력된다.

다음은 스레드를 사용해서 실행하는 코드이다.

async def get_url_data2(url):
  print(f'Request for: {url}')
  loop = asyncio.get_event_loop()
  s = time.time()
  res = await loop.run_in_executor(None, urlopen, url)
  data = res.read().decode()
  return url, data, f'{time.time() -  s: .3f}'

loop = asyncio.get_event_loop()
loop.run_until_complete(test_urls(get_url_data2, urls))

일반 함수호출인 아닌 런루프+스레드 조합의 코루틴으로 감싼 호출을 사용했다. 실제 완료 시간이 체감상으로 확 줄어드는 것을 볼 수 있다.

결과 비교

다음은 위 코드를 순차적으로 실행하여 테스트하고, 걸린 시간을 비교하는 결과이다.

----------------------------------------
call by run_in_executor:
----------------------------------------
requesting: https://www.naver.com
requesting: https://www.daum.net
requesting: https://www.yahoo.com
requesting: http://fa.bianp.net/
requesting: https://jakevdp.github.io
requesting: http://arogozhnikov.github.io
----------------------------------------
response:   https://www.naver.com, 155791, 0.199
response:   https://www.daum.net, 204124, 0.379
response:   http://arogozhnikov.github.io, 24867, 0.346
response:   https://jakevdp.github.io, 60277, 0.492
response:   https://www.yahoo.com, 509304, 1.987
response:   http://fa.bianp.net/, 51394, 2.093
total time: 2.210sec
----------------------------------------

----------------------------------------
call normal function:
----------------------------------------
requesting: https://www.naver.com
requesting: https://www.daum.net
requesting: https://www.yahoo.com
requesting: http://fa.bianp.net/
requesting: https://jakevdp.github.io
requesting: http://arogozhnikov.github.io
----------------------------------------
response:   https://www.naver.com, 152331, 0.098
response:   https://www.daum.net, 204133, 0.105
response:   https://www.yahoo.com, 509307, 1.440
response:   http://fa.bianp.net/, 51394, 2.027
response:   https://jakevdp.github.io, 60277, 1.729
response:   http://arogozhnikov.github.io, 24867, 1.163
total time: 6.743sec
----------------------------------------
  1. 비동기 코루틴으로 바꿔서 실행한 경우에 총 수행 시간은 일반 함수 호출을 사용한 것보다 약 절반 이하로 실행된다.
  2. 그렇다고 개별 HTTP 요청에 소요된 시간이 (당연히) 더 짧아지지는 않는다.
  3. 전체 수행 시간은 가장 오래걸린 연결 시간보다 약간 큰 값이다.
  4. 결과가 출력되는 순서는 요청을 시작한 순서와 다르며, 이는 응답시간이 빠른 순으로 표시된다. 결국 6개의 연결이 동시에 수행되었다가, 먼저 완료된 것 순으로 결과가 출력되었음을 알 수 있다.
  5. 일반 함수 호출모드에서 테스트한 결과에서 총 시간은 개별 연결 시간의 합과 비슷하다.
  6. 개별 연결에 걸린 시간에 상관없이 출력 순서는 요청 순서와 일치한다. 즉 HTTP 통신 자체가 동시에 이루어지지 못하고 순서대로 하나씩 실행된 셈이다.

정리

대부분의 가이드 문서가 asyncio.sleep 만 사용하는 식으로 예제를 만들어내다 보니, 기존의 표준 라이브러리 함수들을 논블럭 모드로 사용하는 방법에 대해서는 따로 소개하지 않는 경우가 많다. 이처럼 런루프의 run_in_executor() 를 사용하면 기존의 표준 함수들도 병렬적으로 동작하는 코루틴으로 완전하게 전환할 수 있다. 어차피 I/O 대기 시간을 병렬적으로 쉬어버리는 식으로 스케줄링 되기 때문에 GIL의 영향을 우회해서 훌륭하게 전체 수행 시간을 단축할 수 있으며, 기존의 함수를 그대로 쓸 수 있다는 막강한 장점이 있다.

다만, 이는 스레드를 별도로 생성해서 동작하기 때문에 메모리 자원을 보다 많이 사용하게 되는 문제가 있고, 또한 I/O 바운드되는 작업에만 적용할 수 있다. 예를 들어 CPU를 많이 사용하는 계산이 필요한 소수 검사 등의 연산은 당연히 스레드를 동시에 돌리지 못한다. 물론 이 경우에도 ProcessPoolExecutor를 사용해서 다중 프로세스로 처리하는 방법도 있을 것이다.

 

 

asyncio : 단일 스레드 기반의 Nonblocking 비동기 코루틴 완전 정복

asyncio에 의한 단일 스레드 병렬 작업

지난번 concurrent.futures를 소개한 글에서 파이썬 3에서부터 멀티스레딩/멀티프로세싱에 대해 새로 도입된 고수준 API에 대해 살펴봤다. 이 새로운 API는 함수 호출을 병렬로 처리하는 동작을 사용하기 쉽게 만들 뿐 아니라, 직접 스레드를 제어하는 것이 아닌 Future 객체를 사용함으로써 자바스크립트의 Promise 개념을 도입한 것으로 평가할 수 있다고 보았다.

새로운 병렬처리 API와 더불어 Future 클래스가 도입된 것이 파이썬 3.2였다. Future 개념의 도입은 스레드를 관리하고, 다른 스레드에서 돌아가는 작업에 대해서 리턴을 동기화하는 등의 작업들이 매우 골치아팠던 것을 그 자체를 객체로 래핑하면서 매우 우아하게 처리할 수 있었다. 이는 결국 비선형적인 제어 흐름과 관계된 코드를 작성하는 것이 더 이상 너저분한 작업이 아닐 수 있다는 가능성을 보였다.

다중 스레드 및 다중 프로세스에 대해서 Future를 적용하는 것이 성공적이었다면, 이는 단일 스레드에 대해서도 비동기 non-blocking 코드를 작성하는데에 동일한 Future 개념을 도입할 수 있지 않을까하는 것으로 아이디어가 옮겨갔다.

asyncio : 단일 스레드 기반의 Nonblocking 비동기 코루틴 완전 정복 더보기

asyncio

https://docs.python.org/3/library/asyncio-task.html

Task와 코루틴

코루틴은 특정한 규약을 따르는 제너레이터이다. 문서상으로 모든 코루틴은 @asyncio.coroutine 데코레이터를 붙이지만, 이것이 반드시 필수적인 것은 아니다.

코루틴에서는 전통적인 yield 대신에 yield from 구문을 사용한다.

코루틴이라는 단어는 제너레이터와 마찬가지로 두 가지 다른 컨셉으로 사용된다.

  • 코루틴을 정의하는 함수. 이 경우 구분을 위해 이것을 코루틴 함수라 따로 부를 수 있다.
  • 코루틴 함수를 호출하여 생성된 객체. 이 객체는 계산 및 IO 작업을 표현한다. 또한 반드시 실행을 완료해야 한다. 코루틴 함수와 구분하여 코루틴 객체라 부른다.

asyncio 더보기