Опубліковано
6 хв читання

Обчислювально інтенсивні операції в Node.js

Автори
  • Learna
    Ім’я
    Learna

Модель Node.js

Типова модель Node.js — цикл подій з одним основним потоком JavaScript, який використовує асинхронні операції через libuv. libuv спирається на системні API й за потреби власний пул потоків. Така модель є властивістю середовища виконання, а не обмеженням самої мови JavaScript.

Обчислення в кількох потоках

Що таке Worker Threads?

Модуль worker_threads дає змогу паралельно виконувати JavaScript в окремих потоках. Створення Worker має додаткову вартість: потрібні окремий екземпляр середовища V8, глобальний контекст, купа пам’яті та інші ресурси.

Звичайні змінні одного Worker не стають автоматично доступними іншому. Дані можна передавати повідомленнями, а для спільної пам’яті — використовувати SharedArrayBuffer.

Коли Worker Threads корисні?

worker_threads призначений насамперед для обчислювально інтенсивних завдань. Перенісши важкі обчислення з основного потоку до Worker, можна зберегти чутливість сервера до мережевих подій та інших запитів. Для звичайного асинхронного I/O окремий JavaScript-Worker зазвичай не потрібен.

Чим cluster відрізняється від Worker Threads?

cluster створює кілька окремих процесів Node.js. worker_threads натомість запускає окремі середовища виконання JavaScript у потоках одного процесу.

Корисні інструменти

SharedArrayBuffer — буфер спільної пам’яті для двійкових даних.
Atomics — атомарні операції для безпечної координації доступу до спільних даних.
MessageChannel — асинхронний двобічний канал обміну повідомленнями.
workerData — дані, передані Worker під час створення.

Обчислювально інтенсивне завдання

Розділимо велике обчислення — рахування до 1e10 — на менші частини, які можна виконувати паралельно. Для цього використаємо worker_threads у Node.js і горутини Go.

Горутини — легкий механізм конкурентного виконання, яким керує Go runtime. Це не універсальний бенчмарк Node.js проти Go: результат залежить від реалізації, компілятора, обладнання та накладних витрат. Мета прикладу — показати два способи організації паралельної роботи.

Приклад Worker Threads у Node.js

const { Worker, threadId, parentPort, isMainThread } = require("worker_threads");
/*
What data type to use?
    It is always nice to think about memory consumption,
    since WorkerThreads are fatty ones we wanna use as little memory as possible,
    but not in our case, since we are going to manipulate big numbers

    Int8Array   - from -127 to 127      - to small
    Uint8Array  - from 0 to 255         - to small
    Uint16Array - from 0 to 65535       - to small
    Uint32Array - from 0 to 4294967295  - the most suitable
*/

// STATIC VARIABLES
const MESSAGE_TYPES = { INIT_WORKER: 1,  WORKER_JOB_DONE: 2 };
const WORKER_AMOUNT = 4;
const EXPECTED_RESULT = 1e10;
const WORKER_FILE = "./worker.js";

// DYNAMIC VARIABLES
// Allocate memory for ${WORKER_AMOUNT} integers
const sab = new SharedArrayBuffer(Uint32Array.BYTES_PER_ELEMENT * WORKER_AMOUNT);
const arr = new Uint32Array(sab);
let t0;

for (let i = 0; i < WORKER_AMOUNT; i++) {
    const worker = new Worker(WORKER_FILE, {
        workerData: { MESSAGE_TYPES, WORKER_AMOUNT, EXPECTED_RESULT },
    });

    worker.on("online", () => {
        if (!t0) t0 = performance.now();
    });

    worker.on("message", (msg) => {
        if (msg.type === MESSAGE_TYPES.WORKER_JOB_DONE && arr.every(Boolean)) {
            handleResult();
        }
    });
    // Share data between workers
    worker.postMessage({ type: MESSAGE_TYPES.INIT_WORKER, data: arr });
}

const handleResult = () => {
    const t1 = performance.now() - t0;
    const result = arr.reduce((acc, v) => (acc += v), 0);
    console.log(
        `To count to the ${EXPECTED_RESULT} took: ${t1 / 1000}s`
    );
    process.exit(0);
};

Файл worker.js:
У прикладі threadId використовується для вибору окремого слота спільної пам’яті, щоб потоки не записували результат в одне місце. У виробничому коді краще явно призначати слот і перевіряти межі індексу, а не покладатися на послідовність ідентифікаторів потоків.

const { parentPort, workerData, threadId } = require("worker_threads");

const { MESSAGE_TYPES, EXPECTED_RESULT, WORKER_AMOUNT } = workerData;

const workerRunJob = () => {
    const maxValue = EXPECTED_RESULT / WORKER_AMOUNT;
    let cnt = 0;

    for (let i = 0; i < maxValue; i++) {
        cnt++;
    }
    return cnt;
};

