Превратите свои ракеты в стиральные машины

Существует множество способов создания реактивных интерфейсов, но мой любимый — использование конечных автоматов. Я уже писал ранее о том, что JavaScript Promises — это конечные автоматы, но я лишь вкратце коснулся того, как конечные автоматы взаимодействуют друг с другом. С общением приходит возможность условий гонки, а условия гонки приносят печаль.

Существуют разные каналы для этого общения. В браузере у нас есть: прослушиватели событий, DOM, каналы сообщений, обратные вызовы (включая промисы), методы, разделяемая память и т. д.

В чем разница между ракетой и стиральной машиной? Для наших целей разница в том, как долго они прослужат. Ракета создается, выполняет свою задачу и (надеюсь) сгорает при входе в атмосферу. Стиральная машина создается и служит долго, многократно выполняя свою задачу.

Чтобы проиллюстрировать состояние гонки, мы реализуем окно поиска, используя различные стили ракеты и стиральной машины. Каждый стиль доступен из этого окна поиска, где вы можете переключаться между ними и проверять, как они себя ведут. Полный код находится на GitHub.

Сломанное окно поиска

Давайте рассмотрим некоторый ошибочный код для реализации окна поиска.

import { elements, clear_and_spin, hide_spinner } from './base.mjs';
const {
  form, results
} = elements();
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  // Clear previous search results and show the spinner
  clear_and_spin();
  // Fetch search results
  const args = new URLSearchParams(new FormData(e.target));
  const response = await fetch('https://www.googleapis.com/customsearch/v1?' + args.toString());
  const json = await response.json();
  const { items } = json;
  // Display results and hide spinner
  for (const { htmlTitle, link, snippet } of items ?? []) {
    results.insertAdjacentHTML('beforeend', `<li>
      <a href="${link}">${htmlTitle}</a>
      <p>${snippet}</p>
    </li>`);
  }
  hide_spinner();
});

Немедленным красным флагом является прослушиватель асинхронных событий. Хотя это не означает наверняка, что существует состояние гонки, это указывает на то, что вам следует снизить скорость, чтобы убедиться, что код работает. В этом случае это не работает и может быть отключено с помощью этой последовательности событий:

  1. Пользователь отправляет запрос «Как рисовать?»: элемент результатов очищается и отображается счетчик.
  2. Пользователь редактирует свой запрос и отправляет поиск «Как рисовать?»: элемент результатов уже очищен, и счетчик уже показан, поэтому ничего не меняется, даже если они запускаются снова.
  3. Результаты второго запроса возвращаются и записываются на страницу. Счетчик скрыт.
    Это результаты самого последнего поиска, которые интересуют пользователя.
  4. Некоторое время спустя результаты первого запроса возвращаются и добавляются к результатам второго запроса. Спиннер уже скрыт, поэтому он не меняется.

Это может сбить пользователя с толку, потому что счетчик был скрыт, в то время как результаты поиска, которые его не волнуют, продолжали загружаться. И затем эти результаты случайным образом добавляются в конец. (Было бы еще хуже, если бы мы попытались переключить счетчик. По этой причине я избегаю API на основе переключения.)

Обещания действуют как ракеты в том смысле, что они самодвижущиеся. Как только вы запустите Обещание или ракету, его уже не вернуть. Лучшее, что вы можете сделать, это попросить ракету прекратить самопроизвольное прерывание — надеюсь, она способна слушать.

Повторяю, нет никакого способа убить Promise извне. Контроллер прерывания и сигнал прерывания представляют собой запрос на закрытие промиса. Это отличается от генераторов в JavaScript или фьючерсов на других языках, где задача не будет выполняться, если она не будет активно управляться извне.

Вероятность встретить странно отложенный поисковый запрос невелика, особенно если вы тестируете с компьютера с хорошим интернетом, поэтому вы можете увидеть такую ​​​​ошибку только в производственной среде, где условия интернета более разнообразны, а пользователи быстро взаимодействуют с вашим интерфейсом.

Чтобы увеличить вероятность попадания в это состояние гонки, я обернул глобальную выборку функцией, которая случайным образом задерживает примерно 50% запросов.

