Pull to refresh

Comments 40

не такое уж и катастрофическое отставание у ноды. Добавить бы сюда питон и раби на рельсах и тест совершенно подругому будет смотреться.

Из скриптовых в таких сценариях нода всегда была топ 1 по скорости, гугл умеет делать быстрые движки.

Но рано или поздно всё придет к тому, что однопоточная нода упрется в логику в одном потоке, и придется придумывать костыли в виде воркер-тредов с изолированным контекстом, когда раст или го смогут использовать потоки на полную с общим контекстом без каких-либо узких шин обмена данными между потоками (по необходимости)

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

Возникает вопрос - а есть ли вообще смысл тогда сравнивать абстрактный пустой HTTP-сервер, если эти языки и платформы категорически по-разному выполняют свою работу?

С нодой это только кажется минусом. Nginx работает по той же схеме и это одна из причин успеха - вместо спавна потоков у нас асинхронный эвент-луп. Именно потому так быстро и так много он держит. В ноде это с рождения, потому что так работал тот самый первый JS из 90х и это оказалось отличным подходом. Но если уж очень надо - в ноде есть спавн и процессов и потоков, просто не все знают и не все используют - да и зачем, в основных кейсах всегда лучше заходит эвент-луп. А если совсем уж математика нужна - тогда можно и соспавнить, либо порезать на куски, либо архитектурно не иметь такой потребности и вынести в микросервис рядом. И для особых ценителей - можно подключить модуль на C и совместимых языках и вынести особо лютые рассчёты туда. Впрочем, при желании, можно и внутри ноды, не все знают, но там даже память можно выделять, в буферы так называемые класть, и работать с чисто бинарными последовательностями, напрямую или через всякие датавью с оффсетом в байтах.

Просто не все реально умеют в NodeJS, а у кого-то знание о функционале времён 2012 года.

Возможно, последнее предложение было резковатым, но остальное, вроде, по делу. Минусы не понятны :)

вместо спавна потоков у нас асинхронный эвент-луп

В этом event-loop и содержится проблема, так как он блокируется на время исполнения JS. А для исполнения JS требуется интерпретатор и вот это все. Хоть сокеты в тредпуле и продолжат считываться и записываться в это время, код на JS все равно остается узким местом в плане производительности и распределения нагрузки.

Касательно спавна потоков, смотри epoll для линукса, или другие реализации неблокирующего ввода-вывода для других систем. Это не изобретение node, или nginx, а то что они просто используют, как и все остальные представленные веб-серверы, на сколько я понимаю.

Блокируется, это верно. Потребляя под себя 1 ядро процессора на исполнение. Другие на другом будут, ввод-вывод и прочее. И, соответственно, если наша нагрузка выше того что может переварить одно ядро работая полностью без остановки - мы действительно получим проблему. Но такую же мы получим и в других языках/платформах. Там будут спавниться потоки, полноценные или зеленые, но тем не менее. В ноде будет медленнее в этом случае, это правда. Но эта правда только если полученный квант нагрузки требует обработки выше возможности одного ядра в рамках блокирующей интерпретации. Это значит что это очень тяжелый вычислительный процесс за 1 раз от 1 клиента, иначе это был бы не один квант. И для таких задач действительно имеет смысл взять то что сможет начать с этим работать на многих ядрах и весьма экономно. Однако, в мире веба это экзотический кейс и обычно это большой поток малых запросов, из тех что требуют немедленного исполнения. А это означает что преимущество заполнения всех ядер нагрузкой за раз оказывается уже не таким существенным и мы вполне получаем преимущество эвент-лупа, где по сути каждая таска внутри это такой микро-поток в частно случае, а при увеличении потока запросов мы можем масштабироваться количественным способом, в том числе на множестве машин, где возможность спавнить потоки уже полностью теряется. То есть это как с ламборгини в пробке - на прямой дороге без трафика мы всех обгоняем, но вот в пробке наши 12 цилиндров-потоков уже и не так едут. Всё дело в типичном кванте нагрузки. Ну и пусть горутина потребляет всего пару килобайт, на миллион запросов это будет чертовски много, а вот эвент-луп этого и не заметит.

Собственно, да. Это то, что я писал ниже в своём примере, но с нюансом.

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

Поэтому большие компании сейчас активно уходят в микросервисах от php, nodejs и python в пользу Go, если там реально средние или большие нагрузки. (Опять же в вакууме, потому что ещё существует джава или шарп, но я намеренно о них не говорю)

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

