open atlas
↑ К треку
Node.js с нуля до senior NODE · 10 · 01

TCP и модуль net: сокеты, жизненный цикл и backpressure

Модуль net даёт сырой TCP-стрим под http: дуплексный сокет с data/end/error/close, семантику half-open, батчинг Nagle, отключаемый через setNoDelay, и булев результат write() — сигнал backpressure на уровне сокета.

NODE Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Коллега выкатывает крошечный TCP-прокси. На localhost работает, ревью проходит, а под реальным трафиком течёт память, пока под не падает по OOM. Причина — две недостающие строки: байты клиента пайпили в апстрим-сокет, но ни разу не смотрели на результат write() и не повесили 'error' на апстрим. Медленный апстрим заставлял write() возвращать false на каждом вызове; непрочитанные байты копились в безграничном внутреннем буфере. net отдавал правду на каждой записи — её проигнорировали.

Сокет — это дуплексный стрим, а не почтовый ящик

Когда пропускаешь хотя бы одно из четырёх событий жизненного цикла, воспроизводишь ровно тот класс бага из хука — будь то утечка памяти, тихое падение или зомби-fd, который никогда не закрывается. Вот полная картина.

Всё, что делает http, лежит поверх net. net.Socket — это дуплексный стрим: Readable для байтов, приходящих от пира, и Writable для байтов, которые ты шлёшь. net.createServer((socket) => { ... }) даёт по сокету на каждое принятое соединение; net.connect(port, host) — клиентский конец. Никакого фрейминга сообщений нет — TCP это поток байтов, поэтому одно логическое «сообщение» может прийти разбитым на несколько событий 'data', а две твои отправки могут слиться в один 'data' у пира. Любой протокол поверх net обязан делать свой фрейминг сам (префикс длины, разделитель или парсер).

Жизненный цикл — фиксированная последовательность событий, и подключить нужно их все:

const net = require("node:net");

const server = net.createServer((socket) => {
  socket.setEncoding("utf8");            // или оставь Buffer'ы
  socket.on("data",  (chunk) => { /* парсинг — может быть частичным */ });
  socket.on("end",   () => { /* пир прислал FIN, чтений больше нет */ });
  socket.on("error", (err) => { /* ECONNRESET и т.п. — ОБЯЗАН обработать */ });
  socket.on("close", (hadError) => { /* полностью разобран */ });
  socket.write("hello\n");
});

server.listen(7000);

Незыблемое правило: необработанный 'error' на сокете кидает исключение и роняет процесс. TCP-ошибки вроде ECONNRESET (сигнал RST от пира — резкий обрыв соединения) не экзотика — клиент, захлопнувший крышку ноутбука, штатно их порождает — поэтому каждому сокету нужен слушатель 'error', иначе первый же reset положит весь сервер.

end против close и ловушка half-open

'end' и 'close' — не одно и то же событие, и путаница между ними — классический баг.

СобытиеСмыслМожно ли ещё писать?
‘end’Пир прислал FIN — читаемая сторона завершенаДа — твоя пишущая половина ещё открыта
’finish’Ты вызвал end()пишущая сторона слитаНет — ты закрыл свою половину
’close’Обе половины закрыты, сокет полностью освобождёнНет — fd больше нет

По умолчанию Node автоматически закрывает пишущую сторону при получении FIN, поэтому время жизни сокета симметрично. Но TCP по-настоящему поддерживает half-open: пир может перестать слать (end), пока ты продолжаешь слать. Передай allowHalfOpen: true в createServer/connect, и Node не будет авто-end()-ить твою сторону на FIN пира — полезно для протоколов запрос/ответ, где клиент сигналит «закончил слать» через FIN, но ждёт твой полный ответ. Ловушка обратная: с allowHalfOpen: true ты теперь сам обязан вызвать socket.end(), иначе half-open соединение зависает и течёт файловым дескриптором.

Почему это работает

FIN закрывает одно направление. TCP — это два независимых симплексных стрима, склеенных вместе, поэтому «соединение закрыто» на самом деле значит «одна половина закрыта». Большинству серверов нужно симметричное закрытие (по умолчанию), но прокси или стриминговому протоколу часто надо дочитать до EOF и затем ещё слить трейлер — это и даёт allowHalfOpen, ценой того, что закрытие теперь на тебе.

Nagle, setNoDelay и загадка 40 мс

По умолчанию TCP работает с алгоритмом Nagle: он придерживает маленький исходящий сегмент, пока не подтвердится (ACK) предыдущий сегмент или не накопится данных на целый пакет, чтобы не заваливать сеть крошечными пакетами. В сочетании с delayed ACK пира (который ждёт до ~40 мс перед подтверждением) Nagle порождает известный сбой: протокол запрос/ответ с мелкими записями застревает на ~40 мс на круг, потому что твоя сторона не шлёт маленький сегмент, пока пир не пришлёт ACK, а пир не шлёт ACK, пока не сработает его таймер delayed-ACK.

Лечится через socket.setNoDelay(true), который отключает Nagle, и каждый write() уходит сразу. Для интерактивных, чувствительных к задержке протоколов (RPC, REPL, игра) это правильный дефолт. Для пропускной способности с множеством мелких записей оставить Nagle может быть лучше — он коалесцирует. Пойми, что у тебя: низкая задержка на мелких сообщениях → setNoDelay(true); чистая пропускная способность → оставь. socket.setKeepAlive(true, delay) — отдельная ручка: ОС шлёт периодические keepalive-пробы на простаивающем соединении, чтобы мёртвый пир (упавший, сетевой разрыв) обнаружился, а не висел сокетом вечно.

write() возвращает булево — это твой backpressure

socket.write(chunk) возвращает булево, и это самое игнорируемое значение в сетевом Node. true значит, что чанк ушёл в буфер отправки ядра и можно писать дальше. false значит, что внутренний буфер Node теперь выше high-water mark — пир (или сеть) сливает не так быстро, как ты пишешь — и надо прекратить запись, пока не сработает событие 'drain'. Игнорирование false и запись дальше не дают ошибки; Node просто продолжает буферизовать в памяти, и против медленного потребителя этот буфер растёт безгранично, пока процесс не упадёт по OOM. Это и есть баг из хука.

// Ручной backpressure: стоп на false, продолжить на 'drain'.
function sendAll(socket, chunks, done) {
  let i = 0;
  (function next() {
    while (i < chunks.length) {
      const ok = socket.write(chunks[i++]);
      if (!ok) return socket.once("drain", next);   // ждём, затем дальше
    }
    done();
  })();
}

На практике этот цикл пишут редко: pipeline(source, socket) (или source.pipe(socket)) подключает backpressure за тебя, ставя источник на паузу при false и возобновляя на 'drain'. Урок: либо доверь управление пайплайну стримов, либо честно соблюдай булево руками — но никогда не пиши «выстрелил-и-забыл» в сокет, чьим drain ты не управляешь.

Выбери лучший вариант

Ты проксируешь байты быстрого клиента в более медленный апстрим-сокет. Как двигать данные?

ref и unref: должен ли этот сокет держать процесс живым?

Event loop Node жив, пока есть активный handle — а открытый сокет это handle. socket.unref() говорит циклу «не считай меня при решении, выходить ли»; socket.ref() отменяет это. Кейс — фоновое или опциональное соединение: метрик-сокет, health-check проба или дебаг-листенер, который сам по себе не должен держать CLI-процесс живым после завершения настоящей работы. server.unref() делает то же для слушающего сервера. По умолчанию — ref’нуто (нормальный сервер должен держать процесс), поэтому тянись к unref() только на вспомогательных соединениях, которые не должны пинить процесс открытым.

Викторина

Твой TCP-сервер периодически падает в продакшене с непойманным ECONNRESET. Что чинить?

Викторина

Маленький протокол запрос/ответ поверх net добавляет ~40 мс задержки на круг на простаивающих линках. Вероятная причина?

Вспомните перед уходом
  1. 01
    Почему write() возвращает булево и что делать, когда оно false?
  2. 02
    Что такое half-open TCP-соединение и что меняет allowHalfOpen?
Итог

Модуль net — это сырой TCP-слой под http: net.Socket — дуплексный стрим без фрейминга сообщений, поэтому ты парсишь чанки 'data', которые могут быть частичными, и строишь свой фрейминг. Подключай весь жизненный цикл — 'data', 'end', 'error', 'close' — и никогда не пропускай 'error', потому что необработанная ошибка сокета кидает исключение и роняет процесс, а reset’ы вроде ECONNRESET — рутина. 'end' значит, что пришёл FIN пира (чтение завершено), пока твоя пишущая половина может быть ещё открыта; allowHalfOpen держит её открытой ценой того, что end() теперь на тебе. Настраивай транспорт осознанно: setNoDelay(true) отключает Nagle ради низкой задержки на мелких записях, setKeepAlive даёт ОС обнаруживать мёртвых пиров. Главное — write() возвращает булево: false это backpressure, сигнал остановиться до 'drain', и игнор его — каноничный OOM безграничного буфера. Доверяй управление backpressure pipeline(), когда можешь, и используй unref() только на вспомогательных сокетах, что не должны держать процесс живым. Теперь, когда встретишь OOM в прокси или сервер, который «просто висит» под нагрузкой, — проверь первым делом, смотрит ли код на результат write() и есть ли на каждом сокете обработчик 'error'.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.