// delay ~50% of requests by 4 seconds
const original_fetch = fetch;
window.fetch = async function () {
  const chance = Math.random();
  if (chance >= 0.5) {
    console.log("slowing this request: ", chance);
    await new Promise(res => setTimeout(res, 4_000));
  }
  return await original_fetch(...arguments);
};

Затем давайте добавим систему прерывания, чтобы исправить нашу ошибочную реализацию окна поиска.

Запустить систему прерывания

Не так уж сложно обернуть нашу асинхронную функцию синхронной функцией, которая предоставит ей сигнал прерывания.

function single(async_func) {
  let controller;
  return (...args) => {
    if (controller) {
      controller.abort();
    }
    controller = new AbortController();
    const signal = controller.signal;
    async_func(signal, ...args).finally(() => {
      // If our promise settles without being interrupted (the controller's signal is the same as the one we gave to the async_func) then delete the controller because it doesn't need to be cancelled next time around.
      if (signal == controller.signal) controller = false;
    });
  };
}
form.addEventListener('submit', single(async (signal, e) => {
  e.preventDefault();
  // Clear previous search results and show the spinner
  clear_and_spin();
  // Fetch search results
  const args = new URLSearchParams(new FormData(e.target));
  const response = await fetch(
    'https://www.googleapis.com/customsearch/v1?' + args.toString(),
    { signal }
  );
  const json = await response.json();
  if (signal.aborted) return;
  const { items } = json;
  // Display results and hide spinner
  for (const { htmlTitle, link, snippet } of items ?? []) {
    results.insertAdjacentHTML('beforeend', `<li>
      <a href="${link}">${htmlTitle}</a>
      <p>${snippet}</p>
    </li>`);
  }
  hide_spinner();
}));

Нам нужно убедиться, что мы передаем наш сигнал каждому обещанию, которое мы ожидаем, или что мы проверяем, были ли мы прерваны после ожидания обещания, которое не принимает сигнал прерывания. Поскольку чтение ответа в виде JSON не принимает сигнал, нам нужно проверить его на аборт после его чтения. В качестве альтернативы вы можете отменить обещание, используя функцию-оболочку, подобную этой: https://gist.github.com/jakearchibald/070c108c65e6db14db43d90d1c3a0305.

Это не идеальное решение. Если вы дважды нажмете кнопку поиска, не изменив свой запрос, это решение отбрасывает предыдущий (и все еще действительный) запрос и начинает сначала. Мы должны проверить, совпадают ли параметры SearchParams с предыдущим запросом, и прервать выполнение только в том случае, если они отличаются. Наши окончательные реализации сделают это.

Замки

Если сильно прищуриться, можно увидеть, что наша функция single немного похожа на мьютекс или другую блокировку. Его цель состоит в том, чтобы обеспечить выполнение только одной поисковой задачи в каждый момент времени.

JavaScript является ~однопоточным~, поэтому в этом случае критические разделы, которые защищает наша блокировка, находятся между ключевым словом await. Так как у нас было два ключевых слова await, нам нужно было проверить, был ли дважды прерван сигнал. Один раз был обработан fetch автоматически, потому что он выдает ошибку при прерывании. В другом мы использовали пару операторов if и return. Глядя на критические разделы, становится ясно, почему в нашем ошибочном примере могут быть результаты для запроса 1, за которыми следуют результаты для запроса 2, или результаты для запроса 2, за которыми следуют результаты для запроса 1. Что невозможно, так это несколько из 1 и несколько из 2, а затем несколько из 1 снова. Поскольку JS является ~однопоточным~, результаты никогда не будут чередоваться, поскольку в цикле for нет ключевого слова await.

В JavaScript есть и другие блокировки, которые мы можем использовать. Например, мы также могли бы отключить кнопку поиска, чтобы пользователь не мог нажать ее, пока мы не закончим получение результатов поиска. Это замок.

Быть ~однопоточным~ означает, что блокировка может быть такой же простой, как одна логическая переменная.

В стороне: синхронизация ⇄ асинхронные интерфейсы

