Асинхронное

программирование

Tinkoff Python

Лекция 8

Палий Антон

Виды соединений

Poll

Постоянно заваливаем запросами

+ простота реализации

 

- очень велики расходы на постоянную установку соединения

не рекомендуется к использованию

Poll

Long Polling

  1. Отправляется запрос на сервер
  2. Соединение не закрывается сервером, пока не появится событие или наступит таймаут
  3. Событие отправляется в ответ на запрос
  4. Клиент тут же отправляет новый ожидающий запрос

К примеру поможет для получения реалтайм оповещений

+ Простота реализаци

- Опять же необходимость каждый раз устанавливать соединение

- Нет обратной связи

Long Polling

WebSocket

Соединение, при котором и клиент и сервер могут обмениваться друг с другом данными

+ Создается одно соедиенение и в его рамках идет передача всех данных

+ Передача данных в обе стороны

- Более сложная реализация

WebSocket

c10k

Действительно ли проблема?

c10m

вот это проблема!

10 миллионов процессов?

import time
from concurrent.futures import ProcessPoolExecutor

import requests

# во всех примерах используется сервер, который отвечает за 0.3 секунды 
def load_many() -> None:
    session = requests.Session()
    tasks = []
    with ProcessPoolExecutor(max_workers=100) as pool:
        for i in range(500):
            tasks.append(pool.submit(session.get, "http://127.0.0.1:8000/"))

        for task in tasks:
            print(task.result().json())


start = time.monotonic()
load_many()
duration = time.monotonic() - start
print(f"spend {duration} seconds")
python process-load.py
...
spend 2.271605703019304 seconds

10 миллионов тредов?

import time
from concurrent.futures import ThreadPoolExecutor

import requests


def load_many() -> None:
    session = requests.Session()
    tasks = []
    with ThreadPoolExecutor(max_workers=100) as pool:
        for i in range(500):
            tasks.append(pool.submit(session.get, "http://127.0.0.1/:8000"))

        for task in tasks:
            print(task.result().json())


start = time.monotonic()
load_many()
duration = time.monotonic() - start
print(f"spend {duration} seconds")
python thread-load.py
...
spend 2.1006762809993234 seconds

Слишком накладно?

Синхронизировать это все дело, следить чтобы ничего не утекло, правильно поставлены локи, потокобезоапасная сессии и т.д.

На каждое пользовательское соединение отдельный поток, процесс?
Чем больше тредов, тем дольше будет переключаться контекст в операционной системе.

Слишком жирно?

Каждый тред или процесс будут выжирать память и время cpu, при этом не использоваться на полную мощность

В синхронных операциях задачи выполняются друг за другом.

 

В асинхронных задачи могут запускаться и завершаться независимо друг от друга.

Такой подход позволяет решить проблему с ресурсами и скоростью работы кода

Асинхронное программирование

Это вызов асинхронных системных функций.

Но по факту сложнее.

Почему же?

Системные вызовы сложны

Мы же пишем на питоне

Есть разные шаблоны, которые упрощают работку с асинхронными вызовами

Event Loop

Очередь из задач, которая регулирует порядок их запуска в системе.

Callback

Функции обратного вызова

import tornado.ioloop
from tornado.httpclient import AsyncHTTPClient


def handle_response(response):
    if response.error:
        print("Error:", response.error)
    else:
        url = response.request.url
        data = response.body
        print("{}: {} bytes: {}".format(url, len(data), data))


def load_many():
    http_client = AsyncHTTPClient()
    tasks = []
    for i in range(500):
        tasks.append(http_client.fetch("http://127.0.0.1:8000/", handle_response))

    tornado.ioloop.IOLoop.instance().start()


load_many()

very painful

Можно декомпозировать и написать иначе, но при написании кода на колбеках теряется линейность

Теория конечных автоматов

Конечный автомат — это некоторая абстрактная модель, содержащая конечное число состояний чего-либо, и регулирующая правило переключения этих состояний

Примитивы асинхронного кода

Корутина (сопрограмма)

Функция, которая может передавать управление циклу событий

In [1]: async def kek() -> None: 
   ...:     pass 
   ...:    
   ...: type(kek()) 
   ...:                                                                                                                                                                                                                                                                                                                                                                      
/home/os/.local/bin/ipython:5: RuntimeWarning: coroutine 'kek' was never awaited
  from IPython import start_ipython
RuntimeWarning: Enable tracemalloc to get the object allocation traceback
Out[1]: coroutine

In [2]: type(kek)                                                                                                                                                                                                                                                                                                                                                            
Out[2]: function

await

ключевое слово для получения результата корутины, или других объектов типа future или task

asyncio

основная библиотека для работы с асинхронностью

является частью стандартной библиотеки

