Главная / Статьи

Ожидание против вычислений: конкурентность и параллелизм в Node.js и Go

Примеры кода на Node.js и Go, которые разделяют конкурентность и параллелизм, показывающие, когда достаточно обещаний (promises), когда необходимы рабочие потоки, и в чём разница между горутинами.

Издатель: REACTAPP.TOP8 мин чтения1761 слов

Источники: Concurrency vs parallelism, explained with Node.js and Go

Рассматривание конкурентности и параллелизма как синонимов приводит к реальным ошибкам, таким как застой API при высокой нагрузке. Сравнение Node.js и Go делает эту разницу более очевидной. Node.js обеспечивает конкурентность без дополнительных усилий, заставляя разработчика самостоятельно реализовывать параллелизм, в то время как Go предоставляет оба этих явления с помощью одного ключевого слова. С помощью небольших запускаемых программ на каждом языке вы сможете понять, какой из них необходим для вашей рабочей нагрузки.

Два определения и кухня

Конкурентность означает, что один работник обрабатывает множество задач, переключаясь на другую каждый раз, когда текущая задача находится в состоянии ожидания. Параллелизм означает, что несколько работников выполняют несколько задач буквально в один и тот же момент.

Представьте кухню. Один повар с тремя сковородами — это конкурентность: повар помешивает содержимое первой сковороды, а пока вторая кипит, нарезает овощи для третьей. В никакой момент не происходит двух действий одновременно, но повар всегда занят. Если в кухню поставить трех поваров, по одному к каждой сковороде, это уже параллелизм.

Node.js: конкурентность встроена изначально

Один поток, множество обрабатываемых запросов

Node.js выполняет ваш JavaScript в одном потоке. Он всё равно хорошо справляется с высокой нагрузкой, поскольку большая часть работы сервера сводится к ожиданию данных из базы данных, файла или другого сервиса. Node передаёт это ожидание операционной системе (а для некоторых операций — во внутренний пул потоков libuv) и продолжает обработку следующего фрагмента кода на JavaScript. Этот механизм планирования называется циклом событий. Чтобы узнать больше о его работе, прочитайте как libuv, цикл событий и пул потоков взаимодействуют друг с другом.

Следующая программа имитирует три запроса, каждый из которых занимает одну секунду, с использованием таймеров, обёрнутых в обещания:

function fakeRequest(name, ms) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(`${name} done`), ms);
  });
}

async function main() {
  console.time("total");

  const results = await Promise.all([
    fakeRequest("users", 1000),
    fakeRequest("orders", 1000),
    fakeRequest("products", 1000),
  ]);

  console.log(results);
  console.timeEnd("total");
}

main();

Поскольку Promise.all запускает все три задачи одновременно, а таймеры истекают параллельно, console.timeEnd показывает примерно одну секунду вместо трех. Один поток выполнял три задачи одновременно и никогда не оставался в состоянии ожидания. Это и есть конкурентность.

Что происходит при реальной работе CPU

Цикл событий может выполнять только операции ожидания одновременно. Когда задача представляет собой чистые вычисления, нет ничего, что можно было бы передать операционной системе. В этой программе большой диапазон суммируется три раза:

function countTo(n) {
  let sum = 0;
  for (let i = 0; i < n; i++) sum += i;
  return sum;
}

console.time("total");
countTo(2_000_000_000);
countTo(2_000_000_000);
countTo(2_000_000_000);
console.timeEnd("total");

Общее время выполнения составляет примерно в три раза больше, чем время одного вызова. Использование Promise.all для объединения этих вызовов не поможет, поскольку каждая функция countTo выполняется синхронно от начала до конца, прежде чем можно будет запустить что-либо ещё. Для примера: один повар должен по очереди готовить каждую сковороду. Что ещё хуже, на сервере процесс не может обрабатывать никаких HTTP-запросов во время выполнения цикла.

Достижение параллелизма с помощью рабочих потоков

Для использования более чем одного ядра CPU требуется более чем один поток, и инструментом Node для этого является модуль worker_threads. Сначала создается рабочий поток — отдельный файл, который читает данные из workerData, выполняет вычисления и отправляет результат обратно родительскому процессу.