мы вполне получаем преимущество эвент-лупа, где по сути каждая таска внутри это такой микро-поток в частно случае

Ну и пусть горутина потребляет всего пару килобайт

Что-то я вижу в этом некоторое противоречие.

Если честно, практически незнаком с go, и как там считают потребление горутин, но в event-loop также есть свои накладные расходы на описание структуры задач, хранение их в очереди, на запуск и прочее. При этом libuv может обрабатывать задачи только в одном потоке, а Go этим не ограничен. Что эффективнее в плане накладных расходов: одна задача в очереди event-loop или одна горутина - для меня лично вопрос открытый.

Впрочем я вообще вел к тому, что асинхронный ввод-вывод, не является какой-то особенностью ноды или nginx. Нода является очень простым и удобным инструментом для создания эффективных веб-сервисов, которые не требуют особых вычислений, а например только гоняют данные из базы и обратно. Но нода это не про эффективное распределение нагрузки и параллельные вычисления, так как у нее практически нет для этого инструментов. Так что это просто очевидный ее минус, но опять же в контексте проектов, где это требуется. И тут я согласен с автором треда, что подобный бенчмарк практически бессмысленен.

Я честно признаюсь, но я не ставил минусов.

Тезисно:

  1. Я бы советовал прочитать про планировщик задач Go, чтобы понять, в чём разница между Go и NodeJS в плане работы.


    TL;DR
    : в Go есть горутины (легковесные потоки), которые (если убрать понимание "процессоров" в планировщике) привязаны к реальным потокам и имеют размер всего в пару килобайт. То есть, мы не рожаем там реальные потоки. Мы можем выделить потоков приложения ровно столько, сколько может быть реальных потоков у системы или меньше (но не больше). При этом горутин может быть несчетное количество, но они будут располагаться на обычных потоках (если упрощенно).
    При этом программа выполняется в одном общем контексте. Горутины могут так же обращаться к общим данным программы в куче. Плюс свои тонкости в виде потокобезопасности и т.д.

  2. Спавн процессов и обмен данными между ними. Просто советую посмотреть скорость обменами данными между двумя процессами на хабре или на личном примере.
    TL;DR: скорость обмена данными между двумя процессами из-за узкой шины в виде операционной системы в десятки, сотни раз меньше, чем работа с памятью напрямую в пределах одного процесса.
    Плюс - логично, что отдельный процесс с огромной вероятностью, как и отдельный сервис, понесет за собой издержки в виде отдельной кодовой базы.

  3. Деление на независимый код, прочие архитектурные моменты - всё это разбивается об базовый пример. Если попросить ноду одновременно сходить в 3 сервиса (пусть даже Promise.All с этим справится с асинхронщиной), но заставить её ещё одновременно посчитать 3 разных вещи в разных потоках - нода уже задохнется. Если спросить зачем, то очевидно - ради скорости обработки запросов.

    UPD: В данном случае, очевидно, что нода будет либо ждать, либо решать 3 синхронных задачи последовательно в одном основном потоке. Либо в воркер-треде. Либо в отдельном процессе. Издержки я уже назвал.

Зачем сюда записаны модули на С, буфферы - я не уловил, так как эти штуки уже для других задач. (имхо)

В дополнение:

  1. Самое забавное, что о пониманиях разницы между потоками, процессами, горутинами с полным понимание планировщика задач задают вопросы в Go даже для джунов.

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

Спасибо за пояснение.

Однако самым первым пунктом я указал тот факт что специально был выбран принцип когда вместо спавна потоков, пусть даже таких легковесных как горутины, был выбран подход эвент-лупа и он в живую обитает в том же nginx и всё хорошо. То есть создание потоков или процессов в NodeJS это шаг 2, в случае если почему-то не хватает асинхронности.

С решением параллельно трёх запросов и трех синхронных задач - всегда можно поставить на паузу через setImmediate, если мы хотим дать поработать остальным, проверить чего там пришло по сети и прочее. Дальше код продолжит с места остановки. Для тонких случаев можно отправить в конец очереди, но не в следующий цикл, до сборки мусора и опроса сети, тогда у нас есть process.nextTick. То есть мы всегда имеем возможность порезать любую синхронную задачу если она вдруг слишком велика и тормозит остальных. Вытесняем медленных или не приоритетных, прям как в многозадачных ОС.

Если же у нас какой-то особый бизнес-кейс, когда надо много считать, то обычно это всё же микросервисы с разным назначением, а также горизонтальное масштабирование через количество нод.

Нет, Вы не поняли возможно меня.

Представим, что Вам прилетело 30 запросов, которые требуют выполнения каких-то вычислений.

Представим, что каждый запрос - это 3 похода в сторонние сервиса и 3 независимых вычисления на процессоре (довольно тяжелых, скажем, по 30 мсек каждый).

Какое бы ни было удобное разделение на распределение задач в пределах одного потока - всё то же самое делает Go с планировщиком задач, НО он может вычислять это всё на разных потоках. В то время как мы на ноде всё равно будем ограничены единым потоком.

На примере реальной жизни:

  • У нас есть 1 конвейер, который мы можем настраивать как хотим

  • У нас есть 16 конвейеров, которые могут делать то же самое

Наверное, логично будет, что 16 конвейеров при любых условиях с этим справятся быстрее?

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

Пример с несколькими нодами так же некорректен. Если представить, что запросов не всего 30, а 30 на каждую ноду - мы столкнемся с всё тем же неэффективным использованием процессора, потому что вся логика будет выполняться в пределах одного потока (как в примере выше, конвейера, которым мы можем управлять как хотим)

И да, нода всё равно будет рожать свои потоки, которые ей нужны для управления той же асинхронностью. Но мы всё равно не сможем их использовать как хотим этого мы. Мы можем управлять только нашим потоком (в вакууме)

Чуть выше по ветке я ответил на вопрос чем же хорош эвент-луп и почему таки да - он имеет смысл, возможно это прояснит некоторые моменты.

Можно просто лоад балансером поднять больше нод.

Реальность такова, что нода 100% будет использоваться в кубе

Как Вам расширение нод поможет в одновременном решении физических задач в пределах одного запроса? (Вместе с асинхронных или без них)

Что такое не асинхронные физические задачи? CPU bound цепочки задач где результат каждого звена зависит от вычисления предыдущего? А чем в этом случае помогут треды/гринтреды - горутины? А если каждую цепочку задач вешать на отдельную горутину под каждое ядро то чем это лучше кластера нод(по количеству ядер) позади лоад-балансера или кучи воркер-тредов?

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

Edit:

Противопоставление 16 конвееров против 1 оно неверно. У вас и там и там по 16 конвееров, просто в го вам напрямую доступно примерно 14(один на планировщик и один на сборщик мусора) а в ноде вам напрямую доступен 1. Но напрямую недоступен != не используется. На IO задачах очень даже используется.

Более того в го точно такая же схема как и в ноде, в нем есть только один главный поток и это поток планировщика.

Я уже выше описал. Могу ещё раз:

Представим, что вам приходит запрос - сшить 30 кофточек.
В пределах этого запроса нужно сходить в 3 сервиса и помимо этого посчитать что-то на ядре компьютера (3 другие задачи).
Как бы Вы ни пытались сделать это на NodeJS (кроме воркер-тредов), у вас всё равно задача будет исполняться на одном потоке в одном контексте. В одном конвейере. Нода может прямо сейчас:

  • либо подшивать воротничок

  • либо делать пуговицы

  • либо делать рукава

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

В то время как на Go к Вам пришел запрос - сшить 30 кофточек:
Вы можете в пределах одного заказа одновременно использовать 3 или больше там станка, которые :

  • шить кофту

  • делать пуговицы

  • шить рукава

  • ожидать какие-то бумажки от бухгалтерии

В совокупности, работа будет выполнена одна и та же.

Но в случае NodeJS у вас 15 бегунов, которые бегают за бумагами, и 1 работник (если поток 16)

В случае Go у вас 16 фуллстеков, которые могут как подождать, так и сбегать, так и на конвейере поработать.

UPD: В случае первого работа будет выполнена за 1+1+1=3 единицы времени (где нужно сначала пошить кофту, потом пошить пуговицу, потом пошить рукава). Асинхронность просто позволит не соблюдать этот порядок. Переключаться между пошивом пуговиц, шитьем и ожиданием. Но всё равно чтобы сделать работу в полном объеме - нужно 3 единицы времени.

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

Это справедливо только в том случае если мы не можем задачу пошива 30 кофточек разбить на 30 подзадач по пошиву одной кофточки. Тогда я согласен с вами, мы выигрываем в latency.

