{Вы не знаете JS} Асинхронная обработка и оптимизация [1 ed.] 978-5-4461-1313-2

Каким бы опытом программирования на JavaScript вы ни обладали, скорее всего, вы не понимаете язык в полной мере. Это лак

1,720 158 4MB

Russian Pages 352 Year 2019

Report DMCA / Copyright

DOWNLOAD FILE

Polecaj historie

{Вы не знаете JS} Асинхронная обработка и оптимизация [1 ed.]
 978-5-4461-1313-2

  • Commentary
  • True PDF
Citation preview

Async & Performance

Kyle Simpson

ВЫ НЕ ЗНАЕТЕ

АСИНХРОННАЯ ОБРАБОТКА ОПТИМИЗАЦИЯ КАЙЛ СИМПСОН

ББК 32.988.02-018 УДК 004.738.5 С37



Симпсон К.

С37 {Вы не знаете JS} Асинхронная обработка и оптимизация. — СПб.: Питер, 2019. — 352 с. — (Серия «Бестселлеры O’Reilly»).

ISBN 978-5-4461-1313-2 Каким бы опытом программирования на JavaScript вы ни обладали, скорее всего, вы не понимаете язык в полной мере. Это лаконичное, но при этом глубоко продуманное руководство посвящено новым асинхронным возможностям и средствам повышения производительности, которые позволяют создавать сложные одностраничные веб-приложения и избежать при этом «кошмара обратных вызовов». Как и в других книгах серии «Вы не знаете JS», вы познакомитесь с нетривиальными особенностями языка, которых так боятся программисты. Только вооружившись знаниями, можно достичь истинного мастерства.

16+ (В соответствии с Федеральным законом от 29 декабря 2010 г. № 436-ФЗ.)

ББК 32.988.02-018 УДК 004.738.5

Права на издание получены по соглашению с O’Reilly. Все права защищены. Никакая часть данной книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев авторских прав. Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как надежные. Тем не менее, имея в виду возможные человеческие или технические ошибки, издательство не может гарантировать абсолютную точность и полноту приводимых сведений и не несет ответственности за возможные ошибки, связанные с использованием книги. Издательство не несет ответственности за доступность материалов, ссылки на которые вы можете найти в этой книге. На момент подготовки книги к изданию все ссылки на интернет-ресурсы были действующими.

ISBN 978-1491904220 англ. ISBN 978-5-4461-1313-2

Authorized Russian translation of the English edition of You Don’t Know JS: Async & Performance (ISBN 9781491904220) © 2015 Getify Solutions, Inc. This translation is published and sold by permission of O’Reilly Media, Inc., which owns or controls all rights to publish and sell the same © Перевод на русский язык ООО Издательство «Питер», 2019 © Издание на русском языке, оформление ООО Издательство «Питер», 2019 © Серия «Бестселлеры O’Reilly», 2019

Оглавление

Предисловие. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10 Введение . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12 Задача . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13 О книге. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15 Типографские соглашения . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15 Использование программного кода примеров . . . . . . . . . . . . . . 16 От издательства. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17

Глава 1. Асинхронность: сейчас и потом. . . . . . . . . . . . . 18 Блочное строение программы. . . . . . . . . . . . . . . . . . . . . . . . . . 19 Асинхронный вывод в консоль. . . . . . . . . . . . . . . . . . . . . . . 22 Цикл событий. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23 Параллельные потоки. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26 Выполнение до завершения . . . . . . . . . . . . . . . . . . . . . . . . . . . 30 Параллельное выполнение. . . . . . . . . . . . . . . . . . . . . . . . . . . . 33 Отсутствие взаимодействий. . . . . . . . . . . . . . . . . . . . . . . . . 36 Взаимодействия. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36 Кооперация. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42 Задания . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45 Упорядочение команд. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46 Итоги. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50

Глава 2. Обратные вызовы . . . . . . . . . . . . . . . . . . . . . . . . 52 Продолжения. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53 Последовательное мышление. . . . . . . . . . . . . . . . . . . . . . . . . . 55

6

Оглавление

Работа и планирование. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 56 Вложенные/сцепленные обратные вызовы. . . . . . . . . . . . . . 59 Проблемы доверия. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65 История о пяти обратных вызовах. . . . . . . . . . . . . . . . . . . . . . . 66 Не только в чужом коде. . . . . . . . . . . . . . . . . . . . . . . . . . . . 69 Попытки спасти обратные вызовы. . . . . . . . . . . . . . . . . . . . . . . 71 Итоги. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 76

Глава 3. Обещания. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78 Что такое обещание? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79 Будущее значение. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80 Событие завершения. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 86 События обещаний . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 90 Утиная типизация с методом then()(thenable) . . . . . . . . . . . . . . 93 Доверие Promise. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 96 Слишком ранний обратный вызов . . . . . . . . . . . . . . . . . . . . 97 Слишком поздний обратный вызов. . . . . . . . . . . . . . . . . . . . 97 Обратный вызов вообще не вызывается. . . . . . . . . . . . . . . 100 Слишком малое или слишком большое количество вызовов. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 101 Отсутствие параметров/переменных среды . . . . . . . . . . . . 102 Поглощение ошибок/исключений. . . . . . . . . . . . . . . . . . . . 102 Обещания, заслуживающие доверия?. . . . . . . . . . . . . . . . . . . 104 Формирование доверия. . . . . . . . . . . . . . . . . . . . . . . . . . . 108 Сцепление . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 109 Терминология: разрешение, выполнение и отказ. . . . . . . . 118 Обработка ошибок . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 121 Бездна отчаяния . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 125 Обработка неперехваченных ошибок. . . . . . . . . . . . . . . . . 126 Бездна успеха. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 128 Паттерны обещаний. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 131 Promise.all([ .. ]). . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 131 Promise.race([ .. ]) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 133 Вариации на тему all([ .. ]) и race([ .. ]) . . . . . . . . . . . . . . . 137

Оглавление

7

Параллельно выполняемые итерации. . . . . . . . . . . . . . . . . . . 139 Снова о Promise API . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 140 Конструктор new Promise(..). . . . . . . . . . . . . . . . . . . . . . . . 141 Promise.resolve(..) и Promise.reject(..) . . . . . . . . . . . . . . . . . 141 then(..) и catch(..). . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 142 Promise.all([ .. ]) и Promise.race([ .. ]). . . . . . . . . . . . . . . . . 143 Ограничения обещаний. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 145 Последовательность обработки ошибок. . . . . . . . . . . . . . . 145 Единственное значение. . . . . . . . . . . . . . . . . . . . . . . . . . . 147 Инерция . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 152 Неотменяемость обещаний . . . . . . . . . . . . . . . . . . . . . . . . 157 Эффективность обещаний. . . . . . . . . . . . . . . . . . . . . . . . . 159 Итоги. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 161

Глава 4. Генераторы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . 162 Нарушение принципа выполнения до завершения. . . . . . . . . . 162 Ввод и вывод. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 166 Передача сообщений при итерациях . . . . . . . . . . . . . . . . . 167 Множественные итераторы. . . . . . . . . . . . . . . . . . . . . . . . . . . 171 Генерирование значений . . . . . . . . . . . . . . . . . . . . . . . . . . . . 176 Производители и итераторы . . . . . . . . . . . . . . . . . . . . . . . 176 Итерируемые объекты. . . . . . . . . . . . . . . . . . . . . . . . . . . . 180 Итераторы генераторов. . . . . . . . . . . . . . . . . . . . . . . . . . . 182 Асинхронный перебор итераторов. . . . . . . . . . . . . . . . . . . . . . 186 Синхронная обработка ошибок. . . . . . . . . . . . . . . . . . . . . . 190 Генераторы + обещания. . . . . . . . . . . . . . . . . . . . . . . . . . . . . 192 Выполнение генератора с поддержкой обещаний. . . . . . . . 195 Параллелизм обещаний в генераторах. . . . . . . . . . . . . . . . 199 Делегирование. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 204 Почему делегирование?. . . . . . . . . . . . . . . . . . . . . . . . . . . 207 Делегирование сообщений. . . . . . . . . . . . . . . . . . . . . . . . . 208 Делегирование асинхронности. . . . . . . . . . . . . . . . . . . . . . 213 Делегирование рекурсии. . . . . . . . . . . . . . . . . . . . . . . . . . 214 Параллельное выполнение генераторов . . . . . . . . . . . . . . . . . 216

8

Оглавление

Преобразователи . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 222 s/promise/thunk/. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 227 Генераторы до ES6. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 231 Ручное преобразование . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 231 Автоматическая транспиляция. . . . . . . . . . . . . . . . . . . . . . 237 Итоги. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 239

Глава 5. Быстродействие программ. . . . . . . . . . . . . . . . 241 Веб-работники . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 242 Рабочая среда. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 246 Передача данных. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 247 Общие работники . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 248 Полифилы для веб-работников . . . . . . . . . . . . . . . . . . . . . 250 SIMD. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 251 asm.js. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 253 Как оптимизировать с asm.js. . . . . . . . . . . . . . . . . . . . . . . 254 Модули asm.js. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 255 Итоги. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 258

Глава 6. Хронометраж и настройка. . . . . . . . . . . . . . . . 260 Хронометраж . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 261 Повторение. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 262 Benchmark.js . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 264 Все зависит от контекста. . . . . . . . . . . . . . . . . . . . . . . . . . . . .267 Оптимизации движка . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 268 jsPerf.com. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 271 Проверка на здравый смысл. . . . . . . . . . . . . . . . . . . . . . . . . . 272 Написание хороших тестов. . . . . . . . . . . . . . . . . . . . . . . . . . . 276 Микробыстродействие. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 277 Различия между движками. . . . . . . . . . . . . . . . . . . . . . . . . . . 282 Общая картина. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 285 Оптимизация хвостовых вызовов (TCO). . . . . . . . . . . . . . . . . . 288 Итоги. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 291

