Asyncio event loop отвечает за выделение памяти в основном процессе

Обновлено: 02.07.2024

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

Но до недавнего момента я не представлял, как async / await работает в Python 3.5. Я знал, что yield from в Python 3.3 в купе с asyncio в Python 3.4 привел к появлению этого нового синтаксиса. Но тот факт, что мне не часто приходилось писать код, работающий с сетью (на чем asyncio как раз и фокусируется, но не ограничивается), вылился в итоге в то, что я мало внимания уделял теме async / await . Я конечно знал, что

в целом эквивалентен конструкции

Но, поскольку я хотел иметь наиболее полное понимание, как работает этот новый синтаксис, это эссе содержит некоторые низкоуровневые технические детали, связанные с работой CPython. Не переживайте, если подробностей больше, чем вам необходимо, или есть вещи, которые вы не полностью понимаете, поскольку я не планирую объяснять все нюансы реализации CPython, чтобы не допустить превращения этого эссе в целую книгу (к примеру, если вы не знаете, что объекты, представляющие собой код, имеют флаги, не заморачивайтесь насчет этого, ничего страшного, вам необязательно это знать, чтобы получить необходимую информацию из этого эссе). Также я попытался написать наиболее обобщенное резюме в конце каждого раздела, поэтому, если сам раздел покажется вам слишком избыточным, вы можете перейти сразу к резюме.

Исторический урок о сопрограммах в Python

Еще в Python 2.2 генераторы впервые были представлены в PEP 255 (они также назывались генераторами-итераторами поскольку реализовывали интерфейс итератора). Первоначально вдохновленные на создание языком программирования Icon, генераторы позволяли создать итератор в наиболее простой форме, который не расходовал бы память при вычислении следующего значения в процессе итерации по нему (вы конечно же могли бы создать целый класс, который реализует функции __iter__() и __next__() и не хранит каждое значение итератора, но на это потребуется больше времени). Например, если бы вы захотели создать свой вариант range() , то вы могли бы реализовать его вот таким очень затратным по памяти способом, создав просто список с целыми числами:

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

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

Генераторы больше не трогали до Python 3.3, в котором в рамках PEP 380 добавили выражение yield from . Строго говоря, это выражение дает возможность выполнить более чистый рефакторинг генераторов, позволяя одной простой конструкцией выдавать каждое значение из итератора (которым обычно оказывается генератор):

Сделав рефакторинг более простым, yield from также позволил создавать цепочку из генераторов таким образом, что значения могли всплывать и опускаться обратно по стэку вызовов без использования всякого дополнительного кода.

Резюме

Генераторы, добавленные в Python 2.2, позволили останавливать выполнение кода. С появлением функционала отправки значения обратно в остановленный генератор, который внедрили в Python 2.5, в нем появилась возможность реализации идеи сопрограмм. А добавление yield from в Python 3.3 не только упростило рефакторинг генераторов, но и позволило выстраивать их в цепочки.

Что такое event loop?

Если вы хотите разобраться с async / await , важно понимать, что такое event loop, и как он позволяет сделать асинхронное программирование возможным. Если вы ранее участвовали в создании графического интерфейса пользователя (GUI) (сюда входит и клиентская вэб-разработка), тогда у вас уже должен быть опыт работы с event loop. Но, поскольку идея асинхронного программирования в Python, выраженная в этой языковой конструкции, появилась достаточно недавно, это вполне нормально, что вы не знаете, что такое event loop.

В случае с Python, asyncio был добавлен в стандартную библиотеку языка, чтобы обеспечить как раз реализацию идеи event loop. В asyncio основной фокус сделан на сеть, что в случае с event loop подразумевает, что частным случаем "когда случилось А" может являться событие готовности сокета к чтению и/или записи (I/O) (посредством модуля selectors ). Помимо графического интерфейса пользователя (GUI) и сетевого ввода/вывода (I/O), event loop также часто используется для организации исполнения кода в отдельном потоке или дочернем процессе и выступает в качестве планировщика (кооперативная многозадачность). Если вы понимаете, как работает GIL в Python, event loop'ы могут быть очень кстати в ситуациях, когда отключение GIL в принципе возможно и это будет даже эффективно.

Резюме

