Asyncio и конкурентное программирование на Python [Мэттью Фаулер] (pdf) читать онлайн

-  Asyncio и конкурентное программирование на Python  (пер. А. А. Слинкин) 13.29 Мб, 400с. скачать: (pdf) - (pdf+fbd)  читать: (полностью) - (постранично) - Мэттью Фаулер

Книга в формате pdf! Изображения и текст могут не отображаться!


 [Настройки текста]  [Cбросить фильтры]

Мэттью Фаулер

Asyncio и конкурентное
программирование на Python

Python Concurrency
with asyncio

M AT T H E W F OW L E R

Asyncio и конкурентное
программирование на Python

М Э Т Т Ь Ю ФАУЛ Е Р

Москва, 2023

УДК 004.42
ББК 32.372
Ф28

Фаулер М.
Ф28 Asyncio и конкурентное программирование на Python / пер. с англ. А. А. Слинкина. – М.: ДМК Пресс, 2022. – 398 с.: ил.
ISBN 978-5-93700-166-5
Из данной книги вы узнаете, как работает библиотека asyncio, как написать первое
реальное приложение и как использовать функции веб-API для для повышения производительности, пропускной способности и отзывчивости приложений на языке
Python. Рассматривается широкий круг вопросов: от модели однопоточной конкурентности до многопроцессорной обработки.
Издание будет полезно не только Python-разработчикам, но и всем программистам,
которые хотят лучше понимать общие проблемы конкурентности.

УДК 004.42
ББК 32.372

© DMK Press 2022. Authorized translation of the English edition © 2022 Manning Publications.
This translation is published and sold by permission of Manning Publications, the owner of all rights
to publish and sell the same.
Все права защищены. Любая часть этой книги не может быть воспроизведена в какой
бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.

ISBN 978-1-6172-9866-0 (англ.)
ISBN 978-5-93700-166-5 (рус.)

© Manning Publications, 2022
© Перевод, оформление, издание, ДМК Пресс, 2022

Посвящается моей любимой супруге Кэти.
Спасибо, что ты всегда рядом.

Оглавление
1
2
3
4
5
6
7
8
9
10
11
12
13
14
















Первое знакомство с asyncio............................................................................... 21
Основы asyncio....................................................................................................... 45
Первое приложение asyncio................................................................................ 74
Конкурентные веб-запросы...............................................................................101
Неблокирующие драйверы баз данных. .........................................................130
Счетные задачи.....................................................................................................157
Решение проблем блокирования с помощью потоков................................189
Потоки данных......................................................................................................223
Веб-приложения...................................................................................................251
Микросервисы.......................................................................................................279
Синхронизация.....................................................................................................303
Асинхронные очереди.........................................................................................327
Управление подпроцессами. .............................................................................350
Продвинутое использование asyncio...............................................................365

Содержание
Оглавление............................................................................................................. 6
Предисловие......................................................................................................... 12
Благодарности..................................................................................................... 14
Об этой книге...................................................................................................... 15
Об авторе............................................................................................................ 19
Об иллюстрации на обложке............................................................................... 20

1

2

Первое знакомство с asyncio...................................................... 21
1.1
1.2

Что такое asyncio?.................................................................................... 22
Что такое ограниченность производительностью ввода-вывода
и ограниченность быстродействием процессора................................. 24
1.3
Конкурентность, параллелизм и многозадачность.............................. 25
1.3.1
Конкурентность............................................................................ 25
1.3.2
Параллелизм. ................................................................................ 26
1.3.3
Различие между конкурентностью и параллелизмом..................... 27
1.3.4
Что такое многозадачность......................................................... 28
1.3.5
Преимущества кооперативной многозадачности.......................... 28
1.4
Процессы, потоки, многопоточность и многопроцессность................ 29
1.4.1
Процесс. ........................................................................................ 29
1.4.2
Поток. .......................................................................................... 29
1.5
Глобальная блокировка интерпретатора............................................... 33
1.5.1
Освобождается ли когда-нибудь GIL?............................................. 37
1.5.2
Аsyncio и GIL.................................................................................. 39
1.6
Как работает однопоточная конкурентность........................................ 39
1.6.1
Что такое сокет?......................................................................... 39
1.7
Как работает цикл событий.................................................................... 41
Резюме................................................................................................................. 44

Основы asyncio........................................................................................ 45
2.1
2.2
2.3

Знакомство с сопрограммами................................................................ 46
Создание сопрограмм с по­мощью ключевого слова async................. 46
Приостановка выполнения с по­мощью ключевого слова await......... 48
Моделирование длительных операций с по­мощью sleep.................... 49
Конкурентное выполнение с по­мощью задач....................................... 52
2.1.1
2.1.2

Содержание

8

2.4

2.3.1
2.3.2

Основы создания задач................................................................... 52
Конкурентное выполнение нескольких задач. ................................. 53

Снятие задач и задание тайм-аутов...................................................... 56
Снятие задач................................................................................. 56
Задание тайм-аута и снятие с по­мощью wait_for.......................... 57
2.5
Задачи, сопрограммы, будущие объекты и объекты,
допускающие ожидание......................................................................... 59
2.5.1
Введение в будущие объекты.......................................................... 59
2.5.2
Связь между будущими объектами, задачами и сопрограммами..... 61
2.6
Измерение времени выполнения сопрограммы с по­мощью
декораторов............................................................................................. 62
2.7
Ловушки сопрограмм и задач................................................................. 65
2.7.1
Выполнение счетного кода............................................................. 65
2.7.2
Выполнение блокирующих API........................................................ 67
2.8
Ручное управление циклом событий..................................................... 68
2.8.1
Создание цикла событий вручную.................................................. 69
2.8.2
Получение доступа к циклу событий.............................................. 69
2.9
Отладочный режим................................................................................. 70
2.9.1
Использование asyncio.run.............................................................. 70
2.9.2
Использование аргументов командной строки. ............................. 71
2.9.3
Использование переменных окружения........................................... 71
Резюме................................................................................................................. 72
2.4.1
2.4.2

3

4

Первое приложение asyncio.......................................................... 74
3.1
3.2

Работа с блокирующими сокетами........................................................ 75
Подключение к серверу с по­мощью telnet............................................ 78
3.2.1
Чтение данных из сокета и запись данных в сокет......................... 79
3.2.2
Разрешение нескольких подключений и опасности блокирования.... 80
3.3
Работа с неблокирующими сокетами.................................................... 82
3.4
Использование модуля selectors для построения цикла событий
сокетов..................................................................................................... 86
3.5
Эхо-сервер средствами цикла событий asyncio.................................... 89
3.5.1
Сопрограммы цикла событий для сокетов..................................... 89
3.5.2
Проектирование асинхронного эхо-сервера.................................... 90
3.5.3
Обработка ошибок в задачах......................................................... 92
3.6
Корректная остановка............................................................................. 94
3.6.1
Прослушивание сигналов................................................................ 95
3.6.2
Ожидание завершения начатых задач. .......................................... 96
Резюме................................................................................................................. 99

Конкурентные веб-запросы........................................................101
4.1
4.2
4.3
4.4
4.5

Введение в aiohttp..................................................................................102
Асинхронные контекстные менеджеры...............................................103
4.2.1
Отправка веб-запроса с по­мощью aiohttp.....................................105
4.2.2
Задание тайм-аутов в aiohttp......................................................107
И снова о конкурентном выполнении задач........................................108
Конкурентное выполнение запросов с помощью gather.....................111
4.4.1
Обработка исключений при использовании gather.........................113
Обработка результатов по мере поступления......................................115
4.5.1
Тайм-ауты в сочетании с as_completed.........................................117

Содержание

9

4.6

Точный контроль с по­мощью wait........................................................119
4.6.1
Ожидание завершения всех задач..................................................119
4.6.2
Наблюдение за исключениями.......................................................122
4.6.3
Обработка результатов по мере завершения...............................123
4.6.4
Обработка тайм-аутов...............................................................126
4.6.5
Зачем оборачивать сопрограммы задачами?................................127
Резюме................................................................................................................128

5

Неблокирующие драйверы баз данных..............................130
5.1
5.2
5.3
5.4
5.5

Введение в asyncpg.................................................................................131
Подключение к базе данных Postgres...................................................131
Определение схемы базы данных.........................................................133
Выполнение запросов с помощью asyncpg...........................................135
Конкурентное выполнение запросов с помощью пулов
подключений..........................................................................................138
5.5.1
Вставка случайных SKU в базу данных о товарах..........................138
5.5.2

5.6

Создание пула подключений для конкурентного выполнения
запросов.......................................................................................142

Управление транзакциями в asyncpg....................................................146
Вложенные транзакции................................................................148
Ручное управление транзакциями. ...............................................149
5.7
Асинхронные генераторы и потоковая обработка
результирующих наборов......................................................................151
5.7.1
Введение в асинхронные генераторы.............................................151
5.7.2
Использование асинхронных генераторов и потокового курсора.....153
Резюме................................................................................................................156
5.6.1
5.6.2

6

7

Счетные задачи....................................................................................157
6.1
6.2

Введение в библиотеку multiprocessing................................................158
Использование пулов процессов...........................................................160
6.2.1
Асинхронное получение результатов............................................161
6.3
Использование исполнителей пула процессов в сочетании
с asyncio...................................................................................................162
6.3.1
Введение в исполнители пула процессов........................................162
6.3.2
Исполнители пула процессов в сочетании с циклом событий........164
6.4
Решение задачи с по­мощью MapReduce и asyncio...............................166
6.4.1
Простой пример MapReduce.........................................................167
6.4.2
Набор данных Google Books Ngram. ...............................................169
6.4.3
Применение asyncio для отображения и редукции. ........................170
6.5
Разделяемые данные и блокировки......................................................175
6.5.1
Разделение данных и состояние гонки...........................................176
6.5.2
Синхронизация с по­мощью блокировок..........................................179
6.5.3
Разделение данных в пулах процессов............................................181
6.6
Несколько процессов и несколько циклов событий............................184
Резюме................................................................................................................188

Решение проблем блокирования с помощью
потоков.......................................................................................................189
7.1

Введение в модуль threading.................................................................190

Содержание

10
7.2

Совместное использование потоков и asyncio.....................................194
Введение в библиотеку requests.....................................................194
Знакомство с исполнителями пула потоков.................................195
Исполнители пула потоков и asyncio............................................197
Исполнители по умолчанию..........................................................198
7.3
Блокировки, разделяемые данные и взаимоблокировки....................200
7.3.1
Реентерабельные блокировки. ......................................................201
7.3.2
Взаимоблокировки........................................................................204
7.4
Циклы событий в отдельных потоках...................................................206
7.4.1
Введение в Tkinter.........................................................................207
7.4.2
Построение отзывчивого UI с по­мощью asyncio и потоков............209
7.5
Использование потоков для выполнения счетных задач....................217
7.5.1
hashlib и многопоточность...........................................................217
7.5.2
Многопоточность и NumPy..........................................................220
Резюме................................................................................................................222

8
9

10

7.2.1
7.2.2
7.2.3
7.2.4

Потоки данных.....................................................................................223
8.1
8.2
8.3
8.4

Введение в потоки данных....................................................................224
Транспортные механизмы и протоколы..............................................224
Потоковые читатели и писатели...........................................................228
Неблокирующий ввод данных из командной строки..........................231
8.4.1
Режим терминала без обработки и сопрограмма read..................235
8.5
Создание серверов.................................................................................242
8.6
Создание чат-сервера и его клиента.....................................................244
Резюме................................................................................................................249

Веб-приложения...................................................................................251
9.1

Разработка REST API с по­мощью aiohttp..............................................252
9.1.1
Что такое REST?. ........................................................................252
9.1.2
Основы разработки серверов на базе aiohttp.................................253
9.1.3
Подключение к базе данных и получение результатов...................255
9.1.4
Сравнение aiohttp и Flask..............................................................260
9.2
Асинхронный интерфейс серверного шлюза.......................................263
9.2.1
Сравнение ASGI и WSGI. ...............................................................263
9.3
Реализация ASGI в Starlette...................................................................264
9.3.1
Оконечная REST-точка в Starlette.................................................265
9.3.2
WebSockets и Starlette....................................................................266
9.4
Асинхронные представления Django....................................................269
9.4.1
Выполнение блокирующих работ в асинхронном представлении.....275
9.4.2
Использование асинхронного кода в синхронных представлениях....277
Резюме................................................................................................................278

Микросервисы. .......................................................................................279
10.1

10.2
10.3

Зачем нужны микросервисы?................................................................280
Сложность кода. ..........................................................................281
Масштабируемость.....................................................................281
Независимость от команды и технологического стека.................281
Чем может помочь asyncio?..........................................................281
Введение в паттерн backend-for-frontend.............................................282
Реализация API списка товаров.............................................................283

10.1.1
10.1.2
10.1.3
10.1.4

Содержание
10.3.1
10.3.2
10.3.3
10.3.4
10.3.5

11

Сервис избранного........................................................................284
Реализация базовых сервисов........................................................284
Реализация сервиса backend-for-frontend.......................................289
Повтор неудачных запросов..........................................................294
Паттерн Прерыватель................................................................297

Резюме................................................................................................................302

11
12
13
14

Синхронизация......................................................................................303
11.1
11.2
11.3

Природа ошибок в модели однопоточной конкурентности...............304
Блокировки.............................................................................................309
Ограничение уровня конкурентности с помощью семафоров...........312
11.3.1 Ограниченные семафоры...............................................................315
11.4 Уведомление задач с по­мощью событий..............................................317
11.5 Условия....................................................................................................322
Резюме................................................................................................................326

Асинхронные очереди.......................................................................327
12.1

Основы асинхронных очередей............................................................328
Очереди в веб-приложениях. .........................................................335
Очередь в веб-роботе. ..................................................................338
12.2 Очереди с приоритетами.......................................................................341
12.3 LIFO-очереди..........................................................................................347
Резюме................................................................................................................349
12.1.1
12.1.2

Управление подпроцессами.........................................................350
13.1

Создание подпроцесса...........................................................................351
Управление стандартным выводом..............................................353
Конкурентное выполнение подпроцессов.......................................357
13.2 Взаимодействие с подпроцессами........................................................360
Резюме................................................................................................................363
13.1.1
13.1.2

Продвинутое использование asyncio...................................365
14.1
14.2
14.3
14.4
14.5

API, допускающие сопрограммы и функции........................................366
Контекстные переменные.....................................................................368
Принудительный запуск итерации цикла событий.............................370
Использование других реализаций цикла событий............................371
Создание собственного цикла событий................................................373
14.5.1 Сопрограммы и генераторы. ........................................................373
14.5.2
14.5.3
14.5.4
14.5.5
14.5.6
14.5.7

Использовать сопрограммы на основе генераторов
не рекомендуется.........................................................................374
Нестандартные объекты, допускающие ожидание. ......................376
Сокеты и будущие объекты...........................................................378
Реализация задачи........................................................................381
Реализация цикла событий...........................................................382
Реализация сервера с использованием своего цикла событий.........385

Резюме................................................................................................................387
Предметный указатель......................................................................................388

Предисловие
Почти 20 лет назад я начал профессиональную карьеру в области программной инженерии, написав приложение для управления массспектрометрами и другими лабораторными приборами и анализа
поступающих от них данных. В приложении использовались языки
Matlab, C++ и VB.net. Меня всегда охватывал восторг при виде того,
как строчка кода заставляет машину двигаться по моему желанию,
и с тех пор я понял, что хочу заниматься только программной инженерией. Шли годы, мои интересы постепенно сместились в сторону
разработки API и распределенных систем, в основном на Java и Scala,
но попутно я активно изучал Python.
Я впервые столкнулся с Python примерно в 2015 году, работа была
связана главным образом с построением конвейера машинного
обуче­ния, который принимал данные от датчиков и на их основе делал предсказания относительно действий носителя датчика – например, отслеживание сна, подсчет числа шагов, переходы из положения
сидя в положение стоя и т. д. Тогда этот конвейер был медленным настолько, что это стало проблемой для клиентов. Одним из способов,
которые я применил для решения проблемы, стало использование
конкурентности. Копаясь в доступной информации о конкурентном
программировании на Python, я обнаружил, что разобраться в ней
труднее, чем в привычном мне мире Java. Почему многопоточность
работает не так, как в Java? Есть ли смысл использовать многопроцессную обработку? Что такое глобальная блокировка интерпретатора и зачем она нужна? На тему конкурентности в Python книг было
немного, а знания в основном были разбросаны по документации
и блогам разного качества. И сегодня ситуация мало чем отличается.
Ресурсов стало больше, но ландшафт по-прежнему скудный, разъединенный и не такой дружелюбный к начинающим, каким должен, по
идее, быть.

Предисловие

13

Разумеется, за последние несколько лет многое изменилось. Тогда пакет asyncio пребывал в младенчестве, а теперь стал важным
модулем в Python. Ныне модели однопоточной конкурентности и сопрограммы являются одним из базовых компонентов Python в дополнение к многопоточности и многопроцессности. Это значит, что
ландшафт конкурентности в Python стал шире и сложнее, но так и не
обрел исчерпывающих ресурсов, к которым мог бы обратиться желающий изучить его.
Я написал эту книгу, чтобы заполнить этот пробел, конкретно в области asyncio и однопоточной конкурентности. Я хотел сделать сложную и плохо документированную тему однопоточной конкурентности более доступной разработчикам всех уровней. Кроме того, я хотел
написать книгу, благодаря которой читатель стал бы лучше понимать
общие проблемы конкурентности, выходящие за рамки Python. В таких каркасах, как Node.js, и таких языках, как Kotlin, имеются модели
однопоточной конкурентности и сопрограммы, поэтому приведенные здесь сведения будут полезны и при работе с этими инструментами. Надеюсь, что книга окажется полезной читателям-разработчикам
в повседневной работе, – и не только в Python, но и вообще в области
конкурентного программирования.

Благодарности
Прежде всего хочу сказать спасибо своей жене, которая всегда была готова прийти на помощь в качестве корректора, когда я не был уверен,
что делаю нечто разумное, и вообще всячески поддерживала меня на
протяжении всего процесса. Второе спасибо – моему псу, Дагу, который всегда с готовностью ронял перед мной свой мячик, напоминая,
что пора сделать перерыв и поиграть.
Затем я благодарю своего редактора, Дуга Раддера, и технического
рецензента, Роберта Веннера. Ваши замечания здорово помогли мне
написать книгу в срок и с высоким качеством, благодаря им код и пояснения получились осмысленными и понятными.
Спасибо всем рецензентам: Алексею Выскубову, Энди Майлсу,
Чарльзу М. Шелтону, Крису Вайнеру, Кристоферу Коттмайеру, Клиффорду Тэрберу, Дэну Шейху, Дэвиду Кабреро, Дидье Гарсия, Димитриосу Кузис-Лукасу, Эли Майосту, Гэри Бэйку, Гонзало Габриэлю Хименесу Фуэнтесу, Грегори А. Люссьеру, Джеймсу Лю, Джереми Чену, Кенту
Р. Спиллнеру, Дакшми Нарайянану Нарасимхану, Леонардо Таккари,
Матиасу Бушу, Павлу Филатову, Филлипу Соренсену, Ричарду Вогану, Сандживу Киларапу, Симеону Лейзерзону, Симону Щёке, Симоне
Сгуацца, Самиту К. Сингху, Вайрону Дадала, Уильяму Джамиру Силва
и Зохебу Айнапору. Ваши предложения помогли сделать книгу лучше.
Наконец, я признателен бесчисленным учителям, коллегам и наставникам, с которыми общался на протяжении своей жизни. Я так
много узнал от вас. Ваш совокупный опыт снабдил меня инструментами, позволившими издать эту работу и вообще добиться успеха
в карьере. Без вас я не стал бы тем, кем стал. Спасибо вам!

Об этой книге
Эта книга написана для тех, кто хочет научиться использовать средства конкурентности в Python, чтобы повысить производительность,
пропускную способность и отзывчивость приложений. Сначала мы
рассмотрим базовые вопросы конкурентности, объясним, как работает модель однопоточной конкурентности в asyncio, а также расскажем о принципах работы сопрограмм и синтаксисе async/await. Затем
перейдем к практическим приложениям конкурентности, например:
как отправить несколько конкурентных веб-запросов или запросов
к базе данных, как управлять потоками и процессами, строить вебприложения и решать вопросы синхронизации.
Кому стоит прочитать эту книгу?
Книга адресована разработчикам средней и высокой квалификации, которые хотят разобраться в конкурентности и использовать ее
в уже написанных или новых приложениях. Одна из целей книги –
объяснить сложные вопросы простым и понятным языком. Предварительного знакомства с конкурентностью не требуется, хотя оно,
конечно, не помешает. Мы рассмотрим широкий круг применений:
от API на основе веба до командных приложений, так что книга будет
полезна для решения многих задач, с которыми сталкиваются разработчики.

Структура книги
В этой книге 14 глав, в которых рассматриваются постепенно усложняющиеся темы. Последующие главы основаны на материале предыдущих.
„„ Глава 1 посвящена базовым вопросам конкурентности в Python.
Мы узнаем о задачах, ограниченных быстродействием процессора (счетных) и производительностью ввода-вывода, а также

16

Об этой книге

начнем рассказ о том, как работает модель однопоточной конкурентности.
„„ Глава 2 посвящена основам сопрограмм asyncio и использованию синтаксиса async/await в конкурентных приложениях.
„„ В главе 3 рассматриваются неблокирующие сокеты и селекторы,
а также описывается построение эхо-сервера с применением
asyncio.
„„ Глава 4 посвящена отправке нескольких конкурентных веб-за­
просов. Попутно мы больше узнаем о том, как использовать
asyncio API для конкурентного выполнения сопрограмм.
„„ Тема главы 5 – конкурентное выполнение запросов к базе данных с использованием пула подключений. Также мы узнаем об
асинхронных контекстных менеджерах и асинхронных генераторах в контексте баз данных.
„„ В главе 6 обсуждается многопроцессная обработка, в частности
использование asyncio для выполнения счетных задач. Для демонстрации будет построено сообщение типа map-reduce.
„„ Глава 7 посвящена многопоточной обработке, а особенно ее использованию в сочетании с asyncio для обработки блокирующего
ввода-вывода. Это полезно при работе с библиотеками, в которые поддержка asyncio не встроена, что не мешает им получать
выгоды от конкурентного выполнения.
„„ Глава 8 посвящена потоковой обработке и протоколам. Мы напишем сервер и клиент чата, способные конкурентно обрабатывать несколько пользователей.
„„ В главе 9 рассматриваются веб-приложения на основе asyncio
и асинхронный интерфейс серверного шлюза, ASGI. Мы изучим
несколько каркасов, поддерживающих ASGI, и обсудим, как
строить в них веб-API. Также поговорим о технологии WebSo­
ckets.
„„ В главе 10 описывается, как с по­мощью веб-API на основе asyncio построить гипотетическую микросервисную архитектуру.
„„ Глава 11 посвящена проблемам синхронизации в модели однопоточной конкурентности и методам их решения. Мы рассмотрим блокировки, семафоры, события и условия.
„„ Глава 12 посвящена асинхронным очередям. С их помощью мы
построим веб-приложение, мгновенно отвечающее на клиентские запросы, хотя в фоновом режиме производятся длительные
операции.
„„ В главе 13 обсуждается создание подпроцессов и управление
ими. Мы покажем, как читать и передавать данные подпроцессу.
„„ В главе 14 рассматриваются дополнительные темы, в том числе
принудительный переход к новой итерации цикла событий, контекстные переменные и создание собственного цикла событий.
Эта информация полезна прежде всего проектировщикам asyncio API и читателям, интересующимся внутренним устройством
цикла событий в asyncio.

Об этой книге

17

Как минимум следует прочитать первые четыре главы, чтобы понять,
как работает asyncio, как написать свое первое реальное приложение
и как использовать базовые функции asyncio API для конкурентного
выполнения сопрограмм (рассматриваются в главе 4). После этого вы
вольны читать книгу в любом порядке, отвечающем вашим интересам.

О примерах кода
В этой книге много примеров кода как в пронумерованных листингах,
так и в виде небольших фрагментов. Некоторые листинги используются повторно путем импорта в той же главе, а иногда и в нескольких
главах. Предполагается, что код, используемый в нескольких главах,
помещен в модуль util; мы создадим его в главе 2. Предполагается также, что для каждого отдельного листинга будет создан модуль
с именем chapter_{номер_главы}, а код будет помещен в файл с именем listing_{номер_главы}_{номер_листинга}.py, принадлежащий этому модулю. Например, код из листинга 2.2 в главе 2 должен находиться
в файле listing_2_2.py, принадлежащем модулю chapter_2.
В нескольких местах приводятся сведения о производительности,
например время работы программы или количество веб-запросов
в секунду. Примеры кода выполнялись на компьютере 2019 MacBook
Pro с 8-ядерным процессором Intel Core i9 с тактовой частотой 2,4 ГГц
и 32 Гб памяти DDR4, работающей на частоте 2667 МГц; использовалось также гигабитное беспроводное подключение к интернету. На
вашей машине цифры могут получиться другими, как и коэффициенты ускорения или иного улучшения производительности.
Исполняемые фрагменты кода можно найти в онлайновой версии
книги на сайте https://livebook.manning.com/book/python-concurrency-with-asyncio. Полный исходный код можно скачать бесплатно
с сайта издательства Manning по адресу https://www.manning.com/
books/python-concurrency-with-asyncio, также он доступен на Github
по адресу https://github.com/concurrency-in-python-with-asyncio.

Форум на сайте liveBook
Приобретение этой книги открывает бесплатный доступ к платформе liveBook онлайнового чтения, созданной издательством Manning. Средства обсуждения на liveBook позволяют присоединять
комментарии как к книге в целом, так и к отдельным разделам или
абзацам. Совсем несложно добавить примечания для себя, задать или
ответить на технический вопрос и получить помощь от автора и других пользователей. Для доступа к форуму перейдите по адресу https://
livebook.manning.com/#!/book/python-concurrency-with-asyncio/discussion. Узнать о форумах Manning и правилах поведения на них можно по адресу https://livebook.manning.com/#!/discussion.
Издательство Manning обязуется предоставлять площадку для содержательного диалога между читателями, а также между читателями

Об этой книге

18

и автором. Но это обязательство не подразумевает какого-то конкретного количества часов присутствия автора, участие которого в работе
форума остается добровольным (и не оплачивается). Мы рекомендуем
задавать автору трудные вопросы, чтобы его интерес не угасал! Форум
и архивы прошлых обсуждений остаются доступны на сайте издательства до тех пор, пока книга продолжает допечатываться.

Отзывы и пожелания
Мы всегда рады отзывам наших читателей. Расскажите нам, что вы
ду­маете об этой книге, – что понравилось или, может быть, не понравилось. Отзывы важны для нас, чтобы выпускать книги, которые
будут для вас максимально полезны.
Вы можете написать отзыв на нашем сайте www.dmkpress.com,
зайдя­ на страницу книги и оставив комментарий в разделе «Отзывы и рецензии». Также можно послать письмо главному редактору
по адресу dmkpress@gmail.com; при этом укажите название книги
в теме письма.
Если вы являетесь экспертом в какой-либо области и заинтересованы в написании новой книги, заполните форму на нашем сайте по
адресу http://dmkpress.com/authors/publish_book/ или напишите в издательство по адресу dmkpress@gmail.com.

Список опечаток
Хотя мы приняли все возможные меры для того, чтобы обеспечить
высокое качество наших текстов, ошибки все равно случаются. Если
вы найдете ошибку в одной из наших книг, мы будем очень благодарны, если вы сообщите о ней главному редактору по адресу dmkpress@
gmail.com. Сделав это, вы избавите других читателей от недопонимания и поможете нам улучшить последующие издания этой книги.

Нарушение авторских прав
Пиратство в интернете по-прежнему остается насущной проблемой.
Издательства «ДМК Пресс» и Manning Publications очень серьезно относятся к вопросам защиты авторских прав и лицензирования. Если
вы столкнетесь в интернете с незаконной публикацией какой-либо из
наших книг, пожалуйста, пришлите нам ссылку на интернет-ресурс,
чтобы мы могли применить санкции.
Ссылку на подозрительные материалы можно прислать по адресу
элект­ронной почты dmkpress@gmail.com.
Мы высоко ценим любую помощь по защите наших авторов, благодаря которой мы можем предоставлять вам качественные материалы.

Об авторе
Мэттью Фаулер почти 20 лет занимается программной
инженерией на различных должностях: от архитектора ПО
до директора по инженерии. Он начинал с написания научных программ, затем перешел на сквозную разработку
веб-приложений и распределенных систем и в итоге возглавил несколько команд разработчиков и менеджеров,
которые занимались созданием интернет-магазина, обслуживающего десятки миллионов пользователей. Проживает в Лексингтоне, штат Массачусетс, с женой Кэти.

Об иллюстрации
на обложке
На обложке книги изображена крестьянка из маркизата Баден. Рисунок взят из книги Жака Грассе де Сен-Совера, изданной в 1797 году.
Все иллюстрации мастерски нарисованы и раскрашены вручную. В те
времена легко было по одежде определить, где человек живет, чем
занимается и каков его статус. Издательство Manning откликается на
новации и инициативы в компьютерной отрасли обложками своих
книг, на которых представлено широкое разнообразие местных укладов быта в прошлых веках. Мы возвращаем его в том виде, в каком
оно запечатлено на рисунках из таких собраний, как это.

1

Первое знакомство
с asyncio

Краткое содержание главы
Что такое библиотека asyncio и какие преимущества она
дает.
„„ Конкурентность, параллелизм, потоки и процессы.
„„ Глобальная блокировка интерпретатора и создаваемые ей
проблемы для конкурентности.
„„ Как неблокирующие сокеты позволяют добиться
конкурентного выполнения в одном потоке.
„„ Начала конкурентности на основе цикла событий.
„„

Многие программы, особенно в нынешнем мире, где правят бал вебприложения, сильно зависят от операций ввода-вывода. Это скачивание веб-страницы из интернета, взаимодействие по сети с группой
микросервисов или отправка нескольких запросов такой базе данных,
как MySQL или Postgres. Выполнение веб-запроса или взаимодействие с микросервисом может занять несколько сотен миллисекунд
или даже секунды, если сеть медленная. Запрос к базе данных может
занимать много времени, особенно если нагрузка на базу высока или
запрос сложный. Веб-серверу иногда приходится обрабатывать одновременно сотни или тысячи запросов.
Попытка выполнить эти запросы ввода-вывода сразу может привести к серьезным проблемам с производительностью. Если отправлять
запросы один за другим, как в последовательном приложении, то за-

22

Глава 1

Первое знакомство с asyncio

медление будет нарастать. Например, если мы пишем приложение,
которое должно скачать 100 веб-страниц или выполнить 100 запросов к базе данных, и каждое взаимодействие продолжается 1 с, то для
завершения приложения потребуется 100 с. Но если воспользоваться
конкурентностью и начать все скачивания в одно и то же время, после
чего дожидаться результатов, то теоретически все операции можно
было бы завершить за 1 с.
Библиотека asyncio впервые появилась в версии Python 3.4 как еще
один способ справляться с высокими конкурентными нагрузками,
не прибегая к нескольким потокам или процессам. При правильном
использовании эта библиотека может значительно повысить производительность и уменьшить потребление ресурсов в приложениях,
выполняющих много операций ввода-вывода, поскольку позволяет
запускать сразу много таких долго работающих задач.
В этой главе мы познакомимся с основами конкурентности, чтобы
лучше понять, как она достигается в Python и библиотеке asyncio. Мы
рассмотрим различия между задачами, ограниченными быстродействием процессора и производительностью ввода-вывода, чтобы вы
могли понять, какая модель конкурентности лучше отвечает вашим
потребностям. Мы также поговорим об основах процессов и потоков
и о специфических проблемах, связанных с наличием в Python глобальной блокировки интерпретатора (GIL). Наконец, мы поймем, как
использовать неблокирующий ввод-вывод совместно с циклом событий
и таким образом добиться конкурентности всего в одном процессе
и потоке. Это основная модель конкурентности в asyncio.

1.1

Что такое asyncio?
В синхронном приложении код исполняется последовательно. Следующая строка кода выполняется после завершения предыдущей,
и в каждый момент времени происходит что-то одно. Эта модель
хорошо работает для многих, если не для большинства приложений.
Но что, если какая-то одна строка кода занимает слишком много времени? В таком случае весь последующий код должен будет замереть,
пока эта строка не соблаговолит завершиться. Многие из нас раньше
встречали плохо написанные пользовательские интерфейсы, в которых все поначалу шло хорошо, а потом приложение внезапно зависало, оставляя нас созерцать крутящееся колесико или ждать хоть
какого-нибудь ответа. Такие блокировки в приложении оставляют тягостное впечатление у пользователя.
Любая достаточно длительная операция может блокировать приложение, но особенно часто это бывает, когда приложение ждет завершения ввода-вывода. Ввод-вывод выполняют такие устройства,
как клавиатура, жесткий диск и, конечно же, сетевая карта. Такие
операции ждут ввода от пользователя или получения содержимого

Что такое asyncio?

23

от веб-API. В синхронном приложении мы будем ждать завершения
операции и до тех пор ничего не сможем делать. Это ведет к проблемам с производительностью и отзывчивостью, поскольку в каждый
момент времени может выполняться только одна длительная операция, а она не дает приложению больше ничего делать.
Один из способов решения этой проблемы – ввести в программу конкурентность. Говоря по-простому, конкурентность позволяет
одновременно выполнять более одной задачи. Примерами конкурентного ввода-вывода могут служить одновременная отправка нескольких веб-запросов или создание одновременных подключений
к веб-серверу.
В Python есть несколько способов организовать такую конкурентность. Одним из последних добавлений в экосистему Python является
библиотека асинхронного ввода-вывода asyncio. Она позволяет исполнять код в рамках модели асинхронного программирования, т. е.
производить сразу несколько операций ввода-вывода, не жертвуя отзывчивостью приложения.
Так что же означают слова «асинхронное программирование»? Что
длительную задачу можно выполнять в фоновом режиме отдельно от
главного приложения. И система не блокируется в ожидании завершения этой задачи, а может заниматься другими вещами, не зависящими от ее исхода. Затем, по завершении задачи, мы получим уведомление о том, что она все сделала, и сможем обработать результат.
В Python версии 3.4, когда состоялся дебют библиотеки asyncio, она
включала декораторы и синтаксис генератора yield from для определения сопрограмм. Сопрограмма – это метод, который можно приостановить, если имеется потенциально длительная задача, а затем
возобновить, когда она завершится. В Python 3.5 в самом языке была
реализована полноценная поддержка сопрограмм и асинхронного
программирования, для чего были добавлены ключевые слова async
и await. Этот синтаксис, общий с другими языками программирования, например C и JavaScript, позволяет писать асинхронный код
так, что он выглядит как синхронный. Такой асинхронный код проще читать и понимать, поскольку он похож на последовательный код,
с которым большинство программистов хорошо знакомо. Библиотека
asyncio исполняет сопрограммы асинхронно, пользуясь моделью конкурентности, получившей название однопоточный цикл событий.
Название asyncio может навести на мысль, будто библиотека годится только для операций ввода-вывода. Но на самом деле она способна выполнять и операции других типов благодаря взаимодействию
с механизмами многопроцессности и многопоточности. Поэтому
синтаксис async и await можно использовать в сочетании с процессами и потоками, что делает соответствующий код понятнее. Следовательно, библиотека пригодна не только для организации конкурентного ввода-вывода, но и для выполнения счетных задач, активно
использующих процессор. Чтобы лучше разобраться в том, с какими
рабочими нагрузками позволяет справляться asyncio и какая модель