Оглавление

9

Приложение А. Библиотека asynquence . . . . . . . . . . . . 292 Последовательности и архитектура, основанная на абстракциях . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 293 asynquence API. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 297 Шаги. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 297 Ошибки . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 300 Параллельные шаги. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 303 Ветвление последовательностей . . . . . . . . . . . . . . . . . . . . 311 Объединение последовательностей. . . . . . . . . . . . . . . . . . 311 Значение и последовательности ошибки. . . . . . . . . . . . . . . . . 313 Обещания и обратные вызовы . . . . . . . . . . . . . . . . . . . . . . . . 314 Итерируемые последовательности . . . . . . . . . . . . . . . . . . . . . 316 Выполнение генераторов . . . . . . . . . . . . . . . . . . . . . . . . . . . . 318 Обертки для генераторов. . . . . . . . . . . . . . . . . . . . . . . . . . 319 Итоги. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 319

Приложение Б. Расширенные асинхронные паттерны. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 321 Итерируемые последовательности . . . . . . . . . . . . . . . . . . . . . 321 Расширение итерируемых последовательностей. . . . . . . . . 325 Реакция на события . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 330 Наблюдаемые объекты в ES7. . . . . . . . . . . . . . . . . . . . . . . 332 Реактивные последовательности. . . . . . . . . . . . . . . . . . . . 334 Генераторные сопрограммы (Generator Coroutine). . . . . . . . . . 338 Конечные автоматы. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 340 Взаимодействующие последовательные процессы. . . . . . . . . . 343 Передача сообщений. . . . . . . . . . . . . . . . . . . . . . . . . . . . . 343 Эмуляция CSP в asynquence. . . . . . . . . . . . . . . . . . . . . . . . 346 Итоги. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 349

Об авторе. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 350

Предисловие

За годы работы мой руководитель достаточно стал доверять мне, чтобы поручить мне проведение собеседований. Если вы ищете кандидата, умеющего программировать на JavaScript, первые вопросы должны… вообще-то они должны выявить, не нужно ли кандидату в туалет и не хочет ли он пить, потому что комфорт — это важно. Разобравшись с физиологией, я начинаю выяснять, действительно ли кандидат знает JavaScript или он знает только jQuery. Я ничего не имею против jQuery. jQuery позволяет сделать много полезного без реальных знаний JavaScript, и это достоинство, а не недостаток. Но если должность требует высоких познаний в области быстродействия JavaScript и сопровождения кода, вам нужен человек, который знает, как устроены библиотеки (такие, как jQuery). Он должен уметь пользоваться базовыми возможностями JavaScript на том же уровне, что и разработчики библиотек. Чтобы оценить навыки владения базовыми средствами JavaScript, я в первую очередь поинтересуюсь, что кандидат знает о замыканиях и как он использует асинхронные средства с максимальной эффективностью — именно этой теме посвящена книга. Для начала будут рассмотрены обратные вызовы — «хлеб с маслом» асинхронного программирования. Конечно, питаться одним хлебом с  маслом не очень весело, но следующее блюдо будет полно вкусных-превкусных «обещаний» (Promise)! Если вы еще не знакомы с обещаниями, или промисами, — самое время познакомиться. Обещания стали официальным способом

Предисловие

11

