JavaScript. Event Loop — макрозадачи и микрозадачи

04.06.2022

Теги: BackendFrontendJavaScriptWeb-разработкаСобытиеТеорияЦикл

Событийный цикл

Поток выполнения в браузере, равно как и в Node.js, основан на событийном цикле. Есть бесконечный цикл, в котором движок JavaScript ожидает задачи, исполняет их и снова ожидает появления новых.

  1. Пока есть задачи — выполнять их по очереди, начиная с самой старой
  2. Бездействовать до появления новой задачи, а затем перейти к пункту 1

Движок JavaScript большую часть времени ничего не делает и работает, только если требуется исполнить скрипт/обработчик или обработать событие. Примеры задач:

  • Когда загружается внешний скрипт <script src="…">, то задача — выполнение этого скрипта
  • Когда пользователь двигает мышь, задача — сгенерировать событие mousemove и выполнить его обработчики
  • Когда истечёт таймер, установленный с помощью setTimeout(func, …), задача — выполнение функции func

Задачи поступают на выполнение — движок выполняет их — затем ожидает новые задачи (во время ожидания практически не нагружая процессор компьютера). Может так случиться, что задача поступает, когда движок занят чем-то другим, тогда она ставится в очередь. Очередь, которую формируют такие задачи, называют «очередью макрозадач» (macrotask queue).

Например, когда движок занят выполнением скрипта, пользователь может передвинуть мышь, тем самым вызвав появление события mousemove, или может истечь таймер, установленный setTimeout, и т.п. Эти задачи не могут быть выполнены прямо сейчас (потому что движок JavaScript — однопоточный), поэтому добавляются в очередь макрозадач.

Задачи из очереди исполняются по правилу «первым пришёл — первым ушёл». Когда браузер заканчивает выполнение скрипта, он обрабатывает событие mousemove, затем выполняет обработчик, заданный setTimeout, и так далее.

Два важных момента:

  1. Рендеринг (отрисовка страницы) никогда не происходит во время выполнения задачи движком. Не имеет значения, сколь долго выполняется задача. Изменения в DOM отрисовываются только после того, как задача выполнена.
  2. Если задача выполняется очень долго, то браузер не может выполнять другие задачи, обрабатывать пользовательские события, поэтому спустя некоторое время браузер предлагает «убить» долго выполняющуюся задачу. Такое возможно, когда в скрипте много сложных вычислений или ошибка, ведущая к бесконечному циклу.

Первый пример. Разбиение «тяжёлой» задачи

Допустим, у нас есть задача, требующая значительных ресурсов процессора. Пока движок занят выполнением этой задачи, он не может делать ничего, связанного с DOM, не может обрабатывать пользовательские события и т.д. Возможно даже «подвисание» браузера, что совершенно неприемлемо. Мы можем избежать этого, разбив задачу на части. Выполнить часть задачи, а затем запланировать setTimeout с нулевой задержкой) для выполнения следующей части задачи и т.д.

let i = 0;

let start = Date.now();

function count() {
    // делаем тяжёлую работу
    for (let j = 0; j < 1000000000; j++) {
        i++;
    }
    alert('Done in ' + (Date.now() - start) + 'ms');
}

count();

Давайте разобьём задачу на части, воспользовавшись вложенным setTimeout:

let i = 0;

let start = Date.now();

function count() {
    // делаем часть тяжёлой работы
    do {
        i++;
    } while (i % 1000000 !== 0);

    if (i === 1000000000) {
        alert('Done in ' + (Date.now() - start) + 'ms');
    } else {
        setTimeout(count); // планируем новый вызов
    }
}

count();

Теперь если новая сторонняя задача (например, событие onclick) появляется, пока движок занят выполнением 1-й части тяжелой задачи, то она становится в очередь, и затем выполняется, когда 1-я часть завершена, перед следующей частью. Периодические возвраты в событийный цикл между запусками count() дают движку достаточно «воздуха», чтобы сделать что-то ещё, отреагировать на действия пользователя.

Оба варианта кода — с разбиением задачи с помощью setTimeout и без — сопоставимы по скорости выполнения. Нет большой разницы в общем времени счёта. Чтобы сократить разницу ещё сильнее, давайте немного улучшим наш код — перенесём планирование очередного вызова в начало count().

let i = 0;

let start = Date.now();

function count() {
    // перенесём планирование очередного вызова в начало
    if (i < 1000000000 - 1000000) {
        setTimeout(count); // запланировать новый вызов
    }

    do {
        i++;
    } while (i % 1000000 !== 0);

    if (i === 1000000000) {
        alert('Done in ' + (Date.now() - start) + 'ms');
    }
}

count();

Теперь, когда мы начинаем выполнять count() и видим, что потребуется выполнить count() ещё раз, мы планируем этот вызов немедленно, перед выполнением работы. Если запустить этот код, то можно заметить, что он требует значительно меньше времени.

Дело в том, что у браузера есть минимальная задержка в 4 миллисекунды при множестве вложенных вызовов setTimeout. Даже если мы указываем задержку ноль, на самом деле она будет равна 4 мс (или чуть больше). Поэтому чем раньше мы запланируем выполнение — тем быстрее выполнится код.

Второй пример. Индикация прогресса

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

В примере ниже мы не увидим промежуточное состояние индикатора, а только конечное состояние, когда задача полностью завершена:

<progress id="progress" value="0"></progress>

<script>
    let i = 0;
    let max = 1000000;

    function count() {
        for (let i = 0; i < max; i++) {
            i++;
            progress.value = i;
        }
    }

    progress.max = max;
    count();
</script>

Если мы разобьём тяжёлую задачу на части, используя setTimeout, то изменения индикатора будут отрисованы в промежутках между частями:

<progress id="progress" value="0"></progress>

<script>
    let i = 0;
    let max = 1000000;

    function count() {
        // сделать часть крупной задачи
        do {
            i++;
            progress.value = i;
        } while (i % 1000 !== 0);
        // запланировать следующую часть
        if (i < max) {
            setTimeout(count);
        }
    }

    progress.max = max;
    count();
</script>

Третий пример. Действие после события

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

<button id="menu">Меню (нажми меня)</button>

<script>
    menu.onclick = function() {
        console.log('Событие click, начало');
        let customEvent = new CustomEvent('menu-open', {bubbles: true});
        menu.dispatchEvent(customEvent);
        console.log('Событие click, конец');
    };
    document.addEventListener('menu-open', () => console.log('Вложенное событие'))
</script>
Событие click, начало
Вложенное событие
Событие click, конец

Вложенное событие menu-open успевает всплыть и запустить обработчик на document. Обработка вложенного события полностью завершается до того, как управление возвращается во внешний код, то есть функцию onclick. Если нам это не подходит, то мы можем обернуть генерацию события menu-open в setTimeout с нулевой задержкой, чтобы оно возникло после того, как полностью обработано событие click.

<button id="menu">Меню (нажми меня)</button>

<script>
    menu.onclick = function() {
        console.log('Событие click, начало');
        let customEvent = new CustomEvent('menu-open', {bubbles: true});
        setTimeout(() => menu.dispatchEvent(customEvent));
        console.log('Событие click, конец');
    };
    document.addEventListener('menu-open', () => console.log('Вложенное событие'))
</script>
Событие click, начало
Событие click, конец
Вложенное событие

Макрозадачи и Микрозадачи

Помимо макрозадач существуют еще микрозадачи, которые обычно создаются промисами. Выполнение обработчика then, catch, finally становится микрозадачей. Сразу после каждой макрозадачи движок исполняет все задачи из очереди микрозадач перед тем, как выполнить следующую макрозадачу или отобразить изменения на странице, или сделать что-то ещё.

Микрозадачи также используются «под капотом» await, так как это форма обработки промиса.
setTimeout(() => console.log('timeout'));

Promise.resolve()
    .then(() => console.log(('promise'));

console.log(('code');

Какой здесь будет порядок?

  • code будет первым, т.к. это обычный синхронный код, т.е. макрозадача из очереди макрозадач
  • promise будет вторым, это микрозадача из очереди микрозадач после очередной макрозадачи
  • timeout будет последним, потому что это очередная макрозадача из очереди макрозадач

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

Если мы хотим запустить функцию асинхронно (после текущего кода), но до отображения изменений и до новых событий, то можем запланировать это через функцию queueMicrotask. Ниже пример с индикатором выполнения, похожий на предыдущий, но в этот раз использована функция queueMicrotask вместо setTimeout. Обратите внимание — отрисовка страницы происходит только в самом конце. Как и в случае обычного синхронного кода.

<progress id="progress" value="0"></progress>

<script>
    let i = 0;
    let max = 1000000;

    function count() {
        // сделать часть крупной задачи
        do {
            i++;
            progress.value = i;
        } while (i % 1000 !== 0);
        // запланировать следующую часть
        if (i < max) {
            queueMicrotask(count);
        }
    }

    progress.max = max;
    count();
</script>

Вместо заключения

Более подробный алгоритм событийного цикла:

  1. Выбрать и исполнить старейшую задачу из очереди макрозадач
  2. Выбрать и исполнить все микрозадачи из очереди микрозадач
  3. Отрисовать изменения страницы, если они есть
  4. Если очередь макрозадач пуста — ждать появления макрозадачи
  5. Перейти к первому шагу

Поиск: Backend • Frontend • JavaScript • Web-разработка • Событие • Теория • Цикл

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.