// worker.js
const { parentPort, workerData } = require("worker_threads");

let sum = 0;
for (let i = 0; i < workerData; i++) sum += i;

parentPort.postMessage(sum);

Основной файл оборачивает каждого рабочего процесса в обещание, которое решается при событии message рабочего процесса и отклоняется при событии error, после чего запускаются три рабочих процесса одновременно:

// main.js
const { Worker } = require("worker_threads");
const path = require("path");

function countInWorker(n) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(path.join(__dirname, "worker.js"), {
      workerData: n,
    });
    worker.on("message", resolve);
    worker.on("error", reject);
  });
}

async function main() {
  console.time("total");

  await Promise.all([
    countInWorker(2_000_000_000),
    countInWorker(2_000_000_000),
    countInWorker(2_000_000_000),
  ]);

  console.timeEnd("total");
}

main();

На машине с тремя или более ядрами общее время выполнения сокращается примерно до длительности одного вызова. Три повара, три сковороды — работают одновременно. Основной поток также остается свободным для обработки запросов.

Однако посмотрите, сколько для этого потребовалось: отдельный файл, канал сообщений и обертка в виде обещания. В Node.js параллелизм — это то, что необходимо собирать самостоятельно. В производственных условиях также следует избегать создания нового рабочего процесса для каждой задачи, поскольку запуск рабочего процесса сопряжен с значительными затратами; обычным решением является использование повторно используемого пула рабочих процессов, о чем рассказано в нашем руководстве по рабочим потокам и пулам.

Go: одно ключевое слово для обоих случаев

Горутины вместо цикла событий

В Go добавление префикса go перед вызовом функции запускает горутину. Каждая горутина начинается с стека объемом всего несколько килобайт, который увеличивается по мере необходимости, поэтому запуск тысяч таких горутин — обычное дело. Схематор выполнения в Go отвечает за привязку горутин к потокам операционной системы; по умолчанию он позволяет выполнять столько горутин одновременно, сколько есть ядер CPU, причем это ограничение задается через переменную GOMAXPROCS.

Вот снова симуляция с тремя запросами, где используется sync.WaitGroup для ожидания завершения всех горутин:

package main

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

func fakeRequest(name string, wg *sync.WaitGroup) {
 defer wg.Done()
 time.Sleep(time.Second)
 fmt.Println(name, "done")
}

func main() {
 start := time.Now()
 var wg sync.WaitGroup

 for _, name := range []string{"users", "orders", "products"} {
  wg.Add(1)
  go fakeRequest(name, &wg)
 }

 wg.Wait()
 fmt.Println("total:", time.Since(start))
}

Каждая горутина вызывает wg.Done() с помощью defer, а wg.Wait() блокируется до завершения всех горутин. Результат совпадает с версией на Node.js Promise.all: в общем примерно одна секунда.

Работа, зависящая от производительности CPU, не требует дополнительных механизмов

Теперь пример подсчёта. Если в Node.js требовался файл-рабочий процесс и передача сообщений, то версия на Go сохраняет точно ту же структуру, что и раньше:

package main

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

func countTo(n int, wg *sync.WaitGroup) {
 defer wg.Done()
 sum := 0
 for i := 0; i < n; i++ {
  sum += i
 }
 fmt.Println("sum:", sum)
}

func main() {
 start := time.Now()
 var wg sync.WaitGroup

 for i := 0; i < 3; i++ {
  wg.Add(1)
  go countTo(2_000_000_000, &wg)
 }

 wg.Wait()
 fmt.Println("total:", time.Since(start))
}

На многопроцессорной машине это выполняется примерно за время одного вызова. Планировщик нашёл три горутины, готовые к выполнению, и запустил их на трёх потоках одновременно. Вы выразили работу конкурентно, а среда выполнения преобразовала её в параллельное выполнение.

Это отражает различие, о котором говорит известная презентация Роба Пайка «Конкурентность — это не параллелизм»: конкурентность — это способ структурирования программы как независимо выполняющихся частей, а параллелизм — это то, что среда выполнения может сделать с этой структурой при наличии нескольких ядер.

Цена общей памяти

