Чекання проти обробки: конкурентність та паралелізм у Node.js та Go
Примірники коду на Node.js та Go, які розрізняють конкурентність та паралелізм, показуючи, коли достатньо обіцянок, коли потрібні робочі потоки, та в чому полягають відмінності між горутинами.
Джерела: 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 фіксує приблизно одну секунду замість трьох. Один потік виконував три завдання одночасно та ніколи не залишався вільним у очікуванні їх завершення. Це і є конкурентність.
Що відбувається під час реальної роботи процесора
Цикл подій може лише перекривати моменти очікування. Коли завдання полягає лише у обчисленнях, немає чого передавати операційній системі. Ця програма тричі підсумовує великий діапазон:
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() блокується до моменту завершення всіх горутин. Результат збігається з версією Promise.all у Node.js: загалом приблизно одна секунда.
Робота, залежна від 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 він увімкнений за замовчуванням, причому схематор використовує всі ядра.
Вибір правильного інструменту: очікування чи обчислення?
Одне запитання вирішує більшість випадків: чи є повільною частиною вашої роботи процес очікування, чи обчислення?
Якщо це очікування (на базі даних, файловій системі чи мережевому запиті), вам потрібна лише конкурентність. У Node.js це означає обіцянки та async/await. У Go це означає горутини, а схематор призупиняє ті, що заблоковані, щоб вони не споживали CPU.
Якщо йдеться про обробку даних (зміна розміру зображень, створення хешів, парсинг дуже великих файлів), потрібен паралелізм. У Node.js перемістіть роботу у робочі потоки або використайте cluster, щоб розподілити обробку запитів між процесами. У Go запустіть горутини та переконайтеся, що вони обмінюються даними через канали або захищають спільний стан за допомогою мьютексу.
Паралелізм обмежений апаратним забезпеченням: у контейнері з одним ядром три завдання, що залежать від CPU, виконуються не швидше, ніж послідовно, у будь-якій з мов.
Ключові висновки
- Конкурентність стосується структури: організація роботи так, щоб ніщо не залишалося без дії поки щось чекає. Паралелізм стосується апаратного забезпечення: кількість завдань, які справді виконуються одночасно.
- У Node.js
Promise.allдозволяє паралельно виконувати завдання, що чекають, але не допомагає при синхронній обробці, яка блокує кожен запит у потоці.