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

Я думаю, что до LibUV нам нужно знать некоторые фундаментальные темы в области компьютерных наук, чтобы их понимание могло помочь нам понять, как все работает. К концу этой темы, надеюсь, вы поймете:

  • Асинхронный против синхронного
  • Поток против процесса
  • Что такое системный вызов
  • Что такое циклы событий и что они не блокируют асинхронное выполнение?
  • LibUV и цикл событий
  • Что такое задержка цикла событий?
  • Есть ли в Node.js один поток?

Асинхронный против синхронного

Синхронная команда просто означает чтение кода построчно, и для каждой строки она должна ждать, пока предыдущая строка завершит свое выполнение. Две строки кода никогда не выполняются одновременно. Фактически, именно так работают движки Javascript, например, v8. (загляните в мою первую тему, если хотите узнать больше о JS-движках). Движки JS подготавливают коды построчно, а затем преобразуют их в машинный код. Напротив, асинхронный означает, что мы можем обрабатывать более одной вещи одновременно. Следующая операция может выполняться, пока предыдущая операция все еще обрабатывается, поэтому код может выполняться параллельно. Однако в javascript есть некоторые команды, которым мы можем указать JS-движкам запускать код, а не ждать результата и переходить к следующей строке. Затем, если вся данная инструкция интерпретируется, вернитесь к этой задаче и покажите мне ее результат.
давайте посмотрим код:

В js есть функция с именем setTimeout, которая позволяет запускать функцию позже, а не сразу.

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

const fn = function () {
   console.log('FN')
}

const interval = 2000;

setTimeout(fn, interval)

console.log('MAIN')

если вы запустите этот код, вы увидите MAIN, а затем через две секунды (2000 мс) FN в консоли. Теперь, если мы изменим интервал на 0, что мы увидим? давай сделаем это

const fn = function () {
   console.log('FN')
}

const interval = 0; // I changed interval to 0

setTimeout(fn, interval)

console.log('MAIN')

на бумаге мы должны увидеть FN, а затем MAIN, потому что интервал равен нулю, но если вы запустите этот код, вы увидите тот же результат, что и раньше. Хотя вы ставите интервал 0, js-движки не сразу запускают обратный вызов setTimeout, вместо этого ему нужно проверить, есть ли какая-либо другая строка для интерпретации или нет. В этом примере ответ «да», console.log(‘MAIN’), поэтому в журнал записывается MAIN, а затем записывается FN.

давайте немного оживим его. Я хочу добавить длинный цикл после console.log(‘MAIN’) и посмотреть, что произойдет

const fn = function () {
   console.log('FN')
}

const interval = 0;

setTimeout(fn, interval)

console.log('MAIN')
for(let i=0; i<99999999;i++) {}

давайте предположим, что этот цикл for занимает 500 мс секунд для завершения. если мы запустим этот код, вы увидите MAIN , тогда движки JS должны посчитать до 99999999. когда i достигает этого числа, теперь движок JS свободен и может обрабатывать обратный вызов setTimeout. Это действительно важно знать. На самом деле этот интервал не дает гарантии, что callback запустится именно в это время, потому что js — это один поток, и он не может обрабатывать две вещи одновременно.

Поток против процесса

когда мы запускаем программу на нашем компьютере, мы запускаем нечто, называемое процессом, и это экземпляр работающей программы. Каждый процесс должен иметь по крайней мере один поток, который называется основным потоком, и он выполняет основную работу, потому что программная инструкция (наш код) существует и готова к выполнению ЦП. Внутри одного процесса может быть несколько потоков. каждый процесс имеет свое собственное адресное пространство памяти. Один процесс не может повредить пространство памяти другого процесса (хотя есть некоторые исключения). Напротив, все потоки в одном процессе используют общую общую память процесса. Вот почему неправильное поведение потока может привести к остановке всего процесса. например, давайте предположим, что у нас есть два потока в процессе, и первый поток удаляет что-то в памяти, которое нужно второму потоку. Это может разрушить весь процесс. Нам также нужно управлять условиями гонки и блокировкой, которые иногда могут свести вас с ума (многопоточность — это зло :)

