Event Loop крутится, Node.js мутится. Часть 4. HTTP-сервер.

Event Loop крутится, Node.js мутится. Часть 4. HTTP-сервер.

Anastasia Kotova

Предыдущие части

Event Loop крутится, Node.js мутится. Часть 1. Браузер VS сервер.

Event Loop крутится, Node.js мутится. Часть 2. Нюансы работы Event Loop.

Event Loop крутится, Node.js мутится. Часть 3. За пределами Event Loop.


Мы уже говорили про Event Loop и thread pool в Node.js. Но как это всё работает в реальном приложении, например в HTTP-сервере?

Как работает сервер на Node.js

Когда мы пишем:

const http = require('http');

const server = http.createServer((req, res) => {
  res.end('Hello, world!');
});

server.listen(3000);

…кажется, что Node.js просто вызывает нашу функцию, когда кто-то стучится на порт. Но на самом деле всё чуть глубже.

Вызов http.createServer() — это просто обёртка над net.createServer(), по факту мы работаем с обычным TCP-соединением. Далее Node.js сам парсит заголовки, собирает req, res и передает их в наш callback. Но тот же самый пример можно реализовать и вот так:

const net = require('net');

const server = net.createServer((socket) => {
  socket.write('HTTP/1.1 200 OK\\r\\n');
  socket.write('Content-Type: text/plain\\r\\n');
  socket.write('Content-Length: 13\\r\\n');
  socket.write('\\r\\n');
  socket.write('Hello, world!');
  socket.end();
});

server.listen(3000);

Здесь мы сами отвечаем по протоколу HTTP — просто шлём строку. Это всё ещё TCP, просто ручной режим и отсутствие привычных req и res.

А кто вообще следит за портом?

Когда мы вызываем server.listen(), Node.js передаёт порт библиотеке libuv, которая и занимается всей I/O-магией. Она регистрирует сокет в системе и говорит: «ОС, скажи, когда на этот порт кто-нибудь постучится». В разных операционных системах для этого используются разные системные вызовы.

Сам Node.js ничего не «слушает» вручную — он просто ждёт от ОС сигнал, что появилось соединение.

Так в какой фазе Event Loop работает сервер?

Когда соединение приходит, Node.js добавляет обработку в Event Loop — не мгновенно, а в очередь. Это значит, что сначала выполнятся все nextTick и Promise, если они были запланированы раньше, а HTTP-запрос будет ждать своей очереди в фазе poll.

Даже внутри обработчика запроса все микротаски и process.nextTick попадают в свою очередь и выполняются после основной синхронной части. То есть сначала выполнится весь код в теле обработчика, а потом — всё, что было отложено.

Например:

const http = require('http');

http.createServer((req, res) => {
  console.log('start');

  process.nextTick(() => {
    console.log('nextTick');
  });

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

  console.log('end');
  res.end('ok');
}).listen(3000);

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

start
end
nextTick
promise

Это означает, что микротаски внутри запроса тоже не “перебивают” его выполнение. Сначала идёт синхронный код, и только потом — очередь микротасок.

А что будет, если клиент пришёл с запросом, и сервер начали выполнять что-то тяжёлое? ****Что произойдет с остальными клиентами? Сейчас разберемся.

Потоковая обработка данных и блокировка Event Loop

Когда в Node.js приходит HTTP-запрос, объекты req и res представляют собой потоки (streams). Это означает, что данные приходят и отправляются по частям, а не загружаются и обрабатываются целиком. Такой подход позволяет экономно использовать память и избегать блокировки Event Loop.

Однако если внутри запроса выполнить тяжёлую синхронную операцию, это может полностью остановить обработку всех остальных запросов. Стандартно Node.js работает в одном потоке, и пока выполняется тяжёлая операция, Event Loop не может перейти к следующему событию.

Например, если создать такой сервер:

const http = require('http');

function heavySyncWork() {
  const end = Date.now() + 5000;
  while (Date.now() < end) {}
}

http.createServer((req, res) => {
  if (req.url === '/block') {
    heavySyncWork();
    res.end('Done with blocking work');
  } else {
    res.end('Hello!');
  }
}).listen(3000);

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

Существует и другой вариант блокировки — с использованием process.nextTick. Этот механизм позволяет выполнять задачи перед переходом к следующей фазе Event Loop, но при неправильном использовании может привести к зависанию.

http.createServer((req, res) => {
  if (req.url === '/tick') {
    function infiniteTick() {
      process.nextTick(infiniteTick);
    }
    infiniteTick();
    res.end('Never reached');
  } else {
    res.end('Still alive');
  }
}).listen(3000);

В этом примере функция infiniteTick никогда не позволяет Event Loop перейти к следующей фазе. Результат — первый запрос на /tick вернет Never reached, а дальше сервер полностью зависает.

Чтобы избежать подобных ситуаций, в Node.js в частности рекомендуется использовать потоковую обработку данных. Она позволяет обрабатывать большие объёмы информации без блокировки Event Loop и без необходимости загружать всё в память сразу. Пример — передача большого файла клиенту:

const fs = require('fs');
const http = require('http');

http.createServer((req, res) => {
  const readStream = fs.createReadStream('./bigfile.txt');
  readStream.pipe(res);
}).listen(3000);

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

Почему важно ставить таймауты

Node.js позволяет держать соединение с клиентом открытым столько, сколько потребуется. Это удобно для медленных клиентов и для повторного использования соединений, но одновременно — риск для сервера.

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

