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

async не е нишка

Накратко: async и await не създават нишка: await пуска нишката, докато чака, а кодът след него продължава в басейна от нишки, затова race между две чакания е възможен.

Проблем

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

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

int stock = 1;
int sold = 0;
async Task Checkout(string buyer)
{
int left = stock;
if (left == 0) return;
await Pay(buyer);
stock = left - 1;
Interlocked.Increment(ref sold);
}
await Task.WhenAll(Checkout("Ана"), Checkout("Борис"));
Console.WriteLine($"Продадени: {sold}, наличност: {stock}");
// Банката потвърждава плащането за 100 ms.
async Task Pay(string buyer)
{
await Task.Delay(100);
}
Изход
Продадени: 2, наличност: 0

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

Тук няма Thread и няма Parallel. В урока за race condition две нишки се сблъскваха на count++. Тук двете поръчки дори не са едновременно в процесора: и двете стоят и чакат банката.

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

Идеята

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

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

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

В C# await също пуска нишката, докато задачата чака. Но в конзолна програма и в ASP.NET Core няма една нишка за event loop. Продължението след await върви в басейна от нишки (урокът пул от нишки): в която нишка е свободна. Затова две продължения може да вървят наистина едновременно. В WPF и WinForms е иначе: там прозорецът има своя нишка с опашка от съобщения, и продължението се връща в нея, както при event loop.

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

int waiting = 0;
int most = 0;
async Task<string> Call(string name, int ms)
{
int now = Interlocked.Increment(ref waiting);
most = Math.Max(most, now);
await Task.Delay(ms);
Interlocked.Decrement(ref waiting);
return $"{name}: готово след {ms} ms";
}
string[] done = await Task.WhenAll(
Call("плащане", 300),
Call("имейл", 100),
Call("склад", 200));
foreach (string line in done)
{
Console.WriteLine(line);
}
Console.WriteLine($"Чакаха наведнъж: {most}");
Изход
плащане: готово след 300 ms
имейл: готово след 100 ms
склад: готово след 200 ms
Чакаха наведнъж: 3
Всяко Call върви до await Task.Delay(ms) и връща незавършена задача. Task.WhenAll чака и трите и дава резултатите по реда на задачите, затова и изходът е по него. waiting се намалява с Interlocked, защото продълженията са в басейна и може да вървят едновременно.

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

Пробвай

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

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

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

string first = await Call("плащане", 300);
string[] rest = await Task.WhenAll(Call("имейл", 100), Call("склад", 200));
Console.WriteLine($"Чакаха наведнъж: {most}");
Отговор

Виж го

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

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

int stock = 1;
int sold = 0;
async Task Checkout(string buyer)
{
int left = stock;
if (left == 0) return;
await Pay(buyer);
stock = left - 1;
Interlocked.Increment(ref 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 кодът върви без прекъсване. Но на await функцията пуска нишката, и през това време може да върви всичко друго. Когато продължи, светът може да е друг: left е стойност, прочетена преди чакането.

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

В C# има още нещо: продълженията на Ана и Борис вървят в басейна и може да вървят едновременно в две нишки. Тук банката отговаря на двамата по различно време, затова не се засичат. Но sold++ в две нишки наведнъж губи увеличения като в урока за race condition. Затова sold се увеличава с Interlocked.Increment.

Идеята

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

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

int stock = 1;
int sold = 0;
object sync = new object();
async Task Checkout(string buyer)
{
lock (sync)
{
if (stock == 0) return;
stock--; // запази бройката преди await
}
await Pay(buyer);
Interlocked.Increment(ref sold);
}
Изход
Продадени: 1, наличност: 0
Проверката и stock-- са в lock, защото Checkout може да се вика от няколко нишки наведнъж: заявките в уеб сървъра идват от басейна. await вътре в lock C# не позволява (грешка CS1996): заключването е на нишка, а след await продължението може да е в друга. Затова lock пази само парчето без await.

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

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

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

SemaphoreSlim(1, 1): await gate.WaitAsync() чака, без да държи нишка, а gate.Release() е във finally. Така се пази и парче с await вътре.

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

Пробвай

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

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

int total = 0;
async Task AddOrder(int id)
{
total += await PriceOf(id);
}
await Task.WhenAll(AddOrder(1), AddOrder(2), AddOrder(3));
Console.WriteLine($"Общо: {total} €");
Изход
Общо: 25 €

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

Идеята

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

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

var watch = Stopwatch.StartNew();
await Task.WhenAll(Report(), ProductPage("Ана"));
async Task Report()
{
Thread.Sleep(300); // блокира: чака 300 ms без await
Console.WriteLine("Отчетът е готов");
await Task.Delay(10);
}
async Task ProductPage(string buyer)
{
await Task.Delay(50);
Console.WriteLine($"{buyer} чака над 0,25 s: {watch.ElapsedMilliseconds > 250}");
}
Изход
Отчетът е готов
Ана чака над 0,25 s: True

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

В C# Thread.Sleep в async метод блокира нишката, която го изпълнява. Тук това е главната нишка, още преди първия await, затова ProductPage изобщо не е тръгнала. Синхронно чакане (Thread.Sleep, .Result, .Wait()) в async код е същата грешка: ползвай await Task.Delay и await. В WPF или WinForms приложение същото замръзва прозореца: бутоните не реагират, докато нишката на прозореца е заета.

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

await Task.WhenAll(Report(), ProductPage("Ана"));
async Task Report()
{
await Task.Run(() => Thread.Sleep(300));
Console.WriteLine("Отчетът е готов");
}
async Task ProductPage(string buyer)
{
await Task.Delay(50);
Console.WriteLine($"{buyer}: страницата е готова");
}
Изход
Ана: страницата е готова
Отчетът е готов
await Task.Run(...) праща работата в басейна и пуска нишката. Страницата е готова след 50 ms, а отчетът след 300.

Пробвай

Попълни

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

string[] done = await (
    Call("плащане", 300),
    Call("имейл", 100),
    Call("склад", 200));
foreach (string line in done)
{
    Console.WriteLine(line);
}
Console.WriteLine($"Чакаха наведнъж: {most}");

Упражнение

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

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

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

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

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

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

Още задачи

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