Event loop позволяет организовать логику "когда произошло А, сделай В". Проше говоря, event loop наблюдает за тем, не произошло ли "что-то", за что он отвечает, и если это "что-то" случилось, он вызывает код, который должен обработать это событие. Python включил event loop в стандартную библиотеку в виде asyncio начиная с версии Python 3.4.

Как работают async и await

Как это было реализовано в Python 3.4

Возьмем пример, приведенный выше. Event loop запускает каждую сопрограмму countdown() , выполняя код до выражения yield from и функции asyncio.sleep() в одной из сопрограмм. Это выражение возвращает объект asyncio.Future , который отправляется в event loop и останавливает выполнение сопрограммы. Далее event loop наблюдает за этим future-объектом до его завершения, по факту пока не истечет секунда (также проверяя и другие объекты, такие как вторая сопрограмма). Как только секунда истекла, event loop берет остановленную сопрограмму countdown() , которая ранее вернула future-объект, и отправляет результат завершения future-объекта в сопрограмму в то самое место, откуда она вернула future-объект, а затем сопрограмма вновь запускается. Так продолжается до тех пор, пока все запущенные сопрограммы countdown() не завершатся и внутри event loop не останется объектов для наблюдения. Позже мы рассмотрим более полный пример того, как сопрограммы и event loop работают в связке, но для начала я расскажу, как работает async и await .

Переход от yield from к await в Python 3.5

Функция, которая помечалась как сопрограмма для использования в асинхронном программировании, в Python 3.4 выглядела так:

В Python 3.5 был добавлен декоратор types.coroutine , который помечает генератор как сопрограмму аналогично asyncio.coroutine . Также можно использовать конструкцию async def , чтобы синтаксически обозначить функцию, являющуюся сопрограммой, несмотря на то, что она не может содержать выражения yield , допускается использовать только return и await для возврата значения из такой сопрограммы.

По сути async и types.coroutine сужают определение того, что считается сопрограммой. Это меняет определение сопрограмм от "просто интерфейс генератора", сделав различие между любым генератором и генератором, используемым как сопрограмма, намного более строгим (даже функция inspect.iscoroutine() теперь выполняет более строгую проверку, сообщая, что для сопрограммы необходимо использовать async ).

Как же в итоге обыгрывается различие между yield from и await на более низком уровне (то есть, разница между генератором с декоратором types.coroutine и определенным с помощью выражения async def )? Чтобы это выяснить, давайте взглянем на байткод двух примеров, представленных выше. Байткод для функции py34_coro() выглядит так:

А байткод для функции py35_coro() такой:

Хотелось бы сделать одно существенное замечание касательно разницы между сопрограммами на базе генераторов и сопрограммами с использованием async . Только сопрограммы на базе генераторов могут действительно останавливать выполнение и отсылать что-либо в event loop. Как правило, вы не замечаете эту важную деталь, потому что обычно используете функции, которые существуют в экосистеме event loop, такие как asyncio.sleep() , а поскольку event loop'ы реализуют свой собственный API, то о реализации этой важной детали заботятся сами функции из этой экосистемы. Большинство из нас чаще использует event loop нежели реализует его логику, поэтому, используя только сопрограммы на базе async , вам по большому счету будет все равно, как они работают внутри. Но если вам ранее было интересно, почему вы не могли создать что-то похожее на asyncio.sleep() , используя только сопрограммы на базе async , то это как раз тот самый момент истины.

Резюме

Думайте об async / await как о программном интерфейсе для асинхронного программирования

Я хочу отметить одну ключевую особенность, о которой я глубоко не задумывался, пока не посмотрел доклад Дэвида Бизли на Python Brazil 2015. В этом докладе Дэвид сделал акцент на том, что async / await на самом деле является программным интерфейсом для асинхронного программирования (о чем он упомянул еще раз в Twitter.) Дэвид считает, что люди не дожны думать об async / await как о синониме asyncio , а вместо этого расценивать asyncio как отдельный фреймворк, который может использовать программный интерфейс async / await для реализации асинхронного программирования.

Пример реализации

После всего прочтенного ваша голова наверняка слегка переполнена новыми терминами и понятиями, затрудняя охват всех нюансов реализации асинхронного программирования. Чтобы помочь вам расставить точки над i, далее приведен всеобъемлющий (хотя и надуманный) пример реализации асинхронного программирования, начиная от event loop и заканчивая функциями, относящимися к пользовательскому коду. Пример содержит сопрограммы, представляющие собой отдельные таймеры обратного отсчета, но выполняющиеся совместно. Это и есть асинхронное программирование через одновременное выполнение, по сути 3 отдельных сопрограммы будут запущены независимо друг от друга и все это будет выполняться в одном единственном потоке.

