21. Событийный цикл, библиотека libUV

Если вы привыкли глубоко вникать в происходящее, то эта глава для вас. В ней мы разберем те вопросы, которые рано или поздно обязательно возникнут при разработке и ответы на которые требуют глубокого понимания, как именно работает Node.JS. Например,

здесь для чтения файла использован асинхронный вызов fs.readFile(), но любые ли операции можно сделать асинхронными? На сколько действительно опасны синхронные вызовы, и что делать если какая то синхронная операция есть и избежать ее никак нельзя, как снизить ее вредный эффект? Что происходит с теми запросами которые приходят пока интерпретатор занят. Например если здесь

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

Для того чтоб глубже понимать происходящее, познакомимся с библиотекой LibUV. Вот ссылочка на доку — http://docs.libuv.org/en/v1.x/

screenshot_21_01

К этой библиотеки не надо обращаться каким то явным образом, она написана на языке «С» и встроена в сервер Node.JS. Библиотека LibUV отвечает за две принципиально важные вещи. Первое это кроссплатформенные операции ввода-вывода — работа с файлами, работа с сетью. Мы через JavaScript даем команду Node.JS — просчитай такой то файл или отправь такие данные по сети, а Node.JS, чтобы это сделать, внутри себя использует библиотеку LibUV. Таким образом LibUV отвечает за кроссплатформенную реализацию этих функций. Именно она уже знает как работать с Windows, работать с Linux и так далее.
Вторая область ответственности LibUV это поддержка основного событийного цикла Node.JS. Оказывается, когда мы запускаем какой то скрипт. то он запускается в режиме цикла. Этот цикл чередует выполнение JavaScript, который обеспечивается виртуальной машиной V8, с ожиданием различных событий ввода-вывода, срабатывания таймеров, за которые так же отвечает библиотека LibUV. И этот цикл будет продолжаться до тех пор, пока возможно появление каких то новых событий, ввода-вывода или таймеров которые нужно будет обработать.

Для примера разберем, что происходит при запуске вот такого сервера

Вначале срабатывает JsvsScript. Он подключает модули ‘http’, ‘fs’, создает объект ‘server’, вешает обработчик — «server.on()», то что внутри обработчика пока не важно, он еще не сработал,  и наконец последняя строчка, это вызов команды «listen()». Команда «listen()» это уже команда с сетевыми соединениями. JavaScript команда server.listen(….) попадая в Node.JS проходит через его С++ код, превращается в вызов внутреннего метода TCPWrap::Listen(….), этот внутренний метод уже вызывает библиотеку LibUV, а именно ее метод uv_listen(….), который как раз осуществляет всю работу, то есть он, в зависимости от операционной системы, вешает обработчик соединений на данный порт. Для MacOS, для Unix систем используется системный вызов listen(….).

screenshot_21_02

Таким образом, LibUV назначило обработчик на соединение на этот порт. Этот обработчик, или в терминах LibUV он называется watcher, является внутренним, то есть мы к нему доступа не имеем, это именно LibUV его поставила и когда что то произойдет, например когда придет новое соединение, то watcher сработает, он вызовет соответствующие методы LibUV, Node.JS и в конечном счете даст нам какое то событие, например событие connection. Но это все будет потом, а пока что listen(….) просто повесила обработчик-watcher, результат этого действия подымается по цепочке, если все хорошо, то это приводит к событию listening в JavaScript, если обработчик повесить не удалось, то error.

На этой радостной ноте выполнение JavaScript завершается и LibUV проверяет, есть ли какие то watcher которые могут сработать, есть ли какие то внутренние обработчики, если их нет, то завершается весь процесс Node.JS,  завершается весь событийный цикл. Но в нашем случае, один такой watcher, а именно обработчик на порту 3000, был поставлен, именно по этому процесс Node.JS не завершится, а временно заснет и будет спать до какой то причины ему проснуться, например до появления новых событий ввода вывода. Рано или поздно такое событие скорее всего произойдет.

screenshot_21_03

Появится сигнал из операционной системы, что кто то присоединился к порту 3000, внутренний watcher LibUV вызовет соответствующий callback, этот callback передаст сигнал библиотеке LibUV, потом он перейдет в Node.JS, обертка Node.JS тут же сгенерирует событие connection и примется разбирать то, что нам присылают. Далее, если в процессе анализа данных установлено, что это http запрос, то будет сгенерировано событие request и наконец то сработает обработчик server.on(….).

Если получилось так, что url вот такой —  if(req.url == ‘/’), то опять же при помощи LibUV инициируется считывание вот этого файла