Глава 1

24

Первое знакомство с asyncio

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

1.2

Что такое ограниченность
производительностью ввода-вывода
и ограниченность быстродействием
процессора
Говоря, что операция ограничена производительностью ввода-вывода или быстродействием процессора, мы имеем в виду фактор, препятствующий более быстрой работе. Это значит, что если увеличить
производительность того, что ограничивает операцию, то для ее завершения понадобится меньше времени.
Операция, ограниченная быстродействием процессора (счетная
операция), завершилась бы быстрее, будь процессор более мощным,
например с частотой 3, а не 2 ГГц. Операция, ограниченная производительностью ввода-вывода, работала бы быстрее, если бы устройство могло обработать больше данных за меньшее время. Для этого
можно было бы увеличить пропускную способность сети, заплатив
больше денег интернет-провайдеру или поставив более шуструю сетевую карту.
Счетные операции в Python – это обычно вычисления и обработка данных. Примерами могут служить вычисления цифр числа π или
применение бизнес-логики к каждому элементу словаря. В случае
операции, ограниченной вводом-выводом, мы тратим большую часть
времени на ожидание ответа от сети или другого устройства. Примерами могут служить запрос к веб-серверу или чтение файла с жесткого диска.
Листинг 1.1 Операции, ограниченные производительностью
ввода-вывода и быстродействием процессора
Веб-запрос ограничен
производительностью ввода-вывода
response = requests.get('https://www.example.com')
Обработка ответа
items = response.headers.items()
ограничена
быстродействием
headers = [f'{key}: {header}' for key, header in items]
процессора
formatted_headers = '\n'.join(headers)
Конкатенация строк ограничена
import requests

with open('headers.txt', 'w') as file:
file.write(formatted_headers)
Запись на диск ограничена производительностью ввода-вывода

быстродействием процессора

Конкурентность, параллелизм и многозадачность

25

Операции, ограниченные производительностью ввода-вывода и быстродействием процессора, обычно сосуществуют бок о бок.
Сначала мы выполняем ограниченный производительностью ввода-вывода запрос, чтобы скачать содержимое страницы https://www.
example.com. Получив ответ, мы выполняем ограниченный быстродействием процессора цикл форматирования заголовков ответа, в котором они преобразуются в строку и разделяются символами новой
строки. Затем мы открываем файл и записываем в него строку – обе
операции ограничены производительностью ввода-вывода.
Асинхронный ввод-вывод позволяет приостановить выполнение
метода, встретив операцию ввода-вывода; ожидая завершения этой
операции, работающей в фоновом режиме, мы можем выполнять какой-нибудь другой код. Это позволяет выполнять одновременно много операций ввода-вывода и тем самым ускорить работу приложения.

1.3

Конкурентность, параллелизм
и многозадачность
Чтобы лучше понять, как конкурентность может повысить производительность приложения, важно для начала разобраться в терминологии конкурентного программирования. Мы узнаем, что означает
слово «конкурентность» и как в asyncio используется концепция многозадачности для ее достижения. Конкурентность и параллелизм –
два понятия,помогающие понять, как осуществляется планирование
программы и какие задачи, методы и процедуры приводят весь механизм в действие.

1.3.1 Конкурентность
Говоря, что две задачи выполняются конкурентно, мы имеем в виду,
что они работают в одно и то же время. Взять, к примеру, пекаря, выпекающего два разных торта. Чтобы их испечь, нужно сначала разогреть духовку. Разогрев может занимать десятки минут – это зависит
от духовки и требуемой температуры, но нам необязательно ждать,
пока духовка нагреется, поскольку можно в это время заняться другими делами, например смешать муку с сахаром и вбить в тесто яйца.
Это можно делать, пока духовка звуковым сигналом не известит нас
о том, что нагрелась.
Мы также не хотим связывать себя ограничением – приступать ко
второму торту только после готовности первого. Ничто не мешает замесить тесто для одного торта, положить его в миксер и начать готовить вторую порцию, пока первая взбивается. В этой модели мы
переключаемся между разными задачами конкурентно. Такое переключение (делать что-то, пока духовка разогревается, переключаться
с одного торта на другой) – пример конкурентного поведения.

Глава 1

26

Первое знакомство с asyncio

1.3.2 Параллелизм
Хотя конкурентность подразумевает, что несколько задач выполняется одновременно, это еще не значит, что они работают параллельно.
Говоря о параллельной работе, мы имеем в виду, что две задачи или более не просто чередуются, а выполняются строго в одно и то же время.
Возвращаясь к примеру с тортами, представьте, что мы призвали
на помощь второго пекаря. В этом случае мы можем трудиться над
первым тортом, а помощник будет заниматься вторым. Два человека,
готовящих тесто, работают параллельно, потому что одновременно
делают два разных дела (рис. 1.1).
Конкурентность
Время

Пекарь 1

Включить
духовку

Смешать сухие
ингредиенты
для торта 1

Смешать сухие
ингредиенты
для торта 2

Разбить яйца
для торта 1

Положить яйца
в миксер
для торта 1

Параллелизм
Время

Пекарь 1

Включить
духовку

Смешать сухие
ингредиенты
для торта 1

Включить
миксер

Разбить яйца
для торта 1

Положить яйца
в миксер
для торта 1

Пекарь 2

Включить
духовку

Смешать сухие
ингредиенты
для торта 2

Включить
миксер

Разбить яйца
для торта 2

Положить яйца
в миксер
для торта 2

Рис. 1.1 В случае конкурентности несколько задач работает в течение одного
промежутка времени, но только одна активна в каждый момент. В случае
параллелизма несколько задач активно одновременно

Теперь переведем это на язык приложений, исполняемых операционной системой. Пусть работают два приложения. В конкурентной
системе мы можем переключаться между ними, дав немного поработать сначала одному, а потом другому. Если делать это достаточно
быстро, создается впечатление, что два дела делаются одновременно.
В параллельной системе два приложения работают действительно
одновременно, т. е. оба активны в одно и то же время.
Понятия конкурентности и параллелизма похожи (рис. 1.2), и различить их бывает затруднительно, но важно хорошо понимать, чем
они отличаются.

27

Конкурентность, параллелизм и многозадачность
Конкурентность
Время

Процессор

Выполнять
приложение 1

Выполнять
приложение 2

Выполнять
приложение 3

Выполнять
приложение 4

Выполнять
приложение 5

Параллелизм
Время

Процессор

Выполнять приложение 1

Процессор

Выполнять приложение 2

Рис. 1.2 В случае конкурентности мы переключаемся между двумя
приложениями. В случае параллелизма мы активно выполняем два приложения
одновременно

1.3.3 Различие между конкурентностью и параллелизмом
Конкурентность возможна, когда несколько задач может работать независимо друг от друга. Конкурентность можно организовать, имея
процессор всего с одним ядром, применив вытесняющую многозадачность (определяется в следующем разделе) для переключения между
задачами. С другой стороны, параллелизм означает, что мы должны
выполнять две задачи или более строго одновременно. На машине
с одним ядром это невозможно, необходимо иметь процессор с несколькими ядрами.
Параллелизм подразумевает конкурентность, но обратное верно
не всегда. Многопоточное приложение, работающее на многоядерной
машине, является и конкурентным, и параллельным. В этом случае
имеется несколько задач, работающих одновременно, и два ядра, независимо исполняющих код этих задач. Но многозадачность допускает также наличие нескольких задач, работающих конкурентно, но так,
что в каждый момент времени выполняется только одна.

28

Глава 1

Первое знакомство с asyncio

1.3.4 Что такое многозадачность
В современном мире многозадачность встречается повсеместно. Мы
готовим завтрак, а пока в чайнике закипает вода, кому-то звоним
или отвечаем на текстовое сообщение. А пока электричка везет нас
на работу, читаем книгу. В этом разделе мы обсудим два основных
вида многозадачности: вытесняющую и невытесняющую, или кооперативную.

Вытесняющая многозадачность
В этой модели мы позволяем операционной системе решить, как переключаться между выполняемыми задачами с по­мощью процедуры
квантования времени. Когда операционная система переключает задачи, мы говорим, что имеет место вытеснение.
Как устроен этот механизм, зависит от операционной системы.
Обычно для этого используется либо несколько потоков, либо несколько процессов.

Кооперативная многозадачность
В этой модели мы не полагаемся для переключения между задачами на операционную систему, а явно определяем в коде приложения
точки, где можно уступить управление другой задаче. Исполняемые
задачи кооперируются, т. е. говорят приложению: «Я сейчас на время
приостановлюсь, а ты можешь пока выполнять другие задачи».

1.3.5 Преимущества кооперативной многозадачности
В asyncio для организации конкурентности используется кооперативная многозадачность. Когда приложение доходит до точки, в которой
может подождать результата, мы явно помечаем это в коде. Поэтому другой код может работать, пока мы ждем получения результата,
вычисляемого в фоновом режиме. Как только вычисление результата
завершится, мы «просыпаемся» и возобновляем задачу. Это является
формой конкурентности, потому что несколько задач может работать
одновременно, но – и это очень важно – не параллельно, так как их
выполнение чередуется.
У кооперативной многозадачности есть ряд преимуществ перед
вытесняющей. Во-первых, кооперативная многозадачность потребляет меньше ресурсов. Когда операционной системе нужно переключиться между потоками или процессами, мы говорим, что имеет
место контекстное переключение. Это трудоемкая операция, потому
что операционная система должна сохранить всю информацию о работающем процессе или потоке, чтобы потом его можно было возобновить.
Второе преимущество – гранулярность. Операционная система
приостанавливает поток или процесс в соответствии со своим алго-

Процессы, потоки, многопоточность и многопроцессность

29

ритмом планирования, но выбранный для этого момент не всегда
оптимален. В случае кооперативной многозадачности мы явно помечаем точки, в которых приостановить задачу наиболее выгодно. Это
дает выигрыш в эффективности, потому что мы переключаем задачи
именно в тот момент, когда это нужно. А теперь, когда мы разобрались в понятиях конкурентности, параллелизма и многозадачности,
посмотрим, как они реализуются в Python с по­мощью потоков и процессов.

1.4

Процессы, потоки, многопоточность
и многопроцессность
Чтобы лучше понять, как работает конкурентность в Python, нужно
сначала разобраться с потоками и процессами. Затем мы поговорим
о том, как использовать их для организации конкурентной работы
с по­мощью многопоточности и многозадачности. Начнем с определения процесса и потока.

1.4.1 Процесс
Процессом называется работающее приложение, которому выделена
область памяти, недоступная другим приложениям. Пример создания Python-процесса – запуск простого приложения «hello world» или
ввод слова python в командной строке для запуска цикла REPL (цикл
чтения–вычисления–печати).
На одной машине может работать несколько процессов. Если машина оснащена процессором с несколькими ядрами, то несколько процессов могут работать одновременно. Если процессор имеет
только одно ядро, то все равно можно выполнять несколько приложений конкурентно, но уже с применением квантования времени.
При использовании квантования операционная система будет автоматически вытеснять работающий процесс по истечении некоторого промежутка времени и передавать процессор другому процессу.
Алгоритмы, определяющие, в какой момент переключать процессы,
зависят от операционной системы.

1.4.2 Поток
Потоки можно представлять себе как облегченные процессы. Кроме
того, это наименьшая единица выполнения, которая может управляться операционной системой. У потоков нет своей памяти, они
пользуются памятью создавшего их процесса. Потоки ассоциированы с процессом, создавшим их. С каждым процессом всегда ассоциирован по меньшей мере один поток, обычно называемый главным.
Процесс может создавать дополнительные потоки, которые обычно

Глава 1

30

Первое знакомство с asyncio

называются рабочими или фоновыми. Эти потоки могут конкурентно выполнять другую работу наряду с главным потоком. Потоки, как
и процессы, могут работать параллельно на многоядерном процессе, и операционная система может переключаться между ними с по­
мощью квантования времени. Обычное Python-приложение создает
процесс и главный поток, который отвечает за его выполнение.
Листинг 1.2 Процессы и потоки в простом Python-приложении
import os
import threading
print(f'Исполняется Python-процесс с идентификатором: {os.getpid()}')
total_threads = threading.active_count()
thread_name = threading.current_thread().name
print(f'В данный момент Python исполняет {total_threads} поток(ов)')
print(f'Имя текущего потока {thread_name}')

На рис. 1.3 схематически изображен процесс, соответствующий
листингу 1.2. Мы написали простое приложение, демонстрирующее
работу главного потока. Сначала мы получаем уникальный идентификатор процесса и печатаем его, чтобы доказать, что действительно имеется работающий процесс. Затем получаем счетчик активных
потоков и имя текущего потока, чтобы доказать, что действительно
работает один поток – главный. При каждом выполнении этой программы идентификатор процесса будет разным, но всегда выводятся
строки вида:
Исполняется Python-процесс с идентификатором: 98230
В данный момент Python исполняет 1 поток(ов)
Имя текущего потока MainThread
Родительский процесс
Память

Главный
поток

Рис. 1.3 Процесс с одним
главным потоком, читающим
данные из памяти

Процессы могут порождать дополнительные потоки, разделяющие
память со своим процессом-родителем. Они могут конкурентно выполнять другую работу, это называется многопоточностью.

Процессы, потоки, многопоточность и многопроцессность

Листинг 1.3

31

Создание многопоточного Python-приложения

import threading
def hello_from_thread():
print(f'Привет от потока {threading.current_thread()}!')
hello_thread = threading.Thread(target=hello_from_thread)
hello_thread.start()
total_threads = threading.active_count()
thread_name = threading.current_thread().name
print(f'В данный момент Python выполняет {total_threads} поток(ов)')
print(f'Имя текущего потока {thread_name}')
hello_thread.join()

На рис. 1.4 схематически изображен процесс и потоки, соответствующие листингу 1.3. Мы написали метод, печатающий имя текущего
потока, а затем создали поток, исполняющий этот метод. После этого
вызывается метод потока start, запускающий поток. А в конце вызывается метод join, который приостанавливает программу, до тех
пор пока указанный поток не завершится. Этот код печатает такие
сообщения:
Привет от потока !
В данный момент Python выполняет 2 поток(ов)
Имя текущего потока MainThread
Процесс

Память

Главный поток

Рабочий поток

Рабочий поток

Рис. 1.4 Многопоточная программа с двумя рабочими и одним главным
потоком. Все они разделяют общую память с процессом

Отметим, что при выполнении этой программы сообщения «Привет от потока» и «В данный момент Python выполняет 2 поток(ов)»
могут быть напечатаны на одной строке. Это состояние гонки, мы будем говорить о нем в следующем разделе и подробнее в главах 6 и 7.
Многопоточные приложения – обычный способ реализации конкурентности во многих языках программирования. Но в Python есть
несколько препятствий на пути организации конкурентности с по­

Глава 1

32

Первое знакомство с asyncio

мощью потоков. Многопоточность полезна только для задач, ограниченных производительностью ввода-вывода, потому что нам мешает
глобальная блокировка интерпретатора, о которой пойдет речь в главе 1.5.
Многопоточность не единственный способ добиться конкурентности. Можно вместо потоков создать несколько конкурентных процессов, это называется многопроцессностью. В таком случае родительский процесс создает один или более дочерних процессов, которыми
управляет, а затем распределяет между ними работу.
В Python для этой цели имеется модуль multiprocessing. Его API
похож на API модуля threading. Сначала создается процесс, при этом
передается функция target. Затем вызывается метод start, чтобы начать выполнение процесса, и в конце – метод join, чтобы дождаться
его завершения.
Листинг 1.4

Создание нескольких процессов

import multiprocessing
import os
def hello_from_process():
print(f'Привет от дочернего процесса {os.getpid()}!')
if __name__ == '__main__':
hello_process = multiprocessing.Process(target=hello_from_process)
hello_process.start()
print(f'Привет от родительского процесса {os.getpid()}')
hello_process.join()

На рис. 1.5 схематически показан процесс и потоки для листинга 1.4.
Мы создаем один дочерний процесс, который печатает свой идентификатор, а также печатаем идентификатор родительского процесса,
чтобы доказать, что работают два разных процесса. Многопроцессность обычно предпочтительна, когда имеется счетная задача.
Многопоточность и многопроцессность могут показаться волшебной палочкой, обеспечивающей конкурентность в Python. Однако
развернуться во всю мощь этим моделям мешает одна деталь реализации Python – глобальная блокировка интерпретатора.

33

Глобальная блокировка интерпретатора
Родительский процесс
Память

Главный
поток

Дочерний процесс

Дочерний процесс

Память

Память

Главный
поток

Главный
поток

Рис. 1.5 Приложение, в котором имеется один родительский процесс
и два дочерних

1.5

Глобальная блокировка интерпретатора
Глобальная блокировка интерпретатора (global interpreter lock – GIL) –
тема, вызывающая споры в сообществе Python. Говоря кратко, GIL
не дает Python-процессу исполнять более одной команды байт-кода
в каждый момент времени. Это означает, что, даже если имеется несколько потоков на многоядерной машине, интерпретатор сможет
в каждый момент исполнять только один поток, содержащий написанный на Python код. В мире, где процессоры имеют несколько ядер,
это создает серьезную проблему для разработчиков на Python, желающих воспользоваться многопоточностью для повышения производительности приложений.
ПРИМЕЧАНИЕ Многопроцессные приложения могут конкурентно выполнять несколько команд байт-кода, потому что
у каждого Python-процесса своя собственная GIL.

Глава 1

34

Первое знакомство с asyncio

Так для чего же нужна GIL? Ответ кроется в том, как CPython управляет памятью. В CPython память управляется в основном с по­мощью
подсчета ссылок. То есть для каждого объекта Python, например целого
числа, словаря или списка, подсчитывается, сколько объектов в данный момент используют его. Когда объект перестает быть нужным
кому-то, счетчик ссылок на него уменьшается, а когда кто-то новый
обращается к нему, счетчик ссылок увеличивается. Если счетчик ссылок обратился в нуль, значит, на объект никто не ссылается, поэтому
его можно удалить из памяти.

Что такое CPython?
CPython – это эталонная реализация Python, т. е. стандартная реализация
языка, которая используется как эталон правильного поведения. Существуют и другие реализации, например Jython, работающая под управлением виртуальной машины Java, или IronPython, предназначенная для
.NET Framework.

Конфликт потоков возникает из-за того, что интерпретатор CPython не является потокобезопасным. Это означает, что если два или
более потоков модифицируют разделяемую переменную, то ее конечное состояние может оказаться неожиданным, поскольку зависит от порядка доступа к переменной со стороны потоков. Эта
ситуация называется состоянием гонки. Состояния гонки могут возникать, когда два потока одновременно обращаются к одному объекту Python.
Как показано на рис. 1.6, если два потока одновременно увеличивают счетчик ссылок, то может случиться, что счетчик обнулится, хотя
объект еще используется. Результатом станет крах приложения при
попытке прочитать данные из освобожденной памяти.

READ count = 0

Счетчик ссылок = 0

READ count = 0

Поток
приостановлен

Счетчик ссылок = 1

Count = count + 1

Count = count + 1

Счетчик ссылок = 1

WRITE count = 1

WRITE count = 1

Счетчик ссылок = 1

Рис. 1.6 Состояние гонки, возникающее, когда два потока одновременно
пытаются увеличить счетчик ссылок. Вместо ожидаемого значения счетчика 2
мы получили значение 1

Глобальная блокировка интерпретатора

35

Для демонстрации влияния GIL на многопоточное программирование рассмотрим счетную задачу вычисления n-го числа Фибоначчи. Мы будем использовать довольно медленный алгоритм, чтобы
продемонстрировать операции, занимающие много времени. В правильном решении для повышения производительности следовало
бы использовать технику запоминания или другой математический
метод.
Листинг 1.5 Генерирование последовательности Фибоначчи
и его хронометраж
import time
def print_fib(number: int) -> None:
def fib(n: int) -> int:
if n == 1:
return 0
elif n == 2:
return 1
else:
return fib(n - 1) + fib(n - 2)
print(f'fib({number}) равно {fib(number)}')
def fibs_no_threading():
print_fib(40)
print_fib(41)
start = time.time()
fibs_no_threading()
end = time.time()
print(f'Время работы {end – start:.4f} с.')

В этой реализации используется рекурсия, поэтому алгоритм получился медленным, он занимает время O(2^N). Если нужно напечатать
два числа Фибоначчи, то можно просто вычислить их синхронно и замерить время вычисления, как показано в листинге выше.
В зависимости от быстродействия процессора результаты хронометража могут быть различны, но результат будет примерно таким,
как показано ниже.
fib(40) равно 63245986
fib(41) равно 102334155
Время работы 65.1516 с.

Вычисление долгое, но обращения к функции print_fib не зависят
друг от друга. Это значит, что их можно поместить в разные потоки,
которые теоретически могли бы исполняться разными процессорными ядрами, что ускорило бы работу приложения.

Глава 1

36

Первое знакомство с asyncio

Листинг 1.6 Многопоточное вычисление последовательности чисел
Фибоначчи
import threading
import time
def print_fib(number: int) -> None:
def fib(n: int) -> int:
if n == 1:
return 0
elif n == 2:
return 1
else:
return fib(n - 1) + fib(n - 2)
def fibs_with_threads():
fortieth_thread = threading.Thread(target=print_fib, args=(40,))
forty_first_thread = threading.Thread(target=print_fib, args=(41,))
fortieth_thread.start()
forty_first_thread.start()
fortieth_thread.join()
forty_first_thread.join()
start_threads = time.time()
fibs_with_threads()
end_threads = time.time()
print(f'Многопоточное вычисление заняло {end_threads - start_threads:.4f} с.')

Здесь мы создали два потока, один из которых вычисляет fib(40),
а другой fib(41), и запустили их конкурентно, вызвав метод start()
для каждого. Затем мы дважды вызвали метод join(), благодаря
чему главная программа будет ждать завершения потоков. Учитывая,
что вычисления fib(40) и fib(41) начались одновременно и выполняются конкурентно, можно было бы ожидать разумного ускорения
работы, однако даже на многоядерной машине мы видим такой результат:
fib(40) равно 63245986
fib(41) равно 102334155
Многопоточное вычисление заняло 66.1059 с.

Многопоточная версия работала почти столько же времени. На
самом деле даже чуть дольше! Это все из-за GIL и накладных расходов на создание и управление потоками. Да, потоки выполняются
конкурентно, но в каждый момент времени только одному из них
разрешено выполнять Python-код. А второй поток вынужден ждать
завершения первого, что сводит на нет весь смысл нескольких потоков.

Глобальная блокировка интерпретатора

37

1.5.1 Освобождается ли когда-нибудь GIL?
Предыдущий пример поднимает вопрос, возможна ли вообще многопоточная конкурентность в Python, коль скоро GIL запрещает одновременное выполнение двух строк Python-кода? Но на наше счастье
GIL не удерживается постоянно, что открывает возможность использования нескольких потоков.
Глобальная блокировка интерпретатора освобождается на время
выполнения операций ввода-вывода. Это позволяет использовать потоки для конкурентного выполнения ввода-вывода, но не для выполнения счетного кода, написанного на Python (есть исключения, когда
GIL все же освобождается на время выполнения счетных задач, и мы
обсудим их в следующей главе). Для иллюстрации рассмотрим чтение
кода состояния веб-страницы.
Листинг 1.7

Синхронное чтение кода состояния

import time
import requests
def read_example() -> None:
response = requests.get('https://www.example.com')
print(response.status_code)
sync_start = time.time()
read_example()
read_example()
sync_end = time.time()
print(f'Синхронное выполнение заняло{sync_end - sync_start:.4f} с.')

Здесь мы дважды скачиваем содержимое страницы example.com
и печатаем код состояния. Результат зависит от скорости сетевого
подключения и нашего местоположения, но в целом будет примерно
таким:
200
200
Синхронное выполнение заняло 0.2306 с.

Теперь у нас есть эталон для сравнения – время работы синхронной
версии, – и можно написать многопоточную версию. В ней мы создадим по одному потоку для каждого запроса к example.com и запустим
их конкурентно.
import time
import threading
import requests

Глава 1

38

Первое знакомство с asyncio

def read_example() -> None:
response = requests.get('https://www.example.com')
print(response.status_code)
thread_1 = threading.Thread(target=read_example)
thread_2 = threading.Thread(target=read_example)
thread_start = time.time()
thread_1.start()
thread_2.start()
print('Все потоки работают!')
thread_1.join()
thread_2.join()
thread_end = time.time()
print(f'Многопоточное выполнение заняло {thread_end - thread_start:.4f} с.')

В результате выполнения этой программы мы увидим следующие
строки, где время, как и раньше, зависит от сетевого подключения
и местоположения:
Все потоки работают!
200
200
Многопоточное выполнение заняло 0.0977 с.

Это примерно в два раза быстрее первоначальной версии, в которой потоки не использовались, поскольку теперь два запроса выполняются приблизительно в одно и то же время! Разумеется, результат
зависит от скорости подключения к интернету и от характеристик
машины, но порядок цифр именно такой.
Так почему же GIL освобождается при вводе-выводе, но не освобождается для счетных задач? Все дело в системных вызовах, которые
выполняются за кулисами. В случае ввода-вывода низкоуровневые
системные вызовы работают за пределами среды выполнения Python.
Это позволяет освободить GIL, потому что код операционной системы не взаимодействует напрямую с объектами Python. GIL захватывается снова, только когда полученные данные переносятся в объект
Python. Стало быть, на уровне ОС операции ввода-вывода выполняются конкурентно. Эта модель обеспечивает конкурентность, но не
параллелизм. В других языках, например Java или C++, на многоядерных машинах можно организовать истинный параллелизм, потому
что никакой GIL нет и код может выполняться строго одновременно.
Но в Python лучшее, на что можно рассчитывать, – конкурентность
операций ввода-вывода, поскольку в любой момент может выполняться только один кусок написанного на Python кода.

Как работает однопоточная конкурентность

39

1.5.2 Аsyncio и GIL
В asyncio используется тот факт, что операции ввода-вывода освобождают GIL, что позволяет реализовать конкурентность даже в одном
потоке. При работе с asyncio мы создаем объекты сопрограмм. Сопрограмму можно представлять себе как облегченный поток. Точно так
же, как может быть несколько потоков, работающих одновременно
и исполняющих разные операции ввода-вывода, так может существовать много сопрограмм, работающих бок о бок. Ожидая завершения
сопрограмм, занимающихся вводом-выводом, мы можем выполнять
другой Python-код, получая таким образом конкурентность. Важно
отметить, что asyncio не обходит GIL, мы по-прежнему ограничены
ей. Если имеется счетная задача, то для ее конкурентного выполнения все равно нужно заводить отдельный процесс (и в asyncio есть для
этого средства), иначе производительность снизится. Теперь, когда
мы знаем, как организовать конкурентный ввод-вывод в одном потоке, посмотрим, как это работает на примере неблокирующих сокетов.

1.6

Как работает однопоточная
конкурентность
В предыдущем разделе мы рассмотрели многопоточность как механизм конкурентного выполнения операций ввода-вывода. Однако для достижения такого вида конкурентности заводить несколько
потоков ни к чему. Все это можно сделать в рамках одного процесса и одного потока. При этом мы воспользуемся тем фактом, что на
уровне ОС операции ввода-вывода могут завершаться конкурентно.
Чтобы лучше понять, как это возможно, рассмотрим подробнее, как
работают сокеты и конкретно неблокирующие сокеты.

1.6.1 Что такое сокет?
Сокет – это низкоуровневая абстракция отправки и получения данных по сети. Именно с ее помощью производится обмен данными
между клиентами и серверами. Сокеты поддерживают две основные операции: отправку и получение байтов. Мы записываем байты
в сокет, затем они передаются по адресу назначения, чаще всего на
какой-то сервер. Отправив байты, мы ждем, пока сервер пришлет ответ в наш сокет. Когда байты окажутся в сокете, мы сможем прочитать
результат.
Низкоуровневую концепцию сокетов проще понять, если представлять их как почтовые ящики. Вы можете положить письмо в почтовый ящик, почтальон заберет его и доставит в почтовый ящик
получателя. Получатель откроет его и достанет ваше письмо. В зависимости от содержания письма получатель может отправить письмо

40

Глава 1

Первое знакомство с asyncio

в ответ. Здесь письмо является аналогом данных или байтов, которые
мы хотим отправить. Можно рассматривать помещение письма в почтовый ящик как запись байтов в сокет, а извлечение его из ящика –
как чтение байтов из сокета. Почтальон тогда является аналогом механизма передачи через интернет, который маршрутизирует данные
до адреса назначения.
Если нужно получить содержимое страницы example.com, то мы открываем сокет, подключенный к серверу example.com. Затем записываем в сокет запрос и ждем ответа от сервера, в данном случае HTMLкода веб-страницы. На рис. 1.7 показан поток байтов между клиентом
и сервером.
1. Записать байты

2. Отправить байты
Сокет

4. Прочитать байты

Сервер
3. Ответные байты

Рис. 1.7 Запись байтов в сокет и чтение байтов из сокета

По умолчанию сокеты блокирующие. Это значит, что на все время ожидания ответа от сервера приложение приостанавливается
или блокируется. Следовательно, оно не может ничего делать, пока
не придут данные от сервера или произойдет ошибка или случится
тайм-аут.
На уровне операционной системы эта блокировка ни к чему. Сокеты могут работать в неблокирующем режиме, когда мы просто начинаем операцию чтения или записи и забываем о ней, а сами занимаемся другими делами. Но позже операционная система уведомляет
нас о том, что байты получены, и в этот момент мы можем уделить
им внимание. Это позволяет приложению не ждать прихода байтов,
а делать что-то полезное. Для реализации такой схемы применяются
различные системы уведомления с по­мощью событий, разные в разных ОС. Библиотека asyncio абстрагирует различия между системами
уведомления, а именно:
„„ kqueue – FreeBSD и MacOS;
„„ epoll – Linux;
„„ IOCP (порт завершения ввода-вывода) – Windows.
Эти системы наблюдают за неблокирующими сокетами и уведомляют нас, когда с сокетом можно начинать работу. Именно они лежат
в основе модели конкурентности в asyncio. В этой модели имеется
только один поток, исполняющий Python-код. Встретив операцию
ввода-вывода, интерпретатор передает ее на попечение системы
уведомления, входящей в состав ОС. Совершив этот акт, поток Python
волен исполнять другой код или открыть другие неблокирующие сокеты, о которых позаботится ОС. По завершении операции система
«пробуждает» задачу, ожидающую результата, после чего выполня-

41

Как работает цикл событий

ется код, следующий за этой операцией. Эта схема изображена на
рис. 1.8, где имеется несколько операций с разными сокетами.
Поток Python

ОС наблюдает за сокетом

make_io_request_1()

Добавить сокет 1
и сразу вернуться

Сокет 1

make_io_request_2()

Добавить сокет 2
и сразу вернуться

Сокет 2

make_io_request_3()

Добавить сокет 3
и сразу вернуться

Сокет 3

execute_other_code()

Может выполняться другой код

process_response_1()

Уведомить о готовности сокета 1

process_response_2()

Уведомить о готовности сокета 2

process_response_3()

Уведомить о готовности сокета 3

Рис. 1.8 После записи запроса в неблокирующий сокет управление возвращается
немедленно, а ОС начинает наблюдать за данными в сокете. Поэтому функция
execute_other_code() начинает выполняться сразу, не дожидаясь завершения операции
ввода-вывода. Когда операция завершится, мы получим уведомление и сможем
обработать ответ

Но как теперь отличить задачи, ожидающие завершения ввода-вывода, от тех, которые просто выполняют Python-код и ничего не ждут?
Ответ дает конструкция под названием «цикл событий».

1.7

Как работает цикл событий
Цикл событий – сердце любого приложения asyncio. Этот паттерн проектирования встречается во многих системах и был придуман уже
довольно давно. Используя в браузере JavaScript для отправки асин-

Глава 1

42

Первое знакомство с asyncio

хронного запроса, вы создаете задачу, управляемую циклом событий.
В GUI-приложениях Windows за кулисами используются так называемые циклы обработки сообщений; это основной механизм обработки
таких событий, как нажатие клавиш, он позволяет одновременно отрисовывать интерфейс и реагировать на действия пользователя.
По сути своей цикл событий очень прост. Мы создаем очередь, в которой хранится список событий или сообщений, а затем входим в бесконечный цикл, где обрабатываем сообщения по мере их поступления. В Python базовый цикл событий мог бы выглядеть следующим
образом:
from collections import deque
messages = deque()
while True:
if messages:
message = messages.pop()
process_message(message)

В asyncio цикл событий управляет очередью задач, а не сообщений.
Задача – это обертка вокруг сопрограммы. Сопрограмма может приостановить выполнение, встретив операцию ввода-вывода, и дать циклу событий возможность выполнить другие задачи, которые не ждут
завершения ввода-вывода.
Создавая цикл событий, мы создаем пустую очередь задач. Затем
добавляем в эту очередь задачи для выполнения. На каждой итерации цикла проверяется, есть ли в очереди готовая задача, и если
да, то она выполняется, пока не встретит операцию ввода-вывода.
В этот момент задача приостанавливается, и мы просим операционную систему наблюдать за ее сокетами. А сами тем временем переходим к следующей готовой задаче. На каждой итерации проверяется, завершилась ли какая-нибудь операция ввода-вывода; если да, то
ожидавшие ее завершения задачи пробуждаются и им предоставляется возможность продолжить работу. Эта идея иллюстрируется на
рис. 1.9: главный поток поставляет задачи циклу событий, а тот их
выполняет.
Для конкретики представим, что имеется три задачи, каждая из которых отправляет асинхронный веб-запрос. Допустим, что на этапе
инициализации они выполняют некоторый счетный код, затем посылают веб-запрос, а по его завершении обрабатывают результат, что
снова требуется счет. На псевдокоде это выглядит так:
def make_request():
cpu_bound_setup()
io_bound_web_request()

43

Как работает цикл событий
cpu_bound_postprocess()
task_one = make_request()
task_two = make_request()
task_three = make_request()
Процесс Python

OS watched sockets
Цикл событий

Выполнять все
неприостановленные задачи
до блокирующей операции
ввода-вывода, затем
приостановиться
и зарегистрировать сокет
Главный
поток

Поставляет
задачи

Проверить,
есть ли завершенные
операции ввода-вывода,
и, если да, возобновить
ассоциированные
с ними задачи

Сокет 1

Передать сокет
на попечение ОС

Сокет 3
Извещает, когда сокет готов

Повторить

Рис. 1.9

Сокет 2

...

Сокет N

Пример потока, поставляющего задачи циклу событий

Все три задачи вначале выполняют счетные операции, а поскольку
поток всего один, то первая задача начинает работать, а остальные
две ждут. Закончив счетную часть, задача 1 встречает операцию ввода-вывода и приостанавливается со словами: «Я жду завершения ввода-вывода, пусть поработают другие». После этого начинает работать
задача 2 и, встретив операцию ввода-вывода, тоже приостанавливается. В этот момент обе задачи, 1 и 2, ждут завершения ввода-вывода,
так что может приступить к работе задача 3.
Теперь допустим, что пока задача 3 ждет завершения своей операции ввода-вывода, пришел ответ на веб-запрос от задачи 1. Операционная система уведомляет нас о том, что ввод-вывод завершен. Мы
можем возобновить задачу 1, пока задачи 2 и 3 ждут.
На рис. 1.10 показан поток выполнения, соответствующий только
что описанному псевдокоду. Глядя на диаграмму слева направо, мы
видим, что в каждый момент времени работает только один счетный кусок кода, но при этом конкурентно выполняются одна или две
операции ввода-вывода. Именно из-за такого перекрытия ожидания
ввода-вывода asyncio и достигает экономии во времени.

Глава 1

44

Первое знакомство с asyncio
Время

Задача 1

Выполнение
cpu_bound_setup()

Ожидание ввода-вывода,
вызванного веб-запросом

Задача 2

Выполнение
Ждет возможности
начать выполнение cpu_bound_setup()

Задача 3

Ждет возможности начать выполнение

Рис. 1.10

Выполнение
cpu_bound_postprocess()

Ожидание ввода-вывода,
вызванного веб-запросом

Выполнение
cpu_bound_setup()

Выполнение
cpu_bound_postprocess()

Ожидание ввода-вывода,
вызванного веб-запросом

Выполнение
cpu_bound_postprocess()

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

Резюме
Ограниченной быстродействием процессора (счетной) называется
работа, потребляющая в основном ресурсы процессора, а ограниченной производительностью ввода-вывода – работа, потребляющая в основном ресурсы сети или других устройств ввода-вывода.
Главная задача библиотеки asyncio – обеспечить конкурентность
задач, ограниченных производительностью ввода-вывода, однако
она также предлагает API для организации конкурентности счетных задач.
„„ Процессы и потоки – основные единицы конкурентности на уровне операционной системы. Процессы можно использовать для
рабочих нагрузок, ограниченных как производительностью ввода-вывода, так и быстродействием процессора, а потоки в Python
(обычно) – только для эффективного управления задачами, ограниченными производительностью ввода-вывода, потому что GIL
не дает выполнять код параллельно.
„„ Мы видели, что в случае неблокирующих сокетов можно не приостанавливать приложение на время ожидания данных, а попросить операционную систему уведомить нас, когда данные поступят.
Именно это позволяет asyncio организовать конкурентность в одном потоке.
„„ Мы познакомились с циклом событий, лежащим в основе приложений asyncio. В бесконечном цикле событий исполняются счетные
задачи, а задачи, ожидающие ввода-вывода, приостанавливаются.
„„

2

Основы asyncio

Краткое содержание главы
Основы синтаксиса async await и сопрограмм.
Конкурентное выполнение сопрограмм с по­мощью задач.
„„ Снятие задач.
„„ Создание цикла событий вручную.
„„ Измерение времени выполнения сопрограммы.
„„ Наблюдение за проблемами при выполнении сопрограмм.
„„
„„

В главе 1 мы видели, как организуется конкурентность с по­мощью
процессов и потоков. Мы также рассмотрели возможность использования неблокирующего ввода-вывода и цикла событий для организации конкурентности в одном потоке. В этой главе мы поговорим
о том, как писать однопоточные конкурентные программы с применением asyncio. Рассмотренные приемы позволят вам одновременно запускать длительные операции, например веб-запросы, запросы
к базе данных и установление сетевых подключений.
Мы узнаем о сопрограммах и применении синтаксиса async await
для их определения и выполнения. Мы также научимся выполнять сопрограммы конкурентно с по­мощью задач и посмотрим, какой можно достичь экономии времени, для чего создадим повторно используемый таймер. Наконец, рассмотрим типичные ошибки при работе
с asyncio и научимся находить их в режиме отладки.

Глава 2

46

2.1

Основы asyncio

Знакомство с сопрограммами
Сопрограмму можно рассматривать как обычную функцию Python,
наделенную сверхспособностью: приостанавливаться, встретив операцию, для выполнения которой нужно заметное время. По завершении такой длительной операции сопрограмму можно «пробудить»,
после чего она продолжит выполнение. Пока приостановленная сопрограмма ждет завершения операции, мы можем выполнять другой
код. Такое выполнение другого кода во время ожидания и обеспечивает конкурентность внутри приложения. Можно также одновременно выполнять несколько длительных операций, что еще больше повышает производительность приложения.
Для создания и приостановки сопрограммы нам придется использовать ключевые слова Python async и await. Слово async определяет
сопрограмму, а слово await приостанавливает ее на время выполнения длительной операции.

2.1.1 Создание сопрограмм с по­мощью ключевого слова async
Создать сопрограмму так же просто, как обычную функцию Python,
только вместо ключевого слова def нужно использовать async def.
Ключевое слово async говорит, что это сопрограмма, а не обычная
функция.
Листинг 2.1

Использование ключевого слова async

async def my_coroutine() -> None
print('Hello world!')

Здесь сопрограмма всего лишь печатает сообщение «Hello world!».
Отметим, что она не выполняет никаких длительных операций, просто печатает и возвращает управление. Это значит, что после передачи циклу событий эта сопрограмма будет выполнена немедленно,
поскольку никакого блокирующего ввода-вывода нет и ничто не вынуждает ее приостановиться.
Несмотря на простоту синтаксиса, мы создали нечто, весьма отличное от обычной функции Python. Для иллюстрации напишем функцию, которая прибавляет единицу к целому числу, и сопрограмму,
делающую то же самое, и сравним результаты. Также мы воспользуемся вспомогательной функцией type, чтобы узнать типы значений,
возвращаемых сопрограммой и обычной функцией.
Листинг 2.2

Сравнение сопрограмм с обычными функциями

async def coroutine_add_one(number: int) -> int:
return number + 1
def add_one(number: int) -> int:

Знакомство с сопрограммами

47

return number + 1
function_result = add_one(1)
coroutine_result = coroutine_add_one(1)
print(f'Результат функции равен {function_result}, а его тип равен
{type(function_result)}')
print(f'Результат сопрограммы равен {coroutine_result}, а его тип равен
{type(coroutine_result)}')

Этот код печатает следующие строки:
Результат функции равен 2, а его тип равен
Результат сопрограммы равен ,
а его тип равен

Обратите внимание, что обычная функция add_one исполняется
и возвращает управление сразу, а результат вполне ожидаемый – целое число. Однако код сопрограммы coroutine_add_one вообще не выполняется, а получаем мы объект сопрограммы.
Это важный момент – сопрограммы не выполняются, если их вызвать напрямую. Вместо этого возвращается объект сопрограммы,
который будет выполнен позже. Чтобы выполнить сопрограмму, мы
должны явно передать ее циклу событий. И как же создать цикл событий и выполнить в нем нашу сопрограмму?
В версиях Python, предшествующих 3.7, цикл событий нужно было
создавать вручную, если его еще не было. Но затем в asyncio было добавлено несколько функций, абстрагирующих управление циклом событий. Одна из них – вспомогательная функция asyncio.run, которую
можно использовать для запуска нашей сопрограммы. Это показано
в следующем листинге.
Листинг 2.3

Выполнение сопрограммы

import asyncio
async def coroutine_add_one(number: int) -> int:
return number + 1
result = asyncio.run(coroutine_add_one(1))
print(result)

При выполнении этого кода печатается «2», как и следовало ожидать. Мы подали сопрограмму циклу событий и выполнили ее!
В этом случае asyncio.run делает несколько важных вещей. Вопервых, она создает новое событие. Потом она выполняет код переданной нами сопрограммы до конца и возвращает результат. Эта
функция также подчищает все то, что могло остаться после завершения сопрограммы. И в конце она останавливает и закрывает цикл событий.

Глава 2

48

Основы asyncio

Но, возможно, самое главное в asyncio.run – то, что она задумана
как главная точка входа в созданное нами приложение asyncio. Она
выполняет только одну сопрограмму, и эта сопрограмма должна позаботиться обо всех остальных аспектах приложения. Далее мы будем
использовать эту функцию почти во всех наших приложениях. Сопрограмма, которую выполняет asyncio.run, должна создать и запустить
все прочие сопрограммы, это позволит нам обратить себе на пользу
конкурентную природу asyncio.

2.1.2 Приостановка выполнения с по­мощью ключевого слова
await
Пример, который мы видели в листинге, не имело смысла оформлять
в виде сопрограммы, потому что в нем выполняется только неблокирующий Python-код. Истинное достоинство asyncio – возможность
приостановить выполнение и дать циклу событий возможность выполнить другие задачи, пока длительная операция делает свое дело.
Для приостановки выполнения служит ключевое слово await, за ним
обычно следует обращение к сопрограмме (точнее, к объекту, допускающему ожидание, который необязательно является сопрограммой;
мы вернемся к этому вопросу ниже в этой главе).
Использование ключевого слова await приводит к выполнению
следующей за ним сопрограммы, а не просто к возврату объекту сопрограммы, как при прямом вызове. Кроме того, выражение await
приостанавливает объемлющую сопрограмму до того момента, как
сопрограмма, которую мы ждем, завершится и вернет результат.
А после этого мы получим доступ к возвращенному результату, а объемлющая сопрограмма пробудится и обработает результат.
Ключевое слово await следует поместить перед вызовом сопрограммы.Продолжая предыдущую программу, мы можем написать
код, который вызывает функцию add_one из асинхронной функции
main и получает результат.
Листинг 2.4 Использование await для ожидания результата
сопрограммы
import asyncio
async def add_one(number: int) -> int:
return number + 1
async def main() -> None:
one_plus_one = await add_one(1)
two_plus_one = await add_one(2)
print(one_plus_one)
print(two_plus_one)
asyncio.run(main())

Приостановиться и ждать
результата add_one(1)
Приостановиться и ждать
результата add_one(2)

49

Моделирование длительных операций с по­мощью sleep

В листинге 2.4 мы приостанавливаем выполнение дважды. Сначала
ждем завершения add_one(1). После получения результата выполнение
функции main возобновляется, и мы присваиваем значение, возвращенное add_one(1), переменной one_plus_one, в данном случае она станет
равна 2. Затем то же самое мы проделываем с add_one(2), после чего печатаем результаты. Поток выполнения изображен на рис. 2.1. В блоках
показано, что происходит в одной или нескольких строках кода.
Время

RUN main()

await add_one(1)

one_plus_one = 2

await add_one(2)

two_plus_one = 3
print(one_plus_one)
print(one_plus_two)

PAUSE main()

RESUME main()

PAUSE main()

RESUME main()

RUN add_one(1)

RUN add_one(2)

return 1 + 1

return 1 + 2

Рис. 2.1 Встретив выражение await, интерпретатор приостанавливает родительскую
сопрограмму и выполняет сопрограмму в выражении await. По ее завершении
родительская сопрограмма приостанавливается и возвращенное ей значение
присваивается переменной

Этот код работает так же, как обычный последовательный код. По
существу, мы имитируем обычный стек вызовов. А теперь рассмот­
рим простой пример, показывающий, как во время ожидания выполнить другой код, для чего введем в рассмотрение фиктивную операцию засыпания.

2.2

Моделирование длительных операций с по­
мощью sleep
В предыдущих примерах не было медленных операций, мы просто
изучили базовый синтаксис сопрограмм. Чтобы в полной мере прочувствовать все преимущества и показать, как обрабатывать несколько событий одновременно, нам нужны какие-то длительные операции. Но, вместо того чтобы сразу отправлять недетерминированные
веб-запросы или запросы к базе данных, мы смоделируем длительную операцию, указав, сколько времени ждать завершения. Для этого
воспользуемся функцией asyncio.sleep.
Функция asyncio.sleep заставляет сопрограмму «заснуть» на заданное число секунд, т. е. приостанавливает ее на заданное время.

Глава 2

50

Основы asyncio

Это позволяет смоделировать, что происходит при обращении к базе
данных или веб-ресурсу.
asyncio.sleep сама является сопрограммой, поэтому вызывать ее
следует с по­мощью await. Вызвав ее напрямую, мы получим просто
объект сопрограммы. Раз asyncio.sleep – сопрограмма, то, пока мы
ее ждем, может выполняться другой код.
В листинге ниже показан простой пример – программа засыпает на
1 с, а затем печатает сообщение «Hello World!».
Листинг 2.5

Первое применение sleep

import asyncio
async def hello_world_message() -> str:
await asyncio.sleep(1)
Приостановить
return 'Hello World!'
hello_world_message на 1 с
async def main() -> None:
message = await hello_world_message()
print(message)

Приостановить main до завершения
hello_world_message

asyncio.run(main())

Эта программа ждет 1 с, а затем печатает сообщение «Hello World!».
Поскольку hello_world_message – сопрограмма, а мы приостановили
ее на 1 с, у нас появилась одна секунда, в течение которой мог бы конкурентно работать другой код.
В последующих примерах мы часто будем использовать sleep, поэтому потратим немного времени на создание повторно используемой сопрограммы, которая спит заданное время, а затем печатает
полезную информацию. Назовем ее delay. Код приведен ниже.
Листинг 2.6

Повторно используемая сопрограмма delay

import asyncio
async def delay(delay_seconds: int) -> int:
print(f'засыпаю на {delay_seconds} с')
await asyncio.sleep(delay_seconds)
print(f'сон в течение {delay_seconds} с закончился')
return delay_seconds

Сопрограмма delay принимает целое число – продолжительность
сна в секундах – и возвращает это же число вызывающей стороне по
завершении сна. Мы также печатаем сообщения, когда сон начался
и закончился. Это позволит увидеть, как конкурентно работает другой код, пока наша сопрограмма приостановлена.
Чтобы было проще обращаться к этой функции, создадим модуль,
который будем при необходимости импортировать. В этот модуль мы
будем добавлять и другие написанные нами повторно используемые

Моделирование длительных операций с по­мощью sleep

51

функции. Назовем его util и поместим нашу функцию delay в файл
delay_functions.py. Также добавим файл __init__.py, содержащий
следующую строку, чтобы было удобнее импортировать таймер:
from util.delay_functions import delay

Начиная с этого момента, мы будем применять предложение from
util import delay всякий раз, как нужно использовать функцию delay. Теперь, когда у нас есть повторно используемая сопрограмма
задержки, объединим ее с написанной ранее сопрограммой add_one
и попробуем заставить простое сложение работать конкурентно, пока
hello_world_message приостановлена.
Листинг 2.7

Выполнение двух сопрограмм

import asyncio
from util import delay
async def add_one(number: int) -> int:
return number + 1
async def hello_world_message() -> str:
await delay(1)
return 'Hello World!'
async def main() -> None:
message = await hello_world_message()
one_plus_one = await add_one(1)
print(one_plus_one)
print(message)

Приостановить main до возврата
из hello_world_message
Приостановить main
до возврата из add_one

asyncio.run(main())

При выполнении этого кода проходит 1 с, прежде чем будут напечатаны результаты обеих функций. Мы же хотели, чтобы значение
add_one(1) было напечатано немедленно, пока hello_world_message()
работает конкурентно. И что же не так с этим кодом? Дело в том, что
await приостанавливает текущую сопрограмму и код внутри нее не
выполняется, пока выражение await не вернет значение. Поскольку
hello_world_message вернет значение только через секунду, сопрограмма main на эту секунду и приостанавливается. В данном случае
код ведет себя как последовательный. Это поведение показано на
рис. 2.2.
И main, и hello_world приостановлены в ожидании завершения delay(1). А когда это случится, main возобновляется и может выполнить
add_one.
Нам хотелось бы уйти от этой последовательной модели и выполнять add_one конкурентно с hello_world. Для этого введем в рассмот­
рение концепцию задачи.

Глава 2

52

Основы asyncio

Время
await hello_world()
RUN main()

message = ‘Hello World’ await add_one(1) one_plus_one = 1
PAUSE main()

RUN hello_world()
return 1 + 1

PAUSE
hello_world()

RESUME main()

RESUME
hello_world()

await delay(1) return ‘Hello World!’

PAUSE main()

RESUME main()

RUN add_one(1)
return 1 + 2

1 second

Рис. 2.2

2.3

Поток выполнения кода в листинге 2.7

Конкурентное выполнение с по­мощью
задач
Ранее мы видели, что непосредственный вызов сопрограммы не передает ее циклу событий для выполнения. Вместо этого мы получаем
объект сопрограммы, который нужно затем использовать совместно
с ключевым словом await или передать функции asyncio.run, чтобы
получить возвращенное значение. Располагая только этими инструментами, мы можем написать асинхронный код, но не можем выполнить его конкурентно. А чтобы это сделать, нужны задачи.
Задача – это обертка вокруг сопрограммы, которая планирует выполнение последней в цикле событий как можно раньше. И планирование, и выполнение происходят в неблокирующем режиме, т. е.,
создав задачу, мы можем сразу приступить к выполнению другого
кода, пока эта задача работает в фоне. Сравните с ключевым словом
await, которое блокирует выполнение, т. е. мы приостанавливаем
всю сопрограмму на время, пока выражение await не вернет управление.
Способность создавать задачи и планировать их для немедленного
выполнения в цикле событий означает, что несколько задач может
работать приблизительно в одно и то же время. Пока одна задача выполняет длительную операцию, другие могут работать конкурентно.
Для иллюстрации создадим две задачи и попробуем выполнить их
одновременно.

2.3.1 Основы создания задач
Для создания задачи служит функция asyncio.create_task. Ей передается подлежащая выполнению сопрограмма, а в ответ она немедленно возвращает объект задачи. Этот объект можно включить в выражение await, которое извлечет возвращенное значение по завершении
задачи.

Конкурентное выполнение с по­мощью задач

Листинг 2.8

53

Создание задачи

import asyncio
from util import delay
async def main():
sleep_for_three = asyncio.create_task(delay(3))
print(type(sleep_for_three))
result = await sleep_for_three
print(result)
asyncio.run(main())

Здесь мы создали задачу, которой для выполнения нужно 3 с. Кроме того, мы печатаем тип задачи, в данном случае , чтобы показать, что это не сопрограмма.
Следует также отметить, что предложение печати выполняется
сразу после запуска задачи. Если бы мы просто использовали await
для сопрограммы delay, то увидели бы сообщение только через 3 с.
Напечатав сообщение, мы применяем await к задаче sleep_for_
three. Это приостанавливает сопрограмму main до получения результата от задачи.
Важно отметить, что обычно в каком-то месте приложения нужно
использовать await для задачи. В листинге 2.8 если бы мы не включили await, то задача была бы запланирована, но почти сразу остановлена, после чего интерпретатор «прибрал» бы за ней, когда asyncio.
run завершит цикл событий. Использование await применительно
к задачам влияет также на обработку исключений, о чем речь пойдет
в главе 3. Познакомившись с тем, как создавать задачи и конкурентно
запускать другой код, посмотрим, как можно одновременно выполнять несколько длительных операций.

2.3.2 Конкурентное выполнение нескольких задач
Коль скоро задачи создаются мгновенно и планируются для выполнения как можно раньше, мы получаем возможность конкурентно
выполнять несколько длительных задач. Для этого нужно последовательно запустить несколько задач в одной долго работающей сопрограмме.
Листинг 2.9

Конкурентное выполнение нескольких задач

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

Глава 2

54

Основы asyncio

await sleep_again
await sleep_once_more
asyncio.run(main())

Здесь мы запустили три задачи, каждой из которых для завершения
нужно 3 с. Каждое обращение к create_task возвращает управление
немедленно, поэтому до предложения await sleep_for_three мы доходим сразу же. Ранее мы отмечали, что выполнение задач планируется «как можно раньше». На практике это означает, что в точке,
где встречается первое после создания задачи предложение await, все
ожидающие задачи начинают выполняться, так как await запускает
очередную итерацию цикла событий.
Поскольку первым мы встречаем предложение await sleep_for_
three, все три задачи начинают выполняться и засыпают одновременно. Значит, программа в листинге 2.9 завершится примерно через 3 с.
Эта конкурентность показана на рис. 2.3 – обратите внимание, что все
три задачи исполняют свои сопрограммы sleep в одно и то же время.
Заметим, что на рис. 2.3 код в задачах, помеченных RUN delay(3)
(в данном случае некоторые предложения print), не работает конкурентно с другими задачами; конкурентно задачи только спят. Если
бы мы выполняли операции задержки последовательно, то программа работала бы дольше 9 с. А конкурентность позволила уменьшить
время работы в три раза!
Время
task_1 = create_task(delay(3))
task_2 = create_task(delay(3))
task_3 = create_task(delay(3))
PAUSE main()

RUN main()

task_1

await asyncio.sleep(3)
PAUSE delay()

RUN
delay(3)

task_2

RUN
delay(3)

await asyncio.sleep(3)
PAUSE delay()

RUN
delay(3)

task_3

RUN main()

RUN
delay(3)

await asyncio.sleep(3)
PAUSE delay()

task_1 завершилась

RUN
delay(3)

task_2 завершилась

RUN
delay(3)

task_3 завершилась

Приблизительно 3 секунды

Рис. 2.3

Поток выполнения программы в листинге 2.9

ПРИМЕЧАНИЕ Это превосходство растет по мере увеличения
числа задач; если бы мы запустили 10 таких задач, то программа работала бы те же 3 с, что дало бы 10-кратное ускорение.

Конкурентное выполнение с по­мощью задач

55

Конкурентное выполнение таких длительных операций – как раз
та область, где asyncio блистает и резко улучшает производительность
приложения, но на этом преимущества не заканчиваются. Приложение
в листинге 2.9 активно било баклуши, в течение трех секунд ожидая истечения времени задержки. Но пока один код ожидает, можно было бы
выполнить другой. Допустим, мы хотим один раз в секунду печатать
сообщения о состоянии, пока какие-то длительные задачи работают.
Листинг 2.10 Выполнение кода, пока другие операции работают
в фоне
import asyncio
from util import delay
async def hello_every_second():
for i in range(2):
await asyncio.sleep(1)
print("пока я жду, исполняется другой код!")
async def main():
first_delay = asyncio.create_task(delay(3))
second_delay = asyncio.create_task(delay(3))
await hello_every_second()
await first_delay
await second_delay

Здесь мы создаем две задачи, работающие по 3 с. Пока эти задачи
ждут, приложение простаивает, что дает нам возможность занять его
другим кодом. В этом примере выполняется сопрограмма hello_eve­
ry_second, которая дважды печатает сообщение с интервалом в одну
секунду. Пока две задачи что-то делают, мы видим, как печатаются
сообщения:
засыпаю на 3 с
засыпаю на 3 с
пока я жду, исполняется другой код!
пока я жду, исполняется другой код!
сон в течение 3 с закончился
сон в течение 3 с закончился

Поток выполнения показан на рис. 2.4.
Сначала мы запускаем две задачи, которые спят в течение 3 с; пока
эти задачи простаивают, мы видим, как каждую секунду печатается
сообщение «пока я жду, исполняется другой код!». Это означает, что
даже во время выполнения длительных операций наше приложение
может выполнять другие задачи.
Потенциальная проблема заключается в том, что задача может работать неопределенно долго. Быть может, нам захочется остановить
задачу, если она никак не кончается сама. Такая возможность поддерживается и называется снятием.

Глава 2

56

Основы asyncio

Приблизительно 3 секунды
first_delay

second_delay

Рис. 2.4

2.4

await asyncio.sleep(3)
PAUSE delay()

RUN
delay(3)

RUN
delay(3)

await asyncio.sleep(3)
PAUSE delay()

RUN
delay(3)

first_delay завершилась
RUN
delay(3)

RUN
hello_every
_second

PAUSE
hello_every
_second

RUN
hello_every
_second

PAUSE
hello_every
_second

RUN
hello_every
_second

for i in range(2)

One
second

print(“I’m running
other code while
I’m waiting!”)

One
second

print(“I’m running
other code while
I’m waiting!”)

second_delay
завершилась

Поток выполнения программы в листинге 2.10

Снятие задач и задание тайм-аутов
Сетевые подключения ненадежны. Установленное пользователем
подключение может быть разорвано из-за медленной сети, или вебсервер может «упасть» и оставить существующие запросы в подвешенном состоянии. Поэтому, отправляя запросы, мы должны внимательно следить за ними, чтобы не ждать слишком долго. Иначе
приложение может зависнуть, ожидая результата, который никогда
не придет. Наш пользователь останется недоволен – маловероятно,
что он захочет вечно ждать ответа на свой запрос. Кроме того, иногда
имеет смысл предоставить пользователю выбор, если задача работает слишком долго. Пользователь может согласиться с тем, что его запрос требует много времени, или остановить задачу, запущенную по
ошибке.
В рассмотренных выше примерах если бы задачи работали вечно,
то мы бы застряли в предложении await без всякой обратной связи.
И остановить бы программу не смогли, даже если бы захотели. В библиотеке asyncio предусмотрены обе ситуации – мы можем снять задачу или задать тайм-аут.

2.4.1 Снятие задач
Снять задачу просто. У каждого объекта задачи есть метод cancel, который можно вызвать, если требуется остановить задачу. В результате снятия задача возбудит исключение CancelledError, когда мы ждем
ее с по­мощью await. Это исключения можно обработать, как того требует ситуация.
Для иллюстрации предположим, что мы запустили задачу, которая
не должна работать дольше 5 с. Если за это время задача не завершилась, то мы хотим ее снять, сообщив пользователю, что задача работает слишком долго и будет остановлена. Мы также хотим каждую
секунду печатать сообщение о состоянии, чтобы держать пользователя в курсе, а не оставлять в неведении на протяжении нескольких
секунд.

Снятие задач и задание тайм-аутов

Листинг 2.11

57

Снятие задачи

import asyncio
from asyncio import CancelledError
from util import delay
async def main():
long_task = asyncio.create_task(delay(10))
seconds_elapsed = 0
while not long_task.done():
print('Задача не закончилась, следующая проверка через секунду.')
await asyncio.sleep(1)
seconds_elapsed = seconds_elapsed + 1
if seconds_elapsed == 5:
long_task.cancel()
try:
await long_task
except CancelledError:
print('Наша задача была снята')
asyncio.run(main())

Здесь мы создаем задачу, работающую 10 с. Затем в цикле while
проверяем состояние задачи. Метод задачи done возвращает True,
если задача завершилась, и False в противном случае. Каждую секунду мы проверяем, завершилась ли задача, и запоминаем, сколько
секунд уже прошло. Если задача работает дольше 5 с, то мы ее снимаем. Далее задача запускается в предложении await long_task и, если
возникло исключение CancelledError, печатается сообщение «Наша
задача была снята».
Важно отметить, что исключение CancelledError может быть возбуждено только внутри предложения await. То есть, если вызвать метод cancel, когда задача исполняет Python-код, этот код будет продолжать работать, пока не встретится следующее предложение await
(если встретится), и только тогда будет возбуждено исключение CancelledError. Вызов cancel не прерывает задачу, делающую свое дело;
он снимает ее, только если она уже находится в точке ожидания или
когда дойдет до следующей такой точки.

2.4.2 Задание тайм-аута и снятие с по­мощью wait_for
Проверять состояние каждую секунду или с другим интервалом, как
в предыдущем примере, – не самый простой способ реализации таймаута. В идеале хотелось бы иметь вспомогательную функцию, которая
позволяла бы задать тайм-аут и снять задачу по его истечении.
В asyncio есть такая возможность в виде функции asyncio.wait_for.
Она принимает объект сопрограммы или задачи и тайм-аут в секун-

Глава 2

58

Основы asyncio

дах и возвращает сопрограмму, к которой можно применить await.
Если задача не завершилась в отведенное время, то возбуждается исключение TimeoutError и задача автоматически снимается.
Для иллюстрации работы wait_for мы рассмотрим случай, когда задаче требуется две секунды, но мы даем ей только одну. Мы перехватываем исключение TimeoutError и смотрим, была ли задача снята.
Листинг 2.12

Задание тайм-аута для задачи с по­мощью wait_for

import asyncio
from util import delay
async def main():
delay_task = asyncio.create_task(delay(2))
try:
result = await asyncio.wait_for(delay_task, timeout=1)
print(result)
except asyncio.exceptions.TimeoutError:
print('Тайм-аут!')
print(f'Задача была снята? {delay_task.cancelled()}')
asyncio.run(main())

Эта программа завершается примерно через 1 с. По истечении 1 с
предложение wait_for возбуждает исключение TimeoutError, которое
мы обрабатываем, а именно смотрим, была ли снята задача delay,
и печатаем следующие сообщения:
засыпаю на 2 с
Тайм-аут!
Задача была снята? True

Автоматическое снятие задачи, работающей дольше, чем ожидается, обычно является разумной практикой. В противном случае сопрограмма могла бы ждать неопределенно долго, занимая ресурсы,
которые никогда не будут освобождены. Но в некоторых случаях желательно дать сопрограмме поработать. Например, по прошествии
некоторого времени мы можем проинформировать пользователя
о том, что работа занимает дольше, чем ожидалось, но не снимать ее,
когда тайм-аут истечет.
Для этого обернем нашу задачу функцией asyncio.shield. Эта
функция предотвращает снятие сопрограммы, снабжая ее «щитом»,
позволяющим игнорировать запросы на снятие.
Листинг 2.13

Защита задачи от снятия

import asyncio
from util import delay
async def main():
task = asyncio.create_task(delay(10))
try:

Задачи, сопрограммы, будущие объекты и объекты, допускающие ожидание

59

result = await asyncio.wait_for(asyncio.shield(task), 5)
print(result)
except TimeoutError:
print("Задача заняла более 5 с, скоро она закончится!")
result = await task
print(result)
asyncio.run(main())

Здесь мы сначала создаем задачу, обертывающую сопрограмму.
В этом состоит отличие от нашего первого примера снятия, потому
что нам необходим доступ к задаче в блоке except. Если бы мы передали сопрограмму, то wait_for обернула бы ее задачей, но сослаться
на эту задачу мы бы не смогли, потому что она внутренняя.
Затем внутри блока try мы вызываем wait_for, обернув предварительно задачу функцией shield, чтобы она не была снята. В блоке
обработки исключения мы печатаем полезное сообщение пользователю, в котором говорим, что задача еще работает, после чего ждем
ее завершения с по­мощью await. Это позволит задаче доработать до
конца, а пользователь увидит следующие сообщения:
засыпаю на 10 с
Задача заняла более 5 с, скоро она закончится!
сон в течение 10 с закончился
завершилась за 10 с

Снятие и защита от снятия – довольно хитрые вещи, у которых есть
несколько достойных внимания сценариев применения. Мы познакомились с основами, но по мере усложнения примеров более глубоко изучим, как работает механизм снятия.
Мы рассмотрели основы задач и сопрограмм. Эти понятия переплетаются. В следующем разделе мы поговорим о том, как они связаны между собой, и увидим, как устроена библиотека asyncio.

2.5

Задачи, сопрограммы, будущие объекты
и объекты, допускающие ожидание
И сопрограммы, и задачи можно использовать в выражениях await.
Так что же между ними общего? Чтобы ответить на этот вопрос, нужно знать о типах future и awaitable. На практике будущие объекты
(future) бывают нужны редко, но понимать их необходимо, если мы
хотим уяснить, как работает asyncio. Далее в книге мы будем специально отмечать API, возвращающие будущие объекты.

2.5.1 Введение в будущие объекты
Объект future в Python содержит одно значение, которое мы ожидаем
получить в будущем, но пока еще, возможно, не получили. Обычно

Глава 2

60

Основы asyncio

в момент создания future не обертывает никакого значения, потому
что его еще не существует. Объект в таком состоянии называется неполным, неразрешенным или просто неготовым. И только получив
результат, мы можем установить значение объекта future, в результате чего он становится полным и из него можно извлечь результат.
Чтобы лучше разобраться со всем этим, создадим будущий объект,
установим его значение и затем извлечем его.
Листинг 2.14

Основы будущих объектов

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()}')

Для создания объекта future нужно вызвать его конструктор. В этот
момент во future нет никакого результата, поэтому вызов метода
done возвращает False. Затем мы устанавливаем значение future методом set_result, который помечает его как готовый. Если бы вместо
этого мы хотели записать во future исключение, то вызвали бы метод
set_exception.
ПРИМЕЧАНИЕ Мы не вызываем метод result, прежде чем результат установлен, потому что тогда он возбудил бы исключение InvalidState.
Будущие объекты также можно использовать в выражениях await.
Это означает «я посплю, пока в будущем объекте не будет установлено значение, с которым я могу работать, а когда оно появится, разбуди меня и дай возможность его обработать».
Рассмотрим пример – отправка веб-запроса возвращает объект future. В этом случае future возвращается немедленно, но, поскольку
запрос занимает некоторое время, значение future еще не определено. Позже, когда запрос завершится, результат будет установлен, и мы
сможем его получить. Те, кто знаком с JavaScript, легко увидят аналогию с обещаниями (promise). В Java похожая концепция называется
дополняемыми будущими объектами (completable future).
Листинг 2.15

Ожидание будущего объекта

from asyncio import Future
import asyncio
def make_request() -> Future:

Задачи, сопрограммы, будущие объекты и объекты, допускающие ожидание
future = Future()
asyncio.create_task(set_future_value(future))
return future
async def set_future_value(future) -> None:
await asyncio.sleep(1)
Ждать 1 с, прежде чем
future.set_result(42)
установить значение
async def main():
future = make_request()
print(f'Будущий объект готов? {future.done()}')
value = await future
print(f'Будущий объект готов? {future.done()}')
print(value)

61

Создать задачу, которая
асинхронно установит
значение future

Приостановить main,
пока значение future
не установлено

asyncio.run(main())

Здесь мы определяем функцию make_request. В этой функции создается объект future и создается задача, которая асинхронно установит результат future через 1 с. Затем в функции main мы вызываем
make_request. Она сразу же возвращает неготовый будущий объект, не
содержащий результата, после чего мы применяем к нему await. Ожидание готовности этого объекта приостанавливает main на 1 с. Когда
ожидание завершится, value будет равно 42 и объект future станет готовым.
В asyncio редко приходится иметь дело с будущими объектами. Тем
не менее встречаются API, возвращающие будущие объекты, а иногда
возникает необходимость писать код обратных вызовов, что требует
будущих объектов. Кроме того, возможно, вам доведется читать или
отлаживать код каких-то API asyncio самостоятельно. Реализация таких API опирается на будущие объекты, так что неплохо бы хотя бы
в общих чертах понимать, как они работают.

2.5.2 Связь между будущими объектами, задачами
и сопрограммами
Между задачами и будущими объектами существует тесная связь. На
самом деле task напрямую наследует future. Можно считать, что объект future представляет значение, которое появится только в будущем. А task является комбинацией сопрограммы и future. Создавая
задачу, мы создаем пустой объект future и запускаем сопрограмму.
А когда сопрограмма завершится с результатом или вследствие исключения, мы записываем этот результат или объект-исключение во
future.
А существует ли аналогичная связь между задачами и сопрограммами? Ведь все эти типы можно использовать в выражениях await.
Связующим звеном между ними является абстрактный базовый
класс Awaitable. В нем определен единственный абстрактный метод
__await__. Мы не будем вдаваться в детали того, как создавать соб-

Глава 2

62

Основы asyncio

ственные объекты, допускающие ожидание, а просто скажем, что любой объект, который реализует метод __await__, можно использовать
в выражении await. Сопрограммы, как и будущие объекты, наследуют
Awaitable напрямую. Задачи же расширяют будущие объекты, так что
мы имеем диаграмму наследования, показанную на рис. 2.5.

Awaitable

Coroutine

Future

Task

Рис. 2.5

Иерархия наследования класса Awaitable

Далее будем называть объекты, которые можно использовать в выражениях await, объектами, допускающими ожидание (awaitable). Этот
термин часто встречается в документации по asyncio, поскольку многие методы API готовы принимать и сопрограммы, и задачи, и будущие объекты.
Итак, с основами сопрограмм, задач и будущих объектов мы разобрались. А как оценить их производительность? До сих пор мы только
теоретизировали на тему времени их работы. Чтобы сделать рассуждения более строгими, добавим средства измерения времени.

2.6

Измерение времени выполнения
сопрограммы с по­мощью декораторов
До сих пор мы лишь приблизительно говорили о том, сколько времени работают наши приложения, не измеряя его явно. Чтобы построить настоящий профиль, нужно написать код хронометража.
В качестве первой попытки можно было бы обернуть каждое предложение await, запоминая время начала и завершения сопрограммы:
import asyncio
import time

Измерение времени выполнения сопрограммы с по­мощью декораторов

63

async def main():
start = time.time()
await asyncio.sleep(1)
end = time.time()
print(f'Сон занял {end - start} с)
asyncio.run(main())

Однако если предложений await и задач много, то это быстро надоедает. Хорошо бы придумать допускающий повторное использование
способ замерять время работы любой сопрограммы. Это можно сделать с по­мощью декоратора, который будет выполнять предложение
await за нас (листинг 2.16). Назовем этот декоратор async_timed.

Что такое декоратор?
Декоратор в Python – это паттерн, который позволяет расширять функциональность существующей функции, не изменяя ее код. Мы можем
«перехватить» вызываемую функцию и применить декоратор до или после вызова. Декоратор – один из способов реализации сквозной функциональности. В следующем листинге приведен пример декоратора.

Листинг 2.16

Декоратор для хронометража сопрограмм

import functools
import time
from typing import Callable, Any
def async_timed():
def wrapper(func: Callable) -> Callable:
@functools.wraps(func)
async def wrapped(*args, **kwargs) -> Any:
print(f'выполняется {func} с аргументами {args} {kwargs}')
start = time.time()
try:
return await func(*args, **kwargs)
finally:
end = time.time()
total = end - start
print(f'{func} завершилась за {total:.4f} с')
return wrapped
return wrapper

Здесь мы создаем новую сопрограмму wrapped. Это обертка вокруг
исходной сопрограммы, которая принимает ее аргументы, *args
и **kwargs, выполняет предложение await и возвращает результат.
Мы окружили это предложение двумя сообщениями: одно печатается в начале выполнения функции, а второе в конце. В них отображаются время начала и завершения точно так же, как мы делали в пре-

Глава 2

64

Основы asyncio

дыдущих примерах. Теперь можно добавить к любой сопрограмме
этот декоратор в виде аннотации и увидеть, сколько времени она
работала.
Листинг 2.17 Хронометраж двух конкурентных задач с по­мощью
декоратора
import asyncio
@async_timed()
async def delay(delay_seconds: int) -> int:
print(f'засыпаю на {delay_seconds} с')
await asyncio.sleep(delay_seconds)
print(f'сон в течение {delay_seconds} с закончился')
return delay_seconds
@async_timed()
async def main():
task_one = asyncio.create_task(delay(2))
task_two = asyncio.create_task(delay(3))
await task_one
await task_two
asyncio.run(main())

Эта программа печатает примерно такие сообщения:
выполняется с аргументами () {}
выполняется с аргументами (2,) {}
выполняется с аргументами (3,) {}
завершилась за 2.0032 с
завершилась за 3.0003 с
завершилась за 3.0004 с

Как видим, два вызова delay работали примерно 2 и 3 с соответственно, что в сумме составляет 5 с. Заметим, однако, что сопрограмма main работала всего 3 с, поскольку ожидание производилось конкурентно.
Мы будем пользоваться этим декоратором и сведениями, которые
он выводит, в следующих нескольких главах, чтобы показать, сколько времени требуется для выполнения нашим сопрограммам и когда
они начинаются и завершаются. Это поможет составить четкое представление о том, какой выигрыш в производительности дает конкурентность.
Чтобы на этот декоратор было проще ссылаться в будущем, включим его в наш модуль util и запишем код в файл async_timer.py. Также добавим в файл модуля __init__.py следующую строку, позволяющую удобно импортировать таймер:
from util.async_timer import async_timed

Ловушки сопрограмм и задач

65

Далее в этой книге мы будем писать from util import async_timed,
когда понадобится таймер.
Теперь, когда можем использовать декоратор для измерения выигрыша, который дает asyncio при конкурентном выполнении задач,
попробуем применить asyncio к существующим приложениям. Это
возможно, но нужно внимательно следить за тем, чтобы не попасть
в одну из типичных ловушек, которые могут не повысить, а снизить
производительность приложения.

2.7

Ловушки сопрограмм и задач
Увидев, какой выигрыш может дать конкурентное выполнение длительных задач, мы можем поддаться искушению использовать сопрограммы и задачи всюду и везде. Но просто снабдить функции
ключевым словом async и обернуть их задачами может оказаться недостаточно для повышения производительности. А в некоторых случаях производительность может даже упасть.
Есть две основные ошибки на пути преобразования приложения
в асинхронное. Первая – попытка выполнить счетный код в задачах
или сопрограммах, не прибегая к многопроцессности, вторая – использовать блокирующие API ввода-вывода, пренебрегая многопоточностью.

2.7.1

Выполнение счетного кода
В программе могут быть функции, выполняющие длительные вычисления, например обход большого словаря или математические
расчеты. Если есть возможность выполнять эти функции конкурентно, то может возникнуть идея поместить их в отдельные задачи. В принципе, ничего плохого в этом нет, но нужно помнить,
что модель конкурентности в asyncio однопоточная. Это значит, что
действуют все ограничения одного потока и глобальной блокировки
интерпретатора.
Чтобы убедиться в этом, попробуем запустить счетные функции
конкурентно.
Листинг 2.18

Попытка конкурентного выполнения счетного кода

import asyncio
from util import delay
@async_timed()
async def cpu_bound_work() -> int:
counter = 0
for i in range(100000000):
counter = counter + 1
return counter

Глава 2

66

Основы asyncio

@async_timed()
async def main():
task_one = asyncio.create_task(cpu_bound_work())
task_two = asyncio.create_task(cpu_bound_work())
await task_one
await task_two
asyncio.run(main())

Выполнив этот код, мы увидим, что, несмотря на создание двух
задач, он по-прежнему работает последовательно: сначала задача 1,
потом – 2, и, значит, общее время работы складывается из двух обращений к cpu_bound_work:
выполняется с аргументами () {}
выполняется с аргументами () {}
завершилась за 4.6750 с
выполняется с аргументами () {}
завершилась за 4.6680 с
завершилась за 9.3434 с

Глядя на этот результат, мы можем подумать, что нет ничего плохого в том, чтобы повсюду расставить async и await. В конце концов,
времени-то уходит столько же, сколько при последовательном выполнении. Однако при таком подходе мы можем оказаться в ситуации, когда производительность приложения падает. Особенно если
в программе есть другие сопрограммы или задачи, в которых встречаются выражения await. Рассмотрим создание двух счетных задач
наряду с длительной задачей, например нашей сопрограммой delay.
Листинг 2.19

Счетный код и длительная задача

import asyncio
from util import async_timed, delay
@async_timed()
async def cpu_bound_work() -> int:
counter = 0
for i in range(100000000):
counter = counter + 1
return counter
@async_timed()
async def main():
task_one = asyncio.create_task(cpu_bound_work())
task_two = asyncio.create_task(cpu_bound_work())
delay_task = asyncio.create_task(delay(4))
await task_one
await task_two
await delay_task
asyncio.run(main())

Ловушки сопрограмм и задач

67

Кажется, что эта программа должна работать столько же, сколько
программа в листинге 2.18. Разве delay_task не будет выполняться
конкурентно со счетными задачами? Нет, не будет, потому что мы
сначала создали две счетные задачи и тем самым не даем циклу событий выполнить что-то еще. Следовательно, время работы приложения будет равно сумме времен работы задач cpu_bound_work плюс
4 с, которые займет задача delay.
Если требуется выполнить счетную работу и все-таки использовать
async / await, то это можно сделать. Но придется воспользоваться
многопроцессностью и попросить asyncio выполнять наши задачи
в пуле процессов. Как это сделать, мы узнаем в главе 6.

2.7.2

Выполнение блокирующих API
Может возникнуть соблазн использовать существующие библиотеки
ввода-вывода, обернув их сопрограммами. Однако при этом возникнут те же проблемы, что для счетных операций. Эти API будут блокировать главный поток. Поэтому, попытавшись выполнить блокирующий
вызов API в сопрограмме, мы заблокируем сам поток цикла событий,
а значит, воспрепятствуем выполнению всех остальных сопрограмм
и задач. Примерами блокирующих API является библиотека requests
или функция time.sleep. Вообще, любая функция, которая выполняет
ввод-вывод, не являясь сопрограммой, или занимает процессор длительными операциями, может считаться блокирующей.
В качестве примера попробуем трижды конкурентно получить код
состояния страницы www.example.com с по­мощью библиотеки requests. Поскольку задачи запускаются конкурентно, можно ожидать,
что на все про все уйдет столько же времени, сколько на однократное
получение кода состояния.
Листинг 2.20 Неправильное использование блокирующего API
как сопрограммы
import asyncio
import requests
from util import async_timed
@async_timed()
async def get_example_status() -> int:
return requests.get('http://www.example.com').status_code
@async_timed()
async def main():
task_1 = asyncio.create_task(get_example_status())
task_2 = asyncio.create_task(get_example_status())
task_3 = asyncio.create_task(get_example_status())
await task_1
await task_2
await task_3
asyncio.run(main())

68

Глава 2

Основы asyncio

Результат выполнения этой программы показан ниже. Обратите
внимание, что полное время работы сопрограммы main приблизительно равно сумме времен запущенных задач получения кода состояния, т. е. мы не получили никакого выигрыша от конкурентности.
выполняется с аргументами
выполняется
завершилась
выполняется
завершилась
выполняется
завершилась
завершилась за 0.1702 с

() {}
с аргументами () {}
за 0.0839 с
с аргументами () {}
за 0.0441 с
с аргументами () {}
за 0.0419 с

И снова причина в том, что библиотека requests блокирующая, т. е.
блокирует поток, в котором выполняется. Поскольку asyncio однопоточная, библиотека requests блокирует цикл событий и не дает ничему выполняться конкурентно.
Большинство API, с которыми мы обычно работаем, в настоящее
время являются блокирующими и без доработок работать с asyncio не
будут. Нужно использовать библиотеку, которая поддерживает сопрограммы и неблокирующие сокеты. А это значит, что если используемая вами библиотека не возвращает сопрограммы и вы не употребляете await в собственных сопрограммах, то, вероятно, совершаете
блокирующий вызов.
В примере выше мы могли бы использовать библиотеку aiohttp,
в которой используются неблокирующие сокеты и которая возвращает сопрограммы, тогда с конкурентностью все было бы нормально.
Мы познакомимся с этой библиотекой в главе 4.
Если вы все-таки хотите использовать библиотеку requests, то синтаксис async применить можно, но нужно явно попросить asyncio задействовать многопоточность с по­мощью исполнителя пула потоков.
Как это делается, узнаем в главе 7.
Мы рассказали, на что обращать внимание при работе с asyncio,
и написали несколько простых приложений. До сих пор мы не создавали и не конфигурировали цикл событий самостоятельно, а полагались на уже готовые методы. В следующем разделе научимся
создавать цикл событий, что позволит нам получить доступ к низкоуровневой функциональности asyncio и конфигурационным свойствам цикла событий.

2.8

Ручное управление циклом событий
До сих пор мы пользовались вспомогательной функцией asyncio.run,
которая за кулисами создавала цикл событий и запускала приложение.
В силу своей простоты это предпочтительный метод создания цикла
событий. Но бывает, что функциональность, предлагаемая asyncio.

Ручное управление циклом событий

69

run, нас не устраивает. Например, что, если мы хотим реализовать
специальную логику остановки задач, например дать оставшимся задачам завершиться, а не останавливать их, как делает asyncio.run?
Кроме того, могут понадобиться методы самого цикла событий. Как
правило, они низкоуровневые, так что злоупотреблять ими не стоит.
Однако если нужно сделать что-то нестандартное, например работать
с сокетами напрямую или запланировать задачу на конкретный момент в будущем, то доступ к циклу событий необходим. Мы, конечно,
не хотим и не должны увлекаться ручным управлением циклом событий, но временами от этого никуда не деться.

2.8.1 Создание цикла событий вручную
Мы можем создать цикл событий, воспользовавшись методом asyncio.new_event_loop. Он возвращает экземпляр цикла событий, который дает доступ ко всем низкоуровневым методам, в частности методу run_until_complete, который принимает сопрограмму и исполняет
ее до завершения. Закончив работу с циклом событий, мы должны закрыть его, чтобы освободить занятые ресурсы. Обычно это делается
в блоке finally, чтобы цикл был закрыт даже в случае исключения.
Ниже показано, как создать цикл событий и запустить в нем приложение asyncio.
Листинг 2.21

Создание цикла событий вручную

import asyncio
async def main():
await asyncio.sleep(1)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(main())
finally:
loop.close()

Это похоже на то, что происходит при вызове asyncio.run, с той
разницей, что оставшиеся задачи не отменяются. Если нам нужна
специальная логика очистки, то ее следует реализовать в предложении finally.

2.8.2 Получение доступа к циклу событий
Иногда бывает необходим доступ к текущему циклу событий. Библио­
тека asyncio предоставляет для этой цели функцию asyncio.get_running_loop. В качестве примера рассмотрим метод call_soon, который
планирует выполнение функции на следующей итерации цикла событий.

Глава 2

70

Листинг 2.22

Основы asyncio

Получение доступа к циклу событий

import asyncio
def call_later():
print("Меня вызовут в ближайшем будущем!")
async def main():
loop = asyncio.get_running_loop()
loop.call_soon(call_later)
await delay(1)
asyncio.run(main())

Здесь сопрограмма main получает цикл событий от функции asyncio.get_running_loop, и вызывает его метод call_later, который принимает функцию и выполняет ее на следующей итерации цикла. Существует еще функция asyncio.get_event_loop, также позволяющая
получить доступ к циклу событий. Эта функция может создать новый
цикл событий, если его еще не существует в момент вызова, что ведет
к странному поведению. Рекомендуется использовать get_running_
loop, поскольку она возбуждает исключение, если цикл событий не
запущен, что позволяет избежать сюрпризов.
Хотя явно использовать цикл событий в приложениях слишком
часто не стоит, бывает, что нужно настроить его параметры или воспользоваться низкоуровневыми функциями. В следующем разделе мы рассмотрим пример перевода цикла событий в отладочный
режим.

2.9

Отладочный режим
В предыдущих разделах мы говорили, что любую сопрограмму всегда
следует ожидать в какой-то точке приложения. Мы также видели недостатки выполнения счетного и иного блокирующего кода в сопрограммах и задачах. Но бывает трудно сказать, то ли сопрограмма потребляет слишком много процессорного времени, то ли мы по ошибке
забыли где-то поставить await. По счастью, asyncio предоставляет отладочный режим для диагностики таких ситуаций.
При работе в отладочном режиме печатаются полезные сообщения,
когда сопрограмма или задача работают больше 100 мс. Кроме того,
если для некоторой сопрограммы отсутствует await, то возбуждается исключение, показывающее, в каком месте следовало бы добавить
await. Есть несколько способов войти в отладочный режим.

2.9.1 Использование asyncio.run
Функция asyncio.run, которой мы пользовались для выполнения сопрограмм, имеет именованный параметр debug. По умолчанию он

Отладочный режим

71

равен False, но если присвоить ему значение True, то активируется
отладочный режим:
asyncio.run(coroutine(), debug=True)

2.9.2 Использование аргументов командной строки
Включить отладочный режим можно, передав аргумент -X dev в командной строке, запускающей Python-приложение:
python3 -X dev program.py

2.9.3 Использование переменных окружения
Включить отладочный режим можно также, присвоив значение 1 переменной окружения PYTHONASYNCIODEBUG:
PYTHONASYINCIODEBUG=1 python3 program.py

ПРИМЕЧАНИЕ В версиях Python младше 3.9 в отладочном режиме имеется ошибка. При использовании asyncio.run работает только булев параметр debug. Задание параметра в командной строке или переменной окружения работает, только если
цикл событий управляется вручную.
В отладочном режиме мы будем видеть информационные сообщения, если сопрограмма работает слишком долго. Чтобы проверить
это, попробуем запустить следующий счетный код в задаче и посмотрим, будет ли напечатано предупреждение.
Листинг 2.23

Выполнение счетного кода вотладочном режиме

import asyncio
from util import async_timed
@async_timed()
async def cpu_bound_work() -> int:
counter = 0
for i in range(100000000):
counter = counter + 1
return counter
async def main() -> None:
task_one = asyncio.create_task(cpu_bound_work())
await task_one
asyncio.run(main(), debug=True)

При запуске этой программы мы увидим сообщение о том, что задача task_one работает слишком долго и, следовательно, блокирует
цикл событий, не давая ему выполнять другие задачи:

Глава 2

72

Основы asyncio

Executing took 4.829
seconds

Это может быть полезно для отладки ошибок, связанных со случайным выполнением блокирующего вызова. По умолчанию параметры заданы так, что предупреждение выдается, если сопрограмма
работает дольше 100 мс, но, возможно, для вас это слишком мало или
слишком много. Чтобы изменить значение, нужно получить объект
цикла событий и задать в нем параметр slow_callback_duration, как
показано в листинге 2.24. Это число с плавающей точкой равно количеству секунд, при превышении которого обратный вызов считается
медленным.
Листинг 2.24 Изменение продолжительности медленного обратного
вызова
import asyncio
async def main():
loop = asyncio.get_event_loop()
loop.slow_callback_duration = .250
asyncio.run(main(), debug=True)

Здесь продолжительность медленного обратного вызова установлена равной 250 мс, т. е. сообщение печатается, если сопрограмма работает дольше 250 мс.

Резюме
Мы научились создавать сопрограммы с по­мощью ключевого слова
async. Сопрограмма может приостанавливать себя, встретив блокирующую операцию. Это дает возможность поработать другим
сопрограммам. После завершения операции, на которой сопрограмма приостановилась, она пробуждается и продолжает работу
с прерванного места.
„„ Мы узнали, что вызову сопрограммы должно предшествовать ключевое слово await, означающее, что нужно дождаться возврата значения. При этом сопрограмма, внутри которой встретилось слово
await, приостанавливается в ожидании результата. В это время могут работать другие сопрограммы.
„„ Мы узнали, как использовать функцию asyncio.run для выполнения одной сопрограммы, являющейся точкой входа в приложение.
„„ Мы научились использовать задачи для конкурентного выполнения нескольких длительных операций. Задачи – это обертки вокруг
сопрограмм, которые исполняются в цикле событий. Созданная задача планируется для выполнения как можно раньше.
„„

Резюме

73

Мы узнали, как снимать задачу, когда нужно остановить ее, и как
задать тайм-аут, чтобы задача не работала бесконечно долго. После
снятия задачи возбуждается исключение CancelledError, когда мы
ожидаем результат. Если имеются ограничения на время работы
задачи, то можно задать тайм-аут в методе asycio.wait_for.
„„ Мы научились избегать типичных ошибок, которые допускают начинающие изучать asyncio. Первая – выполнение счетного кода
в сопрограммах. Счетный код блокирует цикл событий и не дает
выполняться другим сопрограммам, потому что модель конкурентности однопоточная. Вторая – блокирующий ввод-вывод, потому
что обычные библиотеки нельзя использовать совместно с asyncio,
а нужно работать с заточенными под asyncio и возвращающими сопрограммы. Если внутри вашей сопрограммы не встречается await,
это должно вызвать подозрения. Тем не менее существуют способы исполнять счетный код и блокирующий ввод-вывод совместно
с asyncio, мы рассмотрим их в главах 6 и 7.
„„ Мы узнали об отладочном режиме. Он помогает диагностировать
типичные проблемы в коде на основе asyncio, например выполнение счетного кода в сопрограмме.
„„

3

Первое приложение asyncio

Краткое содержание главы
Применение сокетов для передачи данных по сети.
Применение telnet для взаимодействия с приложением на
основе сокетов.
„„ Использование селекторов для построения простого цикла
событий для неблокирующих сокетов.
„„ Создание неблокирующего эхо-сервера, допускающего
несколько подключений.
„„ Обработка исключений в задачах.
„„ Включение специальной логики остановки в приложение
asyncio.
„„
„„

В главах 1 и 2 мы познакомились с сопрограммами, задачами и цик­
лом событий, а также узнали, как выполнять длительные операции
конкурентно, и изучили некоторые API asyncio, предназначенные для
этой цели. Но до сих пор мы лишь эмулировали длительные операции
с по­мощью функции sleep.
Не желая ограничиваться одними демонстрационными приложениями, воспользуемся некоторыми реальными блокирующими операциями, чтобы показать, как можно создать сервер, способный обрабатывать несколько пользователей одновременно. Мы сделаем это,
используя только один поток, в результате чего приложение окажет-

Работа с блокирующими сокетами

75

ся проще и менее требовательным к ресурсам, чем альтернативные
решения с несколькими потоками и процессами. Мы применим все,
что узнали о сопрограммах, задачах и API asyncio, для написания командного эхо-сервера, в котором будут использоваться сокеты. К концу главы вы сможете сами создавать сетевые приложения на основе
asyncio, умеющие одновременно обслуживать несколько пользователей в одном потоке.
Сначала рассмотрим, как отправлять и принимать данные с по­
мощью блокирующих сокетов. Мы попробуем с их помощью построить эхо-сервер, обслуживающий несколько клиентов. И убедимся, что
хорошо сделать это в одном потоке невозможно. Затем поговорим
о том, как разрешить возникшие проблемы, сделав сокеты неблокирующими и воспользовавшись системой уведомлений, входящей
в состав операционной системы. Это поможет понять, как работает
механизм, лежащий в основе цикла событий. После этого мы применим сопрограммы с неблокирующими сокетами asyncio, чтобы правильно реализовать обслуживание нескольких пользователей, которые одновременно отправляют и принимают сообщения. Наконец,
мы добавим специальную логику остановки, оставляющую некоторое
время для завершения уже начатой работы.

3.1

Работа с блокирующими сокетами
В главе 1 мы познакомились с сокетами. Напомним, что сокет – это
способ читать и записывать данные по сети. Можно считать, что сокет – своего рода почтовый ящик: мы кладем в него письмо, а оно затем доставляется по адресу получателя. Получатель сможет прочесть
сообщение и, возможно, отправит ответ.
Прежде всего создадим главный сокет, называемый серверным.
Он будет принимать сообщения от клиентов, желающих установить
с нами соединение. После того как серверный сокет подтвердит запрос на подключение, мы создаем сокет, предназначенный для взаимодействия с клиентом. Таким образом, сервер становится похож на
почтовое отделение не с одним, а с несколькими почтовыми ящиками. Что касается клиента, можно по-прежнему считать, что он владеет только одним почтовым ящиком, поскольку открывает для взаимодействия с нами один сокет. Когда клиент подключается к серверу,
мы предоставляем ему почтовый ящик, а затем используем его для
получения и отправки сообщений (рис. 3.1).
Такой сервер можно создать с по­мощью встроенного в Python модуля socket, предоставляющего средства для чтения, записи и управления сокетом. Для начала напишем простой сервер, который прослушивает порт, куда поступают запросы на подключение от клиентов,
и печатает сообщения об успешном подключении. С этим сокетом будут ассоциированы имя хоста и порт, он станет главным «серверным
сокетом», с которым могут взаимодействовать клиенты.

Глава 3

76

Первое приложение asyncio
Сервер

Клиент
Запрос
на подключение
Сокет

Серверный
сокет
Создать
клиентский сокет

Запись
Чтение
Клиентский
сокет

Рис. 3.1 Клиент подключается к серверному сокету. Затем сервер создает новый
сокет для взаимодействия с клиентом

Для создания сокета нужно выполнить несколько шагов. Сначала
с по­мощью функции socket создается сокет:
import socket
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

Функция socket принимает два параметра. Первый, socket.AF_
INET, – тип адреса, в данном случае адрес будет содержать имя хоста
и номер порта. Второй, socket.SOCK_STREAM, означает, что для взаимодействия будет использоваться протокол TCP.

Что такое протокол TCP?
Протокол TCP (transmission control protocol – протокол управления передачей) предназначен для передачи данных между приложениями по
сети. При его проектировании на первое место ставилась надежность. Он
проверяет ошибки, гарантирует доставку данных по порядку и в случае
необходимости производит повторную передачу. Но надежность сопровождается высокими накладными расходами. Большая часть веба функционирует на базе TCP. Противоположностью TCP является протокол
передачи дейтаграмм UDP, менее надежный, но и характеризующийся
гораздо меньшими накладными расходами, чем TCP, а потому более производительный. В этой книге нас будут интересовать только TCP-сокеты.

Мы также вызываем функцию setsockopt, чтобы установить флаг
SO_REUSEADDR в 1. Это позволит повторно использовать номер порта,
после того как мы остановим и заново запустим приложение, избегнув тем самым ошибки «Адрес уже используется». Если этого не
сделать, то операционной системе потребуется некоторое время, чтобы освободить порт, после чего приложение сможет запуститься без
ошибок.

77

Работа с блокирующими сокетами

Функция socket.socket создает сокет, но начать взаимодействие по
нему мы еще не можем, потому что сокет не привязан к адресу, по
которому могут обращаться клиенты (у почтового отделения должен
быть адрес!). В данном случае мы привяжем сокет к адресу своего собственного компьютера 127.0.0.1 и выберем произвольный порт 8000:
address = (127.0.0.1, 8000)
server_socket.bind(server_address)

Теперь у сокета есть адрес – 127.0.0.1:8000. Клиенты смогут использовать этот адрес для отправки данных нашему серверу, а если мы
отправим данные клиенту, то клиент увидит адрес, с которого они
пришли.
Далее мы должны активно прослушивать запросы от клиентов,
желающих подключиться к нашему серверу. Для этого вызывается
метод сокета listen. Затем мы ждем запроса на подключение с по­
мощью метода accept. Этот метод блокирует программу до получения
запроса, после чего возвращает объект подключения и адрес подключившегося клиента. Объект подключения – это еще один сокет, который можно использовать для чтения данных от клиента и записи
адресованных ему данных.
server_socket.listen()
connection, client_address = server_socket.accept()

Теперь у нас есть все необходимое для создания серверного приложения на основе сокетов, которое будет ждать запросов на подключение и печатать сообщение, когда запрос придет.
Листинг 3.1 Запуск сервера и прослушивание порта
для подключения
import socket

Создать TCP-сервер

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_address = ('127.0.0.1', 8000)
server_socket.bind(server_address)
server_socket.listen()

Задать адрес сокета, 127.0.0.1:8000
Прослушивать запросы на подключение,
или «открыть почтовое отделение»

connection, client_address = server_socket.accept()
print(f'Получен запрос на подключение от {client_address}!')
Дождаться подключения
и выделить клиенту почтовый ящик

Когда клиент подключится, мы получим клиентский сокет и адрес
клиента, о чем не замедлим сообщить.
Итак, приложение готово, но как к нему подключиться и протестировать? Для этой цели существует немало инструментов, но в этой
главе мы будем использовать командное приложение telnet.

78

3.2

Глава 3

Первое приложение asyncio

Подключение к серверу с по­мощью telnet
В нашем простом примере нет никакого способа подключиться. Для
чтения и записи данных на сервер имеется много командных приложений, в частности telnet – популярное и существующее уже давно.
Программа telnet была разработана в 1969 году, название является
сокращением от teletype network (телетайпная сеть). Telnet устанавливает TCP-подключение к серверу, расположенному на указанном
хосте. Затем создается терминал, который можно использовать для
отправки и приема данных, отображаемых на экране терминала.
В Mac OS установить telnet можно с по­мощью программы Homebrew, набрав команду brew install telnet (для установки Homebrew
зайдите на сайт https://brew.sh/). В дистрибутивах Linux нужно будет
использовать системный менеджер пакетов (apt-get install telnet
или что-то в этом роде). В Windows лучше всего использовать программу PuTTy, которую можно скачать с сайта https://putty.org.
ПРИМЕЧАНИЕ В PuTTY нужно будет включить режим локального редактирования строки, чтобы примеры из этой книги
работали. Для этого перейдите в раздел Terminal в левой части
окна настроек PuTTy и установите переключатель Local line editing в режим Force on.
Чтобы подключиться к серверу в листинге 3.1, мы можем выполнить команду telnet, указав, что хотим подключиться к порту 8000
хоста localhost:
telnet localhost 8000

В ответ увидим сообщение об успешном подключении. Затем telnet
отображает курсор, что позволит нам печатать данные и нажатием
клавиши Enter отправить их на сервер.
telnet localhost 8000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.

На консоли серверного приложения мы должны увидеть сообщение о подключении со стороны telnet-клиента:
Получен запрос на подключение от ('127.0.0.1', 56526)!

А когда серверный код завершится, мы увидим сообщение Connection closed by foreign host, означающее, что сервер разорвал соединение с клиентом. Теперь у нас есть способ подключиться к серверу,
а также читать данные от него и передавать ему данные, но сам сервер пока не умеет ни читать, ни записывать данные. Исправим это,
воспользовавшись методами sendall и recv клиентского сокета.

Подключение к серверу с по­мощью telnet

79

3.2.1 Чтение данных из сокета и запись данных в сокет
Теперь, когда сервер, способный принимать запросы на подключение, создан, посмотрим, как читать посылаемые ему данные. В классе
socket имеется метод recv, который позволяет получать данные из
сокета. Метод принимает целое число, показывающее, сколько байтов мы хотим прочитать. Это важно, потому что мы не можем прочитать из сокета сразу все данные, а должны сохранять их в буфере,
пока не дойдем до конца.
В данном случае концом считается пара символов: возврат каретки, перевод строки, или '\r\n'. Именно эта пара добавляется в конец
строки, когда пользователь нажимает клавишу Enter в telnet. Чтобы
продемонстрировать, как работает буферизация небольших сообщений, зададим размер буфера заведомо малым. В реальных приложениях нужен буфер побольше, например на 1024 байта. Большой буфер
позволит воспользоваться механизмом буферизации на уровне операционной системы, это эффективнее, чем буферизация в приложении.
Листинг 3.2

Чтение данных из сокета

import socket
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_address = ('127.0.0.1', 8000)
server_socket.bind(server_address)
server_socket.listen()
try:
connection, client_address = server_socket.accept()
print(f'Получен запрос на подключение от {client_address}!')
buffer = b''
while buffer[-2:] != b'\r\n':
data = connection.recv(2)
if not data:
break
else:
print(f'Получены данные: {data}!')
buffer = buffer + data
print(f"Все данные: {buffer}")
finally:
server_socket.close()

Здесь мы ждем запроса на подключение в функции server_socket.
accept, как и раньше. Получив запрос, мы пытаемся принять два байта и сохранить их в буфере. Затем входим в цикл и на каждой итерации проверяем, заканчивается ли буфер символами возврата каретки
и перевода строки. Если нет, то получаем еще два байта, печатаем их

80

Глава 3

Первое приложение asyncio

и добавляем в буфер. После получения '\r\n' мы выходим из цикла
и печатаем все сообщение, полученное от клиента, а затем закрываем
серверный сокет в блоке finally. Таким образом, соединение будет
гарантированно закрыто, даже если при чтении данных возникнет
исключение. Если подключиться к этому приложению из telnet и отправить сообщение 'testing123', то мы увидим такую картину:
Получен запрос на подключение от ('127.0.0.1', 49721)!
Получены данные: b'te'!
Получены данные: b'st'!
Получены данные: b'in'!
Получены данные: b'g1'!
Получены данные: b'23'!
Получены данные: b'\r\n'!
Все данные: b'testing123\r\n'

Итак, мы умеем читать данные из сокета, но как отправить данные
клиенту? У сокетов имеется метод sendall, который принимает сообщение и отправляет его клиенту. Код в листинге 3.2 можно дополнить,
так чтобы он отправлял клиенту копию полученного сообщения. Для
этого нужно вызывать метод connection.sendall, передав ему заполненный буфер.
while buffer[-2:] != b'\r\n':
data = connection.recv(2)
if not data:
break
else:
print(f'Получены данные: {data}!')
buffer = buffer + data
print(f"Все данные: {buffer}")
connection.sendall(buffer)

Теперь после подключения к серверу с по­мощью telnet и отправки
сообщения мы должны увидеть на терминале это же сообщение. Мы
написали очень простой эхо-сервер!
Сейчас это приложение в каждый момент времени обслуживает
только одного клиента, но подключиться к одному серверному сокету
может несколько клиентов. Изменим код, разрешив одновременное
подключение нескольких клиентов. Попутно продемонстрируем, что
нормально поддержать несколько клиентов с по­мощью блокирующих сокетов не получается.

3.2.2 Разрешение нескольких подключений и опасности
блокирования
Сокет, находящийся в режиме прослушивания, допускает одновременное подключение нескольких клиентов. Это значит, что при повторном вызове socket.accept мы каждый раз будем получать новый

Подключение к серверу с по­мощью telnet

81

клиентский сокет для чтения и записи данных. Зная это, мы можем без
труда изменить предыдущий пример, так чтобы сервер обслуживал
несколько клиентов. Будем в бесконечном цикле вызывать socket.accept для прослушивания новых подключений. Приняв подключение,
добавим его в конец списка имеющихся подключений. Затем в цикле
обойдем все подключения, примем из каждого данные и запишем их
обратно в сокет, чтобы передать клиенту.
Листинг 3.3

Подключение нескольких клиентов

import socket
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_address = ('127.0.0.1', 8000)
server_socket.bind(server_address)
server_socket.listen()
connections = []
try:
while True:
connection, client_address = server_socket.accept()
print(f'Получен запрос на подключение от {client_address}!')
connections.append(connection)
for connection in connections:
buffer = b''
while buffer[-2:] != b'\r\n':
data = connection.recv(2)
if not data:
break
else:
print(f'Получены данные: {data}!')
buffer = buffer + data
print(f"Все данные: {buffer}")
connection.send(buffer)
finally:
server_socket.close()

Можем проверить эту версию. Подключимся с по­мощью telnet
и введем сообщение. Затем можно подключиться из второго telnetклиента и отправить другое сообщение. И тут же наткнемся на проблему. Первый клиент работает и получает копии своих сообщений,
как и положено, а вот второй не получает ничего. Связано это с тем,
что по умолчанию сокеты блокирующие. Методы accept и recv блокируют выполнение программы, пока не получат данные. А значит,
после того как первый клиент подключился, мы будем ждать, когда

Глава 3

82

Первое приложение asyncio

он отправит свое первое сообщение. А остальные клиенты в это время зависнут в ожидании следующей итерации цикла, которая не произойдет, пока не придут данные от первого клиента (рис. 3.2).
Эхо-сервер
Клиент 1

Клиент 2
Подключение
Сервер блокирован
в ожидании отправки
данных клиентом 1

while True:
connection = socket.accept()
data = connection.recv(2)

Попытка
подключиться
со стороны
клиента 2
блокируется,
пока клиент 1
не отправит данные

Рис. 3.2 При использовании блокирующих сокетов клиент 1 подключается, но клиент 2
заблокирован, пока первый клиент не отправит данные

Очевидно, что пользователям это не понравится; мы написали
программу, которая не масштабируется на случай, когда клиентов
больше одного. Эту проблему можно решить, переведя сокеты в неблокирующий режим. Если сокет помечен как неблокирующий, его
методы не будут блокировать выполнение программы в ожидании
поступления данных.

3.3

Работа с неблокирующими сокетами
Предыдущая версия эхо-сервера допускала подключение нескольких
клиентов, но при наличии более одного подключения возникали проблемы: один клиент может заставить всех остальных ждать, пока он
отправит данные. Эту проблему можно решить, переведя сокеты в неблокирующий режим. Тогда вызов любого метода, который мог бы
блокировать выполнение, например recv, будет возвращать управление немедленно. Если в сокете есть доступные для обработки данные,
то они будут возвращены, как в случае блокирующего сокета. А если
нет, то сокет сразу даст нам знать, что данные не готовы, и мы сможем
перейти к выполнению другого кода.
Листинг 3.4 Создание неблокирующего сокета
import socket
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(('127.0.0.1', 8000))
server_socket.listen()
server_socket.setblocking(False)

По сути дела, создание неблокирующего сокета отличается от создания блокирующего только вызовом метода setblocking с параме-

Работа с неблокирующими сокетами

83

тром False. По умолчанию это значение равно True, т. е. сокет блокирующий. Теперь посмотрим, что произойдет в исходном приложении,
если это сделать. Пропадет ли ошибка?
Листинг 3.5

Первая попытка создать неблокирующий сервер

import socket
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_address = ('127.0.0.1', 8000)
server_socket.bind(server_address)
server_socket.listen()
server_socket.setblocking(False)
connections = []

Пометить серверный сокет
как неблокирующий

try:
while True:
connection, client_address = server_socket.accept()
connection.setblocking(False)
print(f'Получен запрос на подключение от {client_address}!')
connections.append(connection)
Пометить клиентский сокет
как неблокирующий
for connection in connections:
buffer = b''
while buffer[-2:] != b'\r\n':
data = connection.recv(2)
if not data:
break
else:
print(f'Получены данные: {data}!')
buffer = buffer + data
print(f"Все данные: {buffer}")
connection.send(buffer)
finally:
server_socket.close()

При выполнении этой программы одно отличие обнаруживается
немедленно – программа падает сразу после запуска! Возникает исключение BlockingIOError, потому что к серверному сокету еще никто не подключился, поэтому нет данных для обработки:
Traceback (most recent call last):
File "echo_server.py", line 14, in
connection, client_address = server_socket.accept()
File " python3.8/socket.py", line 292, in accept
fd, addr = self._accept()
BlockingIOError: [Errno 35] Resource temporarily unavailable

Глава 3

84

Первое приложение asyncio

Так сокет несколько неожиданно говорит нам: «У меня нет данных,
попробуйте вызвать меня еще раз позже». Не существует простого
способа узнать, есть ли в сокете данные, поэтому возможное решение – перехватить исключение, проигнорировать его и продолжать
цикл, пока в сокете не появятся данные. При таком подходе мы будем
постоянно проверять наличие новых подключений с максимальной
скоростью. Это должно решить проблему, с которой столкнулся наш
эхо-сервер с блокирующим сокетом.
Листинг 3.6 Перехват и игнорирование ошибок блокирующего
ввода-вывода
import socket
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_address = ('127.0.0.1', 8000)
server_socket.bind(server_address)
server_socket.listen()
server_socket.setblocking(False)
connections = []
try:
while True:
try:
connection, client_address = server_socket.accept()
connection.setblocking(False)
print(f'Получен запрос на подключение от {client_address}!')
connections.append(connection)
except BlockingIOError:
pass
for connection in connections:
try:
buffer = b''
while buffer[-2:] != b'\r\n':
data = connection.recv(2)
if not data:
break
else:
print(f'Получены данные: {data}!')
buffer = buffer + data
print(f"Все данные: {buffer}")
connection.send(buffer)
except BlockingIOError:
pass
finally:
server_socket.close()

Работа с неблокирующими сокетами

85

На каждой итерации бесконечного цикла ни один из вызовов accept и recv не блокирует выполнение, и мы либо сразу же получаем
исключение, которое игнорируем, либо данные, которые обрабатываем. Итерации выполняются быстро, и поведение программы не зависит от того, посылает кто-нибудь данные или нет. Так что проблема
блокирующего сервера решена, и несколько клиентов могут подключаться и отправлять данные одновременно.
Описанный подход работает, но обходится дорого. Первый недостаток – качество кода. Перехват исключений всякий раз, как не оказывается данных, приводит к многословному и чреватому ошибками коду. Второй – потребление ресурсов. Запустив эту программу на
ноутбуке, вы уже через несколько секунд услышите, что вентилятор
начал работать громче. Это приложение постоянно потребляет почти 100 % процессорного времени (рис. 3.3), поскольку мы выполняем
итерации цикла и получаем исключения настолько быстро, насколько позволяет операционная система. В результате в рабочей нагрузке
преобладает потребление процессора.

Рис. 3.3 Из-за перехвата исключений в бесконечном цикле потребление
процессора быстро доходит до 100 % и на этом уровне остается

Выше мы уже упоминали о системе уведомления о событиях в операционной системе, которая может уведомить нас о том, что в сокете
появились данные. Эта система опирается на аппаратные прерывания и не подразумевает опроса в цикле while, как в показанной выше
программе. В Python имеется библиотека для использования такой
системы уведомления. В следующем разделе мы воспользуемся ей
для решения проблемы потребления ресурсов. Попутно построим
примитивный цикл событий, работающий только для сокетов.

86

3.4

Глава 3

Первое приложение asyncio

Использование модуля selectors
для построения цикла событий сокетов
У операционной системы есть эффективные API, позволяющие наблюдать за появлением данных в сокетах и за другими событиями.
Конкретный API зависит от системы (kqueue, epoll, IOCP – самые известные), но все системы уведомления работают по одному принципу. Мы передаем системе список сокетов, за событиями которых
хотим наблюдать, а она сообщает, когда в одном из них появляются
данные.
Поскольку все это реализовано на аппаратном уровне, процессор
в мониторинге почти не участвует, так что потребление ресурсов невелико. Эти системы уведомления и лежат в основе механизма конкурентности в asyncio. Поняв, как они устроены, мы сможем лучше
понять, как работает asyncio.
Система уведомления о событиях зависит от операционной системы. Но модуль Python selectors абстрагирует эту зависимость, так
что мы получаем правильное событие, в какой бы системе наш код
ни работал. То есть код оказывается переносимым.
Эта библиотека предоставляет абстрактный базовый класс BaseSelector, имеющий реализации для каждой системы уведомления.
Кроме того, имеется класс DefaultSelector, который автоматически
выбирает реализацию, подходящую для конкретной системы.
У класса BaseSelector есть несколько важных концепций. Первая из
них – регистрация. Если мы хотим получать уведомления для какогото сокета, то регистрируем его, сообщая, какие именно события нас
интересуют, например чтение и запись. Наоборот, если сокет нас
больше не интересует, то его регистрацию можно отменить.
Вторая концепция – селекция. Функция select блокирует выполнение, пока не произойдет какое-то событие, после чего возвращает список сокетов, готовых для обработки, а также событие, которое
произошло с каждым сокетом. Поддерживается также тайм-аут – если
в течение указанного времени ничего не произошло, то возвращается
пустой список сокетов.
Имея такие строительные блоки, мы можем написать неблокирующий эхо-сервер, не нагружающий процессор. После создания серверного сокета мы регистрируем его в селекторе по умолчанию, который
будет прослушивать запросы на подключение от клиентов. Как только
кто-то подключится к серверному сокету, мы зарегистрируем клиентский сокет, чтобы селектор наблюдал за отправленными в него данными. Получив данные, мы отправим их назад клиенту. Кроме того,
добавим тайм-аут, чтобы продемонстрировать возможность выполнять другой код, пока мы ждем наступления событий.

Использование модуля selectors для построения цикла событий сокетов

87

Листинг 3.7 Использование селектора для построения
неблокирующего сервера
import selectors
import socket
from selectors import SelectorKey
from typing import List, Tuple
selector = selectors.DefaultSelector()
server_socket = socket.socket()
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_address = ('127.0.0.1', 8000)
server_socket.setblocking(False)
server_socket.bind(server_address)
server_socket.listen()
selector.register(server_socket, selectors.EVENT_READ)

Создать селектор
с тайм-аутом 1 с
while True:
events: List[Tuple[SelectorKey, int]] = selector.select(timeout=1)
if len(events) == 0:
print('Событий нет, подожду еще!')
for event, _ in events:
event_socket = event.fileobj

Получить сокет,
if
для которого
произошло событие,
он хранится в поле
fileobj
Зарегистрировать
клиент,
подключившийся
к сокету

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

event_socket == server_socket:
connection, address = server_socket.accept()
connection.setblocking(False)
print(f"Получен запрос на подключение от {address}")
selector.register(connection, selectors.EVENT_READ)
else:
data = event_socket.recv(1024)
Если событие произошло
print(f"Получены данные: {data}")
не с серверным сокетом,
event_socket.send(data)
получить данные от клиента
и отправить их обратно

Эта программа печатает сообщение «Событий нет, подожду еще!»
примерно каждую секунду, пока не будет получено событие подключения. После этого мы регистрируем сокет для прослушивания событий чтения. Теперь, как только клиент отправит нам данные, селектор
вернет событие готовности данных и мы сможем прочитать их с по­
мощью функции socket.recv. Мы написали полнофункциональный
эхо-сервер, поддерживающий нескольких клиентов. У него нет проблем с блокированием, поскольку чтение или запись производятся
только тогда, когда имеются данные. Он почти не потребляет процессорного времени, так как мы пользуемся эффективной системой
уведомления о событиях, которая реализована внутри операционной
системы (рис. 3.4).

Глава 3

88

Первое приложение asyncio

Рис. 3.4 График потребления процессорного времени эхо-сервером
с селекторами. При таком методе потребление колеблется между 0 и 1 %

То, что мы сделали, во многом напоминает действия, выполняемые
циклом событий под капотом. В данном случае события – получение
данных через сокет. Каждая итерация нашего цикла событий и цикла событий asyncio запускается либо событием сокета, либо срабатыванием таймера. В цикле событий asyncio в любом из этих случаев
активируется ожидающая сопрограмма и выполняется до конца или
до следующего предложения await. Если await встречается в сопрограмме, где используется неблокирующий сокет, то она регистрирует
этот сокет в системном селекторе и запоминает, что эта сопрограмма
приостановлена в ожидании результата. Эту логику можно записать
на псевдокоде, демонстрирующем идею:
paused = []
ready = []
while True:
paused, new_sockets = run_ready_tasks(ready)
selector.register(new_sockets)
timeout = calculate_timeout()
events = selector.select(timeout)
ready = process_events(events)

Мы выполняем готовые к работе сопрограммы до тех пор, пока
они не будут приостановлены в предложении await, и сохраняем их
в массиве paused. Мы также отслеживаем новые сокеты, за которыми
нужно наблюдать, и регистрируем их в селекторе. Затем вычисляем
тайм-аут, указываемый при вызове select. Вычисление тайм-аута не
вполне тривиально, обычно при этом учитывается, что запланировано для выполнения в конкретный момент времени или в течение

Эхо-сервер средствами цикла событий asyncio

89

конкретного интервала. Примером может служить asyncio.sleep. Затем мы вызываем select и ждем возникновения события сокета или
истечения тайм-аута. Как только произойдет то или другое, мы обрабатываем события и преобразуем их в список сопрограмм, готовых
к выполнению.
Хотя построенный нами цикл событий пригоден только для событий сокетов, он демонстрирует идею использования селектора для
регистрации интересующих нас сокетов, когда программа пробуждается только при возникновении чего-то, что мы хотим обработать.
Более глубоко мы рассмотрим эту тему, когда будем строить собственный цикл событий в конце книги.
Теперь мы понимаем большую часть механизма, приводящего
в движение asyncio. Но если бы мы ограничились селекторами для
написания приложений, то пришлось бы реализовывать собственный
цикл событий для получения той же функциональности, которую дает
asyncio. Чтобы разобраться в том, как сделать это с по­мощью asyncio,
возьмем все сделанное и переведем это на язык async / await и уже
реализованного для нас цикла событий.

3.5

Эхо-сервер средствами цикла событий
asyncio
Уровень select для большинства приложений является слишком низким. Возможно, мы хотим выполнять свой код в фоновом режиме,
пока программа ждет поступления данных в сокет, или запускать фоновые задачи по расписанию. Попытавшись сделать это с по­мощью
одних лишь селекторов, мы в итоге написали бы собственный цикл
событий, в то время как asyncio предлагает уже готовый. Кроме того,
сопрограммы и задачи предоставляют абстракции поверх селекторов, благодаря чему код становится проще писать и сопровождать,
поскольку думать о селекторах вообще не нужно.
Теперь, когда мы лучше понимаем, как устроен цикл событий
в asyncio, еще раз переделаем наш эхо-сервер, используя сопрограммы и задачи. Мы по-прежнему будем работать с низкоуровневыми
сокетами, но на этот раз для управления ими воспользуемся API asyncio, которые возвращают сопрограммы. И немного расширим функциональность, чтобы продемонстрировать несколько ключевых концепций asyncio.

3.5.1 Сопрограммы цикла событий для сокетов
Поскольку сокеты – сравнительно низкоуровневое понятие, методы
для работы с ними принадлежат самому циклу событий. Нам предстоит работать с тремя основными сопрограммами: sock_accept,
sock_recv и sock_sendall. Это аналоги рассмотренных выше мето-

90

Глава 3

Первое приложение asyncio

дов класса socket, только принимают они сокет в качестве аргумента
и возвращают сопрограммы, которые могут ждать поступления данных с по­мощью await.
Начнем с sock_accept. Эта сопрограмма аналогична методу socket.
accept, который мы видели в самой первой реализации. Она возвращает кортеж (структуру данных, в которой хранится упорядоченная
последовательность значений), состоящий из сокета и адреса клиента. Мы передаем серверный сокет и ждем, когда сопрограмма что-то
вернет. После этого мы будем иметь подключенный сокет и адрес. Сокет должен быть неблокирующим и уже привязанным к порту:
connection, address = await loop.sock_accept(socket)

Методы sock_recv и sock_sendall названы по аналогии с sock_accept. Они принимают сокет и ждут результата. sock_recv ждет поступления байтов в сокет. sock_sendall принимает сокет и данные,
которые нужно отправить, после чего ждет, пока все данные будут отправлены. В случае успеха sock_sendall возвращает None.
data = await loop.sock_recv(socket)
success = await loop.sock_sendall(socket, data)

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

3.5.2 Проектирование асинхронного эхо-сервера
В главе 2 мы познакомились с сопрограммами и задачами. Так когда
же в нашем эхо-сервере следует использовать сопрограмму, а когда
нужно обернуть ее задачей? Чтобы принять решение, подумаем, как
должно вести себя приложение.
Прежде всего решим, как следует прослушивать порт на предмет
подключений. Прослушивая порт, мы можем обработать только одно
подключение за раз, потому что socket.accept возвращает только
одно клиентское подключение. Если входящих подключений несколько, то за кулисами они хранятся в очереди, но сейчас нам этот
механизм неинтересен.
Поскольку нам не нужно обрабатывать несколько подключений
конкурентно, одной сопрограммы, исполняющейся в бесконечном
цикле, достаточно. Тогда другой код сможет работать конкурентно,
пока эта сопрограмма приостановлена в ожидании подключения. Назовем эту сопрограмму listen_for_connections:
async def listen_for_connections(server_socket: socket,
loop: AbstractEventLoop):
while True:
connection, address = await loop.sock_accept(server_socket)
connection.setblocking(False)
print(f"Получен запрос на подключение от {address}")

Эхо-сервер средствами цикла событий asyncio

91

Итак, сопрограмма для прослушивания порта у нас есть, а как насчет чтения и записи данных подключившимся клиентам? Для этого нужна просто сопрограмма или сопрограмма, обернутая задачей?
В данном случае мы имеем несколько подключений, по каждому из
которых в любой момент могут отправляться данные. Мы не хотим,
чтобы ожидание данных по одному подключению блокировало все
остальные, желательно иметь возможность читать и записывать данные для нескольких клиентов конкурентно. Поскольку требуется обрабатывать несколько подключений одновременно, имеет смысл
создать для каждого подключения задачу, которая будет отвечать за
чтение и запись данных.
Создадим сопрограмму echo, отвечающую за обработку данных,
связанных с одним подключением. Эта сопрограмма будет крутиться
в бесконечном цикле, ожидая данных от клиента. Получив данные,
она будет отправлять их копию обратно клиенту.
Таким образом, в сопрограмме listen_for_connections мы для
каждого нового подключения будем создавать задачу, обертывающую сопрограмму echo. Эти две сопрограммы дают все, что нужно для
построения асинхронного эхо-сервера.
Листинг 3.8

Построение асинхронного эхо-сервера

import asyncio
import socket
from asyncio import AbstractEventLoop
В бесконечном цикле
async def echo(connection: socket,
ожидаем
данных от клиента
loop: AbstractEventLoop) -> None:
while data := await loop.sock_recv(connection, 1024):
await loop.sock_sendall(connection, data)
Получив данные, отправляем
их обратно клиенту
async def listen_for_connection(server_socket: socket,
loop: AbstractEventLoop):
while True:
connection, address = await loop.sock_accept(server_socket)
connection.setblocking(False)
print(f"Получен запрос на подключение от {address}")
asyncio.create_task(echo(connection, loop))
После получения запроса на подключение создаем
задачу echo, ожидающую данные от клиента
async def main():
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_address = ('127.0.0.1', 8000)
server_socket.setblocking(False)
server_socket.bind(server_address)
server_socket.listen()

Запускаем сопрограмму прослушивания
порта на предмет подключений

await listen_for_connection(server_socket, asyncio.get_event_loop())
asyncio.run(main())

Глава 3

92

Первое приложение asyncio

Архитектура программы показана на рис. 3.5. Имеется одна сопрограмма, listen_for_connection, прослушивающая порт. Как только
клиент подключился, она запускает задачу echo для этого клиента, которая ожидает поступления данных и отправляет их обратно клиенту.
Задача echo для клиента 1
data = await sock_recv()
await sock_sendall(data)
listen_for_connection()

Запустить
задачу

Задача echo для клиента 2

connection = await loop.sock_accept()
create_task(echo(connection))

Запустить
задачу

data = await sock_recv()
await sock_sendall(data)

Запустить
задачу

Задача echo для клиента N
data = await sock_recv()
await sock_sendall(data)

Клиент 1
Чтение
Запись

Клиент 2
Чтение
Запись
Клиент N
Чтение
Запись

Рис. 3.5 Сопрограмма, прослушивающая порт, запускает по одной задаче для каждого
нового подключения

Это приложение позволяет конкурентно прослушивать нескольких
клиентов и отправлять им данные. Под капотом используются все те
же селекторы, которые мы видели раньше, так что потребление процессора остается низким.
Мы написали полнофункциональный эхо-сервер, пользуясь только
asyncio! Но нет ли в нашей реализации дефектов? Есть – мы не обрабатываем ошибки в задаче echo.

3.5.3 Обработка ошибок в задачах
Сетевые подключения часто ненадежны, поэтому в коде приложения
могут возникать неожиданные исключения. Как должно вести себя
приложение в случае ошибки чтения или записи данных? Для тестирования изменим нашу реализацию echo – будем возбуждать исключение, если клиент передает особое слово:
async def echo(connection: socket,
loop: AbstractEventLoop) -> None:
while data := await loop.sock_recv(connection, 1024):
if data == b'boom\r\n':
raise Exception("Неожиданная ошибка сети")
await loop.sock_sendall(connection, data)

Если теперь клиент пришлет слово «boom», то мы возбудим исключение и задача завершится аварийно. И что произойдет в этом
случае? Мы увидим обратную трассу вызовов, содержащую преду­
преж­дение:

Эхо-сервер средствами цикла событий asyncio

93

Task exception was never retrieved
future:
Traceback (most recent call last):
File "asyncio_echo.py", line 9, in echo
raise Exception("Неожиданная ошибка сети")
Exception: Unexpected network error

Важна здесь фраза «Task exception was never retrieved» (Исключение
задачи не извлекалось). Что она означает? Когда внутри задачи возникает исключение, считается, что задача завершена, а ее результатом является исключение. Это значит, что в стек вызовов исключение
не попадает. И очистки здесь нет. Если это исключение возбуждается,
то мы не можем отреагировать на ошибку в задаче, потому что не
пытались его извлечь.
Чтобы исключение дошло до нас, задачу нужно вызывать в выражении await. В таком случае исключение будет возбуждено в точке
await, и это отразится в трассе вызовов. Если не применить await к задаче в каком-то месте приложения, то мы рискуем никогда не увидеть возникшего исключения. Хотя в рассматриваемом примере мы
видим сообщение, заданное в исключении, и можем подумать, что
проблемы нет, это приложение можно немного изменить, так что сообщение будет от нас скрыто.
Для демонстрации предположим, что вместо игнорирования задач
echo, созданных в listen_for_connections, мы сохраняем их в списке:
tasks = []
async def listen_for_connection(server_socket: socket,
loop: AbstractEventLoop):
while True:
connection, address = await loop.sock_accept(server_socket)
connection.setblocking(False)
print(f"Получено сообщение от {address}")
tasks.append(asyncio.create_task(echo(connection, loop)))

На первый взгляд, все должно работать как прежде. Отправив сообщение «boom», мы должны увидеть исключение и предупреждение
о том, что исключение не извлекалось. Однако это не так – мы ничего
не увидим, пока не снимем приложение принудительно!
Все дело в том, что мы храним ссылку на задачу, а asyncio может напечатать сообщение и обратную трассу сбойной задачи, только когда
она убирается в мусор. Это и понятно – ведь нет никакого способа
узнать, не ожидают ли задачу в какой-то другой точке приложения,
где она возбудила бы исключение. Из-за этих осложнений мы либо
должны ждать задачи с по­мощью await, либо обрабатывать все исключения в самих задачах. Как поступить в нашем эхо-сервере?
Первое, что можно сделать, – обернуть код сопрограммы echo предложением try/catch, запротоколировать исключение и закрыть подключение:

Глава 3

94

Первое приложение asyncio

import logging
async def echo(connection: socket,
loop: AbstractEventLoop) -> None:
try:
while data := await loop.sock_recv(connection, 1024):
print('получены данные!')
if data == b'boom\r\n':
raise Exception("Неожиданная ошибка сети")
await loop.sock_sendall(connection, data)
except Exception as ex:
logging.exception(ex)
finally:
connection.close()

Это решает неотложную проблему, из-за которой сервер ругается,
что исключение не извлекалось, – ведь мы обработали его в самой сопрограмме. Кроме того, сокет корректно закрывается в блоке finally,
так что в случае ошибки не остается висячего необработанного исключения.
Отметим, что в этой реализации все открытые подключения клиентов закрываются на этапе остановки приложения. Почему? В главе 2 мы говорили, что asyncio.run снимает все задачи, оставшиеся
в момент остановки приложения. Мы также узнали, что, когда задача
снимается, возбуждается исключение CancelledError в точке, где программа ждет ее спо­мощью await.
Здесь важно, где именно возбуждается исключение. Если наша задача ждет чего-то в предложении вида await loop.sock_recv, а мы эту
задачу сняли, то CancelledError возбуждается в строке await loop.
sock_recv. Это значит, что в рассматриваемом случае будет выполнен блок finally, так как исключение было возбуждено в выражении
await при снятии задачи. Если изменить блок except, так чтобы он
перехватывал и протоколировал эти исключения, то мы увидим по
одному исключению CancelledError на каждую задачу.
Итак, мы решили проблему обработки ошибок при отказе задач.
А что, если требуется произвести какую-то очистку после ошибок или
что-то сделать с задачами, оставшимися в момент остановки приложения? Для этой цели предназначены обработчики сигналов asyncio.

3.6

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

Корректная остановка

95

завершение начатых задач, чтобы они отправили все сообщения, которые собирались. Хотя наша реализация не дотянет до промышленного качества, мы сможем изучить вещи, относящиеся к остановке
приложений asyncio и отмене всех работающих задач.

Сигналы в Windows
Windows не поддерживает сигналы. Поэтому настоящий раздел относится только к системам на базе Unix. В Windows имеется другая система,
которая на момент написания этой книги плохо работает с Python. Если
хотите узнать, как сделать этот код кросс-платформенным, почитайте ответ на вопрос на сайте Stack Overflow по адресу https://stackoverflow.
com/questions/35772001.

3.6.1 Прослушивание сигналов
Сигналы в Unix-системах – это механизм асинхронного уведомления
процесса о событии, случившемся на уровне операционной системы.
Несмотря на кажущуюся низкоуровневость сигналов, с некоторыми
из них вы, безусловно, знакомы. Например, хорошо известен сигнал
SIGINT (сигнал прерывания). Он посылается при нажатии CTRL+C для
снятия командного приложения. В Python мы можем обработать его,
перехватив исключение KeyboardInterrupt. Еще один известный сигнал – SIGTERM (сигнал завершения). Он посылается, когда мы выполняем команду kill для снятия процесса.
Чтобы реализовать специальную логику остановки, мы включим
в приложение обработчики сигналов SIGINT и SIGTERM, в которых
дадим задачам echo несколько секунд для завершения.
Как прослушивать сигналы в приложении? Цикл событий asyncio
позволяет прослушивать любой сигнал, указанный в методе add_signal_handler. Это не то же самое, что задание обработчиков сигналов
с по­мощью функции signal.signal из модуля signal, так как add_signal_handler безопасно взаимодействует с циклом событий. Функция
принимает номер сигнала и функцию, которая должна вызываться
при получении этого сигнала. Для демонстрации добавим обработчик сигнала, который снимает все работающие задачи. В asyncio есть
функция asyncio.all_tasks, возвращающая множество всех работающих задач.
Листинг 3.9 Добавление обработчика сигнала, снимающего
все задачи
import asyncio, signal
from asyncio import AbstractEventLoop
from typing import Set
from util.delay_functions import delay

Глава 3

96

Первое приложение asyncio

def cancel_tasks():
print('Получен сигнал SIGINT!')
tasks: Set[asyncio.Task] = asyncio.all_tasks()
print(f'Снимается {len(tasks)} задач.')
[task.cancel() for task in tasks]
async def main():
loop: AbstractEventLoop = asyncio.get_running_loop()
loop.add_signal_handler(signal.SIGINT, cancel_tasks)
await delay(10)
asyncio.run(main())

Запустив это приложение, мы увидим, что сопрограмма delay начинает работать сразу и ждет 10 с. Если в течение этих 10 с нажать
CTRL+C, то мы увидим сообщение «Получен сигнал SIGINT!», а вслед
за ним сообщение о том, что снимаются все задачи. Кроме того, мы
увидим, что в asyncio.run(main()) возбуждено исключение CancelledError, поскольку мы сняли задачу.

3.6.2 Ожидание завершения начатых задач
В исходной постановке мы хотели, чтобы наш эхо-сервер давал задачам echo несколько секунд на завершение перед остановкой. Это
можно сделать, обернув задачи функцией wait_for и ожидая обернутые задачи с по­мощью await. По истечении тайм-аута эти задачи возбудят исключение TimeoutError, и мы сможем завершить приложение.
Говоря об обработчике остановки, следует отметить, что это обычная функция Python, поэтому использовать внутри нее предложения
await нельзя. Это проблема, так как предложенное решение подразумевает использование await. Возможное решение – создать сопрограмму, которая реализует логику остановки, и в обработчике остановки обернуть ее задачей:
async def await_all_tasks():
tasks = asyncio.all_tasks()
[await task for task in tasks]
async def main():
loop = asyncio.get_event_loop()
loop.add_signal_handler(signal.SIGINT,
lambda: asyncio.create_task(await_all_tasks()))

Такой подход будет работать, но у него есть недостаток: если внутри await_all_tasks возникнет исключение, то мы останемся с брошенной отказавшей задачей и, стало быть, с предупреждением «исключение не извлекалось». Может быть, есть способ лучше?
Можно решить проблему, возбудив специальное исключение, которое будет останавливать нашу сопрограмму main. Тогда мы сможем

Корректная остановка

97

перехватить его внутри main и выполнить логику остановки. Для этого придется создать собственный цикл событий взамен asyncio.run.
Дело в том, что при возникновении исключения asyncio.run снимает
все работающие задачи, а значит, мы не сможем обернуть задачи echo
функцией wait_for.
class GracefulExit(SystemExit):
pass
def shutdown():
raise GracefulExit()
loop = asyncio.get_event_loop()
loop.add_signal_handler(signal.SIGINT, shutdown)
try:
loop.run_until_complete(main())
except GracefulExit:
loop.run_until_complete(close_echo_tasks(echo_tasks))
finally:
loop.close()

Имея в виду этот подход, напишем логику остановки:
async def close_echo_tasks(echo_tasks: List[asyncio.Task]):
waiters = [asyncio.wait_for(task, 2) for task in echo_tasks]
for task in waiters:
try:
await task
except asyncio.exceptions.TimeoutError:
# Здесь мы ожидаем истечения тайм-аута
pass

В сопрограмме close_echo_tasks мы принимаем список задач echo
и обертываем каждую из них задачей wait_for с двухсекундным
тайм-аутом. Это означает, что любая задача echo будет иметь 2 с на
завершение перед принудительным снятием. После этого в цикле
ожидаем все обернутые задачи с по­мощью await. Мы перехватываем все исключения TimeoutError, поскольку ожидаем, что они будут
возбуждены нашими задачами по истечении 2 с. Собирая все вместе,
получаем такой код логики остановки эхо-сервера.
Листинг 3.10

Корректная остановка

import asyncio
from asyncio import AbstractEventLoop
import socket
import logging
import signal
from typing import List

Глава 3

98

Первое приложение asyncio

async def echo(connection: socket,
loop: AbstractEventLoop) -> None:
try:
while data := await loop.sock_recv(connection, 1024):
print('got data!')
if data == b'boom\r\n':
raise Exception("Неожиданная ошибка сети")
await loop.sock_sendall(connection, data)
except Exception as ex:
logging.exception(ex)
finally:
connection.close()
echo_tasks = []
async def connection_listener(server_socket, loop):
while True:
connection, address = await loop.sock_accept(server_socket)
connection.setblocking(False)
print(f"Получено сообщение от {address}")
echo_task = asyncio.create_task(echo(connection, loop))
echo_tasks.append(echo_task)
class GracefulExit(SystemExit):
pass
def shutdown():
raise GracefulExit()
async def close_echo_tasks(echo_tasks: List[asyncio.Task]):
waiters = [asyncio.wait_for(task, 2) for task in echo_tasks]
for task in waiters:
try:
await task
except asyncio.exceptions.TimeoutError:
# Здесь мы ожидаем истечения тайм-аута
pass
async def main():
server_socket = socket.socket()
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_address = ('127.0.0.1', 8000)
server_socket.setblocki(False)
server_socket.bind(server_address)
server_socket.listen()
for signame in {'SIGINT', 'SIGTERM'}:
loop.add_signal_handler(getattr(signal, signame), shutdown)
await connection_listener(server_socket, loop)
loop = asyncio.new_event_loop()
try:

Резюме

99

loop.run_until_complete(main())
except GracefulExit:
loop.run_until_complete(close_echo_tasks(echo_tasks))
finally:
loop.close()

Если остановить приложение нажатием CTRL+C или командой kill
в момент, когда подключен хотя бы один клиент, то будет выполнена
логика остановки. Мы увидим, что приложение ждет 2 с, давая задачам echo возможность завершиться, а затем останавливается.
Есть две причины, по которым эта логика несовершенна. Во-пер­
вых, ожидая завершения задач echo, мы не останавливаем прослушиватель подключений. Это значит, что, пока мы ждем, может поступить
новый запрос на подключение, для которого мы не сможем добавить
двухсекундный тайм-аут. Во-вторых, мы ждем завершения всех задач
echo, но перехватываем только исключения TimeoutException. Следовательно, если задача возбудит какое-то другое исключение, мы его
запомним, а все последующие задачи, возбуждающие исключение,
будут проигнорированы. В главе 4 мы рассмотрим методы asyncio
для более правильной обработки исключений в группе допускающих
ожидание объектов.
Хотя приложение несовершенно и является лишь модельным примером, мы все же построили полнофункциональный сервер с по­
мощью библиотеки asyncio. Этот сервер может конкурентно обрабатывать много пользователей в одном потоке. Если бы мы использовали блокирующий подход, как в самом начале обсуждения, то должны
были бы прибегнуть к многопоточности, из-за чего программа стала
бы сложнее и потребляла больше ресурсов.

Резюме
В этой главе мы узнали о блокирующих и неблокирующих сокетах
и более глубоко изучили работу цикла событий asyncio. Мы также написали свое первое приложение asyncio, эхо-сервер с высокой степенью конкурентности. Мы поняли, как обрабатывать ошибки в задачах
и добавлять собственную логику остановки приложения.
„„ Мы узнали, как создать простое приложение с блокирующими
сокетами. Блокирующий сокет останавливает весь поток, пока
ожидает данных. Это препятствует организации конкурентности, потому что в каждый момент времени мы можем получать
данные только от одного клиента.
„„ Мы узнали, как писать приложения с неблокирующими сокетами. Их методы возвращают управление немедленно – либо в результате получения данных, либо с исключением, если данные
еще не пришли. Поэтому такие сокеты позволяют обеспечить
конкурентность.

Глава 3

100

Первое приложение asyncio

Мы описали, как использовать модуль selectors для эффективного прослушивания событий в сокетах. Он позволяет зарегистрировать сокеты, за которыми мы хотим наблюдать, и сообщает, когда в неблокирующем сокете появляются данные.
„„ Поместив select в бесконечный цикл, мы заново реализуем основную часть функциональности цикла событий. Регистрируем
интересующие нас сокеты и в цикле выполняем свой код при появлении в сокете данных.
„„ Мы узнали, как использовать методы цикла событий asyncio для
построения приложений с неблокирующими сокетами. Они принимают сокет и возвращают сопрограмму, которую можно затем
использовать в выражении await. Это приостанавливает родительскую сопрограмму, до тех пор пока в сокете не появятся данные. Под капотом используется модуль selectors.
„„ Мы видели, как наши задачи реализуют конкурентность асинхронного эхо-сервера, способного отправлять и принимать данные одновременно от нескольких клиентов. Также рассмотрели,
как обрабатывать ошибки в этих задачах.
„„ Мы узнали, как добавить в асинхронное приложение специальную логику остановки. В нашем конкретном примере мы решили дать начатым задачам несколько секунд на завершение отправки данных. Но никто не мешает сделать что-то другое.
„„

4

Конкурентные
веб-запросы

Краткое содержание главы
Асинхронные контекстные менеджеры.
Отправка совместимых с asyncio веб-запросов с по­мощью
библиотеки aiohttp.
„„ Конкурентное выполнение веб-запросов с по­мощью gather.
„„ Обработка результатов по мере поступления с по­мощью
as completed.
„„ Отслеживание незавершенных запросов с по­мощью wait.
„„ Установка и обработка тайм-аутов для групп запросов
и снятие запросов.
„„
„„

В главе 3 мы узнали о механизмах работы сокетов и написали простой
эхо-сервер. Научившись проектировать базовое приложение, мы теперь применим эти знания к реализации конкурентных неблокирующих веб-запросов. Использование для этой цели asyncio позволит одновременно отправлять сотни запросов, резко сократив временные
затраты по сравнению с синхронным подходом. Это полезно, когда
нужно отправить несколько запросов различным функциям REST API,
как часто бывает при работе с микросервисной архитектурой или при
реализации веб-робота. Кроме того, при таком подходе мы сможем
выполнять другой код, ожидая завершения потенциального долгого
веб-запроса, так что приложение получится более отзывчивым.

102

Глава 4

Конкурентные веб-запросы

В этой главе мы узнаем об асинхронной библиотеке aiohttp, которая позволяет сделать все вышеописанное. В ней для отправки вебзапросов используются неблокирующие сокеты, а результатом вызова
являются сопрограммы, которые можно затем ждать с по­мощью await.
Конкретно мы возьмем список, содержащий сотни URL, содержимое
которых хотим получить, и запустим для них запросы одновременно.
По ходу дела познакомимся с различными методами API asyncio, предназначенными для одновременного выполнения сопрограмм, и сможем выбрать между ожиданием завершения всех сопрограмм или
обработкой результатов по мере поступления. Мы также посмотрим,
как задавать тайм-ауты, – индивидуально и для целой группы запросов. Мы увидим, как снять множество исполняемых запросов в зависимости от результатов выполнения других запросов. Эти методы
API полезны не только для веб-запросов, но и всегда, когда требуется
конкурентно выполнить группу сопрограмм. Будем использовать эти
функции на протяжении всей книги, а вы, как разработчик на платформе asyncio, часто будете применять их в собственной практике.

4.1

Введение в aiohttp
В главе 2 мы говорили, что начинающие изучать asyncio часто сталкиваются с проблемой: взять уже имеющийся код и понавтыкать в него
async и await в надежде улучшить производительность. Чаще всего,
в частности для веб-запросов, это работать не будет, поскольку большинство существующих библиотек – блокирующие.
Популярной библиотекой для работы с веб-запросами является
requests. Она плохо совместима с asyncio, поскольку в ней используются блокирующие сокеты. Это значит, что отправка любого запроса блокирует поток, в котором запрос отправлен, а поскольку asyncio
однопоточная, будет блокирован весь цикл событий, пока запрос не
завершится.
Чтобы решить эту проблему и добиться конкурентности, нам нужна библиотека, которая не блокирует ничего вплоть до уровня сокетов. Одной из таких библиотек является aiohttp (асинхронный HTTP
на стороне клиента и сервера для asyncio и Python), которая решает
проблему с по­мощью неблокирующих сокетов.
Аiohttp – библиотека с открытым исходным кодом, часть проекта aio-libs, в описании которого декларируется, что это «набор основанных на asyncio высококачественных библиотек» (см. https://
github.com/aio-libs). Она представляет собой полнофункциональный
веб-клиент и веб-сервер, т. е. умеет отправлять веб-запросы и может
стать основой для разработки асинхронных веб-серверов. (Документация доступна по адресу https://docs.aiohttp.org/.) В этой главе мы
сконцентрируемся на клиентской части aiohttp, но далее рассмотрим
и написание веб-серверов с ее помощью.

Асинхронные контекстные менеджеры

103

Итак, с чего начать? Прежде всего нужно научиться отправлять
HTTP-запрос. Сначала следует рассказать о новом синтаксисе асинхронных контекстных менеджеров, который позволяет корректно
начинать и закрывать HTTP-сеансы. Разработчики на платформе
asyncio часто используют этот синтаксис для асинхронного захвата
ресурсов, например подключений к базе данных.

4.2

Асинхронные контекстные менеджеры
В любом языке программирования приходится работать с ресурсами,
которые нужно открывать, а затем закрывать, например с файлами.
При этом нужно помнить об исключениях – если исключение возникает, когда ресурс открыт, то может не представиться возможности
для очистки, и в результате мы будем иметь утечку ресурсов. В Python
эта проблема решается просто, с использованием блока finally. Следующий пример написан не вполне в духе Python, тем не менее файл
всегда закрывается, даже если возникло исключение:
file = open('example.txt')
try:
lines = file.readlines()
finally:
file.close()

Здесь гарантируется, что описатель файла не останется открытым,
если при выполнении функции file.readlines возникнет исключение. Недостаток в том, что нужно не забыть обернуть код конструкцией try/finally и вызвать правильный метод закрытия ресурса. Для
файлов это нетрудно, нужно только вызывать для них метод close, но
все же хотелось бы иметь что-то допускающее повторное использование, особенно с учетом того, что очистка может не сводиться к вызову
одного метода. В Python для этой цели предусмотрены контекстные
менеджеры. Они позволяют абстрагировать логику освобождения ресурса с по­мощью блока try/finally:
with open('example.txt') as file:
lines = file.readlines()

Этот «питонический» способ управления файлами выглядит куда
чище. Если внутри блока with возникнет исключение, файл автоматически будет закрыт. Это работает для синхронных ресурсов, но что делать, если мы хотим применить этот механизм асинхронно? В таком
случае контекстный менеджер не подходит, потому что предназначен
только для работы с синхронным Python-кодом, а не с сопрограммами и задачами. По этой причине в язык было включено новое средство: асинхронные контекстные менеджеры. Синтаксис почти такой
же, только вместо with нужно писать async with.

104

Глава 4

Конкурентные веб-запросы

Асинхронный контекстный менеджер – это класс, реализующий
два специальных метода-сопрограммы: __aenter__, который асинхронно захватывает ресурс, и __aexit__, который закрывает ресурс.
Сопрограмма __aexit__ принимает несколько аргументов, относящихся к обработке исключений, их мы в этой главе рассматривать не
будем.
Чтобы лучше разобраться, реализуем асинхронный контекстный
менеджер для сокетов, с которыми познакомились в главе 3. Подключенный клиентский сокет можно рассматривать как управляемый ресурс. Когда клиент подключается, мы захватываем клиентский сокет.
А закончив работу с ним, очищаем и закрываем соединение. В главе 3
мы обернули все это блоком try/finally, но могли бы вместо этого
реализовать асинхронный контекстный менеджер.
Листинг 4.1 Асинхронный контекстный менеджер, ожидающий
подключения клиента
import asyncio
import socket
from types import TracebackType
from typing import Optional, Type
class ConnectedSocket:
def __init__(self, server_socket):
self._connection = None
self._server_socket = server_socket

Эта сопрограмма вызывается
при входе в блок with. Она ждет
подключения клиента и возвращает
это подключение

async def __aenter__(self):
print('Вход в контекстный менеджер, ожидание подключения')
loop = asyncio.get_event_loop()
connection, address = await loop.sock_accept(self._server_socket)
self._connection = connection
print('Подключение подтверждено')
return self._connection

async def __aexit__(self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType]):
print('Выход из контекстного менеджера')
self._connection.close()
Эта сопрограмма вызывается при выходе
print('Подключение закрыто')
из блока with. В ней мы производим
async def main():
loop = asyncio.get_event_loop()

очистку ресурса, В данном случае
закрывается подключение

server_socket = socket.socket()
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_address = ('127.0.0.1', 8000)
server_socket.setblocking(False)

Асинхронные контекстные менеджеры
server_socket.bind(server_address)
server_socket.listen()

105

Здесь вызывается __aenter__
и начинается ожидание подключения

async with ConnectedSocket(server_socket) as connection:
data = await loop.sock_recv(connection, 1024)
print(data)
После этого предложения вызывается
__aexit__ и подключение закрывается
asyncio.run(main())

Здесь мы создали асинхронный контекстный менеджер ConnectedSocket. Этот класс принимает серверный сокет, и в сопрограмме
__aenter__ мы ждем подключения клиента. Как только клиент подключился, возвращается соответствующее ему клиентское подключение. Это дает нам доступ к подключению в части as предложения
async with. Затем в блоке async with мы используем это подключение
для ожидания данных от клиента. Когда выполнение этого блока завершается, вызывается сопрограмма __aexit__, которая закрывает
подключение. Если клиент подключился с по­мощью telnet и отправил
какие-то тестовые данные, то при работе этой программы мы увидим
такие сообщения:
Вход в контекстный менеджер, ожидание подключения
Подключение подтверждено
b'test\r\n'
Выход из контекстного менеджера
Подключение закрыто

В aiohttp асинхронные контекстные менеджеры вовсю используются для создания HTTP-сеансов и подключений. Мы также будем
использовать их в главе 5 при работе с асинхронными подключениями к базе данных и транзакциями. Обычно писать асинхронные контекстные менеджеры самостоятельно не возникает необходимости,
но полезно понимать, как они работают и чем отличаются от обычных контекстных менеджеров. Познакомившись с контекстными менеджерами, воспользуемся ими в сочетании с aiohttp, чтобы отправить асинхронный веб-запрос.

4.2.1 Отправка веб-запроса с по­мощью aiohttp
Сначала нужно установить библиотеку aiohttp. Это можно сделать
с по­мощью программы pip:
pip install -Iv aiohttp==3.8.1

Будет установлена последняя версия aiohttp (на момент написания
книги 3.8.1). После этого можно приступать к отправке запросов.
В библиотеке aiohttp и вообще при работе с веб-запросами используется понятие сеанса. Сеанс можно рассматривать как создание
нового окна браузера. В этом окне можно открывать разные вебстраницы, которые могут посылать куки, сохраняемые браузером.

Глава 4

106

Конкурентные веб-запросы

Внутри сеанса хранится много открытых подключений, их можно при
необходимости использовать повторно. Это называется пулом подключений и играет важную роль в производительности приложений
на базе aiohttp. Поскольку создание подключения – дорогостоящее
действие, наличие пула повторно используемых подключений сокращает затраты на выделение и освобождение ресурсов. Сеанс также
самостоятельно сохраняет все полученные куки.
Как правило, мы хотим пользоваться преимуществами пула подключений, поэтому в большинстве приложений на базе aiohttp создается один сеанс для всего приложения. Затем объект сеанса передается методам. У объекта сеанса имеются методы для отправки
веб-запросов, в том числе GET, PUT и POST. Для создания сеанса используется синтаксис async with и асинхронный контекстный менеджер aiohttp.ClientSession.
Листинг 4.2 Отправка веб-запроса с по­мощью aiohttp
import asyncio
import aiohttp
from aiohttp import ClientSession
from util import async_timed
@async_timed()
async def fetch_status(session: ClientSession, url: str) -> int:
async with session.get(url) as result:
return result.status
@async_timed()
async def main():
async with aiohttp.ClientSession() as session:
url = 'https://www.example.com'
status = await fetch_status(session, url)
print(f'Состояние для {url} было равно {status}')
asyncio.run(main())

При выполнении этой программы мы увидим сообщение «Состояние для http://www.example.com было равно 200». Сначала мы создали клиентский сеанс в блоке async with, вызвав функцию aiohttp.
ClientSession(). Имея сеанс, можно отправить любой веб-запрос.
В данном случае мы написали вспомогательную функцию fetch_status_code, которая получает сеанс и URL-адрес, а возвращает состояние для этого адреса. В этой функции имеется еще один блок async
with, а в сеансе выполняется GET-запрос с указанным URL-адресом.
Результат можно обработать в том же блоке with. В данном случае мы
просто извлекаем и возвращаем код состояния.
Отметим, что по умолчанию в сеансе ClientSession можно создать
не более 100 подключений, что дает неявную верхнюю границу количества конкурентных веб-запросов. Чтобы изменить этот предел, мож-

Асинхронные контекстные менеджеры

107

но создать экземпляр класса TCPConnector, входящего в состав aiohttp,
указав максимальное число подключений, и передать его конструктору ClientSession. Подробнее см. документацию по aiohttp по адресу
https://docs.aiohttp.org/en/stable/client_advanced.html#connectors.
Функция fetch_status еще не раз понадобится нам в этой главе,
поэтому сделаем ее повторно используемой. Создайте модуль Python
chapter_04 и поместите в его файл __init__.py эту функцию. В последующих примерах мы будем импортировать ее в предложении from
chapter_04 import fetch_status.

Замечание для пользователей Windows
В настоящее время в версии aiohttp для Windows есть ошибка, из-за которой иногда печатается сообщение «RuntimeError: Event loop is closed»,
хотя приложение работает нормально. Подробнее об этой ошибке можно прочитать по адресу https://github.com/aio-libs/aiohttp/issues/4324
и https://bugs.python.org/issue39232. Чтобы обойти ее, нужно либо
управлять циклом событий вручную с по­мощью функции asyncio.get_
event_loop().run_until_complete(main()), как описано в главе 2, либо
изменить политику цикла событий, вызвав функцию asyncio.set_event_
loop_policy(asyncio.WindowsSelector­Event­LoopPolicy()) до вызова
asyncio.run(main()).

4.2.2 Задание тайм-аутов в aiohttp
Выше мы видели, как задать тайм-аут для объекта, допускающего
ожидание, с по­мощью функции asyncio.wait_for. Так можно задать
тайм-аут и для запроса в aiohttp, но есть более чистый способ – воспользоваться функциональностью, изначально присутствующей
в aiohttp. По умолчанию тайм-аут равен 5 мин., т. е. никакая операция
не будет выполняться дольше. Это большой тайм-аут, и многие разработчики предпочитают его уменьшить. Тайм-аут можно задавать на
уровне сеанса, тогда он будет применяться к каждой операции, или на
уровне запроса, если требуется более точное управление.
Тайм-аут задается с по­мощью структуры данных aiohttp ClientTimeout. Она позволяет установить не только общий тайм-аут в секундах для всего запроса, но также отдельные тайм-ауты для установления соединения или чтения данных. Рассмотрим, как задать тайм-аут
для сеанса и для одного запроса.
Листинг 4.3

Задание тайм-аутов в aiohttp

import asyncio
import aiohttp
from aiohttp import ClientSession
async def fetch_status(session: ClientSession,
url: str) -> int:

Глава 4

108

Конкурентные веб-запросы

ten_millis = aiohttp.ClientTimeout(total=.01)
async with session.get(url, timeout=ten_millis) as result:
return result.status
async def main():
session_timeout = aiohttp.ClientTimeout(total=1, connect=.1)
async with aiohttp.ClientSession(timeout=session_timeout) as session:
await fetch_status(session, 'https://example.com')
asyncio.run(main())

Здесь задается два тайм-аута. Первый – на уровне клиентского
сеанса, полный тайм-аут равен 1 с, а тайм-аут для установления соединения – 100 мс. Затем в функции fetch_status мы переопределяем этот тайм-аут для нашего GET-запроса, задавая его равным 10 мс.
Если запрос к example.com займет более 10 мс, то будет возбуждено
исключение asyncio.TimeoutError в точке await fetch_status. В этом
примере 10 мс должно хватить для завершения запроса к example.
com, так что исключения мы не увидим. Чтобы протестировать исключение, измените URL-адрес, выбрав страницу, скачивание которой занимает больше 10 мс.
Эти примеры демонстрируют основы aiohttp. Однако наше приложение ничего не выиграет от выполнения одного запроса с по­мощью
asyncio. Выигрыш мы получим, только если будем выполнять несколько веб-запросов конкурентно.

4.3

И снова о конкурентном выполнении задач
В первых главах книги мы научились создавать несколько задач для
конкурентного выполнения сопрограмм. Для этого мы вызывали
функцию 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 или списковым
включением, чтобы сделать код более лаконичным, как показано

И снова о конкурентном выполнении задач

109

в листинге ниже. Но такой подход может оказаться проблематичным,
если реализовать его неправильно.
Листинг 4.4 Неправильное использование спискового включения
для создания и ожидания задач
import asyncio
from util import async_timed, delay
@async_timed()
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 с, так как все выполняется последовательно.
выполняется с аргументами () {}
выполняется с аргументами (3,) {}
засыпаю на 3 с
сон в течение 3 с закончился
завершилась за 3.0008 с
выполняется с аргументами (3,) {}
засыпаю на 3 с
сон в течение 3 с закончился
завершилась за 3.0009 с
выполняется с аргументами (3,) {}
засыпаю на 3 с
сон в течение 3 с закончился
завершилась за 3.0020 с
завершилась за 9.0044 с

Здесь имеется тонкая ошибка. Все дело в том, что мы применяем
await сразу же после создания задачи. Это значит, что мы приостанавливаем списковое включение и сопрограмму main для каждой созданной задачи delay до момента, когда она завершится. В данном случае
в каждый момент времени будет работать только одна задача, а не все
конкурентно. Исправить это легко, хотя немного муторно. Мы можем
создавать задачи в одном списковом включении, а ждать в другом.
Тогда все будет работать конкурентно.
Листинг 4.5 Использование спискового включения
для конкурентного выполнения задач
import asyncio
from util import async_timed, delay
@async_timed()
async def main() -> None:

Глава 4

110

Конкурентные веб-запросы

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 с.
выполняется с аргументами () {}
выполняется с аргументами (3,) {}
засыпаю на 3 с
выполняется с аргументами (3,) {}
засыпаю на 3 с
выполняется с аргументами (3,) {}
засыпаю на 3 с
сон в течение 3 с закончился
завершилась за 3.0029 с
сон в течение 3 с закончился
завершилась за 3.0029 с
сон в течение 3 с закончился
завершилась за 3.0029 с
завершилась за 3.0031 с

Это код делает то, что нам нужно, но недостатки еще имеются.
И первый из них заключается в том, что он состоит из нескольких
строк, и мы должны не забыть отделить создание задач от ожидания.
Второй – негибкость, поскольку если одна из наших сопрограмм завершается значительно раньше прочих, то мы застрянем во втором
списковом включении, ожидая завершения всех остальных сопрограмм. Иногда это приемлемо, но чаще мы стремимся к большей
отзывчивости и хотим обрабатывать результаты сразу после поступления. Третий и, пожалуй, самый крупный недостаток связан с обработкой исключений. Если в какой-то сопрограмме возникает исключение, то оно будет возбуждено в момент ожидания сбойной задачи.
Это значит, что мы не сможем обработать успешно завершившиеся
задачи, потому что одно-единственное исключение останавливает
всю работу.
В asyncio есть функции для таких ситуаций и не только. Их рекомендуется использовать при конкурентном выполнении нескольких
задач. В следующих разделах мы рассмотрим некоторые из них и покажем, как с ними работать в контексте конкурентной отправки нескольких веб-запросов.

Конкурентное выполнение запросов с помощью gather

4.4

111

Конкурентное выполнение запросов
с помощью gather
Для конкурентного выполнения допускающих ожидание объектов
широко используется функция asyncio.gather. Она принимает последовательность допускающих ожидание объектов и запускает их
конкурентно всего в одной строке кода. Если среди объектов есть сопрограмма, то gather автоматически обертывает ее задачей, чтобы
гарантировать конкурентное выполнение. Это значит, что не нужно
отдельно обертывать все сопрограммы по отдельности с по­мощью
функции asyncio.create_task, как мы делали раньше.
asyncio.gather возвращает объект, допускающий ожидание. Если
использовать его в выражении await, то выполнение будет приостановлено, пока не завершатся все переданные объекты. А когда это
произойдет, asyncio.gather вернет список результатов работы.
Мы можем воспользоваться этой функцией, чтобы конкурентно отправить любое число веб-запросов. Рассмотрим пример, где имеется
1000 запросов и мы хотим получить все коды состояния. Снабдим сопрограмму main декоратором @async_timed, чтобы знать, сколько прошло времени.
Листинг 4.6

Конкурентное выполнение запросов с по­мощью gather

import asyncio
import aiohttp
from aiohttp import ClientSession
from chapter_04 import fetch_status
from util import async_timed
Сгенерировать список сопрограмм
@async_timed()
для каждого запроса, который мы
async def main():
хотим отправить
async with aiohttp.ClientSession() as session:
urls = ['https://example.com' for _ in range(1000)]
requests = [fetch_status(session, url) for url in urls]
status_codes = await asyncio.gather(*requests)
print(status_codes)
Ждать завершения
всех запросов
asyncio.run(main())

Здесь мы сначала генерируем список URL-адресов, для которых хотим получить код состояния; для простоты все адреса равны example.
com. Затем берем этот список и вызываем fetch_status_code, чтобы
получить список сопрограмм, который передадим gather. При этом
все сопрограммы обертываются задачами и запускаются конкурентно. Во время выполнения на стандартном выводе будет напечатано
1000 сообщений, показывающих, что сопрограммы fetch_status_code

Глава 4

112

Конкурентные веб-запросы

запущены последовательно, но запросы выполняются конкурентно.
Когда придут все результаты, мы увидим сообщение вида « завершилась за 0.5453 с». После того
как содержимое всех запрошенных URL-адресов будет получено, начнут печататься коды состояния. Продолжительность всего процесса
зависит от скорости интернет-подключения и быстродействия компьютера, обычно скрипт завершается за 500–600 мс.
А если сравнить с синхронным выполнением? Функцию main легко
модифицировать, так чтобы она ждала fetch_status_code с по­мощью
await, т. е. блокировала выполнение при каждом запросе. Так мы заставим работать ее синхронно.
@async_timed()
async def main():
async with aiohttp.ClientSession() as session:
urls = ['https://example.com' for _ in range(1000)]
status_codes = [await fetch_status_code(session, url) for url in urls]
print(status_codes)

Теперь программа будет работать гораздо дольше. Кроме того,
вместо 1000 сообщений «выполняется function fetch_status_code», за
которыми следует 1000 сообщений «function fetch_status_code завершилась», мы увидим следующую пару строк для каждого запроса:
выполняется с аргументами (3,) {}
завершилась за 0.01884 с

Это означает, что запросы следуют друг за другом и каждый начинается только после того, как предыдущий вызов fetch_status_code
завершился. Так насколько же это медленнее асинхронной версии?
Зависит от интернет-подключения и машины, на которой запущена
программа, но приблизительно выполнение занимает 18 с. То есть
асинхронная версия работает в 33 раза быстрее. Впечатляет!
Стоит отметить, что порядок поступления результатов для переданных объектов, допускающих ожидание, не детерминирован. Например, если передать gather сопрограммы a и b именно в таком
порядке, то b может завершиться раньше, чем a. Но приятная особенность gather заключается в том, что, независимо от порядка завершения допускающих ожидание объектов, результаты гарантированно будут возвращены в том порядке, в каком объекты передавались.
Продемонстрируем это в описанном ранее сценарии использования
функции delay.
Листинг 4.7 Завершение допускающих ожидание объектов
не по порядку
import asyncio
from util import delay
async def main():

Конкурентное выполнение запросов с помощью gather

113

results = await asyncio.gather(delay(3), delay(1))
print(results)
asyncio.run(main())

Здесь мы передали gather две сопрограммы. Первой для завершения требуется 3 с, второй – одна. Можно ожидать, что результатом
будет список [1, 3], потому что односекундная сопрограмма завершается раньше трехсекундной. Но на самом деле возвращается [3, 1] –
в том порядке, в каком сопрограммы передавались. Функция gather
гарантирует детерминированный порядок результатов, несмотря на
недетерминированность их получения. На внутреннем уровне gather
использует для этой цели специальную реализацию future. Интересующемуся читателю предлагается заглянуть в исходный код gather,
этот поучительный опыт поможет осознать, как много API asyncio построено с использованием будущих объектов.
В примерах выше предполагается, что все запросы завершаются
успешно и не возбуждают исключений. Но что, если в каком-то запросе произойдет ошибка?

4.4.1 Обработка исключений при использовании gather
Разумеется, ответ на веб-запрос приходит не всегда, иногда возникает
исключение. Поскольку сети ненадежны, возможны различные сценарии отказов. Например, переданный адрес может не существовать
или оказаться временно недоступным из-за остановки сайта. Сервер,
к которому мы пытаемся подключиться, тоже может быть остановлен
или отказать в подключении.
asyncio.gather принимает необязательный параметр, return_exceptions, который позволяет указать, как мы хотим обрабатывать исключения от допускающих ожидание объектов. Это булево значение,
поэтому возможно два варианта:
„„ return_exceptions=False – это режим по умолчанию. Если хотя
бы одна сопрограмма возбуждает исключение, то gather возбуждает то же исключение в точке await. Но, даже если какаято сопрограмма откажет, остальные не снимаются и продолжат
работать при условии, что мы обработаем исключение и оно не
приведет к остановке цикла событий и снятию задач;
„„ return_exceptions=True – в этом случае исключения возвращаются в том же списке, что результаты. Сам по себе вызов gather
не возбуждает исключений, и мы можем обработать исключения, как нам удобно.
Для иллюстрации изменим список URL-адресов, включив в него
недопустимый адрес. Тогда aiohttp возбудит исключение при попытке выполнить запрос. Передадим этот список gather и посмотрим, как
она будет себя вести при разных значениях return_exceptions:

114

Глава 4

Конкурентные веб-запросы

@async_timed()
async def main():
async with aiohttp.ClientSession() as session:
urls = ['https://example.com', 'python://example.com']
tasks = [fetch_status_code(session, url) for url in urls]
status_codes = await asyncio.gather(*tasks)
print(status_codes)

Запрос к адресу 'python://example.com' завершается ошибкой, потому что такой URL недопустим. Поэтому наша сопрограмма fetch_
status_code возбуждает исключение AssertionError, так как схему
python:// нельзя преобразовать в номер порта. Это исключение возникает в точке, где мы ждем gather с по­мощью await. Выполнив этот
код и взглянув на результат, мы увидим, что исключение было возбуждено, но выполнение другого запроса продолжилось (для краткости мы сократили длинную трассу вызовов):
выполняется с аргументами () {}
выполняется
выполняется
завершилась за 0.0004 с
завершилась за 0.0203 с
завершилась за 0.0198 с
Traceback (most recent call last):
File "gather_exception.py", line 22, in
asyncio.run(main())
AssertionError
Process finished with exit code 1

asyncio.gather не снимает другие работающие задачи из-за отказа. Во многих случаях это приемлемо, но вообще является одним из
недостатков gather. Ниже в этой главе мы покажем, как снять конкурентные задачи.
Еще одна потенциальная проблема приведенного выше кода заключается в том, что если произойдет несколько исключений, то
в точке await gather мы увидим только первое. Это можно исправить,
задав параметр return_exceptions=True; тогда будут возвращены все
исключения, случившиеся во время выполнения сопрограмм. После
этого можно будет отделить исключения от результатов и обработать
их. Рассмотрим тот же пример с недопустимым URL-адресом:
@async_timed()
async def main():
async with aiohttp.ClientSession() as session:
urls = ['https://example.com', 'python://example.com']
tasks = [fetch_status_code(session, url) for url in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
exceptions = [res for res in results if isinstance(res, Exception)]
successful_results = [res for res in results if not isinstance(res, Exception)]

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

115

print(f'Все результаты: {results}')
print(f'Завершились успешно: {successful_results}')
print(f'Завершились с исключением: {exceptions}')

При выполнении этой программы исключения не возбуждаются,
а возвращаются в одном списке с результатами. Затем мы отфильтровываем все исключения, оставляя только результаты успешно завершившихся запросов. В итоге получаем:
Все результаты: [200, AssertionError()]
Завершились успешно: [200]
Завершились с исключением: [AssertionError()]

Теперь мы видим все исключения, возбужденные сопрограммами,
так что эта проблема решена. Хорошо еще и то, что не нужно явно обрабатывать исключения в блоке try/catch, потому что в точке await
исключение не возбуждается. Немного раздражает необходимость
отделять зерна от плевел, но и вообще API несовершенен.
Функция gather имеет несколько недостатков. Первый мы уже упоминали – не так просто отменить задачи, если одна из них возбудила
исключение. Представьте, что мы отправляем запросы одному серверу, и если хотя бы один завершится неудачно, например из-за превышения лимита на частоту запросов, то остальные постигнет та же
участь. В таком случае хотелось бы отменить запросы, чтобы освободить ресурсы, но это нелегко, потому что наши сопрограммы обернуты задачами и работают в фоновом режиме.
Второй недостаток – необходимость дождаться завершения всех
сопрограмм, прежде чем можно будет приступить к обработке результатов. Если мы хотим обрабатывать результаты по мере поступления, то возникает проблема. Например, если один запрос выполняется 100 мс, а другой 20 с, то придется ждать, ничего не делая, 20 с,
прежде чем мы сможем обработать результаты первого запроса.
Аsyncio предлагает API, позволяющие решить обе проблемы. Сначала посмотрим, как обрабатывать результаты по мере поступления.

4.5

Обработка результатов по мере
поступления
Хотя во многих случаях функция asyncio.gather нас устраивает, у нее
есть недостаток – необходимость дождаться завершения всех допускающих ожидания объектов, прежде чем станет возможен доступ
к результатам. Это проблема, если требуется обрабатывать результаты
в темпе их получения. А также в случае, когда одни объекты завершаются быстро, а другие медленно, потому что gather будет ждать завершения всех. В результате приложение может стать неотзывчивым.
Представьте, что пользователь отправил 100 запросов, из которых два

Глава 4

116

Конкурентные веб-запросы

медленные, а остальные быстрые. Было бы хорошо, если бы мы могли
что-то сообщить пользователю, как только начинают поступать ответы.
Для решения этой проблемы asyncio предлагает функцию as_completed. Она принимает список допускающих ожидание объектов
и возвращает итератор по будущим объектам. Эти объекты можно
перебирать, применяя к каждому await. Когда выражение await вернет управление, мы получим результат первой завершившейся сопрограммы. Это значит, что мы сможем обрабатывать результаты по
мере их доступности, но теперь порядок результатов не детерминирован, поскольку неизвестно, какой объект завершится первым.
Для иллюстрации смоделируем ситуацию, когда один запрос завершается быстро, а другой медленно. Добавим в функцию fetch_status
параметр delay и будем вызывать asyncio.sleep для имитации медленного запроса:
async def fetch_status(session: ClientSession,
url: str,
delay: int = 0) -> int:
await asyncio.sleep(delay)
async with session.get(url) as result:
return result.status

Затем в цикле for обойдем итератор, возвращенный функцией as_
completed.
Листинг 4.8

Использование as_completed

import asyncio
import aiohttp
from aiohttpimport ClientSession
from util import async_timed
from chapter_04 import fetch_status
@async_timed()
async def main():
async with aiohttp.ClientSession() as session:
fetchers = [fetch_status(session, 'https://www.example.com', 1),
fetch_status(session, 'https://www.example.com', 1),
fetch_status(session, 'https://www.example.com', 10)]
for finished_task in asyncio.as_completed(fetchers):
print(await finished_task)
asyncio.run(main())

Здесь мы создаем три сопрограммы – две из них завершаются примерно через 1 с, а третья через 10 с. Эти сопрограммы передаются
функции as_completed. Под капотом каждая сопрограмма обертывается задачей и начинает выполняться конкурентно. Функция немедленно возвращает итератор, который мы начинаем обходить. Войдя
в цикл for, мы сразу натыкаемся на await finished_task. Здесь выполнение приостанавливается до момента поступления первого ре-

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

117

зультата. В данном случае первый результат поступит через 1 с, и мы
напечатаем код состояния. Затем снова дойдем до await finished_
task, и, так как запросы выполняются конкурентно, второй результат
станет доступен почти мгновенно. Наконец, через 10 с завершится
третий запрос, а вместе с ним и цикл. Будут напечатаны следующие
сообщения:
выполняется
выполняется
выполняется
завершилась за
200
завершилась за
200
завершилась за
200
завершилась за 10.0353

1.1269 с
1.1294 с
10.0345 с
с

Полное время обхода result_iterator по-прежнему составляет 10 с,
как было бы при использовании asynio.gather, однако теперь результат первого запроса печатается сразу после получения. Это дает нам
дополнительное время для обработки результата первой успешно завершившейся сопрограммы, пока остальные еще выполняются, поэтому приложение оказывается более отзывчивым.
Эта функция также дает больше контроля над обработкой исключений. Если задача возбудит исключение, то мы сможем обработать ее
сразу же, поскольку оно возникает в точке, где мы ожидаем будущего
объекта с по­мощью await.

4.5.1 Тайм-ауты в сочетании с as_completed
Любой веб-запрос может занять много времени. Возможно, сервер
испытывает высокую нагрузку, а возможно, сеть медленная. Выше мы
видели, как задать тайм-аут для отдельного запроса, но что, если это
нужно сделать для группы запросов? Функция as_completed предоставляет такую возможность с по­мощью необязательного параметра
timeout, равного величине тайм-аута в секундах. Он управляет временем работы as_completed; если потребуется больше, то каждый допускающий ожидание объект в итераторе возбудит исключение TimeoutException в точке ожидания с по­мощью await.
Для иллюстрации возьмем предыдущий пример и создадим два запроса по 10 с и один запрос, занимающий 1 с. И зададим тайм-аут 2 с
при вызове as_completed. По завершении цикла напечатаем все выполняемые в данный момент задачи.
Листинг 4.9
import asyncio
import aiohttp

Задание тайм-аута для as_completed

Глава 4

118

Конкурентные веб-запросы

from aiohttp import ClientSession
from util import async_timed
from chapter_04 import fetch_status
@async_timed()
async def main():
async with aiohttp.ClientSession() as
fetchers = [fetch_status(session,
fetch_status(session,
fetch_status(session,

session:
'https://example.com', 1),
'https://example.com', 10),
'https://example.com', 10)]

for done_task in asyncio.as_completed(fetchers, timeout=2):
try:
result = await done_task
print(result)
except asyncio.TimeoutError:
print('Произошел тайм-аут!')
for task in asyncio.tasks.all_tasks():
print(task)
asyncio.run(main())

При выполнении этой программы мы увидим результат первого
вызова fetch_status, а спустя 2 с два тайм-аута. Также мы увидим,
что два вызова fetch_status еще работают:
выполняется с аргументами () {}
200
Произошел тайм-аут!
Произошел тайм-аут!
завершилась за 2.0055 с




as_completed справляется со своей задачей – возвращать результат
по мере поступления, но она не лишена недостатков. Первый заключается в том, что хотя мы и получаем результаты в темпе их поступления, но невозможно сказать, какую сопрограмму или задачу мы ждем,
поскольку порядок абсолютно не детерминирован. Если порядок нас
не волнует, то и ладно, но если требуется ассоциировать результаты
с запросами, то возникает проблема.
Второй недостаток в том, что, хотя исключения по истечении таймаута возбуждаются как положено, все созданные задачи продолжают
работать в фоновом режиме. А если мы захотим их снять, то будет
трудно понять, какие задачи еще работают. Вот вам и еще одна проблема! Если эти проблемы требуется решить, то нужно точно знать,
какие допускающие ожидание объекты уже завершились, а какие еще
нет. Поэтому asyncio предоставляет функцию wait.

Точный контроль с по­мощью wait

4.6

119

Точный контроль с по­мощью 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. Почему так, мы объясним в конце раздела, а в примерах кода будем придерживаться рекомендованной практики и обертывать все сопрограммы задачами.

4.6.1 Ожидание завершения всех задач
Этот режим подразумевается по умолчанию, если параметр return_
when не задан явно. Он ближе всего к функции asyncio.gather, хотя
несколько отличий все же есть. Как следует из названия, в этом режиме функция ждет завершения всех задач и только потом возвращает
управление. Применим ее в нашем примере конкурентного выполнения нескольких веб-запросов.
Листинг 4.10
import asyncio
import aiohttp

Изучение поведения wait по умолчанию

Глава 4

120

Конкурентные веб-запросы

from aiohttp import ClientSession
from util import async_timed
from chapter_04 import fetch_status
@async_timed()
async def main():
async with aiohttp.ClientSession() as session:
fetchers = \
[asyncio.create_task(fetch_status(session, 'https://example.com')),
asyncio.create_task(fetch_status(session, 'https://example.com'))]
done, pending = await asyncio.wait(fetchers)
print(f'Число завершившихся задач: {len(done)}')
print(f'Число ожидающих задач: {len(pending)}')
for done_task in done:
result = await done_task
print(result)
asyncio.run(main())

Здесь мы конкурентно выполняем два веб-запроса, передавая wait
список задач. Предложение await wait вернет управление, когда все
запросы завершатся, и мы получим два множества: завершившиеся
задачи и еще работающие задачи. Множество done содержит все задачи, которые завершились успешно или в результате исключения,
а множество pending – еще не завершившиеся задачи. В данном случае мы задали режим ALL_COMPLETED, поэтому множество pending будет пустым, так как asyncio.wait не вернется, пока все не завершится.
В итоге мы увидим такие сообщения:
выполняется с аргументами () {}
Число завершившихся задач: 2
Число ожидающих задач: 0
200
200
завершилась за 0.4642 с

Если в одном из запросов возникнет исключение, то asyncio.wait
не возбудит его, как asyncio.gather. В этом случае мы, как и раньше,
получим оба множества done и pending, но не увидим исключения,
пока не применим await к той задаче из done, где имела место ошибка.
Таким образом, у нас на выбор есть несколько способов обработать
исключения. Можно выполнить await и дать возможность исключению распространиться выше, можно выполнить await, обернуть его
в блок try/except, чтобы обработать исключение, или воспользоваться методами task.result() и task.exception(). Вызывать эти методы
безопасно, поскольку в множестве done гарантированно находятся завершенные задачи; иначе их вызов привел бы к исключению.
Предположим, что мы не хотим возбуждать исключение и обрушивать свое приложение. Вместо этого мы хотим напечатать результат

Точный контроль с по­мощью wait

121

задачи, если он получен, или запротоколировать ошибку в случае исключения. В таком случае вполне подойдет использование методов
объекта Task. Посмотрим, как их использовать для обработки исключений.
Листинг 4.11

Обработка исключений при использовании wait

import asyncio
import logging
@async_timed()
async def main():
async with aiohttp.ClientSession() as session:
good_request = fetch_status(session, 'https://www.example.com')
bad_request = fetch_status(session, 'python://bad')
fetchers = [asyncio.create_task(good_request),
asyncio.create_task(bad_request)]
done, pending = await asyncio.wait(fetchers)
print(f'Число завершившихся задач: {len(done)}')
print(f'Число ожидающих задач: {len(pending)}')
for done_task in done:
# result = await done_task возбудит исключение
if done_task.exception() is None:
print(done_task.result())
else:
logging.error("При выполнении запроса возникло исключение",
exc_info=done_task.exception())
asyncio.run(main())

Функция done_task.exception() проверяет, имело ли место исключение. Если нет, то можно получить результат из done_task методом result. Здесь также было бы безопасно написать result = await
done_task, хотя при этом может возникнуть исключение, чего мы,
возможно, не желаем. Если результат exception() не равен None, то
в допускающем ожидание объекте возникло исключение и его можно
обработать, как нам угодно. В данном случае просто печатаем трассу
стека в момент исключения. При выполнении этой программы мы
увидим такую картину (для краткости трасса вызовов сокращена):
выполняется с аргументами () {}
Число завершившихся задач: 2
Число ожидающих задач: 0
200
завершилась за 0.12386679649353027 с
ERROR:root:Request got an exception
Traceback (most recent call last):
AssertionError

122

Глава 4

Конкурентные веб-запросы

4.6.2 Наблюдение за исключениями
Режим ALL_COMPLETED страдает всеми теми же недостатками, что
и gather. Пока мы ждем завершения сопрограмм, может возникнуть
сколько угодно исключений, но мы их не увидим, пока все задачи не
завершатся. Это может стать проблемой, если после первого же исключения следует снять все остальные выполняющиеся запросы.
Кроме того, немедленная обработка ошибок желательна для повышения отзывчивости приложения.
Чтобы поддержать эти сценарии, wait имеет режим FIRST_EXCEPTION. В этом случае мы получаем два разных поведения в зависимости от того, возникает в какой-то задаче исключение или нет.

Ни один допускающий ожидание объект не возбудил
исключения
Если ни в одной задаче не было исключений, то этот режим эквивалентен
ALL_COMPLETED. Мы дождемся завершения всех задач, после чего множество done будет содержать все задачи, а множество pending останется
пустым.

В одной или нескольких задачах возникло исключение
Если хотя бы в одной задаче возникло исключение, то wait немедленно
возвращается. Множество done будет содержать как задачи, завершившиеся успешно, так и те, в которых имело место исключение. Гарантируется,
что done будет содержать как минимум одну задачу – завершившуюся
ошибкой, но может содержать и какие-то успешно завершившиеся задачи. Множество pending может быть пустым, а может содержать задачи, которые продолжают выполняться. Мы можем использовать его для
управления выполняемыми задачами по своему усмотрению.

Для иллюстрации поведения wait в этих случаях посмотрим, что
происходит, когда есть два долгих веб-запроса и мы хотели бы сразу
же снять другой, если при выполнении одного возникло исключение.
Листинг 4.12 Отмена работающих запросов при возникновении исключения
import aiohttp
import asyncio
import logging
from chapter_04 import fetch_status
from util import async_timed
@async_timed()
async def main():
async with aiohttp.ClientSession() as session:

Точный контроль с по­мощью wait

123

fetchers = \
[asyncio.create_task(fetch_status(session, 'python://bad.com')),
asyncio.create_task(fetch_status(session, 'https://www.example.com', delay=3)),
asyncio.create_task(fetch_status(session, 'https://www.example.com', delay=3))]
done, pending = await asyncio.wait(fetchers,
return_when=asyncio.FIRST_EXCEPTION)
print(f'Число завершившихся задач: {len(done)}')
print(f'Число ожидающих задач: {len(pending)}')
for done_task in done:
if done_task.exception() is None:
print(done_task.result())
else:
logging.error("При выполнении запроса возникло исключение",
exc_info=done_task.exception())
for pending_task in pending:
pending_task.cancel()
asyncio.run(main())

Здесь мы отправляем один плохой запрос и два хороших, по 3 с каждый. Ожидание завершения wait прекращается почти сразу, потому
что плохой запрос тут же завершается с ошибкой. Затем мы в цикле
обходим множество done. В данном случае оно будет содержать всего
одну задачу, потому что первый запрос немедленно завершился в результате исключения. Для нее мы идем по ветви, печатающей сведения об исключении.
Множество pending содержит два элемента, поскольку у нас есть два
запроса, исполняющихся примерно по 3 с, а первый запрос закончился почти сразу. Мы не хотим, чтобы оставшиеся запросы продолжали
выполняться, поэтому прерываем их методом cancel. В итоге получается такая картина:
выполняется с аргументами () {}
Число завершившихся задач: 1
Число ожидающих задач: 2
завершилась за 0.0044 с
ERROR:root:Request got an exception

ПРИМЕЧАНИЕ Приложение не заняло почти никакого времени, потому что мы быстро отреагировали на то, что один из запросов возбудил исключение; прелесть этого режима в том, что
реализуется тактика быстрого отказа, т. е. быстрой реакции на
возникающие проблемы.

4.6.3 Обработка результатов по мере завершения
У режимов ALL_COMPLETED и FIRST_EXCEPTION есть недостаток: если за-

Глава 4

124

Конкурентные веб-запросы

дачи не возбуждают исключений, то мы должны ждать, пока все они
завершатся. Иногда это приемлемо, но если мы должны отреагировать
на успешное завершение задачи немедленно, то ничего не получится.
В случае, когда требуется немедленная реакция, можно было бы
воспользоваться функцией as_completed, однако у нее есть проблема:
непонятно, какие задачи еще работают, а какие уж завершились. Получать задачи можно только по одной, от итератора.
Но есть и хорошая новость – параметр return_when может принимать значение FIRST_COMPLETED. В этом режиме wait возвращает
управление, как только получен хотя бы один результат. Это может
быть как успешно завершившаяся задача, так и задача, в которой возникло исключение. Остальные задачи можно либо снять, либо дать
им возможность продолжать работу. Продемонстрируем этот режим,
для чего отправим несколько веб-запросов и обработаем тот, что закончится первым.
Листинг 4.13

Обработка запросов по мере завершения

import asyncio
import aiohttp
from util import async_timed
from chapter_04 import fetch_status
@async_timed()
async def main():
async with aiohttp.ClientSession() as session:
url = 'https://www.example.com'
fetchers = [asyncio.create_task(fetch_status(session, url)),
asyncio.create_task(fetch_status(session, url)),
asyncio.create_task(fetch_status(session, url))]
done, pending = await asyncio.wait(fetchers,
return_when=asyncio.FIRST_COMPLETED)
print(f'Число завершившихся задач: {len(done)}')
print(f'Число ожидающих задач: {len(pending)}')
for done_task in done:
print(await done_task)
asyncio.run(main())

Здесь мы конкурентно отправляем три запроса. Сопрограмма wait
вернет управление, как только завершится любой из них. Следовательно, в done будет один завершившийся запрос, а в pending – еще
работающие, и мы увидим следующую картину:
выполняется с аргументами () {}
Число завершившихся задач: 1
Число ожидающих задач: 2
200
завершилась за 0.1138 с

Точный контроль с по­мощью wait

125

Эти запросы могут завершиться примерно в одно время, поэтому
не исключено, что в done окажется две или три задачи. Попробуйте
выполнить эту программу несколько раз и посмотрите, как будут меняться результаты.
Описанный подход позволяет реагировать, как только завершится первая задача. Но что, если мы хотим обработать и остальные результаты по мере поступления, как при использовании as_completed?
Предыдущий пример легко можно модифицировать, так чтобы задачи из множества pending обрабатывались в цикле, пока там ничего не
останется. Тогда мы получим поведение, аналогичное as_completed,
с тем дополнительным преимуществом, что на каждом шаге точно
знаем, какие задачи завершились, а какие еще работают.
Листинг 4.14 Обработка всех результатов по мере поступления
import aiohttp
from chapter_04 import fetch_status
from util import async_timed
@async_timed()
async def main():
async with aiohttp.ClientSession() as session:
url = 'https://www.example.com'
pending = [asyncio.create_task(fetch_status(session, url)),
asyncio.create_task(fetch_status(session, url)),
asyncio.create_task(fetch_status(session, url))]
while pending:
done, pending = await asyncio.wait(pending,
return_when=asyncio.FIRST_COMPLETED)
print(f'Число завершившихся задач: {len(done)}')
print(f'Число ожидающих задач: {len(pending)}')
for done_task in done:
print(await done_task)
asyncio.run(main())

Здесь мы создаем множество pending и инициализируем его задачами, которые хотим выполнить. Цикл while выполняется, пока
в pending остаются элементы, и на каждой итерации мы вызываем
wait для этого множества. Получив результат от wait, мы обновляем
множества done и pending, а затем печатаем завершившиеся задачи.
Получается поведение, похожее на as_completed, с тем отличием, что
теперь мы лучше знаем, какие задачи завершились, а какие продолжают работать. При выполнении этой программы мы увидим такую
картину:
выполняется с аргументами () {}
Число завершившихся задач: 1

126

Глава 4

Конкурентные веб-запросы

Число ожидающих задач: 2
200
Число завершившихся задач: 1
Число ожидающих задач: 1
200
Число завершившихся задач: 1
Число ожидающих задач: 0
200
завершилась за 0.1153 с

Поскольку запрос выполняется быстро, все запросы могли бы завершиться одновременно, так что вполне возможна и такая картина:
выполняется с аргументами () {}
Число завершившихся задач: 3
Число ожидающих задач: 0
200
завершилась за 0.1304 с

4.6.4 Обработка тайм-аутов
Функция wait позволяет не только точнее контролировать порядок
ожидания задач, но и задавать время, в течение которого все допус­
кающие ожидание объекты должны завершиться. Для этого нужно
задать параметр timeout, указав в нем максимальное время работы
в секундах. Между обработкой тайм-аутов wait и ранее рассмотренными wait_for и as_completed есть два отличия.

Сопрограммы не снимаются
Если при выполнении сопрограммы, запущенной с по­мощью wait_for,
случался тайм-аут, то она автоматически снималась. В случае wait это
не так: поведение ближе к gather и as_completed. Если мы хотим снять
сопрограммы из-за тайм-аута, то должны явно обойти их и снять каждую.

Исключения не возбуждаются
wait не возбуждает исключения в случае тайм-аута, в отличие от wait_
for и as_completed. Когда случается тайм-аут, wait возвращает все завершившиеся задачи, а также те, что еще не завершились в момент таймаута.

Например, рассмотрим случай, когда два запроса завершаются быстро, а третий занимает несколько секунд. Мы зададим в wait
тайм-аут 1 с, чтобы понять, что происходит, когда задачи не успевают завершиться. Параметр return_when пусть принимает значение по
умолчанию ALL_COMPLETED.

Точный контроль с по­мощью wait

Листинг 4.15

127

Использование тайм-аутов в wait

@async_timed()
async def main():
async with aiohttp.ClientSession() as session:
url = 'https://example.com'
fetchers = [asyncio.create_task(fetch_status(session, url),
asyncio.create_task(fetch_status(session, url),
asyncio.create_task(fetch_status(session, url, delay=3))]
done, pending = await asyncio.wait(fetchers, timeout=1)
print(f'Число завершившихся задач: {len(done)}')
print(f'Число ожидающих задач: {len(pending)}')
for done_task in done:
result = await done_task
print(result)
asyncio.run(main())

Здесь wait вернет множества done и pending через 1 с. В множестве
done будет два быстрых запроса, поскольку они успевают завершиться
за это время. А медленный запрос еще работает, поэтому окажется
в множестве pending. Затем мы ждем задачи из done с по­мощью await
и получаем возвращенные ими значения. При желании можно было
бы снять задачу, находящуюся в pending. При выполнении этого кода
мы увидим следующую картину:
выполняется с аргументами () {}
Число завершившихся задач: 2
Число ожидающих задач: 1
200
200
завершилась за 1.0022 с

Заметим, что, как и раньше, задачи в множестве pending не сняты
и продолжают работать, несмотря на тайм-аут. Если в конкретной ситуации требуется завершить еще выполняющиеся задачи, то следовало бы явно обойти множество pending и вызвать для каждой задачи
cancel.

4.6.5 Зачем оборачивать сопрограммы задачами?
В начале этой главы мы упомянули, что сопрограммы, передаваемые wait, рекомендуется оборачивать задачами. Почему? Вернемся
к предыдущему примеру, иллюстрирующему тайм-аут, и немного изменим его. Пусть имеется два запроса к разным API, которые назовем API A и API B. Оба могут тормозить, но наше приложение может
продолжить работу, не получив результата от API B, просто было бы
хорошо его иметь. Мы хотим, чтобы приложение было отзывчивым,

Глава 4

128

Конкурентные веб-запросы

поэтому задаем для запросов тайм-аут 1 с. Если по истечении таймаута запрос к API B еще работает, то мы отменяем его. Посмотрим, что
произойдет, если реализовать эту идею, не обертывая сопрограммы
задачами.
Листинг 4.16

Отмена медленного запроса

import asyncio
import aiohttp
from chapter_04 import fetch_status
async def main():
async with aiohttp.ClientSession() as session:
api_a = fetch_status(session, 'https://www.example.com')
api_b = fetch_status(session, 'https://www.example.com', delay=2)
done, pending = await asyncio.wait([api_a, api_b], timeout=1)
for task in pending:
if task is api_b:
print('API B слишком медленный, отмена')
task.cancel()
asyncio.run(main())

Мы ожидаем, что этот код напечатает «API B слишком медленный,
отмена», но на самом деле мы его вообще не увидим. Такое может случиться, потому что, когда wait передаются просто сопрограммы, они
автоматически обертываются задачами, а возвращенные множества
done и pending будут содержать эти задачи. Это значит, что сравнения
на предмет присутствия задачи в множестве pending, как в предложении if task is api_b, неправомерны, потому что мы сравниваем разные объекты: сопрограмму и задачу. Но если обернуть fetch_status
задачей, то новые объекты не создаются и сравнение if task is api_b
будет работать, как мы и ожидаем.

Резюме
Мы научились использовать и создавать асинхронные контекстные
менеджеры. Это специальные классы, которые позволяют асинхронно захватывать ресурсы, а затем освобождать их даже при наличии исключения. Их задача – очистить захваченные ресурсы без
лишнего кода, особенно они полезны при работе с HTTP-сеансами
и подключениями к базе данных. Для использования асинхронных контекстных менеджеров предназначена синтаксическая конструкция async with.
„„ Мы можем использовать библиотеку aiohttp для отправки асинхронных веб-запросов. Эта библиотека позволяет создавать клиен„„

Резюме

129

ты и серверы с неблокирующими сокетами. В случае веб-клиента
мы можем конкурентно выполнять несколько запросов, не блокируя цикл событий.
„„ Функция asyncio.gather позволяет конкурентно запустить несколько сопрограмм и ждать их завершения. Она возвращает
управление, когда завершатся все переданные ей объекты, допускающие ожидание. Если нужно отслеживать возникающие ошибки, то можно задать параметр return_exeptions равным True. Тогда
будут возвращаться как результаты успешно завершившихся объектов, так и возникшие исключения.
„„ Функция as_completed позволяет обрабатывать результаты списка
допускающих ожидание объектов по мере их завершения. Она возвращает итератор по будущим объектам, который можно обойти.
Как только сопрограмма или задача завершается, итератор отдает
ее результат.
„„ Если мы хотим выполнить несколько задач конкурентно, но при
этом понимать, какие задачи уже завершились, а какие еще работают, то можем использовать функцию wait. Она также дает более
точный контроль над моментом возврата результатов. После возврата мы получаем множество завершившихся задач и множество
еще работающих. Затем можем отменить какие-то задачи или снова ждать их завершения.

5

Неблокирующие драйверы
баз данных

Краткое содержание главы
Выполнение совместимых с asyncio запросов к базе данных
с по­мощью asyncpg.
„„ Создание пула подключений к базе данных для
конкурентного выполнения нескольких SQL-запросов.
„„ Управление асинхронными транзакциями базы данных.
„„ Использование асинхронных генераторов для потоковой
обработки результатов запроса.
„„

В главе 4 мы изучали отправку неблокирующих веб-запросов с по­
мощью библиотеки aiohttp, а заодно рассмотрели несколько функций
asyncio API для конкурентного выполнения этих запросов. Сочетание
asyncio API с библиотекой aiohttp позволяет конкурентно выполнять
долгие веб-запросы и тем самым повысить производительность приложения. Рассмотренные в главе 4 идеи применимы не только к вебзапросам, а также к SQL-запросам, что позволяет писать более быстрые приложения для работы с базой данных.
Как и в случае веб-запросов, нам понадобится совместимая с asyncio библиотека, потому что большинство стандартных библиотек для
работы с SQL блокируют главный поток, а значит, и цикл событий до
получения результата. В этой главе мы подробнее поговорим об асинхронном доступе к базам данных на примере библиотеки asyncpg.

Подключение к базе данных Postgres

131

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

5.1

Введение в asyncpg
Выше мы уже говорили, что существующие блокирующие библиотеки
плохо сочетаются с сопрограммами. Чтобы конкурентно выполнять
запросы к базе данных, нам необходима совместимая с asyncio биб­
лиотека, в которой используются неблокирующие сокеты. Примером
такой библиотеки является asyncpg, позволяющая асинхронно подключаться к базе данных Postgres и предъявлять к ней запросы.
В этой главе мы будем говорить только о СУБД Postgres, но все изложенное применимо также к MySQL и другим базам данных. Авторы
aiohttp создали также библиотеку aiomysql, которая умеет подключаться к СУБД MySQL и выполнять в ней запросы.
Несмотря на некоторые различия, API похожи и знания, полученные для одной, переносятся на другую. Отметим, что библиотека
asyncpg не реализует спецификации API работы с базами данных для
Python, описанные в документе PEP-249 (https://www.python.org/dev/
peps/pep-0249). Это осознанный выбор со стороны авторов библиотеки, поскольку асинхронная реализация принципиально отлична
от синхронной. Однако создатели aiomysql пошли по другому пути
и все-таки реализовали PEP-249, так что эта библиотека покажется
более привычной тем, кто работал с синхронными драйверами баз
данных в Python.
Текущая документация по asynpg доступна по адресу https://magicstack.github.io/asyncpg/current/. Определившись с драйвером, попробуем подключиться к базе данных.

5.2

Подключение к базе данных Postgres
Для работы с asyncpg мы возьмем реалистичный пример базы данных
с информацией о товарах для интернет-магазина. На этом сквозном
примере мы продемонстрируем проблемы, которые необходимо решить.
Прежде чем создавать базу данных и выполнять запросы, необходимо подключиться к базе данных. В этой главе мы будем предполагать, что на локальной машине установлена СУБД Postgres, которая
прослушивает порт по умолчанию 5432, и что пользователь по умолчанию postgres имеет пароль 'password'.

132

Глава 5

Неблокирующие драйверы баз данных

ПРЕДУПРЕЖ ДЕНИЕ Мы зашиваем пароль в код примеров
только для простоты демонстрации. В реальном коде паролей не должно быть никогда, потому что это нарушает базовые
принципы безопасности. Всегда храните пароли в переменных
окружения или пользуйтесь каким-то другим механизмом конфигурирования.
Скачать и установить Postgres можно со страницы https://www.
postgresql.org/download/; выберите только подходящую операционную систему. Можно также остановиться на Docker-образе Postgres;
дополнительные сведения имеются по адресу https://hub.docker.
com/_/postgres/.
После базы данных нужно установить библиотеку asyncpg. Для этого мы воспользуемся программой pip3 и установим последнюю на
момент написания книги версию 0.0.23:
pip3 install -Iv asyncpg==0.23.0

Затем библиотеку нужно импортировать и подключиться к базе данных. Для этой цели asyncpg предлагает функцию asyncpg.connect. Воспользуемся ей, чтобы подключиться к базе, и напечатаем номер версии:
Листинг 5.1 Подключение к базе данных Postgres от имени
пользователя по умолчанию
import asyncpg
import asyncio
async def main():
connection = await asyncpg.connect(host='127.0.0.1',
port=5432,
user='postgres',
database='postgres',
password='password')
version = connection.get_server_version()
print(f'Подключено! Версия Postgres равна {version}')
await connection.close()
asyncio.run(main())

Здесь мы создаем подключение к базе данных postgres от имени
пользователя по умолчанию postgres. В предположении, что экземпляр Postgres работает, мы должны увидеть на консоли сообщение
вида «Подключено! Версия Postgres равна ServerVersion(major=12,
minor=0, micro=3, releaselevel=’final’ serial=0)», означающее, что мы
успешно подключились к базе данных. И в конце закрываем подключение в предложении await connection.close().
Мы подключились, но пока в нашей базе ничего нет. Следующий
шаг – создать схему, с которой можно будет работать. Попутно мы узнаем, как выполнять простые запросы с по­мощью asyncpg.

Определение схемы базы данных

5.3

133

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

Марка
Под маркой (brand) понимается производитель многих различных товаров. Например, Ford – марка, под которой производятся различные модели автомобилей (Ford F150, Ford Fiesta и т. д.).

Товар
Товар (product) ассоциирован с одной маркой, существует связь один-комногим между марками и товарами. Для простоты в нашей базе данных
у товара будет только название. В примере с Ford товаром будет компактный автомобиль Fiesta; его марка Ford. Кроме того, с каждым товаром
может быть связано несколько размеров и цветов. Совокупность размера
и цвета мы определим как SKU.

SKU
SKU расшифровывается как stock keeping unit (складская единица хранения). SKU представляет конкретный предмет, выставленный на продажу.
Например, для товара джинсы SKU может иметь вид: джинсы, размер: M,
цвет: синий или джинсы, размер: S, цвет: черный. Существует связь одинко-многим между товаром и SKU.

Размер товара
Товар может иметь несколько размеров (product size). В этом примере
будем предполагать, что всего есть три размера: малый (S), средний (M)
и большой (L). С каждым SKU ассоциирован один размер, поэтому существует связь один-ко-многим между размером товара и SKU.

Цвет товара
Товар может иметь несколько цветов (product color). В этом примере
будем предполагать, что цветов всего два: черный и синий. Существует
связь один-ко-многим между цветом товара и SKU.

Глава 5

134

Неблокирующие драйверы баз данных

В итоге получается модель базы данных, изображенная на рис. 5.1.
brand
brand_id INT
brand_name TEXT
Indexes
product_size
product_size_id INT
product
product_id INT
product_name TEXT
brand_id INT
Indexes

sku
sku_id INT

product_size_name TEXT
Indexes

product_id INT
product_size_id INT
product_color_id INT
Indexes

product_color
product_color_id INT
product_color_name TEXT
Indexes

Рис. 5.1 Диаграмма сущность–связь для базы данных о товарах

Теперь определим переменные, нужные для создания этой схемы. С по­мощью asyncpg мы выполним соответствующие команды
для создания базы данных о товарах. Поскольку размеры и цвета известны заранее, вставим несколько записей в таблицы product_size
и product_color. Мы будем ссылаться на эти переменные в последующих листингах, чтобы не повторять длинные команды SQL.
Листинг 5.2 Команды создания таблиц в схеме базы данных
о товарах
CREATE_BRAND_TABLE = \
"""
CREATE TABLE IF NOT EXISTS brand(
brand_id SERIAL PRIMARY KEY,
brand_name TEXT NOT NULL
);"""
CREATE_PRODUCT_TABLE = \
"""
CREATE TABLE IF NOT EXISTS product(
product_id SERIAL PRIMARY KEY,
product_name TEXT NOT NULL,
brand_id INT NOT NULL,
FOREIGN KEY (brand_id) REFERENCES brand(brand_id)
);"""
CREATE_PRODUCT_COLOR_TABLE = \
"""
CREATE TABLE IF NOT EXISTS product_color(
product_color_id SERIAL PRIMARY KEY,

Выполнение запросов с помощью asyncpg

135

product_color_name TEXT NOT NULL
);"""
CREATE_PRODUCT_SIZE_TABLE = \
"""
CREATE TABLE IF NOT EXISTS product_size(
product_size_id SERIAL PRIMARY KEY,
product_size_name TEXT NOT NULL
);"""
CREATE_SKU_TABLE = \
"""
CREATE TABLE IF NOT EXISTS sku(
sku_id SERIAL PRIMARY KEY,
product_id INT NOT NULL,
product_size_id INT NOT NULL,
product_color_id INT NOT NULL,
FOREIGN KEY (product_id)
REFERENCES product(product_id),
FOREIGN KEY (product_size_id)
REFERENCES product_size(product_size_id),
FOREIGN KEY (product_color_id)
REFERENCES product_color(product_color_id)
);"""
COLOR_INSERT = \
"""
INSERT INTO product_color VALUES(1, 'Blue');
INSERT INTO product_color VALUES(2, 'Black');
"""
SIZE_INSERT = \
"""
INSERT INTO product_size VALUES(1, 'Small');
INSERT INTO product_size VALUES(2, 'Medium');
INSERT INTO product_size VALUES(3, 'Large');
"""

Итак, команды создания таблиц и вставки записей о размерах
и цветах у нас есть, теперь нужно как-то выполнить соответствующие
запросы.

5.4

Выполнение запросов с помощью asyncpg
Чтобы выполнить запрос к базе данных, нужно сначала подключиться
к экземпляру Postgres и создать базу данных вне Python. Для создания
базы достаточно одной SQL-команды (после подключения от имени
пользователя по умолчанию postgres):
CREATE DATABASE products;

136

Глава 5

Неблокирующие драйверы баз данных

Для этого выполните в командной строке команду sudo -u postgres
psql -c "CREATE TABLE products;". В следующих примерах предполагается, что она уже выполнена и мы можем подключаться к базе данных
products непосредственно.
Создав базу данных, подключимся к ней и выполним команды create. В классе connection имеется сопрограмма execute, которая позволяет выполнить команды создания одну за другой. Эта сопрограмма
возвращает полученную от Postgres строку, представляющую состояние запроса. Давайте выполним показанные в предыдущем разделе
команды.
Листинг 5.3 Использование сопрограммы execute для выполнения
команд create
import asyncio
async def main():
connection = await asyncpg.connect(host='127.0.0.1',
port=5432,
user='postgres',
database='products',
password='password')
statements = [CREATE_BRAND_TABLE,
CREATE_PRODUCT_TABLE,
CREATE_PRODUCT_COLOR_TABLE,
CREATE_PRODUCT_SIZE_TABLE,
CREATE_SKU_TABLE,
SIZE_INSERT,
COLOR_INSERT]
print('Создается база данных product...')
for statement in statements:
status = await connection.execute(statement)
print(status)
print('База данных product создана!')
await connection.close()
asyncio.run(main())

Сначала создается подключение к базе данных products так же, как
в нашем первом примере, с той разницей, что теперь мы подключаемся к другой базе. Затем выполняем команды CREATE TABLE по одной
с по­мощью функции connection.execute(). Отметим, что execute() –
сопрограмма, поэтому завершения SQL-команд следует ожидать
с по­мощью await. Если все работает правильно, то состоянием каждой
команды create должна быть строка CREATE TABLE, а каждой команды insert – строка INSERT 0 1. В конце мы закрываем подключение
к базе данных. В этом примере мы ожидаем завершения каждой SQLкоманды с по­мощью await в цикле for, поэтому команды INSERT будут
выполнены синхронно. Поскольку одни таблицы зависят от других,
мы не можем выполнять эти команды конкурентно.

Выполнение запросов с помощью asyncpg

137

С этими командами не связаны результаты, поэтому давайте вста­
вим несколько записей и выполним простые запросы. Сначала вставим несколько марок, а затем выполним запрос и убедимся, что
вставка прошла нормально. Для вставки данных можно использовать
все ту же сопрограмму execute, а для выборки – сопрограмму fetch.
Листинг 5.4

Вставка и выборка марок

import asyncpg
import asyncio
from asyncpg import Record
from typing import List
async def main():
connection = await asyncpg.connect(host='127.0.0.1',
port=5432,
user='postgres',
database='products',
password='password')
await connection.execute("INSERT INTO brand VALUES(DEFAULT, 'Levis')")
await connection.execute("INSERT INTO brand VALUES(DEFAULT, 'Seven')")
brand_query = 'SELECT brand_id, brand_name FROM brand'
results: List[Record] = await connection.fetch(brand_query)
for brand in results:
print(f'id: {brand["brand_id"]}, name: {brand["brand_name"]}')
await connection.close()
asyncio.run(main())

Сначала вставим в таблицу brand две марки. Затем с по­мощью вызова connection.fetch выберем все марки из таблицы brand. По завершении запроса результаты будут находиться в памяти, в переменной
results. Каждый результат представлен объектом asyncpg Record. Эти
объекты похожи на словари: они позволяют обращаться к данным,
передавая имя столбца в качестве индекса. При выполнении показанной выше программы мы увидим такие строки:
id: 1, name: Levis
id: 2, name: Seven

В этом примере все возвращенные запросом данные помещены
в список. Если бы мы хотели выбрать одну запись, то вызвали бы
функцию connection.fetchrow(). По умолчанию все результаты запроса загружаются в память, поэтому пока нет разницы в производительности между fetchrow и fetch. Ниже в этой главе мы узнаем,
как обрабатывать результирующие наборы потоком с по­мощью курсоров. Тогда в память загружается только небольшое подмножество
результатов; это полезно, если запрос может возвращать очень много
данных.

138

Глава 5

Неблокирующие драйверы баз данных

В этом примере запросы выполняются один за другим, поэтому
синхронный драйвер базы данных показал бы такую же производительность. Но поскольку теперь мы возвращаем сопрограммы, ничто
не мешает использовать изученные в главе 4 методы asyncio API для
конкурентного выполнения запросов.

5.5

Конкурентное выполнение запросов
с помощью пулов подключений
По-настоящему преимущества asyncio для операций ввода-вывода
проявляются, когда несколько задач выполняется конкурентно. Не
зависящие друг от друга запросы, которые повторяются многократно, – хорошие примеры применения конкурентности для повышения
производительности приложения. Для демонстрации предположим,
что мы управляем успешным интернет-магазином. Наша компания
продает 100 000 SKU от 1000 различных марок.
Предположим также, что мы продаем товары через партнеров,
которые запрашивают сразу тысячи товаров, пользуясь организованным нами пакетным процессом. Запускать все запросы последовательно было бы медленно, поэтому хотелось бы создать приложение, которое будет выполнять их конкурентно, повысив тем самым
скорость. Поскольку это всего лишь пример и 100 000 SKU у нас нет,
начнем с создания записей о фиктивных товарах и SKU в базе данных.
Случайным образом сгенерируем 100 000 SKU для случайных марок
и товаров, а затем используем этот набор данных как основу для запросов.

5.5.1 Вставка случайных SKU в базу данных о товарах
Поскольку мы не хотим вводить марки, товары и SKU самостоятельно, сгенерируем их случайным образом. Выберем случайные названия из списка 1000 самых часто встречающихся английских слов. Эти
слова находятся в текстовом файле common_words.txt, который можно
скачать из репозитория книги на GitHub по адресу https://github.com/
concurrency-in-python-withasyncio/data.
Первым делом вставим марки, потому что в таблице товаров
brand_id является внешним ключом. Воспользуемся для этой цели
сопрограммой connection.executemany, которая выполняет параметризованную SQL-команду и позволит нам написать один запрос
и передать ему список вставляемых записей в виде параметров, а не
создавать по одной команде INSERT для каждой марки.
Сопрограмма executemany принимает одну SQL-команду и список
кортежей, содержащих вставляемые значения. Параметры представлены маркерами $1, $2 ... $N. Число после знака доллара равно индексу
элемента кортежа. Например, если имеется запрос "INSERT INTO table

Конкурентное выполнение запросов с помощью пулов подключений

139

VALUES($1, $2)" и список кортежей [('a', 'b'), ('c', 'd')], то будут
выполнены две команды вставки:
INSERT INTO table ('a', 'b')
INSERT INTO table ('c', 'd')

Сначала сгенерируем список 100 случайных марок из списка час­
тых слов и вернем его в виде списка кортежей по одному значению
в каждом, чтобы его можно было сразу передать сопрограмме executemany и таким образом выполнить команды INSERT.
Листинг 5.5

Вставка случайных марок

import asyncpg
import asyncio
from typing import List, Tuple, Union
from random import sample
def load_common_words() -> List[str]:
with open('common_words.txt') as common_words:
return common_words.readlines()
def generate_brand_names(words: List[str]) -> List[Tuple[Union[str, ]]]:
return [(words[index],) for index in sample(range(100), 100)]
async def insert_brands(common_words, connection) -> int:
brands = generate_brand_names(common_words)
insert_brands = "INSERT INTO brand VALUES(DEFAULT, $1)"
return await connection.executemany(insert_brands, brands)
async def main():
common_words = load_common_words()
connection = await asyncpg.connect(host='127.0.0.1',
port=5432,
user='postgres',
database='products',
password='password')
await insert_brands(common_words, connection)
asyncio.run(main())

За кулисами executemany в цикле обходит список марок и генерирует по одной команде INSERT для каждой марки. Затем она выполняет
сразу все эти команды. Заодно этот метод параметризации предохраняет нас от атак внедрением SQL, поскольку входные данные надлежащим образом экранируются. По завершении мы будем иметь в системе 100 марок со случайными названиями.
Разобравшись со вставкой случайных марок, воспользуемся той же
техникой для вставки товаров и SKU. Для товаров создадим описание,
составленное из 10 случайных слов и случайного идентификатора
марки. Для SKU случайно выберем размер, цвет и товар. Будем предполагать, что идентификаторы марок принимают значения от 1 до 100.

140

Глава 5

Листинг 5.6

Неблокирующие драйверы баз данных

Вставка случайных товаров и SKU

import asyncio
import asyncpg
from random import randint, sample
from typing import List, Tuple
from chapter_05.listing_5_5 import load_common_words
def gen_products(common_words: List[str],
brand_id_start: int,
brand_id_end: int,
products_to_create: int) -> List[Tuple[str, int]]:
products = []
for _ in range(products_to_create):
description = [common_words[index] for index in sample(range(1000), 10)]
brand_id = randint(brand_id_start, brand_id_end)
products.append((" ".join(description), brand_id))
return products
def gen_skus(product_id_start: int,
product_id_end: int,
skus_to_create: int) -> List[Tuple[int, int, int]]:
skus = []
for _ in range(skus_to_create):
product_id = randint(product_id_start, product_id_end)
size_id = randint(1, 3)
color_id = randint(1, 2)
skus.append((product_id, size_id, color_id))
return skus
async def main():
common_words = load_common_words()
connection = await asyncpg.connect(host='127.0.0.1',
port=5432,
user='postgres',
database='products',
password='password')
product_tuples = gen_products(common_words,
brand_id_start=1,
brand_id_end=100,
products_to_create=1000)
await connection.executemany("INSERT INTO product VALUES(DEFAULT, $1, $2)",
product_tuples)
sku_tuples = gen_skus(product_id_start=1,
product_id_end=1000,
skus_to_create=100000)
await connection.executemany("INSERT INTO sku VALUES(DEFAULT, $1, $2, $3)",
sku_tuples)
await connection.close()
asyncio.run(main())

Конкурентное выполнение запросов с помощью пулов подключений

141

В результате выполнения этой программы мы получим базу данных, содержащую 1000 товаров и 100 000 SKU. Вся процедура может
занять несколько секунд, точное время зависит от машины. Теперь,
написав запрос с несколькими соединениями, мы сможем запросить
все имеющиеся SKU для конкретного товара. Для product id 100 этот
запрос выглядит так:
product_query = \
"""
SELECT
p.product_id,
p.product_name,
p.brand_id,
s.sku_id,
pc.product_color_name,
ps.product_size_name
FROM product as p
JOIN sku as s on s.product_id = p.product_id
JOIN product_color as pc on pc.product_color_id = s.product_color_id
JOIN product_size as ps on ps.product_size_id = s.product_size_id
WHERE p.product_id = 100"""

В результате выполнения этого запроса мы получим по одной строке для каждого SKU, связанного с товаром, а также английские названия цвета и размера вместо идентификаторов. В предположении,
что вкаждый момент времени запрашивается информация о многих
товарах, этот запрос является прекрасным кандидатом для конкурентного выполнения. Можно наивно применить функцию asyncio.
gather, указав одно подключение:
async def main():
connection = await asyncpg.connect(host='127.0.0.1',
port=5432,
user='postgres',
database='products',
password='password')
print('Creating the product database...')
queries = [connection.execute(product_query),
connection.execute(product_query)]
results = await asyncio.gather(*queries)

Но при попытке выполнить это мы получим сообщение об ошибке:
RuntimeError: readexactly() called while another coroutine is already waiting
for incoming data

Что это значит? В SQL одному подключению к базе соответствует
один сокет. Поскольку подключение всего одно, а мы пытаемся прочитать одновременно результаты нескольких запросов, естественно,
возникает ошибка. Проблему можно решить, создав несколько подключений к базе, по одному на каждый запрос. Но создание подклю-

142

Глава 5

Неблокирующие драйверы баз данных

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

5.5.2 Создание пула подключений для конкурентного
выполнения запросов
Поскольку в каждый момент времени на одном подключении можно
выполнять только один запрос, нам необходим механизм для создания нескольких подключений и управления ими. Именно таким механизмом и является пул подключений. Мы можем рассматривать его
как кеш существующих подключений к экземпляру сервера базы данных. В пуле находится конечное число подключений, которые можно
захватывать по мере необходимости.
Захват подключения означает, что мы спрашиваем у пула: «У тебя
есть сейчас свободные подключения? Если да, дай мне одно, чтобы
я мог выполнить запрос». Пулы обеспечивают повторное использование подключений. Иными словами, если мы захватываем подключение из пула для выполнения запроса, то после завершения запроса
подключение «освобождается», т. е. возвращается в пул. Это важно,
потому что создание подключения к базе данных – процесс небыстрый. Если бы нужно было создавать подключения для каждого запроса, то производительность приложения резко снизилась бы.
Поскольку число подключений в пуле конечно, возможно, придется
немного подождать освобождения подключения, если все они заняты. Поэтому операция захвата подключения может занять некоторое
время. Если в пуле всего 10 подключений и все они используются, то
придется подождать, пока одно освободится для выполнения нашего
запроса.
Для иллюстрации того, как это работает в терминах asyncio, допустим, что имеется пул с двумя подключениями. Допустим также,
что имеется три сопрограммы, каждая из которых выполняет запрос.
Будем запускать их конкурентно, как задачи. При таких настройках
пула первые две сопрограммы успешно захватят оба имеющихся
подключения и начнут выполнять свои запросы. А третья сопрограмма в это время будет ждать освобождения подключения. Когда какая-нибудь из первых двух сопрограмм закончит выполнять запрос,
она освободит свое подключение и вернет его в пул. В этот момент
третья сопрограмма захватит подключение и начнет выполнять запрос (рис. 5.2).
В этой модели одновременно может выполняться не более двух
запросов. Обычно пул подключений делают гораздо больше, чтобы
увеличить степень конкурентности. В наших примерах пул будет содержать 6 подключений, но вообще их число зависит от оборудования, на котором работают база данных и приложение. Следует провести тестирование производительности и найти оптимальный размер

143

Конкурентное выполнение запросов с помощью пулов подключений

пула. Помните, что больше не всегда лучше, но это отдельная и очень
обширная тема.
Пул подключений

Сопрограмма 1

Захватить
подключение

Подключение 1

База данных
Сопрограмма 2

Сопрограмма 3

Захватить
подключение

Подключение 2

Ждать свободного подключения

Рис. 5.2 Сопрограммы 1 и 2 захватывают подключения, чтобы выполнить свои запросы,
а сопрограмма 3 в это время ждет подключения. Как только сопрограмма 1 или 2
завершится, сопрограмма 3 сможет воспользоваться освободившимся подключением
и выполнить свой запрос

Итак, вы теперь понимаете, как работает пул подключений. Но как
создать его в asyncpg? Для этой цели asyncpg предлагает сопрограмму create_pool. Мы воспользуемся ей вместо функции connect, которую раньше использовали для подключения к базе данных. При вызове create_pool будем задавать желаемое количество подключений
в пуле с по­мощью параметров min_size и max_size. Параметр min_size
определяет минимальное число подключений, т. е. гарантируется, что
столько подключений в пуле всегда будет. Параметр max_size определяет максимальное число подключений. Если подключений недостаточно, то пул попытается создать еще одно при условии, что число
подключений не превысит max_size. В первом примере мы зададим
оба значения равными 6, т. е. пул будет гарантированно содержать
шесть подключений.
Пулы asyncpg являются асинхронными контекстными менеджерами, т. е. для создания пула нужно использовать конструкцию async
with. После того как пул создан, мы можем захватывать соединения
с по­мощью сопрограммы acquire. Она приостанавливается, до тех
пор пока в пуле не появится свободное подключение. Затем это подключение можно использовать для выполнения SQL-запроса. Захват
соединения также является асинхронным контекстным менеджером, который возвращает соединение в пул после использования,
так что и в этом случае нужна конструкция async with. Теперь можно
переписать код, так чтобы несколько запросов выполнялось конкурентно.

144

Глава 5

Неблокирующие драйверы баз данных

Листинг 5.7 Создание пула подключений и конкурентное
выполнение запросов
import asyncio
import asyncpg
product_query = \
"""
SELECT
p.product_id,
p.product_name,
p.brand_id,
s.sku_id,
pc.product_color_name,
ps.product_size_name
FROM product as p
JOIN sku as s on s.product_id = p.product_id
JOIN product_color as pc on pc.product_color_id = s.product_color_id
JOIN product_size as ps on ps.product_size_id = s.product_size_id
WHERE p.product_id = 100"""
async def query_product(pool):
async with pool.acquire() as connection:
return await connection.fetchrow(product_query)
async def main():
async with asyncpg.create_pool(host='127.0.0.1',
port=5432,
user='postgres',
password='password',
database='products',
min_size=6,
max_size=6) as pool:
await asyncio.gather(query_product(pool),
query_product(pool))
asyncio.run(main())

Создать пул
с шестью
подключениями

Конкурентно выполнить
два запроса

Здесь мы сначала создаем пул с шестью подключениями. Затем создается два объекта сопрограмм выполнения запросов, конкурентная
работа которых планируется с по­мощью asyncio.gather. Сопрограмма query_product сначала захватывает подключение из пула, вызывая
метод pool.acquire(). Затем она приостанавливается, до тех пор пока
не освободится подключение. Это делается в блоке async with; тем самым гарантируется, что по выходе из блока подключение будет возвращено в пул. Это важно, потому что в противном случае подключения быстро закончились бы и приложение зависло бы в ожидании
подключения, которого никогда не получит. Захватив подключение,
мы можем выполнить запрос, как в предыдущих примерах.
Можно увеличить количество запросов в этом примере до 10 000,
создав 10 000 объектов сопрограмм. Чтобы было интереснее, на­

Конкурентное выполнение запросов с помощью пулов подключений

145

пишем версию, которая выполняет эти запросы синхронно, и сравним время.
Листинг 5.8

Синхронное и конкурентное выполнение запросов

import asyncio
import asyncpg
from util import async_timed
product_query = \
"""
SELECT
p.product_id,
p.product_name,
p.brand_id,
s.sku_id,
pc.product_color_name,
ps.product_size_name
FROM product as p
JOIN sku as s on s.product_id = p.product_id
JOIN product_color as pc on pc.product_color_id = s.product_color_id
JOIN product_size as ps on ps.product_size_id = s.product_size_id
WHERE p.product_id = 100"""
async def query_product(pool):
async with pool.acquire() as connection:
return await connection.fetchrow(product_query)
@async_timed()
async def query_products_synchronously(pool, queries):
return [await query_product(pool) for _ in range(queries)]
@async_timed()
async def query_products_concurrently(pool, queries):
queries = [query_product(pool) for _ in range(queries)]
return await asyncio.gather(*queries)
async def main():
async with asyncpg.create_pool(host='127.0.0.1',
port=5432,
user='postgres',
password='password',
database='products',
min_size=6,
max_size=6) as pool:
await query_products_synchronously(pool, 10000)
await query_products_concurrently(pool, 10000)
asyncio.run(main())

В сопрограмме query_products_synchronously мы поместили await
в списковое включение, благодаря чему все вызовы query_product
выполняются последовательно. А в сопрограмме query_products_con-

146

Глава 5

Неблокирующие драйверы баз данных

currently создается список сопрограмм, подлежащих выполнению,
после чего все они запускаются конкурентно с по­мощью gather. В сопрограмме main мы выполняем синхронную и конкурентную версии
с 10 000 запросами. Точные результаты могут варьироваться в зависимости от оборудования, но в общем конкурентная версия примерно
в пять раз быстрее последовательной:
выполняется с аргументами
(, 10000) {}
завершилась за 21.8274 с
выполняется с аргументами
(, 10000) {}
завершилась за 4.8464 с

Это значительное улучшение, но можно добиться большего. Поскольку наш запрос относительно быстрый, этот код является смесью
счетного и зависящего от производительности ввода-вывода. В главе 6 мы увидим, как в этой ситуации выжать дополнительную производительность.
До сих пор мы рассматривали вставку данных в базу при условии
отсутствия ошибок. Но что, если на середине процесса вставки товаров произойдет ошибка? Мы не хотим получить несогласованное
состояния базы данных, поэтому самое время поговорить о транзакциях. Далее мы увидим, как использовать асинхронные контекстные
менеджеры, чтобы начать транзакцию и управлять ей.

5.6

Управление транзакциями в asyncpg
Транзакции – ключевая концепция во многих базах данных, обладающих свойствами ACID (атомарность, согласованность, изолированность, долговечность). Транзакция включает одну или несколько SQLкоманд, выполняемых как неделимое целое. Если при выполнении
этих команд не возникло ошибки, то транзакция фиксируется в базе
данных, так что изменения становятся постоянными. Если же ошибки были, то транзакция откатывается и база данных выглядит так,
будто ни одна из команд не выполнялась. В случае нашей базы данных о товарах необходимость в откате набора обновлений может возникнуть, если мы попытаемся вставить дубликат марки или нарушим
установленное ограничение целостности.
В asyncpg для работы с транзакциями проще всего воспользоваться
асинхронным контекстным менеджером connection.transaction, который начинает транзакцию, а затем, если в блоке async with произошло исключение, автоматически откатывает ее. Если же все команды
выполнены успешно, то транзакция автоматически фиксируется. Рассмотрим, как создать транзакцию и выполнить две простые команды
insert для добавления двух марок.

Управление транзакциями в asyncpg

Листинг 5.9

147

Создание транзакции

import asyncio
import asyncpg
async def main():
connection = await asyncpg.connect(host='127.0.0.1',
port=5432,
user='postgres',
database='products',
Начать транзакцию
password='password')
базы данных
async with connection.transaction():
await connection.execute("INSERT INTO brand "
"VALUES(DEFAULT, 'brand_1')")
await connection.execute("INSERT INTO brand "
"VALUES(DEFAULT, 'brand_2')")
query = """SELECT brand_name FROM brand
WHERE brand_name LIKE 'brand%'"""
brands = await connection.fetch(query)
Выбрать марки и убедиться,
print(brands)
что транзакция была зафиксирована
await connection.close()
asyncio.run(main())

В предположении, что транзакция успешно зафиксирована, мы должны увидеть на консоли сообщение [,
]. Для демонстрации того, что происходит при откате, сделаем в SQL-коде ошибку, а именно попытаемся вставить две марки с одинаковым первичным ключом id. Первая
коман­да вставки выполнится успешно, но вторая возбудит исключение из-за дубликата ключа.
Листинг 5.10

Обработка ошибки в транзакции

import asyncio
import logging
import asyncpg
async def main():
connection = await asyncpg.connect(host='127.0.0.1',
port=5432,
Команда insert завершится
user='postgres',
неудачно из-за дубликата
database='products',
первичного ключа
password='password')
try:
async with connection.transaction():
insert_brand = "INSERT INTO brand VALUES(9999, 'big_brand')"
await connection.execute(insert_brand)
await connection.execute(insert_brand)
except Exception:

Глава 5

148

Неблокирующие драйверы баз данных

logging.exception('Ошибка при выполнении транзакции')
finally:
Если было исключение,
query = """SELECT brand_name FROM brand
протоколировать ошибку
WHERE brand_name LIKE 'big_%'"""
brands = await connection.fetch(query)
Выбрать марки и убедиться,
print(f'Результат запроса: {brands}')
что ничего не вставлено
await connection.close()
asyncio.run(main())

Вторая команда возбудила исключение, и вот что мы увидим:
ERROR:root:Ошибка при выполнении транзакции
Traceback (most recent call last):
File "listing_5_10.py", line 16, in main
await connection.execute("INSERT INTO brand "
File "asyncpg/connection.py", line 272, in execute
return await self._protocol.query(query, timeout)
File "asyncpg/protocol/protocol.pyx", line 316, in query
asyncpg.exceptions.UniqueViolationError: duplicate key value violates unique
constraint "brand_pkey"
DETAIL: Key (brand_id)=(9999) already exists.
Query result was: []

Сначала возникло исключение, потому что мы пытались вставить
дубликат ключа, а затем мы видим, что результат команды select
пуст, т. е. мы успешно откатили транзакцию.

5.6.1 Вложенные транзакции
Аsyncpg поддерживает также вложенные транзакции благодаря имеющемуся в Postgres механизму точек сохранения, которые определяются командой SAVEPOINT. Если определена точка сохранения, то мы
можем откатиться к ней, т. е. все запросы, выполненные после точки
сохранения, откатываются, а те, что были до нее, – нет.
В asyncpg для создания точки сохранения вызывается контекстный
менеджер connection.transaction, которому передается существующая транзакция. Затем если во внутренней транзакции произошла
ошибка, то она откатывается, но внешняя транзакция при этом не затрагивается. Посмотрим, как это работает, для чего вставим внутри
транзакции марку, а затем во вложенной транзакции попытаемся
вставить уже существующий в базе цвет.
Листинг 5.11 Вложенная транзакция
import asyncio
import asyncpg
import logging
async def main():

Управление транзакциями в asyncpg

149

connection = await asyncpg.connect(host='127.0.0.1',
port=5432,
user='postgres',
database='products',
password='password')
async with connection.transaction():
await connection.execute("INSERT INTO brand VALUES(DEFAULT, 'my_new_brand')")
try:
async with connection.transaction():
await connection.execute("INSERT INTO product_color VALUES(1, 'black')")
except Exception as ex:
logging.warning('Ошибка при вставке цвета товара игнорируется', exc_info=ex)
await connection.close()
asyncio.run(main())

Здесь первая команда INSERT выполнена успешно, потому что такой марки в базе еще нет. А при выполнении второй команды INSERT
возникает ошибка дубликата ключа. Поскольку вторая команда находится внутри транзакции и мы перехватили и запротоколировали
исключение, то, несмотря на ошибку, внешняя транзакция не откатывается и новая марка вставляется в базу. Не будь вложенной транзакции, ошибка во второй команде вставки привела бы к откату вставки
марки.

5.6.2 Ручное управление транзакциями
До сих пор для фиксации и отката транзакций мы использовали асинхронные контекстные менеджеры. Поскольку это короче, чем управлять самостоятельно, то такой подход обычно рекомендуется. Но
иногда бывают ситуации, когда транзакцией приходится управлять
вручную. Например, если мы хотим выполнить специальный код при
откате или произвести откат по условию, отличному от исключения.
Для ручного управления транзакцией мы можем воспользоваться
менеджером транзакций, возвращенным методом connection.transaction, вне контекстного менеджера. При этом нужно вручную вызвать его метод start, чтобы начать транзакцию, а затем метод commit
в случае успешного завершения или rollback в случае ошибки. Продемонстрируем это, для чего перепишем наш первый пример.
Листинг 5.12

Ручное управление транзакцией

import asyncio
import asyncpg
from asyncpg.transaction import Transaction
async def main():
connection = await asyncpg.connect(host='127.0.0.1',

Глава 5

150

Начать
транзакцию

Неблокирующие драйверы баз данных

port=5432,
user='postgres',
database='products',
password='password')
transaction: Transaction = connection.transaction()
Создать экземпляр
await transaction.start()
транзакции
try:
await connection.execute("INSERT INTO brand "
"VALUES(DEFAULT, 'brand_1')")
await connection.execute("INSERT INTO brand "
"VALUES(DEFAULT, 'brand_2')")
except asyncpg.PostgresError:
print('Ошибка, транзакция откатывается!')
await transaction.rollback()
Если было исключение, откатить
else:
print('Ошибки нет, транзакция фиксируется!')
await transaction.commit()
Если исключения не было, зафиксировать
query = """SELECT brand_name FROM brand
WHERE brand_name LIKE 'brand%'"""
brands = await connection.fetch(query)
print(brands)
await connection.close()

asyncio.run(main())

Мы начинаем с того, что создаем транзакцию тем же методом,
который использовали при работе с асинхронным контекстным менеджером, но теперь сохраняем возвращенный экземпляр класса
Transaction. Этот класс можно рассматривать как менеджер нашей
транзакции, поскольку он умеет фиксировать и откатывать транзакцию по мере необходимости. Имея экземпляр транзакции, мы
вызываем сопрограмму start. Она выполняет запрос к Postgres, необходимый, чтобы начать транзакцию. Затем в блоке try можем выполнять произвольные запросы. В данном случае мы вставляем две
марки. Если хотя бы одна команда INSERT завершится ошибкой, то мы
попадем в блок except и откатим транзакцию, вызвав сопрограмму
rollback. Если же ошибок не было, то вызываем сопрограмму commit,
которая завершает транзакцию и делает все изменения в базе данных
постоянными.
До сих пор мы выполняли запросы так, что все результаты сразу
загружались в память. Во многих приложениях это нормально, потому что запросы, как правило, возвращают небольшие результирующие наборы. Но бывает, что результирующий набор настолько
велик, что целиком в память не помещается. В таком случае мы хотели бы обрабатывать результаты потоком, чтобы снизить нагрузку
на оперативную память системы. Далее рассмотрим, как сделать это
с по­мощью asyncpg, а попутно познакомимся с асинхронными генераторами.

Асинхронные генераторы и потоковая обработка результирующих наборов

5.7

151

Асинхронные генераторы и потоковая
обработка результирующих наборов
У реализации fetch по умолчанию в asynpg есть один недостаток: она
загружает все возвращенные по запросу данные в память. Следовательно, если запрос возвращает миллионы строк, то мы попытаемся
скопировать весь этот набор из базы данных на запросившую машину. Представьте, что наш бизнес оказался сверхуспешным и в базе
данных хранятся миллиарды товаров. Весьма вероятно, что некоторые запросы будут возвращать очень большие результирующие наборы, что может снизить производительность.
Конечно, мы могли бы включить в запрос фразу LIMIT и разбить результаты на страницы, и для многих, а то и для большинства приложений это имеет смысл. Однако у этого подхода есть недостаток – мы
отправляем один и тот же запрос несколько раз, что может создать
излишнюю нагрузку на базу данных. Если в нашем приложении это
действительно проблема, то стоит обрабатывать результаты запросов
потоком. Это снизит как потребление памяти на уровне приложения,
так и нагрузку на базу данных. Однако расплачиваться приходиться
дополнительными раундами обращения к базе по сети.
Postgres поддерживает потоковую обработку результатов запроса
с по­мощью курсоров. Курсор можно рассматривать как указатель на
текущую позицию в результирующем наборе. Получая один результат
из потокового запроса, мы продвигаем курсор на следующую позицию и т. д., пока результаты не будут исчерпаны.
В asyncpg получить курсор можно непосредственно от подключения, а затем использовать для потоковой обработки запроса. В реализации курсоров используется средство asyncio, которое нам еще
не встречалось, – асинхронные генераторы. Асинхронные генераторы
порождают результаты асинхронно по одному, как и обычные генераторы в Python. Они также позволяют использовать специальный синтаксис цикла for для обхода результатов. Чтобы лучше разобраться
в том, как это работает, давайте сначала познакомимся с асинхронными генераторами и с синтаксической конструкцией async for для
их обхода.

5.7.1

Введение в асинхронные генераторы
Многие разработчики знакомы с генераторами по синхронному коду
на Python. Генераторы – это реализация паттерна проектирования
Итератор, получившего известность благодаря книге «банды четырех» «Паттерны проектирования» (Addison-Wesley Professional, 1994).
Этот паттерн позволяет «лениво» определять последовательности
данных и обходить их поэлементно. Это полезно, когда последовательность потенциально велика и сохранить ее в памяти целиком невозможно. Простой синхронный генератор – это обычная функция

152

Глава 5

Неблокирующие драйверы баз данных

Python, содержащая предложение yield вместо return. Например, вот
как можно создать и использовать генератор, возвращающий положительные целые числа, начиная с нуля и до заданной границы.
Листинг 5.13

Синхронный генератор

def positive_integers(until: int):
for integer in range(until):
yield integer
positive_iterator = positive_integers(2)
print(next(positive_iterator))
print(next(positive_iterator))

Здесь функция positive_integers принимает целое число, до которого нужно посчитать. В ней мы входим в цикл, продолжающийся,
пока не будет достигнуто заданное число. На каждой итерации мы
отдаем следующее целое число в предложении yield. При вызове positive_integers(2) мы не возвращаем список целиком и даже не выполняем цикл. Запросив тип positive_iterator, мы получим в ответ
.
Затем используем функцию next для обхода генератора. При каждом ее вызове выполняется одна итерация цикла for в positive_integers, и мы получаем результат предложения yield. Таким образом,
код в листинге 5.13 напечатает на консоли 0 и 1. Вместо next мы могли
использовать совместно с генератором цикл for, чтобы перебрать все
возвращаемые генератором значения.
Для синхронных методов все это работает, но как быть, если мы
хотим использовать сопрограммы для асинхронного порождения последовательности значений? В нашем примере с базой данных как
можно «лениво» получить данные из базы? Для этого в Python имеются асинхронные генераторы и специальная конструкция async for.
Для демонстрации простого асинхронного генератора реализуем тот
же пример порождения целых чисел, но включим в него сопрограмму,
которая работает несколько секунд. Для этого воспользуемся функцией delay из главы 2.
Листинг 5.14

Простой асинхронный генератор

import asyncio
from util import delay, async_timed
async def positive_integers_async(until: int):
for integer in range(1, until):
await delay(integer)
yield integer
@async_timed()
async def main():
async_generator = positive_integers_async(3)

Асинхронные генераторы и потоковая обработка результирующих наборов

153

print(type(async_generator))
async for number in async_generator:
print(f'Получено число {number}')
asyncio.run(main())

Как видим, это не обычный генератор, а объект типа . Асинхронный генератор отличается от обычного тем, что
отдает не объекты Python, а генерирует сопрограммы, которые могут ждать получения результата с по­мощью await. Поэтому обычные
циклы for и функция next с такими генераторами работать не будут.
А вместо них предложена специальная синтаксическая конструкция
async for. В данном примере мы использовали ее, чтобы обойти целые числа в сопрограмме positive_integers_async.
Этот код напечатает числа 1 и 2, но будет ждать 1 с перед возвратом
первого числа и 2 с перед возвратом второго. Отметим, что генератор
не выполняет порожденные сопрограммы конкурентно, а порождает
и ждет их одну за другой.

5.7.2

Использование асинхронных генераторов
и потокового курсора
Понятие асинхронного генератора прекрасно сочетается с понятием
потокового курсора базы данных. С по­мощью таких генераторов мы
можем получать по одной строке за раз в простом цикле, похожем на
for. В asyncpg для выполнения потоковой обработки нужно сначала
открыть транзакцию, поскольку таково требование Postgres. Затем
мы можем вызвать метод cursor класса Connection и получить курсор.
Методу cursor передается запрос, а возвращает он асинхронный генератор для потоковой обработки результатов. Для примера выполним
запрос, который получает на курсоре все товары, имеющиеся в базе
данных. И будем выбирать элементы из результирующего набора по
одному в цикле async for.
Листинг 5.15

Потоковая обработка результатов

import asyncpg
import asyncio
import asyncpg
async def main():
connection = await asyncpg.connect(host='127.0.0.1',
port=5432,
user='postgres',
database='products',
password='password')
query = 'SELECT product_id, product_name FROM product'
async with connection.transaction():
async for product in connection.cursor(query):

154

Глава 5

Неблокирующие драйверы баз данных

print(product)
await connection.close()
asyncio.run(main())

Здесь распечатываются все имеющиеся товары. Хотя в таблице хранится 1000 товаров, в память загружается лишь небольшая порция.
На момент написания книги объем предвыборки по умолчанию был
равен 50 записей, чтобы уменьшить затраты на сетевой трафик. Это
значение можно изменить, задав параметр prefetch.
Курсор можно использовать и для того, чтобы извлечь произвольное число записей из середины результирующего набора, как показано в листинге ниже.
Листинг 5.16

Перемещение по курсору и выборка записей

import asyncpg
import asyncio
async def main():
connection = await asyncpg.connect(host='127.0.0.1',
port=5432,
user='postgres',
database='products',
password='password')
Создать курсор
async with connection.transaction():
для запроса
query = 'SELECT product_id, product_name from product'
cursor = await connection.cursor(query)
await cursor.forward(500)
Сдвинуть курсор вперед
products = await cursor.fetch(100)
на 500 записей
for product in products:
print(product)
Получить следующие
100 записей
await connection.close()
asyncio.run(main())

Здесь мы сначала создаем курсор для запроса. Обратите внимание,
что это делается в предложении await, как для сопрограммы, а не
асинхронного генератора; это возможно, потому что в asyncpg курсор
является одновременно асинхронным генератором и объектом, допускающим ожидание. В большинстве случаев оба способа похожи, но
при таком создании курсора есть различия в поведении предвыборки – мы не можем задать количество выбираемых за одно обращение
записей; попытка сделать это приведет к исключению InterfaceError.
Получив курсор, мы вызываем его метод-сопрограмму forward,
чтобы сдвинуться вперед по результирующему набору. В результате
мы пропустим первые 500 записей в таблице товаров. Затем выбираем следующие 100 товаров и печатаем их на консоли.

Асинхронные генераторы и потоковая обработка результирующих наборов

155

По умолчанию такие курсоры не допускают прокрутки, т. е. двигаться по результирующему набору можно только вперед. Если нужны
прокручиваемые курсоры, допускающие смещение вперед и назад, то
следует самостоятельно выполнить SQL-команду DECLARE ... SCROLL
CURSOR (подробнее о том, как это делается, можно прочитать в документации Postgres по адресу https://www.postgresql.org/docs/current/
plpgsql-cursors.html).
Оба метода полезны, когда результирующий набор велик и мы не
хотим загружать его в память целиком. Циклы async for (листинг 5.16)
хороши для обхода всего набора, тогда как создание курсора и метод
fetch удобны для выборки порции записей или пропуска части результирующего набора.
Но что, если нам только и нужно, что выбрать фиксированное число элементов с предвыборкой, но при этом использовать цикл async
for? Можно было бы добавить в цикл async for счетчик и выходить из
цикла после получения некоторого числа элементов, но такой подход
не приспособлен для повторного использования. Если такие действия
производятся в программе часто, то лучше написать свой собственный асинхронный генератор, который мы назовем take. Он будет
принимать асинхронный генератор и число элементов. Покажем, как
это делается, на примере выборки первых пяти элементов из результирующего набора.
Листинг 5.17 Получение заданного числа элементов с по­мощью
асинхронного генератора
import asyncpg
import asyncio
async def take(generator, to_take: int):
item_count = 0
async for item in generator:
if item_count > to_take - 1:
return
item_count = item_count + 1
yield item
async def main():
connection = await asyncpg.connect(host='127.0.0.1',
port=5432,
user='postgres',
database='products',
password='password')
async with connection.transaction():
query = 'SELECT product_id, product_name from product'
product_generator = connection.cursor(query)
async for product in take(product_generator, 5):
print(product)

Глава 5

156

Неблокирующие драйверы баз данных

print('Получены первые пять товаров!')
await connection.close()
asyncio.run(main())

Наш асинхронный генератор take хранит число уже отданных элементов в переменной item_count. В цикле async_for он отдает нам
запи­си с по­мощью предложения yield и, как только будет отдано
item_count элементов, выполняет return и тем самым завершает работу. А в сопрограмме main можно использовать take в цикле async
for, как обычно. В данном случае мы запрашиваем у курсора первые
пять элементов и видим следующую картину: