Преамбула
В технологии NodeJS существует интересная возможность написания серверных приложений с помощью скриптового языка. У Майкрософта уже давно есть компонент WSH (Windows Scripting Host), способный запускать сценарии на скриптовых языках JScript и VBScript, а также и на других дополнительно устанавливаемых языках (например, Perl). Но он не получил особого распространения, так как не мог реализовать одну из желаемых серверных функциональностей – веб-сервер. Веб-приложения должны использовать Apache, IIS, nginx, и т.д. так как серверные скрипты, способные генерировать динамические веб страницы, по-прежнему отделены от веб сервера.
NodeJS - это одна из технологий, которые предоставили нам эту возможность.
Но серверные приложения - это не только веб-серверы и веб сайты, а любые приложения, выполняющие сервисные (обслуживающие) функции по запросу клиента, предоставляя ему доступ к определённым ресурсам или услугам.
Сюда можно отнести также web API, базы данных различных типов, сетевые приложения, охватывающие такой класс задач, как балансировка нагрузки, ETL процессы, распределённые вычисления, распределённые файловые хранилища и т.д.
И для того, чтобы лучше понять возможности NodeJS, границы применимости подходов в параллельных вычислениях, физические ограничения среды, прочувствовать сокеты, потоки (streams), межпроцессное взаимодействие и чтобы закрепить теоретические знания, была выбрана задача – распараллелить один из самых быстрых алгоритмов сортировки – Quick Sort.
Таким образом, с целью более глубокого изучения возможностей технологии, была поставлена задача:
Но серверные приложения - это не только веб-серверы и веб сайты, а любые приложения, выполняющие сервисные (обслуживающие) функции по запросу клиента, предоставляя ему доступ к определённым ресурсам или услугам.
Сюда можно отнести также web API, базы данных различных типов, сетевые приложения, охватывающие такой класс задач, как балансировка нагрузки, ETL процессы, распределённые вычисления, распределённые файловые хранилища и т.д.
И для того, чтобы лучше понять возможности NodeJS, границы применимости подходов в параллельных вычислениях, физические ограничения среды, прочувствовать сокеты, потоки (streams), межпроцессное взаимодействие и чтобы закрепить теоретические знания, была выбрана задача – распараллелить один из самых быстрых алгоритмов сортировки – Quick Sort.
Таким образом, с целью более глубокого изучения возможностей технологии, была поставлена задача:
- добиться, чтобы параллельная (или распределённая) сортировка массива была быстрее, чем сортировка в одном процессе (NodeJS декларирован как STA (single thread application)),
- найти объёмы массивов, при которых сортировка в одном процессе медленнее, чем в нескольких процессах (или потоках).
Решение поставленной задачи усложняется наличием двух факторов:
- эмпирический закон Амдала накладывает ограничение на ускорение алгоритма из-за наличия в нем последовательных фрагментов;
- необходимость передавать данные между процессами увеличивает время выполнения этих последовательных фрагментов.
Была выдвинута гипотеза: если разделить массив на несколько кусков, и каждый кусок отсортировать в отдельном потоке (или процессе) на отдельном ядре процессора параллельно, то общее время на сортировку будет меньше, чем сортировка в один поток.
Следует заметить, что если разделить массив на части «как есть», то на финальном этапе нам предстоит слияние отсортированных кусков. Одна из стратегий (чтобы избежать финального слияния) – сделать предварительную сортировку. Благоприятным моментом является то, что алгоритм quick sort на каждой итерации проводит последовательный скан массива, разделяя сканируемый кусок на две части по признаку «больше или меньше опорного значения». Каждая из частей неотсортированная, но любой элемент из части «маленьких элементов» меньше самого маленького элемента из части «больших элементов» (и наоборот – любой элемент из части «больших элементов» больше самого большого из части «маленьких элементов»). Таким образом, каждая из частей может быть отсортирована отдельно от другой и над результатом не надо будет производить слияние.
Если нам нужно разделить массив на число частей, равное два в некоторой степени, то показателем степени будет количество последовательных сканов всего массива, которые нужно выполнить для разделения.
Например
Следует заметить, что если разделить массив на части «как есть», то на финальном этапе нам предстоит слияние отсортированных кусков. Одна из стратегий (чтобы избежать финального слияния) – сделать предварительную сортировку. Благоприятным моментом является то, что алгоритм quick sort на каждой итерации проводит последовательный скан массива, разделяя сканируемый кусок на две части по признаку «больше или меньше опорного значения». Каждая из частей неотсортированная, но любой элемент из части «маленьких элементов» меньше самого маленького элемента из части «больших элементов» (и наоборот – любой элемент из части «больших элементов» больше самого большого из части «маленьких элементов»). Таким образом, каждая из частей может быть отсортирована отдельно от другой и над результатом не надо будет производить слияние.
Если нам нужно разделить массив на число частей, равное два в некоторой степени, то показателем степени будет количество последовательных сканов всего массива, которые нужно выполнить для разделения.
Например
- На первой итерации алгоритм разделит весь массив на две неотсортированные части.
- На второй итерации каждая половинка будет разделена надвое (сканирование двух половинок имеет ту же вычислительную сложность, что и сканирование целого массива) – итого 4 неотсортированные части.
- На третьей итерации каждая четверть будет разделена пополам – это третье сканирование. И в результате - 8 неотсортированных частей и т.д.
Замечание.
Сравнение производится с сортировкой в один поток, в котором эти предварительные сканирования обязательно произойдут. Таким образом, применяя предварительное расщепление, потери темпа по сравнению с последовательным алгоритмом не происходит.
Реализация
Настройка окружения.
Для решения поставленной задачи были сгенерированы массивы фейковых данных о пользователях (типа ФИО, дата рождения, адрес проживания и т.д.) и сохранены в JSON-формате.
С первых же строчек кода стало понятно, что накладные расходы по созданию рабочих процессов (вёркеров) столь велики, что пока вёркеры подадут сигнал «ONLINE», STA уже просигнализирует о завершении задачи.
Но это изменение помогло незначительно – сортировка массива в 16к элементов в STA происходила за десятки миллисекунд, а передача данных вёркерам и обратно происходила намного дольше.
Следующим шагом было наращивание объёма массива, чтобы время передачи стало относительно меньше времени сортировки.
Возникла новая проблема: ограничение на размер исходной строки для JSON-парсера (встроенного в V8) около 1 Gb. Пришлось использовать сторонний модуль «stream-json/utils/StreamArray».
С первых же строчек кода стало понятно, что накладные расходы по созданию рабочих процессов (вёркеров) столь велики, что пока вёркеры подадут сигнал «ONLINE», STA уже просигнализирует о завершении задачи.
(Слово «веркер» здесь употребляется в смысле «рабочий процесс», или «работник», «исполнитель»).Было решено время инициализации вёркеров вынести за рамки поставленной задачи, изменив ее формулировку следующим образом: построить некий сервер, принимающий более одного массива для сортировки и возвращающий отсортированные массивы.
Но это изменение помогло незначительно – сортировка массива в 16к элементов в STA происходила за десятки миллисекунд, а передача данных вёркерам и обратно происходила намного дольше.
Следующим шагом было наращивание объёма массива, чтобы время передачи стало относительно меньше времени сортировки.
Возникла новая проблема: ограничение на размер исходной строки для JSON-парсера (встроенного в V8) около 1 Gb. Пришлось использовать сторонний модуль «stream-json/utils/StreamArray».
Проблема была решена, но при дальнейшем наращивании объёма массива, он перестал помещаться в оперативной памяти, выделяемой для процесса NodeJS.
Помог параметр запуска самого процесса NodeJS «--max-old-space-size=4096», увеличив размер памяти до 4 Gb.
Помог параметр запуска самого процесса NodeJS «--max-old-space-size=4096», увеличив размер памяти до 4 Gb.
Кроме того, массив сузился до двух колонок (ID, SortingValue), где ID - порядковый номер записи в оригинальном массиве (после сортировки «узкий» массив всегда можно восстановить до полного размера по оригинальным ID). Сужение массива даёт ещё один положительный эффект – вёркерам передаётся меньший объём данных.
Поскольку не стояла задача оперировать массивами данных, не помещающимися в оперативной памяти сервера, то на этом решено было прекратить дальнейшее наращивание объёмов данных и остановиться на 1.1 млн записей.
Поскольку не стояла задача оперировать массивами данных, не помещающимися в оперативной памяти сервера, то на этом решено было прекратить дальнейшее наращивание объёмов данных и остановиться на 1.1 млн записей.
Укорачивание последовательных фрагментов алгоритма
Предположим, что в нашем процессоре есть 4 ядра, и мы создали 4 вёркера. Мы можем разделить сортируемый массив на 4 части и передать вёркерам – по одной части на каждый. Каждую из частей массива можно передавать вёркеру любыми кусками в любой последовательности, но к сортировке своей части каждый из них приступит только после получения всех частей. Таким образом, передача части вёркеру – цепочка последовательных операций, то есть последовательный фрагмент.
И мы заинтересованы передать данные как можно быстрее и должны отыскать самый быстрый вариант передачи.
И мы заинтересованы передать данные как можно быстрее и должны отыскать самый быстрый вариант передачи.
Замечание: Внутри единичного процесса NodeJS библиотека libuv предоставляет пул потоков (thread pool), в котором количество потоков по умолчанию равно 4 (UV_THREADPOOL_SIZE == 4) (например см. http://docs.libuv.org/en/latest/threadpool.html). В нашем случае мы располагаем 4 ядрами и было бы заманчиво использовать потоки вместо процессов, так как время передачи данных уменьшилось бы практически до нуля (время на запись в переменную ссылки на участок памяти). Было проверено предположение, что async модуль, который, как заявлено, может запускать задачи параллельно в асинхронном режиме, может использовать внутренний пул потоков и запускать каждую задачу в отдельный поток (если задач не более 4 штук), утилизируя доступные ядра. Но, к сожалению, данное предположение не подтвердилась. Сортировка в один поток не уступала по времени сортировке с помощью async более 10%, а иногда и опережала (при некоторых величинах массивов, хотя закономерности не выявлено).
Кроме того, пул потоков глобален для одного процесса, а значит, разделяет память, доступную процессу (см. выше), между всеми потоками и наращивая количество потоков мы уменьшаем память, которой может оперировать каждый поток.
Замечание. Далее в тексте термин «поток» будет применяться к понятию «поток данных»– stream).
- IPC (использование встроенного IPC-канала)
- Sockets
- FS (сохранение в файле и передача вёркеру только пути к файлу).
- Stdio (использование стандартных IO streams - stdin/stdout).
Коль скоро большая часть из перечисленного - это потоки данных (кроме IPC), то и свои данные для передачи мы тоже можем сформировать в виде потоков (custom streams). Это даст возможность делегировать управление передачей данных (back pressure, optimal package size and other issues) встроенному механизму просто используя метод pipe(). Более того, достаточно написать два потока («from-part-of-array-to-stream» and «from-stream-to-array») и можно их использовать с любым транспортным потоком.
Кроме того, были использованы два формата передачи данных (далее называемые протоколами), для каждого из которых были сделаны по два потока – Readable (из части массива в поток) и Writable (из потока обратно в массив):
- Буферный протокол. Каждая запись имеет фиксированную длину. В нашем случае была выбрана длина 32 байта, так как длина поля для сортировки не превышает 24. 1-й байт – длина поля; 2-5 байты – целое число (Int32) – номер элемента в исходном массиве (см. рисунок ниже), далее следует само значение для сортировки, длина которого сохранена в 1-м байте. Остальные символы – мусор, который будет игнорироваться. Идея состоит в том, что размер буфера станет больше, зато все записи будут иметь фиксированную длину. Этот протокол даёт нам произвольный доступ (random access) к любому блоку записей просто по номеру элемента (операция seek(), вместо операции scan()). Поскольку файловая система оперирует блоками (кластерами), размеры которых кратны 1024 байт(1К, 2К, и тд), то, разделив размер полученного блока на размер одного элемента ( в нашем случае 32 байта), мы получим целое число элементов и каждый элемент просто будет считан с определённой позиции блока.
- Строковый протокол. Каждая запись имеет вид arr[i].ID + ‘|’ + arr[i].name + ‘\n’ (см. рисунок ниже) и конец массива обозначен символом EOT (ascii 0x04). . Этот маркер (End of Transmission) нам поможет запустить(emit) событие finish внутри нашего потока, когда мы считываем данные из него в массив, так как некоторые потоки (например child.stdout) не генерируют (emit) это событие. Этот формат более компактный, чем предыдущий, но об random access можно забыть. Чтобы считать определённую запись, придётся считать все предшествующие от начала блока.
Кроме того, в случае записи элементов массива в поток можно гарантировать, чтобы каждый блок (или чанк) будет содержать целое число элементов (то есть ни один элемент не будет разделён между двумя чанками). Но при прохождении данных через сокет, на выходе из сокета чанки могут быть нарезаны произвольным образом и нельзя рассчитывать, что в пришедшем на обработку чанке содержится целое число элементов. Причём кусок элемента может быть как в начале, так и в конце.Таким образом в функции mystream.prototype._read() придётся объединять остаток от предыдущего чанка со вновь пришедшим, чтобы не потерять ни одной записи. А это – дополнительные накладные расходы.
Рисунок 2. Дамп памяти в протоколе – «Строковый протокол»
Итак, получилось 4 потока.
Таблица 1. R/W Потоки, соответствующие протоколам Buffer format и String format
| Название | Тип | Описание |
|---|---|---|
| ArrayToBufferStream | Readable | Читает из массива в Буферный поток |
| ArrayFromBufferStream | Writable | Пишет из Буферного потока в массив |
| ArrayToStringStream | Readable | Читает из массива в Строковый поток |
| ArrayFromStringStream | Writable | Пишет из Строкового потока в массив |
Результат сравнения разных способов передачи «узкого» массива представлен на рисунке 3. При этом массив содержит 1.1 млн элементов, где каждый элемент содержит ID (Int32) и Value (не более 24 символа).
Рисунок 3. Сравнение транспортов передачи данных разными протоколами (Buffer and String)
При этом если при передаче через файл buffer stream ожидаемо выиграл у строкового, то при передаче через сокет результат был неожиданным. Мало того, что сокет передавал массив дольше, чем файл, так ещё и строковый поток опередил буферный. Кроме того, IPC канал, при котором массив передается как объект, оказался быстрее, чем через сокет (за сценой, возможно, происходит превращение в строку).
«Победителем соревнования» оказался способ, при котором использовались стандартные потоки ввода вывода вёркеров.
Именно этот способ был использован для окончательного эксперимента – соревнования по сортировке массива в одном процессе (через STA) и с помощью четырёх вёркеров на четырёх-ядерном процессоре.
Необходимо отметить влияние ещё одного фактора – размер чанков, которые формируются в момент превращения массива в поток. Строго говоря, на скорость передачи данных влияет одновременно два параметра - размер чанка и тип транспорта. То есть для одного и того же типа потока оптимальный размер чанка будет разным в зависимости от типа транспорта. Для каждого способа передачи размер чанка подбирается отдельно и на рисунке 3 можно увидеть результаты соревнования при лучших настройках для каждого эксперимента.
Кроме того, следует отметить, что передача данных через сокеты внутри одного процесса или даже в пределах одного сервера не то же самое, что передача данных по сети или через веб, когда данные проходят через роутеры. В последнем случае оптимальные размеры пакетов будут другими. Более того, используя протокол передачи Buffer Stream можно быть уверенным, что пакеты, которые передаются, не будут раздроблены (в крайнем случае они объединятся), поэтому в алгоритме у меня нет обработки «обрезков» элементов (в протоколе String Stream есть). Скорее всего, при использовании web-socket Buffer Stream работать не будет (вернее будет, если его немного изменить).
Существует ещё один момент – передавать через IPC-канал целый массив – не самый быстрый способ. Лучше передавать по блоку записей. В моём случае оптимальной оказалась передача по 8к записей.
Настройки размеров чанков приведены в следующей таблице:
Таблица 2.
| Протокол и транспорт | Длина чанка | ед.изм. |
|---|---|---|
| Строковый поток через файл | 40*1024 | байт |
| Буферный поток через файл | 40*1024 | байт |
| Строковый поток через сокет | 32*1024 | байт |
| Буферный поток через сокет | 32*1024 | байт |
| Строковый поток через ввод-вывод | 16*1024 | байт |
| Буферный поток через ввод-вывод | 32*1024 | байт |
| Массив через IPC | 8*1024 | шт. |
Эксперименты.
Итак, настал момент окончательного соревнования при сортировке массива с помощью параллельных процессов.
Первый эксперимент (IO async).
Transport: Standard process IO. Protocol: String Stream. Task Starting: Asynchronous
Алгоритм:
- Главный (Main) процесс расщепляет массив из 1.1 млн элементов на 4 куска и, используя async.forEach(), превращает каждый из кусков массива в поток данных и подключает (pipe) к нему соответствующий канал child.stdin каждого вёркера.
- На стороне вёркера к стандартному потоку ввода (process.stdin) подключается (pipe) наш созданный обратный поток, который превращает поток снова в массив.
- Каждый вёркер сортирует свой кусок массива …
- … и передаёт массив назад в Main процесс, используя стандартный поток вывода (process.stdout)
- Конкатенация кусков массива обратно в целый массив.
Конкатенация произойдёт тогда, когда будут получены все отсортированные части массива.
(Проверка на «отсортированность» проводится, но вынесена за рамки соревнования)
На рисунке 4 показана временная диаграмма (timeline) эксперимента (the top series corresponds to the STA sorting in the according scale).
На рисунке 4 показана временная диаграмма (timeline) эксперимента (the top series corresponds to the STA sorting in the according scale).
Рисунок 4. Transport: Standard process IO. Protocol: String Stream. Task Starting: Asynchronous
На рисунке выделяется большой период времени (3.28 - 4.57 (s)) в течение которого асинхронно четыре куска массива превращаются в потоки и передаются через stdin-stream-ы своим вёркерам. Поскольку главный процесс однопоточный (STA), он «размывает» процесс передачи данных между всеми вёркерами и в результате все четыре вёркера смогут приступить каждый к своей работе почти одновременно (почти – из-за неодинаковой длины кусков). Назад отсортированные куски массивов успевают разминуться, не пересекаясь. Только два самых длинных куска немного помешают друг другу на финише (worker 0 and 1). Последний этап - конкатенация пришедших кусков (40 ms).
Итак, процесс сортировки (с момента начала разделения массива на куски (split) (2.843 s) до завершения конкатенации (6.332 s)) занял примерно 3.5 s. При том, что сортировка в одном процессе (STA) занимает в среднем 3.7 s.
Конечно, параллельная сортировка получилась несколько быстрее последовательной. Но выигрыш в 5.5% не стоит тех усилий, что были затрачены.
Из-за того, что главный процесс однопоточный, наши вёркеры простаивают в ожидании своей порции задачи и время на пересылку нивелирует полезную работу.
Мы поставим второй эксперимент, немного исправив алгоритм, и начнём пересылку задачи следующему вёркеру, только после того, когда предыдущий вёркер получит задачу полностью.
Посмотрим, что их этого получится.
Итак, процесс сортировки (с момента начала разделения массива на куски (split) (2.843 s) до завершения конкатенации (6.332 s)) занял примерно 3.5 s. При том, что сортировка в одном процессе (STA) занимает в среднем 3.7 s.
Конечно, параллельная сортировка получилась несколько быстрее последовательной. Но выигрыш в 5.5% не стоит тех усилий, что были затрачены.
Из-за того, что главный процесс однопоточный, наши вёркеры простаивают в ожидании своей порции задачи и время на пересылку нивелирует полезную работу.
Мы поставим второй эксперимент, немного исправив алгоритм, и начнём пересылку задачи следующему вёркеру, только после того, когда предыдущий вёркер получит задачу полностью.
Посмотрим, что их этого получится.
Второй эксперимент (IO series).
Transport: Standard process IO. Protocol: String Stream. Task Starting: Sequential
Алгоритм (отличие от предыдущего):
- Main процесс расщепляет массив из 1.1 млн элементов на 4 куска и последовательно превращает каждый из кусков массива в поток и подключает (pipe) к нему соответствующий канал child.stdin каждого вёркера.
Давайте рассмотрим timeline эксперимента на следующем рисунке:
Рисунок 5. Transport: Standard process IO. Protocol: String Stream. Task Starting: Sequential
Интересно, что общее время сортировки практически не изменилось. Главный процесс, посылая задачу последнему вёркеру одновременно начал принимать результат от первого вёркера, тем самым отодвинув процесс сортировки в последнем процессе (на рисунке это вёркер 0). И, как следствие, повлиял на общее время сортировки.
Это похоже на раздачу пищи на полевой кухне – каждый человек съест свою порцию быстро, но обед закончится, когда один повар раздаст пищу последнему. Плюс время приема пищи этим последним. При этом совсем неважно, в какой последовательности вёркеры получат свою порцию. Либо один за другим последовательно, либо повар будет идти вдоль стола, докладывая в каждую миску по кусочку. Потом пойдёт обратно и т.д., пока все тарелки не наполнятся одновременно. Общее время приёма пищи будет тем же.
И совсем неожиданно, применив для статистики другой транспорт (через файл), был получены существенно лучший результат:
Третий эксперимент (via File async).
Transport: via File. Protocol: Buffer Stream. Task Starting: Asynchronous
Рисунок 6. Transport: via File. Protocol: Buffer Stream. Task Starting: Asynchronous
Как показывает рис.6, время передачи данных от главного процесса вёркерам резко сократилось.
Почему так произошло? Ведь при сравнении транспортов (см. рис. 3) Buffer Stream via File System показал средний результат.
Причина оказалась в следующем. При передаче данных через IO, процессорное время главного процесса расходуется с начала и до конца передачи (пока последний байт не уйдёт вёркеру). Но при использовании файловой системы операция передачи данных состоит из двух подопераций (sub-operation) – сохранение в файл и считывание из файла.
И хотя при сохранении данных в файл также расходуется процессорное время главного процесса (как в предыдущих экспериментах), считыванием из файла занимаются уже вёркеры и тратят уже каждый своё процессорное время!
А так как обе подоперации примерно одинаковы по времени, то мы получаем сокращение времени главного процесса на передачу данных вёркерам почти вдвое.
Процесс сортировки в этом случае (с момента начала разделения массива на куски (split) (2.820 s) до завершения конкатенации (5.203 s)) занял примерно 2.4 s. Что составило 68% от времени сортировки в одном процессе (STA). То есть, распараллеливание с использованием файловой системы в качестве транспорта ускорило процесс сортировки на 1/3 по сравнению с STA.
Почему так произошло? Ведь при сравнении транспортов (см. рис. 3) Buffer Stream via File System показал средний результат.
Причина оказалась в следующем. При передаче данных через IO, процессорное время главного процесса расходуется с начала и до конца передачи (пока последний байт не уйдёт вёркеру). Но при использовании файловой системы операция передачи данных состоит из двух подопераций (sub-operation) – сохранение в файл и считывание из файла.
И хотя при сохранении данных в файл также расходуется процессорное время главного процесса (как в предыдущих экспериментах), считыванием из файла занимаются уже вёркеры и тратят уже каждый своё процессорное время!
А так как обе подоперации примерно одинаковы по времени, то мы получаем сокращение времени главного процесса на передачу данных вёркерам почти вдвое.
Процесс сортировки в этом случае (с момента начала разделения массива на куски (split) (2.820 s) до завершения конкатенации (5.203 s)) занял примерно 2.4 s. Что составило 68% от времени сортировки в одном процессе (STA). То есть, распараллеливание с использованием файловой системы в качестве транспорта ускорило процесс сортировки на 1/3 по сравнению с STA.
Выводы
Идея распараллеливания различных фрагментов одного алгоритма продуктивна, но приходится учитывать стоимость передачи данных.
Пока данных немного, разделять алгоритм не имеет смысла.
Но при большом количестве данных основной процесс, при распределении работы между рабочими процессами, избавляется от части работы, но взамен тратит время на передачу данных.
Если бы данные можно было подготовить в общей памяти (shared memory) с быстрым доступом и передавать не сами данные, а ссылку на них (как между потоками (threads) одного процесса), то эффект от распараллеливания стал бы более заметен.
Можно попробовать реализовать модуль на С++, который внутри себя запустит пул потоков (thread pool), и выполнит необходимые действия в рабочих потоках с нашими данными, которые передаются в этот модуль по ссылке … Но это уже не является задачей программирования на Node.JS.
Пока данных немного, разделять алгоритм не имеет смысла.
Но при большом количестве данных основной процесс, при распределении работы между рабочими процессами, избавляется от части работы, но взамен тратит время на передачу данных.
Если бы данные можно было подготовить в общей памяти (shared memory) с быстрым доступом и передавать не сами данные, а ссылку на них (как между потоками (threads) одного процесса), то эффект от распараллеливания стал бы более заметен.
Можно попробовать реализовать модуль на С++, который внутри себя запустит пул потоков (thread pool), и выполнит необходимые действия в рабочих потоках с нашими данными, которые передаются в этот модуль по ссылке … Но это уже не является задачей программирования на Node.JS.






No comments:
Post a Comment