Но теперь, когда у нас есть рабочие процессы, JavaScript не всегда однопоточный. И хотя нам не разрешено блокировать основной поток, нам разрешено блокировать рабочие потоки. Конечно, среда выполнения JavaScript может заблокировать основной поток, если очередь событий пуста и ей нечего делать, но это означает, что стек JavaScript будет пуст. Но с помощью SharedArrayBuffer и Atomics мы можем заблокировать поток JavaScript, не очищая стек JavaScript. Это похоже на превращение любой обычной функции JavaScript в генератор и использование yield.

Одним из ключевых применений для этого является реализация синхронного интерфейса WASM поверх асинхронного API JavaScript. Например, методы seek и write в FileSystemWritableFileStream возвращают обещания, в то время как трейты Rust Write и Seek являются синхронными. Если мы поместим наш код Rust в рабочий поток с общей памятью, то мы сможем передать обещание, полученное от вызова stream_handle.write(), обратно в основной поток и использовать Atomics.wait() для блокировки рабочего потока. Когда промис разрешается, мы можем Atomics.notify() воркер, чтобы он продолжился. Это один из способов справиться с этой сложной ситуацией.

Синхронный интерфейс лучше и универсальнее превратить в асинхронный, так как вам не нужен воркер. Если вы используете Emscripten, вы можете использовать Asincify, чтобы сделать именно это. Рабочий трюк предназначен только для ситуаций, когда вы не можете изменить синхронизацию интерфейса, который вы реализуете.

Теперь вернемся к межмашинному взаимодействию.

Очереди

Представьте веб-страницу, которая позволяет пользователям загружать несколько файлов. Они выбирают файл, затем начинают загрузку. Во время загрузки они выбирают другой файл и нажимают «Загрузить». Наш сервер может обрабатывать только пользователя, загружающего один файл за раз, поэтому наша веб-страница должна поставить в очередь второй файл, который будет загружен позже. Написать очередь может быть довольно просто. Вот один:

export function queue() {
  let items = [];
  let waiting = [];
  return {
    append(item) {
      items.push(item);
      const w = waiting;
      waiting = [];
      w.forEach(c => c());
    },
    async *[Symbol.asyncIterator]() {
      while (true) {
        if (items.length) {
          yield items.shift();
        } else {
          await this;
        }
      }
    },
    then(callback) {
      waiting.push(callback);
    }
  };
}

И использование этой очереди для реализации нашего примера загрузки может выглядеть так:

const upload_queue = queue();
// File drop:
drop_zone.ondrop = function (e) {
  e.preventDefault();
  upload_queue.append(...(e.dataTransfer.items ?? []));
};
// Uploader:
(async () => {
  for await (const item of upload_queue) {
    if (item.kind === 'file') {
      const file = item.getAsFile();
      await fetch(/* Upload the file */);
    }
  }
})();

Обратите внимание, что загрузчик является самодвижущимся промисом, но он создается во время первоначального выполнения скрипта и выполняет свою задачу более одного раза, что делает его стиральной машиной. Вы можете представить себе наличие двух серверов загрузки, и в этом случае было бы очень легко просто создать два таких загрузчика вместо одного.

Здесь это не имеет смысла, но вы могли бы провести конвейерный анализ этого кода. В схемотехнике конвейеры предназначены для балансировки пропускной способности и скорости, но в программном обеспечении конвейеры могут использоваться для управления конечными ресурсами. Наличие очереди, которая заполняется, когда система перегружена, может привести к более предсказуемому взаимодействию с пользователем, чем наличие системы, которая пытается немедленно обработать каждый запрос, но в конечном итоге замедляет все запросы и становится неэффективной.

Вы также можете использовать конвейеры для представления ограниченных ресурсов реальной системы. Если задача требует действия сотрудника, то имеет смысл ограничить количество машин количеством имеющихся у вас сотрудников.

В любом случае, создаете ли вы экземпляр конечного автомата для каждой задачи или создаете экземпляры нескольких долго работающих конечных автоматов, цель использования блокировок, каналов или очередей одна и та же: ограничить и скоординировать критические разделы вашей системы. Чем меньше автоматов состояний и критических секций, о которых вам придется беспокоиться, тем проще это будет. Вот почему я рекомендую использовать несколько долгоживущих стиральных машин, а не много недолговечных ракет.

