파이썬 비동기 프로그래밍

파이썬 비동기 프로그래밍

파이썬의 sleep(1)은 왜 비효율적이라고 하는 걸까? 자바스크립트의 setTimeout(1000)과 파이썬의 sleep(1)은 근본적으로 어떤 차이를 가지고 있는 걸까? 이것이 단지 동기와 비동기라는 차이라면 비동기라는 녀석은 정확히 어떻게 동작하고 있는 걸까?


자바스크립트


자바스크립트는 기본적으로 싱글 스레드로 동작한다. 자바스크립트 엔진은 한 번에 하나의 테스크만 처리할 수 있다. 무한루프를 돌려보자. 자바스크립트는 루프 밖의 코드를 실행할 수 없는 상태에 도달한다. 자바스크립트는 기본적으로 동기 처리를 기본으로 한다. 동기 처리는 실행 순서가 보장된다는 장점이 있지만 현재 작업이 종료될 때 까지 다음 작업을 진행할 수 없다는 단점이 있다.

자바스크립트에서는 타이머, 네트워크 요청, 이벤트 핸들러 등의 비동기 처리 방식을 지원하는 함수를 제공한다. 비동기 처리는 현재 작업이 진행중이더라도 다음 작업을 처리할 수 있도록 한다. 즉, 비동기란 독립적인 두 작업이 서로의 시작 시간과 종료 시간에 영향을 주지 않는다고 생각할 수 있다.

비동기 처리 로직

자바스크립트엔 콜 스택과 이벤트 루프, 테스크 큐라는 녀석들이 있다. 모든 함수는 호출시 콜 스택에 등록된다. 스택은 나중에 들어온 녀석이 먼저 나가는 방식으로 처리된다. 콜 스택은 대부분의 프로그래밍 언어에서 동일하게 동작한다. 비동기 함수는 이때 콜 스택에 등록되었다가 실행된 직후 사라진다. 그럼 콜 스택은 다음 작업을 지속적으로 처리해 나간다.

예를들어 setTimeout의 경우 WebAPI에 등록되어 타이머를 측정한다. 이후 타이머가 끝나면 테스크 큐에 프로그래머가 지정한 콜백 함수가 등록되며 이벤트 루프는 콜 스택에 처리할 작업이 없다는 것을 확인하면 테스크 큐에 있는 함수를 콜 스택에 등록하여 콜백 함수가 처리된다.

즉, 자신의 시스템이 sleep(1)으로 인해서 메인 로직에 영향을 받고 있다면 비효율을 논할 수 있으나 sleep 함수 자체가 비효율적이라고 표현하는 것은 잘못되었다.


파이썬


여하지간 파이썬 3.4에도 asyncio라는 비동기 표준 라이브러리가 추가되었고, 3.5에선 asyncawait라는 예약어가 추가하는 등 파이썬에서도 비동기 프로그래밍을 적극적으로 지원하고 있다.

def do_sync(): #일반 함수
    pass
async def do_async(): #코루틴 함수
    pass

파이썬에서 비동기 함수는 코루틴 함수라고 불려진다. 코루틴 함수를 직접 호출하면 노드에서 프로미스를 호출하듯 코루틴 함수가 반환된다. 기본적으로 코루틴 함수는 다른 코루틴 함수에서 await 키워드를 붙여서 호출해야 한다.

async def main_async():
    await do_async()

일반 함수에서 코루틴 함수를 호출하려면 asyncio 라이브러리 이벤트 루프를 이용해야 한다. 루프 시작을 비롯해 버전별로 다소 상이한 경우가 있으므로 잘 참고해서 사용하길 바란다.

3.6 이하에서 비동기 루프 실행

import asyncio

async def do_async():
    pass

async def main_async():
    await do_async()

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main_async())
    loop.close()

3.7 이상에서 비동기 루프 실행

import asyncio

async def do_async():
    pass

async def main_async():
    await do_async()

if __name__ == '__main__':
    asyncio.run(main_async())

비동기 파일 IO

비동기 방식으로 프로그래밍을 하려면 이미 비동기로 만들어진 함수를 잘 활용 해야 한다. 비동기 파일 입출력은 aiofiles라는 라이브러리를 이용하여 처리할 수 있다.

