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

Async, await и нишки

Проблем

Три отчета, един по един

Програмата сваля три месечни отчета от сървъра. Тук свалянето е пауза: await sleep(ms) чака толкова милисекунди. sleep е setTimeout от node:timers/promises, а async пред функцията позволява await в нея.
reports.js
import { setTimeout as sleep } from "node:timers/promises";
async function download(file, ms) {
await sleep(ms);
console.log(`Свалих ${file}`);
}
await download("март.pdf", 600);
await download("април.pdf", 200);
await download("май.pdf", 400);
console.log("Готово");
Изход
Свалих март.pdf
Свалих април.pdf
Свалих май.pdf
Готово
Паузите са 0,6, 0,2 и 0,4 секунди, общо 1,2 секунди. Докато чака, Node не е заключен: ще видим, че може да върши и друго. Но всяко await чака предишното сваляне да свърши, преди да започне следващото. А трите отчета не зависят един от друг и спокойно могат да се свалят едновременно.

Идеята

Процес и нишка

Когато пуснеш програмата, операционната система ѝ дава процес: своя памет, свои отворени файлове. Вътре в процеса работят една или повече нишки (threads). Нишката е поредица от команди, които се изпълняват една след друга. Досега всяка твоя програма имаше една нишка: тази, която изпълнява файла отгоре надолу.

Нишките в един процес делят паметта си: виждат едни и същи обекти. Това ги прави удобни (лесно си предават данни) и опасни (могат да се объркат взаимно, ще го видим след малко).

  • Паралелно значи наистина едновременно, на различни ядра на процесора.
  • Конкурентно значи на едно ядро, като се редуват толкова бързо, че изглежда едновременно.

За програмиста разликата рядко има значение: и в двата случая не знаеш коя нишка кога ще продължи.

JavaScript е различен. Node изпълнява твоя код в една нишка. Когато кодът чака (таймер, мрежа, файл), Node не стои: оставя чакането на системата и продължава с друг код. Когато резултатът е готов, функцията, която го чака, влиза в опашка. Това е цикълът на събитията (event loop): конкурентно, в една нишка.

Цената: докато твоят код смята, без да чака, нищо друго не може да тръгне. Таймерът тук е за 0 ms, но излиза чак след цикъла:

setTimeout(() => console.log("таймерът"), 0);
const end = Date.now() + 200;
while (Date.now() < end) {
// 0,2 s смятане, без нито едно await
}
console.log("цикълът свърши");
Изход
цикълът свърши
таймерът

За истински паралелни сметки Node има Worker нишки (node:worker_threads): всяка със своя памет. Ще ги видим в средата на урока.

По-точно, за любопитните
  • Браузърът Chrome пуска отделен процес за всеки раздел. Затова един увиснал сайт не спира останалите, но и затова браузърът ползва много памет.
  • Нишки ползва всяка програма, която прави няколко неща наведнъж: играта пуска звука в една нишка, а логиката в друга; уеб сървърът обслужва всеки клиент в своя нишка.
  • Цената: всяка нишка има свой стек и паметта за нея се заделя отделно. Хиляди нишки струват. Затова в истински програми се ползва басейн от нишки (последната идея в урока).

Виж го

Първото обещание

download е async функция. Когато я извикаш без await, тя тръгва и веднага връща обещание (Promise): обект, който казва „резултатът ще дойде“. Програмата продължава:

const worker = download("април.pdf", 200);
console.log("Програмата не чака");
await worker;
console.log("Отчетът е свален");
Изход
Програмата не чака
Свалих април.pdf
Отчетът е свален
  • Извикването без await пуска свалянето и се връща веднага, затова „Програмата не чака“ се отпечатва първо.
  • await worker чака обещанието да се изпълни. Това е като join() при нишките в други езици.

Нишка тук няма: свалянето е таймер, а Node вика продължението на download, когато таймерът изтече.

По-точно, за любопитните
await е най-новият начин да кажеш „направи това, когато е готово“. В стария код ще срещнеш и другите два:
setTimeout(() => {
console.log("1. функция, която Node вика после (callback)");
}, 0);
await sleep(50);
sleep(50).then(() => console.log("2. then на обещание"));
await sleep(100);
await sleep(50);
console.log("3. await");
Изход
1. функция, която Node вика после (callback)
2. then на обещание
3. await
Callback е функция, която подаваш, за да я извика Node после. then закача такава функция за обещание. await прави същото, но кодът се чете отгоре надолу, затова днес се пише така.

Виж го

Три наведнъж

Сега пускаме трите сваляния, без да чакаме, и чакаме трите обещания заедно с Promise.all:
const start = Date.now();
await Promise.all([
download("март.pdf", 600),
download("април.pdf", 200),
download("май.pdf", 400),
]);
const ms = Date.now() - start;
console.log(`Готово за под секунда: ${ms < 1000}`);
Изход
Свалих април.pdf
Свалих май.pdf
Свалих март.pdf
Готово за под секунда: true

Свалянията тръгват в реда март, април, май, но свършват по реда на паузите: април (0,2 s), май (0,4 s), март (0,6 s). Цялото чакане е колкото най-дългото, 0,6 секунди вместо 1,2.

Promise.all чака всички обещания и дава масив с резултатите им. Нишката е една: таймерите изтичат в своя ред и Node вика продълженията едно по едно.

Пробвай

await в цикъл

Какво ще отпечата програмата?

const start = Date.now();
for (const [file, ms] of [["март.pdf", 600], ["април.pdf", 200]]) {
await download(file, ms);
}
console.log(`Отне над 0,7 s: ${Date.now() - start > 700}`);
Отговор

Идеята

Животът на обещанието

Обещанието е в едно от три състояния. В кое е, не може да се попита с код, но inspect от node:util го показва, както и console.log:
const report = sleep(500, "май.pdf");
console.log(inspect(report));
await report;
console.log(inspect(report));
const broken = Promise.reject(new Error("няма връзка"));
try {
await broken;
} catch (error) {
console.log(`Грешка: ${error.message}`);
}
Изход
Promise { <pending> }
Promise { 'май.pdf' }
Грешка: няма връзка
Състояние Кога
pending (чака) работата още не е свършила
fulfilled (изпълнено) има резултат: await го връща
rejected (отхвърлено) има грешка: await я хвърля, а try/catch я хваща

Обещанието сменя състоянието си само веднъж. След това await на него връща същия резултат веднага.

По-точно, за любопитните
  • Таймер или заявка се отказва с AbortController: подаваш signal на sleep или на fetch, а abort() отхвърля обещанието с AbortError.
  • Promise.allSettled чака всички обещания, дори някое да е отхвърлено, а Promise.race дава първото готово.

Проблем

Загубените посещения

Сайтът брои посещенията. Броячът е в SharedArrayBuffer: памет, която виждат няколко Worker-а. Int32Array чете и пише в нея цели числа. Два Worker-а обслужват посетители и всеки увеличава брояча 1 000 000 пъти. new Worker(new URL(import.meta.url)) пуска същия файл в нова нишка, а там isMainThread е false:
visits.js
import { Worker, isMainThread, workerData } from "node:worker_threads";
import { once } from "node:events";
if (isMainThread) {
const visits = new Int32Array(new SharedArrayBuffer(4));
const pages = [1, 2].map(() => new Worker(new URL(import.meta.url), { workerData: visits }));
await Promise.all(pages.map((page) => once(page, "exit")));
console.log(`Посещения: ${visits[0]}`);
} else {
for (let i = 0; i < 1_000_000; i++) {
workerData[0]++;
}
}

Очакваш 2 000 000. Пет пускания:

Изход (всеки път различен)
Посещения: 1091916
Посещения: 1063068
Посещения: 1072106
Посещения: 1119773
Посещения: 1080911

Всеки път по-малко и всеки път различно. Причината е, че workerData[0]++ не е една стъпка, а три:

let value = workerData[0]; // 1. прочети
value = value + 1; // 2. добави 1
workerData[0] = value; // 3. запиши

// counts е Int32Array в SharedArrayBuffer: обща памет за двата Worker-а.
function increment(counts) {
counts[0]++; // прочети, добави 1, запиши
}
function safeIncrement(counts) {
Atomics.add(counts, 0, 1);
}
Стъпка по стъпка, с думи
  1. counts[0] е 5. Worker A и Worker B викат increment() едновременно.
  2. A прочита count: 5.
  3. B прочита count, преди A да е записала: също 5.
  4. A добавя 1 в своето копие и записва 6.
  5. B също записва 6. Две извиквания, а count е 6, не 7: едното добавяне е загубено.
  6. Отначало, с Atomics.add: A прочита 5 и записва 6 с една неделима стъпка.
  7. B стига до Atomics.add и чака, докато стъпката на A свърши.
  8. B прочита 6 и записва 7: нищо не е загубено.

Когато двете нишки прочетат една и съща стойност, преди някоя да е записала, едното добавяне се губи. Това се казва състезание (race condition). Дали ще стане, зависи от това кога операционната система сменя нишките, затова резултатът е различен при всяко пускане.

Идеята

Atomics: трите стъпки наведнъж

Решението е трите стъпки да станат една, която никой не може да прекъсне. Това прави Atomics.add (атомарно, неделимо добавяне):
import { Worker, isMainThread, workerData } from "node:worker_threads";
import { once } from "node:events";
if (isMainThread) {
const visits = new Int32Array(new SharedArrayBuffer(4));
const pages = [1, 2].map(() => new Worker(new URL(import.meta.url), { workerData: visits }));
await Promise.all(pages.map((page) => once(page, "exit")));
console.log(`Посещения: ${visits[0]}`);
} else {
for (let i = 0; i < 1_000_000; i++) {
Atomics.add(workerData, 0, 1);
}
}
Изход
Посещения: 2000000

Atomics.add(масив, място, колко) прочита, добавя и записва като една стъпка. Двата Worker-а не могат да я разделят, затова резултатът е 2 000 000 при всяко пускане. Atomics.load чете стойност по същия сигурен начин. Ключалки като в Java няма: обикновените обекти и масиви не се делят между Worker-ите, а се копират.

По-точно, за любопитните
  • Производител и потребител в една нишка не иска ключалка: кухнята чака поръчка с await, а put изпълнява обещанието ѝ:
class OrderBox {
#orders = [];
#waiting = [];
put(order) {
const kitchen = this.#waiting.shift();
if (kitchen) {
kitchen(order);
} else {
this.#orders.push(order);
}
}
take() {
if (this.#orders.length > 0) {
return Promise.resolve(this.#orders.shift());
}
return new Promise((resolve) => this.#waiting.push(resolve));
}
}
const box = new OrderBox();
async function kitchen() {
for (let i = 0; i < 3; i++) {
console.log(`Кухнята прие ${await box.take()}`);
}
}
const cook = kitchen();
box.put("пица");
box.put("салата");
box.put("супа");
await cook;
Изход
Кухнята прие пица
Кухнята прие салата
Кухнята прие супа
  • Мъртва хватка (deadlock) между ключалки в една нишка няма, защото ключалки няма. Worker-ите могат да чакат един друг с Atomics.wait, и там е възможна, но това е рядко нужно.
  • Приоритети Worker-ите нямат.

Пробвай

Два банкомата

Двама души теглят по 80 € от една сметка със 100 € в един и същ момент. Банката потвърждава всяко теглене за 0,1 s. Програмата е в една нишка, а двамата получават парите и балансът става -60 €. На кой ред е грешката?

Избери реда с грешката.

Идеята

Басейн от задачи и Promise

Хиляда сваляния наведнъж претоварват сървъра. Басейнът ограничава колко задачи вървят едновременно: щом една свърши, следващата тръгва. В Node готов басейн за обещания няма, но е няколко реда: runLimited пуска limit „ленти“, а всяка взима следващата задача, щом свърши своята:
async function sizeOf(file, ms) {
await sleep(ms);
return file.length * 100;
}
async function runLimited(limit, jobs) {
const results = [];
let next = 0;
async function lane() {
while (next < jobs.length) {
const i = next++;
results[i] = await jobs[i]();
}
}
await Promise.all(Array.from({ length: limit }, lane));
return results;
}
const files = ["март.pdf", "април.pdf", "май.pdf", "юни.pdf"];
const sizes = await runLimited(2, files.map((file) => () => sizeOf(file, 300)));
files.forEach((file, i) => console.log(`${file}: ${sizes[i]} KB`));
Изход
март.pdf: 800 KB
април.pdf: 900 KB
май.pdf: 700 KB
юни.pdf: 700 KB
  • Задачата е функция, която връща обещание: () => sizeOf(file, 300). Така тя тръгва чак когато лентата я вземе.
  • Обещанието е Future на JavaScript: резултатът ще дойде, а await го чака.
  • runLimited връща масив с резултатите в реда на задачите, а не в реда на свършване.

За тежки сметки басейнът е от Worker-и. Него Node също не дава готов, а го дават библиотеки като Piscina.

По-точно, за любопитните
Ако задачата не връща нищо, чакаш само runLimited:
const reports = [1, 2, 3, 4, 5].map((i) => `отчет-${i}.pdf`);
await runLimited(2, reports.map((file) => () => download(file, 100)));
console.log("Всички свършиха.");
Изход
Свалих отчет-1.pdf
Свалих отчет-2.pdf
Свалих отчет-3.pdf
Свалих отчет-4.pdf
Свалих отчет-5.pdf
Всички свършиха.
Първите два отчета вървят едновременно, но в една нишка: таймерите им изтичат в реда, в който са пуснати, затова редът е един и същ при всяко пускане.

Пробвай

Кой резултат пръв?

„архив.zip“ се сваля 0,6 s, а „бележка.txt“ 0,1 s. Какво ще отпечата програмата?

const big = sizeOf("архив.zip", 600);
const small = sizeOf("бележка.txt", 100);
console.log(`Голям: ${await big}`);
console.log(`Малък: ${await small}`);
Отговор

Упражнение

Програми с async и Worker

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

1. Паралелна сума. Масивът sales има 1 000 000 продажби, sales[i] = i % 100, в Int32Array върху SharedArrayBuffer. Раздели го на 4 равни части и сумирай всяка в свой Worker. Подай на всеки в workerData масива, номера на частта и общия Float64Array partial. Всеки Worker пише в свое място в partial, затова не трябва Atomics. Изчакай ги със once(worker, "exit") и събери частите:

Изход
Части: [12375000, 12375000, 12375000, 12375000]
Общо: 49500000

2. Сваляне с басейн. Свали шест отчета (март.pdf, април.pdf, май.pdf, юни.pdf, юли.pdf, август.pdf) с runLimited, по 3 наведнъж. Ползвай sizeOf(file, 300) от картата за басейна. Отпечатай размерите по реда на файловете, сбора и дали всичко е станало за под 1,5 секунди (едно след друго би било 1,8 s):

Изход
март.pdf: 800 KB
април.pdf: 900 KB
май.pdf: 700 KB
юни.pdf: 700 KB
юли.pdf: 700 KB
август.pdf: 1000 KB
Общо: 4800 KB
Под 1,5 секунди: true

3. Брояч без загуби. Четири Worker-а, всеки с по 50 000 посещения, увеличават общ брояч в SharedArrayBuffer. Напиши го така, че резултатът да е винаги 200 000. Опитай първо с ++ и виж колко се губи (по-ясно се вижда с 1 000 000 на Worker), после поправи с Atomics.add.

Изход
Посещения: 200000

Още задачи

  • Тест за брояча. Напиши node:test тест, който пуска 8 Worker-а по 100 000 увеличения и проверява 800 000 с assert.equal. Пусни го срещу брояча с ++ няколко пъти. Тест, който пада понякога, е типичният знак за състезание.
  • Банкоматите. В програмата с двата банкомата сложи проверката и тегленето в отделна функция без await между тях и провери, че балансът остава 20 € и при пет банкомата.

Тест

Провери се

1. Какво прави const report = download("април.pdf", 200); без await?
2. Три сваляния са в for цикъл, всяко с await. Колко време отнемат?
3. Два Worker-а правят по 1 000 000 пъти workerData[0]++ върху общ SharedArrayBuffer. Какво ще е накрая?
4. Защо JavaScript код в една нишка не се нуждае от ключалки за обикновените си променливи?
5. Пускаш две задачи и чакаш първо await big, после await small. Малката свършва първа. Какво се печата първо?

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