7 minute read

파이썬 비동기 프로그래밍

동기적, 비동기적 프로그래밍의 간단한 정의

동기 프로그래밍

응답을 기다려야 하는 일이 발생할때, 응답이 올때까지 기다린 이후에 다른 작업

비동기 프로그래밍

응답을 기다려야 하는 일이 발생할때, 기다리지 않고 다른 작업을 하는것

동기 프로그래밍이 비효율적인 이유

보통 동기 프로그래밍 에서 프로그램이 I/O 대기에 들어가면,

실행이 멈추고 커널이 I/O 요청과 관련한 저수준 연산을 처리함.(context switching)

해당 작업을 위해 프로그램 상태를 저장해야할 뿐만아니라 CPU 사용을 포기해야하고

나중에 다시 실행되어야 할때 마더보드에서 프로그램을 다시 초기화하고

시작하기 위한 준비 작업을

수행해야함.

비동기 프로그래밍을 사용한다면 이해 해야할 개념

1. 이벤트 루프 (Event Loop)

이벤트루프 는 작업들을 루프를 돌면서 하나씩 실행 시키는 역할을 함.

만약 실행된 작업이 특정한 데이터를 요청하고 기다려야 한다면,

해당 작업은 다시 이벤트 루프에 통제권을 넘겨 줌

이벤트 루프는 통제권을 다시 받았기 때문에 이어서 다음 작업을 실행 하게됨

그리고 응답을 받은 순서대로 멈췄던 부분에서 다시 통제권을 가지고

작업을 마무리 하게됨.

2. 코루틴 (Coroutine)

이때, 위의 이벤트 루프 작업들은 파이썬에서 코루틴 (Coroutine) 으로

이루어져 있음.

코루틴은 응답이 지연되는 부분에서 이벤트 루프에 통제권을 줄 수 있으며,

응답이 완료 되었을때, 멈춰던 부분에서 기존의 상태를 유지한 채 남은 작업

완료 할 수 있는 함수를 의미함.

파이썬에서 코루틴이 아닌 일반적인 함수는 서브루틴(Subroutine) 이라고 함

파이썬에서 제네레이터를 이용해 코루틴을 구현함.
실행을 중단 했다가 나중에 다시 시작 가능한 구조가 이미
마련 되어 있기 때문

3. 콜백(Callback)

콜백 패러다임에서는 각 함수를 호출 할때, 콜백이라는 인자를 넘김.

이때 불려진 함수는 값을 반환하는 대신 또 다시 그 값을 인자로 실어서

콜백 함수를 호출 함.

호출 함수 -> 다른 함수 -> 다른 함수 -> 다른 함수

같은 형태가 되기도 한다.

함수의 사슬과 같은 형태가 된다.

4. 퓨쳐(Future)

퓨쳐(Future)는 실제 결과가 아닌 미래에 얻을 결과를

담을 프라미스(promise)를 반환하는 비동기 함수.

이로인해 비동기 함수가 반환하는 퓨쳐가 완료되어 필요한 값들이 채워지기

까지 기다려야 한다.

퓨쳐에 요청한 데이터가 들어오기를 기다리는 동안 다른 계산 수행 가능

동기와 비동기의 작업 수행 결과 비교

동기의 경우

import time

def sub_routine_1():
    print('서브루틴 1 시작')
    print('서브루틴 1 중단... 5초간 대기')
    time.sleep(5)
    print('서브루틴 1 종료')

def sub_routine_2():
    print('서브루틴 2 시작')
    print('서브루틴 2 중단... 4초간 대기')
    time.sleep(4)
    print('서브루틴 2 종료')

if __name__ == "__main__":

    sub_routines = [sub_routine_1, sub_routine_2]

    start = time.time()
    for i in range(2):
        sub_routines[i]()
    end = time.time()
    print(f'time taken: {end-start}')

평소의 우리가 알고 있고 예상된 결과로 출력된다.

서브루틴 1 시작
서브루틴 1 중단... 5초간 대기
서브루틴 1 종료
서브루틴 2 시작
서브루틴 2 중단... 4초간 대기
서브루틴 2 종료
time taken: 9.001360893249512

비동기의 경우

import asyncio
import time


async def coroutine_1():
    print('코루틴 1 시작')
    print('코루틴 1 중단... 5초간 대기')

    await asyncio.sleep(5)
    print('코루틴 1 재개')


async def coroutine_2():
    print('코루틴 2 시작')
    print('코루틴 2중단... 4초간 대기')
    await asyncio.sleep(4)
    print('코루틴 2 재개')

if __name__ == "__main__":
    loop = asyncio.get_event_loop()

    start = time.time()

    loop.run_until_complete(asyncio.gather(coroutine_1(), coroutine_2()))
    end = time.time()

    print(f'time taken: {end-start}')
  • 해당 작업의 결과
    코루틴 1 시작
    코루틴 1 중단... 5초간 대기
    코루틴 2 시작
    코루틴 2중단... 3초간 대기
    코루틴 2 재개
    코루틴 1 재개
    time taken: 5.001537084579468
    