Асинхронный код не запустить вне цикла событий

import asyncio

async def main():
  pass

loop = asyncio.get_event_loop()

loop.run_until_complete(main())

# Или просто 
asyncio.run(main())

Task

Корутина запущенная concurrently

import asyncio


async def payload_work(num: int) -> None:
    await asyncio.sleep(10)
    print('kek')


async def main():
    tasks = [asyncio.create_task(payload_work(i)) for i in range(10)]


asyncio.run(main())

Что выведет этот код?

Ничего, таска не успеет отработать

Правильно

import asyncio


async def payload_work(num: int) -> None:
    await asyncio.sleep(10)
    print('kek')


async def main():
    tasks = [asyncio.create_task(payload_work(i)) for i in range(10)]
    for task in tasks:
        await tas

asyncio.run(main())

Что бы гарантировать результат выполнения из task, необходимо ее awaitнуть

Задачи можно отменять

import asyncio


async def payload_work(num: int) -> None:
    try:
        await asyncio.sleep(1)
        print(f'kek {num}')
    except Exception as e:
        print(e)


async def main():
    tasks = [asyncio.create_task(payload_work(i)) for i in range(10)]
    tasks[5].cancel()
    for task in tasks:
        await task


asyncio.run(main())
# в данном случае все задачи отработают кроме 5

Полезно при получении ошибки в коде и задача больше не нужна

aiohttp

один из первых асинхронных веб фреймворков на основе asyncio

# Пример программы на aiohttp, клиент
import asyncio
import time
from typing import Dict

import aiohttp


async def load_one(session: aiohttp.ClientSession) -> Dict[str, str]:
    async with session.get("http://127.0.0.1/") as result:
        return await result.json()


async def load_many() -> None:
    tasks = []
    async with aiohttp.ClientSession() as session:
        for i in range(500):
            tasks.append(asyncio.create_task(load_one(session)))

        for task in tasks:
            res = await task
            print(res)


start = time.monotonic()
asyncio.run(load_many())
duration = time.monotonic() - start
print(duration)  # 1.767098006006563
# пример aiohttp веб сервера
from aiohttp import web


async def hello(request):
    return web.Response(text="Hello, world")


app = web.Application()
app.add_routes([web.get("/", hello)])
web.run_app(app)
import asyncio

from fastapi import FastAPI


app = FastAPI()

# тот самый сервер на котором я тестировал
@app.get("/")
async def hello_world():
    await asyncio.sleep(0.3)
    return {"Hello": "World"}
import time

from fastapi import FastAPI


app = FastAPI()


@app.get("/")
async def hello_world():
    time.sleep(0.3)
    return {"Hello": "World"}

Слегка пошутим

Результат стал хуже в 10 раз, хотя сервер запущен в 10 воркеров. асинхронная попытка выгрузить отработала за 18 секунд, в чем же дело?

Результат стал хуже в 10 раз, хотя сервер запущен в 10 воркеров. асинхронная попытка выгрузить отработала за 18 секунд, в чем же дело?

Вызывая синхронные блокирующие операции в асинхронном приложении, мы блокируем всё приложение.

А что если библиотека, которую мы используем не может в асинхронный код, но аналогов нету?

Text

from concurrent.fututers import ThreadPoolExecutor, ProcessPoolExecutor

await asyncio.run_in_executor(executor, function)

from functools import partial

await asyncio.run_in_executor(executor, partial(function, arg1, arg2))
import asyncio
import functools
import typing


def run_in_threadpool(func):
    @functools.wraps(func)
    def wrap(*args, **kwargs) -> typing.Awaitable[func]:
        def inner():
            return func(*args, **kwargs)

        return asyncio.get_running_loop().run_in_executor(None, inner)

    return wrap
  
@run_in_threadpool
def load_from_database():
    ...
    return res
  
await load_from_database()

Множество библиотек имеют асинхронные реализации

aioredis

import asyncio
import aioredis

async def go():
    redis = await aioredis.create_redis_pool(
        'redis://localhost')
    await redis.set('my-key', 'value')
    val = await redis.get('my-key', encoding='utf-8')
    print(val)
    redis.close()
    await redis.wait_closed()

asyncio.run(go())
# will print 'value'

pytest-asyncio

def test_http_client(event_loop):
    url = 'http://httpbin.org/get'
    resp = event_loop.run_until_complete(http_client(url))
    assert b'HTTP/1.1 200 OK' in resp

@pytest.fixture()
async def async_fixture():
    return await asyncio.sleep(0.1)

@pytest.mark.asyncio
async def test_some_asyncio_code():
    res = await library.do_something()
    assert b'expected result' == res
    

Errors

import asyncio
import time
from typing import Dict

import aiohttp


