habrahabr

Как я писал асинхронные веб-запросы на Python, или почему провайдер считает, что я бандит

  • четверг, 30 июня 2022 г. в 00:35:17
https://habr.com/ru/post/674150/
  • Python
  • Программирование


На днях по работе потребовалось сделать утилиту, которая прямо вот из консоли ходит в апи нашего клауд сервиса и берет оттуда кое-какую информацию. Подробности что и зачем - вне этого рассказа. Принципиальный вопрос здесь другой - скорость. Скорость реально важна (порядок количества запросов - десятки и сотни). Потому что ждать - не кайф.
Здесь я хочу поделиться своим ресёрчем на тему запросов, как делать круто, а как нет. С примерами кода конечно. А так же рассказать, как я тупил.

Есть только я и неприятности
Есть только я и неприятности

Начнем, пожалуй, с классики

Последовательные синхронные запросы. Будем использовать всем известную либу requests и tqdm для красивого вывода в консоль. В качестве игрушечного примера выбрал первую-попавшуюся публичную апишку: https://catfact.ninja/. Метрикой качества будет RPS (Request per second). Чем выше - тем соотвественно лучше.

import time
import requests
from tqdm import tqdm

URL = 'https://catfact.ninja/'


class Api:
    def __init__(self, url: str):
        self.url = url

    def http_get(self, path: str, times: int):
        content = []
        for _ in tqdm(range(times), desc='Fetching data...', colour='GREEN'):
            response = requests.get(self.url + path)
            content.append(response.json())
        return content


if __name__ == '__main__':
    N = 10
    api = Api(URL)

    start_timestamp = time.time()
    print(api.http_get(path='fact/', times=N))

    task_time = round(time.time() - start_timestamp, 2)
    rps = round(N / task_time, 1)
    print(
        f"| Requests: {N}; Total time: {task_time} s; RPS: {rps}. |\n"
    )

Получаем следующий вывод в терминале:

Fetching data...: 100%|██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 10/10 [00:06<00:00,  1.52it/s]
[{'fact': 'Despite imagery of cats happily drinking milk from saucers, studies indicate that cats are actually lactose intolerant and should avoid it entirely.', 'length': 148}, {'fact': 'The smallest pedigreed cat is a Singapura, which can weigh just 4 lbs (1.8 kg), or about five large cans of cat food. The largest pedigreed cats are Maine Coon cats, which can weigh 25 lbs (11.3 kg), or nearly twice as much as an average cat weighs.', 'length': 249}, {'fact': 'A cat has 230 bones in its body. A human has 206. A cat has no collarbone, so it can fit through any opening the size of its head.', 'length': 130}, {'fact': "Cats' hearing is much more sensitive than humans and dogs.", 'length': 58}, {'fact': 'The first formal cat show was held in England in 1871; in America, in 1895.', 'length': 75}, {'fact': 'In contrast to dogs, cats have not undergone major changes during their domestication process.', 'length': 94}, {'fact': 'Ginger tabby cats can have freckles around their mouths and on their eyelids!', 'length': 77}, {'fact': 'Cats bury their feces to cover their trails from predators.', 'length': 59}, {'fact': 'While it is commonly thought that the ancient Egyptians were the first to domesticate cats, the oldest known pet cat was recently found in a 9,500-year-old grave on the Mediterranean island of Cyprus. This grave predates early Egyptian art depicting cats by 4,000 years or more.', 'length': 278}, {'fact': 'Relative to its body size, the clouded leopard has the biggest canines of all animals’ canines. Its dagger-like teeth can be as long as 1.8 inches (4.5 cm).', 'length': 156}]
| Requests: 10; Total time: 6.61 s; RPS: 1.5. |

RPS - 1.5. Очень грустно. У меня еще и интернет не самый быстрый сейчас дома. Ну тут добавить нечего.

Что можно оптимизировать уже сейчас? Ответ: использовать requests.Session
Eсли делать несколько запросов к одному и тому же хосту, базовое TCP-соединение будет использоваться повторно, что приводит к значительному увеличению производительности. (цитата из документации requests)

Используем сессию

    def http_get_with_session(self, path: str, times: int):
        content = []
        with requests.session() as session:
            for _ in tqdm(range(times), desc='Fetching data...', colour='GREEN'):
                response = session.get(self.url + path)
                content.append(response.json())
        return content

Немного изменив метод, и вызвав его, видим следующее:

Fetching data...: 100%|██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 10/10 [00:02<00:00,  3.88it/s]
[{'fact': 'Cats purr at the same frequency as an idling diesel engine, about 26 cycles per second.', 'length': 87}, {'fact': 'Cats eat grass to aid their digestion and to help them get rid of any fur in their stomachs.', 'length': 92}, {'fact': 'A cat’s heart beats nearly twice as fast as a human heart, at 110 to 140 beats a minute.', 'length': 88}, {'fact': 'The world’s rarest coffee, Kopi Luwak, comes from Indonesia where a wildcat known as the luwak lives. The cat eats coffee berries and the coffee beans inside pass through the stomach. The beans are harvested from the cat’s dung heaps and then cleaned and roasted. Kopi Luwak sells for about $500 for a 450 g (1 lb) bag.', 'length': 319}, {'fact': 'There are more than 500 million domestic cats in the world, with approximately 40 recognized breeds.', 'length': 100}, {'fact': 'Cats sleep 16 to 18 hours per day. When cats are asleep, they are still alert to incoming stimuli. If you poke the tail of a sleeping cat, it will respond accordingly.', 'length': 167}, {'fact': 'Since cats are so good at hiding illness, even a single instance of a symptom should be taken very seriously.', 'length': 109}, {'fact': 'At 4 weeks, it is important to play with kittens so that they do not develope a fear of people.', 'length': 95}, {'fact': 'The technical term for a cat’s hairball is a “bezoar.”', 'length': 54}, {'fact': 'Baking chocolate is the most dangerous chocolate to your cat.', 'length': 61}]
| Requests: 10; Total time: 2.58 s; RPS: 3.9. |

