Harmonica : [одному из трех мужчин] Ты Фрэнк?
Snaky : Фрэнк послал нас.
Harmonica : Вы привели для меня коня?
Snaky : Кажется… кажется у нас не хватает одного коня.
Harmonica : Вы привели на двух коней больше.(Once Upon a Time in the West, 1968)
Меня зовут Алексей Новохацкий, я – Software Engineer. Сейчас работаю над архитектурой высоконагруженных систем, провожу технические собеседования, воплощаю в жизнь собственные проекты.
Как известно, Node.js хорошо справляется с I/O intensive задачами. А вот для решения CPU bound мы имеем несколько вариантов – child processes/cluster, worker threads. Также есть возможность использовать другой язык программирования (C, C++, Rust, Golang) в качестве отдельного сервиса/микросервиса или через WebAssembly скрипты.
В данной обзорной статье будут описаны подходы к использованию Golang в разработке Node.js приложений для запуска некоторых CPU intensive задач (простой суммы чисел, последовательности Фибоначчи, а также для таких хеш-функций как md5 и sha256).
Какие у нас есть варианты?
1. Попытаться решить CPU bound задачи только с помощью Node.js
2. Создать отдельный сервис, написанный на Golang и "общаться" с нашим приложением с помощью запросов/брокера сообщений и т.д. (в данной статье будут использованы обычные http запросы)
3. Использовать Golang для создания wasm файла, что позволит использовать дополнительные методы в Node.js
Скорость и деньги
Я фан старых добрых спагетти вестернов, особенно Хороший, плохой, злой. В этой статье 3 подходы к решению задач, а в этом фильме 3 совсем разных героя, которые очень хорошо характеризуют эти подходы.
Так что… давайте погрузимся в атмосферу тех времен, когда скорость и деньги решали все… Дикий Запад
Node.js (The Good)
Достоинства:
1. Один и тот же язык (JavaScript) на фронтенде и бэкенде
2. Мастер I/O операции - имеет супербыстрый event loop
3. Самый большой арсенал оружия - npm
Golang (The Bad)
Достоинства:
1. Разработан в Google
2. Поддерживается почти на всех OS
3. Горутины – специальные функции Golang, которые отрабатывают конкурентно с другими функциями и методами (хорошо справляются с CPU bound задачами)
4. Простой синтаксически – имеет только 25 ключевых слов
nodejs-golang/WebAssembly (The Ugly)
Достоинства:
1. Доступный везде
2. Дополняет JavaScript
3. Дает возможность писать код на разных языках и использовать .wasm скрипты в JavaScript
Немного подробнее о последнем подходе.
Код, написанный на Golang может быть преобразован в .wasm файл с помощью нижеприведенной команды, если установить Operating System как “js” и Architecture как “wasm” (список возможных значений GOOS и GOARCH находится здесь):
GOOS=js GOARCH=wasm go build -o main.wasm
Для запуска скомпилированного кода Go необходимо связать его через специальный интерфейс wasm_exec.js. Содержимое по ссылке:
${GOROOT}/misc/wasm/wasm_exec.js
Для использования WebAssembly я применил @assemblyscript/loader и создал модуль nodejs-golang (кстати, @assemblyscript/loader - это единственная зависимость данного модуля). Этот модуль помогает создавать, билдить и запускать отдельные wasm скрипты или функции, которые могут быть использованы в JavaScript коде
require('./go/misc/wasm/wasm_exec');
const go = new Go();
...
const wasm = fs.readFileSync(wasmPath);
const wasmModule = await loader.instantiateStreaming(wasm, go.importObject);
go.run(wasmModule.instance);
Кстати, другие языки аналогично могут быть использованы для создания .wasm файла.
C: emcc hello.c -s WASM=1 -o hello.html
C++: em++ hello.cpp -s WASM=1 -o hello.html
Rust: cargo build --target wasm --release
Давайте проверим, кто у нас самый быстрый на Диком Западе
"Вы не можете сказать, насколько хорош
человек или арбуз, пока по нему не постучите."
- Roy Bean
Для этого мы создадим 2 простых сервера
1. Golang сервер
package main
import (
...
"fmt"
...
"net/http"
...
)
func main() {
...
fmt.Print("Golang: Server is running at http://localhost:8090/")
http.ListenAndServe(":8090", nil)
}
2. Node.js сервер
const http = require('http');
...
(async () => {
...
http.createServer((req, res) => {
...
})
.listen(8080, () => {
console.log('Nodejs: Server is running at http://localhost:8080/');
});
})();
Мы будем измерять время выполнения каждой задачи – для Golang сервера это будет время непосредственного выполнения функции + сетевая задержка запроса. В то время как для Node.js и WebAssembly - это будет только время выполнения функции.
Финальная дуэль
1. “ping” (просто проверим сколько времени уйдет на выполнение запроса)
Node.js
const nodejsPingHandler = (req, res) => {
console.time('Nodejs: ping');
const result = 'Pong';
console.timeEnd('Nodejs: ping');
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.write(JSON.stringify({ result }));
res.end();
};
Golang
// golang/ping.js
const http = require('http');
const golangPingHandler = (req, res) => {
const options = {
hostname: 'localhost',
port: 8090,
path: '/ping',
method: 'GET',
};
let result = '';
console.time('Golang: ping');
const request = http.request(options, (response) => {
response.on('data', (data) => {
result += data;
});
response.on('end', () => {
console.timeEnd('Golang: ping');
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.write(JSON.stringify({ result }));
res.end();
});
});
request.on('error', (error) => {
console.error(error);
});
request.end();
};
// main.go
func ping(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, "Pong")
}
nodejs-golang
// nodejs-golang/ping.js
const nodejsGolangPingHandler = async (req, res) => {
console.time('Nodejs-Golang: ping');
const result = global.GolangPing();
console.timeEnd('Nodejs-Golang: ping');
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.write(JSON.stringify({ result }));
res.end();
};
// main.go
package main
import (
"syscall/js"
)
func GolangPing(this js.Value, p []js.Value) interface{} {
return js.ValueOf("Pong")
}
func main() {
c := make(chan struct{}, 0)
js.Global().Set("GolangPing", js.FuncOf(GolangPing))
<-c
}
Результат:
2. Следующей задачей будет просто сумма двух чисел
Node.js
const result = p1 + p2;
Golang
func sum(w http.ResponseWriter, req *http.Request) {
p1, _ := strconv.Atoi(req.URL.Query().Get("p1"))
p2, _ := strconv.Atoi(req.URL.Query().Get("p2"))
sum := p1 + p2
fmt.Fprint(w, sum)
}
nodejs-golang
func GolangSum(this js.Value, p []js.Value) interface{} {
sum := p[0].Int() + p[1].Int()
return js.ValueOf(sum)
}
Результат:
3. Далее последовательность Фибоначчи (получаем 100000-е число)
Node.js
const fibonacci = (num) => {
let a = BigInt(1),
b = BigInt(0),
temp;
while (num > 0) {
temp = a;
a = a + b;
b = temp;
num--;
}
return b;
};
Golang
func fibonacci(w http.ResponseWriter, req *http.Request) {
nValue, _ := strconv.Atoi(req.URL.Query().Get("n"))
var n = uint(nValue)
if n <= 1 {
fmt.Fprint(w, big.NewInt(int64(n)))
}
var n2, n1 = big.NewInt(0), big.NewInt(1)
for i := uint(1); i < n; i++ {
n2.Add(n2, n1)
n1, n2 = n2, n1
}
fmt.Fprint(w, n1)
}
nodejs-golang
func GolangFibonacci(this js.Value, p []js.Value) interface{} {
var n = uint(p[0].Int())
if n <= 1 {
return big.NewInt(int64(n))
}
var n2, n1 = big.NewInt(0), big.NewInt(1)
for i := uint(1); i < n; i++ {
n2.Add(n2, n1)
n1, n2 = n2, n1
}
return js.ValueOf(n1.String())
}
Результат:
Давайте перейдем к старым добрым хеш-функциям. Сначала – md5 (10k строк)
Node.js
const crypto = require('crypto');
const md5 = (num) => {
for (let i = 0; i < num; i++) {
crypto.createHash('md5').update('nodejs-golang').digest('hex');
}
return num;
};
Golang
func md5Worker(c chan string, wg *sync.WaitGroup) {
hash := md5.Sum([]byte("nodejs-golang"))
c <- hex.EncodeToString(hash[:])
wg.Done()
}
func md5Array(w http.ResponseWriter, req *http.Request) {
n, _ := strconv.Atoi(req.URL.Query().Get("n"))
c := make(chan string, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go md5Worker(c, &wg)
}
wg.Wait()
fmt.Fprint(w, n)
}
nodejs-golang
func md5Worker(c chan string, wg *sync.WaitGroup) {
hash := md5.Sum([]byte("nodejs-golang"))
c <- hex.EncodeToString(hash[:])
wg.Done()
}
func GolangMd5(this js.Value, p []js.Value) interface{} {
n := p[0].Int()
c := make(chan string, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go md5Worker(c, &wg)
}
wg.Wait()
return js.ValueOf(n)
}
Результат:
5. … и наконец sha256 (10k строк)
Node.js
const crypto = require('crypto');
const sha256 = (num) => {
for (let i = 0; i < num; i++) {
crypto.createHash('sha256').update('nodejs-golang').digest('hex');
}
return num;
};
Golang
func sha256Worker(c chan string, wg *sync.WaitGroup) {
h := sha256.New()
h.Write([]byte("nodejs-golang"))
sha256_hash := hex.EncodeToString(h.Sum(nil))
c <- sha256_hash
wg.Done()
}
func sha256Array(w http.ResponseWriter, req *http.Request) {
n, _ := strconv.Atoi(req.URL.Query().Get("n"))
c := make(chan string, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go sha256Worker(c, &wg)
}
wg.Wait()
fmt.Fprint(w, n)
}
nodejs-golang
func sha256Worker(c chan string, wg *sync.WaitGroup) {
h := sha256.New()
h.Write([]byte("nodejs-golang"))
sha256_hash := hex.EncodeToString(h.Sum(nil))
c <- sha256_hash
wg.Done()
}
func GolangSha256(this js.Value, p []js.Value) interface{} {
n := p[0].Int()
c := make(chan string, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go sha256Worker(c, &wg)
}
wg.Wait()
return js.ValueOf(n)
}
Результат:
Итоговый результат
Что мы сегодня узнали:
1. Существует Node.js, который хорошо выполняет свою работу
2. Существует Golang, который хорошо выполняет свою работу
3. Существует WebAssembly (а теперь и мой модуль nodejs-golang), который хорошо выполняет свою работу
4. Golang можно использовать как: самостоятельное приложение, сервис/микросервис, источник для wasm скрипта (который затем можно использовать в JavaScript)
5. Node.js и Golang имеют готовые механизмы использования WebAssembly в JavaScript
Выводы:
“Скорость – это хорошо, но точность – это все.” - Wyatt Earp
1. Не запускать CPU-bound задачи с Node.js (если есть возможность)
2. На самом деле лучше не делать никакой задачи, если это возможно
3. Если вам нужно запустить CPU-bound задачу, в Node.js приложении – попробуйте сделать это с помощью Node.js. (будет не так плохо)
4. Между производительностью (с использованием других языков) и удобством чтения (продолжая сохранять только код JavaScript в команде, поддерживающей только JavaScript), лучше выбрать читабельность
5. "Хороший архитектор отталкивается от того, что решение еще не принято, и формирует систему таким образом, что эти решения все еще могут быть изменены или отложены как можно дольше. Хороший архитектор максимизирует количество не принятых решений" - Clean Architecture by Robert C. Martin
6. Лучше "держать отдельно отдельно". Создайте сервис/микросервис для тяжелых вычислений – при необходимости будет легко масштабироваться
7. WebAssembly полезен прежде всего для браузеров. Бинарник Wasm меньше и проще для парсинга, чем код JS и т.д.
Спасибо за прочтение. Надеюсь, вам понравилось.
Для получения дополнительной информации и возможности проверить результаты, пожалуйста, посетите мой github.
Модуль, написанный специально для статьи - nodejs-golang
До новых приключений!
*Примечание:
Благодаря внимательным читателям в тексте было обнаружено несколько неточностей. В частности, исправлен вызов синхронных функций (md5/sha256) с помощью async/await (Node.js). Прошу прощения за допущенную ошибку в написании кода. Результаты замеров проверены - отклонения не имеют значительного влияния на исследование в целом и составляют ±5-10% для вышеупомянутых функций. Полная статистическая оценка не была проведена, так как это выходило за рамки цели данной статьи - показать разницу между использованием Golang для написания отдельного сервиса и для создания wasm файла.
Спасибо всем за плодотворное обсуждение.
Комментарии (16)
Alexandroppolus
13.12.2021 22:34+3Второй "забег", очевидно, не стоит принимать во внимание - js там просто складывал два числа, а остальные предварительно приводили строку к числу.
4 и 5 - сравнение нативных реализаций, написанных скорее всего на плюсах. Это не сравнение языков.
Итого остается 1 и 3. Ну с первым всё понятно, нода рулит в io. А насчет третьего - может быть, оптимизирующий компилятор v8 недостаточно прогрел код?
NovokhatskyiOleksii Автор
13.12.2021 23:17Спасибо за отзыв.
Возможно ввел в заблуждение, но в Node.js тоже учитывалось приведение к числу.
Так же не хотел показать "сравнения языков". Скорее сравнение подходов по использованию Golang (как отдельный микросервис или wasm).
По поводу последнего ("прогретости") - вполне.
demp
14.12.2021 00:31+2А почему не рассматривался 4-й вариант: написать Native Addon для Node.js использую https://nodejs.org/api/n-api.html#node-api ?
Даже если поблизости нет C или С++ программиста, то вроде бы в Go можно сделать библиотеку, экспортирующую функции в C стиле, и использовать их с Node-API
NovokhatskyiOleksii Автор
14.12.2021 00:50Спасибо за интересную мысль!
Не задумывался над такой возможностью именно в Go. Посмотрю в эту сторону.
un1t
14.12.2021 10:29Автор сделал ерунду. Первые два примера вообще ни о чем. А четвертый и пятый пример про хеш функции скорее всего сводится в вызову сишного кода под капотом. Только про фибоначи нормальный пример.
dustik
14.12.2021 10:54+1Простите за оффтоп, но для себя открыл, что на m1 100000-е число Фибоначчи, считается в среднем за 74ms.
Методика замера:const timeDiff = (begin, end) => (end - begin) / 1000000n; const main = () => { const begin = process.hrtime.bigint(); let num = 100000n let a = 1n, b = 0n, temp; while (num > 0n) { temp = a; a += b; b = temp; num -= 1n; } const end = process.hrtime.bigint(); console.log(b, '\n'); console.log('Время:', timeDiff(begin, end), ' ms.'); }; main();
lucky_libora
15.12.2021 12:53+1Интересное исследование, пару месяцев назад делал аналогичное для Node.js+Rust WASM практически для тех же задач. Главная проблема с вызовом функций из WASM это оверхед при передаче/получении аргументов и результатов функций, поэтому задачи в стиле сложить два числа выполняются Node.js быстрее. В случае с хешом, Node.js под капотом использует плюсы и тут вряд-ли что-то можно оптимизировать.
hello_my_name_is_dany
При сравнении SHA-256 была допущена неточность.
Для Node.js вы делаете
await
в цикле, а для GoLangwg.Wait();
после цикла. Если вы измеряете скорость параллельного вычисления, то имеет смысл в Node.js сделать с помощьюPromise.all
NovokhatskyiOleksii Автор
Спасибо за интерес к статье и дельное замечание.
К счастью влияние на абсолютный результат - минимальны (перепроверил).
hello_my_name_is_dany
И в принципе логично, потому что CPU-bound работа идёт просто в том же потоке, но если бы модуль crypto имел асинхронные интерфейсы, то всё бы действительно выполнялось параллельно. А так async/await вешать на обычную функцию, пускай она и будет даже иметь обёртку в виде:
Всё равно будет выполняться синхронно. Через node-gyp можно писать на C/C++ как раз модули, где и реализовать асинхронный интерфейс для функций хеширования (на npm даже вроде есть готовые пакеты).
Кстати, WASM - есть WASM, ему неважно на каком языке писали, поэтому у компилятора для Go на WASM тоже есть подводные камни.
Так что лучший перформанс можно получить, написав нативные модули к Node.js (например, с помощью node-gyp).
NovokhatskyiOleksii Автор
Да, готовые пакеты есть (скорее всего речь идет о crypto-async).
Что касается Go -> wasm, то с подводными камнями пока не знаком. Как раз для начала "раскопок" в этом направлении и была написана статья.
Alexandroppolus
Если в 4 и 5 тестах убрать ненужный async/await, каковы будут результаты для js?
NovokhatskyiOleksii Автор
Различия незначительны. 1-2 мс что в одну, что в другую сторону.