Главная / Статьи / Однопоточная архитектура JavaScript против многопроцессной среды браузера

Однопоточная архитектура JavaScript против многопроцессной среды браузера

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

1692 слов

JavaScript выполняется в одном потоке.

Вы, вероятно, читали это утверждение больше раз, чем можете сосчитать.

Однако при загрузке современной веб-страницы вы можете одновременно наблюдать следующее:

Отправляется сетевой запрос.

Загружается и декодируется изображение.

Плавно работает анимация CSS.

Страница мгновенно реагирует на нажатие.

Содержимое отрисовывается на экране.

И ваш код JavaScript продолжает выполняться всё это время.

Так что же на самом деле происходит внутри?

Если у JavaScript есть только один поток, кто занимается всем остальным?

Ответ на этот вопрос раскрывает одну из самых важных идей относительно того, как на самом деле работают браузеры:

JavaScript и браузер — это не одно и то же.

У JavaScript есть основной поток

Когда разработчики называют JavaScript однопоточным, они имеют в виду способ выполнения кода на языке JavaScript.

Ваш код выполняется в одном основном потоке JavaScript.

Возьмем этот фрагмент в качестве примера:

console.log("One");
console.log("Two");
console.log("Three");

Каждая строка выполняется строго после предыдущей.

JavaScript не будет выполнять эти три оператора одновременно в одном и том же потоке.

Существует только одна стек-команда для работы.

В любой момент времени выполняется только один фрагмент кода на JavaScript.

В этом контексте и заключается суть однопоточности.

Но здесь все становится более сложным.

Сам браузер отвечает за гораздо больше, чем просто выполнение ваших скриптов.

У браузера больше задач, чем просто выполнение JavaScript

Браузер — это гораздо более сложная система, чем просто движок JavaScript.

Ему необходимо управлять такими задачами, как:

  • Загрузка данных через сеть
  • Расписание таймеров
  • Захват ввода с клавиатуры и указателя
  • Отображение кадров на экране
  • Декодирование изображений
  • Воспроизведение звука
  • Обработка воспроизведения видео
  • Сохранение данных на диск
  • Расчёт макета страницы
  • Рисование пикселей во время отрисовки
  • Слияние слоёв в процессе композиции
  • Различные другие задачи, выполняемые браузером и операционной системой

Ваш код на JavaScript не выполняет всё это напрямую.

Вместо этого JavaScript может поручить работу браузеру, позволяя ему заниматься деталями.

Например:

fetch("/api/users");

Ваш скрипт инициирует запрос.

Но ваш JavaScript не открывает сокет вручную и не передаёт отдельные байты непосредственно с сервера.

Эта ответственность лежит на браузере и системах, находящихся под его управлением.

Как только приходит ответ, браузер планирует выполнение вашего функции-ответа или продолжения обработки через promise на потоке JavaScript.

Это сильно отличается от представления о том, что:

«JavaScript обрабатывает абсолютно всё».

Рассматривайте JavaScript как одного рабочего внутри гораздо более крупной системы

Представьте себе ресторан в качестве умственной модели.

JavaScript — это единственный сервер, принимающий заказы.

Браузер представляет собой всю деятельность ресторана.

Множество других рабочих отвечают за выполнение различных задач на фоне.

JavaScript может сказать:

«Мне нужны эти данные с сервера».

В этот момент браузер берет на себя управление сетевыми операциями.

JavaScript не обязан замирать и ждать, пока придет каждый байт.

Он может свободно переходить к другим задачам.

Как только операция завершается и готова к обработке JavaScript, соответствующая задача добавляется в очередь для потока JavaScript.

В этом заключается основа работы асинхронных API браузера.

Что происходит с fetch()?

Возьмем следующий пример:

console.log("Start");
fetch("/api/users")
  .then(() => {
    console.log("Users received");
  });
console.log("End");

Новички часто представляют себе что-то вроде этого:

Start
   ↓
fetch()
   ↓
wait for server
   ↓
Users received
   ↓
End

Но это не точное изображение того, что происходит на самом деле.

Выполнение JavaScript не останавливается на вызове fetch() в ожидании ответа.

Более точное изображение выглядит так:

JavaScript
   │
   ├── Start fetch
   │
   ▼
Browser handles network work
   │
   │
   └───────────────┐
                   │
JavaScript         │
continues          │
                   │
   ▼               │
console.log("End") │
                   │
                   ▼
        Response becomes available
                   │
                   ▼
        Promise continuation
        gets scheduled
                   │
                   ▼
        JavaScript runs it

С учетом этого потока вывод в консоль обычно выглядит так:

Start
End
Users received