передачи асинхронных возвращаемых значений в  JavaScript и DOM. Они будут использоваться всеми будущими асинхронными DOM API и уже используются многими существующими — так что приготовьтесь! В  момент написания книги обещания поддерживались большинством основных браузеров, а скоро будут поддерживаться в IE. А после того, как вы закончите смаковать обещания, надеюсь, у вас еще хватит места для следующего блюда — генераторов (Generator). Генераторы обосновались в стабильных версиях Chrome и Firefox без особой помпы и церемоний, потому что, откровенно говоря, они создают больше сложностей, чем открывают интересных возможностей. По крайней мере, я  так думал, пока не увидел их в связке с обещаниями. Здесь они стали важным инструментом, обеспечивающим удобочитаемость и простоту сопровождения кода. А на десерт… Не стану портить сюрприз, но приготовьтесь заглянуть в будущее JavaScript! В книге будут рассмотрены средства, расширяющие контроль над параллелизмом и асинхронностью. Что ж, не буду вам мешать получать удовольствие от книги, пусть занавес поднимется. А если вы уже прочитали часть книги перед тем, как взяться за предисловие, то получите заслуженные 10 очков за асинхронность! Джейк Арчибальд (Jake Archibald) (http://jakearchibald.com, @jafathecake), разработчик Google Chrome

Введение

С самых первых дней существования Всемирной паутины язык JavaScript стал фундаментальной технологией для управления интерактивностью контента, потребляемого пользователями. Хотя история JavaScript начиналась с мерцающих следов от указателя мыши и раздражающих всплывающих подсказок, через два десятилетия технология и возможности JavaScript выросли на несколько порядков, и лишь немногие сомневаются в его важности как ядра самой распространенной программной платформы в мире — веб-технологий. Но как язык JavaScript постоянно подвергался серьезной критике — отчасти из-за своего происхождения, еще больше из-за своей философии проектирования. Даже само его название наводит на мысли, как однажды выразился Брендан Эйх (Brendan Eich), о «недоразвитом младшем брате», который стоит рядом со своим старшим и умным братом Java. Тем не менее такое название возникло исключительно по соображениям политики и маркетинга. Между этими двумя языками существуют колоссальные различия. У JavaScript с Java общего не больше, чем у луна-парка с Луной. Так как JavaScript заимствует концепции и синтаксические идиомы из нескольких языков, включая процедурные корни в стиле C и менее очевидные функциональные корни в стиле Scheme/Lisp, он в высшей степени доступен для широкого спектра разработчиков — даже обладающих минимальным опытом программирова-

Задача

13

ния. Программа «Hello World» на JavaScript настолько проста, что язык располагает к себе и кажется удобным с самых первых минут знакомства. Пожалуй, JavaScript — один из самых простых языков для изучения и начала работы, но из-за его странностей хорошее знание этого языка встречается намного реже, чем во многих других языках. Если для написания полноценной программы на C или C++ требуется достаточно глубокое знание языка, полномасштабное коммерческое программирование на JavaScript порой (и достаточно часто) едва затрагивает то, на что способен этот язык. Хитроумные концепции, глубоко интегрированные в язык, проявляются в простых на первый взгляд аспектах, например, передачи функций в  форме обратных вызовов. У  разработчика JavaScript появляется соблазн просто использовать язык «как есть» и не беспокоиться о том, что происходит «внутри». Это одновременно простой и удобный язык, находящий повсеместное применение, и сложный, многогранный набор языковых механик, истинный смысл которых без тщательного изучения останется непонятным даже для самого опытного разработчика JavaScript. В этом заключается парадокс JavaScript — ахиллесова пята языка, — проблема, которой мы сейчас займемся. Так как JavaScript можно использовать без полноценного понимания, очень часто это понимание языка так и не приходит к разработчику.

Задача Если каждый раз, сталкиваясь с каким-то сюрпризом или неприятностью в JavaScript, вы заносите их в «черный список» (как многие привыкли делать), вскоре от всей мощи JavaScript у вас останется пустая скорлупа.

14

Введение

Хотя это подмножество принято называть «Хорошими Частями», я призываю вас, дорогой читатель, рассматривать его как «Простые Части», «Безопасные Части» и даже «Неполные Части». Серия «Вы не знаете JS» идет в прямо противоположном направлении: изучить и глубоко понять весь язык JavaScript и особенно «Сложные Части». Здесь мы прямо говорим о существующей среди разработчиков JS тенденции изучать «ровно столько, сколько нужно» для работы, не заставляя себя разбираться в том, что именно происходит и почему язык работает именно так. Более того, мы воздерживаемся от распространенной тактики отступить, когда двигаться дальше становится слишком трудно. Я не привык останавливаться в тот момент, когда что-то просто работает, а я толком сам не знаю почему, и вам не советую. Приглашаю вас в путешествие по этим неровным, непростым дорогам; здесь вы узнаете, что собой представляет язык JavaScript и что он может сделать. С этими знаниями ни один метод, ни один фреймворк, ни одно модное сокращение или термин недели не останутся за пределами вашего понимания. В каждой книге серии мы возьмем одну из конкретных базовых частей языка, которые часто понимаются неправильно или недостаточно глубоко, и рассмотрим ее вдумчиво и подробно. После чтения у вас должна сформироваться твердая уверенность в том, что вы понимаете не только теорию, но и практические аспекты «того, что нужно знать для работы». Скорее всего, то, что вы сейчас знаете о JavaScript, вы узнавали по частям от других людей, которые тоже недостаточно хорошо разбирались в теме. Такой JavaScript — не более чем тень настоящего языка. На самом деле вы пока JavaScript не знаете, но будете знать, если как следует ознакомитесь с этой серией книг. ­Читайте дальше, друзья мои. JavaScript ждет вас.

Типографские соглашения

15

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

Типографские соглашения В этой книге приняты следующие типографские соглашения: Курсив Используется для обозначения новых терминов. Моноширинный шрифт

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

16

Введение

Так выделяются советы и предложения.

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

Так обозначаются предупреждения и предостережения.

Использование программного кода примеров Вспомогательные материалы (примеры кода, упражнения и т. д.) доступны для загрузки по адресу http://bit.ly/ydkjs-async-code. Эта книга призвана оказать вам помощь в решении ваших задач. В общем случае все примеры кода из этой книги вы можете использовать в своих программах и в документации. Вам не нужно обращаться в издательство за разрешением, если вы не собираетесь воспроизводить существенные части программного кода. Например, если вы разрабатываете программу и используете в ней несколько отрывков программного кода из книги, вам не нужно обращаться за разрешением. Однако в случае продажи или распространения компакт-дисков с примерами из этой книги вам необходимо получить разрешение от издательства O’Reilly. Если вы отвечаете на вопросы, цитируя данную книгу или примеры из нее, получение разрешения не требуется. Но при включении существенных объемов программного кода примеров из этой книги

От издательства

17

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

От издательства Ваши замечания, предложения, вопросы отправляйте по адресу [email protected] (издательство «Питер», компьютерная редакция). Мы будем рады узнать ваше мнение! На веб-сайте издательства www.piter.com вы найдете подробную информацию о наших книгах.

1

Асинхронность: сейчас и потом

Одна из важнейших, но при этом часто недопонимаемых частей программирования в таких языках, как JavaScript, — это средства выражения и управления поведением программы во времени. Конечно, я не имею в виду то, что происходит от начала цикла for до конца цикла for, что, безусловно, занимает некоторое время (микросекунды или миллисекунды). Речь о том, какая часть программы выполняется сейчас, а какая часть программы будет выполняться позднее, а между «сейчас» и «потом» существует промежуток, в котором ваша программа не выполняется активно. Практически всем нетривиальным программам, когда-либо написанным (особенно на JS), в том или ином отношении приходится управлять этим промежутком, будь то ожидание пользовательского ввода, запрос информации из базы данных или файловой системы, передача данных по сети и ожидание ответа или периодическое выполнение задачи с фиксированным интервалом времени (скажем, анимация). Во всех этих ситуациях ваша программа должна управлять состоянием на протяжении промежутка времени. Собственно, связь между частями программы, выполняющимися сейчас и потом, занимает центральное место в асинхронном программировании.

Блочное строение программы

19

Безусловно, асинхронное программирование было доступно с первых дней JS. Но многие разработчики JS никогда не задумывались над тем, как именно и почему оно возникает в их программах, и не использовали другие способы его организации. Всегда существовал достаточно хороший вариант — обычная функция обратного вызова (callback). И сегодня многие настаивают на том, что обратных вызовов более чем достаточно. Но с ростом масштаба и сложности JS, которые необходимы для удовлетворения вечно расширяющихся требований полноценного языка программирования, работающего в браузерах и на серверах, а также на всех промежуточных мыслимых устройствах, проблемы с управлением асинхронностью становятся непосильными. Возникает острая необходимость в решениях, которые были бы одновременно более действенными и разумными. Хотя все эти рассуждения могут показаться довольно абстрактными, уверяю вас, что тема будет более полно и конкретно рассмотрена в книге. В нескольких ближайших главах представлены перспективные методы асинхронного программирования JavaScript. Но сначала необходимо гораздо глубже разобраться, что такое асинхронность и как она работает в JS.

Блочное строение программы Программу можно написать в одном файле .js, но ваша программа почти наверняка состоит из нескольких блоков, только один из которых будет выполняться в данный момент, а остальные будут выполняться позднее. Самой распространенной структурной единицей блоков является функция. Проблема, с  которой сталкивается большинство начинающих разработчиков JS, заключается в том, что «потом» не наступает четко и немедленно после «сейчас». Иначе говоря, задачи, которые

20

Глава 1. Асинхронность: сейчас и потом

не могут быть завершены прямо сейчас, по определению будут завершаться асинхронно, а следовательно, вы не будете наблюдать блокирующее поведение, как вы могли бы интуитивно предположить или желать. Пример: // ajax(..) - произвольная функция Ajax из библиотеки var data = ajax( "http://some.url.1" ); console.log( data ); // Ой! В общем случае `data` не содержит результатов Ajax

Вероятно, вы знаете, что стандартные запросы Ajax не завершаются синхронно. Это означает, что у функции ajax(..) еще нет значения, которое можно было бы вернуть для присваивания переменной data. Если бы функция ajax(..) могла блокироваться до возвращения ответа, то присваивание data = .. работало бы нормально. Но Ajax-взаимодействия происходят не так. Вы выдаете асинхронный запрос Ajax сейчас, а  результаты получаете только через какое-то время. Простейший (но определенно не единственный и даже не всегда лучший!) способ «ожидания» основан на использовании функции, обычно называемой функцией обратного вызова (callback function): // ajax(..) - произвольная функция Ajax из библиотеки ajax( "http://some.url.1", function myCallbackFunction(data){ console.log( data ); // Отлично, данные `data` получены! } );

Возможно, вы слышали о синхронных Ajax-запросах. Хотя такая техническая возможность существует, вы никогда, ни при каких условиях не должны пользоваться ею, потому что она полностью блокирует пользовательский интерфейс брау­зера (кнопки, меню, прокрутку и т. д.) и перекрывает любые взаимодействия с пользователем. Это очень плохая мысль, избегайте ее в любых случаях.

Блочное строение программы

21

Прежде чем вы начнете протестовать — нет, ваше желание избежать путаницы c обратными вызовами не оправдывает блокирующие синхронные операции Ajax. Возьмем для примера следующий код: function now() { return 21; } function later() { answer = answer * 2; console.log( "Meaning of life:", answer ); } var answer = now(); setTimeout( later, 1000 ); // Meaning of life: 42

Программа состоит из двух блоков: того, что будет выполняться сейчас, и того, что будет выполняться потом. Наверное, вы и сами понимаете, что это за блоки, но чтобы не оставалось ни малейших сомнений: Сейчас: function now() { return 21; } function later() { .. } var answer = now(); setTimeout( later, 1000 );

Потом: answer = answer * 2; console.log( "Meaning of life:", answer );

Блок «сейчас» выполняется немедленно, как только вы запускаете свою программу. Но setTimeout(..) также настраивает событие (тайм-аут), которое должно произойти в будущем, так что содер-

22

Глава 1. Асинхронность: сейчас и потом

жимое функции later() будет выполнено позднее (через 1000 миллисекунд). Каждый раз, когда вы упаковываете фрагмент кода в функцию и указываете, что она должна быть выполнена по некоторому событию (таймер, щелчок мышью, ответ Ajax и т. д.), вы создаете в своем коде блок «потом», а следовательно, вводите асинхронность в свою программу.

Асинхронный вывод в консоль Не существует никаких спецификаций или наборов требований, определяющих, как работают методы console.*, — официально они не являются частью JavaScript, а добавляются в JS управляющей средой. А значит, разные браузеры и среды JS действуют так, как считают нужным, что иногда приводит к неожиданному поведению. В частности, для некоторых браузеров и некоторых условий console. log(..) не выводит полученные данные немедленно. Прежде всего, это может произойти из-за того, что ввод/вывод — очень медленная и блокирующая часть многих программ (не только JS). Для браузера может быть более производительно (с точки зрения страниц/ пользовательского интерфейса) выполнять консольный ввод/вывод в фоновом режиме, и вы, возможно, даже не будете подозревать о нем. Не слишком распространенная, но возможная ситуация, в которой можно понаблюдать за этим явлением (не из самого кода, а снаружи): var a = { index: 1 }; // потом console.log( a ); // ?? // еще позднее a.index++;

Цикл событий

23

Обычно мы ожидаем, что объект a будет зафиксирован в точный момент выполнения команды console.log(..) и будет выведен результат { index: 1 }, чтобы в следующей команде при выполнении a.index++ изменялось нечто иное, чем выводимое значение a. В большинстве случаев приведенный выше код, скорее всего, выдаст в консоль инструментария разработчика представление объекта, чего вы и ожидаете. Но может оказаться, что тот же код будет выполняться в ситуации, в которой браузер сочтет нужным перевести консольный ввод/вывод в фоновый режим. И тогда может оказаться, что к тому времени, когда представление объекта будет выводиться в консоль браузера, увеличение a.index++ уже произошло, и будет выведен результат { index: 2 }. На вопрос о том, при каких именно условиях консольный ввод/ вывод будет отложен и  будет ли вообще наблюдаться данный эффект, невозможно дать четкий ответ. Просто учитывайте возможную асинхронность при вводе/выводе в том случае, если у вас когда-нибудь возникнут проблемы с отладкой, когда объекты были изменены уже после команды console.log(..), но эти изменения совершенно неожиданно проявляются при выводе. Если вы столкнетесь с этой нечастой ситуацией, лучше всего использовать точки прерывания в отладчике JS, вместо того чтобы полагаться на консольный вывод. Следующий вариант — принудительно «зафиксировать» представление интересующего вас объекта, преобразовав его в строку (например, JSON. stringify(..)).

Цикл событий Начну с утверждения (возможно, даже удивительного): несмотря на то что вы очевидным образом имели возможность писать асинхронный код JS (как в примере с тайм-аутом, рассмотренным выше), до последнего времени (ES6) в самом языке JavaScript никогда не было никакого встроенного понятия асинхронности.

24

Глава 1. Асинхронность: сейчас и потом

Что?! Но это же полный бред? На самом деле это чистая правда. Сам движок JS никогда не делало ничего, кроме выполнения одного блока программы в  любой конкретный момент времени, когда ему это приказывали. «Ему приказывали»… Кто приказывал? Это очень важный момент! Движок JS не работает в изоляции. Он работает внутри управляющей среды, которой для большинства разработчиков становится обычный веб-браузер. За последние несколько лет (впрочем, не только за этот период) язык JS вышел за границы браузеров в другие среды — например, на серверы — благодаря таким технологиям, как Node.js. Более того, JavaScript сейчас встраивается в самые разные устройства, от роботов до лампочек. Но у всех этих сред есть одна характерная особенность: у  них существует механизм, который обеспечивает выполнение нескольких фрагментов вашей программы, обращаясь с вызовами к движку JS в разные моменты времени. Этот механизм называется циклом событий. Иначе говоря, движок JS сам по себе не обладает внутренним чувством времени, но он становится средой исполнения для любого произвольного фрагмента JS. Планирование «событий» (выполнений фрагментов кода JS) всегда осуществляется окружающей средой. Итак, например, когда ваша программа JS выдает запрос Ajax для получения данных с сервера, вы определяете код реакции в функции (обычно называемой функцией обратного вызова, или просто обратным вызовом), а движок JS говорит управляющей среде: «Так, я собираюсь ненадолго приостановить выполнение, но когда ты завершишь обработку этого сетевого запроса и получишь данные, пожалуйста, вызови вот эту функцию». Браузер настраивается для прослушивания ответа от сети, и когда у него появятся данные, чтобы передать их программе, планирует выполнение функции обратного вызова, вставляя ее в цикл событий. Так что же такое «цикл событий»?

Цикл событий

25

Следующий фиктивный код поможет представить суть цикла событий на концептуальном уровне: // `eventLoop` - массив, работающий по принципу очереди // (первым пришел, первым вышел) var eventLoop = [ ]; var event; // продолжать "бесконечно" while (true) { // отработать "квант" if (eventLoop.length > 0) { // получить следующее событие в очереди event = eventLoop.shift();

}

}

