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)


  1. hello_my_name_is_dany
    13.12.2021 21:44
    +5

    При сравнении SHA-256 была допущена неточность.

    Для Node.js вы делаете await в цикле, а для GoLang wg.Wait(); после цикла. Если вы измеряете скорость параллельного вычисления, то имеет смысл в Node.js сделать с помощью Promise.all


    1. NovokhatskyiOleksii Автор
      13.12.2021 21:59

      Спасибо за интерес к статье и дельное замечание.
      К счастью влияние на абсолютный результат - минимальны (перепроверил).


      1. hello_my_name_is_dany
        13.12.2021 22:12
        +3

        И в принципе логично, потому что CPU-bound работа идёт просто в том же потоке, но если бы модуль crypto имел асинхронные интерфейсы, то всё бы действительно выполнялось параллельно. А так async/await вешать на обычную функцию, пускай она и будет даже иметь обёртку в виде:

        function hash(data, algo) {
          return new Promise(resolve => {
            resolve(crypto.createHash(algo).update(data).digest('hex'));
          });
        }

        Всё равно будет выполняться синхронно. Через node-gyp можно писать на C/C++ как раз модули, где и реализовать асинхронный интерфейс для функций хеширования (на npm даже вроде есть готовые пакеты).

        Кстати, WASM - есть WASM, ему неважно на каком языке писали, поэтому у компилятора для Go на WASM тоже есть подводные камни.

        Так что лучший перформанс можно получить, написав нативные модули к Node.js (например, с помощью node-gyp).


        1. NovokhatskyiOleksii Автор
          13.12.2021 22:47

          Да, готовые пакеты есть (скорее всего речь идет о crypto-async).
          Что касается Go -> wasm, то с подводными камнями пока не знаком. Как раз для начала "раскопок" в этом направлении и была написана статья.


          1. Alexandroppolus
            13.12.2021 22:57

            Если в 4 и 5 тестах убрать ненужный async/await, каковы будут результаты для js?


            1. NovokhatskyiOleksii Автор
              13.12.2021 23:24

              Различия незначительны. 1-2 мс что в одну, что в другую сторону.


  1. DimoNj
    13.12.2021 22:19
    -3

    Кайфанул при прочтении, большое спасибо :)


  1. Alexandroppolus
    13.12.2021 22:34
    +3

    Второй "забег", очевидно, не стоит принимать во внимание - js там просто складывал два числа, а остальные предварительно приводили строку к числу.

    4 и 5 - сравнение нативных реализаций, написанных скорее всего на плюсах. Это не сравнение языков.

    Итого остается 1 и 3. Ну с первым всё понятно, нода рулит в io. А насчет третьего - может быть, оптимизирующий компилятор v8 недостаточно прогрел код?


    1. NovokhatskyiOleksii Автор
      13.12.2021 23:17

      Спасибо за отзыв.
      Возможно ввел в заблуждение, но в Node.js тоже учитывалось приведение к числу.
      Так же не хотел показать "сравнения языков". Скорее сравнение подходов по использованию Golang (как отдельный микросервис или wasm).
      По поводу последнего ("прогретости") - вполне.


  1. 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


    1. NovokhatskyiOleksii Автор
      14.12.2021 00:50

      Спасибо за интересную мысль!

      Не задумывался над такой возможностью именно в Go. Посмотрю в эту сторону.


  1. QeqReh
    14.12.2021 07:51
    +3

    gif мешают усваивать материал.


    1. NovokhatskyiOleksii Автор
      14.12.2021 11:09

      Спасибо за отзыв. Убрал часть анимаций.


  1. un1t
    14.12.2021 10:29

    Автор сделал ерунду. Первые два примера вообще ни о чем. А четвертый и пятый пример про хеш функции скорее всего сводится в вызову сишного кода под капотом. Только про фибоначи нормальный пример.


  1. 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();


  1. lucky_libora
    15.12.2021 12:53
    +1

    Интересное исследование, пару месяцев назад делал аналогичное для Node.js+Rust WASM практически для тех же задач. Главная проблема с вызовом функций из WASM это оверхед при передаче/получении аргументов и результатов функций, поэтому задачи в стиле сложить два числа выполняются Node.js быстрее. В случае с хешом, Node.js под капотом использует плюсы и тут вряд-ли что-то можно оптимизировать.