Назад в Центр знаний
Отпечатки

Микрозадачи и таймеры: реальный порядок кода

Одна проба порядка: синхронный код заканчивается первым, промисы стекают в порядке очереди, вложенные микрозадачи ждут, await возобновляется, таймер не прерывает цикл.

BotBrowser Team

Документация

Нужна структурированная документация по теме Отпечатки?

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

BotBrowser поддерживает повтор authorized same-origin фикстуры под объявленным профилем и сохраняет ограниченные наблюдения о порядке, в котором страница выполнила свои callback, но он не может изменить, как среда планирует свои очереди, не может заставить таймер прервать синхронный код и не превращает порядок callback в сигнал идентичности. Этот обзор прогоняет одну пробу порядка на фиксированном наборе форм и фиксирует, когда заканчивается синхронный код, когда стекают callback промисов, когда микрозадача, поставленная внутри микрозадачи, ждет очередь перед собой, когда возобновляется await, где оказывается process.nextTick относительно промиса, и задерживает ли блокирующий цикл таймер с нулевой задержкой.

Кратко

  • Синхронный код всегда заканчивается первым: проба печатает sync-start,sync-end до любого callback промиса, потому что текущая задача выполняется до конца.
  • Затем callback промисов стекают в порядке постановки, поэтому promise-1,microtask-1,promise-2 идут в порядке планирования, а таймер идет последним.
  • Микрозадача, поставленная внутри микрозадачи, ждет уже поставленные микрозадачи, поэтому порядок m1,m2,m1-nested,timeout.
  • await null возобновляется как микрозадача, поэтому код после await выполняется до таймера с нулевой задержкой.
  • process.nextTick выигрывает у промиса, поставленного после него, когда оба находятся в callback таймера или setImmediate либо в теле скрипта CommonJS; callback промиса, callback queueMicrotask и тело модуля ES печатают promise,nextTick.
  • Таймер с нулевой задержкой не может прервать синхронный блок: цикл на пятьдесят миллисекунд в пробе заставляет таймер сработать на пятьдесят миллисекунд или позже.
  • Повтор той же формы дает тот же порядок каждый раз, поэтому последовательность воспроизводима, а не гонка.

Содержание

Синхронный блок выполняется до конца первым, затем очередь MICROTASK стекает в порядке постановки callback, затем callback TIMER выполняется последним; отдельная панель показывает микрозадачу, поставленную внутри микрозадачи, которая ждет за очередью перед собой, а третья панель показывает блокирующий цикл, толкающий таймер с нулевой задержкой за его задержку

Почему синхронный код заканчивается до любого callback

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

node=v26.9.0
sync-micro-timer -> sync-start,sync-end,promise-1,microtask-1,promise-2,timeout-0

Две синхронные метки идут первыми, в порядке выполнения, и любой callback идет после. Ничто в синхронном блоке не может дать поставленному callback выполниться в середине, потому что среда не прерывает выполняющуюся задачу, чтобы начать поставленную. Это основание каждого вопроса о порядке ниже: текущая задача владеет потоком до своего возврата.

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

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

Как callback промисов стекают в порядке планирования

Callback промисов и callback queueMicrotask делят одну очередь микрозадач, и они стекают в порядке постановки, а не в порядке создания. Проба ставит промис, микрозадачу и еще один промис, затем таймер, и печатает порядок стекания:

sync-micro-timer -> sync-start,sync-end,promise-1,microtask-1,promise-2,timeout-0

Два промиса и микрозадача выполняются ровно в порядке постановки: promise-1, затем microtask-1, затем promise-2. Таймер выполняется последним, после того как вся очередь микрозадач пуста. Тот, кто ждет, что микрозадача обгонит промисы, потому что она выглядит как примитив более низкого уровня, ошибается; очередь это первым пришел, первым вышел, и тип callback не меняет его позицию.

Этот порядок объясняет, почему цепочка вызовов .then выполняется один за другим в одном обороте, а не по одному на таймер. Промис, который разрешается синхронно, ставит свой callback сразу, а второй .then на результате ставит другой callback, который выполняется после первого, все до следующего таймера. Страница, которая ждет цепочку промисов, а затем обновляет экран, делает всю цепочку за один оборот, поэтому обновление экрана появляется сразу, а не по этапам.

Порядок стекания также объясняет, почему неограниченная цепочка callback промисов может морить голодом таймеры. Callback, который ставит другой callback промиса, который ставит еще один, держит очередь микрозадач непустой, и среда не начинает следующий таймер, пока очередь не пуста. Цикл, построенный на callback промисов, который никогда не останавливается, поэтому блокирует каждый таймер и каждое событие ввода, хотя ни один callback не блокирует поток надолго. Голодание это та же заморозка, что и синхронный цикл выше, приходящая через очередь, а не через цикл. Это голодание есть то, что измерение времени самой страницы видит как джиттер: performance timing fingerprinting показывает, как чтение часов вокруг стекания очереди может превратить задержку планирования в число, которое записывает сборщик.

Где ждет микрозадача, поставленная внутри микрозадачи

Микрозадача, поставленная изнутри микрозадачи, не обгоняет очередь; она добавляется в конец очереди и выполняется после callback, что уже ждут. Проба ставит две микрозадачи, где первая ставит третью, и печатает порядок:

nested-microtask -> m1,m2,m1-nested,timeout

Порядок это m1, затем m2, затем m1-nested, затем таймер. Вложенная микрозадача не выполняется сразу после m1, которая ее поставила; она ждет, пока m2, уже стоящая в очереди, не выполнится. Только тогда очередь достигает m1-nested, и только когда вся очередь пуста, выполняется таймер.

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

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

Где возобновляется await

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

await-resumes-as-microtask -> async-start,sync,after-await,timeout

Асинхронная функция начинает и ставит async-start, затем приостанавливается на await. Синхронный код после вызова ставит sync. Функция затем возобновляется как микрозадача и ставит after-await. Таймер выполняется последним. Хотя await null нечего ждать, возобновление все равно микрозадача и все равно выполняется после завершения текущего синхронного блока, поэтому метка sync появляется между двумя половинами асинхронной функции.

Этот разрыв объясняет, почему асинхронная функция, которая выглядит последовательной, не синхронна. Тот, кто вызывает асинхронную функцию и ждет, что побочный эффект ее первой строки будет виден сразу, видит эффект, но тот, кто ждет, что эффект после первого await будет виден в том же операторе, не видит его, потому что возобновление поставлено в очередь. Разрыв это ровно одна микрозадача, невидимая при однопоточном чтении, но видимая в любой пробе порядка, которая помечает обе половины.

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

Где nextTick оказывается относительно промиса

process.nextTick это специфичная для Node очередь, которая выполняется до микрозадач промисов в большинстве случаев, и проба показывает, как он выигрывает внутри callback:

nextTick-before-promise -> nextTick,promise,timeout
nextTick-beats-later-promise-in-callback -> nextTick,promise

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

Правило уже, чем «nextTick всегда выигрывает». Контрольная проба поставила ту же пару в четыре контекста и напечатала nextTick,promise из тела скрипта CommonJS и из callback таймера, а затем promise,nextTick из callback промиса и из callback queueMicrotask. Тело модуля ES ведет себя как случаи микрозадачи и тоже печатает promise,nextTick. Разделение следует за контекстом, из которого ставятся строки: макрозадача опустошает очередь nextTick до очереди промисов, а контекст микрозадачи уже находится внутри очереди промисов, поэтому продолжение, поставленное там, выполняется до того, как управление вернется к nextTick.

Поэтому проба этой статьи изолирует каждый случай в callback таймера, и поэтому оба порядка, которые она печатает, являются результатами макрозадачи. Проба, которая ставит пару на верхнем уровне CommonJS, согласуется с ней; верхний уровень модуля ES или callback микрозадачи печатает обратное. Запись, которая говорит «nextTick выполняется первым» без указания контекста, зафиксировала результат, который больше нигде не воспроизведется.

Почему таймер никогда не прерывает блокирующий цикл

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

timer-not-preemptive -> elapsed_at_least_50=true

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