// выполнить следующее событие try { event(); } catch (err) { reportError(err); }

Конечно, этот сильно упрощенный псевдокод только демонстрирует основные концепции. Тем не менее и его должно быть достаточно для понимания сути. Как видите, имеется непрерывно работающий цикл, представленный циклом while; каждая итерация цикла называется тиком. В каждом тике, если в очереди ожидает событие, оно, событие, извлекается и выполняется. Этими событиями становятся ваши функции обратного вызова. Важно заметить, что функция setTimeout(..) не ставит обратный вызов в очередь цикла событий. Вместо этого она запускает таймер; по истечении таймера среда помещает ваш обратный вызов в цикл событий, чтобы некий тик в будущем подобрал его для выполнения.

26

Глава 1. Асинхронность: сейчас и потом

А если в очереди в данный момент уже находятся 20 элементов? Вашему обратному вызову придется подождать. Он ставится в очередь после всех остальных — обычно не существует способа выйти вперед и обогнать других в очереди. Это объясняет, почему таймеры setTimeout(..) не гарантируют идеальной точности. Функция гарантирует (упрощенно говоря), что обратный вызов не сработает до истечения заданного вами интервала, но это может произойти в заданный момент или после него в зависимости от состояния очереди событий. Другими словами, ваша программа обычно разбивается на множество мелких блоков, которые выполняются друг за другом в очереди цикла событий. И с технической точки зрения в эту очередь также могут попасть другие события, не имеющие прямого отношения к вашей программе. Мы упомянули «до недавнего времени», говоря о том, как специ­ фикация ES6 изменила природу управления очередью цикла событий. В основном это формальная техническая деталь, но ES6 теперь точно указывает, как работает цикл событий; это означает, что формально он попадает в сферу действия движка JS, а не только управляющей среды. Одна из главных причин для такого изменения — появление в ES6 обещаний (см. главу  3), для которых необходим прямой, точный контроль за опе­рациями планирования очереди цикла событий (вызов setTimeout(..0) рассматривается в разделе «Кооперация»).

Параллельные потоки Термины «асинхронный» и «параллельный» очень часто используются как синонимы, но в действительности они имеют разный смысл. Напомню, что суть асинхронности — управление промежутком между «сейчас» и «потом». Параллелизм обозначает возможность одновременного выполнения операций. Самые распространенные средства организации параллельных вычислений — процессы и потоки (threads). Процессы и потоки

Параллельные потоки

27

выполняются независимо и могут выполняться одновременно на разных процессорах и даже на разных компьютерах, но несколько потоков могут совместно использовать общую память одного процесса. С другой стороны, цикл событий разбивает свою работу на задачи и выполняет их последовательно, что делает невозможным параллельный доступ и изменения в общей памяти. Параллелизм и последовательность не могут совместно существовать в  форме взаимодействующих циклов событий в разных потоках. Чередование параллельных потоков выполнения и чередование асинхронных событий происходит на совершенно разных уровнях детализации. Пример: function later() { answer = answer * 2; console.log( "Meaning of life:", answer ); }

Хотя все содержимое later() будет рассматриваться как один элемент очереди цикла событий, в потоке, в котором будет выполняться этот код, произойдет более десятка низкоуровневых операций. Например, для команды answer = answer * 2 нужно будет сначала загрузить текущее значение answer, потом поместить кудато 2, затем выполнить умножение, затем взять результат и снова сохранить его в answer. В однопоточной среде неважно, что элементы очереди потока являются низкоуровневыми операциями, потому что ничто не может прервать поток. Но в  параллельной системе, в  которой в одной программе могут выполняться два разных потока, легко могут возникнуть непредсказуемые последствия. Пример: var a = 20; function foo() {

28

}

Глава 1. Асинхронность: сейчас и потом

a = a + 1;

function bar() { a = a * 2; } // ajax(..) - произвольная функция Ajax из библиотеки ajax( "http://some.url.1", foo ); ajax( "http://some.url.2", bar );

При однопоточном поведении в JavaScript, если foo() выполняется перед bar(), в результате a будет содержать 42, а если bar() выполняется перед foo(), то результат будет равен 41. Но если события JS, совместно использующие одни данные, выполняются параллельно, проблемы становятся намного более коварными. Возьмем два списка задач из псевдокода как потоки, которые могут выполнять код foo() и bar() соответственно, и посмотрим, что произойдет, если они выполняются ровно в одно и то же время. Поток 1 (X и Y — временные области памяти): foo(): a. загрузить значение `a` в `X` b. сохранить `1` в `Y` c. сложить `X` и `Y`, сохранить результат в `X` d. сохранить значение `X` в `a`

Поток 2 (X и Y — временные области памяти): bar(): a. загрузить значение `a` в `X` b. сохранить `2` в `Y` c. перемножить `X` и `Y`, сохранить результат в `X` d. сохранить значение `X` в `a`

Теперь предположим, что два потока выполняются действительно параллельно. Вы уже заметили проблему, верно? Они ис-

Параллельные потоки

29

пользуют области общей памяти X и Y для своих промежуточных операций. Какой результат будет сохранен в a, если операции будут выполняться в следующем порядке? 1a 2a 1b 2b 1c 1d 2c 2d

(загрузить значение `a` в `X` ==> `20`) (загрузить значение `a` в `X` ==> `20`) (сохранить `1` в `Y` ==> `1`) (сохранить `2` в `Y` ==> `2`) (сложить `X` и `Y`, сохранить результат в `X` ==> `22`) (сохранить значение `X` в `a` ==> `22`) (перемножить `X` и `Y`, сохранить результат в `X` ==> `44`) (сохранить значение `X` в `a` ==> `44`)

Значение a будет равно 44. А как насчет такого порядка? 1a 2a 2b 1b 2c 1c 1d 2d

(загрузить значение `a` в `X` ==> `20`) (загрузить значение `a` в `X` ==> `20`) (сохранить `2` в `Y` ==> `2`) (сохранить `1` в `Y` ==> `1`) (перемножить `X` и `Y`, сохранить результат в `X` ==> `20`) (сложить `X` и `Y`, сохранить результат в `X` ==> `21`) (сохранить значение `X` в `a` ==> `21`) (сохранить значение `X` в `a` ==> `21`)

На этот раз значение a будет равно 21. Итак, многопоточное программирование может быть очень сложным, потому что если вы не предпримете специальных мер для предотвращения подобных прерываний/чередований, возможно очень странное недетерминированное поведение, которое часто создает проблемы. В JavaScript совместное использование данных разными потоками невозможно, что означает, что этот уровень недетерминизма не создаст проблем. Но это не означает, что поведение JS всегда детерминировано. Вспомните предыдущий пример, в котором относительный порядок foo() и bar() мог приводить к двум разным результатам (41 или 42).

30

Глава 1. Асинхронность: сейчас и потом

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

Выполнение до завершения Из-за однопоточного характера JavaScript код внутри функций foo() (и bar()) выполняется атомарно; это означает, что после того, как функция foo() начнет выполняться, весь ее код будет завершен до того, как будет выполнен какой-либо код bar(), и наоборот. Это поведение называется выполнением до завершения (run-to-completion). Собственно, семантика выполнения до завершения становится более очевидной, когда foo() и bar() содержат больше кода: var a = 1; var b = 2; function foo() { a++; b = b * a; a = b + 3; } function bar() { b--; a = 8 + b; b = a * 2; } // ajax(..) - произвольная функция Ajax из библиотеки ajax( "http://some.url.1", foo ); ajax( "http://some.url.2", bar );

Поскольку foo() не может прерываться bar(), а  bar() не может прерываться foo(), у этой программы есть только два возможных результата в зависимости от того, какая из функций запустится первой. Если присутствует многопоточное выполнение, а отдель-

Выполнение до завершения

31

ные команды в foo() и bar() чередуются, количество возможных результатов значительно возрастает! Блок 1 является синхронным (выполняется сейчас), но блоки 2 и 3 асинхронны (выполняются потом); это означает, что их выполнение будет разделено временным интервалом. Блок 1: var a = 1; var b = 2;

Блок 2 (foo()): a++; b = b * a; a = b + 3;

Блок 3 (bar()): b--; a = 8 + b; b = a * 2;

При выполнении блоков 2 и 3 первым может оказаться любой из этих блоков, поэтому у программы есть два возможных результата: Результат 1: var a = 1; var b = 2; // foo() a++; b = b * a; a = b + 3; // bar() b--; a = 8 + b; b = a * 2;

32

