Асинхронное программирование: концепция, реализация, примеры
Разбираемся, чем асинхронное программирование отличается от синхронного, зачем оно нужно, и как реализуется асинхронность в разных языках.
Компьютерные программы часто имеют дело с длительными процессами. Например, получают данные из базы или производят сложные вычисления. Пока выполняется одна операция, можно было бы завершить еще несколько. А бездействие приводит к снижению продуктивности и убыткам. Асинхронное программирование увеличивает эффективность, потому что не позволяет блокировать основной поток выполнения.
Тенденции
Асинхронность была всегда, но в последние годы этот стиль разработки стал особенно популярным. Все современные языки имеют инструменты для его реализации и постоянно улучшают их. Например, от событий и функций обратного вызова мы перешли к обещаниям. Также существует множество библиотек асинхронности, например, ReactiveX, которая работает в Java, C#, Swift, JavaScript и ряде других языков.
В мире, где никто не любит ждать, просто нельзя писать код синхронно! Чтобы не отставать от современных тенденций, нужно освоить асинхронное программирование.
Человек в синхронном мире
Один занятой молодой человек запланировал на вечер свидание. Он очень хочет, чтобы все прошло идеально, а для этого нужно сделать несколько дел:
- разобраться с рабочими документами;
- забрать костюм из химчистки;
- съездить в цветочный магазин за букетом лилий;
- а самое главное – попросить маму приготовить ее фирменный торт.
Без торта, букета, костюма и стопки разобранных бумаг, свидание точно не состоится.
Молодой человек живет в синхронном мире. Это значит, что он не может приступить к следующему делу, пока не закончится предыдущее.
Прежде всего, нужно отправить запрос на торт, так как приготовление занимает несколько часов. Он звонит маме, и она тут же начинает замешивать тесто. К вечеру торт несомненно будет готов. Однако молодой человек не успеет сделать остальные дела, и свидание не состоится. Дело в том, что все это время он провел с трубкой у уха, ожидая подтверждения о завершении запроса. Бессердечный синхронный мир не позволил ему поработать и купить букет.
Решить проблему могло бы асинхронное программирование. С его помощью блокирующий процесс маминой готовки можно убрать из потока приготовления к свиданию.
В асинхронном мире человек не зависит от торта. Он просит маму перезвонить, а сам едет за парадным костюмом в химчистку. Когда выложена последняя вишенка, мама запускает событие "Торт готов". Нарядный молодой человек хватает букет и бежит на свидание.
Асинхронное программирование
В синхронном коде каждая операция ожидает окончания предыдущей. Поэтому вся программа может зависнуть, если одна из команд выполняется очень долго.
Асинхронный код убирает блокирующую операцию из основного потока программы, так что она продолжает выполняться, но где-то в другом месте, а обработчик может идти дальше. Проще говоря, главный "процесс" ставит задачу и передает ее другому независимому "процессу".
Запрос данных
Асинхронное программирование успешно решает множество задач. Одна из самых важных – доступность интерфейса пользователя.
Возьмем для примера приложение, которое подбирает фильм по указанным критериям. После того как пользователь выбрал параметры, программа отправляет запрос на сервер. А там происходит подбор подходящих картин.
Обработка может длиться довольно долго. Если приложение работает синхронно, то пользователь не сможет взаимодействовать со страницей, пока не придет результат. Он не сможет даже скроллить!
Асинхронный код позволяет скрыть от пользователя эти неприятные эффекты и сохранить подвижность страницы. После того как данные загрузятся, программа выведет их на экран.
В этом случае главный поток выполнения разделяется на две ветви. Одна из них продолжает заниматься интерфейсом, а другая выполняет запрос.
// создание и отправка запроса к серверу let xhr = new XMLHttpRequest(); xhr.open('GET', '/my/url', true); xhr.send(); // обработчик обратного вызова xhr.onreadystatechange = function() { if (this.readyState != 4) return; if (this.status != 200) { console.log( 'ошибка' ); return; } let result = this.responseText; ... }
Завершение асинхронной операции
Тут возникает проблема. Когда запрос завершится в дополнительной ветви, как об этом узнает главная? Как вернуть полученное значение в основной поток, если это необходимо? Для этого существуют события и механизм обратного вызова.
Если запрос выполняется асинхронно, то он может оповестить всех желающих о своем окончании. Программа подписывается на это сообщение и регистрирует для него обработчик. Когда придет время, запрос создаст событие и уведомит подписчиков.
Обработчик продолжает выполнять последующий код, пока не получит сообщение. Тогда он прервется и обработает его.
Асинхронных операций в программе может быть несколько. Чтобы разобраться с многочисленными событиями, существует специальная очередь. Она работает по принципу "первый пришел – первый ушел".
Чтобы детальнее изучить механизм обратного вызова, обратимся к Node.JS. В сердце этой платформы лежит библиотека LibUV. Она написана на языке C и способна наладить контакт с различными операционными системами. В Windows, Linux или MacOS библиотека чувствует себя как рыба в воде.
LibUV берет на себя фундаментальную задачу управления операциями ввода-вывода в Node.JS. При этом взаимодействие с программистом происходит через нескольких посредников:
- Сначала JavaScript код формулирует задачу и отдает ее платформе Node.JS. Например, это может быть чтение файла или отправка запроса по сети.
- Node.JS, как настоящий руководитель, делегирует задачу LibUV, полностью полагаясь на ее способности.
- Библиотека находит подход к конкретной ОС и передает команду.
Любой скрипт в однопоточной Node.JS запускается в режиме цикла. Это значит, что выполнение синхронного JavaScript-кода постоянно чередуется с асинхронными событиями, например, обработкой ввода-выводы или таймерами. Пока есть, что обрабатывать, этот цикл не остановится.
В глубины коллбэков
Запустим простой сервер и на его примере проследим, как происходит передача управления.
var http = require('http'); var fs = require('fs'); var server = new http.Server(); server.on('request', function(req, res){ // обработка события request }); server.listen(3000);
Сначала в программе безраздельно царствует JavaScript. Он подключает модули, создает объект сервера и устанавливает обработчик события request
. То, что находится внутри функции обратного вызова в данный момент не имеет никакого значения.
Все это происходит в глобальном контексте выполнения кода.
Наконец, вызывается метод сервера listen
. Он работает с сетевыми соединениями, поэтому управление передается платформе Node.JS. Команда обрабатывается рядом внутренних методов, пока не достигнет библиотеки LibUV. Ее метод uv_listen
находит указанный порт и устанавливает наблюдение за ним.
Теперь ни одно событие порта не ускользнет от внимания LibUV. А сейчас библиотека просто отправляет сообщение о том, что все прошло успешно. Управление передается сначала Node.JS, а затем JavaScript-коду.
Но у JS-интерпретатора больше нет никаких дел. Последняя строка уже выполнена, метод server.listen
завершен. Обработчик кода снова находится в глобальном контексте выполнения. Пора заканчивать программу? Но как тогда быть с отслеживанием события request
?
Прежде чем свернуть работу, Node.JS спросит у LibUV, не осталось ли внутренних наблюдателей. Если следить больше не за чем, то цикл остановится. Однако, в нашем случае один наблюдатель все же завалялся. Поэтому программа не завершает работу, а просто засыпает.
Сон длится ровно до того момента, как операционная система просигнализирует о присоединении к порту. Сработает наблюдатель LibUV, и после некоторых обработок сигнал попадет в Node.JS и JavaScript-код в виде события request
на объекте сервера. JavaScript в ответ запускает функцию обратного вызова.
Запуск коллбэка никак не связан во времени с его регистрацией. Между этими событиями могло пройти несколько часов - вот где асинхронность!
Функция, обрабатывающая запрос, запускается из глобального контекста. Именно в нем находился интерпретатор после старта сервера. Когда обработка завершится, он вновь вернется сюда и просигнализирует о том, что программу можно заканчивать. LibUV снова пойдет проверять своих наблюдателей. Цикл повторится.
Проблемы обратных вызовов
Обработчик события сам по себе может блокировать поток выполнения кода. Например, если внутри него синхронно выполняются сложные операции. Это возвращает нас к проблеме ожидания и зависания программы.
Чтобы избежать блокировки, можно сделать еще один обратный вызов. На самом деле, технически уровней вложенности может быть сколько угодно. Однако в большом количестве функций легко запутаться. Подобные конструкции называются адом обратных вызовов и являются плохим стилем кода.
function getPassport(data, callback) {...} function getVisa(data, callback) {...} function buyTickets(data, callback) {...} function bookHotel(data, callback) {...} getPassport( data, getVisa( passport, buyTickets( visa, bookHotel( ticket, function() { console.log("Можно ехать в отпуск"); } ) ) ) );
В коде можно найти 4 асинхронные функции: getPassport
, getVisa
, buyTickets
, bookHotel
. Каждая из них принимает на вход данные и функцию обратного вызова.
Общая схема программы:
- Подать документы на загранпаспорт.
- Оформить визу.
- Купить билетов на самолет.
- Забронировать отель.
- Вывести сообщение о том, что можно ехать в отпуск.
Другие решения
События выполнения и обратные вызовы – это классическая схема асинхронной модели. Так она реализована в большинстве языков. Однако у нее есть ряд недостатков.
Сейчас существуют более удобные инструменты для работы с асинхронностью. Их можно разделить на две группы.
Первая из них возвращает "обещания". Сюда относятся deferred, promises и futures.
Вторая реализует асинхронность вычислений. Это конструкции с ключевыми словами async/await. Впервые эта архитектура возникла в C#, но ее преимущества быстро оценили в других языках.
Немного терминов
Когда речь заходит об асинхронности, всплывают еще три близких понятия. Это конкурентность (concurrency), параллелизм (parallel execution) и многопоточность (multithreading). Все они связаны с одновременным выполнением задач, однако это не одно и то же.
Конкурентность
Понятие конкурентного исполнения самое общее. Оно буквально означает, что множество задач решаются в одно время. Можно сказать, что в программе есть несколько логических потоков – по одному на каждую задачу.
При этом потоки могут физически выполняться одновременно, но это не обязательно.
Задачи при этом не связаны друг с другом. Следовательно, не имеет значения, какая из них завершится раньше, а какая позже.
Параллелизм
Параллельное исполнение обычно используется для разделения одной задачи на части для ускорения вычислений.
Например, нужно сделать цветное изображение черно-белым. Обработка верхней половины не отличается от обработки нижней. Следовательно, можно разделить эту задачу на две части и раздать их разным потокам, чтобы ускорить выполнение в два раза.
Наличие двух физических потоков здесь принципиально важно, так как на компьютере с одним вычислительным устройством (процессорным ядром) такой прием провести невозможно.
Многопоточность
Здесь поток является абстракцией, под которой может скрываться и отдельное ядро процессора, и тред ОС. Некоторые языки даже имеют собственные объекты потоков. Таким образом, эта концепция может иметь принципиально разную реализацию.
Асинхронность
Идея асинхронного выполнения заключается в том, что начало и конец одной операции происходят в разное время в разных частях кода. Чтобы получить результат, необходимо подождать, причем время ожидания непредсказуемо.
Шаблоны асинхронности
Можно выделить три самые популярные схемы асинхронных запросов. Рассмотрим их реализацию с помощью "обещаний" (JavaScript) и операторов async-await (C#).
Для демонстрации потребуются тестовые функции, которые имитируют возвращение нужных объектов с задержкой.
JavaScript:
function getPromise(returnValue) { return new Promise((resolve) => { setTimeout(() => { resolve(returnValue); }, 300); }); }
C#:
public static async Task GetStringTask(String toReturn) { await Task.Delay(300); return toReturn; } public static async Task GetIntTask(int toReturn) { await Task.Delay(300); return toReturn; }
Последовательное выполнение
Используется для связанных задач, которые нужно запускать друг за другом. Например, первый запрос получает названия фильмов, а второй – информацию о них.
JavaScript:
getPromise('value1') .then((result) => { return getPromise(result + 'value2'); }).then((result) => { return getPromise(result + 'value3'); }).then((result) => { console.log(result); // => value1value2value3 });
Каждая функция возвращает новый Promise
, выполнение которого также можно отслеживать. В результате получается удобная одноуровневая цепочка обещаний.
C#:
var str = await GetStringTask("Hello world"); var len = await GetIntTask(str.Length); var res = await GetStringTask("Len: " + len); Console.Out.WriteLine(res);
Переменная str
получит значение только тогда, когда отработает функция GetStringTask
. Лишь после этого обработчик кода продолжит выполнение.
Параллельное выполнение
Применяется для решения независимых задач, когда важно, чтобы выполнились все запросы. Например, данные веб-страницы грузятся с трех серверов, а после этого начинается рендеринг.
Promise.all([ promise1, promise2, promise3 ]).then((results) => { ... })
Параметр results
– это массив, в котором содержатся результаты всех трех выполненных операций.
C#:
var tasks = new Task[3]; tasks[0] = GetIntTask(1); tasks[1] = GetIntTask(2); tasks[2] = GetIntTask(3); Task.WaitAll(tasks); for (int i = 0; i < 3; i++) { Console.Out.WriteLine("Res " + i + ": " + tasks[i].Result); }
Метод WaitAll
класса Task
собирает результаты трех запросов вместе.
Конкурентное выполнение
Используется для решения независимых задач, когда важно, чтобы выполнился хотя бы один запрос. Например, отправка идентичных запросов на разные сервера.
JavaScript:
Promise.race([ promise1, promise2, promise3 ]).then((result) => { ... })
В параметр result
попадет первый вернувшийся результат из трех.
C#:
var tasks = new Task[3]; tasks[0] = GetIntTask(1); tasks[1] = GetIntTask(2); tasks[2] = GetIntTask(3); int firstResult = Task.WaitAny(tasks); Console.Out.WriteLine("Res " + firstResult);
Метод WaitAny
дождется самого первого выполнения и положит его в переменную firstResult
.
Это лишь простые примеры использования асинхронных инструментов в разных языках. Чтобы писать эффективный и понятный код, необходимо познакомиться с ними поближе. Например, почитать про обещания можно здесь и здесь.