Мы часто говорим, что цикл событий JS имеет микро-очередь и макро-очередь, все асинхронные события будут помещены в эти две очереди для выполнения, и микро-задачи должны выполняться перед макро-задачами. На самом деле цикл событий — это способ работы с многопоточностью. Обычно для повышения эффективности работы создается один или несколько потоков для выполнения параллельных операций, а затем сообщается о результате и завершается после завершения расчета.Работать, когда есть задача, и спать, когда ее нет. задача, чтобы потоки не создавались и не уничтожались часто. Такой способ работы позволяет этим потокам использовать цикл обработки событий.
1. Обычный цикл событий JS
Мы знаем, что JS является однопоточным, при выполнении относительно длинного кода JS страница будет зависать и не сможет отвечать, но все ваши операции будут записаны другим потоком, например, нажатие кнопки при зависании, хотя обратный вызов будет не запускаться немедленно, операция щелчка, только что запущенная, будет запущена при выполнении JS. Итак, говорят, что существует очередь, которая записывает все операции, которые должны быть выполнены.Эта очередь делится на макро и микро, такие как события setTimeout/ajax/user, которые относятся к макросу, а Promise и MutationObserver относятся к микро, и микро будет работать лучше, чем макрос. Быстрее, следующий код:
setTimeout(() => console.log(0), 0);
new Promise(resolve => {
resolve();
console.log(1)
}).then(res => {
console.log(2);
});
console.log(3);
Его порядок вывода — 1, 3, 2, 0, где setTimeout — макрозадача, поэтому она медленнее, чем микрозадача Promise.
2. Характер макрозадачи
На самом деле макрозадачи (MacroTask) в исходном коде Chrome нет.Так называемая макрозадача на самом деле является многопоточным циклом событий или циклом сообщений в обычном понимании.Лучше называть его очередью цикла сообщений, чем очередь макросов.
Все резидентные многопотоки Chrome, включая потоки браузера и потоки рендеринга страниц, выполняются в цикле обработки событий. Мы знаем, что Chrome представляет собой многопроцессную структуру. Основной поток и поток ввода-вывода процесса браузера объединены и отвечают за адрес. поле ввода.Процессы на уровне браузера для таких функций, как ответ, загрузка ресурсов по сетевому запросу и т. д., и каждая страница имеет независимый процесс.Основным потоком каждого процесса страницы является поток рендеринга, который отвечает за построение DOM, рендеринг, выполнение JS и суб-потоков ввода-вывода.
Все эти потоки являются резидентными потоками, они работают в бесконечном цикле for, у них есть несколько очередей задач, они постоянно выполняют задачи от себя или других потоков через PostTask, или они спят до установленного времени или кто-то PostTask не разбудит их.
через исходный кодmessage_pump_default.ccФункция Run может знать рабочий режим цикла событий следующим образом:
void MessagePumpDefault::Run(Delegate* delegate) {
// 在一个死循环里面跑着
for (;;) {
// DoWork会去执行当前所有的pending_task(放一个队列里面)
bool did_work = delegate->DoWork();
if (!keep_running_)
break;
// 上面的pending_task可能会创建一些delay的task,如定时器
// 获取到delayed的时间
did_work |= delegate->DoDelayedWork(&delayed_work_time_);
if (!keep_running_)
break;
if (did_work)
continue;
// idl的任务是在第一步没有执行被deferred的任务
did_work = delegate->DoIdleWork();
if (!keep_running_)
break;
if (did_work)
continue;
ThreadRestrictions::ScopedAllowWait allow_wait;
if (delayed_work_time_.is_null()) {
// 没有delay时间就一直睡着,直到有人PostTask过来
event_.Wait();
} else {
// 如果有delay的时间,那么进行睡眠直到时间到被唤醒
event_.TimedWaitUntil(delayed_work_time_);
}
}
}
Во-первых, код выполняется в бесконечном цикле for. Первым шагом является вызов DoWork для обхода и удаления всех pending_tasks, которые не отложены в очереди задач для выполнения. Некоторые задачи могут быть отложены до третьего шага, DoIdlWork, для Вторым шагом является выполнение этих задач. Для отложенных задач, если они не могут быть выполнены немедленно, установите время ожидания delayed_work_time_ и верните did_work значение false, и разбудите выполнение после того, как время ожидания TimedWaitUntil последнего кода казнен.
Это основная модель многопоточной петли событий. Откуда выполняются задачи так много потоков?
Каждый поток имеет целевую задачу Task_Runner или более типов, каждый с собственной задачей задач задач_Runner, Chrome Task будет разделена на множество типов, видимыхtask_type.h:
kDOMManipulation = 1,
kUserInteraction = 2,
kNetworking = 3,
kMicrotask = 9,
kJavascriptTimer = 10,
kWebSocket = 12,
kPostedMessage = 13,
...
Цикл сообщений имеет свой собственныйmessage_loop_task_runner, эти объекты task_runner являются общими, и другие потоки могут вызывать функцию PostTask этого task_runner для отправки задач. В приведенном выше цикле for отложенная задача также извлекается для выполнения через функцию TakeTask в task_runner.
При отправке задачи задача будет поставлена в очередь, и в то же время будет разбужен поток:
// 需要上锁,防止多个线程同时执行
AutoLock auto_lock(incoming_queue_lock_);
incoming_queue_.push(std::move(pending_task));
task_source_observer_->DidQueueTask(was_empty);
Поскольку несколько потоков совместно используют объект task_runner, он должен быть заблокирован, когда ему даются задачи публикации. DidQueueTask, вызванный в последней строке, разбудит поток уведомлений:
// 先调
message_loop_->ScheduleWork();
// 上面的代码会调
pump_->ScheduleWork();
// 最后回到message_pump进行唤醒
void MessagePumpDefault::ScheduleWork() {
// Since this can be called on any thread, we need to ensure that our Run
// loop wakes up.
event_.Signal();
}
Что такое так называемая задача? Задача на самом деле является обратным вызовом, вторым параметром следующего вызова кода:
GetTaskRunner()->PostDelayedTask(
posted_from_,
BindOnce(&BaseTimerTaskInternal::Run, Owned(scheduled_task_)), delay);
Подождите, сказав так много, кажется, что это не имеет никакого отношения к JS? На самом деле нет отношений в полцента, так как все это до выполнения JS. Не спешите.
Выше приведен код, выполняемый циклом событий по умолчанию, но поток рендеринга Mac Chrome там не выполняется. Его цикл событий использует NSRunLoop Mac Cocoa sdk. Согласно исходному коду, это из-за полосы прокрутки страницы . , всплывающее окно выбора раскрывающегося списка использует Cocoa, поэтому оно должно быть подключено к механизму цикла обработки событий Cococa, как показано в следующем коде:
#if defined(OS_MACOSX)
// As long as scrollbars on Mac are painted with Cocoa, the message pump
// needs to be backed by a Foundation-level loop to process NSTimers. See
// http://crbug.com/306348#c24 for details.
std::unique_ptr<base::MessagePump> pump(new base::MessagePumpNSRunLoop());
std::unique_ptr<base::MessageLoop> main_message_loop(
new base::MessageLoop(std::move(pump)));
#else
// The main message loop of the renderer services doesn't have IO or UI tasks.
std::unique_ptr<base::MessageLoop> main_message_loop(new base::MessageLoop());
#endif
Если это OS_MACOSX, насос циркуляции сообщений использует NSRunLoop, в противном случае он использует значение по умолчанию. Значение этого насоса насоса должно относиться к источнику сообщения. на самом деле ввеб-сайт crbugВ ходе обсуждения отправители исходного кода Chromium по-прежнему надеются удалить Cococa в потоке рендеринга и использовать собственную графическую библиотеку Chrome Skia для рисования полос прокрутки, чтобы поток рендеринга не реагировал напрямую на события UI/IO, но нет Cycle to do this event , из более раннего обсуждения вы можете видеть, что кто-то пытался это сделать, но получил ошибку и, наконец, вернул ее обратно.
Помпа Cococa и помпа по умолчанию имеют унифицированный внешний интерфейс, например, есть функция ScheduleWork для пробуждения потока, но внутренняя реализация отличается, например, разный метод пробуждения.
Поток ввода-вывода Chrome (включая дочерний поток ввода-вывода процесса страницы) добавляет цикл обработки сообщений, предоставляемый библиотекой libevent.c, в насос по умолчанию. libevent — это кроссплатформенная сетевая библиотека, управляемая событиями, в основном используемая для программирования сокетов, управляемого событиями. Файл насоса, который обращается к libevent, называетсяmessage_pump_libevent.cc, который добавляет строку к коду помпы по умолчанию:
bool did_work = delegate->DoWork();
if (!keep_running_)
break;
event_base_loop(event_base_, EVLOOP_NONBLOCK);
Просто чтобы посмотреть, будет ли libevent делать что-нибудь после DoWork. Так что видно, что он устанавливает цикл событий libevent в свой собственный цикл событий, но этот libevent неблокируемый, то есть он выполнится только один раз, а затем завершится, а также у него есть функция пробуждения.
Теперь давайте обсудим что-то связанное с JS.
(1) Пользовательские события
Когда мы инициируем событие мыши на странице, процесс браузера получает его первым, а затем передает его процессу страницы через библиотеку многопроцессорной связи Mojo в Chrome.Как показано на следующем рисунке, сообщение пересылается другим процессам через Mojo. :
Видно, что принцип этого Mojo заключается в использовании локального сокета для многопроцессорной связи, поэтому последний метод — использовать сокет записи. Сокет — это распространенный способ взаимодействия нескольких процессов.
Наблюдая за процессом страницы через точку останова, предполагается, что он должен быть разбужен libevent подпотока ввода-вывода процесса страницы и, наконец, вызвать PostTask для task_runner цикла сообщений:
Это не было проверено напрямую, потому что это не очень легко проверить. Однако, совмещая эти библиотеки и наблюдения за точками останова, этот способ должен быть более разумным и возможным, а этого легко добиться введением libevent.
То есть, обмен сообщениями о щелчке мыши выглядит следующим образом:
Документация Chromium также описывает этот процесс, но эта документация немного устарела.
Другая распространенная асинхронная операция — setTimeout.
(2) установить время ожидания
Чтобы исследовать поведение setTimeout, мы запускаем следующий код JS:
console.log(Object.keys({a: 1}));
setTimeout(() => {
console.log(Object.keys({b: 2}));
}, 2000);
затем вv8/src/runtime/runtime_object.ccУстановив точку останова в функции этого файла runtime_objectSeaskeys этого файла, вы можете наблюдать за тому, что время исполнения Settimeout. Как показано на следующем рисунке, эта функция имеет место, где выполняется объект.
Мы обнаружили, что место, где выполняется Object.keys при зависании точки останова в первый раз, срабатывает и выполняется HTMLParserScriptParser после DoWork, а второй setTimeout выполняется в DoDelayedWork (упомянутая выше модель цикла событий) of.
В частности, после первого выполнения Object.keys будет зарегистрирован DOMTimer, и этот DOMTimer отправит отложенную задачу в основной поток (потому что в данный момент он выполняется в основном потоке), и время задержки указано в этой задаче , так что в цикле событий это время задержки будет использоваться как время ожидания TimedWaitUntil (поток рендеринга использует CFRunLoopTimerSetNextFireDate Cococa).Следующий кодпоказано:
TimeDelta interval_milliseconds = std::max(TimeDelta::FromMilliseconds(1), interval);
// kMinimumInterval = 4 kMaxTimerNestingLevel = 5
// 如果嵌套了5层的setTimeout,并且时间间隔小于4ms,那么取时间为最小值4ms
if (interval_milliseconds < kMinimumInterval && nesting_level_ >= kMaxTimerNestingLevel)
interval_milliseconds = kMinimumInterval;
if (single_shot)
StartOneShot(interval_milliseconds, FROM_HERE);
else
StartRepeating(interval_milliseconds, FROM_HERE);
Так как это одноразовый setTimeout, то StartOneShort в предпоследней строке будет скорректирован Эта функция, наконец, скорректирует PostTask для timer_task_runner:
И вы можете видеть, что время задержки — это переданные 2000 мс, которые здесь конвертируются в наносекунды. Этот timer_task_runner запускается в потоке рендеринга, как message_loop_task_runner.Этот timer_task_runner, наконец, использует это время задержки для отправки задачи задержки в средство выполнения задач цикла сообщений.
Что можно увидеть в исходном коде, минимальное время для вызова setinterval составляет 4 мс:
// Chromium uses a minimum timer interval of 4ms. We'd like to go
// lower; however, there are poorly coded websites out there which do
// create CPU-spinning loops. Using 4ms prevents the CPU from
// spinning too busily and provides a balance between CPU spinning and
// the smallest possible interval timer.
static constexpr TimeDelta kMinimumInterval = TimeDelta::FromMilliseconds(4);
Цель состоит в том, чтобы избежать слишком частых обращений к центральному процессору. На самом деле, это время также зависит от точности времени, которую может обеспечить операционная система, особенно в Windows, посредствомtime_win.ccИз этого файла мы можем узнать, что обычная ошибка точности времени, которую может предоставить Windows, составляет 10 ~ 15 мс, то есть, когда вы устанавливаете Timeout 10 мс, фактический интервал выполнения может составлять несколько миллисекунд или более 20 миллисекунд. Таким образом, Chrome оценит время задержки:
#if defined(OS_WIN)
// We consider the task needs a high resolution timer if the delay is
// more than 0 and less than 32ms. This caps the relative error to
// less than 50% : a 33ms wait can wake at 48ms since the default
// resolution on Windows is between 10 and 15ms.
if (delay > TimeDelta() &&
delay.InMilliseconds() < (2 * Time::kMinLowResolutionThresholdMs)) {
pending_task.is_high_res = true;
}
#endif
Для сравнения, если задержка установлена на небольшую, он попытается использовать время высокой точности. Однако, поскольку API высокоточного времени (QPC) требует поддержки операционной системы и требует много времени и энергии, он не будет активирован, если ноутбук не подключен к сети. Но в целом можно подумать, что setTimeout JS может быть с точностью до 10 мс.
Другой вопрос, а что если время setTimeout равно 0? То же самое, он также опубликует задачу в конце, но время задержки этой задачи равно 0, и она будет выполнена в функции DoWork цикла сообщений.
Следует отметить, что setTimeout хранится в sequence_queue, что должно строго обеспечивать порядок выполнения (а очередь вышеописанного цикла сообщений не может быть строго гарантирована). Связанная функция RunTask этой последовательности будет использоваться в качестве обратного вызова задачи и передана исполнителю задачи цикла событий для выполнения задач в собственной очереди.
Поэтому, когда мы выполняем setTimeout 0, мы размещаем задачу в очереди цикла сообщений, а затем выполняем работу текущей задачи, например код, который не был выполнен после setTimeout 0.
Здесь обсуждается цикл событий, а затем мы обсуждаем микрозадачи и микроочереди.
2. Микрозадачи и микроочереди
Микроочередь — это настоящая очередь и реализация в V8. Микрозадачи в V8 делятся на следующие 4 типа (видимыеmicrotask.h):
- callback
- callable
- promiseFullfil
- promiseReject
Первый обратный вызов относится к обычным обратным вызовам, включая некоторые обратные вызовы задач из blink, такие как Mutation Observer. Второй вызываемый объект — это задача для внутренней отладки, а два других — выполнение и сбой обещания. У finally обещания есть then_finally и catch_finally, которые будут переданы в качестве параметров then/catch для окончательного выполнения.
Когда выполняются микрозадачи? Отладка с помощью следующего JS:
console.log(Object.keys({a: 1}));
setTimeout(() => {
console.log(Object.keys({b: 2}));
var promise = new Promise((resolve, reject) => {
resolve(1);
});
promise.then(res => {
console.log(Object.keys({c: 1}));
});
}, 2000);
Здесь мы сосредоточимся на том, когда выполняется promise.then. В стеке вызовов точки останова мы обнаруживаем, что одна из наиболее интересных вещей заключается в том, что она выполняется внутри функции-деструктора:
Извлеките основной код следующим образом:
{
v8::MicrotasksScope microtasks_scope();
v8::MaybeLocal result = function->Call(receiver, argc, args);
}
Этот код сначала создает экземпляр объекта области, который помещается в стек, а затем вызывает function.call. Этот function.call является кодом JS, который будет выполняться в данный момент. После того, как JS будет выполнен и покинет область действия, объект стека будет разрушается, а затем выполняет микрозадачу внутри деструктора. Обратите внимание, что в дополнение к конструктору в C++ также есть функция деструктурирования.Функция деструктурирования выполняется при уничтожении объекта.Поскольку в C++ нет автоматической сборки мусора, вам нужна функция деструктурирования, чтобы вы могли самостоятельно освобождать новую память.
То есть микрозадача выполняется сразу после выполнения текущего вызова JS. Она синхронная. В том же стеке вызовов нет многопоточной асинхронности. Например, код в обратном вызове setTimeout включает promise.then это все, что выполняет DOMTimer.Fired, это просто означает, что затем помещается в конец всей асинхронной функции обратного вызова, которая выполняется в данный момент.
Итак, setTimeout 0 добавляет новую задачу (обратный вызов) в очередь задач цикла сообщений основного потока, а promise.then вставляет задачу в микрозадачу в V8 текущей задачи. Затем следующая задача должна быть выполнена после выполнения текущей задачи.
В дополнение к обещаниям, другими распространенными задачами, которые могут создавать микрозадачи, являются MutationObserver, Vue $nextTick и полифилл Promise, в основном реализованные с этим, и его роль заключается в том, чтобы поместить обратный вызов в качестве микрозадачи в синхронизируемом в данный момент JS при последнем выполнении. Когда мы изменяем атрибут данных vue для обновления модификации DOM, vue фактически перезаписываетСеттер объектаКогда модифицирующие атрибуты запускают объект STATTER, на этот раз Vue знают, что вы внесли изменения, а затем изменяются DOM, и эти операции синхронизируются завершенные JS, вероятно, просто вызовите стек относительно глубоких стеков, когда эти вызовы на этот раз могут выполнять модификации. Задача в микро-синхронно раньше, поэтому NextTick может быть выполнена только после изменения DOM.
Кроме того, когда мы инициируем запрос в JS, также создается микрозадача:
let img = new Image();
img.src = 'image01.png?_=' + Date.now();
img.onload = function () {
console.log('img ready');
}
console.log(Object.keys({e: 1}));
У нас часто возникают проблемы, нужно писать onload перед назначением src, чтобы избежать срабатывания запроса после добавления src, но строка onload еще не выполнена. На самом деле нам не о чем беспокоиться, потому что после выполнения назначения src blink создаст микрозадачу и поместит ее в микроочередь, как показано в следующем коде:
Это операция постановки в очередь, выполняемая ImageLoader, а затем выполняет Object.keys в последней строке.После завершения выполнения RunMicrotasks завершается, и задача, которая только что была поставлена в очередь, то есть обратный вызов для загрузки ресурсов, взял и бегом.
Приведенный выше код для постановки микроочереди в очередь предназначен для мигания, а собственная очередь V8 находится вbuiltins-internal-gen.ccВ этом файле файл встроенного типа выполняется непосредственно для создания ассемблерного кода, а затем компилируется, поэтому исходный код отображается как ассемблерный код во время отладки. Это нелегко отладить. Цель может состоять в том, чтобы генерировать разные ассемблерные коды непосредственно для разных платформ, что может ускорить скорость выполнения.
Наконец, цикл событий — это способ работы с многопоточностью.В Chrome общий объект task_runner используется для публикации задач для себя и других потоков, а также использует бесконечный цикл для непрерывного вывода задач на выполнение или перехода в спящий режим и ждите, пока вас разбудят. Поток рендеринга Chrome и поток браузера Mac также используют NSRunLoop Cococa sdk Mac в качестве источника событий пользовательского интерфейса. Многопроцессорная связь Chrome (связь через локальные сокеты потоков ввода-вывода разных процессов) использует цикл событий libevent и добавляет его в основной цикл сообщений.
Микрозадача не относится к циклу событий, это реализация V8, которая используется для реализации then/reject промиса и других коллбэков, которые нужно синхронно откладывать, по сути выполняется синхронно с текущей V8 стек вызовов. Просто поместите его в конец. В дополнение к Promise/MutationObserver, запросы, сделанные в JS, также будут создавать микрозадачу для задержки выполнения.