Глава 1. Асинхронность: сейчас и потом

a; // 11 b; // 22

Результат 2: var a = 1; var b = 2; // bar() b--; a = 8 + b; b = a * 2; // foo() a++; b = b * a; a = b + 3; a; // 183 b; // 180

Два результата при выполнении одного кода? Недетерминизм! Но он проявляется на уровне упорядочения функций (событий), а не на уровне упорядочения команд (или даже на уровне упорядочения выражений), как в случае потоков. Иначе говоря, выполнение более детерминировано, чем в случае с потоками. Применительно к поведению JavaScript этот недетерминизм упорядочения функций относится к стандартному состоянию гонки (функции foo() и  bar() словно соревнуются друг с другом за право запуститься первой). А конкретно состояние гонки проявляется в том, что вы не можете заранее надежно предсказать значения a и b. Если бы в JS существовала функция, не обладающая поведением выполнения до завершения, то возможных результатов было бы намного больше, не так ли? Оказывается, в ES6 такая функция появилась (см. главу 4), но пока не беспокойтесь — мы еще вернемся к этой теме!

Параллельное выполнение

33

Параллельное выполнение Представьте сайт со списком обновлений (например, поставки новостей социальной сети), которые последовательно загружаются при прокрутке списка пользователем. Чтобы такая функциональность работала правильно, по крайней мере два разных «процесса» должны выполняться одновременно (то есть в пределах одного временного окна, но не обязательно в один момент времени). Термин «процесс» заключен в кавычки, потому что речь идет не о настоящих процессах уровня операционной системы в понимании компьютерных наук. Это виртуальные процессы (или задачи), представляющие логически связанные, последовательные серии операций. Я  использую термин «процесс» вместо «задача», потому что с точки зрения терминологии он подходит под определения тех концепций, которые рассматриваются в этой главе.

Первый «процесс» реагирует на события onscroll (выдавая запросы Ajax на получение нового контента), срабатывающие при прокрутке страницы. Второй «процесс» получает ответы Ajax (для рендера контента на странице). Очевидно, если пользователь прокручивает страницу достаточно быстро, за время, необходимое для получения и обработки первого ответа, могут сработать два и более события onscroll. Таким образом, события onscroll и события ответов Ajax будут срабатывать достаточно часто и чередоваться друг с другом. Параллельное выполнение происходит тогда, когда два и  более «процесса» выполняются одновременно в течение одного периода времени независимо от того, выполняются ли параллельно составляющие их отдельные операции (в один момент времени на разных процессорах или ядрах). Таким образом, речь идет о параллелизме уровня «процессов» (или уровня задач), в  отличие от параллелизма уровня операций (потоков на разных процессорах).

34

Глава 1. Асинхронность: сейчас и потом

Параллельное выполнение также открывает дополнительный вопрос о взаимодействии этих «процессов» друг с другом. Мы вернемся к этой теме позднее.

Давайте представим каждый независимый «процесс» как серию событий/операций в заданном временном окне (прокрутки страницы пользователем в течение нескольких секунд): «Процесс» 1 (события onscroll): onscroll, onscroll, onscroll, onscroll, onscroll, onscroll, onscroll,

запрос запрос запрос запрос запрос запрос запрос

1 2 3 4 5 6 7

«Процесс» 2 (события ответов Ajax): ответ ответ ответ ответ ответ ответ ответ

1 2 3 4 5 6 7

Вполне возможно, что событие onscroll и событие ответа Ajax окажутся готовыми к обработке точно в один момент. Возьмем наглядное представление этих событий на временной шкале: onscroll, onscroll, onscroll, ответ 3 onscroll, onscroll, onscroll,

запрос 1 запрос 2 запрос 3

ответ 1 ответ 2

запрос 4 запрос 5 запрос 6

ответ 4

Параллельное выполнение

35

onscroll, запрос 7 ответ 6 ответ 5 ответ 7

Вспомните концепцию цикла событий, представленную ранее в этой главе: JS может обрабатывать только одно событие за раз, так что первым произойдет либо onscroll, запрос 2, либо ответ 1, но они не могут произойти буквально в один момент. Представьте детей в школьной столовой — как бы они ни толпились у дверей, им придется выстроиться в очередь по одному, чтобы получить свой обед! В очереди цикла событий чередование событий будет выглядеть примерно так: onscroll, onscroll, ответ 1 onscroll, ответ 2 ответ 3 onscroll, onscroll, onscroll, ответ 4 onscroll, ответ 6 ответ 5 ответ 7

запрос 1 запрос 2 запрос 3

> 3 ]) | 0; } }

}

return +(sum / count);

return { foo: foo };

var heap = new ArrayBuffer( 0x1000 ); var foo = fooASM( window, null, heap ).foo; foo( 10, 20 );

// 233

Этот пример для asm.js написан вручную для демонстрационных целей, так что он не содержит кода, который будет получен при компиляции для asm.js. Тем не менее он показывает типичный код asm.js, особенно подсказки типов и использование буфера кучи для хранения временных переменных.

Первый вызов fooASM(..) настраивает модуль asm.js c выделением памяти кучи. Результатом является функция foo(..), которая может вызываться столько раз, сколько потребуется. Эти вызовы

258

Глава 5. Быстродействие программ

foo(..) должны специально оптимизироваться движком JS с под-

держкой asm.js. Что еще важнее, приведенный код полностью стандартен и должен нормально работать (без специальной оптимизации) в движке без поддержки asm.js. Очевидно, природа ограничений, которые обеспечивают оптимизируемость кода asm.js, значительно сокращает возможные применения такого кода. asm.js не обязательно будет составлять обобщенный набор оптимизаций для любой заданной программы JS. Скорее, asm.js предполагает оптимизированный способ решения специализированных задач, таких как интенсивные математические вычисления (например, обработка графики для компьютерных игр).

Итоги Первые четыре главы этой книги основаны на предположении о том, что паттерны асинхронного программирования позволяют писать более эффективный код, что обычно является очень важным улучшением. Однако возможности асинхронного поведения не бесконечны, потому что оно, по сути, связано с однопоточным циклом событий. Поэтому в этой главе были представлены некоторые механизмы программного уровня, позволяющие дополнительно улучшить быстродействие. Веб-работники позволяют запустить файл JS (то есть программу) в отдельном потоке, используя асинхронные события для передачи информации между потоками. Они прекрасно подходят для перемещения очень долгих или интенсивно потребляющих ре­ сурсы задач в другой поток, чтобы снять нагрузку с основного UI-потока. SIMD отображает параллельные математические операции уровня процессора на JavaScript API для высокопроизводительного

Итоги

259

выполнения операций с параллелизмом уровня данных (например, числовой обработки больших наборов данных). Наконец, asm.js описывает небольшое подмножество JavaScript, которое избегает частей JS, плохо поддающихся оптимизации (например, уборки мусора и преобразования типов), и позволяет движку JS распознавать и  выполнять такой код посредством агрессивных оптимизаций. Код asm.js может быть написан вручную, но это крайне монотонная работа с высоким риском ошибок, сродни ручному написанию кода на языке ассемблера (отсюда и название). В первую очередь предполагается, что asm.js станет хорошей целью для кросс-компиляции программ из других высокооптимизируемых языков, например транспиляции C/C++ на JavaScript с использованием Emscripten. Хотя в этой главе другие возможности не рассматриваются, сейчас на очень ранней стадии обсуждаются еще более радикальные идеи для JavaScript, включая аппроксимации прямой многопоточной функциональности (не просто скрытой за API структурой данных). Произойдет ли это явно или мы просто увидим, как параллелизм все в большей степени проникает в JS, будущее оптимизации программного уровня в JS выглядит многообещающе.

6

Хронометраж и настройка

Первые четыре главы этой книги были посвящены быстродействию как паттерну программирования (асинхронность и параллельное выполнение), а глава 5 была посвящена быстродействию на уровне макропрограммной архитектуры. В этой главе речь пойдет о быстродействии на микроуровне; мы сосредоточимся на отдельных выражениях/командах. Одна из самых частых областей для экспериментов (некоторые разработчики буквально одержимы ею!) — анализ и тестирование различных вариантов написания строки или блока кода и определение того, какой вариант работает быстрее. Мы рассмотрим некоторые аспекты микроуровня, но при этом важно с самого начала понимать, что эта глава написана не для того, чтобы подпитывать навязчивое стремление к оптимизации микроуровня (например, выполняет ли конкретный движок JS операцию ++a быстрее, чем a++). Самое важное в этой главе — разобраться в том, какие виды оптимизаций в JS важны, а какие нет и как отличить одни от других. Но еще перед тем, как браться за изучение материала, необходимо выяснить, как наиболее точно и надежно проводить тестирование

Хронометраж

261

быстродействия JS, потому что бесчисленные неверные представления и мифы на эту тему забивают нашу коллективную базу знаний. Сначала нужно отсеять весь мусор, чтобы картина приобрела ясность.

Хронометраж Постараюсь рассеять некоторые неверные представления. Бьюсь об заклад, что подавляющее большинство разработчиков JS, если им предложат измерить скорость (то есть время выполнения) некоторой операции, изначально предложат что-нибудь такое: var start = (new Date()).getTime(); // или `Date.now()` // выполнить некоторую операцию var end = (new Date()).getTime(); console.log( "Duration:", (end - start) );

Поднимите руку, если нечто похожее пришло вам в голову. Да, я так и думал. Такой подход неверен во многих отношениях. Не стыдитесь, мы все там были. Что именно сообщит вам полученный результат? Понимание того, что он говорит и не говорит о времени выполнения операции, ключ к правильному хронометражу быстродействия в JavaScript. Если вы получите результат 0, может возникнуть впечатление, что выполнение заняло менее миллисекунды. Тем не менее это неточный результат. Некоторые платформы не поддерживают точность до одной миллисекунды, а обновляют таймер с большими приращениями. Например, старые версии Windows (а следовательно, IE) обеспечивали точность только до 15 мс, а это означает, что для получения ненулевого результата операция должна занимать не менее 15 мс! Более того, для полученной продолжительности вы в действительности знаете лишь то, что операция заняла приблизительно столько времени в этом конкретном выполнении. Уверенность в том,

