Галоўная / Артыкулы

Чаканне протык вычысленняў: сумавершанасць і паралелізм у 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);

Галоўны файл абгортае кожнага працоўніка ў promise, які рашуеся пад час выпанавання зместу эвэнту 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();

На апаратазе з трыма чы ўжо большаю колькасцю ядоў загальны час выконання зменшыцца прыбліжна да трываласі цяпероўшага вызову. Тры кухары, тры сковороды, якія працуюць адразу. Галоўны потак таксама застаецца вільным для обробкі запытанняў.

Але паглядзіце, што для цього знадобілася: спецыяльны файл, канал для перадачы сообщэнняў і абгортка у ваце promise. У 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 ён є стандартны, пры чым скаржальнік выкарыстоўвае ўсе ядра.
  • Робота з великай навантажэнням на 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 автаматычна ператварае канкуруючыя горутіны на паралельную експанацію, але спяльная память выклікае рыску конфліктавання дадзеных; лепш выкарыстоўваць каналы та детектар рысак.