Как я и сказал, это надуманный пример, но если вы запустите этот код в Python 3.5, вы заметите, что все 3 сопрограммы выполняются независимо друг от друга в одном потоке, а также, что общее время выполнения составляет около 5 секунд. Вы можете расценивать Task , SleepingLoop и sleep() как то, что уже реализовано в таких фреймворках как asyncio и curio . Для обычного же пользователя важен только код в функциях countdown() и main() . Можно заметить, что тут нет никакой магии относительно async , await или всего этого асинхронного программирования. Это всего лишь программный интерфейс, который Python предоставляет вам, как разработчику, чтобы сделать такие вещи более простыми в реализации.

Мои надежды и мечты относительно будущего

Я также надеюсь, что Python получит необходимую поддержку yield в сопрограммах на базе async . Это может потребовать введения еще одного ключевого слова в синтаксис (возможно, что-то типа anticipate ?), но сам факт того, что сейчас нельзя реализовать систему на базе event loop только с использованием сопрограмм async , меня тревожит. К счастью, я такой не один и, поскольку автор PEP 492 солидарен со мной, есть шанс, что эта странность будет исправлена в будущем.

Заключение

По сути async и await являются модными генераторами, которые мы называем сопрограммами, вдобавок имеется дополнительная поддержка для вещей, называемых awaitable-объектами, и функционал для преобразования простых генераторов в сопрограммы. Все это вместе призвано обеспечить одновременное выполнение, чтобы мы имели наиболее полную поддержку асинхронного программирования в Python. Сопрограммы круче и намного проще в использовании, чем такие сравнимые по функционалу решения, как потоки (я ранее написал пример с использованием асинхронного программирования менее чем из 100 строк с учетом комментариев), в то же время это решение является достаточно гибким и быстрым с точки зрения производительности ( FAQ проекта curio содержит информацию, что curio быстрее, чем twisted на 30-40%, но медленнее gevent всего на 10-15%, и это с учетом того, что он полностью написан на чистом Python, а учитывая тот факт, что Python 2 + Twisted может использовать меньше памяти и проще в отладке чем Go, только представьте, что вы можете сделать с его помощью!). Я очень рад, что это прижилось в Python 3 и я с радостью наблюдаю, как сообщество принимает это и начинает помогать с поддержкой в библиотеках и фреймворках, чтобы мы все в итоге только выиграли от появления более широкой поддержки асинхронного программирования в Python.

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

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

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

использовании асинхронности

На графике видно, что синхронный подход займет 45 секунд, в то время как при использовании асинхронности время выполнения можно сократить до 20 секунд.

Где асинхронность применяется в реальном мире?

Асинхронность больше всего подходит для таких сценариев:

  1. Программа выполняется слишком долго.
  2. Причина задержки — не вычисления, а ожидания ввода или вывода.
  3. Задачи, которые включают несколько одновременных операций ввода и вывода.

Разница в понятиях параллелизма, concurrency, поточности и асинхронности

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

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

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

Асинхронность — однопоточный, однопроцессорный дизайн, использующий многозадачность. Другими словами, асинхронность создает впечатление параллелизма, используя один поток в одном процессе.

Составляющие асинхронного программирования

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

Сопрограммы

Сопрограммы (coroutine) — это обобщенные формы подпрограмм. Они используются для кооперативных задач и ведут себя как генераторы Python.

Для определения сопрограммы асинхронная функция использует ключевое слово await . При его использовании сопрограмма передает поток управления обратно в цикл событий (также известный как event loop).

Для запуска сопрограммы нужно запланировать его в цикле событий. После этого такие сопрограммы оборачиваются в задачи ( Tasks ) как объекты Future .

Пример сопрограммы

В коде ниже функция async_func вызывается из основной функции. Нужно добавить ключевое слово await при вызове синхронной функции. Функция async_func не будет делать ничего без await .

Я помню тот момент, когда подумал «Как же медленно всё работает, что если я распараллелю вызовы?», а спустя 3 дня, взглянув на код, ничего не мог понять в жуткой каше из потоков, синхронизаторов и функций обратного вызова.

Тогда я познакомился с asyncio, и всё изменилось.

Если кто не знает, asyncio — новый модуль для организации конкурентного программирования, который появился в Python 3.4. Он предназначен для упрощения использования корутин и футур в асинхронном коде — чтобы код выглядел как синхронный, без коллбэков.

Я помню, в то время было несколько похожих инструментов, и один из них выделялся — это библиотека gevent. Я советую всем прочитать прекрасное руководство gevent для практикующего python-разработчика, в котором описана не только работа с ней, но и что такое конкурентность в общем понимании. Мне настолько понравилось та статья, что я решил использовать её как шаблон для написания введения в asyncio.

Небольшой дисклеймер — это статья не gevent vs asyncio. Nathan Road уже сделал это за меня в своей заметке. Все примеры вы можете найти на GitHub.

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

Потоки, циклы событий, корутины и футуры

Потоки — наиболее распространённый инструмент. Думаю, вы слышали о нём и ранее, однако asyncio оперирует несколько другими понятиями: циклы событий, корутины и футуры.

  • цикл событий (event loop) по большей части всего лишь управляет выполнением различных задач: регистрирует поступление и запускает в подходящий момент — специальные функции, похожие на генераторы python, от которых ожидают (await), что они будут отдавать управление обратно в цикл событий. Необходимо, чтобы они были запущены именно через цикл событий — объекты, в которых хранится текущий результат выполнения какой-либо задачи. Это может быть информация о том, что задача ещё не обработана или уже полученный результат; а может быть вообще исключение

Синхронное и асинхронное выполнение

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

Asyncio делает тоже самое — вы можете разбивать ваш код на процедуры, которые определять как корутины, что даёт возможность управлять ими как пожелаете, включая и одновременное выполнение. Корутины содержат операторы yield, с помощью которых мы определяем места, где можно переключиться на другие ожидающие выполнения задачи.

За переключение контекста в asyncio отвечает yield, который передаёт управление обратно в event loop, а тот в свою очередь — к другой корутине. Рассмотрим базовый пример:

* Сначала мы объявили пару простейших корутин, которые притворяются неблокирующими, используя sleep из asyncio
* Корутины могут быть запущены только из другой корутины, или обёрнуты в задачу с помощью create_task
* После того, как у нас оказались 2 задачи, объединим их, используя wait
* И, наконец, отправим на выполнение в цикл событий через run_until_complete

Используя await в какой-либо корутине, мы таким образом объявляем, что корутина может отдавать управление обратно в event loop, который, в свою очередь, запустит какую-либо следующую задачу: bar. В bar произойдёт тоже самое: на await asyncio.sleep управление будет передано обратно в цикл событий, который в нужное время вернётся к выполнению foo.

Представим 2 блокирующие задачи: gr1 и gr2, как будто они обращаются к неким сторонним сервисам, и, пока они ждут ответа, третья функция может работать асинхронно.

Обратите внимание как происходит работа с вводом-выводом и планированием выполнения, позволяя всё это уместить в один поток. Пока две задачи заблокированы ожиданием I/O, третья функция может занимать всё процессорное время.

Порядок выполнения

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

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

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

Тут стоит обратить внимание на пару моментов.

Во-первых, разница во времени — при использовании асинхронных вызовов мы запускаем запросы одновременно. Как говорилось ранее, каждый из них передавал управление следующему и возвращал результат по завершении. То есть скорость выполнения напрямую зависит от времени работы самого медленного запроса, который занял как раз 0.54 секунды. Круто, правда?

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

Создание конкурентности

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

Посмотрите на отступы и тайминги — мы запустили все задачи одновременно, однако они обработаны в порядке завершения выполнения. Код в данном случае немного отличается: мы пакуем корутины, каждая из которых уже подготовлена для выполнения, в список. Функция as_completed возвращает итератор, который выдаёт результаты корутин по мере их выполнения. Круто же, правда?! Кстати, и as_completed, и wait — функции из пакета concurrent.futures.

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

Что ж, для этого в нашей любимой функции wait есть специальный параметр return_when. До сих пор мы игнорировали то, что возвращает wait, т.к. только распараллеливали задачи. Но теперь нам надо получить результат из корутины, так что будем использовать набор футур done и pending.

Что же случилось? Первый сервис ответил успешно, но в логах какое-то предупреждение!

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

Состояния футур

  • ожидание (pending)
  • выполнение (running)
  • выполнено (done)
  • отменено (cancelled)

Вы можете узнать состояние футуры с помощью методов done, cancelled или running, но не забывайте, что в случае done вызов result может вернуть как ожидаемый результат, так и исключение, которое возникло в процессе работы. Для отмены выполнения футуры есть метод cancel. Это подходит для исправления нашего примера.

Простой и аккуратный вывод — как раз то, что я люблю!

Если вам нужна некоторая дополнительная логика по обработке футур, то вы можете подключать коллбэки, которые будут вызваны при переходе в состояние done. Это может быть полезно для тестов, когда некоторые результаты надо переопределить какими-то своими значениями.

Обработка исключений

asyncio — это целиком про написание управляемого и читаемого конкурентного кода, что хорошо заметно при обработке исключений. Вернёмся к примеру, чтобы продемонстрировать.
Допустим, мы хотим убедиться, что все запросы к сервисам по определению IP вернули одинаковый результат. Однако, один из них может быть оффлайн и не ответить нам. Просто применим try. except как обычно:

Мы также можем обработать исключение, которое возникло в процессе выполнения корутины:

Точно также, как и запуск задачи без ожидания её завершения является ошибкой, так и получение неизвестных исключений оставляет свои следы в выводе:

Таймауты

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

И снова у wait есть подходящий аргумент:

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

Заключение

Asyncio укрепил мою и так уже большую любовь к python. Если честно, я влюбился в сопрограммы, ещё когда познакомился с ними в Tornado, но asyncio сумел взять всё лучшее из него и других библиотек по реализации конкурентности. Причём настолько, что были предприняты особые усилия, чтобы они могли использовать основной цикл ввода-вывода. Так что если вы используете Tornado или Twisted, то можете подключать код, предназначенный для asyncio!

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

Надеюсь, в этом уроке я показал, насколько приятно работать с asyncio, и эта технология подтолкнёт вас к переходу на python 3, если вы по какой-то причине застряли на python 2.7. Одно точно — будущее Python полностью изменилось.

Примитивы Lock, Event, Condition и Semaphore в asyncio.

Примитивы синхронизации модуля asyncio очень похожи на примитивы синхронизации модуля threading с двумя важными оговорками:

  • примитивы синхронизации asyncio не являются потокобезопасными, поэтому их не следует использовать для синхронизации потоков ОС;
  • методы этих примитивов синхронизации не принимают аргумент таймаута. Для выполнения операций с таймаутами используйте функцию asyncio.wait_for() .

Базовые примитивы синхронизации asyncio :

  • asyncio.Lock - монопольный доступ к общему ресурсу,
  • asyncio.Event - доступ к ресурсу по событию,
  • asyncio.Condition - сочетает в себе Lock и Event ,
  • asyncio.Semaphore - управляет внутренним счетчиком,
  • asyncio.BoundedSemaphore - ограниченный объект Semaphore .

Изменено в Python 3.9: было удалено получение блокировки с помощью await lock или with (yield from lock) . Вместо этого используйте async lock .

asyncio.Lock(*, loop=None) :

Класс asyncio.Lock() реализует блокировку взаимное исполнения критических участков кода для задач asyncio . Не потокобезопасный.

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

С версии Python 3.8 не рекомендуется использовать аргумент loop , будет удален в Python 3.10

Предпочтительный способ использования asyncio.Lock() - это асинхронный оператор async with :

Методы объекта Lock :

Lock.acquire() :

Метод Lock.acquire() получает блокировку. Для других - устанавливает блокировку в состояние locked и возвращает True .

Метод представляет собой сопрограмму.

Если более чем одна сопрограмма блокируется методом Lock.acquire , то продолжать работу будет только одна сопрограмма, которая получила блокировку, в то время как другие сопрограммы будут ждать, пока блокировка не будет снята.

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

Lock.release() :

Метод Lock.release() сбрасывает полученную блокировку в состояние unlocked и возвращает результат.

Если блокировка уже разблокирована, то возникает исключение RuntimeError .

Lock.locked() :

Метод Lock.locked() возвращает True , если блокировка установлена.

asyncio.Event(*, loop=None) :

Класс asyncio.Event() представляет собой объект события. Не потокобезопасный.

Объект события asyncio можно использовать для уведомления нескольких задач asyncio о том, что произошло какое-то событие.

