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Чакаха наведнъж: 3after(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);call(...).join() чака плащането докрай. Чак после allOf пуска другите две наведнъж, затова най-много са 2, а редът на изхода е редът на свършване. Цялото отнема около 500 ms.await веднага след извикването значи „чакай това, преди да продължиш“. За да чакат заедно, първо ги пусни всичките, после чакай.
Виж го
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);}Стъпка по стъпка, с думи
- Остава една бройка. Две поръчки идват в един и същи момент: на Ана и на Борис.
- checkout на Ана чете наличността: left е 1. Остатъкът е в thenRunAsync: ще върви, когато банката отговори, а дотогава нишката е свободна.
- Нишката е свободна, затова върви checkout на Борис. И той чете 1: Ана още не е записала нищо.
- Банката отговаря на Ана. Кодът ѝ продължава след thenRunAsync: stock = left - 1 = 0, sold = 1. Дотук е вярно.
- Борис продължава със старото 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, наличност: 0stock-- са преди pay(...) и вървят наведнъж. Тук checkout се вика от една нишка, затова стига. Ако го викаха много нишки (заявките на уеб сървър), трябва и synchronized около проверката и намаляването, както в урока за заключване.Ако плащането се провали, бройката трябва да се върне. Това е упражнението накрая.
По-точно, за любопитните
Понякога цялото парче трябва да е заключено заедно с чакането: например само една поръчка наведнъж да пише в стар склад, който не понася две. Тогава трябва заключване, което разбира от await:
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 €Избери реда с грешката.
int before = total чете сбора преди цената да дойде. И трите поръчки прочитат 0, а продълженията записват 0 + 25. Събирането трябва да е в продължението: priceOf(id).thenAcceptAsync(price -> total += price, loop).Грешката не е в нишките, а в реда: стойност, прочетена преди await, е стара след него.
Идеята
Не блокирай 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);CompletableFuture.allOf чака всички обещания. Трите заявки вече чакат заедно, затова цялото отнема колкото най-дългата.
Упражнение
Запази, плати, върни
Реши задачата в редактора си.
Остават две бройки. Четирима купувачи поръчват наведнъж: Ана, Борис, Вера и Галя. Плащането отнема 100 ms. Картата на Борис е отказана: плащането хвърля изключение с текст „картата е отказана“.
checkout запазва бройката преди await, после плаща. Ако плащането падне, бройката се връща. Отпечатай резултата на всеки купувач по реда на поръчките, а накрая наличността и продадените:
Ана: платеноБорис: картата е отказана, бройката е върнатаВера: няма наличностГаля: няма наличностНаличност: 1, продадени: 1Вера и Галя виждат „няма наличност“, защото бройката на Борис още е запазена, докато банката отговори. Това е честно: върнатата бройка ще купи следващият.
Още задачи
- Опит отново. Ако купувач няма наличност, но друг ще върне бройка, нека чака до 200 ms и опита пак.
- Таймаут. Банката понякога не отговаря. Ако плащането отнеме над 1 s, откажи поръчката и върни бройката. (orTimeout)
- Отчет. Сметни сбора на продажбите (по 25 €) и го отпечатай накрая.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Защо нишки · Race condition · Заключване · Пул от нишки и задачи
- Продължава в: Как се тества конкурентен код
- Задачи по темата: Нишки и race conditions