Почти 4 RPS, в сравнении с 1.5 уже прорыв.
Но не секрет, что для сокращения i/o time есть практика использования асихнронных/многопоточных программ. Это как раз такой случай, потому что во время ожидания ответа от сервера наша программа ничего не делает, хотя могла бы отправлять уже другой запрос, а потом другой и т.д. Попробуем реализовать асинхронный подход к решению кейса.

async / await

Для удобства вызовов сделаем функцию-оболочку:

def run_case(func, path, times):
    start_timestamp = time.time()
    
    asyncio.run(func(path, times))

    task_time = round(time.time() - start_timestamp, 2)
    rps = round(times / task_time, 1)
    print(
        f"| Requests: {times}; Total time: {task_time} s; RPS: {rps}. |\n"
    )

И собственно сама реализация метода (не забудьте поставить aiohttp, обычные реквесты не работают в асинхронной парадигме):

    async def async_http_get(self, path: str, times: int):
        async with aiohttp.ClientSession() as session:
            content = []
            for _ in tqdm(range(times), desc='Async fetching data...', colour='GREEN'):
                response = await session.get(url=self.url + path)
                content.append(await response.text(encoding='UTF-8'))
            return content
if __name__ == '__main__':
		N = 50
    api = Api(URL)
    run_case(api.async_http_get, path='fact/', times=N)

Видим:

Async fetching data...: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 50/50 [00:12<00:00,  3.99it/s]
| Requests: 50; Total time: 12.55 s; RPS: 4.0. |

Удивительно, но разницы с предыдущим случаем особо нет. Я думаю выигрыш в i/o тайме компенсируется издержками на передачу управления потоком функциями друг другу (слышал что очень ругают эти моменты в python). Как на самом деле не знаю.

На словах такой подход можно объяснить так.
- В цикле создается корутина, которая отправляет запрос.
- Не дожидаясь ответа, управление потоком отдается снова event loop'у, который создает следующую по "for циклу" корутину, которая тоже отправляет запрос.
- Но теперь прежде чем отдать управление эвент лупу проверяется статус ответа первой корутины. Если она может быть продолжена (получила ответ на запрос), то управление потоком возвращается ей, если нет, то см. пункт 2.
- И так далее

В итоге код-то впринципе асихнронный, но запросы не отправляются "разом". Принципиально иначе подойти к этой ситуации поможет asyncio.gather.

Используем asyncio.gather

Gather - как ни банально с английского собирать. Метод gather собирает коллекцию корутин и запускает их разом (тоже условно конечно). То есть, в отличии от предыдущего случая, мы в цикле создаем корутины, а потом их запускаем.

Было:
[cоздали корутину] -> [запустили корутину] -> [cоздали корутину] -> [запустили корутину] ->
[cоздали корутину] -> [запустили корутину] ->[cоздали корутину] -> [запустили корутину]

А стало:
[cоздали корутину] -> [создали корутину] ->[cоздали корутину] -> [создали корутину] ->
[запустили корутину] -> [запустили корутину] -> [запустили корутину] -> [запустили корутину]

    async def async_gather_http_get(self, path: str, times: int):
        async with aiohttp.ClientSession() as session:
            tasks = []
            for _ in tqdm(range(times), desc='Async gather fetching data...', colour='GREEN'):
                tasks.append(asyncio.create_task(session.get(self.url + path)))

            responses = await asyncio.gather(*tasks)
            return [await r.json() for r in responses]
if __name__ == '__main__':
		N = 50
    api = Api(URL)
    run_case(api.async_gather_http_get, path='fact/', times=N)

И получаем... получаем... ничего не получаем. Курсор продолжает многозначительно мигать в окне терминала. Не работает - подумал Штирлиц.

Путем мучительного дебага и попыток понять, почему мой код не работает, я понял - причина в моем VPN. Его узлы находятся где-то в юрисдикции Cloudflare. А они такое поведение не поощряют, считая, что я бот. Нормальный человек столько запросов в секунду делать не будет, поэтому мои запросы...теряются где-то в пучинах интернета. Ответа на них не будет. Никогда. Корутины просто не заканчиваются.

Окей, поняв откуда ноги растут, запускаем код:

Async gather fetching data...: 100%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 50/50 [00:00<00:00, 106834.03it/s]
| Requests: 50; Total time: 2.38 s; RPS: 21.0. |

Цифры выросли, круто, 21 не 1.5 - это уж точно.
Однако же есть какой-то предел, увеличим N (число запросов) до 200.

| Requests: 200; Total time: 2.97 s; RPS: 67.3. |

Это что же получается, можно и так? На самом деле нет. Если внимательно рассмотреть, что же все-таки нам отвечает сервер, то заметим, что большая часть ответов это {'message': 'Rate Limit Exceeded', 'code': 429}

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

Что там с threading?

Тут особо смысла нет - практический эксперимент показал, что threading показывает такие же результаты (плюс - минус), как асихнронный код из пункта 3.

multiprocessing

Не буду врать, просто посмотрел как нечто похожее делал какой-то индус с Ютуба. Результаты сильно хуже, чем у предыдущих способов. Да и писать такой код - это насилие над своей психикой. А я свою психику берегу.

Подведу итоги:

  • На днях упал Cloudlfare - извините, это из-за меня, больше не буду.

  • Хотите быть чемпионом по запросам - используйте asyncio.gather, но с тщательно подобранными лимитами. Если вы ходите не на один хост, а в разные источники, то вообще не стестяйтесь.