что такое асинхронный код блока
JavaScript: методы асинхронного программирования
Синхронный код на JavaScript, автор которого не стремился сбить с толку тех, кто этот код будет читать, обычно выглядит просто и понятно. Команды, из которых он состоит, выполняются в том порядке, в котором они следуют в тексте программы. Немного путаницы может внести поднятие объявлений переменных и функций, но чтобы превратить эту особенность JS в проблему, надо очень постараться. У синхронного кода на JavaScript есть лишь один серьёзный недостаток: на нём одном далеко не уехать.
Практически каждая полезная JS-программа написана с привлечением асинхронных методов разработки. Здесь в дело вступают функции обратного вызова, в просторечии — «коллбэки». Здесь в ходу «обещания», или Promise-объекты, называемые обычно промисами. Тут можно столкнуться с генераторами и с конструкциями async/await. Асинхронный код, в сравнении с синхронным, обычно сложнее писать, читать и поддерживать. Иногда он превращается в совершенно жуткие структуры вроде ада коллбэков. Однако, без него не обойтись.
Сегодня предлагаем поговорить об особенностях коллбэков, промисов, генераторов и конструкций async/await, и подумать о том, как писать простой, понятный и эффективный асинхронный код.
О синхронном и асинхронном коде
Начнём с рассмотрения фрагментов синхронного и асинхронного JS-кода. Вот, например, обычный синхронный код:
Он, без особых сложностей, выводит в консоль числа от 1 до 3.
Теперь — код асинхронный:
Возможно, если вы только начинаете путь JS-разработчика, вы зададитесь вопросами: «Зачем это всё? Может быть, можно переделать асинхронный код в синхронный?». Поищем ответы на эти вопросы.
Постановка задачи
Предположим, перед нами стоит задача поиска пользователя GitHub и загрузки данных о его репозиториях. Главная проблема тут в том, что мы не знаем точного имени пользователя, поэтому нам нужно вывести всех пользователей с именами, похожими на то, что мы ищем, и их репозитории.
В плане интерфейса ограничимся чем-нибудь простым.
Простой интерфейс поиска пользователей GitHub и соответствующих им репозиториев
Обратите внимание на то, что в этих примерах важно не то, что в итоге придёт с сервера, и как это будет обработано, а сама организация кода при использовании разных подходов, которые вы сможете использовать в своих асинхронных разработках.
Функции обратного вызова
С функциями в JS можно делать очень много всего, в том числе — передавать в качестве аргументов другим функциям. Обычно так делают для того, чтобы вызвать переданную функцию после завершения какого-то процесса, который может занять некоторое время. Речь идёт о функциях обратного вызова. Вот простой пример:
Используя этот подход для решения нашей задачи, мы можем написать такую функцию request :
Разберём то, что здесь происходит:
Если придать нашему коду более завершённый вид, снабдить его средствами обработки ошибок и отделить определение функций обратного вызова от кода выполнения запроса, что улучшит читабельность программы, получится следующее:
Ад коллбэков во всей красе. Изображение взято отсюда.
В данном случае под «состоянием гонки» мы понимаем ситуацию, когда мы не контролируем порядок получения данных о репозиториях пользователей. Мы запрашиваем данные по всем пользователям, и вполне может оказаться так, что ответы на эти запросы окажутся перемешанными. Скажем, ответ по десятому пользователю придёт первым, а по второму — последним. Ниже мы поговорим о возможном решении этой проблемы.
Промисы
Используя промисы можно улучшить читабельность кода. В результате, например, если в ваш проект придёт новый разработчик, он быстро поймёт, как там всё устроено.
Для того, чтобы создать промис, можно воспользоваться такой конструкцией:
Разберём этот пример:
Для того, чтобы не погрязнуть в теории, вернёмся к нашему примеру. Перепишем его с использованием промисов.
Это — промис в состоянии ожидания. Он может быть либо успешно разрешён, либо отклонён
Благодаря такому подходу мы разобрались с состоянием гонки и с некоторыми возникающими при этом проблемами. Ада коллбэков тут не наблюдается, но код пока ещё читать не так-то легко. На самом деле, наш пример поддаётся дальнейшему улучшению за счёт выделения из него объявлений функций обратного вызова:
На самом деле, это лишь вершина айсберга того, что называется промисами. Вот материал, который я рекомендую почитать тем, кто хочет более основательно погрузиться в эту тему.
Генераторы
Ещё один подход к решению нашей задачи, который, однако, нечасто встретишь — это генераторы. Тема это немного более сложная, чем остальные, поэтому, если вы чувствуете, что вам это изучать пока рано, можете сразу переходить к следующему разделу этого материала.
Посмотрим теперь, как это всё применить к нашей задаче. Итак, вот функция request :
Генератор будет выглядеть так:
Вот что здесь происходит:
Здесь мы можем индивидуально обрабатывать список репозиториев каждого пользователя. Для того, чтобы улучшить этот код, можно было бы выделить функции обратного вызова, как мы уже делали выше.
Я неоднозначно отношусь к генераторам. С одной стороны, можно быстро понять, чего ожидать от кода, взглянув на генератор, с другой, выполнение генераторов приводит к проблемам, похожим не те, что возникают в аду коллбэков.
Надо отметить, что генераторы — возможность сравнительно новая, как результат, если вы рассчитываете на использование вашего кода в старых версиях браузеров, код надо обработать транспилятором. Кроме того, генераторы в написании асинхронного кода используют нечасто, поэтому, если вы занимаетесь командной разработкой, учтите, что некоторые программисты могут быть с ними незнакомы.
На тот случай, если вы решили лучше вникнуть в эту тему, вот и вот — отличные материалы о внутреннем устройстве генераторов.
Async/await
Здесь происходит следующее:
Этот подход и использование промисов — мои любимые методы асинхронного программирования. Код, написанный с их использованием, удобно и читать и править. Подробности об async/await можно почитать здесь.
Итоги
В зависимости от особенностей поставленной перед вами задачи, может оказаться так, что вы будете пользоваться async/await, коллбэками, или некоей смесью из разных технологий. На самом деле, ответ на вопрос о том, какую именно методику асинхронной разработки выбрать, зависит от особенностей проекта. Если некий подход позволяет решить задачу с помощью читабельного кода, который легко поддерживать, который понятен (и будет понятен через некоторое время) вам и другим членам команды, значит этот подход — то, что вам нужно.
Уважаемые читатели! Какими методиками написания асинхронного кода на JavaScript вы пользуетесь?
Виды кодов
Код вставки необходимо установить на сайт один раз, а все управление рекламными кампаниями и баннерами осуществлять через интерфейс ADFOX.
Асинхронные коды для площадок с безразмерными типов баннеров
Библиотека context.js — это единый загрузчик рекламы с автоматическим обновлением.
Мы объединили несколько библиотек для разных видов кодов в одну и разместили ее на нашем сервере, поэтому вам всегда будет доступна самая последняя стабильная версия библиотеки.
Подключите один раз в head страницы код загрузчика:
Из кодов вставок ссылку на библиотеку нужно удалять.
Принцип работы асинхронного кода заключается в том, что загрузка страницы не блокируется на время ожидания ответа от сервера ADFOX. В свою очередь ADFOX подгружает результат запроса (баннер или заглушку) параллельно загрузке страницы как будто в отдельное окно (iframe), а только потом уже выгружает данные на страницу, даже тогда, когда она полностью сформирована и загружена.
Формат ответа сервера ADFOX
Ответ на запрос за баннером сервер ADFOX возвращает в JSON формате.
JSON-ответ содержит несколько объектов:
Некоторые переменные в ответе могут быть закодированы с помощью base64. Чтобы раскодировать полученное значение вручную, используйте страницу-кодировщик.
Асинхронный код вставки
Управление загрузкой баннера:
Асинхронный код с проверкой скролла
Код вставки с проверкой скролла обычно используется на площадках во втором и ниже экранах. Запрос за баннером будет отправлен только тогда, когда площадка с таким кодом вставки попадет в область видимости на экране монитора посетителя сайта.
Управление загрузкой баннера:
Адаптивный код
Сайт с адаптивной версткой — это такой сайт, у которого есть стабильная верстка HTML и за ее отображение на разных разрешениях экранов отвечают CSS-стили. Адаптивную верстку применяют для удобного просмотра сайта на различных устройствах: мобильных телефонах, планшетах и десктопах.
Чтобы для разных версий подгружать разные наборы баннеров, мы разработали адаптивный код вставки.
В адаптивном коде необходимо перечислить версии сайта, на которых он должен произвести запрос за баннером, благодаря чему можно настраивать разные наборы рекламных площадок под разные версии сайта без лишних запросов кода.
Возможности адаптивного кода:
Управление загрузкой баннера:
Адаптивный код с проверкой скролла
Управление загрузкой баннера:
XML код
Для площадок, созданных с XML-типами баннеров, в интерфейсе ADFOX генерируются ссылки для получения XML.
Пример ссылки для получения XML:
Ссылка устанавливается в приложение или плеер и в ответ на запрос сервер ADFOX вернет XML-код баннера.
Коды для размещения страницах AMP и Turbo
На сайтах, поддерживающих технологии AMP и Турбо-страницах, возможно размещение рекламы с помощью ADFOX.
При получении кода вставки интерфейс автоматически предложит подходящий для площадки Вид кода :
Устаревшие виды кодов
Синхронный код вставки генерируется для Стандартных типов баннеров, являющихся устаревшим видом и некоторые возможности ADFOX на этом виде кода не поддерживаются.
Рекомендуем перевести все синхронные площадки на асинхронные коды вставки. За подробностями обращайтесь в службу поддержки.
Пример синхронного кода вставки:
Асинхронный код вставки, требующий подключения дополнительных библиотек на сайт, является устаревшей версией и рекомендуется его заменить на асинхронный код без подключения отдельных библиотек.
Пример асинхронного кода вставки (устаревшая версия с подключением библиотеки):
Асинхронный код с проверкой скролла, требующий подключения дополнительной библиотеки на сайт, является устаревшей версией и рекомендуется его заменить на асинхронный код с проверкой скролла без подключения отдельных библиотек.
Пример асинхронного кода вставки с проверкой скролла (устаревшая версия с подключением библиотеки):
Асинхронное программирование с async/await
Доброго времени суток, друзья!
Сравнительно новыми дополнениями JavaScript являются асинхронные функции и ключевое слово await. Эти возможности в основном являются синтаксическим сахаром над обещаниями (промисами), облегчая написание и чтение асинхронного кода. Они делают асинхронный код похожим на синхронный. Данная статья поможет вам разобраться, что к чему.
Условия: базовая компьютерная грамотность, знание основ JS, понимание азов асинхронного кода и обещаний.
Цель: понять, как устроены обещания, и как они используются.
Основы async/await
Использование async/await состоит из двух частей.
Ключевое слово async
Прежде всего, у нас есть ключевое слово async, которое мы помещаем перед объявлением функции, чтобы сделать ее асинхронной. Асинхронная функция — это функция, которая предвосхищает возможность использования ключевого слова await для запуска асинхронного кода.
Попробуйте набрать в консоли браузера следующее:
Функция вернет ‘Hello’. Ничего необычно, верно?
Но что если мы превратим ее в асинхронную функцию? Попробуйте сделать следующее:
Теперь вызов функции возвращает обещание. Это одна из особенностей асинхронных функций — они возвращают значения, которые гарантировано преобразуются в обещания.
Вы также можете создать асинхронное функциональное выражения, например, так:
Также можно использовать стрелочные функции:
Все эти функции делают одно и тоже.
Таким образом, добавление ключевого слова async заставляет функцию возвращать обещание вместо значения. Кроме того, это позволяет синхронным функциям избегать любых накладных расходов, связанных с запуском и поддержкой использования await. Простое добавление async перед функцией обеспечивает автоматическую оптимизацию кода движком JS. Круто!
Ключевое слово await
Преимущества асинхронных функций становятся еще более очевидными, когда вы комбинируете их с ключевым словом await. Оно может быть добавлено перед любой основанной на обещаниях функцией, чтобы заставить ее дожидаться завершения обещания, а затем вернуть результат. После этого выполняется следующий блок кода.
Вы можете использовать await при вызове любой функции, возвращающей обещание, включая функции Web API.
Вот тривиальный пример:
Разумеется, приведенный код бесполезен, он лишь служит демонстрацией синтаксиса. Давайте двигаться дальше и посмотрим на реальный пример.
Переписываем код на обещаниях с использованием async/await
Возьмем пример с fetch из предыдущей статьи:
У вас уже должны быть понимание того, что такое обещания и как они работают, но давайте перепишем этот код с использованием async/await, чтобы увидеть насколько все стало проще:
Использование ключевого слова async превращает функцию в обещание, поэтому мы можем использовать смешанный подход из обещаний и await, выделив вторую часть функции в отдельный блок с целью повышения гибкости:
Вы можете переписать пример или запустить наше живое демо (см. также исходный код).
Но как это работает?
Мы обернули код внутри функции и добавили ключевое слово async перед ключевым словом function. Вам нужно создать асинхронную функцию, чтобы определить блок кода, в котором будет запускаться асинхронный код; await работает только внутри асинхронных функций.
Еще раз: await работает только в асинхронных функциях.
Значение, возвращаемое обещанием fetch(), присваивается переменной response, когда данное значение становится доступным, и «парсер» останавливается на этой линии до завершения обещания. Как только значение становится доступным, парсер переходит к следующей строчке кода, которая создает Blob. Эта строчка также вызывает основанный на обещаниях асинхронный метод, поэтому здесь мы также используем await. Когда результат операции возвращается, мы возвращаем его из функции myFetch().
Добавляем обработку ошибок
Если вы хотите добавить обработку ошибок, у вас есть несколько вариантов.
Вы можете использовать синхронную структуру try. catch вместе с async/await. Этот пример является расширенной версией приведенного выше кода:
Блок catch()<> принимает объект ошибки, который мы назвали «e»; теперь мы можем вывести его в консоль, это позволит нам получить сообщение о том, в каком месте кода произошла ошибка.
Ожидание Promise.all()
Async/await основан на обещаниях, так что вы можете использовать все возможности последних. К ним, в частности, относится Promise.all() — вы легко можете добавить await к Promise.all(), чтобы записать все возвращаемые значения способом, похожим на синхронный код. Снова возьмем пример из предыдущей статьи. Держите вкладку с ним открытой, чтобы сравнить с показанным ниже кодом.
С async/await (см. живое демо и исходный код) он выглядит так:
Мы легко превратили функцию fetchAndDecode() в асинхронную с помощью парочки изменений. Обратите внимания на строчку:
Недостатки async/await
Async/await имеет парочку недостатков.
Async/await делает код похожим на синхронный и в некотором смысле заставляет его вести себя более синхронно. Ключевое слово await блокирует выполнение следующего за ним кода до завершения обещания, как это происходит в синхронной операции. Это позволяет выполняться другим задачам, но ваш собственный код является заблокированным.
Это означает, что ваш код может быть замедлен большим количеством ожидающих обещаний, следующих друг за другом. Каждый await будет ждать завершения предыдущего, в то время как мы хотели бы, чтобы обещания начали выполняться одновременно, так будто мы не используем async/await.
Существует шаблон проектирования, позволяющий смягчить эту проблему — отключение всех процессов обещаний путем сохранения объектов Promise в переменных и последующего их ожидания. Давайте посмотрим на то, как это реализуется.
В нашем распоряжении имеется два примера — slow-async-await.html (см. исходный код) и fast-async-await.html (см. исходный код). Оба примера начинаются с функции-обещания, которая имитирует асинхронную операцию с помощью setTimeout():
Затем следует асинхронная функция timeTest(), которая ожидает трех вызовов timeoutPromise():
Каждый из трех вызовов timeTest() завершается записью времени выполнения обещания, затем записывается время выполнения всей операции:
В каждом случае функция timeTest() отличается.
В slow-async-await.html timeTest() выглядит так:
Здесь мы просто ожидаем три вызова timeoutPromise, каждый раз устанавливая задержку в 3 секунды. Каждый вызов ждет завершения предыдущего — если вы запустите первый пример, то увидите модальное окно примерно через 9 секунд.
В fast-async-await.html timeTest() выглядит так:
Здесь мы сохраняем три объекта Promise в переменных, что заставляет связанные с ним процессы выполняться одновременно.
Далее мы ожидаем их результаты — поскольку обещания начинают выполняться одновременно, обещания завершатся также в одно время; когда вы запустите второй пример, то увидите модальное окно примерно через 3 секунды!
Вам следует осторожно тестировать код и помнить об этом при снижении производительности.
Еще одним незначительным неудобством является необходимость оборачивания ожидаемых обещаний в асинхронную функцию.
Использование async/await совместно с классами
В завершение отметим, что вы можете добавлять async даже в методах создания классов, чтобы они возвращали обещания, и ждать обещания внутри них. Возьмем код из статьи про объектно-ориентированный JS и сравним его с модифицированной с помощью async версией:
Метод класса может быть использован следующим образом:
Поддержка браузеров
Одним из препятствий использования async/await является отсутствие поддержки старых браузеров. Эта возможность доступна почти во всех современных браузерах, также как обещания; некоторые проблемы существуют в Internet Explorer и Opera Mini.
Если вы хотите использовать async/await, но нуждаетесь в поддержке старых браузеров, можете использовать библиотеку BabelJS — она позволяет использовать новейший JS, преобразуя его в подходящий для конкретного браузера.
Заключение
Async/await позволяет писать асинхронный код, который легко читать и поддерживать. Несмотря на то, что async/await поддерживается хуже других способов написания асинхронного кода, его определенно стоит изучить.
Асинхронность в программировании
В области разработки высоконагруженных многопоточных или распределенных приложений часто возникают дискуссии об асинхронном программировании. Сегодня мы подробно погрузимся в асинхронность и изучим, что это такое, когда она возникает, как влияет на код и язык программирования, которым мы пользуемся. Разберемся, зачем нужны Futures и Promises и затронем корутины и операционные системы. Это сделает компромиссы, возникающие во время разработки ПО, более явными.
В основе материала — расшифровка доклада Ивана Пузыревского, преподавателя школы анализа данных Яндекса.
Видеозапись
1. Содержание
2. Введение
Всем привет, меня зовут Иван Пузыревский, я работаю в компании Яндекс. Последние лет шесть я занимался инфраструктурой хранения и обработки данных, сейчас перешел в продукт — в поиск путешествий, отелей и билетов. Так как я работал долгое время в инфраструктуре, то у меня накопилось довольно много опыта, как писать разные нагруженные приложения. Наша инфраструктура работает 24*7*365 каждый день нон-стоп, непрерывно на тысячах машин. Естественно, нужно писать код так, чтобы он работал надежно и производительно и решал задачи, которые перед нами ставит компания.
Сегодня мы с вами поговорим про асинхронность. Что такое асинхронность? Это несовпадение чего-либо с чем-либо во времени. Из этого описания вообще не понятно, про что я сегодня буду говорить. Чтобы как-то пояснить вопрос, мне нужен пример а-ля «Hello, world!». Асинхронность обычно возникает в контексте написания сетевых приложений, поэтому у меня будет сетевой аналог «Hello, world!». Это приложение ping-pong. Код выглядит таким образом:
Я создаю сокет, читаю оттуда строку, и проверяю — если это ping, то пишу в ответ pong. Очень просто и понятно. Что происходит, когда вы видите такой код на экране своего компьютера? Мы думаем об этом коде как о последовательности вот таких шагов:
С точки зрения реального физического времени все немного смещено.
Те, кто реально такой код писал и запускал, знают, что после шага read и после шага
write идет довольно заметный интервал времени, когда наша программа вроде бы ничего не делает с точки зрения нашего кода, но под капотом работает машинерия, которую мы называем «ввод-вывод».
Во время ввода/вывода происходит обмен пакетами по сети и вся сопутствующая тяжелая низкоуровневая работа. Проведем мысленный эксперимент: возьмем такую одну программу, запустим на одном физическом процессоре и сделаем вид, что у нас нет никакой операционной системы, что получится? Процессор не может остановиться, он продолжает делать такты, не исполняя никаких инструкций, просто зря потребляя энергию.
Возникает вопрос, можем ли мы в этот период времени сделать что-нибудь полезное. Очень естественный вопрос, ответ на который позволил бы нам сэкономить процессорные мощности и использовать их для чего-то полезного, пока наше приложение вроде как ничего не делает.
3. Основные понятия
3.1. Поток выполнения
Как мы можем подступиться к этой задаче? Давайте согласуем понятия. Я буду говорить «поток выполнения», имея в виду некоторую осмысленную последовательность элементарных операций или шагов. Осмысленность будет определяться контекстом, в котором я говорю о потоке выполнения. То есть если мы говорим про однопоточный алгоритм (Ахо-Корасик, поиск по графу), то сам этот алгоритм — уже есть поток выполнения. Он делает какие-то шаги для решения задачи.
Если я говорю о базе данных, то одним потоком выполнения может быть часть действий, совершаемых базой данных для обслуживания одного пришедшего запроса. То же и для веб-серверов. Если я пишу какое-то мобильное или веб-приложение, то для обслуживания одной операции пользователя, например, клика на кнопку, происходят сетевые взаимодействия, взаимодействие с локальным хранилищем и так далее. Последовательность этих действий с точки зрения моего мобильного приложения будет также отдельным осмысленным потоком выполнения. С точки зрения операционной системы, процесс или нить процесса также являются осмысленным потоком выполнения.
3.2. Многозадачность и параллелизм
Краеугольный камень производительности — это умение сделать такой трюк: когда у меня есть один поток выполнения, который содержит в своей физической временной развертке пустоты, тогда заполнить эти пустоты чем-нибудь полезным — исполнить шаги других потоков выполнения.
Базы данных обычно обслуживают много клиентов одновременно. Если мы можем совместить работу над несколькими потоками выполнения в рамках одного потока выполнения более высокого уровня, то это называется многозадачность. То есть многозадачность — это когда я в рамках одного более крупного потока выполнения совершаю действия, которые подчинены решению более мелких задач.
При этом важно не путать понятие многозадачности с параллелизмом. Параллелизм —
это свойства среды исполнения, которое дает возможность за один такт времени, за один шаг, совершить прогресс в разных потоках выполнения. Если у меня есть два физических процессора, то за один такт времени они могут исполнить две инструкции. Если программа запущена на одном процессоре, то ей потребуется два такта времени, чтобы исполнить эти же две инструкции.
Важно не путать эти понятия, так как они относятся к разным категориям. Многозадачность — это свойство вашей программы, что она внутри структурирована как переменная работа над разными задачами. Параллелизм — это свойство среды исполнения, которое дает вам возможность за один такт времени работать над несколькими задачами.
Во многом асинхронный код и написание асинхронного кода — это написание многозадачного кода. Основная сложность связана с тем, как мне кодировать задачи и как ими управлять. Поэтому сегодня мы будем говорить именно об этом — о написании многозадачного кода.
4. Блокирование и ожидание
Начнем с какого-нибудь простого примера. Вернемся к ping-pong:
Как мы уже обсудили, после строчек read и white поток выполнения засыпает, блокируется. Обычно мы так и говорим, «поток заблокирован».
Это значит, что поток выполнения дошел до такой точки, когда для дальнейшего его продолжения необходимо наступление какого-либо события. В частности, в случае нашего сетевого приложения нужно, чтобы поступили данные по сети или, наоборот, у нас освободился буфер для записи данных в сеть. События могут быть разные. Если мы говорим про временные аспекты, то можем ждать срабатывания таймера или завершения другого процесса. События здесь — некая абстрактная вещь, про них важно понимать, что их можно ожидать.
Когда мы пишем простой код, то неявно отдаем управление ожиданием событий уровню выше. В нашем случае — операционной системе. Она, как сущность более высокого уровня, отвечает за выбор, какая задача будет исполняться далее, и она же отвечает за отслеживание наступления событий.
Наш код, который мы пишем как разработчики, структурирован в это же время относительно работы над одной задачей. Фрагмент кода из примера занимается обработкой одного соединения: он из одного соединения читает ping и в одно соединение пишет pong.
Код понятный. Его можно прочитать и понять, что он делает, как он работает, какую задачу решает, какие инварианты в нем есть и так далее. При этом мы очень слабо управляем планированием задач в такой модели. Вообще, в операционных системах есть понятия приоритетов, но если вы писали системы мягкого реального времени, то знаете, что инструментов, доступных в Linux, не хватает для создания достаточно вменяемых систем реального времени.
Далее, операционная система — штука сложная, и переключение контекста из нашего приложения в ядро стоит единицы микросекунд, что при некотором несложном подсчете дает нам оценку на порядка 20-100 тысяч переключений контекста в секунду. Это значит, что если мы пишем веб-сервер, то за одну секунду можем обработать порядка 20 тысяч запросов, предполагая, что обработка запросов стоит в десять раз дороже, чем работа системы.
4.1. Неблокирующее ожидание
Если вы приходите к ситуации, что вам нужно работает с сетью более эффективно, то вы начинаете искать помощь в интернете и приходите к использованию select/epoll. В интернете написано, что если хочется обслуживать тысячи соединений одновременно, нужен epoll, потому что это хороший механизм и так далее. Вы открываете документацию и видите что-то типа такого:
Функции, в которых в интерфейсе фигурирует либо множество дескрипторов, с которыми вы работаете (в случае select), либо множество событий, которые проходят
через границы вашего приложения ядра операционной системы, которые вам нужно обрабатывать (в случае epoll).
Также стоит добавить, что можно прийти не к select/epoll, а к библиотеке типа libuv, у которой в API не будет никаких событий, но будет множество коллбэков. Интерфейс библиотеки будет говорить: «Дорогой друг, для чтения сокета предоставь коллбэк, который я позову, когда появятся данные».
Что поменялось по сравнению с нашим синхронным кодом в предыдущей главе? Код стал асинхронным. Это значит, что мы в приложение забрали логику по определению момента времени, когда отслеживается наступление событий. Явные вызовы select/epoll — это точки, где мы запрашиваем у операционной системы информацию о наступивших событиях. Также мы забрали в код своего приложения выбор, над какой задачей работать дальше.
Из примеров интерфейсов можно заметить, что механизмов привнесения многозадачности принципиально есть два. Один вида «тяни», когда мы
вытягиваем множество наступивших событий, которые мы ждем, и дальше на них как-то реагируем. В таком подходе легко амортизировать накладные расходы на одно
событие и поэтому достигать высокой пропускной способности по коммуникации о множестве наступивших событий. Обычно все сетевые элементы вроде взаимодействия ядра с сетевой карточкой или взаимодействия вас и операционной системы построены на poll-механизмах.
Второй способ — это механизм вида «толкай», когда некая внешняя сущность явно приходит, прерывает поток выполнения и говорит: «Теперь, пожалуйста, обработай событие, которое сейчас наступило». Это подход с коллбэками, с uniх-сигналами, с прерываниями на уровне процессора, когда внешняя сущность явно вторгается в ваш поток выполнения и говорит: «Сейчас, пожалуйста, работаем вот над этим событием». Такой подход появился для того, чтобы уменьшить задержку между наступлением события и реакцией на него.
Зачем мы, разработчики на C++, которые пишут и решают конкретные прикладные задачи, можем захотеть притащить в свой код событийную модель? Если мы перетаскиваем в свой код работу над многими задачами и управление ими, то из-за отсутствия перехода в ядро и обратно, мы можем чуть быстрее работать и за единицу времени совершать больше полезных действий.
К чему это приводит с точки зрения кода, который мы пишем? Возьмем, к примеру, nginx — высокопроизводительный HTTP-сервер, очень распространенный. Если почитать его код, он построен по асинхронной модели. Код читать довольно сложно. Когда ты задаешься вопросом, что же конкретно происходит при обработке одного HTTP-запроса, то оказывается, что в коде есть очень много фрагментов, разнесенных по разным файлам, по разным углам кодовой базы. Каждый фрагмент совершает маленький объем работы в рамках обслуживания всего HTTP-запроса. К примеру:
Есть структура request, которая пробрасывается в обработчик наступивших событий, когда сокет сигнализирует о доступности на чтение или на запись. Дальше этот обработчик постоянно по ходу работы программы переключается в зависимости от того, в каком состоянии находится обработка запроса. Либо мы читаем заголовки, либо читаем тело запроса, либо спрашиваем у upstream данные — в общем, много разных состояний.
Такой код сложновато читать, потому что он, в своей сути, описан в терминах реакции на события. Мы находимся в таком-то состоянии и реагируем определенным образом на наступившие события. Не хватает целостной картины обо всем процессе обработки HTTP-запроса.
Другой вариант – который обычно часто используется в JavaScript — это построение кода на основе коллбеков, когда мы пробрасываем в интерфейсный вызов свой коллбэк, в котором обычно есть еще какой-нибудь вложенный коллбэк на наступление события и так далее.
Код опять сильно фрагментирован, нет понимания текущего состояния, как мы работаем над запросом. Через замыкания передается много информации, и нужно прикладывать умственные усилия, чтобы реконструировать логику обработки одного запроса.
Таким образом, привнеся в свой код многозадачность (логику выбора рабочих задач и их мультиплексирования), мы получаем эффективный код и контроль над приоритезацией задач, но очень сильно теряем в читаемости. Этот код сложно читать и сложно поддерживать.
Почему? Представим, у меня есть простой кейс, например, я читаю файл и передаю его по сети. В неблокирущем варианте этому кейсу будет соответствовать такой линейный конечный автомат:
Теперь, допустим, я хочу к этому файлу добавить информацию из базы данных. Простой вариант:
Вроде как линейный код, но количество состояний увеличилось.
Дальше вы начинаете думать, что было бы неплохо распараллелить два шага — чтение из файла и из базы данных. Начинаются чудеса комбинаторики: вы находитесь в начальном состоянии, запрашиваете чтение файла и данные из базы данных. Дальше вы можете прийти либо в состояние, где есть данные из базы данных, но нет файла, либо наоборот — есть данные из файла, но нет из базы данных. Далее нужно перейти в состояние, когда у вас есть одно из двух. Опять же это два состояния. Потом нужно перейти в состояние, когда у вас есть оба ингредиента. Потом писать их в сокет и так далее.
Чем сложнее приложение, тем больше состояний, тем больше фрагментов кода, которые нужно комбинировать в своей голове. Неудобно. Либо вы пишете лапшу из коллбэков, которую читать неудобно. Если пишется развесистая система, то однажды наступает момент, когда терпеть это больше нельзя.
5. Futures/Promises
Чтобы решить проблему, нужно посмотреть на ситуацию проще.
Есть программа, в ней есть черные и красные кружочки. Наш поток выполнения – это черные кружочки; иногда они перемежаются красными, когда поток не может продолжать свою работу. Проблема в том, что для нашего черного потока выполнения нужно попасть в следующий черный кружочек, который будет неизвестно когда.
Проблема в том, что когда мы пишем код на языке программирования, мы объясняем компьютеру, что делать прямо сейчас. Компьютер — условно простая штука, которая ожидает инструкции, которые мы пишем на языке программирование. Она ожидает инструкции про следующий кружочек, и в нашем языке программирования не хватает средств, чтобы сказать: «В будущем, пожалуйста, когда наступит некоторое событие, сделай что-нибудь».
В языке программирования мы оперируем понятными сиюминутными действиями: вызов функции, арифметические операции и тд. Они описывают конкретный ближайший следующий шаг. При этом для обработки логики приложения нужно описывать не следующий физический шаг, а следующий логический шаг: что нам делать, когда появятся данные из базы данных, например.
Поэтому нужен некий механизм, как комбинировать эти фрагменты. В случае, когда мы писали синхронный код, мы скрыли вопрос полностью под капот и сказали, что этим будет заниматься операционная система, разрешили ей прерывать и перепланировать наш потоки выполнения.
В уровне 1 мы открыли этот ящик Пандоры, и он привнес в код много switch, case, условий, ветвей, состояний. Хочется какого-то компромисса, чтобы код был относительно читаемый, но сохранял все преимущества уровня 1.
К счастью для нас, в 1988 году люди, занимающиеся распределенными системами, Барбара Лисков и Люба Ширира, осознали проблему, и пришли к необходимости лингвистических изменений. В язык программирования нужно добавить конструкции, позволяющие выражать темпоральные связи между событиями — в текущем моменте времени и в неопределенном моменте в будущем.
Это назвали Promises. Концепция классная, но она двадцать лет пылилась на полке. В последнее время она набирает интерес — к примеру, товарищи в Twitter, когда рефакторили свой код с Ruby on Rails на Scala, прониклись этой концепцией достаточно глубоко, и решили, что все сервисы будут суть функция, которая берет запрос и возвращает future на ответ. Вы можете прочитать статью Your Server as a Function. Очень стройная концепция, которая позволила им очень оперативно переструктурировать весь код.
Но это Scala, а что делать нам, С++ разработчикам?
Нам нужна некая абстракция, назовем её Future. Это контейнер для значения типа T cо следующей семантикой: прямо сейчас значение в контейнере может отсутствовать, но когда-то в будущем оно появится.
С помощью этого контейнера мы будем связывать те значения, что появятся в будущем, с теми, что есть в настоящем моменте. То есть мы, находясь в «сейчас», будем говорить, что нужно будет сделать в будущем. В дальнейшем в рассказе Future будем называть интерфейсом для «чтения», а Promise — для «записи». В других языках программирования именования могут быть другими; к примеру, в JavaScript, Promise — интерфейс для чтения и записи одновременно, а в Java – есть только Future.
Чтобы проиллюстрировать идею, я буду использовать модельную реализацию. Если вам нужен реальный код, который можно использовать в своей кодовой базе, то стоит посмотреть на boost::future (не std::future) — в нем есть большая часть того, о чем мы будем говорить.
5.1. Интерфейс Future & Promise
Это контейнер, значит, есть какое-то значение, о котором мы хотим контейнер спросить. В частности, узнать, есть ли в нем значение сейчас, достать его оттуда. И, раз значения могут появиться в будущем, было бы неплохо иметь возможность подписаться — предоставить некоторую функцию, которая будет вызвана, когда появится значения. Для выразительности добавим ещё две функции Then, о которых я буду говорить позже.
Интерфейс для записи. Через него также можно опросить контейнер, есть ли в нем значение или нет. Можем сказать контейнеру «запиши, пожалуйста, значение, которое у меня есть в руках».
5.2. Композиция вычислений
В чем крутость конструкции? Крутость начинается, когда вы пытаетесь написать комбинаторы для связывания ваших вычислений. Функция Then — это то, что позволяет делать комбинации такого рода.
Я могу скомбинировать мое обещание в будущем получить значение t и желание применить трансформацию f. Тогда я получу обещание в настоящем моменте времени, о том, что в будущем появится значение r.
Появится каким способом: когда будет значение t, то я применю к нему функцию, получу значение r и положу в заранее подготовленный контейнер. С точки зрения кода это выглядит так:
На диаграмме мы находимся в левом нижнем углу, в будущем когда-то появится значение типа t. Мы над ним в будущем позовем функцию f, и ещё дальше в будущем появится значение r, которое положим в контейнер. Верхняя дуга связывает обещание, которое даем в настоящем и то значение, которое мы получим.
С точки зрения кода, в момент вызова Then случаются три шага:
Функция работает быстро и моментально связывает, что произойдет в настоящем и в будущем. Такой подход удобен тем, что позволяет прямо сейчас сконструировать конвейер обработки, не дожидаясь никаких событий, и выносит за скобки логику ожидания событий и вызова реакции на них.
Если обратить внимание на слайды, то можно заметить, что в предыдущих примерах я только подписываюсь на появление значения, но нигде не вызываю функции-обработчики. Чтобы конструкция заработала, нужны интерфейсы, чтобы вызывать функции-обработчики, переданные в Subscribe. Нужен поток или пул потоков в фоне, в которые вы сможете, при наступлении событий, запланировать функции-обработчики на исполнение. Они будут исполнены, и вся конструкция заведется.
5.3. Примеры
Мы это можем выразить таким сниппетом. Он утрированный, но что здесь важно: желание посчитать (2v+1) 2 с точки зрения исходного кода очень локально. Мы уже в этом сниппете кода написали, что хотим сделать во все последующие моменты времени и взглядом охватить происходящее.
С точки зрения времени исполнения, картинка не будет соответствовать написанному, но сейчас нас это беспокоить не должно. Мы хотим исправить проблему с понятностью кода.
Второй пример. Имеется несколько функций: первая считает ключ, по которому в БД хранятся секретные послания; вторая читает данные из БД по ключу; третья отправляет данные роверу на другой планете.
Можно скомбинировать все три шага в новую функцию — ExploreOuterSpace. С точки зрения кода она состоит просто из цепочки вызовов Then; логика работы функции — последовательная композиция действий — помещается на один экран, её просто понять и осознать. При этом все шаги исполнения будут во времени (скорее всего) разнесены. Темпоральный характер связывания вынесен за скобки.
5.4. Any-комбинатор
Приятный бонус: если применять конструкцию с Future в среде с параллелизмом, то можно выстроить ещё более интересные комбинаторы, которых нет в однопоточном последовательном коде. Например, можем сказать, что мы бы хотели дождаться появления любого из двух значений:
Мы создаем обещание, что в будущем посчитаем это Any-значение, и в подписке на два Future устраиваем гонку: кто первый успел, тот и молодец. То есть если в контейнере пусто, то кладем туда значение, которое появилось.
Это может понадобиться, к примеру, когда у нас есть две базы данных, и мы читаем из обеих, и смотрим, откуда быстрее пришел ответ. То есть пишем код вида «Отправить запрос в DB1, отправить запрос в DB2, и как только получили любой из ответов — делаем что-то ещё».
5.5. All-комбинатор
Симметрично можем устраивать барьеры. Если из двух баз данных мы читаем две существенно разные записи и хотим комбинировать их у себя локально, то можно один раз написать барьерную логику, создать контейнер, и заполнять его частями (пары T1 и T2), прописаться в обработчике T1 и T2 на появление значений, положить их соответствующие компоненты контейнера и завести счетчик, сколько шагов осталось.
Вспомним пример с nginx. Там отслеживание того, какие части обработки запросы уже закончены, был явный и привязанный к конкретному домену приложения. То есть в случае nginx были выделены стадии «разбираю заголовки», «читаю тело запроса», «пишу заголовки и ответа ответа» и так далее. В All-комбинаторе логика по отслеживанию завершенных шагов абстрагирована до подсчета того, сколько фрагментов уже закончило работу. Это позволяет схлопнуть сложность нашего приложения.
5.6. Адаптация обратного вызова
Третий плюс Future и Promises — они просто интегрируются с legacy-кодом, построенным на коллбеках. Можно элегантно подцепить существующие callback-ориентированные библиотеки в наш код, обернув их в несложную обертку, устроенную простым образом: мы создаем Future, который сразу же и возвращает, а в callback-функции заполняем Future.
Итого: мы получили простой и читаемый код, инструменты для композиции нашего кода и сохранили возможность реализовать исполнение фрагментов кода поверх Future неблокирующим образом.
6. Как сделать код читаемее с помощью корутин
Иногда возникают ситуации, когда у нас возникает понятный, но нелинейный по данным код. Рассмотрим сниппет.
У меня есть некая обработка запроса. Я получаю структуру Request, хожу за какими-то данным в бэкэнд. Они закодированы, поэтому их нужно отдельно декодировать. А потом, чтобы прислать ответ, нужно на руках одновременно иметь и декодированные данные, и оригинальную структуру запроса. К примеру, чтобы пробросить какой-то заголовок из моего запроса в ответ.
Вроде бы, связка читаема, но выглядит не совсем аккуратно. Что делать? Типичный подход — всё рефакторить, не пробрасывать отдельно request и payload, а завести абстрактный контекст — один аргумент, который всюду будем протаскивать сквозным образом.
Например, так в Java устроена библиотека Netty. Удобно, но тогда контекст становится общей тарелкой, куда разные фрагменты кода пишут или читают значения. Сложно понять, что в этом контексте реально заполнено и когда, и что там происходит.
Если бы мы писали синхронный код, то позвали бы GetRequest, QueryBackend, HandlePayload и потом Reply от двух аргументов, если бы не было Future.
Чтобы воплотить фантазии в реальность, нужен некий метод, который принимает Future и возвращает T — назовём его WaitFor.
Тогда мы реформируем код таким образом:
Почему стало проще: убрали обертку из Future, которую только что добавили. Код читаемый и удобный. Также теперь в нем явным образом размечены четыре точки, где мы ждем данных. Это точки разрыва нашего потока исполнения.
У нас появляется две опции. Мы в этих точках на уровне нашего приложения можем выбрать как именно мы будем связывать эти два разорванных фрагмента кода. Либо мы можем связывать а-ля уровнь 0, синхронно заблокировать наш тред через, к примеру, mutex+cvar внутри future. Либо можем поставить себе задачу сделать неблокирующее ожидание. Мы освободим квант времени в нашем приложении под другие задачи, а текущий поток исполнения заморозим.
6.1. Корутины
Это функции, в которых есть множество точек входа и выхода. Если вспомнить картинку с черными и красными кружочками, то там, где черный меняется на красный, нам надо выйти, потому что есть какие-то события, которых нужно подождать. Но если мы прерываем корутину, нам нужно выйти куда-то.
В нашем случае каждая точка ожидания — «выход» в некую сущность более высокого уровня, которая решит, какой задачей заняться далее. На уровне операционной системы это планировщик задач. У нас будет свой планировщик. Модельная имплементация: boost::asio и boost::fiber.
Чтобы переходить от текущего логического потока исполнения в планировщик, нам понадобится возможность менять контекст исполнения физического процессора и прыгать из одной точки программы в другую. Как это сделать?
6.2. Черновик реализации WaitFor
Есть разные инструменты, например, boost::context, который в сущности сводится к интерфейсу такого вида: есть структура, отвечающая за контекст; есть функция, которая принимает один контекст для сохранения текущего контекста и один контекст для загрузки следующего. В ассемблере для x86/64 типичная реализация выписывает все регистры на стек, сохраняет в структуре текущий указатель на стек, далее заменяет указатель на стек новым и восстанавливает регистры.
Пока можно думать об этой функции, как об управляемом в процессе исполнения goto: когда в коде говорите, что прыгните в другую точку, но она задается не статически в момент компиляции, а подбирается динамически.
Чтобы доработать конструкцию до рабочего состояния, нужно как-то оперировать запланированными задачами. Обычно планируемую задачу называют Fiber — логический поток выполнения. Мы её будем представлять как пару контекст+Future. Для того, чтобы привязывать все конструкции с ожиданием, будем хранить в каждом логическом потоке выполнения ту Future, которую он сейчас ждет.
Здесь Future будет либо пустой, если мы ничего не ждем, либо некий заполненный объект, если мы ожидаем этот объект. Планировщик устроен таким образом: основная функция Loop, которая содержит цикл основной работы программы, очередь логических потоков выполнения, которые нужно запланировать, указатель на текущий поток выполнения и контекст планировщика, в который будем переключаться.
Как будет выглядеть функция WaitFor?
Здесь важное наблюдение: для того, чтобы дождаться какого-то значения в будущем, нам не очень важен его тип, поэтому мы хотим работать с Future поверх void, и конкретный тип значения нас интересовать не будет. И нам придется только один раз реализовать логику по темпоральной привязке.
Конструкция Future игнорирует тип контейнера, использует его как абстрактный контейнер, который будет заполнен когда-то в будущем.
Тогда функция WaitFor будет выглядеть следующим образом: я говорю планировщику: «Я бы хотел в текущем Fiber дождаться появления Future», и в следующей инструкции моей текущей сопрограммы (потока выполнения) сразу из этого контейнера хочу извлечь значение.
Как планировщик будет обслуживать моё желание? Он запомнит, что текущий поток выполнения ожидает некоторый Future, и перейдет обратно в цикл планирования.
6.3. Как устроен цикл планирования
Раз мы что-то уже запустили, наверное, у нас была очередь, из которой мы извлекли какой-то готовый к планированию логический поток, и перепрыгнули в него. Когда мы сделали SwitchContext из нашего потока, возвращаемся в точку 2 — из логического потока выполнения возвращаемся в планировщик.
Что происходит дальше? Нам нужно понять, если поток выполнения, из которого мы вернулись, попросил нас дождаться появления Future, то тогда подпишемся на этот ожидаемый Future, чтобы, при появлении значения в будущем, мы могли обратно поставить наш логический поток выполнения в цикл планирования.
Чтобы не запутаться, можно посмотреть на два этих слайда. Ход исполнения программы будет устроен таким образом:
Когда я зову функцию WaitFor — перехожу в планировщик.
Планировщик с помощью Switch-контекста прыгает в основной цикл.
Дальше я подписываюсь на Future и в будущем (обозначено синей стрелкой), когда значение появится, я положу в очередь исполнения обратно мой логический поток. Тогда в какой-то следующей итерации я этот логический поток вытащу из очереди и перепрыгну обратно в мой Fiber.
Вернувшись в WaitFor я смогу извлечь из Future значение, и там уже точно что-то будет, Future будет заполнен. Мы получим такую классную конструкцию:
Мы сможем написать код, который выглядит как синхронный, в нем явно размечены точки ожидания, они хорошо читаются. При этом сохранены возможности приложения влиять на планирование задач, а переключать контекст дешевле, чем это делает ядро.
6.4. Coroutine TS
Можно ли сделать лучше? В будущем — да. Когда будет Coroutine TS, можно будет убрать скобочки в коде и сказать, что WaitFor и CoroutineWait, который получается из CoroutineTS — это более-менее одинаковые сущности. Это явно размеченные точки ожидания, где нам нужно прерывать текущий поток выполнения и чего-то дождаться. Соответственно, можно будет реализовать Waiter и Co, которые будут делать всё то, что на предыдущих слайдах.
7. Что получили?
Давайте подведем итог. С помощью корутин мы получили механизм для написания читаемого внятного кода, похожего на синхронный, сохранили возможность улучшать производительность, реализуя всю сложную машинерию. Но заплатили за это тем, что реализовали свой планировщик из операционной системы, и нам нужно его поддерживать, отлаживать и так далее.
Нужно это или нет — хороший вопрос. Нужно посмотреть на цифры, когда вообще этот механизм стоит использовать и какие у него есть плюсы и минусы по сравнению с другими. Переключение контекста процессора стоит порядка сотни наносекунд. Это на два порядка быстрее, чем переключить контекст нити исполнения. Соответственно, если в вашем коде фрагменты полезной логики очень маленькие, и время их выполнения сопоставимо с переключением контекста, тогда вы сможете оптимизировать свое приложение за счет того, что вы будете явно управлять многозадачностью.
Именно поэтому все сетевые вещи типа веб-серверов строятся как асинхронный код, потому что там действий по существу очень мало, там нужно перекладывать байты из одной корзины в другую корзину и ничего больше не делать. Там нет никаких симуляций, сложных решений. Процессор там по большому счету простаивает, и поэтому мы можем срезать накладные расходы на взаимодействие с ядром и больше делать в приложении, меньше платя за накладные расходы по переключению процессов или нитей.
Как понять из всех этих четырех вариантов написания кода, что делать дальше? Для этого нужно подумать, чего мы хотим как разработчики.
Давайте кратко резюмируем. На нулевом уровне мы писали понятный код, не писали кода по планированию, по ожиданию событий, по выбору, что делать дальше и так далее. Этими задачами за нас занималась операционная система. Если у вас не очень нагруженное приложение, и вам достаточно этого уровня, то дальше идти не нужно, вы можете здесь остаться, и все будет хорошо.
Если вы автор nginx, у вас, к сожалению, тернистый путь, вам нужно явно работать с низкими уровнями, вписать этот сложный код. И если вы хотите максимально уменьшать накладные расходы, то вы срезаете все абстракции, в частности, не используете никаких future и promises.
Если вы оказываетесь в ситуации, когда вы пишете большую и сложную систему, которая должна быть производительна, но не точна до копеек, и вам больше важна скорость вашей разработки, тогда вы готовы слегка пожертвовать производительностью, получив выгоду во времени вашей разработки, чтобы могли быстрее писать новый код.
Тогда вам будут удобны абстракции типа futures, promises и actors. Это может сэкономить время. При этом эти абстракции можно более глубоко интегрировать в язык за счет сопрограмм, как я постарался проиллюстрировать.
Важно: если вы работаете с асинхронным кодом, поймите, что вы умеете, и какую задачу вы решаете. Сейчас кажется очень легко найти в интернете материалы по асинхронном программированию на все случаи жизни, кучу библиотек с акторами, файберами, корутинами и вашими любимыми новыми технологиями, которые появятся завтра. Но нужно ли их использовать? Подумайте, это важный вопрос.
Минутка рекламы. 19-20 апреля в Москве пройдёт конференция C++ Russia 2019. На ней будут доклады похожей тематики, например, Grimm Rainer приедет с докладом «Concurrency and parallelism in C++17 and C++20/23», а Павел Новиков расскажет об асинхронной разработке на C++. Подробнее программу можно посмотреть на официальном сайте, там же можно приобрести билет. Обратите внимание, что если вы не сможете приехать на конференцию вживую, есть билеты на онлайн-трансляцию.