### Многозадачность В asyncio для организации конкурентности используется кооперативная многозадачность. Когда приложение доходит до точки, в которой может подождать результата, мы явно помечаем это в коде. Поэтому другой код может работать, пока мы ждем получения результата, вычисляемого в фоновом режиме. Как только вычисление результата завершится, мы «просыпаемся» и возобновляем задачу. Это является формой конкурентности, потому что несколько задач может работать одновременно, но – и это очень важно – не параллельно, так как их выполнение чередуется. У кооперативной многозадачности есть ряд преимуществ перед вытесняющей. Во-первых, кооперативная многозадачность потребляет меньше ресурсов. Когда операционной системе нужно переключиться между потоками или процессами, мы говорим, что имеет место контекстное переключение. Это трудоемкая операция, потому что операционная система должна сохранить всю информацию о работающем процессе или потоке, чтобы потом его можно было возобновить. Второе преимущество – гранулярность. Операционная система приостанавливает поток или процесс в соответствии со своим алгоритмом планирования, но выбранный для этого момент не всегда оптимален. В случае кооперативной многозадачности мы явно помечаем точки, в которых приостановить задачу наиболее выгодно. Это дает выигрыш в эффективности, потому что мы переключаем задачи именно в тот момент, когда это нужно. ### Глобальная блокировка интерпретатора (GIL) Говоря кратко, GIL не дает Python-процессу исполнять более одной команды байт-кода в каждый момент времени. Это означает, что, даже если имеется несколько потоков на многоядерной машине, интерпретатор сможет в каждый момент исполнять только один поток, содержащий написанный на Python код. Так для чего же нужна GIL? В CPython память управляется в основном с помощью подсчета ссылок. То есть для каждого объекта Python, например целого числа, словаря или списка, подсчитывается, сколько объектов в данный момент используют его. Когда объект перестает быть нужным кому-то, счетчик ссылок на него уменьшается, а когда кто-то новый обращается к нему, счетчик ссылок увеличивается. Если счетчик ссылок обратился в нуль, значит, на объект никто не ссылается, поэтому его можно удалить из памяти. Конфликт потоков возникает из-за того, что интерпретатор CPython не является потокобезопасным. Это означает, что если два или более потоков модифицируют разделяемую переменную, то ее конечное состояние может оказаться неожиданным, поскольку зависит от порядка доступа к переменной со стороны потоков. Эта ситуация называется состоянием гонки. Состояния гонки могут возникать, когда два потока одновременно обращаются к одному объекту Python. Для демонстрации влияния GIL рассмотрим счетную задачу вычисления n-го числа Фибоначчи, будем использовать рекурсию, поэтому сложность алгоритма будет O(2^N). Многопоточная версия будет работать почти столько же времени, сколько и однопоточная, даже на самом деле даже чуть дольше! Это все из-за GIL и накладных расходов на создание и управление потоками. Да, потоки выполняются конкурентно, но в каждый момент времени только одному из них разрешено выполнять Python-код. А второй поток вынужден ждать завершения первого, что сводит на нет весь смысл нескольких потоков. ### Освобождается ли когда-нибудь GIL? Возможна ли вообще многопоточная конкурентность в Python, так как GIL запрещает одновременное выполнение двух строк Python-кода? GIL освобождается на время выполнения операций ввода-вывода. Это позволяет использовать потоки для конкурентного выполнения ввода-вывода, но не для выполнения счетного кода, написанного на Python. Так почему же GIL освобождается при вводе-выводе, но не освобождается для счетных задач? Все дело в системных вызовах, которые выполняются за кулисами. В случае ввода-вывода низкоуровневые системные вызовы работают за пределами среды выполнения Python. Это позволяет освободить GIL, потому что код операционной системы не взаимодействует напрямую с объектами Python. GIL захватывается снова, только когда полученные данные переносятся в объект Python. Стало быть, на уровне ОС операции ввода-вывода выполняются конкурентно. Эта модель обеспечивает конкурентность, но не параллелизм. В других языках, например Java или C++, на многоядерных машинах можно организовать истинный параллелизм, потому что никакой GIL нет и код может выполняться строго одновременно. Но в Python лучшее, на что можно рассчитывать, – конкурентность операций ввода-вывода, поскольку в любой момент может выполняться только один кусок написанного на Python кода. ### Что такое сокет? Сокет – низкоуровневая абстракция отправки и получения данных по сети. Именно с её помощью производится обмен данными между клиентами и серверами. По умолчанию сокеты блокирующие. Это значит, что на все время ожидания ответа от сервера приложение приостанавливается или блокируется. Следовательно, оно не может ничего делать, пока не придут данные от сервера или произойдет ошибка или случится тайм-аут. На уровне операционной системы эта блокировка ни к чему. Сокеты могут работать в неблокирующем режиме, когда мы просто начинаем операцию чтения или записи и забываем о ней, а сами занимаемся другими делами. Но позже операционная система уведомляет нас о том, что байты получены, и в этот момент мы можем уделить им внимание. Это позволяет приложению не ждать прихода байтов, а делать что-то полезное. Для реализации такой схемы применяются различные системы уведомления с помощью событий, разные в разных ОС. Библиотека asyncio абстрагирует различия между системами уведомления, а именно: - kqueue – FreeBSD и MacOS; - epoll – Linux; - IOCP (порт завершения ввода-вывода) – Windows. Эти системы наблюдают за неблокирующими сокетами и уведомляют нас, когда с сокетом можно начинать работу. Именно они лежат в основе модели конкурентности в asyncio. В этой модели имеется только один поток, исполняющий Python-код. Встретив операцию ввода-вывода, интерпретатор передает ее на попечение системы уведомления, входящей в состав ОС. Совершив этот акт, поток Python волен исполнять другой код или открыть другие неблокирующие сокеты, о которых позаботится ОС. По завершении операции система «пробуждает» задачу, ожидающую результата, после чего выполняется код, следующий за этой операцией. Именно это позволяет asyncio организовать конкурентность в одном потоке. ### Как работает цикл событий Цикл событий – сердце любого приложения asyncio. По сути цикл событий это очередь, в которой хранится список событий или сообщений, а затем вход в бесконечный цикл, где обрабатываются сообщения по мере их поступления. В Python базовый цикл событий мог бы выглядеть следующим образом: ``` from collections import deque messages = deque() while True: if messages: message = messages.pop() process_message(message) ``` В asyncio цикл событий управляет очередью задач, а не сообщений. Задача – это обертка вокруг сопрограммы. Сопрограмма может приостановить выполнение, встретив операцию ввода-вывода, и дать циклу событий возможность выполнить другие задачи, которые не ждут завершения ввода-вывода. Создавая цикл событий, мы создаем пустую очередь задач. Затем добавляем в эту очередь задачи для выполнения. На каждой итерации цикла проверяется, есть ли в очереди готовая задача, и если да, то она выполняется, пока не встретит операцию ввода-вывода. В этот момент задача приостанавливается, и мы просим операционную систему наблюдать за ее сокетами. А сами тем временем переходим к следующей готовой задаче. На каждой итерации проверяется, завершилась ли какая-нибудь операция ввода-вывода; если да, то ожидавшие ее завершения задачи пробуждаются и им предоставляется возможность продолжить работу. ### Сопрограммы Сопрограмму можно рассматривать как обычную функцию Python, наделенную сверхспособностью: приостанавливаться, встретив операцию, для выполнения которой нужно заметное время. По завершении такой длительной операции сопрограмму можно «пробудить», после чего она продолжит выполнение. Пока приостановленная сопрограмма ждет завершения операции, мы можем выполнять другой код. Такое выполнение другого кода во время ожидания и обеспечивает конкурентность внутри приложения. Можно также одновременно выполнять несколько длительных операций, что еще больше повышает производительность приложения. Для создания и приостановки сопрограммы используются ключевые слова async и await. Слово async определяет сопрограмму, а слово await приостанавливает ее на время выполнения длительной операции. Важный момент – сопрограммы не выполняются, если их вызвать напрямую. Вместо этого возвращается объект сопрограммы, который будет выполнен позже. Чтобы выполнить сопрограмму, мы должны явно передать ее циклу событий. ### Как создать цикл событий и выполнить в нем нашу сопрограмму? В версиях Python, предшествующих 3.7, цикл событий нужно было создавать вручную, если его еще не было. Но затем в asyncio было добавлено несколько функций, абстрагирующих управление циклом событий. Одна из них – вспомогательная функция asyncio.run, которую можно использовать для запуска нашей сопрограммы. ### Приостановка выполнения с помощью ключевого слова await Достоинство asyncio – возможность приостановить выполнение и дать циклу событий возможность выполнить другие задачи, пока длительная операция делает свое дело. Для приостановки выполнения служит ключевое слово await, за ним обычно следует обращение к сопрограмме (точнее, к объекту, допускающему ожидание) Использование ключевого слова await приводит к выполнению следующей за ним сопрограммы, а не просто к возврату объекту сопрограммы, как при прямом вызове. Кроме того, выражение await приостанавливает объемлющую сопрограмму до того момента, как сопрограмма, которую мы ждем, завершится и вернет результат. А после этого мы получим доступ к возвращенному результату, а объемлющая сопрограмма пробудится и обработает результат. Ключевое слово await следует поместить перед вызовом сопрограммы. ### Задачи Непосредственный вызов сопрограммы не передает ее циклу событий для выполнения. Вместо этого мы получаем объект сопрограммы, который нужно затем использовать совместно с ключевым словом await или передать функции asyncio.run, чтобы получить возвращенное значение. Располагая только этими инструментами, мы можем написать асинхронный код, но не можем выполнить его конкурентно. А чтобы это сделать, нужны задачи. Задача – это обертка вокруг сопрограммы, которая планирует выполнение последней в цикле событий как можно раньше. И планирование, и выполнение происходят в неблокирующем режиме, т. е., создав задачу, мы можем сразу приступить к выполнению другого кода, пока эта задача работает в фоне. Сравните с ключевым словом await, которое блокирует выполнение, т. е. мы приостанавливаем всю сопрограмму на время, пока выражение await не вернет управление. Способность создавать задачи и планировать их для немедленного выполнения в цикле событий означает, что несколько задач может работать приблизительно в одно и то же время. Пока одна задача выполняет длительную операцию, другие могут работать конкурентно. ``` import asyncio from util import delay async def main(): sleep_for_three = asyncio.create_task(delay(3)) sleep_again = asyncio.create_task(delay(3)) sleep_once_more = asyncio.create_task(delay(3)) await sleep_for_three await sleep_again await sleep_once_more asyncio.run(main()) ``` Выполнение задач планируется «как можно раньше». На практике это означает, что в точке, где встречается первое после создания задачи предложение await, все ожидающие задачи начинают выполняться, так как await запускает очередную итерацию цикла событий. Поскольку первым мы встречаем предложение await sleep_for_ three, все три задачи начинают выполняться и засыпают одновременно. ### Снятие задач У каждого объекта задачи есть метод cancel, который можно вызвать, если требуется остановить задачу. В результате снятия задача возбудит исключение CancelledError, когда мы ждем ее с помощью await. Это исключения можно обработать, как того требует ситуация. ### Задание таймаута и снятие с помощью wait_for В asyncio есть asyncio.wait_for. Она принимает объект сопрограммы или задачи и тайм-аут в секундах и возвращает сопрограмму, к которой можно применить await. Если задача не завершилась в отведенное время, то возбуждается исключение TimeoutError и задача автоматически снимается. ### Future Объект future в Python содержит одно значение, которое мы ожидаем получить в будущем, но пока еще, возможно, не получили. Обычно момент создания future не обертывает никакого значения, потому что его еще не существует. Объект в таком состоянии называется неполным, неразрешенным или просто неготовым. И только получив результат, мы можем установить значение объекта future, в результате чего он становится полным и из него можно извлечь результат. Чтобы лучше разобраться со всем этим, создадим будущий объект, установим его значение и затем извлечем его. ``` from asyncio import Future my_future = Future() print(f'my_future готов? {my_future.done()}') my_future.set_result(42) print(f'my_future готов? {my_future.done()}') print(f'Какой результат хранится в my_future? {my_future.result()}') ``` Будущие объекты также можно использовать в выражениях await. Это означает «я посплю, пока в future не будет установлено значение, с которым я могу работать, а когда оно появится, разбуди меня и дай возможность его обработать». Рассмотрим пример – отправка веб-запроса возвращает объект future. В этом случае future возвращается немедленно, но, поскольку запрос занимает некоторое время, значение future еще не определено. Позже, когда запрос завершится, результат будет установлен, и мы сможем его получить. Те, кто знаком с JavaScript, легко увидят аналогию с обещаниями (promise). В Java похожая концепция называется дополняемыми будущими объектами (completable future). ### Связь между будущими объектами, задачами и сопрограммами На самом деле task напрямую наследует future. Можно считать, что объект future представляет значение, которое появится только в будущем. А task является комбинацией сопрограммы и future. Создавая задачу, мы создаем пустой объект future и запускаем сопрограмму. А когда сопрограмма завершится с результатом или вследствие исключения, мы записываем этот результат или объект-исключение во future. Связующим звеном между ними является абстрактный базовый класс Awaitable. В нем определен единственный абстрактный метод __await__. ### Ловушки сопрограмм и задач Увидев, какой выигрыш может дать конкурентное выполнение длительных задач, мы можем поддаться искушению использовать сопрограммы и задачи всюду и везде. Но просто снабдить функции ключевым словом async и обернуть их задачами может оказаться недостаточно для повышения производительности. А в некоторых случаях производительность может даже упасть. Есть две основные ошибки на пути преобразования приложения в асинхронное. Первая – попытка выполнить счетный код в задачах или сопрограммах, не прибегая к многопроцессности, вторая – использовать блокирующие API ввода-вывода, пренебрегая многопоточностью. #### Выполнение счетного кода В программе могут быть функции, выполняющие длительные вычисления, например обход большого словаря или математические расчеты. Если есть возможность выполнять эти функции конкурентно, то может возникнуть идея поместить их в отдельные задачи. В принципе, ничего плохого в этом нет, но нужно помнить, что модель конкурентности в asyncio однопоточная. Это значит, что действуют все ограничения одного потока и глобальной блокировки интерпретатор Может возникнуть соблазн использовать существующие библиотеки ввода-вывода, обернув их сопрограммами. Однако при этом возникнут те же проблемы, что для счетных операций. Эти API будут блокировать главный поток. Поэтому, попытавшись выполнить блокирующий вызов API в сопрограмме, мы заблокируем сам поток цикла событий, а значит, воспрепятствуем выполнению всех остальных сопрограмм и задач. Примерами блокирующих API является библиотека requests или функция time.sleep. Вообще, любая функция, которая выполняет ввод-вывод, не являясь сопрограммой, или занимает процессор длительными операциями, может считаться блокирующей. И снова причина в том, что библиотека requests блокирующая, т. е. блокирует поток, в котором выполняется. Поскольку asyncio однопоточная, библиотека requests блокирует цикл событий и не дает ничему выполняться конкурентно. ### Использование модуля selectors У операционной системы есть эффективные API, позволяющие наблюдать за появлением данных в сокетах и за другими событиями. Конкретный API зависит от системы (kqueue, epoll, IOCP – самые известные), но все системы уведомления работают по одному принципу. Мы передаем системе список сокетов, за событиями которых хотим наблюдать, а она сообщает, когда в одном из них появляются данные. Поскольку все это реализовано на аппаратном уровне, процессор в мониторинге почти не участвует, так что потребление ресурсов невелико. Эти системы уведомления и лежат в основе механизма конкурентности в asyncio. Система уведомления о событиях зависит от операционной системы. Но модуль Python selectors абстрагирует эту зависимость, так что мы получаем правильное событие, в какой бы системе наш код ни работал. То есть код оказывается переносимым. Эта библиотека предоставляет абстрактный базовый класс BaseSelector, имеющий реализации для каждой системы уведомления. Кроме того, имеется класс DefaultSelector, который автоматически выбирает реализацию, подходящую для конкретной системы. Мы научились создавать несколько задач для конкурентного выполнения сопрограмм. Для этого мы вызывали функцию asyncio.create_task, а затем ждали завершения задачи, как показано ниже: ``` import asyncio async def main() -> None: task_one = asyncio.create_task(delay(1)) task_two = asyncio.create_task(delay(2)) await task_one await task_two ``` Такая тактика работает в простых случаях, когда имеется всего одна или две конкурентно запускаемые сопрограммы. Но если мы хотим конкурентно выполнить сотни, тысячи, а то и больше веб-запросов, то код становится слишком многословным. Возникает соблазн воспользоваться циклом for или списковым включением, чтобы сделать код более лаконичным. Но такой подход может оказаться проблематичным, если реализовать его неправильно. ``` import asyncio from util import delay async def main() -> None: delay_times = [3, 3, 3] [await asyncio.create_task(delay(seconds)) for seconds in delay_times] asyncio.run(main()) ``` Поскольку в идеале мы хотим, чтобы все задачи delay выполнялись конкурентно, ожидается, что метод main будет работать примерно 3 с. Однако же он работает 9 с, так как все выполняется последовательно. Здесь имеется тонкая ошибка. Все дело в том, что мы применяем await сразу же после создания задачи. Это значит, что мы приостанавливаем списковое включение и сопрограмму main для каждой созданной задачи delay до момента, когда она завершится. В данном случае в каждый момент времени будет работать только одна задача, а не все конкурентно. Исправить это легко, хотя немного муторно. Мы можем создавать задачи в одном списковом включении, а ждать в другом. Тогда все будет работать конкурентно. ``` import asyncio from util import async_timed, delay async def main() -> None: delay_times = [3, 3, 3] tasks = [asyncio.create_task(delay(seconds)) for seconds in delay_times] [await task for task in tasks] asyncio.run(main()) ``` Здесь создается сразу несколько задач, которые запоминаются в списке tasks. Создав все задачи, мы ждем их завершения в другом списковом включении. Это работает, потому что функция create_task возвращает управление немедленно, и мы ничего не ждем, до тех пор, пока все задачи не будут созданы. Таким образом, будет затрачено время, равное максимальной задержке в списке delay_times, т. е. общее время работы составит приблизительно 3 с Это код делает то, что нам нужно, но недостатки еще имеются. И первый из них заключается в том, что он состоит из нескольких строк, и мы должны не забыть отделить создание задач от ожидания. Второй – негибкость, поскольку если одна из наших сопрограмм завершается значительно раньше прочих, то мы застрянем во втором списковом включении, ожидая завершения всех остальных сопрограмм. Иногда это приемлемо, но чаще мы стремимся к большей отзывчивости и хотим обрабатывать результаты сразу после поступления. Третий и, пожалуй, самый крупный недостаток связан с обработкой исключений. Если в какой-то сопрограмме возникает исключение, то оно будет возбуждено в момент ожидания сбойной задачи. Это значит, что мы не сможем обработать успешно завершившиеся задачи, потому что одно-единственное исключение останавливает всю работу. ### Конкурентное выполнение запросов с помощью gather Для конкурентного выполнения допускающих ожидание объектов широко используется функция asyncio.gather. Она принимает последовательность допускающих ожидание объектов и запускает их конкурентно всего в одной строке кода. Если среди объектов есть сопрограмма, то gather автоматически обертывает ее задачей, чтобы гарантировать конкурентное выполнение. Это значит, что не нужно отдельно обертывать все сопрограммы по отдельности с помощью функции asyncio.create_task, как мы делали раньше. asyncio.gather возвращает объект, допускающий ожидание. Если использовать его в выражении await, то выполнение будет приостановлено, пока не завершатся все переданные объекты. А когда это произойдет, asyncio.gather вернет список результатов работы. Стоит отметить, что порядок поступления результатов для переданных объектов, допускающих ожидание, не детерминирован. Например, если передать gather сопрограммы a и b именно в таком порядке, то b может завершиться раньше, чем a. Но приятная особенность gather заключается в том, что, независимо от порядка завершения допускающих ожидание объектов, результаты гарантированно будут возвращены в том порядке, в каком объекты передавались. asyncio.gather принимает необязательный параметр, return_exceptions, который позволяет указать, как мы хотим обрабатывать ис- ключения от допускающих ожидание объектов. Это булево значение, поэтому возможно два варианта: - return_exceptions=False – это режим по умолчанию. Если хотя бы одна сопрограмма возбуждает исключение, то gather возбуждает то же исключение в точке await. Но, даже если какая-то сопрограмма откажет, остальные не снимаются и продолжат работать при условии, что мы обработаем исключение и оно не приведет к остановке цикла событий и снятию задач; - return_exceptions=True – в этом случае исключения возвращаются в том же списке, что результаты. Сам по себе вызов gather не возбуждает исключений, и мы можем обработать исключения, как нам удобно. asyncio.gather не снимает другие работающие задачи из-за отказа. Во многих случаях это приемлемо, но вообще является одним из недостатков gather. Ниже в этой главе мы покажем, как снять конкурентные задачи. Функция gather имеет несколько недостатков. Первый мы уже упоминали – не так просто отменить задачи, если одна из них возбудила исключение. Представьте, что мы отправляем запросы одному серверу, и если хотя бы один завершится неудачно, например из-за превышения лимита на частоту запросов, то остальные постигнет та же участь. В таком случае хотелось бы отменить запросы, чтобы освободить ресурсы, но это нелегко, потому что наши сопрограммы обернуты задачами и работают в фоновом режиме. Второй недостаток – необходимость дождаться завершения всех сопрограмм, прежде чем можно будет приступить к обработке результатов. Если мы хотим обрабатывать результаты по мере поступления, то возникает проблема. Например, если один запрос выполняется 100 мс, а другой 20 с, то придется ждать, ничего не делая, 20 с, прежде чем мы сможем обработать результаты первого запроса. ### Обработка результатов по мере поступления Хотя во многих случаях функция asyncio.gather нас устраивает, у нее есть недостаток – необходимость дождаться завершения всех допускающих ожидания объектов, прежде чем станет возможен доступ к результатам. Это проблема, если требуется обрабатывать результаты в темпе их получения. Для решения этой проблемы asyncio предлагает функцию as_completed. Она принимает список допускающих ожидание объектов и возвращает итератор по будущим объектам. Эти объекты можно перебирать, применяя к каждому await. Когда выражение await вернет управление, мы получим результат первой завершившейся сопрограммы. Это значит, что мы сможем обрабатывать результаты по мере их доступности, но теперь порядок результатов не детерминирован, поскольку неизвестно, какой объект завершится первым. as_completed справляется со своей задачей – возвращать результат по мере поступления, но она не лишена недостатков. Первый заключается в том, что хотя мы и получаем результаты в темпе их поступления, но невозможно сказать, какую сопрограмму или задачу мы ждем, поскольку порядок абсолютно не детерминирован. Если порядок нас не волнует, то и ладно, но если требуется ассоциировать результаты с запросами, то возникает проблема. Второй недостаток в том, что, хотя исключения по истечении тайм-аута возбуждаются как положено, все созданные задачи продолжают работать в фоновом режиме. А если мы захотим их снять, то будет трудно понять, какие задачи еще работают. Вот вам и еще одна проблема! Если эти проблемы требуется решить, то нужно точно знать, какие допускающие ожидание объекты уже завершились, а какие еще нет. Поэтому asyncio предоставляет функцию wait. Один из недостатков обеих функций gather и as_completed – сложности со снятием задач, работавших в момент исключения. Во многих случаях в этом нет ничего страшного, но представьте ситуацию, когда мы делаем несколько вызовов сопрограммы, и если один из них завершается неудачно, то так же завершатся и все остальные. Примером может служить задание недопустимого параметра веб-запроса или достижение лимита на частоту запросов. Это может привести к падению производительности, потому что мы потребляем больше ресурсов, так как запустили больше задач, чем необходимо. Другой недостаток, свойственный as_completed, – недетерминированный порядок получения результатов, из-за чего трудно понять, какая именно задача завершилась. Функция wait в asyncio похожа на gather, но дает более точный контроль над ситуацией. У нее есть несколько параметров, позволяющих решить, когда мы хотим получить результаты. Кроме того, она возвращает два множества: задачи, завершившиеся успешно или в результате исключения, а также задачи, которые продолжают выполняться. Еще эта функция позволяет задать тайм-аут, который, однако, ведет себя не так, как в других функциях API: он не возбуждает исключений. В тех случаях, когда необходимо, эта функция позволяет решить некоторые отмеченные выше проблемы, присущие другим функциям asyncio. Базовая сигнатура wait – список допускающих ожидание объектов, за которым следует факультативный тайм-аут и факультативный параметр return_when, который может принимать значения ALL_COMPLETED, FIRST_EXCEPTION и FIRST_COMPLETED, а по умолчанию равен ALL_ COMPLETED. Хотя на момент написания книги wait принимает список допускающих ожидание объектов, в будущих версиях Python она будет принимать только объекты task. Если при выполнении сопрограммы, запущенной с помощью wait_for, случался тайм-аут, то она автоматически снималась. В случае wait это не так: поведение ближе к gather и as_completed. Если мы хотим снять сопрограммы из-за тайм-аута, то должны явно обойти их и снять каждую Исключения не возбуждаются wait не возбуждает исключения в случае тайм-аута, в отличие от wait_ for и as_completed. Когда случается тайм-аут, wait возвращает все завершившиеся задачи, а также те, что еще не завершились в момент тайм-аута.