Chrome выделяет один процесс для каждой вкладки, поэтому, если на одной вкладке возникают какие-либо проблемы, это не влияет на другие вкладки. на самом деле, когда мы запускаем Chrome, ОС создает процесс с именем родительский процесс, а когда мы открываем новую вкладку, родительский процесс создает новый дочерний процесс. также дочерний процесс может создать еще один новый процесс. Эти процессы могут обмениваться данными друг с другом посредством так называемого межпроцессного взаимодействия (IPC).

Каждый процесс представлен в ОС блоком управления процессом (PCB), который показывает некоторые свойства процесса. Давайте посмотрим на некоторые из них:

  • Идентификатор процесса: каждый процесс имеет уникальный идентификатор.
  • Состояние процесса: представляет различные состояния процесса, которые показывают его текущую активность, такую ​​как ОЖИДАНИЕ (например, когда ему нужно, чтобы произошли некоторые события, такие как ввод-вывод), РАБОТАЕТ, ГОТОВ (это означает, что он готов к обработке ЦП. ОС). старается свести к минимуму готовые процессы и как можно быстрее выделить ЦП) или…
  • Счетчик программ: хранит адрес следующей строки инструкции, которая должна быть выполнена.
  • Информация об управлении памятью
  • Информация о состоянии ввода-вывода: показывает устройства, назначенные этому процессу.
  • …..

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

Если вы перейдете к монитору активности на Mac или диспетчеру задач в Windows, вы увидите количество процессов и потоков, которые в данный момент выполняются на вашем компьютере.

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

У каждого потока есть приоритет, который указывает, нужно ли его запускать срочно или нет. Например, поток, отвечающий за управление курсором мыши, должен немедленно получить ресурсы ЦП; в противном случае пользователь может почувствовать некоторое отставание. Таким образом, планировщик ОС постоянно переключается между всеми процессами и потоками, и они называют это переключением контекста.

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

Что такое системный вызов?

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

Если процесс выполняется в пользовательском режиме, эта программа не имеет прямого доступа к памяти и аппаратным ресурсам, и, напротив, если процесс выполняется в режиме ядра, он имеет прямой доступ к ресурсам компьютера.

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

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

Есть несколько сервисов, которые можно запросить у этого ядра:

  • Работа с файлами (создание, удаление, открытие и получение атрибута файла)
  • Управление процессом (создание процесса и выделение памяти или остановка процесса и освобождение памяти, получение или установка атрибута процесса, например, процесс должен ждать некоторое время для повторного выполнения, или ему нужно ждать определенных событий, или ему нужно отправить событие)
  • Управление устройствами (запрос или освобождение устройства)
  • Информационное обслуживание (получение времени и даты и других системных данных, которые могут вам понадобиться)
  • Коммуникации (Под коммуникациями я подразумеваю связь между процессами или различными устройствами, которые могут находиться на одном или другом компьютере. Он создает или удаляет коммуникационное соединение, а также отправляет или получает сообщения, используя их тип системных вызовов. например, отправка сообщения на экран процесса , )

Асинхронная блокировка без блокировки по сравнению с синхронной блокировкой

Предположим, мы хотим создать однопоточный веб-сервер на основе HTTP. когда клиент хочет сделать запрос к нашему серверу по протоколу HTTP, сервер должен установить TCP-соединение, и когда это произойдет, сервер выделяет некоторый объем памяти для этого TCP-соединения, которое называется TCP-сокетом. в это время сервер может принять запрос от этого конкретного клиента. предположим, что клиенты создали GET запрос на Index.html файл. Когда ЦП хочет запустить этот код, он отправляет запрос на контроллер диска и говорит: «Эй, пожалуйста, прочитайте этот файл для меня», а затем ему нужно дождаться результата. на самом деле поток сейчас заблокирован, и следующие строки в инструкции потока не могут выполняться, и им приходится ждать. поэтому мы заблокированы здесь. если в это время другой клиент хочет сделать запрос к веб-серверу, поскольку поток заблокирован, он не может обслужить этот запрос. Так что есть много стратегий, которые могут решить эту проблему. например, apache создает новый поток для каждого запроса (конечно, он не может создавать бесконечное количество потоков и каким-то образом с этим справляется), но недостатком является то, что им нужно решать проблемы многопоточности, такие как гонки потоков друг с другом. для доступа к памяти процесса или переключения контекста или…

