Наглядно о потоке выполнения в Node.js

    В комментариях к моему предыдущему топику об асинхронном программировании, коллбеках и использовании process.NextTick() в Node.js было задано немало вопросов о том, за счёт чего получается или может быть получена большая производительность при использовании неблокирующего кода. Постараюсь это наглядно показать :) Статья призвана в основном прояснить некоторые моменты работы Node.js (и libeio в его составе), которые на словах бывает трудно описать.

    Пример обработки запросов сервером с блокирующим чтением:


    В первую очередь прокомментирую полезность использования неблокирующего ввода/вывода. Как правило, использовать блокирующие операции в Node.js стоит лишь на этапе инициализации приложения, и то не всегда. Правильная обработка ошибок в любом случае потребует использования try/catch, так что код при использовании неблокирующих операций не будет сложнее, чем при использовании блокирующих операций.
    Нужно лишь помнить, что случае, когда запросов неблокирующих операций может оказаться больше, чем потоков libeio. В этом случае новые запросы будут становиться в очередь и блокировать выполнение, однако для программиста это будет происходить прозрачно.

    Пример обработки запросов сервером с неблокирующим чтением:


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

    Пример уменьшения времени обработки при почти одновременном приходе двух запросов:


    И тут мы подходим к самому нелогичному приёму, который используется программистами Node.js и может вызвать недоумения у большинства разработчиков. В случае, если ввод/вывод занимает большую часть времени обработки запроса, то остальной код оптимизировать не стоит. Однако, время получения данных из memcached может быть соизмеримо с временем выполнения бизнес-логики приложения и шаблонизации. А если использовать кеширование или базу данных в памяти процесса Node.js (Dirty или Alfred), то время работы с базой данных может быть и меньше, чем время работы остальных частей приложения. Поэтому, для разбиения кода на отдельные части и вызова коллбеков используют process.nextTick():

    // blocking callbacks
    function func1_cb(str, cb) {
      var res = func1(str);
      
      cb(res);
    }
    
    function func2_cb(str, cb) {
      var res = func2(str);
      
      cb(res);
    }
    
    // non-blocking callbacks
    function func1_cb(str, cb) {
      var res = func1(str);
      
      process.nextTick(function () {
        cb(res);
      });
    }
    
    function func2_cb(str, cb) {
      var res = func2(str);
      
      process.nextTick(function () {
        cb(res);
      });
    }
    
    // usage example
    func1_cb(content, function (str) {
      func2_cb(str, function (result) {
        // work with result
      });
    });

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

    Пример «вреда» от process.nextTick() при почти одновременном приходе двух запросов:


    Однако это худший случай с точки зрения применимости process.nextTick(). В случае, если запросы приходят редко, как в первом рассмотренном примере, вреда от process.nextTick() не будет совсем. В случае, если запросы приходят со «средней» частотой, применение process.nextTick() ускорит обработку запросов за счёт того, что в момент прерывания потока выполнения может вклиниться первичная обработка нового запроса и начало неблокирующего чтения. При этом уменьшается как суммарное время обработки, так и среднее время обработки одного запроса.

    Пример «пользы» от process.nextTick():


    Подведём небольшой итог топика. Во-первых, при использовании Node.js стоит использовать неблокирующий ввод/вывод. Желательно даже в тех случаях, когда используется не стандартное количество потоков libeio, а меньшее, либо при большом количество поступающих запросов. возникающие проблемы можно снять с помощью кеширования и in-process DB, а производительно не будет сильно отличаться от использования других технологий распараллеливания. Во-вторых, от использования process.nextTick() «в среднем» можно улучшить производительность сервера, и в целом от него больше пользы, чем вреда.

    UPD (02.02): Незначительно улучшил схемы. Исходники доступны по ссылке: github.com/Sannis/papers_and_talks/tree/master/2011_node_article_async_process_nexttick.
    Share post

    Comments 22

      0
      Лично мое мнение — нужно обождать с этими process.nextTick до появления воркеров. Особой выгоды пока не наблюдается.
        +2
        Воркеры уже давно есть, node-webworker + пример :) В ядре их не предвидится.
          +1
          Бэ… а я все еще на главной читаю «скоро будут вебворкеры и тогда...» =))))
            0
            Ух блин, надо отправить патч.
              0
              Патч к ядру?
              Неужели?
                +1
                Нет, к сайту xD
                Уже.
                  +2
                  node.js программисты настолько суровы, что используют патчи для правки контента на сайтах.
                    +2
                    Также, как и для правки документации.
        +3
        1:0 в пользу nextTick() =)
        По поводу вреда и частых запросов очень хочется заметить.
        В случае, если запросы приходят редко, как в первом рассмотренном примере, вреда от process.nextTick() не будет совсем.

        Так не бывает, node.js ставят в такие узкие места, где php уже не выдерживает, когда тысяча клиентов каждую секунду долбятся на сервер в поисках обновлений.
        В таких случаях частые запросы — явление обязательное.
        В том случае, когда запросы действительно редкие, мы можем хоть брейнфак с параметрами в процессе запускать для вычислений — это будет уже некритичным, теряется профит node.js.
          +1
          Прошу поправить меня, если я гдето чтото не так или не полностью понял.
            +1
            Всё правильно поняли, кмк.
            +1
            На счёт профита я бы не сказал. Есть же ещё code reuse и, для кого-то, привычность самого языка. Пожалуй единственное, почему на Node.js пока не написали CMS или что-то подобное — лень и большое количество уже написанных движков необходимость в нормальном хостинге.
              0
              Я над этим работаю: github.com/olegp/mcms
                0
                Как-то слишком просто :) Я замахивался на более крупное, но тут уже стольок других желающих набежанло (Nodeca, Calipso).
            +1
            Ваши схемы смутно напомнили мне курс по микроконтроллерам в университете, а в частности схемы сигнализации и стробирования. Мне кажется, что всей этой вознёй с Node.js программисты загоняют себя в рамки, так же как и в случае программирования под микроконтроллеры.

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

            Ну или спасёт, если переписать всё на нём, но это уже другая история :)
              0
              Node.js спасает в одном случае – много долгоиграющих соединений. Он для такого варианта использования очень хорош и удобен. Ну а для других вариантов – это больше just for fun, на яваскрипте покодить и все такое.
              +2
              У вас на первых картинках есть read(1) и read(2)… Я так понял это для случая когда для calc(1,2) нужны 2 файла? Просто на следующих картинках уже остается только один единственный read()

              Что такое libeio Thread? Разве файлы читаются в потоке ОС? Вроде для файлов есть уже асинхронное АПИ на уровне ОС… Хотя это не сильно на суть влияет.

              Было бы совсем круто если на каждом графике была бы подпись типа «readAsync()+nextTick()», «readAsync()», «readSync()» а то сейчас там где 2 графика склеено особенно долго вдумываться приходится.

              Ну а по сути, в любом случае, вся производительность зависит от длины calc() отрезка, соответственно самый лучший способ ускорить NodeJS — сокращать этот отрезок как таковой для каждого запроса. Разбивать его на более мелкие — это работает до некоторого предела, пока у вас весь уровень EventLoop v8 не забьется calc() отрезками. А тогда уже нужно будет запускать воркеров и привет старый добрый апач…
                0
                У вас на первых картинках есть read(1) и read(2)… Я так понял это для случая когда для calc(1,2) нужны 2 файла? Просто на следующих картинках уже остается только один единственный read()
                Да, всё верно. Имхо, это не суть важно, если суть понятна. В первом примере две операции чтения, необходимые для «расчётов», а далее изображена только одна. Я думал думал нарисовать пример с чтением двух файлов, независимо обработкой и последующей синхронизацией, но по остановился на приведённых примерах.

                Что такое libeio Thread? Разве файлы читаются в потоке ОС? Вроде для файлов есть уже асинхронное АПИ на уровне ОС… Хотя это не сильно на суть влияет.
                Да, в потоке OC. Во-первых, для переносимости. Во-вторых, судя по обсуждению в рассылке nodejs, в ядре 2.6 (или в glibc такого-же возраста) асинхронные операции также поддерживаются этим способом. Так что в libeio асинхронность реализована своими силами.

                Было бы совсем круто если на каждом графике была бы подпись типа «readAsync()+nextTick()», «readAsync()», «readSync()» а то сейчас там где 2 графика склеено особенно долго вдумываться приходится.
                Спасибо, стараюсь сделать хорошие иллюстрации, так что добавлю заголовки на них.

                Ну а по сути, в любом случае, вся производительность зависит от длины calc() отрезка, соответственно самый лучший способ ускорить NodeJS — сокращать этот отрезок как таковой для каждого запроса. Разбивать его на более мелкие — это работает до некоторого предела, пока у вас весь уровень EventLoop v8 не забьется calc() отрезками. А тогда уже нужно будет запускать воркеров и привет старый добрый апач…
                От этого никуда не удастся уйти. Никакой язык не позволит сделать то, что может лишний сервер :) Надеюсь это все понимают.
                  0
                  Так что в libeio асинхронность реализована своими силами.

                  Асинхронность файлового чтения или (OMG) сетевого сокета тоже? Надеюсь только первое…

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

                  Прозрачного распределения по процессорам/ядрам у NodeJS, насколько я знаю, нет — просто запускают несколько «воркеров» и балансировщик нагрузки перед ними… Но тогда схема с NextTick еще менее ясной становится — если приходит новый клиент когда процесс Node занят calc-ом, то обработка будет поручена соседнему воркеру и необходимость в усложнении кода разбиением на nextTick отпадает.

                  Как бы это так высказать… Вот ось на графике «EventLoop v8» — это фактически «пропускная способность ядра процессора». Чтобы обрабатывать на сервере с NodeJS как можно больше клиентов, нужно максимально плотно расположить по этой линии все calc()-и (если узкое место — процессор, а не IO). И наиболее простой способ это сделать — запустить более чем одного воркера NodeJS на ядро процессора. В таком случае код усложнять не придется — приостанавливать выполнение calc() будет сама ОС прозрачно для программиста. Планировщик процессорного времени ОС (scheduler) так устроен, что он даст поработать одному процессу, притормозит его, даст поработать другому… Главный плюс — код программы не нужно усложнять без особой на то необходимости.
                    0
                    Асинхронность файлового чтения или (OMG) сетевого сокета тоже? Надеюсь только первое…
                    Только первое, вторым заведует libev со стандартным набором select/poll/epoll/kqueue на выбор внутри.

                    Прозрачного распределения по процессорам/ядрам у NodeJS, насколько я знаю, нет — просто запускают несколько «воркеров» и балансировщик нагрузки перед ними… Но тогда схема с NextTick еще менее ясной становится — если приходит новый клиент когда процесс Node занят calc-ом, то обработка будет поручена соседнему воркеру и необходимость в усложнении кода разбиением на nextTick отпадает.
                    Для этого как раз и сделан последний пример, из которого видно что польза от nextTick может быть, как раз таки за счёт плотного расположения. Ибо вне зависимости от количества воркеров это позволяет немного ускорить каждый из них в определённых ситуациях.
                  +1
                  Надеюсь так стало лучше :)
                    +1
                    Ага, спасибо!

                Only users with full accounts can post comments. Log in, please.