parentPort.on("message", (msg) => {
    if (msg.type === MESSAGE_TYPES.INIT_WORKER) {
        // Set data to the SharedArrayBuffer
	    // `threadId` to avoid race conditions.
        msg.data[threadId - 1] = workerRunJob();
        // Post message about SharedArrayBuffer was updated.
        parentPort.postMessage({ type: MESSAGE_TYPES.WORKER_JOB_DONE });
    }
});

Go та горутини

Горутина — легка одиниця конкурентного виконання, якою керує Go runtime. Функції й методи можуть виконуватися конкурентно з іншими горутинами в одному адресному просторі. Доступ до спільних змінних потрібно синхронізувати. Ось відповідний приклад на Go:

package example

import (
	"fmt"
	"sync"
	"time"
)

// STATIC VARIABLES
const GOROUTINE_AMOUNT int = 4
const EXPECTED_RESULT int = 1e10

// DYNAMIC VARIABLES
var data_store [GOROUTINE_AMOUNT]int
var wg sync.WaitGroup

func routine(i int) {
	defer wg.Done()

	var count int = 0
	var maxValue int = EXPECTED_RESULT / GOROUTINE_AMOUNT

	for i := 0; i < maxValue; i++ {
		count++
	}
	data_store[i] = count
}

func ExampleGoroutine() {
	t0 := time.Now()
	for i := 0; i < GOROUTINE_AMOUNT; i++ {
		wg.Add(1)
		go routine(i)
	}
	wg.Wait()

	t1 := time.Since(t0)
	result := getSum(&data_store)

	fmt.Printf("\nTo count to the %v took: %v",
        EXPECTED_RESULT, t1)
}

// helper func to sum up numbers in array
func getSum(arr *[GOROUTINE_AMOUNT]int) int {
	result := 0
	for i := 0; i < len(arr); i++ {
		result = result + arr[i]
	}
	return result
}
Реакція Чендлера

Залишається імпортувати example у main.go та виконати go run main.go.

package main

import (
	"goroutine/example"
)

func main() {
	example.ExampleGoroutine()
}

Вимірювання

Для початкового експерименту хотілося взяти інстанс AWS EC2 щонайменше з двома фізичними ядрами, чотирма логічними потоками та 8 ГБ пам’яті. Але автор міг дозволити собі лише безкоштовний тариф, якого для такої «мегамашини» виявилося замало 😭.

Параметри інстанса AWS

Тому використаємо локальний комп’ютер 🥳. Його поточне навантаження та інші процеси впливають на результати, тож ці числа слід сприймати як ілюстрацію, а не як надійний висновок про швидкість мов.
Конфігурація: i5-1135G7 @ 4.20 GHz, 16 ГБ RAM.

Результати Node.js

    #1 To count to the 10000000000 took: 6.796850600000471s
    #2 To count to the 10000000000 took: 6.79258940000087s
    #3 To count to the 10000000000 took: 6.88838369999826s
    #4 To count to the 10000000000 took: 6.807960499998182s

Результати Go

    #1 To count to the 10000000000 took: 2.1970919s
    #2 To count to the 10000000000 took: 2.2326607s
    #3 To count to the 10000000000 took: 2.3754543s
    #4 To count to the 10000000000 took: 2.2233289s

У цьому конкретному експерименті варіант на Go виявився швидшим. Це привід перевірити Go для подібного навантаження, але не доказ, що він швидший за Node.js у будь-якій задачі.

Доступ до змінної поза локальною областю видимості

Працюючи над прикладом Node.js, я помітив суттєву різницю між двома варіантами циклу: зі змінною всередині функції та поза нею.

Змінні в локальній області видимості

function run() {
    const RESULT = 1e9;
    let cnt = 0;
    for (let i = 0; i < RESULT; i++) {
        cnt++;
    }
}

const t0 = performance.now();
run();
console.log(performance.now() - t0); // 723.72

Змінні поза локальною областю видимості

const RESULT = 1e9;
let cnt = 0;

function run() {
    for (let i = 0; i < RESULT; i++) {
        cnt++;
    }
}

const t0 = performance.now();
run();
console.log(performance.now() - t0); // 3631.19

У вимірюванні автора варіант із локальною змінною зайняв 723.72ms, а інший — 3631.19ms, приблизно вп’ятеро довше.
Чому так могло статися?

Чому виникла така різниця?

Локальні змінні можуть дати оптимізатору більше інформації й дозволити агресивніше оптимізувати цикл. Для змінних, доступних ззовні, можливі додаткові обмеження. Проте з одного мікробенчмарку не можна вивести правило «локальна змінна завжди в п’ять разів швидша»: результат залежить від версії V8, прогрівання JIT, конкретного коду та методики вимірювання. Обмежувати область видимості корисно насамперед для зрозумілості й коректності; оптимізації перевіряйте на реальному навантаженні.

Дякую за читання! Сподіваюся, стаття була корисною. 🙏