pip install aiofiles
import asyncio
import aiofiles

async def main():
    async with aiofiles.open('test.txt', 'w') as f:
        await f.write('Hello World!')

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()

비동기 네트워크 IO

비동기 네트워크는 aiohttp라는 라이브러리를 사용하여 처리할 수 있다.

pip install aiohttp
import asyncio
import aiohttp

async def main():
    async with aiohttp.ClientSession() as session:
        async with session.get('https://baejino.com') as res:
            print(await res.text())

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()

requests라이브러리의 문법에 익숙한 사용자라면 위 코드가 다소 불편하게 다가올 수 있는데, requests_async라는 라이브러리를 사용하면 기존 requests 라이브러리와 동일한 문법을 사용할 수 있다. 단지 앞에 await 키워드만 붙여주면 된다.

pip install requests_async
import asyncio
import requests_async as requests

async def main():
    res = await requests.get('https://baejino.com')
    print(res.text)

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()

동시성 프로그래밍

비동기의 유일한 장점은 불필요한 대기시간을 없애는 것인데 위와같이 단일 함수만 호출되는 경우 동기식으로 작성한 것과 다를바가 없다. 아래 코드는 대표적으로 잘못 작성한 코드이다.

import asyncio
import requests_async as requests

async def req(i):
    res = await requests.get('https://baejino.com')
    if res.status_code == 200:
        print(str(i) + '번째 요청 : 정상적인 응답입니다.')
        return
    print(str(i) + '번째 요청 : 정상적인 응답이 아닙니다.')

async def main():
    for i in range(10):
        await req(i)

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()
0번째 요청 : 정상적인 응답입니다.
1번째 요청 : 정상적인 응답입니다.
2번째 요청 : 정상적인 응답입니다.
3번째 요청 : 정상적인 응답입니다.
4번째 요청 : 정상적인 응답입니다.
5번째 요청 : 정상적인 응답입니다.
6번째 요청 : 정상적인 응답입니다.
7번째 요청 : 정상적인 응답입니다.
8번째 요청 : 정상적인 응답입니다.
9번째 요청 : 정상적인 응답입니다.

이러면 그저 불필요하게 키보드를 두들기고 코드의 양만 늘어났을 뿐이다. 동시 다발적으로 실행을 시키려면 asyncio.wait 함수를 사용하거나 asyncio.create_task를 활용해야 한다.

asyncio.wait

아주 간단한다. 코루틴 함수를 배열로 감싸서 wait 함수를 await을 해준뒤 실행하면, 코루틴 배열이 모두 종료된 후 다음 코드로 이어진다. 타임아웃을 걸어준 이유는 해당 코루틴 배열중에 응답이 오지 않거나 락에 걸리는 상황에 대비한 것이다.

import asyncio
import requests_async as requests

async def req(i):
    res = await requests.get('https://baejino.com')
    if res.status_code == 200:
        print(str(i) + '번째 요청 : 정상적인 응답입니다.')
        return
    print(str(i) + '번째 요청 : 정상적인 응답이 아닙니다.')

async def main():
    coroutines = []
    for i in range(10):
        coroutines.append(req(i))
    await asyncio.wait(coroutines, timeout=60)

    # await asyncio.wait([req(i) for i in range(10)], timeout=60) # 한줄로 표현

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()
7번째 요청 : 정상적인 응답입니다.
3번째 요청 : 정상적인 응답입니다.
4번째 요청 : 정상적인 응답입니다.
6번째 요청 : 정상적인 응답입니다.
8번째 요청 : 정상적인 응답입니다.
0번째 요청 : 정상적인 응답입니다.
5번째 요청 : 정상적인 응답입니다.
2번째 요청 : 정상적인 응답입니다.
1번째 요청 : 정상적인 응답입니다.
9번째 요청 : 정상적인 응답입니다.
asyncio.create_task

다음은 create_task를 이용하여 동시적으로 코드를 실행하는 방법이다. 테스크란 코루틴 함수를 실행할 이벤트 목록에 추가한 후 반환된 값이다. 이미 코루틴 함수는 프로그래머의 의지와 상관없이(?) 실행 될 준비가 된 상태이며, 해당 코루틴이 정상적으로 실행된 이후 다음 작업을 진행하려면 태스크를 await을 해주면 된다.

3.7이하에서는 create_task가 존재하지 않으므로 ensure_future를 사용해야 한다.

import asyncio
import requests_async as requests

async def req(i):
    res = await requests.get('https://baejino.com')
    if res.status_code == 200:
        print(str(i) + '번째 요청 : 정상적인 응답입니다.')
        return res
    print(str(i) + '번째 요청 : 정상적인 응답이 아닙니다.')
    return res

async def main():
    tasks = [i for i in range(10)] # 테스크를 담을 
    for i in range(10):
        # tasks[i] = asyncio.create_task(req(i)) # 3.7 이상이면 이 코드로
        tasks[i] = asyncio.ensure_future(req(i))
    
    for i in range(10):
        await tasks[i]

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()
0번째 요청 : 정상적인 응답입니다.
4번째 요청 : 정상적인 응답입니다.
8번째 요청 : 정상적인 응답입니다.
6번째 요청 : 정상적인 응답입니다.
3번째 요청 : 정상적인 응답입니다.
2번째 요청 : 정상적인 응답입니다.
9번째 요청 : 정상적인 응답입니다.
7번째 요청 : 정상적인 응답입니다.
1번째 요청 : 정상적인 응답입니다.
5번째 요청 : 정상적인 응답입니다.

함수를 코루틴으로

아무래도 파이썬의 대부분의 라이브러리는 동기 함수로 만들어져 있으므로 비동기를 적극적으로 활용하기엔 어려움이 있다. 예를들어 Django 같은 동기가 지배한 프레임워크에서는 활용하기가 무척이나 어려운데, 아래와 같이 동기 함수를 코루틴으로 변환시키면 비동기를 적극적으로 활용할 수 있다.

import asyncio

from functools import wraps, partial

def asynchronously(func):
    @wraps(func)
    async def coro(*args, loop=None, executor=None, **kwargs):
        if loop is None:
            loop = asyncio.get_event_loop()
        partial_func = partial(func, *args, **kwargs)
        return await loop.run_in_executor(executor, partial_func)
    return coro

사용법은 정말 간단하다. 동기함수를 만들고 저 함수를 데코레이터로 감싸면 된다.

import asyncio
import requests

from functools import wraps, partial

def asynchronously(func):
    @wraps(func)
    async def coro(*args, loop=None, executor=None, **kwargs):
        if loop is None:
            loop = asyncio.get_event_loop()
        pfunc = partial(func, *args, **kwargs)
        return await loop.run_in_executor(executor, pfunc)
    return coro

@asynchronously
def req(i):
    res = requests.get('https://baejino.com')
    if res.status_code == 200:
        print(str(i) + '번째 요청 : 정상적인 응답입니다.')
        return
    print(str(i) + '번째 요청 : 정상적인 응답이 아닙니다.')
    
async def main():
    await asyncio.wait([req(i) for i in range(10)])

if __name__ == '__main__':
    asyncio.run(main())
9번째 요청 : 정상적인 응답입니다.
0번째 요청 : 정상적인 응답입니다.
7번째 요청 : 정상적인 응답입니다.
6번째 요청 : 정상적인 응답입니다.
3번째 요청 : 정상적인 응답입니다.
2번째 요청 : 정상적인 응답입니다.
4번째 요청 : 정상적인 응답입니다.
1번째 요청 : 정상적인 응답입니다.
8번째 요청 : 정상적인 응답입니다.
5번째 요청 : 정상적인 응답입니다.

비동기 웹 프레임워크

비동기 로직에 대한 의문이 왜 여기까지 왔는지 모르겠지만 결과적으로 이걸 학습하는 목적은 현재 이 BLEX도 그렇고 적어도 파이썬으로 웹 개발을 한다면 최대한 비동기 프로그래밍을 활용하고자 한다.

Vibora

Vibora는 파이썬의 비동기 웹 프레임워크이다. 파이썬 웹 프레임워크 중에 가장 빠르다고 알려졌으며(Flask대비 5-6배 초당 응답량 많음) Flask 스타일을 지향한다.

공식 홈페이지에선 3.6이상을 사용하라고 권장하나 Python 3.7에선 Syntax에러가 발생하여 사용이 불가능하다. 해당 프레임워크를 사용해보고 싶은 경우 3.6 버전을 설치해야 하므로 pyenv를 활용하도록 하자.

from vibora import Vibora, Request
from vibora.responses import JsonResponse

app = Vibora()

@app.route('/')
async def home(request: Request):
    return JsonResponse({'hello': 'world'})

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=8000)

Flask와 문법이 전체적으로 동일하나 모든 라우트 함수 앞에 async라는 키워드가 추가되었다. 여담으로 vivora의 경우 문서의 상태가 썩 좋지 못하고, 자잘한 오류가 정말 많은데다 가장 큰 문제는 이 자잘한 오류가 이슈에도 등재되어 있으며 전혀 고쳐지지 않고 있다. 조금은 느리더라도 이미 검증되어 널리 사용되는 Sanic을 사용하는 것을 추천하고 싶다.

Async ORM

해당 라이브러리의 이름은 orm으로 지어져 있으나. 이는 실제 널리 사용되는 이름이므로 혼동이 생길 수 있어 Async ORM으로 부르도록 하겠다. 필자는 간단하게 테스트를 위해서 사용하므로 sqlite를 사용한다.

pip install orm~=0.1
pip install aiosqlite

라이브러리 개발자가 말하길 개발중인 상태이므로 위와같이 설치하는 것을 권장한단다.

import databases
import orm
import sqlalchemy
import asyncio

database = databases.Database("sqlite:///db.sqlite")
metadata = sqlalchemy.MetaData()

class Note(orm.Model):
    __tablename__ = "notes"
    __database__ = database
    __metadata__ = metadata

    id = orm.Integer(primary_key=True)
    text = orm.String(max_length=100)
    completed = orm.Boolean(default=False)

# Create Database
engine = sqlalchemy.create_engine(str(database.url))
metadata.create_all(engine)

async def create():
    await Note.objects.create(text="Buy the groceries.", completed=False)
    await Note.objects.create(text="Call Mum.", completed=True)
    await Note.objects.create(text="Send invoices.", completed=True)

async def get():
    notes = await Note.objects.all()
    return notes

개쩐다. 장고의 ORM 문법과 거의 동일하다. 장고의 내부 함수를 많이 사용하지 않았다면 마이그레이션도 간단할 것 같다. 여하지간 요걸 Vibora랑 섞으면 이런 느낌이 된다.

import databases
import orm
import sqlalchemy
import asyncio
import re

from vibora import Vibora, Request
from vibora.responses import Response, JsonResponse

database = databases.Database("sqlite:///db.sqlite")
metadata = sqlalchemy.MetaData()

class Note(orm.Model):
    __tablename__ = "notes"
    __database__ = database
    __metadata__ = metadata

    id = orm.Integer(primary_key=True)
    text = orm.String(max_length=100)
    completed = orm.Boolean(default=False)

app = Vibora()

@app.route('/create_database')
async def note_create(request: Request):
    engine = sqlalchemy.create_engine(str(database.url))
    metadata.create_all(engine)

@app.route('/create_notes')
async def create():
    await Note.objects.create(text="Buy the groceries.", completed=False)
    await Note.objects.create(text="Call Mum.", completed=True)
    await Note.objects.create(text="Send invoices.", completed=True)
    return JsonResponse({'state': 'done'})

@app.route('/notes')
async def note(request: Request):
    notes = await Note.objects.all()
    return JsonResponse(list(map(lambda x: {x.id: x.text}, notes)))

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=8000)

상당히 Node 스럽네...

curl http://localhost:8000/create_database
curl http://localhost:8000/create_notes
curl http://localhost:8000/notes
[{"1":"Buy the groceries."},{"2":"Call Mum."},{"3":"Send invoices."}]

이 글이 도움이 되었나요?

신고하기
0분 전
작성된 댓글이 없습니다. 첫 댓글을 달아보세요!
    댓글을 작성하려면 로그인이 필요합니다.