262

Глава 6. Хронометраж и настройка

что она всегда будет выполняться с такой скоростью, близка к нулю. Вы понятия не имеете, не вмешивается ли движок или система в выполнение операции, а в любое другое время операция может выполняться быстрее. А если вы получили продолжительность 4? Можно ли быть уверенными в том, что она заняла 4 мс? Нет. Операция могла занять меньше времени, а какая-то задержка могла привести к получению начального или конечного времени. Но самое неприятное, что вы не можете быть уверены в том, что обстоятельства проведения теста не были излишне оптимистичными. Могло оказаться, что движок JS обнаружил способ оптимизации вашего изолированного тестового примера, а в реальной программе такая оптимизация окажется менее эффективной или невозможной, так что операция будет выполняться медленнее, чем в вашем тесте. Итак… Что же мы узнали? К сожалению, как следует из этих рассуждений, узнали очень немного. Данные с такой низкой степенью доверия даже отдаленно не подходят для построения каких-то выводов. Ваши хронометражные данные практически бесполезны. И что еще хуже, они опасны в том отношении, что внушают ложную уверенность не только вам, но и другим разработчикам, которые не будут критически думать об условиях, приводящих к такому результату.

Повторение Теперь вы говорите: «Ладно, давайте заключим операцию в цикл, чтобы весь тест занимал больше времени». Если повторить операцию 100 раз и выполнение всего цикла заняло 137 мс, можно разделить это значение на 100 и получить среднюю продолжительность 1,37 мс для каждой операции, верно? Не совсем так. Тривиального математического усреднения определенно недостаточно для того, чтобы делать выводы о быстродействии, если

Хронометраж

263

вы собираетесь экстраполировать результаты в масштабах всего приложения. С сотней итераций даже пара выбросов (аномально высоких или низких значений) может исказить среднее, а затем при повторном применении этого вывода искажение может быть раздуто до неимоверных пределов. Вместо того чтобы просто отрабатывать фиксированное количество итераций, можно в цикле выполнять тесты до истечения некоторого интервала времени. Возможно, этот вариант более надежен, но как принять решение с продолжительностью выполнения? Казалось бы, логично предположить, что он должен быть кратен времени, необходимому для однократного выполнения операции. Неправильно. На самом деле промежуток времени для повторений должен базироваться на точности используемого таймера, а именно на минимизации вероятности погрешности. Чем меньшей точностью обладает таймер, тем дольше нужно выполнять код, чтобы свести к минимуму вероятность ошибки. Пятнадцатимиллисекундный таймер очень плохо подходит для точного хронометража; чтобы неопределенность (то есть погрешность) была ниже 1 %, каждый цикл тестовых итераций должен выполняться не менее 750 мс. Для 1-миллисекундного таймера цикл должен выполняться всего 50 мс. Но это всего лишь одна выборка. Чтобы быть уверенными в том, что вы исключили смещение, нужно иметь много выборок для усреднения. Также необходимо кое-что понимать относительно того, насколько медленно работает худшая выборка, насколько быстро работает самая лучшая выборка, насколько разнесены лучший и худший случаи и т. д. Нужно знать не только число, которое сообщает, насколько быстро что-то выполнялось, но и некоторую количественную метрику доверия к этому числу. Возможно, вам также стоит объединить эти разные методы (и добавить к ним другие), чтобы добиться наилучшего баланса по всем возможным методам.

264

Глава 6. Хронометраж и настройка

И это абсолютный минимум, просто для начала. Если вы подходили к хронометражу быстродействия менее серьезно… что ж, вам еще только предстоит узнать о правильном хронометраже.

Benchmark.js Любые актуальные и надежные данные хронометража должны базироваться на статистически достоверных практических методах. Я не буду писать отдельную главу о статистике, поэтому просто буду пользоваться некоторыми терминами: «стандартное отклонение», «дисперсия», «погрешность». Если вы не знаете, что означают эти термины (я проходил курс статистики в колледже, так что у меня тоже нет полной ясности) — вашей квалификации недостаточно для написания собственной логики хронометража. К счастью, умные люди, такие как Джон-Дэвид Дальтон (JohnDavid Dalton) и Матиас Байненс (Mathias Bynens), понимают эти концепции, поэтому написали статистически достоверное средство хронометража Benchmark.js. Поэтому я  не стану нагнетать напряжение и скажу: «Просто используйте этот инструмент». Я не буду повторять всю документацию по работе Benchmark.js; для API доступна превосходная документация, которую стоит прочитать. Кроме того, в разных местах можно найти отличные статьи с описанием подробностей и методологии. Но пока просто для демонстрации я покажу, как использовать Benchmark.js для проведения несложного теста быстродействия: function foo() { // тестируемые операции } var bench = new Benchmark( "foo test", foo,

// имя теста // тестируемая функция

265

Хронометраж

{

);

// (только содержимое) // ..

}

bench.hz; bench.stats.moe; bench.stats.variance; // ..

// дополнительные параметры // (см. документацию)

// количество операций в секунду // погрешность // дисперсия между выборками

О Benchmark.js необходимо знать гораздо больше поверхностного обзора, который я здесь даю. Суть в том, что Benchmark.js берет на себя все сложности настройки надежного, объективного и состоятельного хронометража для заданного блока кода JavaScript. Если вы собираетесь тестировать свой код и проводить хронометраж, начинать следует отсюда. Здесь продемонстрировано применение Benchmark.js для тестирования одиночной операции (скажем, X), но на практике бывает нужно сравнить X с Y. Эта задача легко решается простым созданием двух разных тестов в семействе (организационная возможность Benchmark.js). Затем тесты выполняются «на равных», а на основании их статистик делается вывод о том, что работало быстрее — X или Y. Конечно, Benchmark.js может использоваться для тестирования JavaScript в браузере (см. раздел «jsPerf.com» этой главы), но также возможна работа и в небраузерных средах (Node.js и т. д.). Один из сценариев использования Benchmark.js, о котором часто незаслуженно забывают, — использование в средах разработки или контроля качества для проведения автоматизированных регрессионных тестов быстродействия для критических ветвей кода JavaScript вашего приложения. По аналогии с проведением наборов модульных тестов перед развертыванием вы также можете сравнить быстродействие с предыдущими данными и понять, повысилось или понизилось быстродействие приложения.

266

Глава 6. Хронометраж и настройка

Setup и teardown В предыдущем фрагменте мы обошли вниманием объект «дополнительных параметров» { .. }. Однако о двух параметрах, setup и teardown, все же следует поговорить. Эти два параметра определяют функции, которые должны вызываться до и после выполнения тестового примера. Невероятно важно понимать, что код setup и  teardown не выполняется после каждой итерации теста. Правильнее всего считать, что существуют два цикла: внешний (повторение наборов тестов) и внутренний (повторение итераций тестов). setup и teardown выполняются в начале и в конце каждой итерации внешнего цикла, но не во внутреннем цикле. Почему это важно? Представьте, что у вас имеется тестовый пример, который выглядит так: a = a + "w"; b = a.charAt( 1 );

Затем вы определяете код setup следующим образом: var a = "x";

Возможно, вам кажется, что a начинает со значения "x" при каждой итерации теста. Но это не так! Значение "x" будет присваиваться a для каждого набора тестов, после чего из-за повторяющихся конкатенаций + "w” значение a будет становиться все длиннее, хотя вы по-прежнему обращаетесь только к символу "w" в позиции 1. Чаще всего эта проблема проявляется при внесении изменений с побочными эффектами в DOM (например, присоединения дочернего элемента). Возможно, вы думаете, что родительский элемент каждый раз становится пустым, но на самом деле к нему

Все зависит от контекста

267

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

Все зависит от контекста Не забывайте проверять контекст конкретного хронометража быстродействия, особенного сравнение между задачами X и Y. Даже если ваш тест показывает, что X быстрее Y, это не означает, что заключение «X быстрее Y» действительно обоснованно. Допустим, тест быстродействия показывает, что X выполняет 10 000 000 операций в секунду, а Y выполняет 8 000 000 операций в секунду. Можно утверждать, что Y работает на 20 % медленнее X, и с математической точки зрения это будет правильно, но ваш вывод не настолько убедителен, как вам кажется. Взгляните на результаты с более критических позиций: 10 000 000 операций в секунду — это 10 000 операций в миллисекунду, или 10 операций в миллисекунду. Другими словами, одна операция занимает 0,1 мс, или 100 нс. Трудно представить, насколько мал этот интервал. Для сравнения: часто утверждается, что человеческий глаз не способен различить что-либо, происходящее менее чем за 100 мс, а это в миллион раз медленнее 100-наносекундной скорости операции X. Даже ранние научные исследования, показывавшие, что, возможно, мозг может обрабатывать события со скоростью до 13 мс (примерно в 8 раз быстрее предыдущего утверждения), означают лишь то, что X работает в 125 000 раз быстрее предельной скорости, воспринимаемой человеческим мозгом. X выполняется очень, очень быстро. Но что еще важнее, стоит взглянуть на различия между X и Y — 2 000 000 операций в секунду. Если X выполняется за 100 нс, а Y — за 80 нс, разность составляет 20 нс, что в лучшем случае

268

Глава 6. Хронометраж и настройка

