В этой статье обсуждается, как JavaScript не блокирует поток в ожидании события ввода-вывода. Это продолжение моей предыдущей статьи: Почему простое использование async-await и промисов не делает ваш код асинхронным (JavaScript), в которой объясняется, что такое блокировка потока и ее различные типы.

Давайте сначала рассмотрим пример блокировки ввода-вывода. Чтение/запись с/на жесткий диск являются операциями ввода/вывода.

Я создал файл «ice.txt» с содержимым «доставлено мороженое». Мы вызываем 2 метода: OrderIceCream(), который будет читать файл ice.txt и регистрировать его содержимое (слово «мороженое доставлено»). Чтение с диска может занять некоторое время, особенно при работе с большим объемом данных. Поэтому я хочу выполнить другую важную работу, пока выполняется операция чтения ввода-вывода. То есть я хочу заказать мороженое и съесть пиццу, ожидая доставки мороженого. Как вы думаете, каким будет фактический порядок регистрации?

Итак, мы заказали мороженое и только после того, как его доставили, смогли съесть пиццу, которая, вероятно, уже остыла. Как многие из вас уже могли догадаться, это происходит потому, что мы читаем файл синхронно, используя fs.readFileSync(). Это означает, что основной поток JavaScript будет заблокирован до тех пор, пока данные не будут прочитаны с диска, т. е. пока не завершится операция ввода-вывода. Вы не можете делать что-либо еще на нем до тех пор.

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

Я запускаю сервер, который прослушивает localhost: 5000 и отвечает «Мороженое доставлено» через 5 секунд. Пока я жду ответа, я хочу выполнить другую важную работу, которая связана с "съеданием пиццы". Как вы думаете, какой порядок журналов будет на этот раз?

Мы снова заказали мороженое, и нам пришлось ждать его доставки, прежде чем мы смогли съесть пиццу. Это определенно не то, чего мы хотим. Мы хотим съесть пиццу, ожидая, пока принесут мороженое.

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

Итак, это были 2 примера блокировки нашего потока во время ожидания ввода-вывода. Но как мы можем предотвратить это?

Традиционным способом преодоления блокировки потока, связанного с вводом-выводом, было перемещение работы в другой поток. Затем другой поток будет заблокирован в ожидании ввода-вывода, и ваш основной поток сможет выполнять другие важные действия. Некоторые из вас могут сказать, что это невозможно сделать, поскольку JavaScript является однопоточным. Действительно, это так, но JavaScript работает в браузере или с использованием NodeJs. И у браузера, и у NodeJ есть собственные рабочие потоки, которые можно использовать. Но этот подход имеет некоторые недостатки.

Во-первых, нить стоит дорого. Для этого требуется около 1 МБ памяти. Хотя это может показаться немного, но если вам нужно выполнить тысячи сетевых операций, у вас может не хватить оперативной памяти из-за огромного количества потоков в очереди ожидания. Кроме того, это очень контринтуитивный подход. Здесь вместо того, чтобы ждать, пока доставят мороженое, а затем есть пиццу, вы просите своего друга (рабочий поток) дождаться мороженого, пока вы едите пиццу.

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

JavaScript говорит, что почему бы не использовать основную память (ОЗУ) для хранения состояния выполнения до тех пор, пока не начнется операция ввода-вывода, и оставить поток свободным для обработки других событий. Затем, когда ввод-вывод завершится, просто прочитайте состояние из памяти и запустите остальной код, который зависит от результата операции ввода-вывода.

Как мы узнаем, когда операция ввода-вывода завершена? Устройство, выполняющее эту операцию, вызовет прерывание, чтобы обозначить ее завершение.

Теперь давайте посмотрим, как мы можем решить проблемы, представленные в двух приведенных выше примерах, используя асинхронный JavaScript (асинхронное ожидание, обещания и обратные вызовы). Я также объясню, как они работают под капотом.

На этот раз мы будем использовать метод fs.readFile(), который позволяет асинхронно считывать данные и планировать обратный вызов (AcceptDelivery), когда данные будут прочитаны. Как вы думаете, какой порядок записи будет на этот раз?

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

В качестве третьего аргумента метода fs.readFile() мы указали функцию обратного вызова (AcceptDelivery()), которая должна выполняться после завершения операции чтения с диска и у нас есть содержимое файла.