Теперь давайте поговорим о другой возможности:

LibUV

Я думаю, что большая часть красоты Node.js исходит от этой замечательной библиотеки. Он написан на языке C, и мы также можем использовать его автономно, как v8.

Эта библиотека дает нам возможность писать синхронный код на javascript, которым очень легко управлять и который по-прежнему реагирует на то, что происходит асинхронно, как и на любую операцию ввода-вывода. Так что нам как разработчику больше не нужно иметь дело с многопоточностью; однако, если мы этого не понимаем, мы можем злоупотреблять Node.js. Теперь давайте поговорим о том, как работает эта библиотека.

давайте предположим, что мы хотим запустить эту программу node.js

console.log('Hi from the main');
const callback = () => {
  console.log('Hi From call back');
}
setTimeout(callback, 1000)
console.log('Hi Log after the setTimeout');

Когда мы запускаем программу node.js, ОС создает процесс с одним потоком. эта программа переходит на v8, а затем коды доступа v8 в LIBUV. LibUV проверяет первую строку кода, которая является console.log(‘Hi from the main’);
LibUV знает, что console.log является синхронной командой, поэтому она передает эту строку в v8 для запуска.

Следующая строка просто присваивает переменную. давайте перейдем к следующей строке, которая является командой setTimeout. Когда libUV видит эту команду, он знает, что не должен сейчас передавать функцию callback в V8, потому что ему нужно дождаться 1000ms, поэтому LibUV помещает callback в очередь, а затем переходит к проверке следующей строки, которая console.log(‘Hi Log after the setTimeout’); Поскольку эта строка также является еще одной синхронной командой, LibUV передает ее V8 для выполнения. Эта строка была последней строкой, теперь libUV знает, что в очереди есть элемент, и он должен ждать.

Еще раз LibUV проверяет, передается ли 1000ms или нет. если нет, он приостанавливается на некоторое время, а затем снова проверяет. Если время уже истекло, libUV передает обратный вызов v8 для выполнения.

Эта постоянная проверка называется циклом обработки событий и управляется основным потоком. Как я могу это доказать? давайте поместим длинный цикл for в конец нашей программы node.js:

console.log('Hi from the main');
const callback = () => {
  console.log('Hi From call back');
}
setTimeout(callback, 1000)
console.log('Hi Log after the setTimeout');
for (var i = 0; i<= 99999999 ;i++) {} // this line

Когда LibUv хочет запустить этот цикл, давайте предположим, что ЦП требуется 2000ms для его выполнения. Итак, в это время основной поток занят этим циклом. Дело в том, что у нас была команда setTimeout, которая должна была запускать 1000ms. но, к сожалению, поскольку наше приложение является однопоточным, основной поток блокируется этой командой и не может выполнить обратный вызов setTimeout. Таким образом, в этом сценарии обратный вызов будет выполняться после 2000ms вместо 1000ms. как видите, у нас есть задержка, которая называется Задержка цикла событий (это один из наиболее важных показателей производительности). Поэтому, как разработчик, мы должны обратить внимание на то, чтобы не писать коды, которые блокируют основную рекламу!

Но что, если я хочу запустить ресурсоемкую задачу, например создание хэша? Теперь LibUV сияет. возьмем пример:

В Node.js есть встроенный метод, который может создать для нас хэш.

const crypto = require('crypto');
  
const now = new Date()

const makeHash = (index) => {
   const callback = (err, derivedKey) => {
    console.log(`${index} Hash is ready after ${new Date() - now} ms`);
   }
   crypto.pbkdf2('secret', 'salt', 100000, 64, 'sha512', callback);
}

makeHash(1);
makeHash(2);
makeHash(3);
makeHash(4);
makeHash(5);

В приведенном выше примере я импортировал модуль crypto из node.js, а затем получил текущее время. Затем я создал функцию, которая будет делать хэш, и назначил ей обратный вызов. Поэтому, когда он вычисляет хэш, он вызывает обратный вызов, и в обратном вызове я помещаю простой журнал, который показывает, сколько времени занимает этот процесс хеширования, и в последней строке я запускаю функцию makeHash пять раз. Если вы запустите этот код в зависимости от скорости вашего компьютера, вы должны увидеть что-то вроде этого:

В этом журнале у нас есть три интересных данных. Давайте проанализируем это:

Как видите, первый лог исходит от makeHash(2).

2 call of Hash function get back the result ready after 54 ms

Но исходя из того, что я звонил, я предположил, что должен увидеть результат в порядке того, что я звонил. как будто я должен увидеть результат makeHash(1)до makeHash(2)

Еще одна интересная информация: кажется, что makeHash(1) makeHash(2) makeHash(3) makeHash(4) вернули ответ за то же время около 56ms .

Но как это возможно? Если мы предположим, что вычисление хеш-функции берет 56ms от ЦП, когда мы вызываем makeHash(1), основной поток должен быть занят для 56ms, а когда makeHash(1) выполняется, тогда LibUV должен перейти к makeHash(2), поэтому эта функция должна возвращать данные после 2 * 56ms и makeHash(3) должен вернуть данные после 3* 56ms

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

Это правда, ребята. Node.js — это не один поток :)

LibUV имеет пул потоков, внутри которого четыре потока, которые называются рабочими потоками. (мы можем изменить количество потоков в пуле потоков).

Когда циклы событий хотят запустить дорогостоящие задачи (dns.lookup(), все API файловой системы, кроме fs.FSWatcher(). crypto, zlip), она назначает эту задачу рабочему потоку. Например, когда мы вызвали crypto , LibUV знает, что функция pbkdf2 является трудоемкой задачей, поэтому вместо запуска этой функции в основном потоке она назначает рабочий поток из пула потоков для обработки этой функции, а затем цикл обработки событий продолжает работать. Таким образом, основной поток не блокируется. Как только рабочий поток завершается, он помещает результат в очередь в четном цикле. . теперь цикл событий передает эту функцию для выполнения V8, который выполняется в основном потоке.

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

В приведенном выше примере я создал большой файл с именем bigFile.txt и хочу прочитать этот файл. опять ставлю лог для измерения времени, которое уходит на получение результата

const fs = require('fs')

var now = new Date()

fs.readFile('./bigFile.txt', (err, data) => {
    console.log(`Reading the file get back the result ready after ${new Date() - now} ms`);
})

Когда мы запускаем этот код, libUV видит строку fs.readFile, она знает, что это задача ввода-вывода, и ей нужно назначить рабочий поток. По сути, рабочий поток просыпается и просто создает системный вызов для открытия и чтения файла, а затем рабочий поток возвращается в пул потоков. Таким образом, рабочий поток в этом случае просто делегирует задачу ОС, вызывая системный вызов. Как только ОС выполняет эту задачу, она уведомляет об этом цикл обработки событий. Теперь цикл событий может передать соответствующий обратный вызов в v8 для выполнения.

В приведенном выше примере чтение данных первого фрагмента этого файла на моем компьютере заняло около 20ms . Но что, если мы заблокируем пул потоков? давай сделаем это:

const fs = require('fs')
const crypto = require('crypto')
var now = new Date()

const makeHash = (index) => {
   const callback = (err, derivedKey) => {
    console.log(`${index} call of Hash function get back the result ready after ${new Date() - now} ms`);
   }
   crypto.pbkdf2('secret', 'salt', 100000, 64, 'sha512', callback);
}

makeHash(1);
makeHash(2);
makeHash(3);
makeHash(4);

fs.readFile('./bigFile.txt', (err, data) => {
    console.log(`Reading the file get back the result ready after ${new Date() - now} ms`);
})

В этом примере сначала я запустил четыре хеш-функции, которые загрузили все рабочие потоки для чего-то вроде 54ms

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

Как вы видите, чтение файла выполняется примерно через 20 мс после освобождения одного потока из пула потоков.

Есть еще несколько тем, которые остались здесь, и я скоро расскажу о них, например, о различных фазах цикла событий, IOCP или о том, что мы можем сделать, чтобы использовать всю мощь ЦП в приложениях node.js или…

Вот и все, ребята, надеюсь, вам понравилась эта тема. Если вам понравилось, пожалуйста, сделайте мне одолжение и поставьте лайк или оставьте комментарий, чтобы другие могли видеть мои сообщения. Для меня это большая мотивация продолжать.

Так что следите за обновлениями. Люблю тебя❤️