동기적으로 처리했을때는 약 9초(5초 + 4초) 로 처리되던 작업이

비동기적으로 처리 했을때는 약 5초만에 처리 할 수 있다.

또한 비동기 처리에서는 코루틴 1이 먼저 실행됐음에도 불구하고

코루틴 2의 대기가 먼저 끝나자 코루틴 2 부터 처리 한다.

비동기 라이브러리들

  1. gevent

  2. tornado

  3. AsynclO

1. gevent

  • 가장 단순한 비동기 라이브러리

  • 비동기 함수가 퓨쳐를 반환해야 한다는 패러다임 따름

    이는 코드에 있는 거의 대부분의 로직을 동시에 실행 가능 하다는 뜻

  • 표준 I/O 함수를 몽치 패치 해서 비동기적으로 만들어줌

    함수, 메서드, 속성을 바꾸는 것을 말함(런타임 중 코드 수정한다는 의미), 개발자들 사이에서 안티 패턴

두 가지 메커니즘 제공

A. 그린렛(greenlet)


표준 라이브러리를 비동기 I/O 함수로 변경하며 동시 실행을 위해 사용할 수 있는 객체

  • 모든 그린렛은 동일한 물리 스레드 에서 실행된다.

    여러 CPU가 모든 그린렛을 실행하는 대신, I/O를 대기하는 동안 gevent의 스케줄러가 전에 설명한 이벤트 루프 를 사용해 그린렛들의 실행을 전환 해준다.

  • 그린렛은 wait 함수로 실행을 투명하게 처리 하도록함

    wait 함수는 이벤트 루프를 시작하고, 그린렛 루프를
    모든 그린렛이 종료될 때까지 계속 실행한다.
    wait 함수 덕분에 대기열에 넣은 작업들은 각각이 완료될때까지 실행되며
    그 후 코드는 순차적 실행으로 돌아온다.

  • gevent.spawn 을 이용해 퓨쳐를 만듦

    어떤 함수와 그 함수를위한 인자를 받아서
    그 함수를 실행하기 위한 그린렛을 시작함.
    해당 함수가 완료되고 반환값이 그린렛의 value 필드로
    들어가기 때문에 일종의 퓨쳐로 생각 할 수 있음.

B. 세마포어(Semaphore)

비동기 I/O를 사용하면 동시에 너무 많은 요청이나 연결이 생길 수 있다.
그렇게되면 원격 서버에 과도한 부하를 주게되어 오히려 처리가 늦어질 수있다.

그래서 한번에 보낼수있는 요청 갯수 제한 매커니즘 세마포어가 필요하다.

  • 세마포어는 어느 시점에 특정 개수의 코루틴만 들어가도록 보장

    그린렛을 모두 동시에 실행 하더라도 항상 최대 100개만 연결을 만들수 있게해서
    프로그램의 한 부분이 다른 부분과 서로 간섭하지 않도록 보장 할 수 있다.

  • 락(Lock) 개수의 최적의 값

    모든 퓨쳐를 설정하고 락 메커니즘을 사용해 제어 하기 때문에
    gevent.iwait 함수로 결과가 도착하는것을 기다릴수있다.
    하지만 과도한 부하 방지를 위해 요청 갯수 제한이 필요.
    위의 요청 제한은 100개 지만, 실행하는 컴퓨터 환경, 이벤트 루프 구현방식,
    원격 호스트의 특성과 같은 요소들로 인해 달라 질 수 있다.

grequests

grequests 라이브러리를 사용하면 gevent의 코드를 상당부분 단순화 할 수 있다.

gevent가 모든 종류의 저수준 동시성 소켓연산 이라면,

grequests는 request HTTP 라이브러리와 gevent 를 조합한것이다.

  • 매우 단순하게 동시성 요청 수행 가능

  • 세마포어 로직도 대신 관리 해줌

  • 코드가 더 이해하기 쉬워지면 유지 보수가 간편


  • 일반 적인 코드 ```json import time

def task(i): print(“Task “, i, “ Start”) time.sleep(1)

if name == “main”:

start = time.time()
for i in range(0, 10):
    task(i)

end = time.time() - start

print(end, "Seconds") ```
  • 실행시간
    . . .
    Task  6  Start
    Task  7  Start
    Task  8  Start
    Task  9  Start
    10.017335176467896 Seconds
    
  • gevent pool 사용
import time, gevent
from gevent.pool import Pool


def task(i):
    print("Task ", i, " Start")
    gevent.sleep(1)
    print("Task ", i, " Finish")


if __name__ == "__main__":

    start = time.time()

    pool = Pool(30)
    for i in range(0, 10):
        if pool.full():
            pool.join()
        pool.spawn(task, i)

    pool.join()

    end = time.time() - start

    print(end, "Seconds")

  • 결과
. . .
Task  7  Finish
Task  8  Finish
Task  9  Finish
1.0012171268463135 Seconds
  • Pool 5로 설정
. . .
Task  7  Finish
Task  8  Finish
Task  9  Finish
2.0064570903778076 Seconds

Pool 을 사용했을때 특이한점은 Pool 로 지정한 요청 까지만

진행한 후에 멈췄다가 다시 진행한다.

2. tornado

이 역시 파이썬 비동기 I/O 에서 사용되는 패키지로,

페이스북에서 HTTP 클라이언트와 서버를 위해 개발한 패키지

gevent 와 달리 tornado 는 콜백을 사용해 비동기 동작 수행
하지만 3.x 배포판 부터는 예전의 코드와 호환되고 코루틴 비슷하게 동작 기능 추가

gevent 와 tornado의 가장 중요한 차이

이벤트 루프가 실행되는 시점의 차이다.

1. gevent

이벤트 루프는 iwait 함수가 실행되는 동안에만 실행된다.

주로 CPU를 사용하고 ‘가끔’ 무거운 I/O를 하는경우에 적합

2. tornado

이벤트 루프는 항상 실행 중으로 비동기 I/O 뿐만 아니라,

프로그램의 전체 실행 흐름을 제어한다.

때문에 대부분이 비동기로 실행해야 하는 프로그램에 적합

또 다른 차이

gevent 는 요청 제한을 제어 하기 쉽지 않아 자원을 과도하게 활용하거나

낭비하게 되어 최적으로 작동하지 않지만, tornado 의 경우

최적화 되어 있는 세마포어 관련 로직이 tornado 내부에 있어서

자원을 더 영리하게 사용한다.

  • 비동기로 실행되는 tornado 예제
import time

from tornado import gen
from tornado.ioloop import IOLoop

@gen.coroutine
def coroutine1():
    print("coroutine1 시작")
    print("coroutine1 5초 대기")
    yield gen.sleep(5)
    print("coroutine1 재게 및 종료")

@gen.coroutine
def coroutine2():
    print("coroutine2 시작")
    print("coroutine2 10초 대기")
    yield gen.sleep(10)
    print("coroutine2 재게 및 종료")


@gen.coroutine
def runner():
    # Yield two Futures; wait for waiter() and notifier() to finish.
    yield [coroutine1(), coroutine2()]
  • 실행 결과
coroutine1 시작
coroutine1 5 대기
coroutine2 시작
coroutine2 10 대기
coroutine1 재게  종료
coroutine2 재게  종료
 실행 시간 : 10.002415180206299

3. AsyncIO

I/O가 잦은 시스템을 다루고자 비동기를 많이 사용하게 되면서

파이썬 3.4 버전 부터 예전의 asyncio 라이브러리를 개선함.

코루틴을 정의하고 동시성 함수의 실행을 멈춰서 다른 코루틴이
실행되도록 yield 하는 gevent 와 tornado 방식의
동시성에 많은 영향을 받았다.

asyncio 모듈의 환상적인 장점

다른 표준 라이브러리와 비슷한 API 를 제공함.

따라서 도우미 모듈을 만드는일이 쉬워진다.

원한다면 해당 모듈의 스택을 더 깊이 파고 들어가서

새로운 비동기 프로토콜을 만들 수도 있다.

추가로 표준 라이브 모듈이기 때문에 언제나 PEP 를 준수하며,

유지 보수를 보장 받을 수 있다.

또한 tornado와 gevent 같은 모듈을 하나로 통합해 같은 이벤트 루프

안에서 실행할 수 있다.

실제로 파이썬 3.4 버전의 tornado 는 asyncio 모듈을 활용함.

총 정리

실제 프로덕션 시스템 에서 외부와 통신해야 할때가 자주있다.

EX ) 다른 서버에서 실행중인 데이터 베이스
EX ) 데이터 제공 해주는 데이터 서비스

그런 문제는 대부분 I/O 위주로 바뀌곤하는데 동시성을 활용하면

I/O와 CPU 연산 사이의 근본적인 차이를 공략해 전체 실행 시간을 줄여준다.

  • 각 라이브러리 특징
1. gevent 는 비동기 I/O를 위한 가장 높은 수준의 인터페이스를 제공.

2. tornato 를 사용하면 개발자가 직접 이벤트 루프의 동작을 제어하여 원하는 임의의 작업을 스케줄링 가능.

3. asynio는 비동기 I/O 스택 전체를 원하는 대로 제어 가능.

위에서 살펴본 세 라이브러리의 속도는 약간씩 달랐다.

그 차이는 코루틴을 어떻게 스케줄링 하는지의 차이에서 생겨나는 것이다.

예를 들면 tornado  비동기연산을 새로 시작하고,
코루틴을 빠르게 재게하는 것을 매우 훌륭하게 수행한다.

반면, asyncio는 성능이 살짝  나빠보이지만, 훨씬  낮은 수준까지
API  제어   있다.

이를 활용하면 상당히 정밀하게 튜닝  수도 있다.

Categories:

Updated: