Понимание асинхронного программирования

Node Hero: Глава 3

Перевод книги Node Hero от RisingStack. Переведено с разрешения правообладателей.

Оглавление

В этой главе я расскажу вам о принципах асинхронного программирования и покажу, как создавать асинхронные операции в JavaScript и Node.js.

Синхронное программирование

В традиционной практике программирования большинство операций ввода-вывода происходит синхронно. Если вы задумайтесь о Java и о том, как вы читаете файл в ней, вы получите что-то вроде этого:

try(FileInputStream inputStream = new FileInputStream("foo.txt")) {
Session IOUtils;
String fileContent = IOUtils.toString(inputStream);
}

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

Если у вас больше блокирующих операций, очередь событий становится ещё хуже:

Красные полосы отображают промежутки времени, в которые процесс ожидает ответа от внешнего ресурса и блокируется, чёрные полосы показывают, когда ваш код работает, зелёные полосы отображают остальную часть приложения

Для решения этой проблемы Node.js предлагает модель асинхронного программирования.

Асинхронное программирование в Node.js

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

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

Начнем с простого примера: синхронное чтение файла с использованием Node.js:

const fs = require('fs')
let content
try {
content = fs.readFileSync('file.md', 'utf-8')
} catch (ex) {
console.log(ex)
}
console.log(content)

Что здесь происходит? Мы читаем файл, используя синхронный интерфейс модуля fs. Он работает ожидаемым образом: в переменную content сохраняется содержимое file.md. Проблема с этим подходом заключается в том, что Node.js будет заблокирована до завершения операции, то есть, пока читается файл, она не может сделать ничего полезного.

Посмотрим, как мы можем это исправить!

Асинхронное программирование, в том виде, в каком мы знаем его в JavaScript, может быть реализовано только при условии, что функции являются объектами первого класса: они могут передаваться как любые другие переменные другим функциям. Функции, которые могут принимать другие функции в качестве аргументов, называются функциями высшего порядка.

Один из самых простых примеров функций высшего порядка:

const numbers = [2,4,1,5,4]
function isBiggerThanTwo (num) {
return num > 2
}
numbers.filter(isBiggerThanTwo)

В приведенном выше примере мы передаем функцию isBiggerThanTwo в функцию filter. Таким образом, мы можем определить логику фильтрации.

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

В основе Node.js лежит принцип «первым аргументом в колбеке должна быть ошибка». Его придерживаются базовые модули, а также большинство модулей, найденных в NPM.

const fs = require('fs')
fs.readFile('file.md', 'utf-8', function (err, content) {
if (err) {
return console.log(err)
}
    console.log(content)
})

Что следует здесь выделить:

  • обработка ошибок: вместо блока try-catch вы проверяете ошибку в колбеке
  • отсутствует возвращаемое значение: асинхронные функции не возвращают значения, но значения будут переданы в колбеки

Давайте немного изменим этот файл, чтобы увидеть, как это работает на практике:

const fs = require('fs')
console.log('start reading a file...')
fs.readFile('file.md', 'utf-8', function (err, content) {
if (err) {
console.log('error happened during reading the file')
return console.log(err)
}
console.log(content)
})
console.log('end of the file')

Результатом выполнения этого кода будет:

start reading a file...
end of the file
error happened during reading the file

Как вы можете видеть, как только мы начали читать наш файл, выполнение кода продолжилось, а приложение вывело end of the file. Наш колбек вызвался только после завершения чтения файла. Как такое возможно? Встречайте цикл событий (event loop).

Цикл событий

Цикл событий лежит в основе Node.js и JavaScript и отвечает за планирование асинхронных операций.

Прежде чем погрузиться глубже, давайте убедимся, что мы понимаем, что такое программирование с управлением по событиям (event-driven programming).

Программирование с управлением по событиям представляет собой парадигму программирования, в которой поток выполнения программы определяется событиями, такими как действия пользователя (щелчки мышью, нажатия клавиш), выходы датчиков или сообщения из других программ/потоков.

На практике это означает, что приложения реагируют на события.

Кроме того, как мы уже узнали в первой главе, с точки зрения разработчика Node.js является однопоточным. Это означает, что вам не нужно иметь дело с потоками и синхронизировать их, Node.js скрывает эту сложность за абстракцией. Всё, кроме кода, выполняется параллельно.

Для более глубокого понимания работы цикла событий рекомендуем посмотреть это видео:

https://www.youtube.com/watch?v=8cV4ZvHXQL4

Асинхронный поток управления

Поскольку теперь у вас есть общее представление о том, как работает асинхронное программирование в JavaScript, давайте рассмотрим несколько примеров того, как вы можете организовать свой код.

Async.js

Чтобы избежать так называемого Callback-Hell, вы можете начать использовать async.js.

Async.js помогает структурировать ваши приложения и упрощает понимание потока управления.

Давайте рассмотрим короткий пример использования Async.js, а затем перепишем его с помощью промисов.

Следующий фрагмент перебирает три файла и выводит системную информацию по каждому:

async.parallel(['file1', 'file2', 'file3'],
fs.stat,
function (err, results) {
// results теперь содержит массив системных данных для каждого файла
})

Примечание переводчика: если вы пользуетесь Node.js версии 7 и выше, лучше воспользоваться встроенными конструкциями языка, такими как async/await.

Промисы

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

На практике предыдущий пример можно переписать следующим образом:

function stats (file) {
return new Promise((resolve, reject) => {
fs.stat(file, (err, data) => {
if (err) {
return reject(err)
}
resolve(data)
})
})
}
Promise.all([
stats('file1'),
stats('file2'),
stats('file3')
])
.then((data) => console.log(data))
.catch((err) => console.log(err))

Конечно, если вы используете метод, возвращающий промис, то пример будет заметно компактнее.


В следующей главе вы узнаете, как запустить ваш первый Node.js HTTP-сервер.


Слушайте наш подкаст в iTunes и SoundCloud, читайте нас на Medium, контрибьютьте на GitHub, общайтесь в группе Telegram, следите в Twitter и канале Telegram, рекомендуйте в VK и Facebook.

Глава на GitHub