Вернемся к поисковой строке. Я покажу вам два примера того, как реализовать окно поиска с помощью единственного конечного автомата. Первая представляет собой единую асинхронную функцию, как в нашем примере с загрузчиком, а вторая использует созданную мной экспериментальную библиотеку конечного автомата. Оба примера используют одну и ту же диаграмму состояний:

Стиральная машина окна поиска

Эта реализация немного длиннее, чем одноэлементная версия, но я думаю, что она довольно читабельна.

import { elements, clear_and_spin, hide_spinner } from './base.mjs';
const {
  form, results
} = elements();
function get_args(old_args) {
  return new Promise(res => {
    function handler(e) {
      e.preventDefault();
      const new_args = new URLSearchParams(new FormData(e.target));
      if (!old_args || new_args.get('q') !== old_args.get('q')) {
        form.removeEventListener('submit', handler);
        res(new_args);
      }
    }
    form.addEventListener('submit', handler);
  });
}
// Search Box:
(async () => {
  while (true) {
    // STATE: wait_for_search; TRANSITIONS: query
    let args = await get_args();
    clear_and_spin();
    let search_results;
    let controller;
    while (true) {
      if (controller) controller.abort();
      controller = new AbortController();
      let response;
      const t_response = fetch(
        'https://www.googleapis.com/customsearch/v1?' + args.toString(),
        { signal: controller.signal }
      ).then(res => response = res);
      const t_query = get_args(args).then(new_args => args = new_args);
      // STATE: searching.1; TRANSITIONS: query, response
      await Promise.race([t_query, t_response]);
      if (!response) continue;
      let items;
      const t_json = response.json().then(json => items = json?.items ?? []);
      // STATE: searching.2; TRANSITIONS: query, json
      await Promise.race([t_query, t_json]);
      if (items) {
        search_results = items;
        break;
      }
    }
    for (const { htmlTitle, link, snippet } of search_results ?? []) {
      results.insertAdjacentHTML('beforeend', `<li>
                <a href="${link}">${htmlTitle}</a>
                <p>${snippet}</p>
            </li>`);
    }
    hide_spinner();
  }
})();

Одна из проблем при построении конечных автоматов таким образом заключается в сложности указания переходов и состояний. Каждое состояние с более чем одним возможным переходом оказывается Promise.race() из нескольких обещаний, за которыми сразу же следуют условные операторы для проверки того, какой переход был выполнен. Мы используем .then() для запуска перехода и извлечения данных, которые идентифицируют, какой переход выполнялся.

Следующее решение просто для удовольствия. Я хотел посмотреть, как может выглядеть синтаксис для описания конечных автоматов.

Повторно входящая библиотека FSM

Библиотека использует исключения для «приостановки» функции при вызове state().

import { machine, state, transition } from './machine.mjs';
import { elements, clear_and_spin, hide_spinner } from './base.mjs';
const {
  form, results
} = elements();
machine(function () {
  form.addEventListener('submit',
    transition('query', e => {
      e.preventDefault();
      const new_args = new URLSearchParams(new FormData(e.target));
      if (!this.args || new_args.get('q') !== this.args.get('q')) {
        this.args = new_args;
        if (this.controller) {
          this.controller.abort();
        }
      }
    }),
    { once: true }
  );
  // Get the search query
  if (!this.args) {
    state('waiting_for_search');
  }
  if (!this.controller || this.controller.signal.aborted) {
    // Send the request
    this.controller = new AbortController();
    fetch(
      'https://www.googleapis.com/customsearch/v1?' + this.args.toString()
    ).then(
      transition('response', response => this.response = response)
    );
  } else if (this.response) {
    // Turn the request into json and display the results
    this.response.json().then(
      transition('json', ({ items }) => {
        for (const { htmlTitle, link, snippet } of items ?? []) {
          results.insertAdjacentHTML('beforeend', `<li>
            <a href="${link}">${htmlTitle}</a>
            <p>${snippet}</p>
          </li>`);
        }
        this.args = undefined;
        this.controller = undefined;
        this.response = undefined;
      })
    );
  }
  state('searching', clear_and_spin, hide_spinner);
});

Ну, что же вы думаете? Вы бы рассмотрели возможность написания своих конечных автоматов таким образом, или вы предпочитаете использовать библиотеку вроде xstate, где автоматы определяются через объекты?