Към съдържанието
wannacode

Защо нишки

Накратко: Работата, която чака (мрежа, диск), в JavaScript не иска нишки, стига Promise.all; работата, която смята, иска истински нишки, Worker-и, и повече ядра.

Проблем

Клиентът чака три пъти

Клиентът натиска „Поръчай“. Преди да види „Благодарим“, сървърът трябва да пита три услуги по мрежата: плащането (банката приема ли картата), склада (има ли го) и имейла (тръгна ли потвърждението). Всяка отговаря за около 200 ms.

Тук мрежата е Network. call чака 200 ms и връща отговора на услугата. Програмата пита трите една след друга:

const net = new Network();
const payment = await net.call("плащане", "прието");
const stock = await net.call("склад", "има");
const email = await net.call("имейл", "изпратен");
console.log(payment);
console.log(stock);
console.log(email);
console.log(`Кръгове чакане: ${net.rounds}`);
Изход
плащане: прието
склад: има
имейл: изпратен
Кръгове чакане: 3

Всяко питане започва, след като предишното е свършило. Клиентът чака сбора: около 600 ms. А през цялото време процесорът не прави нищо: само чака отговор.

Network брои и кръговете чакане: нов кръг започва, когато програмата пита, а никой друг отговор не се чака. Тук са три, по един за всяко питане. В урока бързината се мери с този брой, а не с милисекунди: времето зависи от машината, а броят кръгове е един и същ при всяко пускане.

По-точно, за любопитните

Така работи Network. sleep е setTimeout от node:timers/promises, както в урока „Async, await и нишки“. Нишката е една, затова броячите не искат защита.

class Network {
inFlight = 0;
rounds = 0;
// Пита услуга по мрежата: отговорът идва след ~200 ms.
async call(service, answer) {
if (this.inFlight === 0) this.rounds++; // никой не чака: нов кръг
this.inFlight++;
await sleep(200);
this.inFlight--;
return `${service}: ${answer}`;
}
}

Идеята

Работа, която чака, и работа, която смята

Работата на една програма е от два вида.

  • Работа, която чака (I/O-bound): заявка към банката, заявка към базата, четене на файл, изпращане на имейл. Програмата пита и стои, докато отговорът дойде. Процесорът през това време е свободен.
  • Работа, която смята (CPU-bound): 1000 фактури в PDF, умалени снимки на продуктите, отчет от милион поръчки. Тук процесорът е зает през цялото време.
В JavaScript работата, която чака, изобщо не иска нишки. Node оставя чакането на системата и продължава с друг код (урокът „Async, await и нишки“). Работата, която смята, държи единствената нишка. За нея трябват истински нишки, Worker-и, на различни ядра.

Затова първият въпрос пред бавна програма е „какво чака тя?“. Отговорът определя какво помага.

Виж го

Трите наведнъж

Пускаш трите питания, без да чакаш, и чакаш трите обещания заедно с Promise.all (урокът „Async, await и нишки“). Нишка не трябва:
const net = new Network();
const [payment, stock, email] = await Promise.all([
net.call("плащане", "прието"),
net.call("склад", "има"),
net.call("имейл", "изпратен"),
]);
console.log(payment);
console.log(stock);
console.log(email);
console.log(`Кръгове чакане: ${net.rounds}`);
Изход
плащане: прието
склад: има
имейл: изпратен
Кръгове чакане: 1

const net = new Network();
const [payment, stock, email] = await Promise.all([
net.call("плащане", "прието"),
net.call("склад", "има"),
net.call("имейл", "изпратен"),
]);
console.log(payment);
console.log(stock);
console.log(email);
console.log(`Кръгове чакане: ${net.rounds}`);
Стъпка по стъпка, с думи
  1. Поръчката иска три отговора по мрежата: плащане, склад и имейл за потвърждение. Всеки чака около 200 ms.
  2. Едно след друго: нишката пита за плащането и не прави нищо, докато отговорът дойде.
  3. Чак тогава пита за склада. Още 200 ms чакане.
  4. Чак тогава пита за имейла. Още 200 ms чакане.
  5. 3 кръга чакане: клиентът чака около 600 ms, а процесорът през цялото време стои.
  6. Наведнъж: трите въпроса тръгват заедно, всеки в своя нишка, и трите чакания се застъпват.
  7. Програмата чака отговорите. Докато дойде първият, другите два също са готови.
  8. 1 кръг чакане: около 200 ms, колкото най-бавният отговор. Същите отговори, същият ред, една трета от чакането.

Отговорите са същите и в същия ред. Кръгът чакане е един: трите въпроса тръгват, преди някой отговор да е дошъл. Клиентът чака около 200 ms, колкото най-бавната услуга, вместо 600.

Трите таймера изтичат в реда, в който са пуснати, а Node вика продълженията едно по едно. Всичко е в една нишка.
По-точно, за любопитните
Как точно Node редува чаканията и къде е капанът между два await, е урокът „async не е нишка“.

Пробвай

Имейлът чака плащането

Колега преправя поръчката: имейлът трябва да казва дали е платено и дали го има, затова тръгва чак когато плащането и складът са отговорили.