Объект Event управляет внутренним флагом, которому можно присвоить значение True с помощью метода Event.set() и сбросить значение в False с помощью метода Event.clear() .

Метод Event.wait() блокируется, пока флаг не будет установлен в значение True . Первоначально флаг установлен в значение False .

С версии Python 3.8 не рекомендуется использовать аргумент loop , будет удален в Python 3.10

Методы объекта Event :

Event.wait() :

Метод Event.wait() ждет, пока объект события будет установлено в True . Метод представляет собой сопрограмму.

Если событие установлено, то немедленно возвращает True . В противном случае блокирует, пока другая задача не вызовет метод Event.set() .

Event.set() :

Метод Event.set() устанавливает событие.

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

Event.clear() :

Метод Event.clear() очищает/сбрасывает событие.

Задачи, ожидающие методом Event.wait() будут блокироваться до тех пор, пока метод `Event.set() снова не установит объект события.

Event.is_set() :

Метод Event.is_set() возвращает True , если событие установлено.

asyncio.Condition(lock=None, *, loop=None) :

Класс asyncio.Condition() представляет собой объект какого-то условного события. Не потокобезопасный.

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

По сути, объект Condition сочетает в себе функции события Event и блокировки Lock . Можно иметь несколько объектов Condition , совместно использующих одну Lock , что позволяет координировать монопольный доступ к общему ресурсу между различными задачами, заинтересованными в определенных состояниях этого общего ресурса.

Необязательный аргумент lock должен быть объектом asyncio.Lock или None . В последнем случае автоматически создается новый объект Lock .

С версии Python 3.8 не рекомендуется использовать аргумент loop , будет удален в Python 3.10

Предпочтительный способ использования asyncio.Condition() - это асинхронный оператор async with :

Методы объекта Condition :

Condition.acquire() :

Метод Condition.acquire() получает базовую блокировку. Для других - устанавливает состояние блокировки и возвращает True .

Представляет собой сопрограмму. Метод будет ждать, пока базовая блокировка не будет снята.

Condition.notify(n=1) :

Метод Condition.notify() будит не более n задач (по умолчанию 1), ожидающих этого условия. Метод не работает, если нет ожидающих задач.

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

Condition.locked() :

Метод Condition.is_set() возвращает True , если основная блокировка получена.

Condition.notify_all() :

Метод Condition.notify_all() будит все задачи, ожидающие этого условия.

Этот метод действует как Condition.notify() , но пробуждает все ожидающие задачи.

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

Condition.release() :

Метод Condition.release() снимает базовую блокировку.

Если базовая блокировка не получена, то при вызове этого метода возникает ошибка RuntimeError .

Condition.wait() :

Метод Condition.wait() будет ждать уведомления. Представляет собой сопрограмму.

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

Этот метод снимает базовую блокировку, а затем блокирует, пока не будет разбужен вызовом Condition.notify() или Condition.notify_all() . После пробуждения объект Condition повторно получает свою блокировку, а этот метод возвращает True .

Condition.wait_for(predicate) :

Метод Condition.wait_for() будет ждать, пока аргумент predicate станет истинным. Представляет собой сопрограмму.

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

asyncio.Semaphore(value=1, *, loop=None) :

Класс asyncio.Semaphore() представляет собой объект семафора. Не потокобезопасный.

Семафор управляет внутренним счетчиком, который уменьшается при каждом вызове метода Semaphore.acquire() и увеличивается при каждом вызове Semaphore.release() . Счетчик никогда не может опуститься ниже нуля; когда Semaphore.acquire() обнаруживает, что он равен нулю, то блокируется, ожидая, пока какая-либо задача не вызовет Semaphore.release() .

Необязательный аргумент value (по умолчанию 1) задает начальное значение для внутреннего счетчика. Если заданное значение меньше 0,то возникает ошибка ValueError .

С версии Python 3.8 не рекомендуется использовать аргумент loop , будет удален в Python 3.10

Предпочтительный способ использования asyncio.Semaphore() - это асинхронный оператор async with :

Semaphore.acquire() :

Метод Semaphore.acquire() приобретает семафор. Представляет собой сопрограмму.

Если внутренний счетчик больше нуля, то уменьшает его на единицу и немедленно возвращает True . Если счетчик равен нулю, то ждет вызова Semaphore.release() и возвращает True .

Semaphore.locked() :

Метод Semaphore.locked() возвращает True , если семафор не может быть получен немедленно.

Semaphore.release() :

Метод Semaphore.release() освобождает семафор, увеличив внутренний счетчик на единицу. Может разбудить задачу, ожидающую получения семафора.

В отличие от BoundedSemaphore , объект Semaphore позволяет делать больше вызовов Semaphore.release() , чем Semaphore.acquire() .

asyncio.BoundedSemaphore(value=1, *, loop=None) :

Класс asyncio.BoundedSemaphore() представляет собой ограниченный объект семафора, рассмотренного выше. Не потокобезопасный.

Класс asyncio.BoundedSemaphore() - это версия asyncio.Semaphore , которая вызывает исключение ValueError в при вызове метода Semaphore.release() , если увеличивает внутренний счетчик выше начального значения value .

С версии Python 3.8 не рекомендуется использовать аргумент loop , будет удален в Python 3.10

По очереди

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

Потоки дают возможность вашей программе выполнять ряд задач одновременно. Конечно, у потоков есть ряд недостатков. Многопоточные программы являются более сложными и, как правило, более подвержены ошибкам. Они включают в себя такие проблемы: состояние гонки (race condition), взаимная (deadlock) и активная (livelock) блокировка, исчерпание ресурсов (resource starvation).

Переключение контекста

Хотя асинхронное программирование и позволяет обойти проблемные места потоков, оно было разработано для совершенно другой цели — для переключения контекста процессора. Когда у вас есть несколько потоков, каждое ядро процессора может запускать только один поток за раз. Для того, чтобы все потоки/процессы могли совместно использовать ресурсы, процессор очень часто переключает контекст. Чтобы упростить работу, процессор с произвольной периодичностью сохраняет всю контекстную информацию потока и переключается на другой поток.

Асинхронное программирование — это потоковая обработка программного обеспечения / пользовательского пространства, где приложение, а не процессор, управляет потоками и переключением контекста. В асинхронном программировании контекст переключается только в заданных точках переключения, а не с периодичностью, определенной CPU.

Эффективный секретарь

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

Потоки — это пять секретарей, у каждого из которых по одной задаче, но только одному из них разрешено работать в определенный момент времени. Для того, чтобы секретари работали в потоковом режиме, необходимо устройство, которое контролирует их работу, но ничего не понимает в самих задачах. Поскольку устройство не понимает характер задач, оно постоянно переключалось бы между пятью секретарями, даже если трое из них сидят, ничего не делая. Около 57% (чуть меньше, чем 3/5) переключения контекста были бы напрасны. Несмотря на то, что переключение контекста процессора является невероятно быстрым, оно все равно отнимает время и ресурсы процессора.

Зеленые потоки

Зеленые потоки (green threads) являются примитивным уровнем асинхронного программирования. Зеленый поток — это обычный поток, за исключением того, что переключения между потоками производятся в коде приложения, а не в процессоре. Gevent — известная Python-библиотека для использования зеленых потоков. Gevent — это зеленые потоки и сетевая библиотека неблокирующего ввода-вывода Eventlet. Gevent.monkey изменяет поведение стандартных библиотек Python таким образом, что они позволяют выполнять неблокирующие операции ввода-вывода. Вот пример использования Gevent для одновременного обращения к нескольким URL-адресам:

Как видите, API-интерфейс Gevent выглядит так же, как и потоки. Однако за кадром он использует сопрограммы (coroutines), а не потоки, и запускает их в цикле событий (event loop) для постановки в очередь. Это значит, что вы получаете преимущества потоков, без понимания сопрограмм, но вы не избавляетесь от проблем, связанных с потоками. Gevent — хорошая библиотека, но только для тех, кто понимает, как работают потоки.

ABBYY , Москва, можно удалённо , До 230 000 ₽

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

Функция обратного вызова (callback)

В Python много библиотек для асинхронного программирования, наиболее популярными являются Tornado, Asyncio и Gevent. Давайте посмотрим, как работает Tornado. Он использует стиль обратного вызова (callbacks) для асинхронного сетевого ввода-вывода. Обратный вызов — это функция, которая означает: «Как только это будет сделано, выполните эту функцию». Другими словами, вы звоните в службу поддержки и оставляете свой номер, чтобы они, когда будут доступны, перезвонили, вместо того, чтобы ждать их ответа.
Давайте посмотрим, как сделать то же самое, что и выше, используя Tornado:

Сравнения

Если вы хотите предотвратить блокировку ввода-вывода, вы должны использовать либо потоки, либо асинхронность. В Python вы выбираете между зелеными потоками и асинхронным обратным вызовом. Вот некоторые из их особенностей:

Зеленые потоки

  • потоки управляются на уровне приложений, а не аппаратно;
  • включают в себя все проблемы потокового программирования.

Обратный вызов

  • сопрограммы невидимы для программиста;
  • обратные вызовы ограничивают использование исключений;
  • обратные вызовы трудно отлаживаются.

Как решить эти проблемы?

Вплоть до Python 3.3 зеленые потоки и обратный вызов были оптимальными решениями. Чтобы превзойти эти решения, нужна поддержка на уровне языка. Python должен каким-то образом частично выполнить метод, прекратить выполнение, поддерживая при этом объекты стека и исключения. Если вы знакомы с концепциями Python, то понимаете, что я намекаю на генераторы. Генераторы позволяют функции возвращать список по одному элементу за раз, останавливая выполнение до того момента, когда следующий элемент будет запрошен. Проблема с генераторами заключается в том, что они полностью зависят от функции, вызывающей его. Другими словами, генератор не может вызвать генератор. По крайней мере так было до тех пор, пока в PEP 380 не добавили синтаксис yield from , который позволяет генератору получить результат другого генератора. Хоть асинхронность и не является главным назначением генераторов, они содержат весь функционал, чтобы быть достаточно полезными. Генераторы поддерживают стек и могут создавать исключения. Если бы вы написали цикл событий, в котором бы запускались генераторы, у вас получилась бы отличная асинхронная библиотека. Именно так и была создана библиотека Asyncio.

Все, что вам нужно сделать, это добавить декоратор @coroutine , а Asyncio добавит генератор в сопрограмму. Вот пример того, как обработать те же три URL-адреса, что и раньше:

Несколько особенностей, которые нужно отметить:

  • ошибки корректно передаются в стек;
  • можно вернуть объект, если необходимо;
  • можно запустить все сопрограммы;
  • нет обратных вызовов;
  • строка 10 не выполнится до тех пор, пока строка 9 не будет полностью выполнена.

Единственная проблема заключается в том, что объект выглядит как генератор, и это может вызвать проблемы, если на самом деле это был генератор.

Async и Await

Библиотека Asyncio довольно мощная, поэтому Python решил сделать ее стандартной библиотекой. В синтаксис также добавили ключевое слово async . Ключевые слова предназначены для более четкого обозначения асинхронного кода. Поэтому теперь методы не путаются с генераторами. Ключевое слово async идет до def , чтобы показать, что метод является асинхронным. Ключевое слово await показывает, что вы ожидаете завершения сопрограммы. Вот тот же пример, но с ключевыми словами async / await:

Программа состоит из метода async . Во время выполнения он возвращает сопрограмму, которая затем находится в ожидании.

Заключение

В Python встроена отличная асинхронная библиотека. Давайте еще раз вспомним проблемы потоков и посмотрим, решены ли они теперь:

  • процессорное переключение контекста: Asyncio является асинхронным и использует цикл событий. Он позволяет переключать контекст программно;
  • состояние гонки: поскольку Asyncio запускает только одну сопрограмму и переключается только в точках, которые вы определяете, ваш код не подвержен проблеме гонки потоков;
  • взаимная/активная блокировка: поскольку теперь нет гонки потоков, то не нужно беспокоиться о блокировках. Хотя взаимная блокировка все еще может возникнуть в ситуации, когда две сопрограммы вызывают друг друга, это настолько маловероятно, что вам придется постараться, чтобы такое случилось;
  • исчерпание ресурсов: поскольку сопрограммы запускаются в одном потоке и не требуют дополнительной памяти, становится намного сложнее исчерпать ресурсы. Однако в Asyncio есть пул «исполнителей» (executors), который по сути является пулом потоков. Если запускать слишком много процессов в пуле исполнителей, вы все равно можете столкнуться с нехваткой ресурсов.

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

Существует несколько вариантов асинхронного программирования в Python. Вы можете использовать зеленые потоки, обратные вызовы или сопрограммы. Хотя вариантов много, лучший из них — Asyncio. Если используете Python 3.5, то вам лучше использовать эту библиотеку, так как она встроена в ядро ​​python.

Читайте также: