Все примеры кода из этой статьи можно найти в этом репозитории GitHub.

Вы, вероятно, знаете, что JavaScript является однопоточным. Это означает, что технически он может делать только одну вещь за раз. Конечно, у нас есть Promises и синтаксис async...await, которые позволяют нам асинхронно ожидать чего-либо (например, ответа на HTTP-запрос). Но когда дело доходит до того, что ваша программа на JavaScript фактически обрабатывает данные, одновременно может выполняться только одна операция. Это плохо для масштабируемости.

Рассмотрим функцию work(), определенную ниже:

// A function that will sleep synchronously for a certain
// number of milliseconds, blocking the main thread.
function sleepSync(milliseconds) {
    const start = Date.now();
    const expire = start + milliseconds;
    while (Date.now() < expire) {}
}

// A function that simulates a heavy blocking workload by
// synchronously sleeping for 2 seconds.
function work() {
    sleepSync(2_000);
}

Допустим, мы хотим запустить эту функцию 10 раз. Это можно сделать с помощью простого цикла for:

// Run the "work" function 10 times.
console.time('Workflow');

// Run the "work" function 10 times.
for (let i = 0; i <= 10; i++) {
    work();
}

console.timeEnd('Workflow');

Выход:

$ yarn start

  Workflow: 22.000s

Выглядит хорошо, верно? Нет. С этим кодом есть некоторые проблемы с кровотечением.

  • Мы запускаем эти work() вызовы последовательно — это означает, что они выполняются один за другим. Это медленно. Гораздо оптимальнее было бы запускать их параллельно, но в одном потоке это невозможно.
  • Наш драгоценный поток полностью заблокирован во время выполнения этого цикла. Все остальные операции будут зависать в ожидании завершения цикла, что занимает (согласно выходным данным выше) 22 секунды.

Блокирующий характер приведенной выше логики можно увидеть, запустив этот фрагмент кода:

function doWork() {
    for (let i = 0; i <= 10; i++) {
        work();
    }
    console.log('Workflow finished.');
}

doWork();
console.log('Hello world!');

Поскольку doWork() блокирует основной поток, сообщение «Hello world!» log (и любые другие операции) вынуждены ждать 20 секунд, пока они не закончатся, прежде чем они смогут действительно запуститься. Приложение замедляется до полного сканирования.

$ yarn start

  Workflow finished.
  Hello world!

Многопоточность спасает положение

Чтобы показать, насколько ужасен приведенный выше код JavaScript, давайте посмотрим, как Голанг справляется с той же рабочей нагрузкой:

package main

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

// A function that simulates a heavy blocking workload by
// synchronously sleeping for 2 seconds.
func work() {
 time.Sleep(time.Second * 2)
}

func main() {
 // Create a group to keep track of and wait for our
 // operations.
 var waitGroup sync.WaitGroup
 start := time.Now()

 // Run the "work" function 10 times.
 for i := 1; i <= 10; i++ {
  waitGroup.Add(1)

  // Run the task on a different thread.
  go func() {
   defer waitGroup.Done()
   work()
  }()
 }

 // Wait for all the operations to complete.
 waitGroup.Wait()

 // Comparable to "console.timeEnd" in Javascript
 fmt.Printf("Workflow: %vs\n", time.Since(start).Round(time.Millisecond).Seconds())
}

Выход:

$ go run main.go
  
  Workflow: 2.007s

Несмотря на то, что он делает почти то же самое, что и его аналог на JavaScript, для завершения версии Golang требуется всего около 2 секунд. Почему? Многопоточность. Из-за многопоточности обе проблемы с кровотечением, которые были в коде JavaScript, устранены. Благодаря распараллеливанию программа работает не только намного быстрее, но и (что наиболее важно) выполнение других несвязанных операций не блокируется.

