Skip to content

Асинхронное управление потоком

INFO

Материал в этом посте во многом вдохновлен книгой Mixu по Node.js.

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

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

Однако обратные вызовы могут стать сложными в обработке в более сложных процедурах. Это часто приводит к "аду обратных вызовов", когда множественные вложенные функции с обратными вызовами делают код более сложным для чтения, отладки, организации и т.д.

js
async1(function (input, result1) {
  async2(function (result2) {
    async3(function (result3) {
      async4(function (result4) {
        async5(function (output) {
          // что-то делаем с output
        });
      });
    });
  });
});

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

Здесь функции становятся очень полезными. Более сложные операции состоят из многих функций:

  1. стиль инициатора / входные данные
  2. промежуточное ПО
  3. терминатор

"Стиль инициатора / входные данные" - это первая функция в последовательности. Эта функция будет принимать исходные входные данные, если таковые имеются, для операции. Операция представляет собой исполняемую серию функций, и исходные входные данные будут в основном:

  1. переменные в глобальной среде
  2. прямой вызов с аргументами или без них
  3. значения, полученные из файловой системы или сетевых запросов

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

Функция промежуточного ПО вернет другую функцию, а функция терминатора вызовет обратный вызов. Следующее иллюстрирует поток к сетевым или файловым запросам. Здесь задержка равна 0, потому что все эти значения доступны в памяти.

js
function final(someInput, callback) {
  callback(`${someInput} и завершено выполнением обратного вызова`);
}
function middleware(someInput, callback) {
  return final(`${someInput} обработано промежуточным ПО`, callback);
}
function initiate() {
  const someInput = 'hello this is a function ';
  middleware(someInput, function (result) {
    console.log(result);
    // требуется, чтобы обратный вызов `return` вернул результат
  });
}
initiate();

Управление состоянием

Функции могут зависеть от состояния или нет. Зависимость от состояния возникает, когда входные данные или другая переменная функции зависят от внешней функции.

Таким образом, существуют две основные стратегии управления состоянием:

  1. передача переменных непосредственно в функцию, и
  2. получение значения переменной из кэша, сессии, файла, базы данных, сети или другого внешнего источника.

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

Управление потоком

Если объект доступен в памяти, итерация возможна, и поток управления не изменится:

js
function getSong() {
  let _song = '';
  let i = 100;
  for (i; i > 0; i -= 1) {
    _song += `${i} beers on the wall, you take one down and pass it around, ${
      i - 1
    } bottles of beer on the wall\n`;
    if (i === 1) {
      _song += "Hey let's get some more beer";
    }
  }
  return _song;
}
function singSong(_song) {
  if (!_song) throw new Error("song is '' empty, FEED ME A SONG!");
  console.log(_song);
}
const song = getSong();
// это будет работать
singSong(song);

Однако, если данные существуют вне памяти, итерация больше не будет работать:

js
function getSong() {
  let _song = '';
  let i = 100;
  for (i; i > 0; i -= 1) {
    /* eslint-disable no-loop-func */
    setTimeout(function () {
      _song += `${i} beers on the wall, you take one down and pass it around, ${
        i - 1
      } bottles of beer on the wall\n`;
      if (i === 1) {
        _song += "Hey let's get some more beer";
      }
    }, 0);
    /* eslint-enable no-loop-func */
  }
  return _song;
}
function singSong(_song) {
  if (!_song) throw new Error("song is '' empty, FEED ME A SONG!");
  console.log(_song);
}
const song = getSong('beer');
// это не будет работать
singSong(song);
// Uncaught Error: song is '' empty, FEED ME A SONG!

Почему это произошло? setTimeout инструктирует процессор сохранить инструкции в другом месте шины и указывает, что данные запланированы для получения позже. Проходят тысячи тактов процессора, прежде чем функция снова запускается с меткой 0 миллисекунд, процессор извлекает инструкции с шины и выполняет их. Единственная проблема в том, что song ('') была возвращена за тысячи циклов до этого.

Та же ситуация возникает при работе с файловыми системами и сетевыми запросами. Главный поток просто не может быть заблокирован на неопределенный период времени — поэтому мы используем обратные вызовы для планирования выполнения кода во времени контролируемым образом.

Вы сможете выполнять почти все свои операции с помощью следующих 3 шаблонов:

  1. Последовательно: функции будут выполняться в строго последовательном порядке, этот наиболее похож на циклы for.
js
// операции определены в другом месте и готовы к выполнению
const operations = [
  { func: function1, args: args1 },
  { func: function2, args: args2 },
  { func: function3, args: args3 },
];
function executeFunctionWithArgs(operation, callback) {
  // выполняет функцию
  const { args, func } = operation;
  func(args, callback);
}
function serialProcedure(operation) {
  if (!operation) process.exit(0); // завершено
  executeFunctionWithArgs(operation, function (result) {
    // продолжить ПОСЛЕ обратного вызова
    serialProcedure(operations.shift());
  });
}
serialProcedure(operations.shift());
  1. Полный параллелизм: когда порядок не имеет значения, например, отправка электронных писем списку из 1 000 000 получателей.
js
let count = 0;
let success = 0;
const failed = [];
const recipients = [
  { name: 'Bart', email: 'bart@tld' },
  { name: 'Marge', email: 'marge@tld' },
  { name: 'Homer', email: 'homer@tld' },
  { name: 'Lisa', email: 'lisa@tld' },
  { name: 'Maggie', email: 'maggie@tld' },
];
function dispatch(recipient, callback) {
  // `sendMail` — это гипотетический SMTP-клиент
  sendMail(
    {
      subject: 'Dinner tonight',
      message: 'We have lots of cabbage on the plate. You coming?',
      smtp: recipient.email,
    },
    callback
  );
}
function final(result) {
  console.log(`Result: ${result.count} attempts \
      & ${result.success} succeeded emails`);
  if (result.failed.length)
    console.log(`Failed to send to: \
        \n${result.failed.join('\n')}\n`);
}
recipients.forEach(function (recipient) {
  dispatch(recipient, function (err) {
    if (!err) {
      success += 1;
    } else {
      failed.push(recipient.name);
    }
    count += 1;
    if (count === recipients.length) {
      final({
        count,
        success,
        failed,
      });
    }
  });
});
  1. Ограниченный параллелизм: параллелизм с ограничением, например, успешная отправка 1 000 000 электронных писем из списка из 10 миллионов пользователей.
js
let successCount = 0;
function final() {
  console.log(`dispatched ${successCount} emails`);
  console.log('finished');
}
function dispatch(recipient, callback) {
  // `sendEmail` — это гипотетический SMTP-клиент
  sendMail(
    {
      subject: 'Dinner tonight',
      message: 'We have lots of cabbage on the plate. You coming?',
      smtp: recipient.email,
    },
    callback
  );
}
function sendOneMillionEmailsOnly() {
  getListOfTenMillionGreatEmails(function (err, bigList) {
    if (err) throw err;
    function serial(recipient) {
      if (!recipient || successCount >= 1000000) return final();
      dispatch(recipient, function (_err) {
        if (!_err) successCount += 1;
        serial(bigList.pop());
      });
    }
    serial(bigList.pop());
  });
}
sendOneMillionEmailsOnly();

Каждый из них имеет свои варианты использования, преимущества и проблемы, с которыми вы можете экспериментировать и о которых можете подробнее почитать. Самое главное, помните о модульности ваших операций и использовании обратных вызовов! Если вы сомневаетесь, относитесь ко всему, как к промежуточному ПО!