Говоря о том, как работает узел, люди говорят об однопоточном, неблокирующем, асинхронном цикле событий. Но каждая программа должна общаться с операционной системой через системные вызовы. Серверы можно описать как абстракции над механизмами операционной системы, потому что именно операционная система сообщает оборудованию, что делать. Прочитав это, вы сможете пройти собеседование по Javascript с кучей новых модных словечек, которые заставят менеджеров сходить с ума. Модные слова, такие как epoll, поток событий и мультиплексор. Помните, что важнее иметь энергию большой деревянной платформы, чем настоящую большую деревянную платформу. Даже если вы не совсем понимаете, менеджер может подумать, что вы действительно читали исходный код, который он не может протестировать, потому что он, черт возьми, этого не делал. Итак, давайте добавим несколько дюймов энергии вашей деревянной платформы, объяснив, как узел общается с операционной системой Linux.

Давайте объяснять вещи по одному модному слову за раз.

Демультиплексор

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

(req, res, next) => {
    if(isValid(req.body.password, req.body.username)){
        return res.json({authenticated: true})
    }
    return res.json({authenticated: false)}
}

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

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

Неблокирующий

Часто упоминается блокировка, а задачи называются блокирующими или неблокирующими. Чтобы объяснить блокировку, представьте, что вам нужно сделать три вещи. Ты будешь готовить, потом стирать, потом убираться. Когда ты собираешься их делать? Когда вы закончите с одним, вы начнете другой. Другими словами, приготовление пищи блокирует стирку до тех пор, пока вы не закончите готовить, затем уборка блокируется, пока вы не закончите стирку. Это довольно просто, потому что вам не нужно было беспокоиться о планировании задач, время зависело от выполнения рабочих нагрузок. Однако что, если вы жарите курицу? Вы имеете в виду, что я должен ждать полтора часа?

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

Стирка во время жарки цыпленка — это пример параллелизма и параллелизма, но на самом деле параллелизм нам не нужен. Вот как одно ядро ​​процессора использует параллельные процессы, чтобы помочь нам не блокироваться.

Еще в 1980-х годах, когда люди впервые начали специально рвать джинсы, а апельсиновый сок был для богатых, все было немного проще. Если вы хотите писать в терминал и иметь возможность читать вывод, должны произойти две вещи. Вы должны были вызвать write(), а затем read() для файла. Почему файл? Потому что все в Unix является файлом. Интернет-соединение, фактический файл на диске или в данном случае стандартный вывод. Это означает, что read() и write() были тем, как вы отправляли и получали данные.

Когда вы записываете 10 КБ данных в файл, происходит то, что при записи будет отправлен 1 КБ данных, и прежде чем отправить остальные, он ожидает сигнала о том, что он был прочитан. При этом он блокируется. Итак, как же нам перейти к читаемой части? Чтение блокируется до тех пор, пока запись не получит сигнал о прочтении, но чтение заблокировано.

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

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

Первым нововведением в этой модели было создание неблокирующего ввода-вывода. Допустим, у нас есть 100-байтовый буфер записи и всего 200 байт для записи. Мы очищаем наш буфер, но не можем выполнить следующую запись, потому что первые 100 байтов не были прочитаны. С блокирующим вводом-выводом мы будем блокироваться на неопределенный срок. Но что, если мы знаем, что заблокируемся, поэтому вместо блокировки мы вернем ошибку, которая сообщает нам, что другая запись будет заблокирована? Назовем эту ошибку EWOULDBLOCK. Наш неблокирующий код будет выглядеть примерно так:

const response = write()
if(response === EWOULDBLOCK) {
    read()
}

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

Итак, вы думаете, мы вернулись к переключению контекста? Серверы должны выполнять тысячи операций ввода/вывода, неужели мы создаем столько процессов? Переключение контекста имеет свою цену. Каждый раз, когда вы переключаетесь на другой процесс, данные в текущем кэше ЦП заменяются новыми данными. Каждый процесс также занимает память, и все эти процессы занимают много оперативной памяти.

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