Пошив одной кофточки, исходя из здравого смысла, не параллелится. Вы не можете одной кофточке одновременно пришивать пуговицы, делать воротничек и рукава и при этом делая все это на разных станках. Пошив кофточек как вы верно заметили делается не самими рабочими а станками(станки в нашем примере это vCPU). В случае го это один работник на 3 станка, в случае же который предлагал TalosDx это 3 работника на 3 станка через cluster пакет. В мире go/node сами рабочие бесплатные, денег стоят только сами станки.

Если же пошив параллелится то очевидно тоже будет выигрыш в латенси.

В throughput выигрыша не будет.

Опять же, опуская за скобки тот момент что golang сам по себе выполняется быстрее javascript.

Это тот случай, когда 1 работник может сделать работу за 3 часа, а 3 работника могут сделать эту же работу за 1 час.

И если для нас важна скорость выполнения этой работы, которую можно разделить и поручить разным ядрам - как раз и берется Go.

Ведь по сути вся асинхронность ноды это просто удобная обертка(ну или неудобная) поверх libuv который очень даже многопоточен.

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

Ну вот собственно и нет.

Я скорее это к тому что никакой асинхронщины без многопоточности(пускай и где-то под капотом системы) быть не может в принципе.

То есть, если Вы хотите одновременно считать 2 штуки в 2 реальных потоках - то это уже воркер-треды или отдельный процесс.

Ну да, как и везде в общем-то.

Ну да, как и везде в общем-то.

Что я пытаюсь донести - что не как везде.

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

В то же время у NodeJS worker-treads - это отдельный изолированный процесс, у которого изолирован контекст. У него своя куча, свой стек.

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

Собственно, об этой шине узкой я и говорил изначально.

Отдельный процесс несет за собой несколько издержек в виде отдельной кодовой базы и узкого представления шины.

Мы не можем из отдельного процесса обратиться к общей памяти

А SharedArrayBuffer это не общая память?

Это та самая узкая шина, о которой я ещё раз повторяю уже в какой раз.

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

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

То есть, получается, что при worker_threads мы ловим сразу очевидные минусы:

  • изолированный контекст, обмен данными через шину буфера

  • отдельную кодовую базу

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

Go:

  • Многопоточный код можно писать где угодно и как

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

NodeJS

  • Многопоточный код - это отдельный файл как минимум. Общение между каналами происходит пинг-понгом

  • Контекст у программ разный. Чтобы в соседнем потоке обратиться например к какому-то огромному дереву (я, например, работаю с гео-данными и у нас кэши лежат в сервисе), мне это дерево нужно будет поместить в SharedArrayBuffer, с другого потока потока это ещё и прочитать.


    В моменте - это может быть потребление памяти x3:

  • Дерево хранится в основном потоке

  • Оно лежит в буфере

  • Пока его полностью не прочитает второй поток, оно ещё будет наполняться у него

Вы ещё на секунду представьте, если это будет структура, которая в принципе будет меняться. Глобальная мапа или массив с какими-то данными о текущей сессии пользователя, например.

Вы её тоже в SharedArrayBuffer будете класть и гонять туда-сюда?

Вы её тоже в SharedArrayBuffer будете класть и гонять туда-сюда?

Поправьте если я не прав но SharedArrayBuffer это просто кусок памяти указатель на который + некая служебная метадата типа length шарится между worker_threads однократно через postMessage. После этого можете там как угодно читать или писать, при желании синхронизируясь через атомики.

Что и куда гоняется?

Да, действительно, Вы правы.

И честно скажу, это уже появилось после того как я ушел из работы с NodeJS. Я перепутал это с ArrayBuffer.

Тем не менее, я смотрю это на примерах, и всё равно вижу множество ограничений в виде заранее заготовленных структур данных.

И, как минимум, то, что если мы захотим, чтобы все данные программы были как-то условно доступны везде - нужно будет помещать туда всё.

В общем, честно скажу, для меня всё равно сомнительно. Скорость разработки от этого явно упадет, в то время как Go берется изначально для написания простого многопоточного кода в микросервисах без таких компромиссов в виде заранее заготовленных структур и отдельного многопоточного кода :)

В общем, честно скажу, для меня всё равно сомнительно.

Да node.js в целом сомнительный выбор если вам нужно что-то большее чем сходить по другим сервисам и выкатить json.

Касательно огромного дерева геоданных, в порядке наркомании есть идея сделать два массива для имплементации b дерева.