Node.js по умолчанию использует поведение keep-alive, когда соединение после ответа не закрывается сразу, а остаётся открытым в ожидании новых запросов от клиента. Это экономит ресурсы на повторное соединение, но в условиях высокой нагрузки может стать проблемой.

Пример:

const http = require('http');

const server = http.createServer((req, res) => {
  setTimeout(() => {
    res.end('done');
  }, 10000); // ответ через 10 секунд
});

server.listen(3000);

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

Чтобы избежать этого, можно и нужно задавать таймауты:

server.setTimeout(5000); // закрыть соединение, если нет активности 5 секунд
req.setTimeout(3000);    // клиент слишком долго не отправляет тело запроса
res.setTimeout(5000);    // задержка при формировании ответа

Кроме того, можно отключить keep-alive, если сервер не рассчитан на повторное использование соединений:

res.setHeader('Connection', 'close');

А что если вообще не ставить таймауты?

В теории — соединение может висеть бесконечно. На практике его может закрыть:

– сам клиент (например, браузер),

– TCP-уровень операционной системы,

– прокси или балансировщик (например, nginx).

Но всё это — внешние факторы, на которые нельзя полагаться. Некоторые роутеры могут держать соединения открытыми 10 минут, другие — сбрасывать через 30 секунд. Если сервер под нагрузкой, даже 10–20 таких «висящих» соединений без таймаутов могут съесть ресурсы и мешать обслуживанию новых запросов.

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

Обработка ошибок на сервере

Если в обработчике HTTP-запроса в Node.js произойдёт необработанная ошибка, она может привести к падению всего процесса. Node.js не оборачивает callback http.createServer в try/catch — ожидается, что разработчик сам контролирует возможные сбои. В результате любое неперехваченное исключение завершит работу сервера.

Пример:

http.createServer((req, res) => {
  if (req.url === '/crash') {
    throw new Error('Something went wrong');
  }

  res.end('OK');
}).listen(3000);

При обращении к /crash процесс завершится с ошибкой. Чтобы этого избежать, стоит оборачивать обработчик запроса в try/catch:

http.createServer((req, res) => {
  try {
    if (req.url === '/crash') {
      throw new Error('Something went wrong');
    }

    res.end('OK');
  } catch (err) {
    console.error('Request error:', err);
    res.statusCode = 500;
    res.end('Internal Server Error');
  }
}).listen(3000);

Сложности возникают при работе с асинхронным кодом — если ошибка произойдёт внутри Promise или async-функции, она не будет перехвачена внешним try/catch. Чтобы корректно обработать такую ситуацию, нужно явно добавить .catch:

http.createServer((req, res) => {
  (async () => {
    if (req.url === '/crash') {
      throw new Error('Async error');
    }

    res.end('OK');
  })().catch(err => {
    console.error('Async error:', err);
    res.statusCode = 500;
    res.end('Internal Server Error');
  });
}).listen(3000);

В сложных проектах с множеством маршрутов удобно использовать фреймворки, которые предоставляют централизованную обработку ошибок. Но даже в простом сервере важно помнить, что один throw без catch способен остановить всё приложение.

Фреймворки для сервера

Многие фреймворки скрывают работу с http.createServer, но под капотом всё устроено так же. Express, например, просто оборачивает стандартный HTTP-сервер и выстраивает цепочку middleware, которая вызывается последовательно.

Асинхронные middleware в Express действительно работают с await, и если использовать промисы корректно, Event Loop не блокируется. Но это не магия — await просто приостанавливает выполнение текущей функции, пока не завершится промис. В это время Event Loop может обрабатывать другие события. Если же в middleware будет тяжёлая синхронная операция вроде while (true) {}, Event Loop всё равно будет заблокирован.

Пример:

app.get('/block', (req, res) => {
  while (true) {} // сервер зависает
});

app.get('/async', async (req, res) => {
  await new Promise(resolve => setTimeout(resolve, 1000));
  res.send('OK');
});

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

NestJS работает поверх Express (или Fastify) и использует тот же принцип middleware и контроллеров, но добавляет уровни абстракции, инъекцию зависимостей и декларативную обработку ошибок. По умолчанию NestJS уже перехватывает исключения в контроллерах и может возвращать корректный статус 500 без падения сервера.

Важно понимать, что NestJS не делает Event Loop «волшебным». Если внутри контроллера происходит блокирующая операция, поведение будет таким же, как и в Express — ни один фреймворк не может обойти модель выполнения Node.js.

Фреймворки упрощают структуру кода, делают обработку ошибок более предсказуемой и помогают не забыть про важные детали. Но фундамент остаётся прежним: один Event Loop, один поток, и ответственность за производительность — на приложении.

Выводы

В этой части мы разобрались, как Node.js обрабатывает входящие HTTP-запросы и что стоит под капотом. Посмотрели, как http.createServer связан с TCP и Event Loop, и почему каждый запрос — это не мгновенное действие, а событие в общей очереди.

Разобрали, как потоки (streams) помогают обрабатывать данные без блокировки, и почему тяжёлые синхронные операции могут повесить сервер. Поговорили о важности таймаутов и том, что будет, если их не выставить. Затронули обработку ошибок и то, как один throw может остановить весь процесс. А в конце — посмотрели, как с этим справляются Express и NestJS, и почему фреймворки не отменяют фундаментальную модель выполнения Node.js.


Следующие части

Event Loop крутится, Node.js мутится. Часть 5. Настоящая многопоточность.

Event Loop крутится, Node.js мутится. Часть 6. Профилировать и замерять.

Report Page