Асинхронность в программировании
Авторизуйтесь
Асинхронность в программировании
Традиционно в программировании используют синхронное программирование — последовательное выполнение инструкций с синхронными системными вызовами, которые полностью блокируют поток выполнения, пока системная операция, например чтение с диска, не завершится. В качестве примера ниже написан echo-сервер:
При вызове методов read() и write() текущий поток исполнения будет прерван в ожидании ввода-вывода по сети. Причём большую часть времени программа будет просто ждать. В высоконагруженных системах чаще всего так и происходит — почти всё время программа чего-то ждёт: диска, СУБД, сети, UI, в общем, какого-то внешнего, независимого от самой программы события. В малонагруженных системах это можно решить созданием нового потока для каждого блокирующего действия. Пока один поток спит, другой работает.
Но что делать, когда пользователей очень много? Если создавать на каждого хотя бы один поток, то производительность такого сервера резко упадёт из-за того, что контекст исполнения потока постоянно сменяется. Также на каждый поток создаётся свой контекст исполнения, включая память для стека, которая имеет минимальный размер в 4 КБ. Эту проблему может решить асинхронное программирование.
Асинхронность
Асинхронность в программировании — выполнение процесса в неблокирующем режиме системного вызова, что позволяет потоку программы продолжить обработку. Реализовать асинхронное программирование можно несколькими способами, о которых вы узнаете ниже.
Callbacks
Для написания асинхронной программы можно использовать callback-функции (от англ. callback — обратный вызов) — функции, которые будут вызваны асинхронно каким-либо обработчиком событий после завершения задачи. Переписанный пример сервера на callback-функциях:
В wait_connection() мы всё ещё ждём чего-то, но теперь вместе с этим внутри функции wait_connection() может быть реализовано подобие планировщика ОС, но с callback-функциями (пока мы ждём нового соединения, почему бы не обработать старые? Например, через очередь). Callback-функция вызывается, если в сокете появились новые данные — лямбда в async_read() , либо данные были записаны — лямбда в async_write() .
В результате мы получили асинхронную работу нескольких соединений в одном единственном потоке, который намного реже будет ждать. Эту асинхронность можно также распараллелить, чтобы получить полный профит от утилизации процессорного времени.
У такого подхода есть несколько проблем. Первую в шутку называют callback hell. Достаточно погуглить картинки на эту тему, чтобы понять, насколько это нечитаемо и некрасиво. В нашем примере всего две вложенные callback-функции, но их может быть намного больше.
Вторая проблема заключается в том, что код перестал выглядеть как синхронный: появились «прыжки» из wait_connection() в лямбды, например лямбда, переданная в async_write() , что нарушает последовательность кода, из-за чего становится невозможно предсказать, в каком порядке будут вызваны лямбды. Это усложняет чтение и понимание кода.
Async/Await
Попробуем сделать асинхронный код так, чтобы он выглядел как синхронный. Для большего понимания немного поменяем задачу: теперь нам необходимо прочитать данные из СУБД и файла по ключу, переданному по сети, и отправить результат обратно по сети.
Пройдём по программе построчно:
- Ключевое слово async в заголовке функции говорит компилятору, что функция асинхронная и её нужно компилировать по-другому. Каким именно образом он будет это делать, написано ниже.
- Первые три строки функции: создание и ожидание соединения.
- Следующая строка делает асинхронное чтение, не прерывая основной поток исполнения.
- Следующие две строки делают асинхронный запрос в базу данных и чтение файла. Оператор await приостанавливает текущую функцию, пока не завершится выполнение асинхронной задачи чтения из БД и файла.
- В последних строках производится асинхронная запись в сокет, но лишь после того, как мы дождёмся асинхронного чтения из БД и файла.
Это быстрее, чем последовательное ожидание сначала БД, затем файла. Во многих реализациях производительность async / await лучше, чем у классических callback-функций, при этом такой код читается как синхронный.
Корутины
Описанный выше механизм называется сопрограммой. Часто можно услышать вариант «корутина» (от англ. coroutine — сопрограмма).
Далее будут описаны различные виды и способы организации сопрограмм.
Несколько точек входа
По сути корутинами называются функции, имеющие несколько точек входа и выхода. У обычных функций есть только одна точка входа и несколько точек выхода. Если вернуться к примеру выше, то первой точкой входа будет сам вызов функции оператором asynс , затем функция прервёт своё выполнение вместо ожидания БД или файла. Все последующие await будут не запускать функцию заново, а продолжать её исполнение в точке предыдущего прерывания. Да, во многих языках в корутине может быть несколько await ’ов.
Для большего понимания рассмотрим код на языке Python:
Программа выведет всю последовательность чисел факториала с номерами от 0 до 41.
Функция async_factorial() вернёт объект-генератор, который можно передать в функцию next() , а она продолжит выполнение корутины до следующего оператора yield с сохранением состояния всех локальных переменных функции. Функция next() возвращает то, что передаёт оператор yield внутри корутины. Таким образом, функция async_factorial() в теории имеет несколько точек входа и выхода.
Stackful и Stackless
В зависимости от использования стека корутины делятся на stackful, где каждая из корутин имеет свой стек, и stackless, где все локальные переменные функции сохраняются в специальном объекте.
Так как в корутинах мы можем в любом месте поставить оператор yield , нам необходимо где-то сохранять весь контекст функции, который включает в себя фрейм на стеке (локальные переменные) и прочую метаинформацию. Это можно сделать, например, полной подменой стека, как это делается в stackful корутинах.
На рисунке ниже вызов async создаёт новый стек-фрейм и переключает исполнение потока на него. Это практически новый поток, только исполняться он будет асинхронно с основным.
yield в свою очередь возвращает обратно предыдущий стек-фрейм на исполнение, сохраняя ссылку на конец текущего в предыдущий стек.
Наличие собственного стека позволяет делать yield из вложенных вызовов функций, но такие вызовы сопровождаются полным созданием/сменой контекста исполнения программы, что медленней, чем stackless корутины.
Более производительными, но вместе с тем и более ограниченными, являются stackless корутины. Они не используют стек, и компилятор преобразует функцию, содержащую корутины, в конечный автомат без корутин. Например, код:
Будет преобразован в следующий псевдокод:
По сути здесь создаётся класс, который сохраняет всё состояние функции, а также последнюю точку вызова yield . У такого подхода есть проблема: yield может быть вызван только в теле функции-корутины, но не из вложенных функций.
Симметричные и асимметричные
Корутины также делятся на симметричные и асимметричные.
Симметричные имеют глобальный планировщик корутин, который и выбирает среди всех ожидающих асинхронных операций ту, которую стоит выполнить следующей. Примером является планировщик, о котором говорилось в начале функции wait_connection() .
В асимметричных корутинах нет глобального планировщика, и программист вместе с поддержкой компилятора сам выбирает, какую корутину и когда исполнять. Большинство реализаций корутин асимметричные.
Вывод
Асинхронное программирование является очень мощным инструментом для оптимизации высоконагруженных программ с частым ожиданием системы. Но, как и любую сложную технологию, её нельзя использовать только потому, что она есть. Необходимо всегда задавать себе вопрос: а нужна ли мне эта технология? Какую практическую пользу она мне даст? Иначе разработчики рискуют потратить очень много сил, времени и денег, не получив никакого профита.
Источник
Асинхронное программирование: концепция Deferred
Асинхронная концепция программирования заключается в том, что результат выполнения функции доступен не сразу же, а через некоторое время в виде некоторого асинхронного (нарушающего обычный порядок выполнения) вызова. Зачем такое может быть полезно? Рассмотрим несколько примеров.
Первый пример — сетевой сервер, веб-приложение. Чаще всего как таковых вычислений на процессоре такие приложения не выполняют. Большая часть времени (реального, не процессорного) тратится на ввод-вывод: чтение запроса от клиента, обращение к диску за данными, сетевые обращение к другим подсистемам (БД, кэширующие сервера, RPC и т.п.), запись ответа клиенту. Во время этих операций ввода-вывода процессор простаивает, его можно загрузить обработкой запросов других клиентов. Возможны различные способы решить эту задачу: отдельный процесс на каждое соединение (Apache mpm_prefork, PostgreSQL, PHP FastCGI), отдельный поток (нить) на каждое соединение или комбинированный вариант процесс/нить (Apache mpm_worker, MySQL). Подход с использованием процессов или нитей перекладывает мультиплексирование процессора между обрабатываемыми соединениями на ОС, при этом расходуется относительно много ресурсов (память, переключения контекста и т.п.), такой вариант не подходит для обработки большого количества одновременных соединений, но идеален для ситуации, когда объем вычислений достаточно высок (например, в СУБД). К плюсам модели нитей и процессов можно добавить потенциальное использование всех доступных процессоров в многопроцессорной архитектуре.
Альтернативой является использование однопоточной модели с использованием примитивов асинхронного ввода-вывода, предоставляемых ОС (select, poll, и т.п.). При этом объем ресурсов на каждое новое обслуживаемое соединение не такой большой (новый сокет, какие-то структуры в памяти приложения). Однако программирование существенно усложняется, т.к. данные из сетевых сокетов поступают некоторыми “отрывками”, причем за один цикл обработки данные поступают от разных соединений, находящихся в разных состояниях, часть соединений могут быть входящими от клиентов, часть — исходящими к внешним ресурсам (БД, другой сервер и т.п.). Для упрощения разработки используются различные концепции: callback, конечные автоматы и другие. Примеры сетевых серверов, использующих асинхронный ввод-вывод: nginx, lighttpd, HAProxy, pgBouncer, и т.д. Именно при такой однопоточной модели возникает необходимость в асинхронном программировании. Например, мы хотим выполнить запрос в БД. С точки зрения программы выполнение запроса — это сетевой ввод-вывод: соединение с сервером, отправка запроса, ожидание ответа, чтение ответа сервера БД. Поэтому если мы вызываем функцию “выполнить запрос БД”, то она сразу вернуть результат не сможет (иначе она должна была бы заблокироваться), а вернет лишь нечто, что позволит впоследствие получить результат запроса или, возможно, ошибку (нет соединения с сервером, некорректный запрос и т.п.) Этим возвращаемым значением удобно сделать именно Deferred.
Второй пример связан с разработкой обычных десктопных приложений. Предположим, мы решили сделать аналог Miranda (QIP, MDC, …), то есть свой мессенджер. В интерфейсе программы есть контакт-лист, где можно удалить контакт. Когда пользователь выбирает это действие, он ожидает что контакт исчезнет на экране и что он действительно удалится из контакт-листа. На самом деле операция удаления из серверного контакт-листа опирается на сетевое взаимодействие с сервером, при этом пользовательский интерфейс не должен быть заблокирован на время выполнения этой операции, поэтому в любом случае после выполнения операции потребуется некоторое асинхронное взаимодействие с результатом операции. Можно использовать механизм сигналов-слотов, callback’ов или что-то еще, но лучше всего подойдет Deferred: операция удаления из контакт-листа возвращает Deferred, в котором обратно придет либо положительный результат (всё хорошо), либо исключение (точная ошибка, которую надо сообщить пользователю): в случае ошибки контакт надо восстановить контакт в контакт-листе.
Примеры можно приводить долго и много, теперь о том, что же такое Deferred. Deferred — это сердце framework’а асинхронного сетевого программирования Twisted в Python. Это простая и стройная концепция, которая позволяет перевести синхронное программирование в асинхронный код, не изобретая велосипед для каждой ситуации и обеспечивая высокое качества кода. Deferred — это просто возвращаемый результат функции, когда этот результат неизвестен (не был получен, будет получен в другой нити и т.п.) Что мы можем сделать с Deferred? Мы можем “подвеситься” в цепочку обработчиков, которые будут вызваны, когда результат будет получен. При этом Deferred может нести не только положительный результат выполнения, но и исключения, сгенерированные функцией или обработчиками, есть возможность исключения обработать, перевыкинуть и т.д. Фактически, для синхронного кода есть более-менее однозначная параллель в терминах Deferred. Для эффективной разработки с Deferred оказываются полезными такие возможности языка программирования, как замыкания, лямбда-функци.
Приведем пример синхронного кода и его альтернативу в терминах Deferred:
В асинхронном варианте с Deferred он был бы записан следующим образом:
На практике обычно мы возвращаем Deferred из функций, которые получают Deferred в процессе своей работы, навешиваем большое количество обработчиков, обрабатываем исключения, некоторые исключения возвращаем через Deferred (выбрасываем наверх). В качестве более сложного примера приведем код в асинхронном варианте для примера атомарного счетчика из статьи про структуры данных в memcached, здесь мы предполагаем, что доступ к memcached как сетевому сервису идет через Deferred, т.е. методы класса Memcache возвращают Deferred (который вернет либо результат операции, либо ошибку):
Приведенный выше код можно написать “короче”, объединяя часто используемые операции, например:
Практически для каждой конструкции синхронного кода можно найти аналог в асинхронной концепции с Deferred:
- последовательности синхронных операторов соответствует цепочка callback с асинхронными вызовами;
- вызову одной подпрграммы с вводом-выводом из другой соответствует возврат Deferred из Deferred (ветвление Deferred);
- глубокой цепочки вложенности, распространению исключений по стеку соответствует цепочка функций, возвращающие друг другу Deferred;
- блокам try..except соответствуют обработчики ошибок (errback), которые могут “пробрасывать” исключения дальше, любое исключение в callback переводит выполнение в errback;
- для “параллельного” выполнения асинхронных операций есть DeferredList .
Нити часто применяются в асинхронных программах для осуществления вычислительных процедур, осуществления блокирующегося ввода-вывода (когда не существует асинхронного аналога). Всё это легко моделируется в простой модели ‘worker’, тогда нет необходимости при грамотной архитектуре в явной синхронизации, при этом всё элегантно включается в общий поток вычислений с помощью Deferred:
В приведенном выше примере функция deferToThread переносит выполнение указанной функции в отдельную нить и возвращает Deferred, через который будет асинхронно получен результат выполнения функции или исключение, если они будет выброшено. Первое деление (3/2) выполняется в отдельной нити, затем распечатывается его результат на экран, а затем запускается еще одно вычисление (3/0), которое генерирует исключение, обрабатываемое функцией handleDivisionByZero .
В одной статье не описать и части того, что хотелось бы сказать о Deferred, мне удалось не написать ни слова о том, как же они работают. Если успел заинтересовать — читайте материалы ниже, а я обещаю написать еще.
Дополнительные материалы
- Документация Twisted Framework:
Источник