составляет всего 1/650 000 интервала, воспринимаемого человеческим мозгом. К чему я клоню? Что эти различия в быстродействия вообще не важны! Но постойте, а если эта операция будет повторена много раз подряд? Разность будет суммироваться, верно? Значит, мы задаемся другим вопросом: насколько вероятно, что операция X будет повторяться снова и снова, раз за разом, и это должно произойти 650 000 раз подряд, чтобы появилась хотя бы малая надежда, что разность будет заметна человеческому мозгу? А  скорее всего, она должна быть выполнена от 5  000  000 до 10 000 000 раз в сплошном цикле, чтобы разность стала актуальной хотя бы в первом приближении. Хотя ваш внутренний теоретик может возразить, что это возможно, более громкий голос реализма должен задуматься над тем, насколько это вероятно или маловероятно. Даже если это актуально в редких случаях, чаще всего это неактуально. Подавляющее большинство результатов хронометража с малыми операциями — как в случае с мифом про ++x и x++ — совершенно недостоверно как основа вывода о том, что операции X должно отдаваться предпочтение перед Y на основании быстродействия.

Оптимизации движка Вы просто не можете уверенно экстраполировать, что если в вашем изолированном тесте операция X выполнялась на 10 мс быстрее, чем Y, то это означает, что X всегда выполняется быстрее Y и должна всегда использоваться вместо нее. Быстродействие работает не так — все намного, намного сложнее. Например, представьте (чисто гипотетически), что вам нужно протестировать некое поведение из области микробыстродействия, например, сравнение:

Оптимизации движка

269

var twelve = "12"; var foo = "foo"; // тест 1 var X1 = parseInt( twelve ); var X2 = parseInt( foo ); // тест 2 var Y1 = Number( twelve ); var Y2 = Number( foo );

Если вы понимаете, что делает parseInt(..) по сравнению с Number(..), можно предположить, что parseInt(..) теоретически приходится выполнять больше работы, особенно в случае foo. Или же что в случае foo в обоих случаях выполняется одинаковый объем работы, потому что обе функции могут остановиться на первом символе f. Какое предположение верно? Честно говоря, не знаю. Но я возьмусь утверждать, что в данном случае это и не важно. Какие результаты могут быть получены при тестировании? Еще раз: я рассуждаю чисто гипотетически, я  не пытался тестировать этот пример (и меня не интересует результат). Допустим, тесты показывают, что X и Y статистически идентичны. Подтвердит ли это вашу гипотезу о символе f? Нет. В нашем гипотетическом случае движок может понять, что переменные twelve и foo используются в каждом тесте только в одном месте, и подставить их значения в код. Тогда он может понять, что Number( "12" ) можно заменить простым 12. И возможно, он придет к тому же выводу с parseInt(..) — а может, и нет. Или в процесс может вмешаться эвристика удаления неиспользуемого кода, которая определит, что переменные X и Y не используются, так что их объявления нерелевантны. В итоге движок вообще не будет ничего делать в обоих случаях. И все это происходит только из предположений относительно одного запуска теста. Современные движки неизмеримо сложнее

270

Глава 6. Хронометраж и настройка

того, о чем мы рассуждаем здесь. Они проделывают всевозможные трюки — например, отслеживание и трассировку поведения фрагмента кода за короткий период времени или с ограниченным набором входных данных. А что, если движок оптимизирует определенную ветвь из-за фиксированных входных данных, но в реальной программе ввод будет более разнообразным и в ходе оптимизации будут приняты иные решения (или вообще никаких)? Или если движок применяет оптимизации, потому что видит, что функция хронометража выполняет код десятки тысяч раз, но в реальной программе он будет выполнен только сотню раз, и при таких условиях движок решит, что оптимизация не оправдает затраченных усилий? И все эти предполагаемые оптимизации могут случиться в нашем ограниченном тесте, а потом движок не станет применять их в более сложной программе (по разным причинам). Или ситуация может быть иной: движок не будет оптимизировать тривиальный код, но будет склонен к применению агрессивных оптимизаций, когда более сложная программа повышает нагрузку на систему. Я все это говорю к тому, что на самом деле вы точно не знаете, что происходит «под капотом». Никакие догадки и гипотезы не могут считаться веским основанием для принятия таких решений. Означает ли это, что полезный хронометраж в принципе невозможен? Определенно нет! Все сказанное сводится к тому, что тестирование нереального кода не даст реальных результатов. Если это возможно и оправданно, тестируйте реальные нетривиальные фрагменты своего кода и постарайтесь приблизить условия к реальным настолько, насколько это возможно. Только тогда полученные результаты имеют хоть какой-то шанс на отражение реального положения дел. Микрохронометражные тесты вроде сравнения ++x с  x++ с такой высокой вероятностью дадут бесполезные результаты, что можно сразу же считать их таковыми.

jsPerf.com

271

jsPerf.com Хотя Benchmark.js пригодится при тестировании быстродействия вашего кода в любой среде JS, невозможно выразить, насколько важно собрать результаты тестирования из множества разных сред (настольные браузеры, мобильные устройства и т. д.), если вы надеетесь получить достоверные выводы из тестов. Например, Chrome на высокопроизводительной настольной машине вряд ли будет работать хотя бы приблизительно с таким же быстродействием, как мобильная версия Chrome на смартфоне. А смартфон с полным зарядом аккумулятора вряд ли будет работать с таким же быстродействием, как смартфон с  2 % заряда, когда устройство понижает энергопотребление процессора. Если вы хотите делать сколько-нибудь осмысленные выводы типа «X быстрее, чем Y» более чем для одной среды, вам придется протестировать как можно больше таких реальных сред. Если Chrome выполняет некую операцию X быстрее операции Y, это не означает, что та же картина будет наблюдаться во всех браузерах. И конечно, результаты тестовых запусков для разных браузеров должны быть сопоставлены с  демографией ваших пользователей. Для этой цели существует замечательный веб-сайт jsPerf. Он использует библиотеку Benchmark.js, упоминавшуюся выше, для проведения статистически точных и  надежных тестов, причем проводит тест на общедоступном URL-адресе, который вы можете передавать другим. Каждый раз при проведении теста результаты собираются и сохраняются с тестом. Накопленные результаты теста отображаются на диаграмме, с которой могут ознакомиться все желающие. Создание теста на сайте начинается с двух тестовых примеров, но вы можете добавить столько, сколько сочтете нужным. Также предусмотрена возможность создания setup-кода, выполняемого

272

Глава 6. Хронометраж и настройка

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

Вы можете определить начальную подготовку страницы (импортирование библиотек, определение вспомогательных функций, объявление переменных и т. д.). Также предусмотрены параметры для определения setup/teardown-поведения при необходимости.

Проверка на здравый смысл jsPerf — великолепный ресурс, но на нем опубликовано множество тестов, которые при анализе оказываются несостоятельными или неполноценными по различным причинам, описанным в  этой главе. Пример: // Пример 1 var x = []; for (var i=0; i false .runner( // обработчик состояния `1` state( 1, function *stateOne(transition){ console.log( "in state 1" ); prevState = 1; yield transition( 3 ); // перейти в состояние `3` } ), // обработчик состояния `2`

342

Приложение Б. Расширенные асинхронные паттерны

state( 2, function *stateTwo(transition){ console.log( "in state 2" ); prevState = 2; yield transition( 3 );

} ),

// перейти в состояние `3`

// обработчик состояния `3` state( 3, function *stateThree(transition){ console.log( "in state 3" ); if (prevState === 2) { prevState = 3; yield transition( 1 ); // перейти в состояние `1` } // готово! else { yield "That's all folks!";

} )

}

prevState = 3; yield transition( false ); // terminal state

) // работа конечного автомата завершена, идти дальше .val( function(msg){ console.log( msg ); // That's all folks! } );

Важно заметить, что сами генераторы *stateOne(..), *stateTwo(..) и *stateThree(..) вызываются заново при каждом входе в это состояние и завершаются при вызове transition(..) с другим значением. Хотя это здесь не показано, конечно, эти обработчики состояний могут асинхронно приостанавливаться посредством yield-выдачи обещаний/последовательности/преобразователей. Скрытые генераторы, производимые вспомогательной функцией state(..) и фактически переданные ASQ#runner(..), продолжают параллельно работать на протяжении всего времени работы конечного автомата. Каждый из них в кооперативном режиме обеспечивает yield-передачу управления следующему, и т. д.

Взаимодействующие последовательные процессы

343

