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

async не е нишка

Накратко: Асинхронният код не е нишка: докато чака, той пуска нишката, а продължението идва по-късно; в Java това е CompletableFuture, и race между две чакания е възможен и с една нишка.

Проблем

Последната бройка, продадена два пъти

Разпродажбата е в разгара си. От едни слушалки остава една бройка. checkout проверява наличността, чака банката да потвърди плащането и чак тогава намалява наличността. Двама купувачи натискат „Купи“ в един и същи момент:

// Една нишка играе event loop-а: продълженията вървят в нея едно по едно.
static final ExecutorService loop = Executors.newSingleThreadExecutor();
// after(ms): пуска задачата в loop след ms милисекунди.
static Executor after(long ms) {
return CompletableFuture.delayedExecutor(ms, TimeUnit.MILLISECONDS, loop);
}
// Банката потвърждава плащането за 100 ms.
static CompletableFuture<Void> pay(String buyer) {
return CompletableFuture.runAsync(() -> {}, after(100));
}
static int stock = 1;
static int sold = 0;
static CompletableFuture<Void> checkout(String buyer) {
int left = stock;
if (left == 0) {
return CompletableFuture.completedFuture(null);
}
return pay(buyer).thenRunAsync(() -> {
stock = left - 1;
sold++;
}, loop);
}
var first = checkout("Ана");
var second = checkout("Борис");
CompletableFuture.allOf(first, second).join();
System.out.println("Продадени: " + sold + ", наличност: " + stock);
Изход
Продадени: 2, наличност: 0

Продадени са две, а бройката е една. Единият купувач ще получи имейл „Поръчката ви е изпратена“ за слушалки, които ги няма.

Тук всичко върви в една нишка: loop е Executors.newSingleThreadExecutor(). В урока за race condition две нишки се сблъскваха на count++. Тук втора нишка няма.

Изходът е един и същ при всяко пускане: това не е лош късмет, а редът, в който върви кодът. За да го видим, трябва да знаем какво прави await.

Идеята

Една нишка и много чакания

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

Event loop е една нишка и опашка от готови парчета код. Нишката взима следващото парче и го изпълнява докрай. Когато кодът стигне до чакане, той не държи нишката: казва „събуди ме, когато банката отговори“ и я пуска. Нишката взима следващото готово парче. Когато банката отговори, остатъкът от функцията (продължението) влиза в опашката и чака своя ред.

Java няма async и await. Най-близкото е CompletableFuture: обещание за резултат, на което закачаш продължение с thenRunAsync или thenApplyAsync. В кой executor да върви продължението, казваш ти. Тук всички вървят в loop, executor с една нишка, който играе event loop-а. Така работят Netty и Vert.x, на които стоят много Java сървъри.

Три заявки към три услуги, пуснати наведнъж:

static int waiting = 0;
static int most = 0;
static CompletableFuture<Void> call(String name, int ms) {
waiting++;
most = Math.max(most, waiting);
return CompletableFuture.runAsync(() -> {
waiting--;
System.out.println(name + ": готово след " + ms + " ms");
}, after(ms));
}
CompletableFuture.allOf(
call("плащане", 300),
call("имейл", 100),
call("склад", 200)
).join();
System.out.println("Чакаха наведнъж: " + most);
Изход
имейл: готово след 100 ms
склад: готово след 200 ms
плащане: готово след 300 ms
Чакаха наведнъж: 3
after(ms) дава executor, който пуска задачата в loop след ms милисекунди: това е таймерът. call увеличава waiting и връща веднага. allOf(...).join() чака и трите.

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

Пробвай

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

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

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

call("плащане", 300).join();
CompletableFuture.allOf(call("имейл", 100), call("склад", 200)).join();
System.out.println("Чакаха наведнъж: " + most);
Отговор

Виж го

Race между два await-а

Обратно към последната бройка. Ето как се преплитат двете поръчки:

static int stock = 1;
static int sold = 0;
static CompletableFuture<Void> checkout(String buyer) {
int left = stock;
if (left == 0) {
return CompletableFuture.completedFuture(null);
}
return pay(buyer).thenRunAsync(() -> {
stock = left - 1;
sold++;
}, loop);
}
Стъпка по стъпка, с думи
  1. Остава една бройка. Две поръчки идват в един и същи момент: на Ана и на Борис.
  2. checkout на Ана чете наличността: left е 1. Остатъкът е в thenRunAsync: ще върви, когато банката отговори, а дотогава нишката е свободна.
  3. Нишката е свободна, затова върви checkout на Борис. И той чете 1: Ана още не е записала нищо.
  4. Банката отговаря на Ана. Кодът ѝ продължава след thenRunAsync: stock = left - 1 = 0, sold = 1. Дотук е вярно.
  5. Борис продължава със старото left = 1: пак stock = 0, sold = 2. Една бройка, две продажби, и то с една нишка.

Между две await (тук: между началото на метода и thenRunAsync) кодът върви без прекъсване. Но на await функцията пуска нишката, и през това време може да върви всичко друго. Когато продължи, светът може да е друг: left е стойност, прочетена преди чакането.

Това е същото „прочети, промени, запиши“ като при нишките, само че прекъсването не е където и да е, а точно на await. Затова и изходът е един и същ при всяко пускане: прекъсването не е случайно.

Идеята

Поправката: запази бройката преди await

Проверката и намаляването трябва да са едно парче код без await между тях. Поръчката запазва бройката веднага и чак тогава чака банката:

static int stock = 1;
static int sold = 0;
static CompletableFuture<Void> checkout(String buyer) {
if (stock == 0) {
return CompletableFuture.completedFuture(null);
}
stock--; // запази бройката преди await
return pay(buyer).thenRunAsync(() -> sold++, loop);
}
Изход
Продадени: 1, наличност: 0
Проверката и stock-- са преди pay(...) и вървят наведнъж. Тук checkout се вика от една нишка, затова стига. Ако го викаха много нишки (заявките на уеб сървър), трябва и synchronized около проверката и намаляването, както в урока за заключване.

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

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

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

В loop с една нишка всяко парче между две продължения и без това върви само. С много нишки се ползва Semaphore или опашка с една нишка за склада.

Как е в другите езици: в JavaScript и в asyncio нишката е една и парчето между две await върви само. В C# продълженията са в басейна от нишки, затова там трябва и lock. Java няма await: там същото е с CompletableFuture, а от Java 21 и с виртуални нишки, където пишеш обикновен код, който блокира, а JVM-ът сам сваля нишката, докато тя чака.

Пробвай

Намери грешката

Количката събира цените на три поръчки. Цената идва от склада с await. Трите поръчки струват по 25 €, а сборът излиза 25:

static int total = 0;
static CompletableFuture<Void> addOrder(int id) {
int before = total;
return priceOf(id).thenAcceptAsync(price -> {
total = before + price;
}, loop);
}
CompletableFuture.allOf(addOrder(1), addOrder(2), addOrder(3)).join();
System.out.println("Общо: " + total + " €");
Изход
Общо: 25 €

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

Идеята

Не блокирай event loop-а

Event loop-ът работи, само ако никое парче код не държи нишката дълго. Отчетът за продажбите се прави с Thread.sleep(300) в loop. В същото време купувач отваря страница на продукт, която чака склада 50 ms:

static void buildReport() throws InterruptedException {
Thread.sleep(300); // блокира: чака 300 ms без await
}
long start = System.nanoTime();
CompletableFuture<Void> report = CompletableFuture.runAsync(() -> {
try {
buildReport();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Отчетът е готов");
}, loop);
CompletableFuture<Void> page = CompletableFuture.runAsync(() -> {
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println("Ана чака над 0,25 s: " + (ms > 250));
}, after(50));
CompletableFuture.allOf(report, page).join();
Изход
Отчетът е готов
Ана чака над 0,25 s: true

Страницата е чакала над 0,25 s вместо 50 ms. Докато отчетът държи нишката, никое друго продължение не може да върви: нито тази страница, нито плащания, нито таймери. На сървър с хиляди купувачи всички замръзват едновременно.

Поправката е тежката работа да не е в нишката на event loop-а. Тя отива в пул от нишки, а event loop-ът само чака резултата:

static final ExecutorService workers = Executors.newFixedThreadPool(2);
static CompletableFuture<Void> report() {
return CompletableFuture.runAsync(() -> {
try {
buildReport();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, workers).thenRunAsync(() -> System.out.println("Отчетът е готов"), loop);
}
CompletableFuture<Void> page = CompletableFuture.runAsync(
() -> System.out.println("Ана: страницата е готова"), after(50));
CompletableFuture.allOf(report(), page).join();
workers.shutdown();
Изход
Ана: страницата е готова
Отчетът е готов
runAsync(..., workers) прави отчета в пула workers, а thenRunAsync(..., loop) връща продължението в loop. loop остава свободен и страницата е готова след 50 ms.

Пробвай

Попълни

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

(
    call("плащане", 300),
    call("имейл", 100),
    call("склад", 200)
).join();
System.out.println("Чакаха наведнъж: " + most);

Упражнение

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

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

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

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

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

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

Още задачи

  • Опит отново. Ако купувач няма наличност, но друг ще върне бройка, нека чака до 200 ms и опита пак.
  • Таймаут. Банката понякога не отговаря. Ако плащането отнеме над 1 s, откажи поръчката и върни бройката. (orTimeout)
  • Отчет. Сметни сбора на продажбите (по 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 за достъпно обучение по програмиране ·