async def load_one(session: aiohttp.ClientSession) -> Dict[str, str]:
    async with session.get("http://127.0.0.1/") as result:
        return await result.json()


async def load_many() -> None:
    tasks = []
    async with aiohttp.ClientSession() as session:
        for _ in range(10):
            tasks.append(asyncio.create_task(load_one(session)))


start = time.monotonic()
asyncio.run(load_many())
duration = time.monotonic() - start
print(duration)

Пример 1

RuntimeError: Session is closed

Если задача использует контекст, необходимо проследить, что она выполнилась в рамках этого контекста

Вывод: ждем завершения задачи

Пример 2

import asyncio


async def payload_work(num: int) -> None:
    await asyncio.sleep(1)
    print(f'kek {num}')


async def main():
    tasks = [asyncio.create_task(payload_work(i)) for i in range(10)]
    tasks[5].cancel()
    try:
        for task in tasks:
            await task
    except asyncio.CancelledError:
        raise
    except Exception:
        print('error')


asyncio.run(main())

asyncio.CancelledError

Операция была отменена.

В большинстве ситуаций необходимо прокидывать дальше.

Stop use except Exception

reraise CancelledError

Queue

import asyncio

import aiohttp


COUNT = 10


async def fetch(session: aiohttp.ClientSession, num: int, queue: asyncio.Queue) -> None:
    async with session.get("https://official-joke-api.appspot.com/jokes/programming/random") as result:
        await queue.put((num, await result.json()))


async def handle_results(queue: asyncio.Queue) -> None:
    while True:
        num, data = await queue.get()
        print((num, data))


async def main() -> None:
    queue = asyncio.Queue()
    result_task = asyncio.create_task(handle_results(queue))
    timeout = aiohttp.ClientTimeout(total=1.2)
    async with aiohttp.ClientSession(timeout=timeout) as session:
        tasks = []
        for i in range(COUNT):
            tasks.append(asyncio.create_task(fetch(session, i, queue)))
        for task in tasks:
            await task
    await result_task


asyncio.run(main())

FastAPI

  • pydantic
  • openapi
  • удобство
  • скорость написания кода
  • хвалебные отзывы
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    name: str
    price: float
    is_offer: bool = None


@app.get("/")
async def read_root():
    return {"Hello": "World"}


@app.get("/items/{item_id}")
async def read_item(item_id: int, q: str = None):
    return {"item_id": item_id, "q": q}


@app.put("/items/{item_id}")
async def update_item(item_id: int, item: Item):
    return {"item_name": item.name, "item_id": item_id}

Простой пример

  • Все данные провалидируются через pydantic

  • Автоматически сгенерируется сваггер

WebSocket

from fastapi import FastAPI, WebSocket
from fastapi.responses import HTMLResponse

app = FastAPI()

html = """
<!DOCTYPE html>
<html>
... код с js, который создает веб сокет с фронта
</html>
"""


@app.get("/")
async def get():
    return HTMLResponse(html)


@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    while True:
        data = await websocket.receive_text()
        await websocket.send_text(f"Message text was: {data}")

А если из кода?

import asyncio

import aiohttp


async def wc_connection():
    async with aiohttp.ClientSession() as session:
        async with session.ws_connect('http://127.0.0.1:8000/ws') as ws:
            await ws.send_str('hi')
            async for msg in ws:
                await asyncio.sleep(1)
                if msg.type == aiohttp.WSMsgType.TEXT:
                    if msg.data == 'close cmd':
                        await ws.close()
                        break
                    else:
                        await ws.send_str(msg.data)
                        print(msg.data)
                elif msg.type == aiohttp.WSMsgType.ERROR:
                    break


asyncio.run(wc_connection())
Message text was: hi
Message text was: Message text was: hi
Message text was: Message text was: Message text was: hi
Message text was: Message text was: Message text was: Message text was: hi
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    name: str
    price: float
    is_offer: bool = None


@app.get("/")
def read_root():
    return {"Hello": "World"}


@app.get("/items/{item_id}")
def read_item(item_id: int, q: str = None):
    return {"item_id": item_id, "q": q}


@app.put("/items/{item_id}")
def update_item(item_id: int, item: Item):
    return {"item_name": item.name, "item_id": item_id}

Можно писать и синхронные обработчики, 

которые выполнятся в asyncio.run_in_executor

Тесты!

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()


@app.get("/")
async def read_main():
    return {"msg": "Hello World"}


client = TestClient(app)


def test_read_main():
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"msg": "Hello World"}

WSGI -> ASGI

ASGI - духовный насленик wsgi, но нацелен на асинхронность

uvicorn

Tinkoff Python 2020 - 8

By Afonasev Evgeniy

Tinkoff Python 2020 - 8

  • 467