Пример ping pong (http://jsbin.com/qutabu/1/edit?js,output) более полно демонстрирует кооперативное параллельное выполнение с генераторами под управлением ASQ#runner(..).

Взаимодействующие последовательные процессы Модель взаимодействующих последовательных процессов (CSP, Communicating Sequential Processes) впервые была описана Чарльзом Э. Хоаром (C. A. R. Hoare) в академической статье 1978 года и позднее рассмотрена в одноименной книге 1985 года. CSP описывает формальный механизм взаимодействия параллельных «процессов» во время выполнения. Возможно, вы помните, что мы упоминали параллельно выполняемые «процессы» в главе 1, так что наше исследование CSP здесь будет базироваться на этом понимании. Как и большинство выдающихся концепций в  компьютерной теории, CSP в значительной мере происходит из академических исследований, выраженных в алгебре процессов. Тем не менее я подозреваю, что теоремы символической алгебры вряд ли принесут практическую пользу читателю, так что нам придется поискать другой подход к CSP. За формальными описаниями и доказательствами я предлагаю читателю обращаться к работе Хоара и многим другим отличным работам, написанным с того времени. А здесь мы попробуем крат­ ко объяснить идею CSP с неакадемических — и хочется верить, интуитивно понятных — позиций.

Передача сообщений Основной принцип CSP заключается в том, что все взаимодействия/обмен данными между независимыми (в остальном) про-

344

Приложение Б. Расширенные асинхронные паттерны

цессами должны осуществляться посредством формальной передачи сообщений. Возможно, вопреки вашим ожиданиям передача сообщений в CSP описывается как синхронное действие, при котором процесс-отправитель и процесс-получатель должны быть готовы к передаче сообщения. Как синхронная передача сообщений может быть связана с асинхронным программированием на JavaScript? В основе конкретности отношений лежит использование генераторов ES6 для создания действий, которые выглядят синхронно, но во внутренней реализации могут быть синхронными или (более вероятно) асинхронными. Другими словами, два и более параллельно выполняемых генератора могут на первый взгляд синхронно обмениваться сообщениями без потери фундаментальной асинхронности системы, поскольку код каждого генератора приостанавливается (блокируется), ожидая возобновления асинхронного действия. Как это все работает? Представьте генератор («процесс») с именем «A», который хочет отправить сообщение генератору «B». Сначала «A» выдает через yield сообщение (что приводит к приостановке «A») для отправки «B». Когда генератор «B» будет готов и получит сообщение, «A» продолжает работу (разблокируется). Одно из самых популярных выражений теории передачи сообщений CSP происходит из библиотеки ClojureScript core.async, а также из языка go. В этих подходах к CSP воплощается описанная семантика передачи данных через канал, открытый между процессами. Использование термина «канал» отчасти объясняется тем, что в  некоторых режимах в  буфер канала может передаваться более одного значения; это чем-то напоминает наши представления о потоках. Здесь этот механизм подробно не рассматривается, но он может быть очень мощным средством управления потоками данных.

Взаимодействующие последовательные процессы

345

В простейшем представлении CSP канал, созданный между точками «A» и «B», должен содержать метод take(..) для блокировки при получении значения и метод put(..) для блокировки при отправке значения. Это может выглядеть примерно так: var ch = channel(); function *foo() { var msg = yield take( ch ); }

console.log( msg );

function *bar() { yield put( ch, "Hello World" ); }

console.log( "message sent" );

run( foo ); run( bar ); // Hello World // "message sent"

Сравните эту структурированную, синхронную (вернее, синхронно выглядящую) передачу сообщений с неформальным и неструктурированным обменом сообщений, которую предоставляет ASQ#runner(..), с массивом token.messages и кооперативным использованием yield. По сути, yield put(..) представляет собой одну операцию, которая одновременно отправляет значение и приостанавливает выполнение для передачи управления, тогда как в предыдущих примерах это делалось в разных шагах. Кроме того, CSP наглядно показывает, что вы не передаете управление явно, но вместо этого проектируете свои параллельные функции для блокировки в ожидании либо значения, полученного из канала, либо попытки отправить сообщение по каналу. Именно блокировка по получению или отправке сообщений является механизмом координации поведения между сопрограммами.

346

Приложение Б. Расширенные асинхронные паттерны

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

Для этой разновидности CSP, реализованной в JavaScript, существует несколько хороших библиотек, и прежде всего библиотека js-csp (https://github.com/ubolonton/js-csp), которую запустил Джеймс Лонг (James Long) (https://github.com/jlongster/js-csp) и о которой он много писал (http://jlongster.com/Taming-the-Asynchronous-Beast-withCSP-in-JavaScript). Кроме того, статьи Дэвида Нолена (David Nolen) (http://twitter.com/swannodette) содержат исключительно полезную информацию об адаптации CSP ClojureScript в go-стиле core.async для генераторов JS ( http://swannodette.github.io/2013/08/24/es6generators-and-csp/).

Эмуляция CSP в asynquence Поскольку ранее мы здесь обсуждали асинхронные паттерны в контексте моей библиотеки asynquence, возможно, вам будет интересно узнать, что поверх обработки генераторов ASQ#runner(..) можно довольно легко добавить прослойку эмуляции, которая обеспечивает практически идеальное портирование CSP API и реализацию поведения. Прослойка эмуляции поставляется как дополнительная часть пакета asynquence-contrib вместе с asynquence. По аналогии со вспомогательной функцией state(..), ASQ.csp. go(..) получает генератор — в терминологии go/core.async он называется go-процедурой (goroutine) — и адаптирует его для использования с ASQ#runner(..), возвращая новый генератор. Вместо передачи token ваша go-процедура получает изначально созданный канал (ch), который совместно используется всеми go-процедурами для этого запуска программы. Вы можете создать дополнительные каналы (что часто бывает весьма полезно!) вызовом ASQ.csp.chan(..).

Взаимодействующие последовательные процессы

347

В CSP вся асинхронность моделируется в контексте блокирования по сообщениям канала вместо блокирования в ожидании завершения обещания/последовательности/преобразователя. Итак, вместо выдачи через yield обещания, возвращенного от request(..), функция request(…) должна вернуть канал, из которого запрашивается значение вызовом take(..). Иначе говоря, канал с одним значением в этом контексте/варианте использо­ вания приблизительно эквивалентен обещанию/последовательности. Для начала создадим версию request(..) с поддержкой каналов: function request(url) { var ch = ASQ.csp.channel(); ajax( url ).then( function(content){ // `putAsync(..)` - версия `put(..)`, которая может // использоваться за пределами генератора. Она // возвращает обещание для завершения операции. Здесь // это обещание не используется, но мы могли бы // использовать его, если бы потребовалось получать // уведомления о получении значения вызовом `take(..)`. ASQ.csp.putAsync( ch, content ); } ); return ch; }

Вспомните: в главе 3 мы ввели термин «фабрика обещаний» для функции, производящей обещания; в главе 4 — термин «фабрика преобразователей» для функции, производящей преобразователи; и наконец, в приложении А — термин «фабрика последовательностей» для функции, производящей последовательности. Естественно, аналогичный термин стоит ввести и для функции, производящей каналы. Неудивительно, что мы назовем ее «фабрикой каналов». В качестве упражнения для самостоятельной работы читатель может определить функцию channelify(..) по аналогии с Promise.wrap(..)/promisify(..) (глава 3), thunkify(..) (глава 4) и ASQ.wrap(..) (приложение А).

348

Приложение Б. Расширенные асинхронные паттерны

А теперь рассмотрим пример параллелизма Ajax с использованием CSP в стиле asynquence: ASQ() .runner( ASQ.csp.go( function*(ch){ yield ASQ.csp.put( ch, "http://some.url.2" ); var url1 = yield ASQ.csp.take( ch ); // "http://some.url.1" var res1 = yield ASQ.csp.take( request( url1 ) ); yield ASQ.csp.put( ch, res1 ); } ), ASQ.csp.go( function*(ch){ var url2 = yield ASQ.csp.take( ch ); // "http://some.url.2" yield ASQ.csp.put( ch, "http://some.url.1" ); var res2 = yield ASQ.csp.take( request( url2 ) ); var res1 = yield ASQ.csp.take( ch ); // передать результаты следующему шагу // последовательности ch.buffer_size = 2; ASQ.csp.put( ch, res1 ); ASQ.csp.put( ch, res2 );

} ) ) .val( function(res1,res2){ // `res1` comes from "http://some.url.1" // `res2` comes from "http://some.url.2" } );

Передача сообщений, которая обеспечивает обмен строками URL между двумя go-процедурами, реализована тривиально. Первая go-процедура выдает запрос Ajax к первому URL-адресу и помещает ответ в канал ch. Вторая go-процедура выдает запрос Ajax ко второму URL-адресу, после чего получает первый ответ res1 из канала ch. В этой точке оба ответа, res1 и res2, завершены и готовы.

349

Итоги

Если в канале ch в конце go-процедуры остаются какие-либо значения, они будут переданы следующему шагу последовательности. Таким образом, чтобы передать сообщения из последней goпроцедуры, поместите их в  ch вызовом put(..). Чтобы избежать блокирования этих завершающих вызовов put(..), мы переводим ch в режим буферизации, присваивая свойству buffer_size значение 2 (по умолчанию равно 0). Другие примеры использования CSP в стиле asynquence доступны на GitHub: https://gist.github.com/getify/ e0d04f1f5aa24b1947ae.

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

Об авторе

Кайл Симпсон — евангелист Open Web из Остина (штат Техас), большой энтузиаст всего, что касается JavaScript. Автор нескольких книг, преподаватель, спикер и участник/лидер проектов с открытым кодом.

Кайл Симпсон {Вы не знаете JS} Асинхронная обработка и оптимизация Перевел с английского Е. Матвеев

Заведующая редакцией Ведущий редактор Литературный редактор Художественный редактор Корректоры Верстка

Ю. Сергиенко К. Тульцева М. Устимова В. Мостипан С. Беляева, Н. Викторова Л. Егорова

Изготовлено в России. Изготовитель: ООО «Прогресс книга». Место нахождения и фактический адрес: 194044, Россия, г. Санкт-Петербург, Б. Сампсониевский пр., д. 29А, пом. 52. Тел.: +78127037373. Дата изготовления: 05.2019. Наименование: книжная продукция. Срок годности: не ограничен. Налоговая льгота — общероссийский классификатор продукции ОК 034-2014, 58.11.12.000 — Книги печатные профессиональные, технические и научные. Импортер в Беларусь: ООО «ПИТЕР М», 220020, РБ, г. Минск, ул. Тимирязева, д. 121/3, к. 214, тел./факс: 208 80 01. Подписано в печать 14.05.19. Формат 60х90/16. Бумага офсетная. Усл. п. л. 22,000. Тираж 1000. Заказ 0000. Отпечатано в ОАО «Первая Образцовая типография». Филиал «Чеховский Печатный Двор». 142300, Московская область, г. Чехов, ул. Полиграфистов, 1. Сайт: www.chpk.ru. E-mail: [email protected] Факс: 8(496) 726-54-10, телефон: (495) 988-63-87