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

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
Чакаха наведнъж: 3
Всяко call върви до await sleep(ms) и връща обещание. Promise.all чака и трите.

„Чакаха наведнъж: 3“: и трите заявки чакат едновременно, а нишката е само една. Всичко отнема около 300 ms (най-дългата), а не 600 (сборът). Нишката не е чакала нито една от тях.

Пробвай

Колко чакат наведнъж

Същото call, но първата заявка се чака сама:

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

await call("плащане", 300);
await Promise.all([call("имейл", 100), call("склад", 200)]);
console.log(`Чакаха наведнъж: ${most}`);
Отговор

Виж го

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++;
}
Стъпка по стъпка, с думи
  1. Остава една бройка. Две поръчки идват в един и същи момент: на Ана и на Борис.
  2. checkout на Ана чете наличността: left е 1. На await пуска нишката, докато банката отговори.
  3. Нишката е свободна, затова върви checkout на Борис. И той чете 1: Ана още не е записала нищо.
  4. Банката отговаря на Ана. Кодът ѝ продължава след await: stock = left - 1 = 0, sold = 1. Дотук е вярно.
  5. Борис продължава със старото 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, наличност: 0
Между if (stock === 0) и stock-- няма await, затова никоя друга поръчка не може да влезе между тях. Борис вижда 0 и си тръгва.

Ако плащането се провали, бройката трябва да се върне. Това е упражнението накрая.

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

Понякога цялото парче трябва да е заключено заедно с чакането: например само една поръчка наведнъж да пише в стар склад, който не понася две. Тогава трябва заключване, което разбира от await:

В Node няма вграден. Пише се с обещание (опашка от чакащи) или се взима пакетът 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 €

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

Идеята

Не блокирай 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();
}
Изход
Ана: страницата е готова
Отчетът е готов
Отчетът се прави в Worker: същият файл, в който isMainThread е false. Главната нишка само чака събитието exit. Страницата е готова след 50 ms, а отчетът след 300.

Пробвай

Попълни

Пусни трите заявки наведнъж и ги чакай заедно:

await ([
  call("плащане", 300),
  call("имейл", 100),
  call("склад", 200),
]);
console.log(`Чакаха наведнъж: ${most}`);

Упражнение

Запази, плати, върни

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

Остават две бройки. Четирима купувачи поръчват наведнъж: Ана, Борис, Вера и Галя. Плащането отнема 100 ms. Картата на Борис е отказана: плащането хвърля изключение с текст „картата е отказана“.

checkout запазва бройката преди await, после плаща. Ако плащането падне, бройката се връща. Отпечатай резултата на всеки купувач по реда на поръчките, а накрая наличността и продадените:

Изход
Ана: платено
Борис: картата е отказана, бройката е върната
Вера: няма наличност
Галя: няма наличност
Наличност: 1, продадени: 1

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

Още задачи

  • Опит отново. Ако купувач няма наличност, но друг ще върне бройка, нека чака до 200 ms и опита пак.
  • Таймаут. Банката понякога не отговаря. Ако плащането отнеме над 1 s, откажи поръчката и върни бройката. (AbortSignal.timeout)
  • Отчет. Сметни сбора на продажбите (по 25 €) и го отпечатай накрая.

Тест

Провери се

1. В колко нишки върви твоят код в JavaScript (Node) и в asyncio на Python?
2. Три заявки чакат 100, 200 и 300 ms. Колко време общо, ако ги чакаш една по една с await, и колко с Promise.all, gather, Task.WhenAll или allOf?
3. В JavaScript и Python async функция чете общите данни, после прави await, после записва. Кога друг код може да ги промени?
4. В обработката на заявка има сметка, която отнема 2 секунди, без нито един await. Какво става с другите заявки в Node или asyncio?
5. Как да не се продаде последната бройка два пъти?

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