Hidden text

Делим каждый массив на 4 элементные чанки. Массив A используем исключительно для адресации. Первый инт в чанке указатель на родителя(указатель это просто адрес первого значения чанка в массиве), второй и третий инты указатель на детей, а четвертый либо пустой для выравнивания либо какие-либо данные умещающиеся в инт. Судя по геоданным у вас скорее всего два дабла, тогда заводим массив B и разбиваем на чанки по 4, индекс каждого элемента совпадаем с индексом листа дерева в массива A. Либо используем четвертый инт в массиве A для адреса данных в массиве B.

Из двух int через Floatbuffer магию можно получить дабл. Где-то на этом этапе теряем 90% производительности.

Для синхронизации используем два лока, один readLockSeamphore и один writeLockMutex. Если хочем читать проверяем что writeLockMutex не залочен и инкрементим readLockSeamphore, читаем, по окончанию декрементим. Если хотим писать то проверяем что writeLockMutex свободен, лочимся и ждем пока readLockSeamphore не станет равным 0 потом пишем и освобождаем writeLockMutex.

Если получаем распределение read/write 50 на 50 плачем и выкидываем все в корзину.

Интересно а как вообще реализована потокобезопасность дерева в вашем проекте? Сначала просчитывается какие ветки поменяются и лочим только их?

То есть, в Go мы не рожаем процессы.

Процесс 1. Потоков, условно 16.

Память у этих потоков - общая.

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

Кстати, ещё советую заглянуть в документацию NodeJS, как они пытаются оперировать между понятиями "Threads" и "Processes". Это довольно забавно выглядит.

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

Противопоставление 16 конвееров против 1 оно неверно. У вас и там и там по 16 конвееров, просто в го вам напрямую доступно примерно 14(один на планировщик и один на сборщик мусора) а в ноде вам напрямую доступен 1. Но напрямую недоступен != не используется. На IO задачах очень даже используется.

О чём я и говорил, что в итоге эти языки или же платформы выполняют свою работу категорически по-разному.

Потому что в го всего 1 поток в итоге будет отвечать за ожидание от IO физически. Все остальные так же будут продолжать свою работу. А равным распределением нагрузки уже будет заниматься планировщик (скажем, так же выделенный 1 поток) между 14 оставшимися потоками (в т.ч. и сборкой мусора).

Тесты в облаке что тестируют? На мой взгляд они бесполезны. Вот почему:

  1. Они могут тестировать борьбу за кэш cpu;

  2. Хватает ли пропускной способности ram у vps;

  3. Сеть от машины тестирования до сервера;

  4. Скорость работы файрвола;

  5. Условный турбобуст и какая за него конкуренция.

  6. Whatever…

    Вообще, все эти тесты в духе hello world на A быстрее чем на B - бред. Особенно бред, если не приводятся данные о сатурации cpu/ram/iops машины, где они выполняются. Ну и что, что 12MB/s, например, это 2 ядра из 4х, т.е. в сумме могло бы быть 24. В общем, типичная лажа.

Я, как любитель Rust, конечно рад, но даже у энтузиастов такие тесты кроме чувства стыда не должны ничего вызывать :)

На Rust еще есть simd-json, ваще улетит ракетой tothemoon :)

Ну такое, насколько я помню bun имеет свою оболочку вокруг http сервера.

Ставим на ноду uws-socket и обгоняем всех до rust, за счёт того, что из кода обработки запросов убран мусор.

Мне даже интересно проверить это.

Я правильно понимаю, что сравнили веб-сервер на rust/go с маршрутизацией, и сервер на bun который выдаёт один и тот же ответ без маршрутизации, и получили в итоге что "Bun работает хорошо и, похоже, может конкурировать с Rust и Go по HTTP"?


Фиг знает сильно ли эта маршрутизация влияет, но подход странный.

В отличие от таких языков, как Rust и Go, которые предоставляют менеджеры пакетов, Bun также их предоставляет.

Хорошее отличие :) логика поломалась

Ох, тут Just-js нехватает, чтобы показать, кто хозяин ))

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

Берем элементарно pm2, запускаем ноду в кластер режиме, проводим повторно тесты и махаем ручкой перформансу голенга. По крайней мере столь ожидаемой всеми от го разницы....

Как бы это грустно для go разработчиков не звучало. Увы это факт....

Sign up to leave a comment.

Other news