Библиотека сайта rus-linux.net
Веб бот, использующий сопрограммы asyncio
Предварительная публикация главы из сборника "500 строк или меньше", новой книги серии «The Architecture of Open Source Applications»
Оригинал: "A Web Crawler With asyncio Coroutines"
Автор: A. Jesse Jiryu Davis and Guido van Rossum
Дата публикации: 15 September 2015
Перевод: Н.Ромоданов
Дата перевода: январь 2016 г.
Creative Commons
Перевод был сделан в соответствие с лицензией Creative Commons. С русским вариантом лицензии можно ознакомиться здесь.
Это предварительная публикация главы из сборника «500 строк или меньше», четвертой книги из серии книг Архитектура приложений с открытым исходным кодом. Пожалуйста, сообщайте о любых проблемах, которые вы обнаружите при чтении этой главы, в нашем треккере GitHub. Следите за объявлениями о предварительных публикациях новых глав и окончательной публикацией в блоге AOSA или в Твиттере.
Сегодня мы представляем третью главу предварительной публикации нашего нового сборника «500 строк или меньше». Глава была написана А. Джесси Джури Дэвис (A. Jesse Jiryu Davis) и Гвидо ван Россумом (Guido van Rossum).
Мы надеемся, что эта глава поможет программистам, использующим язык Python, подробно разобраться с тем, как работают сопрограммы, а также почему и когда нам следует ими пользоваться. С того момента, когда был выпущен фреймворк asyncio, сопрограммы стали горячей темой в языке Python; сейчас, в версии Python 3.5, они уже встроены непосредственно в сам язык.
Если не ограничиваться языком Python, то в области компьютерных наук есть давнее противостояние между системами, использующими потоки , и системами, использующими события. Практикующему программисту полезно понимать нюансы этой дискуссии, а те, кто пользуется фреймворками, в основе которых лежат эти подходы, несомненно должны в них разбираться.
В сборнике «500 строк или меньше» у нас будут главы, рассказывающие о создании систем, ориентированных на использовании потоков, и систем, ориентированных на использовании событий, но мы надеемся, что в окончательном варианте сборника данная глава будет хорошо выглядеть на фоне этих глав.
Если вы обнаружите ошибки, о которых, как вы считаете, стоит сообщить, пожалуйста, сделайте запись на нашем треккере GitHub.
Приятного чтения!
Это предварительная публикация главы из сборника «500 строк или меньше», четвертой книги из серии книг Архитектура приложений с открытым исходным кодом. Пожалуйста, сообщайте о любых проблемах, которые вы обнаружите при чтении этой главы, в нашем треккере GitHub. Следите за объявлениями о предварительных публикациях новых глав и окончательной публикацией в блоге AOSA или в Твиттере.>
А. Джесси Джури Дэвис работает инженером в MongoDB в Нью-Йорке. Он написал Motor, асинхронный драйвер для языка Python в MongoDB, и он является ведущим разработчиком драйвера для C в MongoDB C и входит в состав команды, работающей над PyMongo. К его разработкам относятся asyncio и Торнадо. Он пишет на сайте http://emptysqua.re.>
Гвидо ван Россум является создателем языка Python, одного из основных языков программирования, используемого как в сети, так и вне сети. Сообщество Python называет его великодушным диктатором (BDFL - Benevolent Dictator For Life), титулом, взятом непосредственно из пародии Монти Пайтона. Домашекй страничкой Гвидо в сети является http://www.python.org/~guido/.>
Введение
В классической компьютерной науке эффективными алгоритмами считаются те, которые выполняют вычисления максимально быстро. Но многие сетевые программы тратят свое время не на вычисления, а на удержание открытыми большого количества соединений, работающих медленно, а события, возникающих в этих соединениях, достаточно редки. С этими программами возникают очень разные проблемы: необходимо эффективно ждать огромное количество сетевых событий. Современным подходом к решению этой проблемы является асинхронный ввод/вывод или технология "async".
В настоящей главе представлен простой веб-бот. Бот является типичным асинхронным приложением, поскольку оно, в основном, ждет большое количество ответов, но вычислений делает немного. Чем больше страниц оно может обработать за один раз, тем быстрее оно завершится. Если оно для каждого запроса будет выделять отдельный поток, то прежде, чем приложение нормальным образом завершит работу, оно отключится в следствии нехватки памяти или иного ресурса, связанного с обслуживанием потоков, из-за того, что число одновременно обрабатываемых запросов будет постоянно увеличиваться. Этого можно избежать, если при работе с потоками воспользоваться асинхронным вводом/выводом.
Мы разделим наше представление на три этапа. Сначала мы расскажем о цикле асинхронных событий и представим общую схему бота, в котором вместе с технологией обратных вызовов (callbacks) будет использоваться цикл событий: это очень эффективный, подход, но распространение его на более сложные проблемы ведет к неконтролируемому росту запутанности кода (код в стиле "спагетти"). Затем мы покажем, что сопрограммы, написанные на языке Python, являются одновременно эффективными и расширяемыми. Мы с помощью функций-генераторов создадим на языке Python простые сопрограммы. На третьем этапе мы воспользуемся полнофункциональными сопрограммами из стандартной библиотеки asyncio языка Python [1] и скоординируем их работу с помощью очереди асинхронных событий.
Задача
Веб-бот находит и загружает все страницы, имеющиеся на сайте, а также, возможно записывает их в архив или индексирует. Он начинает работу с корневого адреса URL, извлекает каждую страницу, находит на каждой странице ссылки, о которых он еще не знал, и добавляет новые ссылки в очередь. Когда он находит страницу, в которой нет в которой нет ссылок, и очередь пуста, то он останавливается.
Мы можем ускорить этот процесс, если будем загружать много страниц одновременно. Как только бот находит новые ссылки, он на разных сокетах запускает одновременно несколько операций извлечения новых страниц. По мере того, как поступают результаты запросов, он их анализирует и добавляет новые ссылки в очередь. В какой-то момент скорость работы может пойти на убыль поскольку слишком много параллельно выполняемых операций ухудшает производительность; поэтому мы ограничиваем количество число одновременных запросов, и не выбираем из очереди ссылки до тех пор, пока не будут завершены некоторые уже активные запросы.
Традиционный подход
Как мы можем сделать так, чтобы действия бота были распараллелены? Традиционно мы должны бы были создать пул потоков. Каждый поток в каждый конкретный момент будет отвечать за загрузку через сокет одной страницы. Например, чтобы загрузить страницу из xkcd.com:
def fetch(url): sock = socket.socket() sock.connect(('xkcd.com', 80)) request = 'GET {} HTTP/1.0\r\nHost: xkcd.com\r\n\r\n'.format(url) sock.send(request.encode('ascii')) response = b'' chunk = sock.recv(4096) while chunk: response += chunk chunk = sock.recv(4096) # Page is now downloaded. links = parse_links(response) q.add(links)
По умолчанию, операции с сокетами являются блокирующими (blocking): когда поток обращаемся к методу, например, connect
или recv
, то он приостанавливается до тех пор, пока операция не будет завершена [2]. Поэтому для того, чтобы сразу скачать много страниц, нам потребуется много потоков. Сложное приложение может сократить затраты на создание потоков за счет сохранения потоков, находящихся в пуле потоков в режиме ожидания, а затем проверять их для того, чтобы воспользоваться ими для последующих задач; то же самое происходит с сокетами в пуле соединений.
И все же использование потоков затратно и поэтому в операционных системах применяются различные жесткие ограничения на количество потоков, которыми может пользоваться процесс, пользователь или машина. В системе у Джесси расходы на один поток на языке Python составляют приблизительно 50k памяти, и запуск десятков тысяч потоков приведет к сбою системы. Если наши масштабы доходят до десятков тысяч одновременных операций на одновременно используемых сокетах, то мы исчерпаем потоки раньше, чем исчерпаем сокеты. Нагрузка по числу потоков и системные ограничения на количество одновременного используемых потоков является узким местом.
В своем влиятельной статье "Проблема C10K" ("The C10K problem" [3]), Дэн Кегель (Dan Kegel) сформулировал ограничения многопоточности на параллельное выполнение операций ввода/вывода. Он заявил следующее:
Пришло время, когда веб серверы должны обрабатывать десятки тысяч клиентов одновременно, разве это не так? В конце концов интернет стал сегодня большим.
Кегель придумал термин "C10K" в 1999 году. Сейчас фраза о десяти тысячах соединений звучит не так страшно, но проблема изменилась только количественно, а не по сути. В то время использование потока соединений для решения задачи C10K было непрактичным. Теперь ограничение стало на порядки выше. Действительно, наш игрушечный бот будет отлично работать с потоками. Тем не менее, для приложений очень большого масштаба с сотнями тысяч соединений ограничение остается: есть предел, за которым большинство систем все еще может создавать сокеты, но потоки при этом будут исчерпаны. Как мы можем это преодолеть?
Асинхронность
Фреймворки асинхронного ввода/вывода выполняют параллельные операции в одном потоке. Давайте разберемся с тем, как это происходит.
Асинхронно работающие фреймворки используют неблокирующие (non-blocking) сокеты. В нашем асинхронном боте мы прежде, чем станем подключаться к серверу, создадим неблокирующий сокет:
sock = socket.socket() sock.setblocking(False) try: sock.connect(('xkcd.com', 80)) except BlockingIOError: pass
Раздражает то, что неблокирующий сокет порождает исключение в connect
, даже в случае, если он работает нормально. Это исключение повторяет раздражающее поведение лежащей в его основе функции C, которая для того, чтобы сказать вам, что процесс начался, устанавливает переменную errno
в состояние EINPROGRESS.
Теперь нашему боту нужен способ узнать, когда соединение установлено с тем, чтобы он мог отправить запрос HTTP. Мы могли бы просто попытаться проверить это в цикле:
request = 'GET {} HTTP/1.0\r\nHost: xkcd.com\r\n\r\n'.format(url) encoded = request.encode('ascii') while True: try: sock.send(encoded) break # Done. except OSError as e: pass print('sent')
Этот метод не только зазря тратит ресурсы, но он даже не сможет эффективно обработать всего лишь несколько сокетов. В давние времена для решения этой проблемы в системе BSD Unix использовался select
, функция, написанная на С, которая ждет событие на неблокирущем сокете или на небольшом массиве сокетов. В настоящее время необходимость использовать для интернет-приложений огромного количества соединений привело к замене этой функции на функцию poll
, а затем замене ее на на функцию kqueue в системе BSD и на функцию epoll
в системе Linux. Эти API похожи на функцию select
, но они хорошо работают с очень большим числом соединений.
В вашей системе для Python 3.4 лучшей функцией, похожей на select
, будет функция DefaultSelector
. Чтобы регистрировать уведомления, относящиеся к сетевому вводу/выводу, мы создадим неблокирующий сокет и зарегистрируем его с помощью указанного выше селектора:
from selectors import DefaultSelector, EVENT_WRITE selector = DefaultSelector() sock = socket.socket() sock.setblocking(False) try: sock.connect(('xkcd.com', 80)) except BlockingIOError: pass def connected(): selector.unregister(sock.fileno()) print('connected!') selector.register(sock.fileno(), EVENT_WRITE, connected)
Мы игнорируем ложное сообщение об ошибке и вызываем selector.register
, передавая дескриптор файла сокета и константу, в которой указывается, какое событие мы ожидаем. Чтобы получить уведомление о том, что соединение установлено, мы передаем EVENT_WRITE
: то есть, мы хотим знать, когда в сокет можно будет делать "записи". Мы также передаем функцию connected, написанную на языке Python, которая будет запущена при наступлении этого события. Такая функция называется функцией обратного вызова (callback).
По мере того, как селектор будет получать уведомления, мы будем их обрабатывать в цикле:
def loop(): while True: events = selector.select() for event_key, event_mask in events: callback = event_key.data callback()
Функция обратного вызова connected
запоминается в виде данных event_key.data
, которые мы получаем и однократно выполняем в тот момент, когда произойдет подключение к неблокирующему сокету.
В отличие от нашего быстро работающего цикла, который был приведен выше, при вызове select
возникает пауза ожидания следующего события ввода/вывода. Затем внутри цикла будут запущены функции обратного вызова, которые ожидали этих событий. Операции, которые не были завершены, будут приостановлены до некоторого следующего такта цикла событий.
Что мы уже продемонстрировали? Мы продемонстрировали методику того, как начинать операцию и как, когда операция будет готова к выполнению, выполнить функцию обратного вызова,. Фреймворк async основан на использовании этих на двух функций, которые, как мы продемонстрировали, пользуются неблокирующими сокетами и циклом событий для запуска в одном потоке одновременно исполняемых операций (concurrent operations).
Мы здесь добились "одновременности" ("concurrency"), а не того, что традиционно называется "распараллеливанием" ("parallelism"). То есть, мы построили крошечный систему, которая выполняет перекрывающие друг друга операции ввода/вывода. Она способна запускать новые операции, тогда как другие операции еще продолжают выполняться. Она, на самом деле, не использует сразу несколько ядер с тем, чтобы выполнять вычисления параллельно. Но эта система предназначена для задач, связанных с вводом/выводом, а не с загрузкой процессора [4].
Таким образом, наш цикл событий эффективен для одновременно выполняемых операций ввода/вывода, поскольку в нем для каждого соединения не выделяется отдельный ресурс в виде потока. Но прежде чем мы продолжим, важно разобраться с общее недоразумением, считающим, что асинхронность работает быстрее, чем многопоточность. Часто это не так — на самом деле, в языке Python, цикл обработки событий, такой как наш, работает медленнее, чем многопоточное решение, обслуживающее небольшое количество очень активных соединений. В режиме, когда отсутствуют взаимные блокировки, многопоточное решение будет показывать лучшую производительность. Что касается асинхронного ввода/вывода, то это правильное решения для ситуаций с большим количеством медленных или совсем пропадающих соединений, события в которых происходят сравнительно редко [5].
Программирование с использованием обратных вызовов
Если с этим хилым фреймворком асинхронного доступа нам бы потребовалось продвинуться дальше, то каким бы образом мы могли создать веб бот? Создание даже простого сборщика адресов URL, это достаточно болезненное дело.
Мы начнем с глобальных наборов set для адресов URL, которые нам еще нужно обработать, и адресов URL, которые мы уже просмотрели:
urls_todo = set(['/']) seen_urls = set(['/'])
В набор seen_urls
входят адреса из набора urls_todo
плюс уже обработанные адреса URL. Эти два набора инициализированы корневым адресом URL - "/".
Для извлечения страницы потребуется несколько обратных вызовов. Когда произойдет подключение к сокету, то будет запущена функция обратного вызова connected
, которая пошлет серверу запрос GET. Но затем потребуется ждать ответа, так что будет зарегистривана еще одна функция обратного вызова. И если в случае, когда эта функция будет запущена, не удастся прочитать ответ полностью, то нужно будет снова зарегистрировать еще одну функцию обратного вызова, и так далее.
Давайте соберем эти обратные вызовы в объект Fetcher
. Ему нужен адрес URL, объект-сокет и место, где можно будет накапливать байты ответа:
class Fetcher: def __init__(self, url): self.response = b'' # Empty array of bytes. self.url = url self.sock = None
Начнем с вызова Fetcher.fetch
:
# Method on Fetcher class. def fetch(self): self.sock = socket.socket() self.sock.setblocking(False) try: self.sock.connect(('xkcd.com', 80)) except BlockingIOError: pass # Register next callback. selector.register(self.sock.fileno(), EVENT_WRITE, self.connected)
Метод fetch
начинает подключение к сокету. Но обратите внимание, что метод возвращает управление раньше, чем будет установлено соединение. Для того, чтобы ожидать соединение, он должен вернуть управление в цикл обработки событий. Чтобы понять, из-за чего это нужно, представьте себе, что все приложение структурировано следующим образом:
# Begin fetching http://xkcd.com/353/ fetcher = Fetcher('/353/') fetcher.fetch() while True: events = selector.select() for event_key, event_mask in events: callback = event_key.data callback(event_key, event_mask)
Все уведомления о события обрабатываются в цикле обработки событий, когда этот цикл вызываетинструкцию select
. Поэтому для того, чтобы программа знала, когда подключен сокет, метод fetch
должен передавать управление в цикл событий. Только после этого в цикле будет запущена функция обратного вызова connected
, которая была зарегистрирована в конце метода fetch
, приведенного выше.
Ниже приведена реализация метода connected
:
# Method on Fetcher class. def connected(self, key, mask): print('connected!') selector.unregister(key.fd) request = 'GET {} HTTP/1.0\r\nHost: xkcd.com\r\n\r\n'.format(self.url) self.sock.send(request.encode('ascii')) # Register the next callback. selector.register(key.fd, EVENT_READ, self.read_response)
Метод посылает запрос GET. В том случае, если все сообщение не может быть отправлено за один раз, реальное приложение будет проверять возвращаемое значение send
. Но наш запрос маленький и наше приложение несложное. Оно беспечно вызывает метод send
, а затем ожидает ответа. Конечно, оно должно зарегистрировать еще один обратный вызов и передать управление в цикл обработки событий. Следующий и последний обратный вызов read_response
обрабатывает ответ сервера:
# Method on Fetcher class. def read_response(self, key, mask): global stopped chunk = self.sock.recv(4096) # 4k chunk size. if chunk: self.response += chunk else: selector.unregister(key.fd) # Done reading. links = self.parse_links() # Python set-logic: for link in links.difference(seen_urls): urls_todo.add(link) Fetcher(link).fetch() # <- New Fetcher. seen_urls.update(links) urls_todo.remove(self.url) if not urls_todo: stopped = True
Обратный вызов выполняется каждый раз, когда селектор видит, что сокет можно "прочитаь", что может означать две вещи: в сокете есть данные или он закрыт.
Обратный вызов запрашивает из сокета фрагмент данных, размером до четырех килобайт. Если размер фрагмента меньше, то прием данных завершен и в chunk
находятся все данные. Если размер фрагмента больше, то в chunk
поступает фрагмент размером в четыре килобайта, а сокет остается в состоянии, когда из него можно читать данные; поэтому на следующем отсчете цикл событий снова запускает этот обратный вызов. Когда ответ будет завершен, сервер закроет сокет и в chunk
ничего не будет.
Метод parse_links
, который здесь не показан, возвращает набор адресов URL. Мы для каждого нового адреса URL запускаем новый экземпляр сборщика fetch
и не думаем ни о каком одновременном исполнении потоков. Обратите внимание на хорошую особенность асинхронного программирования с обратными вызовами: нам не нужны никакие мьютексы, окружающие совместно используемые данные, например, когда мы в seen_urls
добавляем ссылки. Здесь нет вытесняющей многозадачности, поэтому работа нашего кода не будет прерываться в произвольных местах.
Мы добавим глобальную переменную stopped и будем использовать ее для управления циклом:
stopped = False def loop(): while not stopped: events = selector.select() for event_key, event_mask in events: callback = event_key.data callback()
После того, как все страницы будут скачаны, сборщик останавливает глобальный цикл событий и программа завершается.
В этом примере становится явно видна проблема асинхронных вычислений: код в виде спагетти.
Нам нужно некоторым образом задавать последовательности вычислений и операций ввода/вывода и указывать, что несколько таких последовательностей будут запускаться одновременно. Но без использования потоков последовательности операций не могут быть собраны в виде одной функции: каждый раз, когда функция начинает операцию ввода/вывода, она должна явно сохранить свое состояние, которое ей потребуется в будущем, а затем возвратить управление. На вас возлагается обязанность подумать о том, как написать такой код, который будет сохранять состояние функции.
Поясним, что мы имеем в виду. Рассмотрим, как мы просто выполняем получение адреса URL в некотором потоке с обычным блокирующим сокетом:
# Blocking version. def fetch(url): sock = socket.socket() sock.connect(('xkcd.com', 80)) request = 'GET {} HTTP/1.0\r\nHost: xkcd.com\r\n\r\n'.format(url) sock.send(request.encode('ascii')) response = b'' chunk = sock.recv(4096) while chunk: response += chunk chunk = sock.recv(4096) # Page is now downloaded. links = parse_links(response) q.add(links)
Какое состояние эта функция должна запоминать между некоторой одной операцией, выполняемой на сокете, и следующей? У нее есть сокет, адрес URL и буфер для накапливания ответа response
. Функция, которая работает в отдельном потоке, использует обычные средства языка программирования для сохранения этого временного состояния в локальных переменных в стеке этого потока. В этой функции также есть "продолжение", то есть код, который планируется выполнить после завершения ввода/вывода. На этапе выполнения место, где начинается такое продолжение, запоминается при помощи сохранения указателя, указывающего инструкцию, исполняемую в этом потоке. Вам не нужно думать о восстановлении этих локальных переменных и о том, как продолжить работу после завершения ввода/вывода. Все это встроено в язык.
Но при использовании фреймворка асинхронного ввода/вывода эти особенности языка программирования нам не помогут. Когда происходит ожидание ввода/вывода, функция должна явно сохранять свое состояние, т. к. функция прежде, чем будет завершен ввод/вывод, возвратит управление и не запомнит свое состояние. В нашем случае вариант, в котором используются функции обратного вызова, состояние запоминается в переменных sock
и response
, являющимися атрибутами переменной self
, а не в локальных переменных. Вместо указателя команд в этом случае используется регистрация обратных вызовов connected
и read_response
. По мере того, как увеличивается количество особенностей конкретного приложения, усложняется состояние, которое мы должны вручную запоминать при использовании обратных вызовов. Такая обременительная бухгалтерия становится головной болью для кодера.
Еще хуже то, что происходит в случае, если в потоке, в котором используется обратный вызов, исключение возникает прежде, чем управление будет передано в следующий в цепочке обратный вызов. Скажем, мы плохо запрограммировали метод parse_links
и при разборе некоторых вариантов HTML в нем возникает исключение:
Traceback (most recent call last): File "loop-with-callbacks.py", line 111, in <module> loop() File "loop-with-callbacks.py", line 106, in loop callback(event_key, event_mask) File "loop-with-callbacks.py", line 51, in read_response links = self.parse_links() File "loop-with-callbacks.py", line 67, in parse_links raise Exception('parse error') Exception: parse error
Трассировка стека показывает только то, что в цикле событий был запущен обратный вызов. Мы не узнаем, что привело к ошибке. Цепочка оборвана с двух концов: мы забыли, где куда мы шли и откуда мы пришли. Такая потеря контекста называется "исчезновение состояния стека" и она во многих случаях ставит исследователя в тупик. Исчезновение состояния стека также мешает нам устанавливать обработчик исключений для цепочки обратных вызовов, способ, которым блок "try / except" изолирует вызов вызова функции от дерева его потомков [6].
Поэтому даже если уйти от долгих обсуждений сравнительной эффективности многопоточности и асинхронного подхода, есть еще одна тема обсуждений — о том, какой подход более подвержен ошибкам: в потоках может возникнуть состояние гонки данных (data races) в случае, если вы допустите ошибку синхронизации потоков, но обратные вызовы очень тяжело отлаживать из-за исчезновения состояния стека.
Сопрограммы
Мы заинтригуем вас обещанием. Можно написать асинхронный код, который сочетает в себе эффективность обратных вызовов с классическим хорошим внешним видом многопоточного программирования. Такое сочетание достигается с помощью паттерна, который называется "сопрограммы". Если использовать стандартную библиотеку asyncio, имеющуюся в Python 3.4, и пакет, который называется "aiohttp", то получение адреса URL в сопрограмме происходит сравнительно просто [7]:
@asyncio.coroutine def fetch(self, url): response = yield from self.session.get(url) body = yield from response.read()
Это решение также и масштабируемо. По сравнению с 50k памяти на каждый поток и жесткие ограничения со стороны операционной системы на потоки, сопрограмма на языке Python в системе Джесси занимает едва 3k памяти. Python может легко запустить сотни тысяч сопрограмм.
Понятие сопрограммы, начиная со стародавних дней информатики, проста: это подпрограмма, работа которой может быть приостановлена и возобновлена. В то время как управление потоками реализуется операционной системой по принципам упреждающей многозадачности, работа сопрограмм в многозадачном режиме реализуется по принципам кооперативной работы: осуществляется выбор, когда нужно сделать паузу и какая сопрограмма будет работать следующей.
Есть множество реализаций сопрограмм; даже в языке Python их несколько. Сопрограммы в стандартной библиотеке "asyncio" в Python 3.4 создаются с использованием генераторов, класса Future (фьючерсов) и инструкции "yield from". Начиная с версии Python 3.5 сопрограммы включены в состав самого языка [8]; однако, если разобраться с тем, как сопрограммы были впервые реализованы в версии Python 3.4 с использованием уже существующих объектов языка, то это послужит основой для правильного использования нативных сопрограмм в Python 3.5.
Чтобы рассказать о сопрограммах в Python 3.4, базирующихся на использовании генераторов, мы перейдем к рассмотрению генераторов и того, как они используются в качестве сопрограмм в asyncio, и, надеюсь, что вы получите от чтения столько же удовольствия, сколько мы получили при их описании. После того, как мы расскажем о сопрограммах, базирующихся на генераторах, мы воспользуемся ими в нашем асинхронном веб боте.