Красноречивый JavaScript 4-е издание (2024)
https://eloquentjavascript.net

стр.11
https://eloquentjavascript.net/11_async.html
Асинхронное программирование
Кто может спокойно ждать, пока уляжется грязь? Кто может оставаться неподвижным до момента действия? Лао-цзы, Тао Дэ Цзин

Центральная часть компьютера, часть, которая выполняет отдельные шаги, составляющие наши программы, называется процессором . Программы, которые мы видели до сих пор, будут держать процессор занятым, пока они не закончат свою работу. Скорость, с которой может быть выполнено что-то вроде цикла, который манипулирует числами, зависит практически полностью от скорости процессора и памяти компьютера.
Но многие программы взаимодействуют с вещами за пределами процессора. Например, они могут общаться по компьютерной сети или запрашивать данные с жесткого диска, что намного медленнее, чем получать их из памяти.
Когда такое происходит, было бы обидно оставлять процессор без дела — в это время он мог бы заняться другой работой. Частично этим занимается ваша операционная система, которая переключает процессор между несколькими запущенными программами. Но это не поможет, когда мы хотим, чтобы одна программа могла работать, ожидая сетевого запроса.
Асинхронность
В модели синхронного программирования все происходит по одному. Когда вы вызываете функцию, которая выполняет длительное действие, она возвращается только после завершения действия и может вернуть результат. Это останавливает вашу программу на время, необходимое для выполнения действия.
Асинхронная модель позволяет нескольким вещам происходить одновременно. Когда вы начинаете действие, ваша программа продолжает работать. Когда действие завершается, программа информируется и получает доступ к результату (например, к данным, считанным с диска) .
Мы можем сравнить синхронное и асинхронное программирование, используя небольшой пример: программу, которая делает два запроса по сети, а затем объединяет результаты.
В синхронной среде, где функция запроса возвращается только после того, как она выполнила свою работу, самый простой способ выполнить эту задачу — сделать запросы один за другим. Это имеет тот недостаток, что второй запрос будет запущен только после завершения первого. Общее затраченное время будет как минимум суммой двух времен ответа.
Решение этой проблемы в синхронной системе заключается в запуске дополнительных потоков управления. Поток — это другая запущенная программа, выполнение которой может чередоваться с другими программами операционной системой, поскольку большинство современных компьютеров содержат несколько процессоров, несколько потоков могут даже работать одновременно на разных процессорах. Второй поток может запустить второй запрос, а затем оба потока будут ждать возвращения своих результатов, после чего они повторно синхронизируются, чтобы объединить свои результаты.
На следующей диаграмме толстые линии представляют время, которое программа тратит на обычную работу, а тонкие линии представляют время, потраченное на ожидание сети. В синхронной модели время, затраченное сетью, является частью временной шкалы для данного потока управления. В асинхронной модели запуск сетевого действия позволяет программе продолжать работу, пока сетевое взаимодействие происходит параллельно с ней, уведомляя программу о его завершении.
Диаграмма, показывающая поток управления в синхронных и асинхронных программах. Первая часть показывает синхронную программу, где активные и ожидающие фазы программы происходят на одной последовательной строке. Вторая часть показывает многопоточную синхронную программу с двумя параллельными строками, на которых ожидающие части происходят рядом друг с другом, заставляя программу завершаться быстрее. Последняя часть показывает асинхронную программу, где несколько асинхронных действий ответвляются от основной программы, которая в какой-то момент останавливается, а затем возобновляется, когда завершается первое, чего она ждала.Другой способ описать разницу заключается в том, что ожидание завершения действий подразумевается в синхронной модели, тогда как в асинхронной оно явное — под нашим контролем.
Асинхронность — палка о двух концах. Она упрощает выражение программ, которые не соответствуют прямолинейной модели управления, но она также может сделать выражение программ, которые следуют прямой линии, более неуклюжим. Мы увидим несколько способов уменьшить эту неуклюжесть позже в этой главе.
Обе известные платформы программирования JavaScript — браузеры и Node.js — делают операции, которые могут занять некоторое время, асинхронными, а не полагаются на потоки. Поскольку программирование с потоками, как известно, сложно (понять, что делает программа, гораздо сложнее, когда она делает несколько вещей одновременно), это обычно считается хорошим делом.
Обратные вызовы
Один из подходов к асинхронному программированию заключается в том, чтобы заставить функции, которым нужно чего-то ждать, принимать дополнительный аргумент, функцию обратного вызова . Асинхронная функция запускает процесс, настраивает все так, чтобы функция обратного вызова вызывалась после завершения процесса, а затем возвращается.
Например, setTimeoutфункция, доступная как в Node.js, так и в браузерах, ждет заданное количество миллисекунд, а затем вызывает функцию.
setTimeout(() => console.log("Tick"), 500);
Ожидание, как правило, не является важной работой, но оно может быть очень полезным, когда вам нужно организовать что-то в определенное время или проверить, не занимает ли какое-то действие больше времени, чем ожидалось.
Другим примером распространенной асинхронной операции является чтение файла из хранилища устройства. Представьте, что у вас есть функция readTextFile, которая считывает содержимое файла как строку и передает его в функцию обратного вызова.
readTextFile("shopping_list.txt", content => {
console.log(`Shopping List:\n${content}`);
});
// → Shopping List:
// → Peanut butter
// → Bananas
Функция readTextFileне является частью стандартного JavaScript. Мы увидим, как читать файлы в браузере и в Node.js в последующих главах.
Выполнение нескольких асинхронных действий подряд с использованием обратных вызовов означает, что вам придется продолжать передавать новые функции для обработки продолжения вычислений после действий. Асинхронная функция, которая сравнивает два файла и выдает логическое значение, указывающее, является ли их содержимое одинаковым, может выглядеть следующим образом:
function compareFiles(fileA, fileB, callback) {
readTextFile(fileA, contentA => {
readTextFile(fileB, contentB => {
callback(contentA == contentB);
});
});
}
Этот стиль программирования работоспособен, но уровень отступа увеличивается с каждым асинхронным действием, потому что вы попадаете в другую функцию. Выполнение более сложных вещей, таких как обёртывание асинхронных действий в цикл, может стать неудобным.
В некотором смысле асинхронность заразительна . Любая функция, вызывающая функцию, которая работает асинхронно, сама должна быть асинхронной, используя обратный вызов или аналогичный механизм для доставки своего результата. Вызов обратного вызова несколько сложнее и подвержен ошибкам, чем простой возврат значения, поэтому необходимость структурировать большие части вашей программы таким образом не очень хороша.
Обещания
Немного другой способ создания асинхронной программы — заставить асинхронные функции возвращать объект, представляющий ее (будущий) результат, вместо передачи функций обратного вызова. Таким образом, такие функции фактически возвращают что-то осмысленное, и форма программы больше напоминает форму синхронных программ.
Вот Promiseдля чего нужен стандартный класс. Обещание — это квитанция, представляющая значение, которое может быть еще недоступно. Оно предоставляет thenметод, позволяющий вам зарегистрировать функцию, которая должна быть вызвана, когда действие, которого оно ожидает, завершится. Когда обещание разрешается , то есть его значение становится доступным, такие функции (их может быть несколько) вызываются со значением результата. Можно вызвать thenобещание, которое уже разрешено, — ваша функция все равно будет вызвана.
Самый простой способ создать обещание — вызвать Promise.resolve. Эта функция гарантирует, что значение, которое вы ей даете, будет упаковано в обещание. Если это уже обещание, оно просто возвращается. В противном случае вы получаете новое обещание, которое немедленно разрешается с вашим значением в качестве результата.
let fifteen = Promise.resolve(15);
fifteen.then(value => console.log(`Got ${value}`));
// → Got 15
пусть пятнадцать = Обещание.решить( 15 ); fifth.then( value => console.log( `Получил ${value} ` )); // → Получил 15
Чтобы создать обещание, которое не разрешается немедленно, можно использовать Promiseas конструктор. У него довольно странный интерфейс: конструктор ожидает функцию в качестве своего аргумента, которую он немедленно вызывает, передавая ей функцию, которую он может использовать для разрешения обещания.
Например, вот как можно создать интерфейс на основе обещаний для readTextFileфункции:
функция textFile ( имя_файла ) { return new Promise( разрешение => { readTextFile(имя_файла, текст => resolve(текст)); }); } textFile( "plans.txt" ).then(console.log);
Обратите внимание, что в отличие от функций обратного вызова эта асинхронная функция возвращает осмысленное значение — обещание предоставить вам содержимое файла в определенный момент в будущем.
Полезная вещь в этом thenметоде заключается в том, что он сам возвращает другой promise. Этот разрешается в значение, возвращаемое функцией обратного вызова, или, если это возвращаемое значение является promise, в значение, в которое разрешается promise. Таким образом, вы можете «связать» несколько вызовов thenвместе, чтобы настроить последовательность асинхронных действий.
Эта функция, которая считывает файл, полный имен файлов, и возвращает содержимое случайного файла из этого списка, демонстрирует такой вид асинхронного конвейера обещаний:
функция randomFile ( listFile ) { return textFile(listFile) .then( content => content.trim().split( " \n " )) .then( ls => ls[Math.floor(Math.random() * ls.length)]) .then( имя_файла => текстовый_файл(имя_файла)); }
Функция возвращает результат этой цепочки thenвызовов. Начальный promise извлекает список файлов в виде строки. Первый thenвызов преобразует эту строку в массив строк, создавая новый promise. Второй thenвызов выбирает случайную строку из этого, создавая третий promise, который возвращает одно имя файла. Последний thenвызов считывает этот файл, поэтому результатом функции в целом является promise, который возвращает содержимое случайного файла.
В этом коде функции, используемые в первых двух thenвызовах, возвращают обычное значение, которое будет немедленно передано в обещание, возвращаемое функцией, thenкогда функция возвращается. Последний thenвызов возвращает обещание ( textFile(filename)), что делает его фактическим асинхронным шагом.
Также было бы возможно выполнить все эти шаги внутри одного thenобратного вызова, поскольку только последний шаг на самом деле асинхронный. Но часто полезны те виды thenоберток, которые выполняют только некоторые синхронные преобразования данных, например, когда вы хотите вернуть обещание, которое производит обработанную версию некоторого асинхронного результата.
функция jsonFile ( имя_файла ) { return textFile(имя_файла).then(JSON.parse); } jsonFile( "package.json" ).then(console.log);
В общем, полезно думать о обещании как об устройстве, которое позволяет коду игнорировать вопрос о том, когда значение должно прибыть. Обычное значение должно фактически существовать, прежде чем мы сможем на него сослаться. Обещанное значение — это значение, которое может уже быть там или может появиться в какой-то момент в будущем. Вычисления, определенные в терминах обещаний, путем их связывания с thenвызовами, выполняются асинхронно по мере того, как их входные данные становятся доступными.
Отказ
Обычные вычисления JavaScript могут потерпеть неудачу, выдав исключение. Асинхронным вычислениям часто нужно что-то подобное. Сетевой запрос может не сработать, файл может не существовать, или какой-то код, являющийся частью асинхронного вычисления, может выдать исключение.
Одной из самых серьезных проблем стиля обратного вызова в асинхронном программировании является то, что он крайне затрудняет обеспечение надлежащего оповещения об ошибках в обратных вызовах.
Распространенное соглашение заключается в использовании первого аргумента обратного вызова для указания того, что действие не удалось, а второго — для передачи значения, созданного действием в случае его успешного выполнения.
someAsyncFunction(( ошибка , значение ) => { если (ошибка) handleError(ошибка); иначе processValue(значение); });
Такие функции обратного вызова должны всегда проверять, получили ли они исключение, и следить за тем, чтобы любые вызванные ими проблемы, включая исключения, выдаваемые вызываемыми ими функциями, были перехвачены и переданы правильной функции.
Обещания упрощают это. Они могут быть либо разрешены (действие успешно завершено), либо отклонены (оно не удалось). Обработчики разрешения (зарегистрированные в then) вызываются только в случае успешного выполнения действия, а отклонения распространяются на новое обещание, возвращаемое then. Когда обработчик выдает исключение, это автоматически приводит к отклонению обещания, созданного его thenвызовом. Если какой-либо элемент в цепочке асинхронных действий терпит неудачу, результат всей цепочки помечается как отклоненный, и обработчики успеха не вызываются после точки, где он потерпел неудачу.
Подобно тому, как разрешение обещания предоставляет значение, отклонение обещания также предоставляет значение, обычно называемое причиной отклонения. Когда исключение в функции обработчика вызывает отклонение, значение исключения используется в качестве причины. Аналогично, когда обработчик возвращает обещание, которое отклонено, это отклонение перетекает в следующее обещание. Есть Promise.rejectфункция, которая создает новое, немедленно отклоненное обещание.
Для явной обработки таких отклонений у обещаний есть catchметод, который регистрирует обработчик, который будет вызван при отклонении обещания, аналогично тому, как thenобработчики обрабатывают нормальное разрешение. Он также очень похож thenтем, что возвращает новое обещание, которое разрешается в значение исходного обещания, когда оно разрешается нормально, и в результат обработчика catchв противном случае. Если catchобработчик выдает ошибку, новое обещание также отклоняется.
В качестве сокращения thenтакже принимает обработчик отклонения в качестве второго аргумента, поэтому вы можете установить оба типа обработчиков за один вызов метода: ..then(acceptHandler, rejectHandler)
Функция, переданная конструктору, Promiseполучает второй аргумент вместе с функцией разрешения, который она может использовать для отклонения нового обещания.
Когда наша readTextFileфункция сталкивается с проблемой, она передает ошибку своей функции обратного вызова в качестве второго аргумента. Наша textFileоболочка должна фактически проверить этот аргумент, чтобы сбой приводил к отклонению возвращаемого ею обещания.
функция textFile ( имя_файла ) { return new Promise(( разрешить , отклонить ) => { readTextFile(имя_файла, ( текст , ошибка ) => { if (ошибка) reject(ошибка); else resolve(текст); }); }); }
Цепочки значений обещаний, созданные вызовами thenи catch, таким образом, образуют конвейер, по которому перемещаются асинхронные значения или сбои. Поскольку такие цепочки создаются путем регистрации обработчиков, с каждой ссылкой связан обработчик успеха или обработчик отклонения (или оба). Обработчики, не соответствующие типу результата (успех или неудача), игнорируются. Обработчики, которые соответствуют, вызываются, и их результат определяет, какое значение будет следующим — успех, когда они возвращают необещающее значение, отклонение, когда они выдают исключение, и результат обещания, когда они возвращают обещание.
новое обещание(( _ , отклонить ) => отклонить( новая ошибка( "Неудача" ))) .then( значение => console.log( "Обработчик 1:" , значение)) .поймать( причина => { console.log( "Обнаружена ошибка " + причина); return "ничего" ; }) .then( value => console.log( "Handler 2:" , value)); // → Обнаружена ошибка Ошибка: Сбой // → Обработчик 2: ничего
Первая thenфункция обработчика не вызывается, поскольку в этой точке конвейера обещание содержит отклонение. catchОбработчик обрабатывает это отклонение и возвращает значение, которое передается второй thenфункции обработчика.
Подобно тому, как неперехваченное исключение обрабатывается средой, среды JavaScript могут определять, когда отклонение обещания не обрабатывается, и сообщать об этом как об ошибке.
Карла
Солнечный день в Берлине. Взлетно-посадочная полоса старого, выведенного из эксплуатации аэропорта кишит велосипедистами и роликовыми коньками. В траве около мусорного контейнера шумно кружит стая ворон, пытаясь убедить группу туристов расстаться со своими сэндвичами.
Одна из ворон выделяется — большая неряшливая самка с несколькими белыми перьями на правом крыле. Она подманивает людей с мастерством и уверенностью, которые говорят о том, что она делает это уже давно. Когда пожилой мужчина отвлекается на выходки другой вороны, она небрежно подлетает, выхватывает у него из рук недоеденную булочку и улетает.
В отличие от остальной группы, которая выглядит так, будто счастлива провести день, бездельничая здесь, большая ворона выглядит целеустремленной. Неся свою добычу, она летит прямо к крыше здания ангара, исчезая в вентиляционном отверстии.
Внутри здания вы можете услышать странный стук — тихий, но настойчивый. Он исходит из узкого пространства под крышей недостроенной лестницы. Ворона сидит там, окруженная своими украденными закусками, полудюжиной смартфонов (некоторые из которых включены) и путаницей кабелей. Она быстро стучит клювом по экрану одного из телефонов. На нем появляются слова. Если бы вы не знали лучше, вы бы подумали, что она печатает.
Эта ворона известна своим сородичам как «cāāw-krö». Но поскольку эти звуки плохо подходят для человеческих голосовых связок, мы будем называть ее Карлой.
Карла — несколько своеобразная ворона. В юности она увлекалась человеческим языком, подслушивая людей, пока не научилась хорошо понимать, что они говорят. Позже ее интерес переключился на человеческие технологии, и она начала воровать телефоны, чтобы изучать их. Ее текущий проект — научиться программировать. Текст, который она печатает в своей скрытой лаборатории, на самом деле является частью асинхронного кода JavaScript.
Взлом
Карла обожает интернет. К ее огорчению, телефон, на котором она работает, скоро исчерпает предоплаченные данные. В здании есть беспроводная сеть, но для доступа к ней требуется код.
К счастью, беспроводным маршрутизаторам в здании 20 лет, и они плохо защищены. Проведя небольшое исследование, Карла обнаруживает, что механизм сетевой аутентификации имеет изъян, который она может использовать. При подключении к сети устройство должно отправить правильный шестизначный пароль. Точка доступа ответит сообщением об успешном или неудачном подключении в зависимости от того, был ли предоставлен правильный код. Однако при отправке частичного кода (например, только трех цифр) ответ будет отличаться в зависимости от того, являются ли эти цифры правильным началом кода или нет. Отправка неправильных цифр немедленно возвращает сообщение об ошибке. При отправке правильных цифр точка доступа ждет больше цифр.
Это позволяет значительно ускорить угадывание числа. Карла может найти первую цифру, пробуя каждое число по очереди, пока не найдет то, которое не вернет немедленно неудачу. Имея одну цифру, она может найти вторую цифру таким же образом, и так далее, пока не узнает весь пароль.
Предположим, у Карлы есть joinWifiфункция. Учитывая имя сети и пароль (в виде строки), функция пытается присоединиться к сети, возвращая обещание, которое разрешается в случае успеха и отклоняется в случае неудачной аутентификации. Первое, что ей нужно, — это способ обернуть обещание так, чтобы оно автоматически отклонялось после того, как это заняло слишком много времени, чтобы позволить программе быстро двигаться дальше, если точка доступа не отвечает.
функция withTimeout ( обещание , время ) { return new Promise(( разрешить , отклонить ) => { обещание.тогда(решить, отклонить); setTimeout(() => reject( "Время ожидания истекло" ), time); }); }
Это использует тот факт, что обещание может быть разрешено или отклонено только один раз. Если обещание, указанное в качестве аргумента, разрешается или отклоняется первым, этот результат будет результатом обещания, возвращаемого withTimeout. Если, с другой стороны, setTimeoutсрабатывает первым, отклоняя обещание, любые дальнейшие вызовы разрешения или отклонения игнорируются.
Чтобы найти весь пароль, программе нужно многократно искать следующую цифру, пробуя каждую цифру. Если аутентификация прошла успешно, мы знаем, что нашли то, что искали. Если она сразу же терпит неудачу, мы знаем, что цифра была неправильной, и должны попробовать следующую цифру. Если время запроса истекло, мы нашли другую правильную цифру и должны продолжить, добавив еще одну цифру.
Поскольку вы не можете ждать обещания внутри forцикла, Карла использует рекурсивную функцию для управления этим процессом. При каждом вызове эта функция получает код, который мы знаем до сих пор, а также следующую цифру для проверки. В зависимости от того, что происходит, она может вернуть готовый код или вызвать себя, чтобы либо начать взламывать следующую позицию в коде, либо попробовать снова с другой цифрой.
function crackPasscode ( networkID ) { function nextDigit ( code , digit ) { let newCode = code + digit; return withTimeout(joinWifi( networkID, newCode), 50 ) .then(() => новыйКод) .catch( failure => { if (failure == "Превышено время ожидания" ) { return nextDigit(newCode, 0 ); } иначе если (цифра < 9 ) { вернуть следующуюцифру(код, цифра + 1 ); } else { выдать ошибку; } }); } вернуть следующуюцифру( "" , 0 ); }
Точка доступа имеет тенденцию отвечать на неверные запросы аутентификации примерно в течение 20 миллисекунд, поэтому в целях безопасности эта функция ждет 50 миллисекунд, прежде чем инициировать тайм-аут запроса.
crackPasscode( "АНГАР 2" ).then(console.log); // → 555555
Карла наклоняет голову и вздыхает. Это было бы более удовлетворительно, если бы код было немного сложнее угадать.
Асинхронные функции
Даже с обещаниями писать такой асинхронный код раздражает. Обещания часто приходится связывать многословными, произвольно выглядящими способами. Чтобы создать асинхронный цикл, Карле пришлось ввести рекурсивную функцию.
То, что функция взлома на самом деле делает, полностью линейно — она всегда ждет завершения предыдущего действия, прежде чем начать следующее. В синхронной модели программирования это было бы более просто выразить.
Хорошей новостью является то, что JavaScript позволяет писать псевдосинхронный код для описания асинхронных вычислений. asyncФункция неявно возвращает обещание и может в своем теле awaitдругие обещания таким образом, чтобы это выглядело синхронно.
Мы можем переписать crackPasscodeэто так:
асинхронная функция crackPasscode ( networkID ) { for ( let code = "" ;;) { for ( let digit = 0 ;; digit++) { let newCode = code + digit; try { await withTimeout(joinWifi(networkID, newCode), 50 ); return newCode; } catch ( failure ) { if ( failure == "Время ожидания истекло" ) { код = новыйКод; перерыв ; } иначе если (цифра == 9 ) { выдать ошибку; } } } } }
Эта версия более наглядно демонстрирует структуру двойного цикла функции (внутренний цикл пробует цифры от 0 до 9, а внешний цикл добавляет цифры к паролю).
Функция asyncпомечается словом asyncперед functionключевым словом. Методы также можно создавать async, записывая asyncперед их именем. Когда вызывается такая функция или метод, он возвращает обещание. Как только функция возвращает что-либо, это обещание разрешается. Если тело выдает исключение, обещание отклоняется.
Внутри asyncфункции слово awaitможет быть помещено перед выражением, чтобы дождаться разрешения обещания и только затем продолжить выполнение функции. Если обещание отклоняется, в точке await.
Такая функция больше не выполняется от начала до конца за один раз, как обычная функция JavaScript. Вместо этого она может быть заморожена в любой точке, которая имеет awaitи может быть возобновлена позднее.
Для большинства асинхронного кода эта нотация удобнее, чем прямое использование обещаний. Вам все равно нужно понимать обещания, поскольку во многих случаях вы все равно будете взаимодействовать с ними напрямую. Но при их связывании вместе asyncфункции, как правило, приятнее писать, чем цепочки thenвызовов.
Генераторы
Эта способность функций приостанавливаться и возобновляться снова не является исключительной для asyncфункций. В JavaScript также есть функция, называемая функциями- генераторами . Они похожи, но без обещаний.
Когда вы определяете функцию с помощью function*(помещая звездочку после слова function), она становится генератором. Когда вы вызываете генератор, он возвращает итератор, который мы уже видели в Главе 6 .
функция * мощности ( n ) { для ( пусть ток = n;; ток *= n) { выход ток; } } для ( пусть мощность мощностей ( 3 )) { если (мощность > 50 ) прерывание ; консоль.log(мощность); } // → 3 // → 9 // → 27
Изначально, когда вы вызываете powers, функция заморожена в начале. Каждый раз, когда вы вызываете nextитератор, функция выполняется до тех пор, пока не встретит yieldвыражение, которое приостанавливает ее и заставляет полученное значение стать следующим значением, произведенным итератором. Когда функция возвращается (та, что в примере, никогда не возвращается), итератор завершается.
Написание итераторов часто становится намного проще, если использовать функции-генераторы. Итератор для Groupкласса (из упражнения в Главе 6 ) можно написать с помощью этого генератора:
Group.prototype[Symbol.iterator] = function * () { for ( let i = 0 ; i < this.members.length ; i++) { yield this.members [i]; } };
Больше нет необходимости создавать объект для хранения состояния итерации — генераторы автоматически сохраняют свое локальное состояние каждый раз, когда они уступают управление.
Такие yieldвыражения могут встречаться только непосредственно в самой функции генератора, а не во внутренней функции, которую вы определяете внутри нее. Состояние, которое генератор сохраняет при уступке, — это только его локальное окружение и позиция, в которой он уступил.
Функция async— это особый тип генератора. При вызове она создает обещание, которое разрешается при возврате (завершении) и отклоняется при выдаче исключения. Всякий раз, когда она выдает (ожидает) обещание, результатом этого обещания (значением или выданным исключением) является результат выражения await.
Проект искусства Corvid
Однажды утром Карла просыпается от незнакомого шума с асфальта снаружи ее ангара. Запрыгнув на край крыши, она видит, как люди готовятся к чему-то. Там много электрических кабелей, сцена и какая-то большая черная стена, которая возводится.
Будучи любопытной вороной, Карла пристальнее рассматривает стену. Похоже, она состоит из ряда больших стеклянных устройств, подключенных к кабелям. На задней стороне устройств написано «LedTec SIG-5030».
Быстрый поиск в Интернете выдает руководство пользователя для этих устройств. Похоже, что это дорожные знаки с программируемой матрицей янтарных светодиодов. Вероятно, люди намеревались отображать на них какую-то информацию во время своего мероприятия. Интересно, что экраны можно программировать по беспроводной сети. Может быть, они подключены к локальной сети здания?
Каждое устройство в сети получает IP-адрес , который другие устройства могут использовать для отправки ему сообщений. Мы поговорим об этом подробнее в Главе 13. Карла замечает, что все ее телефоны получают адреса типа 10.0.0.20или 10.0.0.33. Возможно, стоит попробовать отправить сообщения на все такие адреса и посмотреть, отвечает ли какой-либо из них интерфейсу, описанному в руководстве для знаков.
Глава 18 показывает, как делать реальные запросы в реальных сетях. В этой главе мы будем использовать упрощенную фиктивную функцию, называемую requestсетевой коммуникацией. Эта функция принимает два аргумента — сетевой адрес и сообщение, которое может быть чем угодно, что может быть отправлено как JSON, — и возвращает обещание, которое либо разрешается в ответ от машины по указанному адресу, либо отклоняется, если возникла проблема.
Согласно руководству, вы можете изменить то, что отображается на знаке SIG-5030, отправив ему сообщение с содержанием типа {"command": "display", "data": [0, 0, 3, …]}, где dataсодержит одно число на светодиодную точку, обеспечивая ее яркость — 0 означает выключено, 3 означает максимальную яркость. Каждый знак имеет ширину 50 огней и высоту 30 огней, поэтому команда обновления должна отправить 1500 чисел.
Этот код отправляет сообщение об обновлении дисплея на все адреса в локальной сети, чтобы посмотреть, что зацепится. Каждое из чисел в IP-адресе может быть от 0 до 255. В отправляемых данных он активирует ряд индикаторов, соответствующих последнему числу сетевого адреса.
для ( пусть адрес = 1 ; адрес < 256 ; адрес++) { пусть данные = []; для ( пусть n = 0 ; n < 1500 ; n++) { данные.push(n < адрес ? 3 : 0 ); } пусть ip = `10.0.0. ${addr} ` ; запрос(ip, { команда : "дисплей" , данные }) .then(() => console.log( `Запрос на ${ip} принят` )) .поймать(() => {}); }
Поскольку большинство из этих адресов не будут существовать или не будут принимать такие сообщения, catchвызов гарантирует, что сетевые ошибки не приведут к сбою программы. Все запросы отправляются немедленно, не дожидаясь завершения других запросов, чтобы не тратить время, когда некоторые машины не отвечают.
Запустив сканирование сети, Карла возвращается наружу, чтобы увидеть результат. К ее радости, все экраны теперь показывают полоску света в верхнем левом углу. Они находятся в локальной сети и принимают команды. Она быстро записывает цифры, показанные на каждом экране. Всего экранов девять, три из которых расположены по высоте и три по ширине. Они имеют следующие сетевые адреса:
const screenAddresses = [ "10.0.0.44" , "10.0.0.45" , "10.0.0.41" , "10.0.0.31" , "10.0.0.40" , "10.0.0.42" , "10.0.0.48" , "10.0.0.47" , "10.0.0.46" ];
Теперь это открывает возможности для всякого рода махинаций. Она могла бы повесить на стене гигантскими буквами надпись «вороны правят, люди пускают слюни». Но это кажется немного грубым. Вместо этого она планирует показать видео летящей вороны, закрывающей все экраны ночью.
Карла находит подходящий видеоклип, в котором полторы секунды отснятого материала можно повторить, чтобы создать зацикленное видео, демонстрирующее взмах крыльев вороны. Чтобы поместиться на девяти экранах (каждый из которых может отображать 50×30 пикселей), Карла разрезает и изменяет размер видео, чтобы получить серию изображений 150×90, 10 в секунду. Затем каждое из них разрезается на девять прямоугольников и обрабатывается так, чтобы темные пятна на видео (где есть ворона) показывали яркий свет, а светлые пятна (без вороны) оставались темными, что должно создать эффект янтарной вороны, летящей на черном фоне.
Она настроила clipImagesпеременную для хранения массива кадров, где каждый кадр представлен массивом из девяти наборов пикселей — по одному для каждого экрана — в формате, который ожидают знаки.
Чтобы отобразить один кадр видео, Карле нужно отправить запрос на все экраны одновременно. Но ей также нужно дождаться результата этих запросов, как для того, чтобы не начинать отправлять следующий кадр, пока текущий не будет правильно отправлен, так и для того, чтобы заметить, когда запросы терпят неудачу.
Promiseимеет статический метод all, который может быть использован для преобразования массива обещаний в одно обещание, которое разрешается в массив результатов. Это обеспечивает удобный способ, чтобы некоторые асинхронные действия происходили рядом друг с другом, ждали их завершения, а затем что-то делали с их результатами (или, по крайней мере, ждали их, чтобы убедиться, что они не дадут сбой).
function displayFrame ( frame ) { return Promise.all(frame.map(( data , i ) => { return request(screenAddresses[i], { command : "display" , data }); })); }
Это отображает изображения в frame(который является массивом массивов данных отображения) для создания массива обещаний запроса. Затем он возвращает обещание, которое объединяет все это.
Чтобы иметь возможность остановить воспроизведение видео, процесс обернут в класс. Этот класс имеет асинхронный playметод, который возвращает обещание, которое разрешается только тогда, когда воспроизведение снова останавливается с помощью stopметода.
функция wait ( время ) { return new Promise( принять => setTimeout(принять, время)); } класс VideoPlayer { конструктор ( frames , frameTime ) { этот . frames = frames; этот . frameTime = frameTime; этот . stopped = true; } async play () { this.stopped = false; for ( let i = 0 ; ! this.stopped ; i++) { let nextFrame = wait( this.frameTime ); await displayFrame( this.frames [i % this.frames.length ]); await nextFrame; } } остановить () { это .остановлено = правда; } }
Функция waitоборачивает setTimeoutобещание, которое разрешается после указанного количества миллисекунд. Это полезно для управления скоростью воспроизведения.
пусть видео = новый VideoPlayer(clipImages, 100 ); видео.воспроизведение().поймать( е => { console.log( "Воспроизведение не удалось: " + e); }); setTimeout(() => video.stop(), 15000 );
В течение всей недели, пока стоит эта стена, каждый вечер, когда темнеет, на ней таинственным образом появляется огромная светящаяся оранжевая птица.
Цикл событий
Асинхронная программа запускается с запуска своего основного скрипта, который часто настраивает обратные вызовы, которые будут вызваны позже. Этот основной скрипт, а также обратные вызовы, выполняются до завершения одним куском, без прерываний. Но между ними программа может простаивать, ожидая, что что-то произойдет.
Поэтому обратные вызовы не вызываются напрямую кодом, который их запланировал. Если я вызываю setTimeoutиз функции, эта функция вернет управление к моменту вызова функции обратного вызова. И когда обратный вызов возвращается, управление не возвращается к функции, которая его запланировала.
Асинхронное поведение происходит в своем собственном пустом стеке вызовов функций. Это одна из причин, по которой без обещаний так сложно управлять исключениями в асинхронном коде. Поскольку каждый обратный вызов начинается с почти пустого стека, ваши catchобработчики не будут находиться в стеке, когда они выдают исключение.
пытаться { установитьTimeout(() => { throw new Error( "Ух ты" ); }, 20 ); } catch ( e ) { // Это не запустится console.log( "Caught" , e); }
Независимо от того, насколько близко друг к другу происходят события, такие как тайм-ауты или входящие запросы, среда JavaScript будет запускать только одну программу за раз. Вы можете представить это как запуск большого цикла вокруг вашей программы, называемого циклом событий . Когда ничего не нужно делать, этот цикл приостанавливается. Но по мере поступления событий они добавляются в очередь, и их код выполняется один за другим. Поскольку никакие две вещи не выполняются одновременно, медленно работающий код может задержать обработку других событий.
В этом примере устанавливается тайм-аут, но затем происходит задержка до момента, указанного в тайм-ауте, из-за чего тайм-аут задерживается.
пусть начало = Дата.сейчас(); установитьTimeout(() => { console.log( "Время ожидания истекло" , Date.now() - start); }, 20 ); пока (Дата.сейчас() < начало + 50 ) {} console.log( "Потрачено времени до" , Date.now() - start); // → Потрачено времени до 50 // → Тайм-аут истек на 55
Обещания всегда разрешаются или отклоняются как новое событие. Даже если обещание уже разрешено, ожидание его приведет к тому, что ваш обратный вызов будет запущен после завершения текущего скрипта, а не сразу.
Promise.resolve( "Готово" ).then(console.log); console.log( "Я первый!" ); // → Я первый! // → Готово
В последующих главах мы рассмотрим различные другие типы событий, которые происходят в цикле событий.
Асинхронные ошибки
Когда ваша программа выполняется синхронно, за один заход, не происходит никаких изменений состояния, кроме тех, которые делает сама программа. Для асинхронных программ это по-другому — они могут иметь пробелы в своем выполнении, во время которых может выполняться другой код.
Давайте рассмотрим пример. Это функция, которая пытается сообщить размер каждого файла в массиве файлов, проверяя, что считывает их все одновременно, а не последовательно.
асинхронная функция fileSizes ( files ) { let list = "" ; await Promise.all(files.map( async fileName => { список += имя_файла + ": " + ( await textFile(fileName)).length + " \n " ; })); список возврата ; }
В этой async fileName =>части показано, как можно также создавать стрелочные функции , помещая перед ними asyncслово .async
Код не выглядит подозрительным… он сопоставляет asyncстрелочную функцию с массивом имен, создавая массив обещаний, а затем использует Promise.allих для ожидания, прежде чем вернуть сформированный ими список.
Но эта программа полностью сломана. Она всегда возвращает только одну строку вывода, в которой указан файл, чтение которого заняло больше всего времени.
fileSizes([ "plans.txt" , "shopping_list.txt" ]) .then(консоль.log);
Проблема заключается в +=операторе, который принимает текущее значение listв момент начала выполнения оператора, а затем, когда он awaitзавершается, устанавливает listпривязку, равную этому значению плюс добавленная строка.
Но между моментом начала выполнения оператора и моментом его завершения есть асинхронный промежуток. Выражение mapвыполняется до того, как что-либо будет добавлено в список, поэтому каждый из +=операторов начинается с пустой строки и заканчивается, когда его извлечение из хранилища заканчивается, установкой listна результат добавления своей строки к пустой строке.
Этого можно было бы легко избежать, вернув строки из сопоставленных обещаний и вызвав joinрезультат Promise.all, вместо того, чтобы создавать список путем изменения привязки. Как обычно, вычисление новых значений менее подвержено ошибкам, чем изменение существующих значений.
асинхронная функция fileSizes ( files ) { let lines = files.map( async fileName => { return fileName + ": " + ( await textFile(fileName)).length; }); return ( await Promise.all(lines)).join( " \n " ); }
Ошибки, подобные этой, легко сделать, особенно при использовании await, и вы должны знать, где в вашем коде возникают пробелы. Преимущество явной асинхронности JavaScript (будь то через обратные вызовы, обещания или await) заключается в том, что обнаружить эти пробелы относительно просто.
Краткое содержание
Асинхронное программирование позволяет выразить ожидание длительных действий без замораживания всей программы. Среды JavaScript обычно реализуют этот стиль программирования с помощью обратных вызовов, функций, которые вызываются после завершения действий. Цикл событий планирует такие обратные вызовы для вызова в подходящее время, один за другим, так что их выполнение не перекрывается.
Асинхронное программирование упрощается благодаря обещаниям — объектам, представляющим действия, которые могут быть выполнены в будущем, и asyncфункциям, которые позволяют писать асинхронную программу так, как если бы она была синхронной.
Упражнения
Тихие времена
Возле лаборатории Карлы есть камера безопасности, которая активируется датчиком движения. Она подключена к сети и начинает отправлять видеопоток, когда активна. Поскольку она не хочет, чтобы ее обнаружили, Карла установила систему, которая замечает этот вид беспроводного сетевого трафика и включает свет в ее логове, когда снаружи что-то происходит, так что она знает, когда следует молчать.
Она также некоторое время регистрировала время срабатывания камеры и хочет использовать эту информацию для визуализации того, какое время в течение средней недели обычно бывает тихим, а какое — загруженным. Журнал хранится в файлах, содержащих один номер временной метки (возвращаемый Date.now()) на строку.
1695709940692 1695701068331 1695701189163
Файл содержит список файлов журналов. Напишите асинхронную функцию , которая для заданного дня недели возвращает массив из 24 чисел, по одному на каждый час дня, которые содержат количество наблюдений трафика сети камер, полученных в этот час дня. Дни идентифицируются по номеру с использованием системы, используемой , где воскресенье — 0, а суббота — 6."camera_logs.txt"activityTable(day)Date.getDay
Функция activityGraph, предоставляемая песочницей, обобщает такую таблицу в строку.
Чтобы прочитать файлы, используйте textFileфункцию, определенную ранее — при указании имени файла она возвращает обещание, которое разрешается в содержимое файла. Помните, что new Date(timestamp)создает Dateобъект для этого времени, который имеет getDayи getHoursметоды, возвращающие день недели и час дня.
Оба типа файлов — список файлов журналов и сами файлы журналов — содержат каждый фрагмент данных на отдельной строке, разделенный "\n"символами новой строки ( ).
асинхронная функция activityTable ( день ) { let logFileList = await textFile( "camera_logs.txt" ); // Ваш код здесь } Таблица активности( 1 ) .then( table => console.log(activityGraph(table)));
Показать подсказки…
Реальные обещания
Перепишите функцию из предыдущего упражнения без async/ await, используя простые Promiseметоды.
function activityTable ( day ) { // Ваш код здесь } активностьТаблица( 6 ) .then( table => console.log(activityGraph(table)));
В этом стиле использование Promise.allбудет удобнее, чем попытка смоделировать цикл по файлам журнала. В asyncфункции простое использование awaitв цикле проще. Если чтение файла занимает некоторое время, какой из этих двух подходов займет меньше всего времени для выполнения?
Если в одном из файлов, перечисленных в списке файлов, есть опечатка и его чтение завершается ошибкой, как эта ошибка отражается в Promiseобъекте, который возвращает ваша функция?Показать подсказки…
Строительство Promise.all
Как мы видели, при наличии массива обещаний Promise.allвозвращает обещание, которое ждет завершения всех обещаний в массиве. Затем оно выполняется успешно, возвращая массив результирующих значений. Если обещание в массиве не выполняется, обещание, возвращенное функцией, allтакже не выполняется, передавая причину сбоя из невыполненного обещания.
Реализуйте что-то подобное самостоятельно в виде обычной функции под названием Promise_all.
Помните, что после того, как обещание было выполнено или не выполнено, оно не может быть выполнено или не выполнено снова, и дальнейшие вызовы функций, которые его разрешают, игнорируются. Это может упростить способ обработки провала вашего обещания.
function Promise_all ( promises ) { return new Promise(( resolve , reject ) => { // Ваш код здесь. }); } // Тестовый код. Promise_all([]).then( array => { console.log( "Это должно быть []:" , array); }); функция скоро ( val ) { return new Promise( resolve => { setTimeout(() => resolve(val), Math.random() * 500 ); }); } Promise_all([скоро( 1 ), скоро( 2 ), скоро( 3 )]).then( массив => { console.log( "Это должно быть [1, 2, 3]:" , array); }); Promise_all([скоро( 1 ), Promise.reject( "X" ), скоро( 3 )]) .тогда( массив => { console.log( "Мы не должны сюда попасть" ); }) .catch( ошибка => { если (ошибка != "X" ) { console.log( "Неожиданная ошибка:" , ошибка); } });
Функция, переданная конструктору, Promise должна будет вызвать then каждое из обещаний в данном массиве. Когда одно из них успешно, должны произойти две вещи. Полученное значение должно быть сохранено в правильной позиции массива результатов, и мы должны проверить, было ли это последнее ожидающее обещание, и завершить наше собственное обещание, если это так.
Последнее можно сделать с помощью счетчика, который инициализируется длиной входного массива и из которого мы вычитаем 1 каждый раз, когда обещание выполняется. Когда оно достигает 0, мы заканчиваем. Убедитесь, что вы учитываете ситуацию, когда входной массив пуст (и, таким образом, ни одно обещание никогда не разрешится).
Обработка сбоев требует некоторых размышлений, но оказывается чрезвычайно простой. Просто передайте rejectфункцию оборачивающего обещания каждому из обещаний в массиве как catchобработчик или как второй аргумент , чтобы thenсбой в одном из них вызывал отклонение всего обещания-оборачивания.◂ ● ▸?