Пошук уроків, статей та іншого контенту
З’ясуємо, як виникають Race Conditions в асинхронному коді та застосуємо способи їх виявлення й уникнення.
Race Condition — це помилка, за якої результат залежить від порядку виконання паралельних або асинхронних операцій.
У Node.js код виконується в одному основному потоці, але це не захищає від таких помилок. Паралельними можуть бути:
кілька Promise, запущених одночасно;
операції з файлами, мережею або базою даних;
обробники різних HTTP-запитів;
задачі у worker_threads;
процеси одного застосунку, запущені через cluster або менеджер процесів.
Критичний момент — виконання може перериватися на await. Після цього інша асинхронна операція отримує можливість змінити спільний стан.
const account = {
balance: 100,
};
const pause = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
async function withdraw(amount, name) {
console.log(`${name}: перевіряємо баланс`);
if (account.balance < amount) {
throw new Error(`${name}: недостатньо коштів`);
}
// Тут функція віддає керування event loop
await pause(10);
account.balance -= amount;
console.log(`${name}: знято ${amount}`);
}
async function main() {
await Promise.allSettled([
withdraw(80, "Операція A"),
withdraw(80, "Операція B"),
]);
console.log("Залишок:", account.balance);
}
main();Обидві операції можуть прочитати balance === 100, пройти перевірку, а потім змінити баланс.
Можливий результат:
Операція A: перевіряємо баланс
Операція B: перевіряємо баланс
Операція A: знято 80
Операція B: знято 80
Залишок: -60Фактично було дозволено зняти 160 із рахунку, на якому було лише 100.
Небезпечна послідовність часто має вигляд:
прочитати спільне значення;
перевірити умову;
дочекатися асинхронної операції;
записати нове значення.
Наприклад:
if (account.balance >= amount) {
await someAsyncOperation();
account.balance -= amount;
}Перевірка та зміна стану не є однією атомарною операцією. Між ними може виконатися інший код.
Інший поширений варіант — lost update, тобто втрата одного з оновлень:
let counter = 0;
async function increment() {
const currentValue = counter;
await Promise.resolve();
counter = currentValue + 1;
}
await Promise.all([
increment(),
increment(),
]);
console.log(counter); // 1, а очікували 2Обидва виклики прочитали counter === 0, а потім записали 1.
Сам оператор counter++ у синхронному коді не переривається між інструкціями JavaScript. Але якщо між читанням і записом є await, асинхронна операція вже може створити Race Condition.
Race Condition може виникати не лише під час арифметичних операцій. Наприклад, користувач швидко змінює пошуковий запит:
запит a відправлено першим;
запит ab відправлено другим;
відповідь для ab прийшла швидше;
відповідь для a прийшла пізніше та перезаписала актуальні результати.
У результаті на екрані відображаються дані для старого запиту.
Цей самий сценарій можливий у серверному коді, якщо кілька операцій оновлюють один об’єкт або кеш.
Race Condition складно виявити, тому що вона може проявлятися лише за певного порядку подій.
Логи мають показувати не лише текст, а й:
ідентифікатор операції;
ідентифікатор сутності;
момент читання стану;
момент запису;
версію стану;
значення до та після зміни.
let nextOperationId = 1;
let balance = 100;
const pause = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
async function withdraw(amount) {
const operationId = nextOperationId++;
const before = balance;
console.log({
operationId,
event: "read",
balance: before,
});
await pause(Math.random() * 20);
balance = before - amount;
console.log({
operationId,
event: "write",
balance,
});
}Такі логи допомагають побачити, що дві операції працювали з одним і тим самим старим значенням.
Випадкові затримки роблять помилку більш імовірною, але для тестів корисні й детерміновані затримки:
const pause = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));Тимчасово додайте await pause(...) між читанням і записом. Якщо після цього порушується інваріант, критичний фрагмент не є безпечним.
Інваріант — це умова, яка завжди має залишатися істинною.
Для банківського рахунку це може бути:
if (account.balance < 0) {
throw new Error("Баланс не може бути від'ємним");
}Інші приклади інваріантів:
кількість товару не може бути меншою за нуль;
один ідентифікатор не може бути призначений двом користувачам;
версія документа не може зменшуватися;
замовлення не може перейти зі статусу completed назад у pending.
Перевірки інваріантів варто виконувати у тестах і, за потреби, у production-коді.
Один успішний запуск не доводить відсутність Race Condition. Конкурентний тест потрібно повторювати:
for (let attempt = 1; attempt <= 1000; attempt += 1) {
await runConcurrentScenario();
}Особливо корисно перевіряти граничні випадки:
два однакові оновлення;
багато оновлень однієї сутності;
одночасне видалення та оновлення;
повторні запити після тайм-ауту;
повторна доставка однієї події.
Якщо операції змінюють спільний стан у межах одного Node.js-процесу, їх можна серіалізувати.
Mutex дозволяє лише одній операції виконувати критичну секцію. Інші операції чекають у черзі.
Нижче наведено повністю runnable-приклад:
class Mutex {
constructor() {
this.locked = false;
this.waiting = [];
}
acquire() {
return new Promise((resolve) => {
this.waiting.push(resolve);
this.#dispatch();
});
}
#dispatch() {
if (this.locked || this.waiting.length === 0) {
return;
}
this.locked = true;
const resolve = this.waiting.shift();
// Функція release звільняє mutex для наступної операції
resolve(() => {
this.locked = false;
this.#dispatch();
});
}
async runExclusive(callback) {
const release = await this.acquire();
try {
return await callback();
} finally {
// Mutex потрібно звільнити навіть після помилки
release();
}
}
}
const account = {
balance: 100,
};
const accountMutex = new Mutex();
const pause = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
async function withdraw(amount, name) {
return accountMutex.runExclusive(async () => {
console.log(`${name}: отримала mutex`);
console.log(`${name}: поточний баланс — ${account.balance}`);
if (account.balance < amount) {
throw new Error(`${name}: недостатньо коштів`);
}
await pause(10);
account.balance -= amount;
console.log(`${name}: новий баланс — ${account.balance}`);
});
}
async function main() {
const results = await Promise.allSettled([
withdraw(80, "Операція A"),
withdraw(80, "Операція B"),
]);
for (const result of results) {
if (result.status === "rejected") {
console.log(result.reason.message);
}
}
console.log("Фінальний баланс:", account.balance);
}
main();Одна операція зніме 80, а друга отримає помилку через недостатній баланс. Від’ємний баланс не виникне.
Критична секція має бути якомога коротшою:
await mutex.runExclusive(async () => {
// Тут лише читання, перевірка та зміна спільного стану
});Не варто утримувати mutex під час повільних або непередбачуваних операцій, якщо це не необхідно. Наприклад, довгий HTTP-запит усередині mutex може заблокувати всі наступні операції.
Водночас не можна виносити критичну зміну за межі mutex:
// Небезпечно: стан змінюється після звільнення mutex
const value = await mutex.runExclusive(async () => {
return account.balance;
});
account.balance = value - amount;У цьому випадку захищеним було лише читання, а не вся операція.
Mutex — не єдиний підхід. Якщо блокування небажане, можна зберігати версію стану.
Алгоритм:
прочитати значення та його версію;
виконати підготовчу роботу;
перед записом перевірити, що версія не змінилася;
якщо версія змінилася — відхилити операцію або повторити її.
const account = {
balance: 100,
version: 0,
};
const pause = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
async function withdrawOptimistically(amount, name) {
const observedVersion = account.version;
const observedBalance = account.balance;
console.log(`${name}: прочитано версію ${observedVersion}`);
if (observedBalance < amount) {
throw new Error(`${name}: недостатньо коштів`);
}
await pause(10);
if (account.version !== observedVersion) {
throw new Error(`${name}: конфлікт версій, потрібна повторна спроба`);
}
account.balance = observedBalance - amount;
account.version += 1;
console.log(`${name}: операцію виконано`);
}
async function main() {
const results = await Promise.allSettled([
withdrawOptimistically(80, "Операція A"),
withdrawOptimistically(80, "Операція B"),
]);
for (const result of results) {
if (result.status === "rejected") {
console.log(result.reason.message);
}
}
console.log(account);
}
main();Цей підхід не змушує другу операцію чекати. Вона виявляє конфлікт і може:
повторити читання та обчислення;
повідомити клієнту про конфлікт;
використати нові дані;
завершитися без зміни стану.
У базі даних таке блокування зазвичай реалізують умовним оновленням:
UPDATE accounts
SET balance = balance - ?, version = version + 1
WHERE id = ?
AND version = ?
AND balance >= ?;Якщо кількість змінених рядків дорівнює нулю, стан уже змінився або коштів недостатньо. Важливо, щоб перевірка та оновлення виконувалися атомарно на стороні бази даних.
Для простих змін краще використовувати атомарну операцію замість схеми «прочитати — перевірити — записати».
Небезпечний варіант:
if (stock.quantity >= requested) {
await pause(10);
stock.quantity -= requested;
}Безпечніший дизайн переносить перевірку та зміну в одну операцію сховища. Наприклад, база даних може виконати умовне зменшення кількості:
UPDATE products
SET quantity = quantity - ?
WHERE id = ?
AND quantity >= ?;Після цього код перевіряє результат операції:
один змінений рядок — товар зарезервовано;
нуль змінених рядків — товару недостатньо або його вже змінив інший запит.
Атомарність повинна бути гарантована самим сховищем, а не лише послідовністю JavaScript-коду.
Mutex, створений у пам’яті Node.js, захищає лише один процес.
Він не синхронізує:
два окремі процеси Node.js;
кілька реплік сервера;
процеси в різних контейнерах;
worker_threads, якщо кожен потік має власний стан і власний mutex.
Тому для спільного стану, який доступний кількома процесами, потрібен механізм на спільному рівні:
атомарна операція бази даних;
транзакція;
унікальне обмеження;
розподілене блокування;
черга повідомлень.
Вибір залежить від гарантій, потрібних конкретній операції. Локальний mutex не може замінити транзакцію бази даних.
Чим менше функцій змінюють один і той самий об’єкт, тим менше можливостей для конфліктів.
Краще передавати дані як аргументи та повертати нове значення, ніж змінювати глобальний об’єкт у багатьох місцях.
Критична секція — це частина коду, де:
читається спільний стан;
перевіряється умова;
змінюється цей самий стан.
Ці операції мають бути захищені одним із способів:
виконуватися синхронно без await;
виконуватися під mutex;
виконуватися атомарно у сховищі;
перевіряти версію перед записом;
виконуватися в транзакції.
Такий код не гарантує, що результат first буде застосовано раніше за second:
const first = loadData("first");
const second = loadData("second");
await Promise.all([first, second]);Promise.all лише очікує завершення обох операцій. Він не встановлює порядок їхніх побічних ефектів.
Якщо порядок важливий, виконуйте операції послідовно:
const firstResult = await loadData("first");
const secondResult = await loadData("second");Або використовуйте спеціальну чергу для операцій над однією сутністю.
Ідемпотентна операція дає той самий ефект при повторному виконанні.
Це важливо, коли клієнт повторює запит після тайм-ауту або повідомлення доставляється двічі. Для цього можна використовувати:
унікальний ідентифікатор операції;
унікальне обмеження в базі даних;
збереження вже оброблених ідентифікаторів;
перевірку поточного статусу перед зміною.
Ідемпотентність не усуває всі Race Conditions, але зменшує наслідки повторного виконання.
Однопотоковість не робить асинхронні операції атомарними. await може розділити логічно єдину операцію на кілька частин.
Локальний mutex захищає лише один екземпляр процесу. У горизонтально масштабованому застосунку потрібна синхронізація через спільне сховище або інший міжпроцесний механізм.
Якщо читання та перевірка відбулися поза критичною секцією, інша операція може змінити стан до моменту запису. Захищати потрібно всю послідовність «прочитати — перевірити — змінити».
Promise.all вирішує проблему конкуренціїPromise.all лише очікує групу промісів. Він не захищає спільний стан і не гарантує порядок виконання побічних ефектів.
Паралельне виконання саме по собі не є помилкою. Race Condition виникає тоді, коли результат залежить від порядку, а код не гарантує правильний результат для всіх допустимих порядків.
Якщо mutex не звільнити в finally, наступні операції можуть залишитися заблокованими назавжди.
const release = await mutex.acquire();
try {
await operation();
} finally {
release();
}Race Condition виникає, коли результат залежить від порядку асинхронних операцій.
Node.js не усуває проблему: await дозволяє іншому коду змінити стан між читанням і записом.
Найнебезпечніша схема — «прочитати — перевірити — дочекатися — записати».
Для виявлення використовуйте детальні логи, контрольовані затримки, повторні конкурентні тести та перевірку інваріантів.
Для одного процесу можна серіалізувати критичні секції за допомогою mutex.
Для спільного стану між процесами використовуйте атомарні операції, транзакції або оптимістичне блокування за версією.
Локальне блокування має охоплювати всю логічну операцію, а звільнення mutex потрібно виконувати у finally.
Паралельність без спільного змінного стану зазвичай не створює Race Condition; проблема виникає саме через неконтрольовану взаємодію над спільними даними.