Когда функция fs.readFile() выполняется (обратите внимание, что эта функция, как и любая другая библиотечная функция, сама будет выполняться в основном потоке), она сохранит информацию о своем обратном вызове в основной памяти. . Затем эта функция попросит операционную систему связаться с диском (используя драйвер устройства для жесткого диска) и выполнить фактическое чтение. Пока происходит чтение, основной поток JavaScript может обрабатывать другие события, такие как выполнение функции EatPizza(). Как только чтение с диска будет завершено, жесткий диск вызовет прерывание (все устройства в вашей компьютерной системе имеют собственные крошечные процессоры для таких целей). Драйвер устройства обработает это прерывание, и в конечном итоге обратный вызов AcceptDelivery() будет помещен в очередь обратного вызова (если вы не знакомы с циклом обработки событий JS и очередью обратного вызова, посмотрите это видео). Всякий раз, когда поток свободен (стек вызовов пуст), цикл обработки событий JavaScript выбирает метод из очереди обратного вызова и назначает его основному потоку для выполнения.

Теперь еще раз посмотрим на этот процесс, более подробно, решив вторую задачу (работа в сети), используя async-await и промисы:

На этот раз я использовал API выборки с асинхронным ожиданием. При запуске результаты из консоли такие, какие мы хотели:

Поток начнет выполнение этого кода последовательно, сначала выполнив функцию OrderIceCream(). «Заказ мороженого» будет зарегистрирован в консоли. Теперь элемент управления достигает функции await fetch(). Этот библиотечный метод fetch() будет выполнен, и он сообщит операционной системе, что нужно попросить сетевую карту начать операцию ввода-вывода по сети (отправить HTTP-запрос), за которой следует операция чтения по сети ( прочитайте ответ из сокета). Когда запрос от ОС доходит до устройства (сетевой карты), он тут же возвращается со статусом «ожидание». ОС передаст это обратно в поток, и метод fetch() вернет промис со статусом «ожидание».

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

Основной поток теперь свободен для выполнения другого кода, здесь EatPizza(). Когда обе операции сетевого ввода-вывода будут завершены, сетевая карта прервет работу процессора, и ее драйвер устройства отправит данные в операционную систему, сообщая ей, что операция ввода-вывода завершена. ОС передаст это вашему приложению, где рабочий поток (из NodeJs или браузера) просматривает память, чтобы получить состояние выполнения, которое мы сохранили ранее, а затем поставит в очередь остальную часть кода после ожидания fetch (console.log(res.text())) в очереди обратного вызова. Цикл событий добавит этот код в стек вызовов, как только основной поток освободится (после выполнения функции EatPizza()).

Это все равно, что написать на листе бумаги «прими доставку мороженого и съешь мороженое», а затем съешь пиццу. Когда курьер позвонит в вашу дверь (подняв прерывание), вы посмотрите, что вы записали, а затем примите доставку и съешьте мороженое!

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

Когда операция ввода-вывода завершена, устройство, которое выполняло эту операцию, инициирует прерывание. Чтобы передать это завершение вашему приложению, на короткое время заимствуются несколько потоков. На уровне приложения рабочий поток (браузерный или NodeJs) обрабатывает ответ о завершении ввода-вывода, просматривает состояние выполнения (в ОЗУ), которое он сохранил ранее, и ставит в очередь остальной код, который зависит от I Ответ /O будет получен циклом обработки событий и добавлен в стек вызовов после освобождения основного потока.

Спасибо за прочтение 😃, вот несколько интересных материалов на темы неблокирующего ввода-вывода, асинхронности и цикла обработки событий:

  1. Нет нити (stephencleary.com)

«2. Как работает неблокирующий ввод-вывод? | Хильке де Врис | Блог ИНГ | Середина"

3. Что, черт возьми, такое цикл событий? | Филип Робертс | JSConf ЕС — YouTube

«4. c# — если async-await не создает никаких дополнительных потоков, то как он заставляет приложения реагировать? - Переполнение стека"

5. Мадс Торгерсен: Внутри C# Async | Углубление | 9 канал (archive.org)

Дополнительные материалы на PlainEnglish.io. Подпишитесь на нашу бесплатную еженедельную рассылку новостей. Подпишитесь на нас в Twitter, LinkedIn, YouTube и Discord .

Заинтересованы в масштабировании запуска вашего программного обеспечения? Ознакомьтесь с разделом Схема.