Race condition
Накратко: Race condition е бъг, при който резултатът зависи от реда на изпълнение: две Worker нишки губят продажба, а await между проверката и покупката пуска двама купувачи дори в една нишка.
Проблем
Разпродажбата брои криво
Петък, разпродажба. Слушалките са на половин цена и поръчките валят. Две каси продават от един склад: всяка продажба вади 1 от наличността.
Касите са два Worker-а (урокът „Async, await и нишки“). Наличността е вSharedArrayBuffer, обща памет, която двата виждат. Всеки продава по 1 000 000 бройки:// stock е Int32Array в SharedArrayBuffer:// обща памет за Worker-ите.function sell(stock) { stock[0]--; // прочети, извади 1, запиши}if (isMainThread) { const stock = new Int32Array(new SharedArrayBuffer(4)); stock[0] = 2_000_000; const checkouts = [1, 2].map( () => new Worker(new URL(import.meta.url), { workerData: stock }), ); await Promise.all(checkouts.map((worker) => once(worker, "exit"))); console.log(`Поръчки: ${2 * 1_000_000}`); console.log("Продадени според склада:"); console.log(2_000_000 - stock[0]);} else { for (let i = 0; i < 1_000_000; i++) { sell(workerData); }}Клиентите са платили 2 000 000 поръчки. Ето колко продадени брои складът в пет пускания:
Продадени според склада: 2000000Продадени според склада: 1003506Продадени според склада: 994022Продадени според склада: 1004855Продадени според склада: 1018607Идеята
stock[0]-- е три стъпки
stock[0]-- е един ред. Процесорът го прави на три стъпки, както ако го разпишеш:// stock е Int32Array в SharedArrayBuffer:// обща памет за Worker-ите.function sell(stock) { const seen = stock[0]; // 1. прочети const after = seen - 1; // 2. извади 1 stock[0] = after; // 3. запиши}Стъпка по стъпка, с думи
- Остават 5 бройки. Worker A и Worker B продават по една в един и същ момент.
- Worker A прочита наличността: 5.
- Worker B прочита, преди A да е записала: също 5.
- Worker A изважда 1 в своята променлива: 4.
- Worker B прави същото от своето 5: също 4.
- Worker A записва 4. Една продажба, остават 4: дотук е вярно.
- Worker B също записва 4, върху 4 на A. Две продажби, а наличността е 4, не 3.
- Една продажба е изгубена: двете прочетоха едно и също число, преди някоя да запише. Това е race condition, а складът показва бройка, която вече е продадена.
- Прочети наличността в своя променлива.
- Извади 1 от своето копие.
- Запиши копието обратно.
Две нишки могат да се редуват между всеки две стъпки. Ако и двете прочетат 5, преди някоя да запише, и двете записват 4. Две продажби, а наличността намалява с една.
Когато резултатът зависи от това коя нишка коя стъпка прави първа, това е race condition (състезание). Трите стъпки „прочети, промени, запиши“ върху общи данни са най-честата форма.
По-точно, за любопитните
stock[0]-- върху Int32Array прочита от общата памет, изважда в регистър и записва. Между тях другият Worker, който върви на друго ядро, може да прочете същото число. В обикновен JavaScript обект това не може да стане: обектите не се делят между Worker-ите, а се копират.Пробвай
Три каси, наличност 5
Ето три каси, разменени на ръка, в един от възможните редове. Всяка прочита в своя променлива, после записва с 1 по-малко:
let left = 5;const seenA = left; // A прочитаconst seenB = left; // B прочитаleft = seenA - 1; // A записваconst seenC = left; // C прочитаleft = seenB - 1; // B записваleft = seenC - 1; // C записваconsole.log("Продажби: 3");console.log(`Наличност: ${left}`);A и B прочитат 5. A записва 4. C прочита вече записаното 4. B записва 4 (своето 5 минус 1), а C записва 3. Три продажби, а наличността е 3 вместо 2: само продажбата на B се е изгубила, защото B прочете, преди A да запише.
Идеята
„При мен работи“
Същата програма, но с по-малко продажби на каса:
if (isMainThread) { const stock = new Int32Array(new SharedArrayBuffer(4)); stock[0] = 2_000; const checkouts = [1, 2].map( () => new Worker(new URL(import.meta.url), { workerData: stock }), ); await Promise.all(checkouts.map((worker) => once(worker, "exit"))); console.log(`Поръчки: ${2 * 1_000}`); console.log("Продадени според склада:"); console.log(2_000 - stock[0]);} else { for (let i = 0; i < 1_000; i++) { sell(workerData); }}Продадени според склада: 2000Продадени според склада: 2000Продадени според склада: 1929Продадени според склада: 2000Продадени според склада: 2000Затова race condition е коварен:
- тестът минава на лаптопа и пада на сървъра с 64 ядра и хиляди клиенти;
- грешката идва „понякога“ и не се повтаря, когато я търсиш;
- добавиш ли ред за отпечатване, редът на нишките се сменя и грешката изчезва.
Липсата на загуба в тест не доказва нищо. Безопасността се доказва с това как е написан кодът: кои данни са общи и кой ги пази.
Проблем
Последната бройка
Една бройка е останала. Двама купувачи натискат „Купи“ в един и същ момент. buy проверява дали има, таксува картата (около 100 ms) и чак тогава взима бройката:
class Stock { constructor(left) { this.left = left; }
async buy() { if (this.left > 0) { // проверка: има ли? await sleep(100); // картата се таксува ~100 ms this.left--; // действие: взимам бройката return true; } return false; }}const stock = new Stock(1);const bought = await Promise.all([stock.buy(), stock.buy()]);console.log("Наличност преди: 1");console.log("Продадени:");console.log(bought.filter((ok) => ok).length);console.log("Наличност след:");console.log(stock.left);Наличност преди: 1Продадени:2Наличност след:-1Тук нишката е една, а грешката е същата. await sleep(100) пуска другото извикване, докато първото чака банката. То стига до проверката, когато left още е 1. В една нишка редът е един и същ всеки път, затова и грешката е всеки път. Повече за това е в урока „async не е нишка“.
Това е втората форма на race condition: проверка, после действие (check-then-act). Решението се взима по стойност, която друг може да смени, преди решението да се изпълни. Тя е навсякъде: „има ли свободна стая“, „свободно ли е потребителското име“, „има ли пари в сметката“.
Пробвай
Номерата на поръчките
Всяка поръчка получава номер: 1001, 1002, 1003… Касите са нишки и викат take(next) едновременно. Понякога две поръчки получават един и същ номер. След кой ред другата нишка трябва да влезе, за да стане това?
Избери реда с грешката.
След прочитането. Ако двете нишки прочетат 1001, преди някоя да запише 1002, и двете връщат 1001. Две поръчки с един номер: едната ще презапише другата в базата или ще стигне до грешен клиент. Това е същото „прочети, промени, запиши“.
Идеята
Кога може да има race condition
Трябват три неща наведнъж:
- Общи данни: нещо, което виждат няколко нишки (наличността, броячът, номерът).
- Поне една нишка ги променя. Ако всички само четат, няма какво да се обърка.
- Нищо не подрежда достъпа: нишките влизат в стъпките си когато им дойде редът.
Махни едно от трите и race condition няма:
- Не дели. Всяка нишка брои в своя променлива, а накрая резултатите се събират. Така ставаше с 1000-те фактури в „Защо нишки“: всяка част смята своето.
- Не променяй. Обект, който никой не променя, може да се чете от колкото искаш нишки. Това е урокът „Атомарни операции и неизменими обекти“.
- Подреди достъпа. Само една нишка наведнъж в трите стъпки. Това е ключалката, следващият урок: „Заключване“.
По-точно, за любопитните
Има и по-тиха беда: видимостта. В SharedArrayBuffer обикновеното четене може да не види запис от друг Worker навреме. Atomics.load и Atomics.store го гарантират. Ключалката решава и това: каквото една нишка е записала, преди да я пусне, следващата го вижда, след като я вземе.
Как е в другите езици: в Java и C# count++ губи както тук. В Python губи по-рядко заради GIL, но губи. В JavaScript губи между Worker-и върху SharedArrayBuffer, а в една нишка race condition има само между два await.
Упражнение
Оборотът на разпродажбата
Реши задачата в редактора си.
Оборотът. Две каси продават по 500 000 слушалки по 3 €. Всяка каса брои своите продажби в своя променлива, а оборотът е общ: Int32Array в SharedArrayBuffer, в който всеки Worker прави revenue[0] += 3. Отпечатай продажбите на всяка каса и оборота. Пусни програмата няколко пъти.
Каса 1: 500000 продажбиКаса 2: 500000 продажбиОборот (€):3000000Продажбите на касите са верни при всяко пускане: всяка брои в своята променлива. Оборотът е по-малък и различен всеки път. Поправката е в следващия урок.
Още задачи
- Колко се губи. Пусни продажбата с 2, 4 и 8 каси. Губи ли се повече с повече каси?
- Намери общото. Вземи програма с нишки, която си писал, и изброй данните, които повече от една нишка пипа. Кои от тях някоя нишка променя?
- Свободна стая. Напиши
asyncметод „резервирай стая 12, ако е свободна“ сawait sleep(100)между проверката и записа и пусни двама гости сPromise.all. Колко гости получават стаята?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Масиви: push, splice и sort · Стрелкови функции · Async, await и нишки · Защо нишки
- Продължава в: Заключване · Атомарни операции и неизменими обекти · Deadlock · Производител и потребител
- Задачи по темата: Нишки и race conditions