У каждого рабочего процесса Node.js есть собственный изолированный хэш, и они не могут читать переменные друг друга; они обмениваются данными путем передачи сообщений. (Если вам действительно нужна общая память в Node.js, существует SharedArrayBuffer с использованием Atomics, но это влечёт за собой те же проблемы синхронизации, описанные ниже.)

В отличие от этого, горутины делятся одной и той же памятью. Если две горутины увеличивают один счётчик без координации, это приводит к конфликту данных, и окончательное значение будет неверным. Стандартным решением в Go является передача данных через каналы и использование мьютекса только тогда, когда общий доступ к состоянию неизбежен.

В этом пуле рабочих процессов используется подход с каналами. Три горутины читают задания из одного канала и записывают удвоенные значения в другой:

package main

import "fmt"

func worker(jobs <-chan int, results chan<- int) {
 for j := range jobs {
  results <- j * 2
 }
}

func main() {
 jobs := make(chan int, 100)
 results := make(chan int, 100)

 for w := 1; w <= 3; w++ {
  go worker(jobs, results)
 }

 for j := 1; j <= 9; j++ {
  jobs <- j
 }
 close(jobs)

 for r := 1; r <= 9; r++ {
  fmt.Println(<-results)
 }
}

Ничто не делится между процессами, кроме самих каналов, поэтому замки не требуются, а добавление рабочих процессов позволяет пулу использовать больше ядер. Результаты поступают в том порядке, в котором завершаются рабочие процессы, что соответствует ожидаемому поведению для этой модели. Запуск программы с командой go run -race main.go включает детектор конкурентных ошибок, который сообщает о любом случайном несинхронизированном доступе к общей памяти.

Сравнение двух моделей

  • Единица конкурентности: Node.js использует обещания и колбэки, запланированные в одном цикле событий; Go использует горутины.
  • Параллелизм: в Node.js он является опциональным и реализуется с помощью worker_threads или модуля cluster (который запускает несколько процессов); в Go он включен по умолчанию, причем сценарий запуска использует все ядра.
  • Работа с высокой нагрузкой на CPU: в Node.js это блокирует цикл событий, если только вы не перенесёте её в рабочий процесс; в Go это просто ещё одна горутина.
  • Обмен данными: рабочие процессы Node.js обмениваются сообщениями; горутины используют каналы или общую память, защищённую мьютексом.
  • Типичный способ сбоя: в Node.js — блокировка цикла событий; в Go — конкурентные ситуации.
  • Выбор подходящего инструмента: ожидание или вычисления?

    В большинстве случаев достаточно одного вопроса: является ли медленная часть вашей нагрузки процессом ожидания или вычислений?

    Если это ожидание (до базы данных, файловой системы или сетевого запроса), вам нужна конкурентность. В Node.js это означает использование обещаний и async/await. В Go это означает горутины, а планировщик приостанавливает те горутины, которые находятся в состоянии блокировки, чтобы они не потребляли CPU.

    Если речь идет о вычислениях (изменение размера изображений, хеширование, обработка очень большого файла), необходим параллелизм. В Node.js перенесите работу в рабочие потоки или используйте cluster, чтобы распределить обработку запросов между процессами. В Go запускайте горутины и убедитесь, что они обмениваются данными через каналы или защищают общее состояние с помощью мьютекса.

    Параллелизм ограничен характеристиками оборудования: в контейнере с одним ядром три задачи, зависящие от производительности CPU, будут выполняться не быстрее, чем последовательно, независимо от языка программирования.

    Основные выводы

    • Конкурентность связана со структурой: организация работы так, чтобы ничто не простаивало во время ожидания. Параллелизм связан с оборудованием: количество операций, выполняющихся одновременно.
    • В Node.js Promise.all позволяет одновременно выполнять операции во время ожидания, но не помогает при синхронных вычислениях, которые блокируют все запросы в потоке.
  • Для реализации параллелизма в Node.js необходимы явно определённые рабочие потоки (желательно из пула) или несколько процессов.
  • Скриптатор Go автоматически преобразует одновременно выполняемые горутины в параллельную обработку, но из-за совместного использования памяти возникает риск конкурентных ситуаций; предпочтительно использовать каналы и инструменты для обнаружения таких ситуаций.