Async, await и нишки
Проблем
Три отчета, един по един
await sleep(ms) чака толкова милисекунди. sleep е setTimeout от node:timers/promises, а async пред функцията позволява await в нея.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Готово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. awaitthen закача такава функция за обещание. await прави същото, но кодът се чете отгоре надолу, затова днес се пише така.Виж го
Три наведнъж
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}`);await в цикъла чака всяко сваляне да свърши, преди да пусне следващото. Затова март (0,6 s) е първи, а всичко отнема 0,8 s. Това е честа грешка: програмата работи, само че нищо не е едновременно. За едновременно пускаш всичките и чакаш с Promise.all.Идеята
Животът на обещанието
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: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. добави 1workerData[0] = value; // 3. запиши// counts е Int32Array в SharedArrayBuffer: обща памет за двата Worker-а.function increment(counts) { counts[0]++; // прочети, добави 1, запиши}
function safeIncrement(counts) { Atomics.add(counts, 0, 1);}Стъпка по стъпка, с думи
- counts[0] е 5. Worker A и Worker B викат increment() едновременно.
- A прочита count: 5.
- B прочита count, преди A да е записала: също 5.
- A добавя 1 в своето копие и записва 6.
- B също записва 6. Две извиквания, а count е 6, не 7: едното добавяне е загубено.
- Отначало, с Atomics.add: A прочита 5 и записва 6 с една неделима стъпка.
- B стига до Atomics.add и чака, докато стъпката на A свърши.
- 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); }}Посещения: 2000000Atomics.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-ите нямат.
Пробвай
Два банкомата
Избери реда с грешката.
await е между проверката и тегленето. Докато първият банкомат чака банката, вторият минава проверката: балансът още е 100. Нишки няма, а грешката е същата като при тях: проверката и промяната не са едно цяло. Поправката е първо да теглиш, после да чакаш:
class Account { balance = 100;
async withdraw(amount) { if (this.balance >= amount) { this.balance -= amount; await sleep(100); // банката потвърждава } }}Тогава вторият банкомат вижда 20 € и отказва. Балансът остава 20 €. В една нишка друг код може да се намеси само на await.
Идеята
Басейн от задачи и Promise
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}`);Малкият файл наистина е готов първи. Но await big е първият ред и чака големия. Когато той дойде, await small връща веднага, защото резултатът вече е там. Редът на изхода е редът на await, затова е един и същ при всяко пускане.
Упражнение
Програми с 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]Общо: 495000002. Сваляне с басейн. Свали шест отчета (март.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 секунди: true3. Брояч без загуби. Четири 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 € и при пет банкомата.
Тест