Мы отправляем команду в LibUV и JavaScript, на текущий момент закончил работу, все ‘request’ события обработано. Так как JavaScript закончил выполнение и есть внутренние обработчики LibUV, то процесс не прерывается, а снова переходит в состояние спячки. Из этой спячки его могут вывести события, какие? Первое это новый запрос, а второе это завершено чтение файла или какая то ошибка возникла, это нам не так важно сейчас. Когда что то из этого произойдет, то будет вызван соответствующий callback. Получается, что наш код на языке JavaScript, выступает в роли этакого рулевого, он говорит LibUV, инициируй какой то процесс, например чтение файла или получай соединение на таком то порту, LibUV умеет это правильно передать операционной системе, и дальше уже операционная система занимается всякими такими делами, а LibUV ждет пока та ответит, LibUV может делать много таких операций одновременно, уведомляет о результатах JS. Когда операционная система ответит, то LibUV опять же вызывает наш JavaScript код который разруливает ситуацию, возможно инициирует какие то новые процессы ввода-вывода и и дальше процесс опять переходит в состояние спячки и так по циклу.

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

screenshot_21_04

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

screenshot_21_05

И будут обработаны последовательно по мере освобождения JavaScript. При этом не смотря на то что очередь событий одна, путаницы никогда не возникнет, потому что когда запускается соответствующий callback — » function(err, info){ …. });» то информацию о том, что это за запрос мы берем из замыкания джаваскриптового. То есть если запустился callback для Паши то тут — «server.on(‘request’, function(req, res){» будет req для Паши. Для Маши callback запустился, это уже другая функция, другое замыкание, соответственно будет продолжаться обработка и будет ответ прислан Маше.

С другой стороны получается так, что для того чтоб работа сервера была наиболее эффективной, JavaScript должен выполняться очень быстро, то есть чтобы никакие новые события не накапливались не ждали. Ну а что будет если JavaScript почему то затормозил. например есть какая то тяжелая вычислительная задача и Node.JS занят, события накапливаются? Такая ситуация называется «Event loop starvation» или по русски «Голодание событийного цикла». При этом обработка всех клиентов которые от них зависят притормозится, что конечно же не очень то хорошо. Для того чтобы обойти эту проблему, тяжелые вычисления обычно выделяют, или в отдельный процесс, или в отдельный поток, либо запускают сам сервер Node.JS в режиме множества процессов, например это можно сделать используя встроенный модуль «cluster», но не только. Еще один вариант, это разбить тяжелую вычислительную задачу на части, то есть например часть ответа можно сгенерировать вот в функции server.on(…), потом через setTimeout(10), отложить генерацию следующей части ответа, и так далее, и так далее. Соответственно при этом получится, что работа в сумме выполняется такая же, но вот выполнение JavaScript разрывается и в промежутке между этими вычислениями, сервер может делать что то еще, например обрабатывать других клиентов. Так или иначе, все эти решения добавляют сложности по этому Node.JS используется в первую очередь там, где тяжелых вычислений не нужно, а где требуется в первую очередь обмен данными. Практика показывает, что это большая часть задач связанных с веб разработкой.

Итак подведем итоги

screenshot_21_06

Сердцем Node.JS является библиотека LibUV. В этом и сила и слабость Node.JS. С одной стороны LibUV позволяет делать много операций ввода-вывода одновременно, то есть наш JavaScript код, может инициировать операцию и дальше заниматься другими делами. Таким образом множество операций ввода-вывода могут обрабатываться одновременно операционной системой, а JavaScript будет пересылать данные от одного клиента к другому, от базы данных клиенту и так далее, просто, эффективно. С другой стороны, это все требует асинхронной разработки, то есть не «readFileSync()», а «readFile()» например. Принятая в Node.JS система колбеков, с одной стороны достаточно проста, с другой стороны она все равно сложнее, чем просто последовательные команды синхронной разработки.  Кроме того, так как JS процесс должен обрабатывать кучу событий, то желательно,чтоб он не ждал, чтоб он все делал быстро, быстро, быстро. Чтоб очередь событий не накапливалась. Эту особенность работы Node.JS стоит иметь ввиду с самого начала разработки веб приложения, потому что казалось бы, ну какие у нас сложные вычислительные операции, что у нас  там заблокирует JavaScript? А например. Парсинг большого JSON или например подсчет MD5 суммы большого файла закаченного. Еще раз обращаю ваше внимание, эти задачи влияют на производительность сервера еще до того как JavaScript, который их выполняет, съест 100% процессора. То есть Node.JS может кушать 20%, но работать не достаточно эффективно просто из за того, что пока JavaScript занят другие задачи, даже те кому нужны другие ресурсы, скажем база данных, не могут продолжить выполнение. Для того чтобы как то защититься от этого, обычно запускают Node.JS приложение в режиме множества процессов. Как это сделать? Мы разберем в одной из следующих глав.

One thought on “21. Событийный цикл, библиотека libUV”

  1. Все очень ясно и подробно описано! Огромное спасибо) Мне, как новичку стало намного понятнее как работает Node.JS под капотом.

Обсуждение закрыто.