Борьба с гонками (race conditions) в JavaScript на примере работы с кешем

от автора

Рассмотрим следующую задачу. Нам необходимо делать вызовы стороннего API, которые считаются дорогими, и, следовательно, их необходимо кешировать в Redis. Мы используем современный NodeJS (версии 14+), а значит и конструкции async / await.

Напишем сначала класс обертку над вызовом API, где сам вызов будем эмулировать 2-секундным таймаутом.

class ApiWrapper {     #callTimes = 0;      async apiCall(payload) {         return new Promise(resolve => {             setTimeout(() => {                 this.#callTimes++;                 resolve(`success: ${payload}`)             }, 2000);         })     }      get callTimes() {         return this.#callTimes;     } }  const run = async () => {     const api = new ApiWrapper();      // эмулируем параллельный вызов API 4 раза     const prDirect = await Promise.all([api.apiCall('test'), api.apiCall('test'), api.apiCall('test'), api.apiCall('test')]);     console.log(prDirect); // => ['success: test', 'success: test', 'success: test', 'success: test']     console.log(apiCache.callTimes); // => 4 }  run();

Я специально добавил в класс счетчик вызовов callTimes — он показывает сколько раз мы вызвали метод API. В данном примере у нас 4 прямых вызова.

Теперь добавим к коду кеширование в Redis. Для этого будем использовать пакет redis@next.

Код с кеширующим методом cachedApiCall
class ApiWrapper {     #client;     #callTimes = 0;      constructor(url) {         // создаем клиента Redis         this.#client = createClient({             url         });     }      async init() {         // Подключаемся к Redis         await this.#client.connect();     }      async apiCall(payload) {         return new Promise(resolve => {             setTimeout(() => {                 this.#callTimes++;                 resolve(`success: ${payload}`)             }, 2000);         })     }      async cachedApiCall(payload) {         let data = await this.#client.get(payload);         if (data === null) {             // cache for 5 minutes             data = await this.apiCall(payload);             await this.#client.set(payload, data, {                 EX: 60 * 5             });         }         return data;     }      get callTimes() {         return this.#callTimes;     } }  const run = async () => {     const api = new ApiWrapper('redis://10.250.200.9:6379/6');     await api.init();      const prCached = await Promise.all([api.cachedApiCall('test'), api.cachedApiCall('test'), api.cachedApiCall('test'), api.cachedApiCall('test')]);     console.log(prCached); // => ['success: test', 'success: test', 'success: test', 'success: test']     console.log(api.callTimes); // => 4 }

Несмотря на то, что мы вызываем cachedApiCall, наш счетчик вызовов API всё равно показывает цифру 4. Это происходит из-за особенностей работы async / await.

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

    async cachedApiCall(payload) {         // получаем данные из кеша         let data = await this.#client.get(payload);         // если данных нет, то вызываем API и кладем в кеш         if (data === null) {             // cache for 5 minutes             data = await this.apiCall(payload);             await this.#client.set(payload, data, {                 EX: 60 * 5             });         }         return data;     }

В коде, при обращении к асинхронным методам, я использовал await. Следовательно, как только исполнение первого вызова cachedApiCall дойдет до этой строки оно прервется, и начнет работать следующий параллельный вызов (а у нас их 4). Так будет происходить на каждом вызове await. Если бы наше обращение к cachedApiCall не вызывалось параллельно, то проблемы бы в таком коде не было. Но при параллельном вызове мы нарвались на состояние гонки. Все 4 вызова соревнуются внутри метода, и в итоге мы имеем 4 запроса на получения значения кеша, 4 вызова API, и 4 вызова на установку значения кеша.

Как можно решить такую проблему? Нужно куда-то спрятать await, например во вложенной фукнции, а сам вызов вложенной функции кешировать в памяти на время работы основой функции.

Выглядеть это будет вот так:

    async cachedApiCall(payload) {         // тут мы спраятали прошлый код во вложенную функцию getOrSet         const getOrSet = async payload => {             let data = await this.#client.get(payload);             if (data === null) {                 // cache for 5 minutes                 data = await this.apiCall(payload);                 await this.#client.set(payload, data, {                     EX: 60 * 5                 });             }             return data;         }         // во временном кеше на время работы функции мы храним промисы         // если находясь тут у нас есть значение в tempCache, то мы словили         // параллельный вызов         if (typeof this.#tempCache[payload] !== 'undefined')             return this.#tempCache[payload];         // конструкция try - finally нам позволяет почистить за собой при         // любом исходе         try {             // помещаем во временный кеш промис             this.#tempCache[payload] = getOrSet(payload);             // используем await, чтобы все параллельные вызовы сюда зашли             return await this.#tempCache[payload];         } finally {             delete this.#tempCache[payload];         }     }

Теперь, при параллельном вызове cachedApiCall все получат один и тот же промис, а значит вызовы к Redis и к нашему API произойдут всего 1 раз.

Боролись ли вы в своих JavaScript проектах с состоянием гонки и какие подходы вы применяли?


ссылка на оригинал статьи https://habr.com/ru/articles/582664/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *