Модель конкурентності та цикл подій

JavaScript має модель одночасності, що базується на циклі подій, який є відповідальним за виконання коду, збір та обробку подій та виконання підзадач з черги. Ця модель доволі сильно відрізняється від моделей у інших мовах, таких як С та Java.

Концепції виконання

Наступні розділи пояснють теоретичну модель. Сучасні імплементації JavaScript реалізують і значно оптимізують описану семантику.

Візуальне відображення

Стек, купа, черга

Стек

Виклики функцій утворюють стек фреймів (frames).

function foo(b) {
  var a = 10;
  return a + b + 11;
}

function bar(x) {
  var y = 3;
  return foo(x * y);
}

console.log(bar(7)); // вертає 42

Коли викликається bar, утворюється перший фрейм, який містить аргументи та локальні змінні функції bar. Коли bar викликає foo, створюється другий фрейм та розміщується над першим, він містить аргументи та локальні змінні функції foo. Коли foo повертає значення, верхній фрейм виштовхується зі стеку (залишаючи лише фрейм виклику bar). Коли bar повертає значення, стек стає порожнім.

Купа

Об'єкти розподіляються у купі, яка є лише назвою для позначення великої (здебільшого не структурованої) області пам'яті.

Черга

Процес виконання JavaScript використовує чергу повідомлень, яка є списком повідомлень, що мають бути опрацьовані. Кожне повідомлення має пов'язану функцію, яка викликається для обробки цього повідомлення 

В певний момент циклу подій процес виконання починає обробку повідомлень з черги, починаючи з найстаршого. Для цього повідомлення видаляється з черги, а його пов'язана функція викликається з повідомленням в якості вхідного параметра. Як завжди, виклик функції створює новий фрейм стеку для цієї функції.

Опрацювання функцій продовжується, доки стек знову не стане порожнім. Тоді цикл подій опрацює наступне повідомлення у черзі (якщо воно є).

Цикл подій

Цикл подій отримав свою назву через те, як він зазвичай реалізується. Як правило, це схоже на:

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

queue.waitForMessage синхронно чекає на прибуття повідомлення (якщо воно вже не надійшло і не чекає на обробку).

"Виконання до завершення"

Кожне повідомлення обробляється до завершення, перш, ніж обробляти будь-яке інше повідомлення.

Це надає деякі приємні властивості для вашої програми, в тому числі той факт, що, коли виконується функція, вона не може бути попередньо вилучена і виконається до кінця перш, ніж буде запущено будь-який інший код (і зможе змінювати дані, якими користується функція). Це відрізняється, наприклад, від C, де, якщо функція виконується у потоці, її можна зупинити в будь-якій точці, щоб запустити інший код в іншому потоці.

Зворотним боком цієї моделі є те, що, якщо обробка повідомлення займає надто багато часу, веб-застосунок не може обробити взаємодії користувача, як-от натискання чи прокручування. Веб-переглядач пом'якшує це діалоговим вікном "a script is taking too long to run" (виконання сценарію займає забагато часу). Гарною практикою є робити обробку повідомлень короткою і, за можливості, розбивати одне повідомлення на декілька.

Додавання повідомлень

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

Функція setTimeout викликається з двома аргументами: повідомлення, що додається до черги, та значення часу (необов'язкове; за замовчуванням 0). Значення часу відображає (мінімальну) затримку, після якої повідомлення буде, власне, додане до черги. Якщо в черзі немає інших повідомлень, це повідомлення буде оброблене одразу після затримки. Однак, якщо там є повідомлення, повідомленню setTimeout доведеться зачекати, доки не будуть оброблені інші повідомлення. З цієї причини другий аргумент вказує мінімальний, а не гарантований час.

Ось приклад, який демонструє цю концепцію (setTimeout не виконується негайно після того, як його таймер завершився): 

const s = new Date().getSeconds();

setTimeout(function() {
  // виводить "2", тобто, функція зворотного виклику не запустилась одразу через 500 мілісекунд.
  console.log("Функція запустилась через " + (new Date().getSeconds() - s) + " секунд");
}, 500)

while (true) {
  if (new Date().getSeconds() - s >= 2) {
    console.log("Добре, виконувалось 2 секунди")
    break;
  }
}

Нульові затримки

Нульова затримка насправді не означає, що зворотній виклик запуститься через нуль мілісекунд. Виклик setTimeout із затримкою в 0 (нуль) мілісекунд не виконує функцію зворотного виклику після заданого інтервалу.

Виконання залежить від кількості задач, що чекають у черзі. У наведеному нижче прикладі повідомлення "це просто повідомлення" буде написане у консоль раніше, ніж буде оброблене повідомлення у зворотному виклику, оскільки затримка - це мінімальний час, необхідний для обробки запиту (а не гарантований час).

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

(function() {

  console.log('це початок');

  setTimeout(function cb() {
    console.log('Зворотний виклик 1: це повідомлення зворотного виклику');
  });

  console.log('це просто повідомлення');

  setTimeout(function cb1() {
    console.log('Зворотний виклик 2: це повідомлення зворотного виклику');
  }, 0);

  console.log('це кінець');

})();

// "це початок"
// "це просто повідомлення"
// "це кінець"
// "Зворотний виклик 1: це повідомлення зворотного виклику"
// "Зворотний виклик 2: це повідомлення зворотного виклику"

Декілька процесів виконання, що спілкуються між собою

Веб-виконавець або iframe перехресного походження має свій стек, купу та чергу повідомлень. Два різних процеси виконання можуть спілкуватися надсиланням повідомлень за допомогою методу postMessage. Цей метод додає повідомлення до іншого процесу виконання, якщо останній прослуховує події message.

Жодного блокування

Дуже цікавою властивістю моделі циклу подій є те, що JavaScript, на відміну від багатьох інших мов, ніколи не блокує. Управління введенням/виводом зазвичай здійснюється за допомогою подій та зворотних викликів, тому, коли програма чекає на результат запиту IndexedDB чи запиту XHR, вона може опрацьовувати інші події, такі як введення даних користувачем.

Існують спадкові винятки, такі як alert або синхронний XHR, але вважається гарною практикою їх уникати. Будьте обережні: існують винятки з винятку (але зазвичай це помилки реалізації, а не що-небудь інше).

Специфікації

Специфікація
HTML Living Standard
The definition of 'Event loops' in that specification.
Node.js Event Loop