Головна / Статті

Серіалізуйте асинхронну роботу в JavaScript за допомогою мьютекса з обіцянкою

Серіалізуйте перекриваючіся асинхронні завдання в JavaScript за допомогою мютекса на основі обіцянок: ланцюг AsyncLock виконує запити за принципом FIFO, тож стан інтерфейсу залишається впорядкованим, і не потрібні блокування потоків.

Видавець: REACTAPP.TOP3 хв читання775 слів

Джерела: Concurrency management in JavaScript: the mutex (mutual exclusion) lock pattern

Контроль конкурентності є частиною кожної серйозної бази коду, а JavaScript робить цю тему ще більш актуальною, оскільки мова не передбачає многопотокових замків у стилі операційної системи. Один поток виконує операції зі стеком по порядку. Асинхронні операції все одно виконуються паралельно: обіцянки (promises) виконуються, перетворюються на мікрозавдання та знову потрапляють у цикл подій після їх завершення. Порядок виконання функцій-колбеків після чергування обіцянок чітко визначений, проте загальний таймінг — ні; зовнішня затримка визначає, який мережевий запит завершиться першим. Доки обіцянка не буде виконана або відхилена, її обробники .then, .catch та .finally залишаються поза циклом. Саме в цій проміжці з’являються умови конкуренції, і саме тут стає корисним свідоме використання певних патернів.

Розгляньмо інтерфейс у стилі банку. Клієнти очікують, що сума на рахунку, яку вони бачать після внесення чи зняття коштів, буде відповідати даним, які надає інтерфейс. Сервер залишається джерелом істини, але клієнт все одно надсилає оновлення та мусить подавати відповіді у логічному порядку.

Без контролю конкурентності з’являється знайомий спосіб збою. Хтось відкриває додаток, бачить 100 доларів на рахунку, вносить ще 100 доларів за чеком та очікує 200 доларів. Поки запит на внесення коштів ще не завершився, він ініціює переказ, припускаючи, що сума на рахунку вже становить 200 доларів. Якщо повільний центр обробки даних дозволяє переказу потрапити до сховища раніше, ніж внесення коштів буде підтверджено, переказ зазнає невдачі через недостатню суму, хоча намір користувача був правильним.

Фреймворки клієнтів рідко надають примітиви у стилі pthread, тому код додатку змушений самостійно визначати порядок виконання операцій на основі обіцянок. Існує кілька підходів — серед них оптимістичне та песимістичне блокування. Ще одним варіантом є серіалізація через мьютекс: чергування критичних секцій, щоб водночас працювала лише одна операція. Наведений нижче скетч на TypeScript реалізує цю ідею для сценарію депозиту/переказу:

class AsyncLock {
  private disableNext: Promise<void>;

  constructor() {
    this.disableNext = Promise.resolve();
  }

  // force async task to wait in line
  async acquire(): Promise<() => void> {
    let release!: () => void;
    const currentLock = this.disableNext; // assign private reference to the most recent promise created

    this.disableNext = new Promise<void>(resolve => { // create a new promise for the next request in queue
      release = resolve;
    });

    await currentLock;
    return release;
  }
}

// how to use AsyncLock
const lock = new AsyncLock();
let balance = 100;

type TransactionType = "withdraw" | "deposit";

async function updateBalance(amount: number, transactionType: TransactionType): Promise<void> {
  const release = await lock.acquire(); // wait in line
  try {
    console.log(`Reading balance... Current: ${balance}`);
    await new Promise(r => setTimeout(r, 50)); // simulate database delay
    if (transactionType === "withdraw" && balance >= amount) { // withdraw
      balance -= amount;
      console.log(`Withdraw: ${amount}. Current: ${balance}`);
    } else if (transactionType === "deposit") { // deposit
      balance += amount;
      console.log(`Deposit: ${amount}. Current: ${balance}`);
    } else {
      console.log("NOP");
    }
  } finally {
    release(); // let the next task run by calling the resolve method on the current promise
  }
}

updateBalance(100, "deposit"); // deposit $100 into checking
updateBalance(200, "withdraw"); // transfer (withdraw) $200 to savings

Як працює ланцюг блокування

Створення об’єкта new AsyncLock() виділяє новий об’єкт блокування. Присвоєння this.disableNext = Promise.resolve() запускає ланцюг із вже виконаним обіцянням, щоб перший користувач міг негайно почати роботу. Кожен виклик lock.acquire() має спостерігати за спільним ланцюгом цього екземпляра: він зафіксовує попереднє обіцяння як currentLock, а потім замінює this.disableNext на нове обіцяння у стані очікування. Оскільки this вказує на довгоживучий об’єкт блокування, наступний виклик acquire() бачить оновлений початок ланцюга та чекає після нього. Коли активна критична секція завершується, функція release() виконує поточне обіцяння та звільняє наступного користувача.

Ефектом є керований чергувальний буфер FIFO. Пізніший запит не може обійти ранніший, тому інтерфейс користувача залишається у відповідності з порядком обробки. Будь-яка кількість перекриваючихся запитів може бути впорядкована без створення додаткових потоків. Використання поля класу disableNext зберігає цей спільний стан у одному об’єкті, саме тому паттерн мьютекс підходить для JavaScript-додатків, яким потрібен прогнозований порядок обробки спільного клієнтського стану без заморожування решти сторінки.

Тримайте критичну секцію короткою: отримайте доступ, виконайте запит, що впливає на баланс, оновіть локальний стан за допомогою відповіді, а потім звільніть доступ. Довгі секції, які чекають на нерелевантну роботу інтерфейсу користувача, підривають мету, затримуючи нерелевантні операції, які використовували ту саму інстанцію блокування.