Published on
Updated on 

Отключение основного потока HTML-разбор в Servo

Authors
  • Эта публикация - перевод статьи. Ее автор - Servo Blog. Оригинал доступен по ссылке ниже:

    Off main thread HTML parsing in Servo

Введение

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

Одной из таких задач является разбор HTML, и я работал над ее распараллеливанием этим летом в рамках своего проекта GSoC. Поскольку Servo написан на Rust, я предполагаю, что читатель имеет некоторые базовые знания о Rust. Если нет, посмотрите эту замечательную книгу по Rust. Готово? Давайте сразу перейдем к деталям:

Парсер HTML

Код парсинга HTML (и XML) в Servo находится в html5ever. Поскольку этот проект касается парсинга HTML, я буду говорить только об этом. Первый компонент, о котором нам нужно знать, это Tokenizer. Этот компонент отвечает за прием необработанного ввода из буфера и создание токенов, в конечном итоге отправляя их в свой сток, который мы назовем TokenSink. Это может быть любой тип, реализующий признак TokenSink.

В html5ever есть тип под названием TreeBuilder, который реализует этот признак. Работа TreeBuilder заключается в создании операций над деревом на основе получаемых им токенов. TreeBuilder содержит свой собственный Sink, называемый TreeSink, который детализирует методы, соответствующие этим древовидным операциям. TreeBuilder вызывает эти методы TreeSink при соответствующих условиях, и эти "методы действия" отвечают за построение дерева DOM.

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

Процесс

Первым шагом, который я предпринял, было отделение создания древовидных операций от построения дерева. Ранее древовидные операции выполнялись сразу после их создания. Это включало создание нового TreeSink, который вместо непосредственного выполнения создавал представление древовидной операции, содержащее все необходимые данные. На данный момент я посылал древовидную операцию в функцию process_op сразу после ее создания, после чего она выполнялась.

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

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

Спекулятивный парсинг

Спецификация HTML предписывает, что в любой момент парсинга, если мы встречаем тег script, то скрипт должен быть выполнен немедленно (если это inline script), или должен быть получен, а затем выполнен (обратите внимание, что это правило не применяется к скриптам async или defer). Почему, спросите вы, это должно быть сделано именно так? Почему мы не можем пометить эти скрипты и выполнить их все в конце, после завершения парсинга? Это происходит из-за старой, непродуманной функции API Document под названием document.write(). Эта функция - больное место для многих разработчиков, работающих над браузерами, поскольку ее реализация достаточно хороша, и при этом приходится работать над многими идиосинкразиями, которые ее окружают. Я не буду вдаваться в подробности, поскольку они не имеют отношения к делу. Нам достаточно знать, что делает document.write(): он принимает строковый аргумент, который обычно является разметкой, и вставляет эту строку как часть HTML-содержимого документа. Достаточно сказать, что использование этой функции может сломать вашу страницу, и ее не следует использовать.

Возвращаясь к задаче разбора, мы не можем совершать никаких манипуляций с DOM, пока скрипт не закончит выполнение, потому что document.write() может сделать их лишними. Целью спекулятивного разбора является продолжение разбора содержимого после тега скрипта в потоке парсера, в то время как скрипт выполняется в основном потоке. Обратите внимание, что здесь мы только спекулятивно создаем древовидные операции, а не фактически строим дерево. После завершения выполнения сценария мы анализируем действия вызовов document.write() (если таковые имеются), чтобы определить, использовать ли древовидные опции или отбросить их.

Дорожное препятствие!

Помните, я говорил, что процесс создания древовидных опций не зависит от построения дерева? Ну, я немного соврал. Еще неделю назад нам нужен был доступ к некоторым узлам DOM для создания пары древовидных действий (одному методу нужно было узнать, есть ли у узла родитель, а другому - существуют ли два узла в одном дереве). Когда я перенес задачу создания древовидных действий в отдельный поток, я больше не мог получить доступ к DOM-дереву, которое жило в главном потоке. Поэтому я использовал Sender в TreeSink для создания и отправки запросов в основной поток, который получал доступ к DOM и отправлял результаты обратно. Затем возвращался только метод TreeSink с данными, которые он получил от основного потока. Кроме того, это означало, что эта пара методов была синхронной по своей природе. Ничего страшного.

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

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

С этими изменениями в html5ever я наконец-то смогу реализовать спекулятивный парсинг. К сожалению, времени на его реализацию в рамках проекта GSoC не так много, поэтому я буду внедрять эту функцию в Servo некоторое время спустя. Я надеюсь опубликовать еще одну запись в блоге с подробным описанием этой функции, а также с деталями улучшения производительности, которое она принесет.

Ссылки на важные PR:

Заключение

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

Я хотел бы поблагодарить моего наставника Энтони Рамина, с которым было очень приятно работать, и Джоша Мэтьюса, который очень помог мне, когда я был еще новичком, желающим внести свой вклад в проект.