Ключевой момент здесь заключается не только в том, что fetch() работает асинхронно.

Самое важное — это кто на самом деле выполняет ожидание.

Поток JavaScript никогда не блокируется во время ожидания ответа с сети.

Цикл событий связывает все элементы

Именно здесь и проявляется роль цикла событий.

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

Упрощённая версия этой системы выглядит так:

Browser
                │
     ┌──────────┼───────────┐
     │          │           │
 Network     Timers      User Input
     │          │           │
     └──────────┼───────────┘
                │
                ▼
         Scheduling queues
                │
                ▼
           Event Loop
                │
                ▼
          Call Stack
                │
                ▼
           JavaScript

Имейте в виду, что этот диаграмма является упрощением.

Фактическая внутренняя структура браузера гораздо сложнее, и разные движки реализуют эти механизмы по-своему.

Тем не менее он отражает основную идею:

Запуск JavaScript — это лишь одна часть гораздо более крупной системы.

Таймеры не тайком выполняют ваш код на фоне

Возьмем в пример следующее:

setTimeout(() => {
  console.log("Done");
}, 1000);

Привлекательным способом представления этого является:

«JavaScript запускает отдельную нить, которая считает обратный отсчёт в одну секунду».

Такая картина ведёт вас в заблуждение.

Именно браузер, а не сам JavaScript, реализует поведение таймеров. Как только истекает указанное время ожидания, функция-возврат становится кандидатом на запуск. Только тогда JavaScript действительно её выполняет — и только тогда, когда основная нить становится свободной для её обработки.

Это означает, что код вроде этого — плохая идея:

setTimeout(() => {
  console.log("Done");
}, 1000);

while (true) {}

Почему это нарушает работу системы?

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

Браузер определенно может знать следующее:

«Таймер сработал».

Но одного этого знания недостаточно — JavaScript все равно нуждается в возможности для фактического выполнения:

Main JavaScript thread

while (true) {
   // never finishes
}
        ↓
Timer becomes ready
        ↓
Callback waits
        ↓
JavaScript never becomes available

Это одно из ключевых отличий, которые стоит усвоить.

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

Тогда почему страница не застывает постоянно?

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

Однако есть нюанс, на который стоит обратить внимание.

Большая часть работы, определяющей скорость реакции страницы — включая выполнение JavaScript и некоторые этапы процесса отрисовки — происходит на той же основной нити.

Вот почему код вроде этого всё равно может заблокировать страницу:

const start = performance.now();

while (performance.now() - start < 5000) {
  // expensive work
}

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

Именно об этом говорят люди, когда утверждают:

«JavaScript работает в однопоточном режиме».

Конкурентность на уровне браузера, однопоточность на уровне JavaScript

Это важное различие, которое необходимо иметь в виду.

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

В общих чертах задачи могут распределяться следующим образом:

Browser
│
├── JavaScript execution
├── Network activity
├── Rendering-related work
├── Image/media processing
├── Browser services
└── Other internal tasks

Однако ничто из этого не позволяет написать что-то вроде:

runThisFunctionOnAnotherBrowserThread();

и заставить произвольный код JavaScript перейти на другой поток.

Ваш обычный JavaScript по-прежнему выполняется согласно модели однопоточной обработки, которая у него всегда была.

Если вам действительно необходимо перенести тяжелые вычисления в JavaScript с главного потока, для этого и существуют Web Workers.

Заключение

Однопоточный JavaScript не означает, что браузер в целом ограничен одним потоком.

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

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

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

Тем не менее основной поток по-прежнему несет большую нагрузку.

Длительное выполнение JavaScript в этом потоке замедляет все остальные задачи, конкурирующие за его ресурсы — взаимодействия с пользователем, отрисовку и другие задачи в очереди.

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

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

Основные выводы

Запомните эти три момента:

  1. Сам JavaScript является однопоточным. Ваш код выполняется последовательно, шаг за шагом, на главном потоке.
  2. Браузер — это более сложная конкурентная среда. Помимо выполнения вашего JavaScript, он параллельно обрабатывает сетевые операции, таймеры, отрисовку, ввод и медиафайлы.
  3. Асинхронность не означает многопоточную обработку вашего кода. Браузер сам выполняет ожидание, а затем возвращает управление JavaScript, как только на главном потоке появляется свободное место.

Полезный способ сформулировать это:

Browser
│
├── JavaScript execution
├── Network activity
├── Timers
├── User input
├── Rendering
├── Media processing
└── Other browser systems

JavaScript — это всего лишь один компонент, работающий внутри этой более крупной системы, а не сама система.

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

Связанные статьи