async не е нишка
Накратко: async и await в JavaScript работят в една нишка с event loop: await пуска нишката, докато чака, затова race между две чакания е възможен и без нишки.
Проблем
Последната бройка, продадена два пъти
Разпродажбата е в разгара си. От едни слушалки остава една бройка. checkout проверява наличността, чака банката да потвърди плащането и чак тогава намалява наличността. Двама купувачи натискат „Купи“ в един и същи момент:
// Банката потвърждава плащането за 100 ms.async function pay(buyer) { await sleep(100);}
let stock = 1;let sold = 0;
async function checkout(buyer) { const left = stock; if (left === 0) return; await pay(buyer); stock = left - 1; sold++;}
await Promise.all([checkout("Ана"), checkout("Борис")]);console.log(`Продадени: ${sold}, наличност: ${stock}`);Продадени: 2, наличност: 0Продадени са две, а бройката е една. Единият купувач ще получи имейл „Поръчката ви е изпратена“ за слушалки, които ги няма.
В JavaScript това изглежда невъзможно: кодът върви в една нишка. В урока за race condition две нишки се сблъскваха на count++. Тук втора нишка няма.
Изходът е един и същ при всяко пускане: това не е лош късмет, а редът, в който върви кодът. За да го видим, трябва да знаем какво прави await.
Идеята
Една нишка и много чакания
Повечето работа в онлайн магазин е работа, която чака (виж работа, която чака, и работа, която смята): плащането чака банката, имейлът чака пощенския сървър, наличността чака склада. Нишка, която стои и чака, е похабена.
Event loop е една нишка и опашка от готови парчета код. Нишката взима следващото парче и го изпълнява докрай. Когато кодът стигне до чакане, той не държи нишката: казва „събуди ме, когато банката отговори“ и я пуска. Нишката взима следващото готово парче. Когато банката отговори, остатъкът от функцията (продължението) влиза в опашката и чака своя ред.
В Node event loop-ът е вграден. await е точно това място: функцията спира, нишката е свободна, а продължението ще влезе в опашката, когато обещанието е готово. Таймерите, мрежата и файловете чакат извън твоя JavaScript.
Три заявки към три услуги, пуснати наведнъж:
let waiting = 0;let most = 0;
async function call(name, ms) { waiting++; most = Math.max(most, waiting); await sleep(ms); waiting--; console.log(`${name}: готово след ${ms} ms`);}
await Promise.all([ call("плащане", 300), call("имейл", 100), call("склад", 200),]);console.log(`Чакаха наведнъж: ${most}`);имейл: готово след 100 msсклад: готово след 200 msплащане: готово след 300 msЧакаха наведнъж: 3call върви до await sleep(ms) и връща обещание. Promise.all чака и трите.„Чакаха наведнъж: 3“: и трите заявки чакат едновременно, а нишката е само една. Всичко отнема около 300 ms (най-дългата), а не 600 (сборът). Нишката не е чакала нито една от тях.
Пробвай
Колко чакат наведнъж
Същото call, но първата заявка се чака сама:
Какво ще отпечата програмата?
await call("плащане", 300);await Promise.all([call("имейл", 100), call("склад", 200)]);console.log(`Чакаха наведнъж: ${most}`);await call(...) чака плащането докрай. Чак после Promise.all пуска другите две наведнъж, затова най-много са 2. Цялото отнема около 500 ms.await веднага след извикването значи „чакай това, преди да продължиш“. За да чакат заедно, първо ги пусни всичките, после чакай.
Виж го
Race между два await-а
Обратно към последната бройка. Ето как се преплитат двете поръчки:
let stock = 1;let sold = 0;
async function checkout(buyer) { const left = stock; if (left === 0) return; await pay(buyer); stock = left - 1; sold++;}Стъпка по стъпка, с думи
- Остава една бройка. Две поръчки идват в един и същи момент: на Ана и на Борис.
- checkout на Ана чете наличността: left е 1. На await пуска нишката, докато банката отговори.
- Нишката е свободна, затова върви checkout на Борис. И той чете 1: Ана още не е записала нищо.
- Банката отговаря на Ана. Кодът ѝ продължава след await: stock = left - 1 = 0, sold = 1. Дотук е вярно.
- Борис продължава със старото left = 1: пак stock = 0, sold = 2. Една бройка, две продажби, и то с една нишка.
Между две await кодът върви без прекъсване. Но на await функцията пуска нишката, и през това време може да върви всичко друго. Когато продължи, светът може да е друг: left е стойност, прочетена преди чакането.
Това е същото „прочети, промени, запиши“ като при нишките, само че прекъсването не е където и да е, а точно на await. Затова и изходът е един и същ при всяко пускане: прекъсването не е случайно.
Идеята
Поправката: запази бройката преди await
Проверката и намаляването трябва да са едно парче код без await между тях. Поръчката запазва бройката веднага и чак тогава чака банката:
let stock = 1;let sold = 0;
async function checkout(buyer) { if (stock === 0) return; stock--; // запази бройката преди await await pay(buyer); sold++;}Продадени: 1, наличност: 0if (stock === 0) и stock-- няма await, затова никоя друга поръчка не може да влезе между тях. Борис вижда 0 и си тръгва.Ако плащането се провали, бройката трябва да се върне. Това е упражнението накрая.
По-точно, за любопитните
Понякога цялото парче трябва да е заключено заедно с чакането: например само една поръчка наведнъж да пише в стар склад, който не понася две. Тогава трябва заключване, което разбира от await:
async-mutex.Как е в другите езици: в JavaScript и в asyncio нишката е една и парчето между две await върви само. В C# продълженията са в басейна от нишки, затова там трябва и lock. Java няма await: там същото е с CompletableFuture, а от Java 21 и с виртуални нишки, където пишеш обикновен код, който блокира, а JVM-ът сам сваля нишката, докато тя чака.
Пробвай
Намери грешката
Количката събира цените на три поръчки. Цената идва от склада с await. Трите поръчки струват по 25 €, а сборът излиза 25:
let total = 0;
async function addOrder(id) { total += await priceOf(id);}
await Promise.all([addOrder(1), addOrder(2), addOrder(3)]);console.log(`Общо: ${total} €`);Общо: 25 €Избери реда с грешката.
total += await priceOf(id) е total = total + await priceOf(id): JavaScript прочита total преди await. И трите прочитат 0 и записват 0 + 25. Първо чакай, после събирай: const price = await priceOf(id); total += price;.Грешката не е в нишките, а в реда: стойност, прочетена преди await, е стара след него.
Идеята
Не блокирай event loop-а
Event loop-ът работи, само ако никое парче код не държи нишката дълго. Отчетът за продажбите се прави с цикъл, който смята 300 ms. В същото време купувач отваря страница на продукт, която чака склада 50 ms:
function buildReport() { const end = Date.now() + 300; // смята 300 ms, без нито един await while (Date.now() < end) {}}
async function report() { buildReport(); console.log("Отчетът е готов");}
async function productPage(buyer, start) { await sleep(50); console.log(`${buyer} чака над 0,25 s: ${Date.now() - start > 250}`);}
const start = Date.now();await Promise.all([report(), productPage("Ана", start)]);Отчетът е готовАна чака над 0,25 s: trueСтраницата е чакала над 0,25 s вместо 50 ms. Докато отчетът държи нишката, никое друго продължение не може да върви: нито тази страница, нито плащания, нито таймери. На сървър с хиляди купувачи всички замръзват едновременно.
Поправката е тежката работа да не е в нишката на event loop-а. Тя отива в пул от нишки или Worker, а event loop-ът само чака резултата:
async function report() { const worker = new Worker(new URL(import.meta.url)); await once(worker, "exit"); console.log("Отчетът е готов");}
async function productPage(buyer) { await sleep(50); console.log(`${buyer}: страницата е готова`);}
if (isMainThread) { await Promise.all([report(), productPage("Ана")]);} else { buildReport();}Ана: страницата е готоваОтчетът е готовisMainThread е false. Главната нишка само чака събитието exit. Страницата е готова след 50 ms, а отчетът след 300.Пробвай
Попълни
Пусни трите заявки наведнъж и ги чакай заедно:
await ([
call("плащане", 300),
call("имейл", 100),
call("склад", 200),
]);
console.log(`Чакаха наведнъж: ${most}`);Promise.all чака всички обещания. Трите заявки вече чакат заедно, затова цялото отнема колкото най-дългата.
Упражнение
Запази, плати, върни
Реши задачата в редактора си.
Остават две бройки. Четирима купувачи поръчват наведнъж: Ана, Борис, Вера и Галя. Плащането отнема 100 ms. Картата на Борис е отказана: плащането хвърля изключение с текст „картата е отказана“.
checkout запазва бройката преди await, после плаща. Ако плащането падне, бройката се връща. Отпечатай резултата на всеки купувач по реда на поръчките, а накрая наличността и продадените:
Ана: платеноБорис: картата е отказана, бройката е върнатаВера: няма наличностГаля: няма наличностНаличност: 1, продадени: 1Вера и Галя виждат „няма наличност“, защото бройката на Борис още е запазена, докато банката отговори. Това е честно: върнатата бройка ще купи следващият.
Още задачи
- Опит отново. Ако купувач няма наличност, но друг ще върне бройка, нека чака до 200 ms и опита пак.
- Таймаут. Банката понякога не отговаря. Ако плащането отнеме над 1 s, откажи поръчката и върни бройката. (AbortSignal.timeout)
- Отчет. Сметни сбора на продажбите (по 25 €) и го отпечатай накрая.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Защо нишки · Race condition · Заключване · Пул от нишки и задачи
- Продължава в: Как се тества конкурентен код
- Задачи по темата: Нишки и race conditions