То же правило объясняет, почему «sleep», построенный на занятом цикле, хуже таймера. Занятый цикл, который ждет двести миллисекунд, блокирует каждый поставленный callback на двести миллисекунд, тогда как таймер, который срабатывает через двести миллисекунд, дает каждому поставленному callback выполниться между ними. Код, который заменяет таймер циклом, чтобы «упростить», превращает планирование в заморозку, и запись, которая показывает таймер, сработавший поздно, есть доказательство, что цикл выполнялся, а не что таймер был сломан.

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

Проверка детерминизма замыкает круг на всем выше:

repeat-deterministic -> spt|spt|spt unique=1

Три прогона одной формы дают один порядок, s, затем p, затем t, с одним уникальным результатом. Порядок не есть гонка, которая случайно выглядит стабильной один раз; это заданное поведение очередей, и повторный прогон его подтверждает. Проба, которая нашла бы разные порядки между повторами, измеряла бы что-то другое, например таймер, чья задержка была короче стекания очереди, и запись должна была бы объяснить вариацию, а не назвать ее недетерминизмом.

Записывайте показания порядка

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

  1. Среда и ее цикл событий, потому что у браузера нет process.nextTick и он упорядочивает микрозадачи промисов в конце текущей задачи.
  2. Точная очередь, в которую поставлен каждый callback, промис, queueMicrotask, nextTick или таймер, потому что очередь решает порядок, а тип callback нет.
  3. Порядок постановки callback, потому что очередь микрозадач это первым пришел, первым вышел, и запись без порядка постановки не может воспроизвести стекание.
  4. Граница оборота, выполнялась ли форма в макрозадаче, такой как callback таймера или setImmediate, в теле скрипта CommonJS или в микрозадаче, такой как callback промиса или queueMicrotask, потому что те же две строки печатают nextTick,promise в первых двух случаях и promise,nextTick в случаях микрозадачи.
  5. Длительность синхронного блока, потому что блокирующий цикл задерживает каждый таймер и запись без нее не может объяснить поздний таймер.
  6. Повторялась ли та же форма, потому что один прогон не может отличить заданный порядок от гонки, которая выглядит стабильной.

Чего такие показания не могут показать, так это ничего о том, как порядок страницы взаимодействует с тем, что она сообщает серверу. Страница может выполнять свои callback в детерминированном порядке и все равно отправлять запрос, время которого зависит от сети, и сервер, который видит приход запроса, не может вывести из него порядок микрозадач страницы. О байтах, которые callback обычно обрабатывает, TextDecoder и TextEncoder показывает, почему одни и те же байты декодируются по-разному в зависимости от метки, а о шаблоне, по которому callback часто бежит, RegExp lastIndex и флаги показывает, почему переиспользованный global шаблон меняет ответ между вызовами.

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

Публичные источники

Итоги

Относитесь к очереди как к части контракта порядка: синхронный код заканчивается первым, callback промисов и queueMicrotask стекают первым пришел, первым вышел, микрозадача, поставленная внутри микрозадачи, добавляется в конец, а await возобновляется как микрозадача, поэтому все это выполняется до таймера с нулевой задержкой. process.nextTick выигрывает у промиса, поставленного после него внутри callback таймера или setImmediate и внутри тела скрипта CommonJS, а callback промиса, callback queueMicrotask и тело модуля ES печатают обратное, поэтому изолируйте каждый случай в своей задаче и указывайте контекст в записи. Таймер никогда не прерывает блокирующий цикл, поэтому цикл на пятьдесят миллисекунд заставляет таймер с нулевой задержкой сработать поздно. Записывайте тип очереди, порядок постановки, границу оборота и длительность блока, и повторяйте форму один раз, чтобы сохраненная последовательность была воспроизводимой, а не гонкой, которая выглядит стабильной.

Читать дальше

О байтах, которые callback обычно обрабатывает, читайте TextDecoder и TextEncoder; о шаблоне, по которому callback часто бежит, читайте RegExp lastIndex и флаги.

#Микрозадача#Цикл Событий#setTimeout#Promise#nextTick

Эта статья входит в руководство: Что такое отпечаток браузера?

Переведите BotBrowser из исследований в продакшн

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