Когда мы вызываем read() или write(), мы вызываем их для файловых дескрипторов (fds), которые затем используют ссылку на файл, чтобы выяснить реальную реализацию этих функций — запись в потоковый сокет явно отличается от записи в терминал. , хотя Linux будет вызывать оба файла. Вы говорите, что fd — это просто целые числа, так как же наша операционная система узнает, что они представляют? Мы всегда работаем в одном и том же процессе, который имеет только одну таблицу описаний файлов, а fd, являющийся индексом, всегда отображается точно 1:1.

Итак, как нам оставаться в одном процессе и обрабатывать тысячи fd? С маленьким другом, тоже 1983 года рождения, select(). Давайте посмотрим, как системный вызов select() обрабатывает 50 прочитанных fd. Select() перебирает их и спрашивает, готов ли каждый из них к чтению. Наряду с тем, какую операцию (чтение) и какие fds мы хотим отслеживать, мы также передали выбор тайм-аута. Это количество времени, в течение которого select() будет блокироваться, пока не отправит нам готовые fds. Правильно, мы все еще блокируем, но блокируем меньше.

Таким образом, вместо того, чтобы ждать, пока отдельные дескрипторы файлов перестанут блокироваться, мы просим select() принять все эти входные данные и вывести поток данных, который представляет собой только те fd, которые готовы для нас. Мало того, все происходит в пользовательском пространстве, сохраняя память. О, мы сейчас занимаемся космосом.

Пространство пользователя

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

Файловая структура, представляющая сетевые сокеты, находится в пространстве ядра, select() и node работают в пользовательском пространстве. Как вы можете себе представить, существует задержка, связанная с переходом от ядра к пользовательской области, но использование пользовательского кода не даст вам доступа почти к такому количеству данных. Есть серверы, которые полностью остаются в пространстве ядра, называемые ускорителями, но они глупы.

Итак, у нас есть безопасный для памяти пользовательский наземный мультиплексор, который отправляет события на узел таким образом, чтобы не блокировать их целиком. Однако есть проблема. Поскольку функция select() должна перебирать все переданные ей fd, она выполняется за время O(n), или, другими словами, мы можем масштабироваться до пары сотен подключений за раз, но не до десяти тысяч. Нам нужен лучший мультиплексор.

Электронный опрос

Вернемся к примеру с готовкой, стиркой, уборкой. Допустим, нам нужно определенное химическое вещество для ускорения уборки, и мы отправили вашего младшего брата купить его. Откуда мы знаем, когда он у него есть? Спрашивайте его каждые 10 секунд, и он сообщит нам, когда. Это довольно раздражает, и это то, что делает select(). Вместо этого мы можем расслабиться и заняться своими делами, а он хлопает нас по плечу, когда у него это получается. Это то, что делает epoll.

Epoll, сокращение от опрос событий, представляет собой структуру ядра, которая отслеживает готовые файловые дескрипторы. Мы общаемся с ним с помощью трех системных вызовов: epoll_create(), который инициализирует структуру event_poll и возвращает для нее fd; epoll_ctl(), которая добавляет fds в список интересов; и epoll_wait(), который блокируется до тех пор, пока fds не станет доступным. Обратите внимание, что мы возвращаем fd из epoll_create() — правильно, мы можем вызвать select() для epoll, потому что epoll можно рассматривать как файл.

Epoll работает одним из двух способов: по уровню и по фронту. Запущенный уровень похож на select(), где мы даем epoll_wait тайм-аут, и он выдает нам готовые fds по истечении срока действия и возвращается к блокировке. Запуск по краю отправляет нам готовые fds каждый раз, когда происходит изменение любого из fds, которые отслеживает epoll.

Важно помнить, что epoll не спрашивает fd, готов ли он. Вместо этого события ввода-вывода, вносящие изменения в файлы, связанные с fds в списке интересов epoll, динамически заполняют готовый список. Когда данные от соединения с каким-либо удаленным компьютером поступают по трубе в вашу сетевую карту, сетевой модуль уведомляет ваш ЦП с помощью прерывания сигнала. Контекст прерывания находится вне контекста процесса и может послать прерывание любому процессу, не находящемуся в состоянии бесперебойности, которое очень быстро используется в некоторых системных вызовах, таких как mkdir. Возможность прерывать процессы для отправки данных, очевидно, очень полезна и реализована на уровне архитектуры ЦП. Как ваш процессор реализует это?

Никогда больше не спрашивай меня об этом.

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

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

Реактор

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

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

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

Еще статьи.