Вы, вероятно, думаете: «Хорошо, чувак, Голанг быстрый. Но как это связано с JavaScript?». Приведенный выше пример демонстрирует, как многопоточность может сделать часть программного обеспечения чрезвычайно масштабируемой, включая ваши приложения Node.js.

Многопоточность в Node.js

Мы собираемся использовать пакет NPM Nanolith и несколько дополнительных строк кода, чтобы превратить нашу медленную программу, созданную в начале этой статьи, в масштабируемое приложение. Шаги следующие:

Установить Нанолит

Используя наш менеджер пакетов, мы сначала установим Nanolith как зависимость:

yarn add nanolith

Используйте ESModules (если вы еще этого не сделали)

Чтобы использовать Nanolith, вам нужно использовать ESModules вместо CommonJS. К счастью, для нас это просто означает добавление следующей пары ключ-значение на верхний уровень нашего файла package.json.

{
  // ...
  "type": "module",
  // ...
}

Создать новый файл

Теперь нам нужно создать новый файл, предназначенный для определения функций, которые мы хотели бы использовать в многопоточности. Этот файл может называться как угодно — допустим worker.ts.

Определите свои задачи

Вместо того, чтобы определять функцию work() внутри нашего основного файла, как раньше, нам нужно использовать функцию define() (импортированную из nanolith) в только что созданном новом файле:

/* File: worker.js */
import { define } from 'nanolith';

// Create and export a variable with a name of your choice
// that points to the awaited result of defining your tasks.
export const api = await define({
    // Define the work function inside of the object.
    work() {
        sleepSync(2_000);
    },
});

function sleepSync(milliseconds) {
    const start = Date.now();
    const expire = start + milliseconds;
    while (Date.now() < expire) {}
}

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

Вот и все

Больше никаких шаблонов не требуется. Теперь мы можем импортировать эту переменную api и использовать ее для вызова нашей функции work(), которая будет выполняться как задача в отдельном потоке (не в основном потоке). Давайте изменим нашу логику JavaScript, чтобы она немного соответствовала приведенному выше примеру Golang:

/* File: index.js */
// Import the definitions we created before.
import { api } from './worker.js';

console.time('Workflow');

// Create an array to store promises generated
// by each multithreaded operation.
const promises = [];

for (let i = 0; i <= 10; i++) {
    // Use the "api" as a function to call the
    // task as a multithreaded operation.
    const promise = api({ name: 'work' });
    // Add the task's promise to the array.
    promises.push(promise);
}

// Wait for all of the operations to complete.
await Promise.all(promises);

console.timeEnd('Workflow');

Выход:

$ yarn start

  Workflow: 2.409s

Основываясь только на выводе, становится ясно, что мы:

  1. Улучшена производительность нашего приложения почти в десять раз. Это связано с тем, что все вызовы work() выполняются параллельно.
  2. Освободил наш основной поток от каких-либо блокировок.

Чтобы наблюдать за неблокирующим характером нашего рабочего процесса с использованием Nanolith, мы можем запустить этот фрагмент кода:

import { api } from './worker.js';

async function doWork() {
    const promises = [];

    for (let i = 0; i <= 10; i++) {
        const promise = api({ name: 'work' });
        promises.push(promise);
    }

    await Promise.all(promises);
    console.log('Workflow finished.');
}

doWork();
console.log('Hello world!');

Выход:

$ yarn test-blocking

  Hello world!
  Workflow finished.

Несмотря на то, что выполнение функции doWork() занимает около 2,5 секунд, во время ее выполнения могут выполняться другие операции.

Заворачивать

Цель этой статьи не в том, чтобы ненавидеть JavaScript за недостаточную производительность, и уж точно не в том, чтобы сравнивать JS с Golang. Скорее, ключевой вывод после прочтения этой статьи должен быть следующим:

Многопоточность — это фантастический способ масштабирования любого приложения. Всякий раз, когда вы хотите использовать многопоточность в Node.js, не ищите ничего, кроме пакета Nanolith NPM.