const net = new Network();
const [payment, stock] = await Promise.all([
net.call("плащане", "прието"),
net.call("склад", "има"),
]);
// имейлът казва дали е платено и дали го има
console.log(payment);
console.log(stock);
console.log(await net.call("имейл", "изпратен"));
console.log(`Кръгове чакане: ${net.rounds}`);
Отговор

Пробвай

Чакай отговора

Какво чака обещанието на плащането?

const payment = net.call("плащане", "прието");
console.log( payment);

Проблем

Хиляда фактури в края на месеца

В края на месеца магазинът прави фактура в PDF за всяка от 1000 поръчки. Тук нищо не чака мрежата: процесорът смята през цялото време. render замества рисуването на PDF със сметка, която само товари процесора и връща контролна сума:

// Рисува фактурата (тук: сметка, която само товари процесора).
function render(invoice) {
let h = invoice;
for (let i = 0; i < 2000; i++) {
h = (h * 31 + i) % 1_000_003;
}
return h;
}
// Фактурите от from до to включително.
function renderRange(from, to) {
let sum = 0;
for (let n = from; n <= to; n++) {
sum += render(n);
}
return sum;
}
const total = renderRange(1, 1000);
console.log("Фактури: 1000");
console.log(`Контролна сума: ${total}`);
Изход
Фактури: 1000
Контролна сума: 499409728

Едно ядро смята всичко, а останалите стоят. Кръгове чакане тук няма: нищо не чака.

Виж го

Четири Worker-а, четири ядра

Фактурите се делят на 4 части по 250, а всяка част отива в свой Worker: истинска нишка на свое ядро. Worker-ът връща сумата на частта си с postMessage, а главната нишка я получава с once(worker, "message"):
if (isMainThread) {
const workers = [0, 1, 2, 3].map((p) => {
const from = p * 250 + 1;
const range = { from, to: from + 249 };
return new Worker(new URL(import.meta.url), { workerData: range });
});
const parts = await Promise.all(
workers.map(async (worker) => (await once(worker, "message"))[0]),
);
const total = parts.reduce((sum, part) => sum + part, 0);
console.log("Фактури: 1000");
console.log(`Контролна сума: ${total}`);
} else {
parentPort.postMessage(renderRange(workerData.from, workerData.to));
}
Изход
Фактури: 1000
Контролна сума: 499409728

Контролната сума е същата: разделянето не сменя резултата, само кой го смята.

Колко по-бързо е, зависи от машината. На моя лаптоп с 250 пъти по-тежка сметка на фактура (500 000 стъпки вместо 2000) едно ядро смяташе около 1,9 s, а четири около 0,5 s.

Това са примерни времена, не обещание. Повече части от ядрата не помагат: те само се редуват на същите ядра.

По-точно, за любопитните
  • Колко нишки. За работа, която смята, колкото ядрата: os.availableParallelism() казва колко са. За работа, която чака, може много повече: те само спят.
  • Не всичко се дели. Ако една десета от работата не може да се раздели (например събирането на частите или записът на един файл), никакъв брой ядра не я прави повече от 10 пъти по-бърза. Това е законът на Amdahl.
  • Worker-ите не делят обикновени обекти: workerData и postMessage ги копират. Обща памет има само в SharedArrayBuffer, а там идват проблемите от следващия урок.

Идеята

Кое помага

Работа, която чака Работа, която смята
Какво я бави отговорът по мрежата, диска, базата самото смятане
Помага да чакаш няколко неща наведнъж повече ядра
В JavaScript Promise.all, без нишки Worker нишки
На едно ядро помага не помага

Нишките идват с цена. Докато всяка работи със своите данни (своята услуга, своите 250 фактури), всичко е наред. Щом две нишки пипат едно и също (общ брояч, наличността в склада), започват грешки, които не се виждат при всяко пускане. Това е следващият урок: „Race condition“.

Упражнение

Страницата на продукта

Реши задачата в редактора си.

Страницата на продукта. Преди да покаже страницата, сървърът пита три услуги: цената, отзивите и доставката. Всяка отговаря за около 200 ms и не зависи от другите. Ползвай Network от първата карта и ги питай наведнъж, така че кръгът чакане да е един:

Изход
цена: 89 €
отзиви: 4,6 от 5
доставка: утре
Кръгове чакане: 1

Още задачи

  • Пет услуги. Добави наличност и отстъпка. Колко са кръговете, ако отстъпката зависи от цената?
  • Колко ядра. Пусни фактурите с 1, 2, 4 и 8 части и мери времето при теб. Кога спира да става по-бързо? Сравни с броя ядра на машината си. - Грешният инструмент. Пусни 1000-те фактури с Promise.all и async функция вместо с Worker-и. Става ли по-бързо? Защо?

Тест

Провери се

1. Поръчката чака три услуги по 200 ms, които не зависят една от друга. Колко чака клиентът, ако ги питаш наведнъж?
2. Как JavaScript пита три услуги наведнъж?
3. Трябва да сметнеш 1000 фактури в Node на 4 ядра. Какво ги ускорява?
4. Защо урокът брои кръговете чакане, а не милисекундите?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·