Перейти к содержанию

TCP/IP в Node.js: сокеты ОС и порты

Источник: theNodeBook — Node.js TCP/IP Networking: OS Sockets & Ports

Сетевой стек TCP/IP в Node.js начинается на границе между JavaScript-объектами и сокетами операционной системы. Механика охватывает адреса, порты, интерфейсы, таблицы маршрутизации, ARP или neighbor discovery, MTU, буферы сокетов в ядре и дескрипторы libuv. JavaScript инициирует операцию. libuv вызывает сетевые API платформы. ОС владеет перемещением пакетов, состоянием соединений и уведомлениями о готовности.

TCP/IP в Node.js

Понимание слоя ОС объясняет многие ошибки Node. EADDRINUSE приходит из состояния bind. ECONNREFUSED — от того, что удалённая конечная точка отклонила установку соединения. Localhost, привязка к wildcard, IPv4 и IPv6 зависят от выбора интерфейса и адреса.

У слушающего Node-сервера два видимых куска состояния — JavaScript-объект и сокет ОС под ним.

Перелом происходит на listen(). До этого вызова у Node есть объект сервера и колбэк. После успешного завершения в ядре появляется привязанная слушающая конечная точка, связанная с процессом.

1
2
3
4
5
6
7
import net from 'node:net';

const server = net.createServer((socket) => {
    socket.end('hi\n');
});

server.listen(3000, '127.0.0.1');

net.createServer() создаёт состояние в JavaScript. Хранит колбэк подключения. Готовит объект сервера с поведением потока и событий, уже разобранным в предыдущих главах.

listen() — место, где граница смещается. Node просит libuv создать TCP-дескриптор. libuv просит у ОС сокет. ОС создаёт состояние сокета в ядре, назначает процессу файловый дескриптор, привязывает сокет к адресу 127.0.0.1:3000, затем помечает его слушающей конечной точкой.

Одна строка. Несколько владельцев.

У процесса теперь JavaScript-объекты в V8, нативные объекты в Node и libuv и состояние сокета в ядре. Части связаны дескрипторами и файловыми дескрипторами. JavaScript видит net.Server. libuv следит за готовностью. Ядро владеет сетевым объектом, принимающим попытки подключения.

1
2
3
server.on('listening', () => {
    console.log(server.address());
});

Напечатанный адрес — то, что Node может аккуратно отдать наружу: host, port и семейство адресов. Остальное живёт ниже JavaScript-объекта. Можно закрыть объект сервера. Можно снять слушатели. Можно держать ссылки. Пока дескриптор открыт, решающее состояние всё ещё в таблице сокетов ядра.

Таблица сокетов ядра — учёт сокетов ОС в активном сетевом контексте хоста или контейнера. В ней слушающие и подключённые конечные точки, локальные и удалённые адреса, состояние протокола, буферы и владение дескрипторами. Node читает и меняет это состояние через системные вызовы.

Граница системного вызова — переход, когда код в user space просит ядро выполнить операцию. JavaScript доходит до таблицы сокетов ядра через нативный код Node и libuv. Нативный код Node и libuv входят в ядро вызовами вроде socket(), bind(), listen(), accept(), connect(), read(), write() и close(), под ними — платформенные API.

Эта граница объясняет остаток главы. Node отдаёт JavaScript-объект. ОС владеет примитивом.

Граница ниже listen()

В Unix-подобных системах сокет потребляет файловый дескриптор. В главе о дескрипторах файлов уже разбирались дескрипторы для файлов; та же идея на уровне процесса применима здесь: дескриптор — маленький целочисленный handle процесса. Цель другая. Вместо открытого файла дескриптор указывает на объект сокета в ядре.

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

1
2
3
4
5
6
net.Server.listen()
  -> Node TCP binding
  -> libuv TCP handle
  -> OS socket
  -> bind 127.0.0.1:3000
  -> listen

Имена различаются по платформам, но форма владения одна. JavaScript держит объект сервера. Нативный слой Node — обёртку для libuv. libuv — handle, встроенный в цикл событий. ОС — состояние сокета и отчёт о готовности обратно в libuv при изменениях.

Цикл событий был в глава 1; здесь узкая версия: сетевой I/O доходит до JavaScript после того, как ядро сообщило об изменении сокета, libuv это увидел, а Node превратил готовность в колбэки и события потока.

Для слушающего TCP-сервера ядро может держать завершённые входящие соединения, пока процесс их не примет. Node делает accept из нативного пути, оборачивает каждый принятый сокет в JavaScript net.Socket и эмитит событие connection. К моменту колбэка ядро уже создало подключённый сокет. JavaScript получает результат accept.

1
2
3
4
5
const server = net.createServer((socket) => {
    console.log(socket.remoteAddress, socket.remotePort);
});

server.listen(3000, '127.0.0.1');

socket в колбэке — JavaScript-обёртка вокруг подключённого сокета ОС. remoteAddress и remotePort — со стороны пира. localAddress и localPort — адресный кортеж на вашей стороне.

Из-за дескрипторов сетевые баги кажутся знакомыми. У процесса могут закончиться дескрипторы. Сокет может остаться открытым, пока какой-то JavaScript-объект или нативный handle его держит. Сервер может не привязаться, потому что другой процесс уже занял конечную точку в таблице сокетов ядра. Сбой приходит через Node, но решение принимает ОС.

1
2
3
4
5
6
7
8
import net from 'node:net';

net.createServer().listen(3000);
net.createServer()
    .listen(3000)
    .on('error', (err) => {
        console.error(err.code);
    });

Второй bind обычно даёт EADDRINUSE. ОС отклонила bind, потому что запрошенный локальный адрес сокета конфликтует с уже существующим состоянием. Детали socket options — позже в этой главе; практическая граница здесь: ошибки bind — ответы ядра, показанные как ошибки Node.

У нативного пути есть несколько переходов состояния, которые стоит держать на виду:

1
2
3
4
created socket
  -> bound local address
  -> listening socket
  -> accepted connected socket

Созданный сокет имеет семейство протокола и тип. Для нашего сервера — IPv4 или IPv6 TCP-сокет. До bind у него нет локального порта. После bind ядро прикрепило локальный адрес сокета. После listen сокет принимает попытки подключения на этот адрес. После accept ядро возвращает новый дескриптор подключённого сокета, а исходный слушающий сокет продолжает слушать.

Это разделение важно в Node, потому что net.Server и net.Socket оборачивают разные объекты ядра. Сервер — слушающий сокет. Сокет в колбэке connection — принятый подключённый сокет. Закрытие принятого сокета завершает это соединение. Закрытие сервера прекращает приём новых. Уже принятые сокеты могут жить, пока код их не закроет.

1
2
3
4
5
6
const sockets = new Set();

const server = net.createServer((socket) => {
    sockets.add(socket);
    socket.on('close', () => sockets.delete(socket));
});

Это обычное JavaScript-владение над нижним состоянием. Set отслеживает обёртки принятых сокетов. Ядро отслеживает подключённые объекты сокетов. При shutdown часто сначала закрывают сервер, затем закрывают или дренируют принятые сокеты. Это разные операции, потому что целятся в разные дескрипторы.

У слушающего сокета есть очередь ниже JavaScript. Когда попытки подключения завершены на уровне TCP, ОС может держать принятые соединения, пока процесс их не заберёт. Нативный цикл accept Node забирает из этого состояния ядра, когда libuv сообщает о готовности. Backlog и очередь accept — в подглаве 9.6; точка владения здесь: событие connection значит, что сокет ядра для этого пира уже существует.

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

Запись в сеть пересекает ту же линию.

1
socket.write(Buffer.from('hello\n'));

socket.write() принимает байты из JavaScript. Слой потока может буферизовать, пока нативный код их не получит. Node передаёт байты в libuv. libuv просит ОС отправить их по сокету. Ядро может скопировать байты в буфер отправки и вернуть управление до прихода данных к пиру. Позже стек TCP/IP разбивает байты на единицы протокола и отправляет через интерфейс, когда маршрутизация и состояние канального уровня позволяют.

В этом предложении спрятан сетевой стек. Пора назвать части по именам.

Форма стека, от которой зависит Node

Стек TCP/IP — сетевая реализация ОС для интернет-протоколов. Она владеет состоянием транспортного протокола, IP-адресацией, выбором маршрута, сборкой пакетов, передачей на канальный уровень, приёмом и интерфейсом сокетов для процессов.

Для backend на Node полезен компактный вид стека:

1
2
3
4
5
JavaScript bytes
  -> TCP segment or UDP datagram
  -> IP packet
  -> Ethernet frame or another link-layer frame
  -> network interface

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

Пакет — ограниченная единица данных в сетевом стеке. Разработчики часто говорят «пакет» свободно. Здесь нужны конкретные единицы, потому что в отладочном выводе они называются по-разному.

TCP-сегмент — единица протокола TCP. Несёт поля заголовка TCP и срез прикладных байтов. TCP владеет состоянием соединения, упорядочиванием, повторной передачей и механизмами flow control. Жизненный цикл TCP — в подглаве 9.3. Пока достаточно формы: когда TCP-сокет Node отправляет байты, они в итоге становятся одним или несколькими TCP-сегментами.

UDP-дейтаграмма — единица UDP. Несёт заголовок UDP и один payload сообщения. Поведение UDP — в подглаве 9.5, но имя важно уже сейчас: тот же транспортный уровень, что и у TCP-сегментов.

IP-пакет — заголовок IP плюс транспортный payload. Для TCP payload — TCP-сегмент. Для UDP — UDP-дейтаграмма. В заголовке IP — исходный и целевой IP-адреса и метаданные для хостов и маршрутизаторов.

Ethernet-кадр — единица канального уровня в сетях Ethernet. Оборачивает IP-пакет заголовками канального уровня и trailer для доставки по локальному каналу. У Wi‑Fi и других каналов свои форматы кадров, но имена Ethernet постоянно встречаются в захватах пакетов, обсуждениях MTU и инструментах Linux.

Пакеты IPv4 и IPv6 имеют разные форматы заголовков. Роль для этой главы одна: исходный адрес, целевой адрес, метаданные протокола и payload. В IPv4 есть поля, связанные с фрагментацией. В IPv6 поведение фрагментации перенесено в extension headers и поведение пути. Запоминать все поля не нужно, но различие семейств нужно: адреса, структуры сокетов, таблицы маршрутов и заголовки пакетов различаются.

TCP получает байты с пути отправки сокета и отдаёт пиру поток байтов. Граница сегмента — транспортная «сантехника». Приложение не получает колбэк на каждый TCP-сегмент. Один socket.write() может стать многими сегментами. Несколько записей пир может увидеть как один чанк data. Границы события data у пира задаёт чтение потока Node из буфера приёма, а не ваши вызовы write.

UDP сохраняет границы сообщений на API сокета. Один вызов send создаёт один payload дейтаграммы на уровне UDP с учётом лимитов размера и поведения IP. Полная глава про UDP — последствия. Контраст полезен здесь, иначе термины путаются: TCP-сегменты несут поток байтов; UDP-дейтаграммы — отдельные сообщения.

Маршрутизация IP относится к обоим как к payload. Слой IP не заботится, писал ли Node HTTP-запрос, команду Redis, свой бинарный протокол или пустой payload. Он видит номер транспортного протокола, исходный и целевой IP и байты для следующего hop.

Имена слоёв быстро становятся академическими. Держите в поле зрения backend-путь. Node отдаёт байты сокету. У сокета есть транспортный протокол. Транспорт использует IP для адресации хостов. IP использует сетевой интерфейс и решение о следующем hop, чтобы покинуть машину. Локальный формат канала несёт IP-пакет по ближайшему сегменту сети.

Для записи TCP путь примерно такой:

1
2
3
4
5
6
7
socket.write(Buffer)
  -> Node stream/native write queue
  -> kernel TCP send buffer
  -> TCP segment
  -> IP packet
  -> interface transmit queue
  -> link-layer frame

Backpressure из главы про потоки снова здесь. Writable-поток Node может сигнализировать, что его очередь пересекла highWaterMark. У сокета ядра тоже может быть конечный буфер отправки. TCP может применять flow control по окну приёма пира. Это разные точки давления. Они взаимодействуют, но живут у разных владельцев.

JavaScript видит socket.write() с возвратом false, когда слой потока Node просит ждать drain. Сигнал говорит: буферизация на стороне записи Node пересекла локальный порог. Пир мог ещё ничего не получить. Пакет может ждать внутри локального стека. Поток на стороне JavaScript просит производителя замедлиться.

Буфер отправки TCP ядра ниже. Node может отдать байты ОС и получить успешную запись, потому что ядро приняло их в этот буфер. Байты могут ждать сегментации, места в congestion window, доступности канала или повторной передачи. Детали TCP — в следующих главах. Полезная привычка уже сейчас: успешная запись в сокет значит, что локальный стек принял байты к передаче.

Приём идёт вверх по тем же владельцам в обратном порядке. Интерфейс получает кадр. Код канального уровня извлекает IP-пакет. IP проверяет адресацию назначения и протокол. TCP или UDP получает транспортную единицу. Подходящий сокет получает данные или состояние. Ядро сообщает о читаемости. libuv видит готовность. Node читает байты и кладёт их в JavaScript-поток.

1
2
3
4
5
6
7
interface receive
  -> link-layer frame
  -> IP packet
  -> TCP segment
  -> socket receive buffer
  -> libuv readiness
  -> net.Socket data

Node начинает вверху этого пути. Многие баги начинаются ниже.

Интерфейсы и локальные адреса

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

Node отдаёт данные интерфейсов через node:os:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import os from 'node:os';

for (const [name, entries] of Object.entries(
    os.networkInterfaces()
)) {
    console.log(
        name,
        entries.map((e) => `${e.address}/${e.family}`)
    );
}

Вывод зависит от платформы и машины. Обычно есть loopback и один или несколько не-loopback интерфейсов. У ноутбуков — Wi‑Fi. У серверов — несколько NIC. У контейнеров — виртуальные интерфейсы. VPN добавляют туннели. Облачные хосты дают имена, отражающие гостевую ОС, а не маркетинг провайдера.

IP-адрес — адрес сетевого уровня, назначенный интерфейсу или используемый как назначение. Он идентифицирует конечную точку на уровне IP в рамках маршрутизации хоста и сети. В коде Node это обычно строка; ОС хранит структурированные данные адреса с семейством.

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

os.networkInterfaces() может вернуть записи такого вида:

1
2
3
4
5
6
7
{
  address: '127.0.0.1',
  netmask: '255.0.0.0',
  family: 'IPv4',
  internal: true,
  cidr: '127.0.0.1/8'
}

Поля cidr и netmask описывают локальный диапазон адресов интерфейса. Дизайн подсетей — вне этой главы, но прямой эффект важен: ОС может понять, находится ли назначение в непосредственно подключённой сети, сравнивая его с маршрутами интерфейсов из этих диапазонов.

На обычной рабочей станции у Wi‑Fi может быть IPv4 вроде 192.168.1.25/24. Назначение 192.168.1.40 скорее совпадёт с локальным маршрутом той же сети. Назначение 203.0.113.10 скорее пойдёт через маршрут по умолчанию. Код Node только называет назначение; ОС применяет адрес и маршруты.

IPv4 — 32-битное семейство IP-адресов в форме dotted decimal, например 127.0.0.1 или 192.0.2.10. IPv6 — 128-битное семейство в шестнадцатеричных группах, например ::1 или 2001:db8::10. Оба могут сосуществовать на одном хосте.

Loopback — интерфейс локального хоста. Трафик на loopback-адреса остаётся в сетевом стеке этой машины. Обычный IPv4 loopback — 127.0.0.1. IPv6 loopback — ::1.

1
server.listen(3000, '127.0.0.1');

Привязка к 127.0.0.1 открывает сервер на IPv4 loopback. Другие машины не достигнут этого адреса на вашем хосте: loopback локален для хоста. Другой процесс на том же хосте может подключиться.

1
server.listen(3000, '0.0.0.0');

0.0.0.0 — IPv4 wildcard для bind. ОС принимает соединения на этот порт на всех подходящих локальных IPv4-адресах. Фактический входящий локальный адрес зависит от того, какой адрес интерфейса использовал пир.

Путаница с wildcard bind даёт много багов «только локально». Процесс на 127.0.0.1 проходит все локальные тесты и остаётся недоступным из браузера вне контейнера, из другой VM или с другого хоста. Процесс на 0.0.0.0 может быть достижим через каждый IPv4-адрес хоста с учётом firewall и маршрутизации.

IPv6 добавляет ещё один частый сюрприз:

1
server.listen(3000, '::1');

::1 — IPv6 loopback. Это другое семейство адресов, не 127.0.0.1. Клиент на IPv4 loopback попадает в сокеты, привязанные под IPv4. Клиент на IPv6 loopback — под IPv6. Dual-stack и опции только IPv6 — в подглаве про socket options; пока семейство адресов — часть конечной точки.

localhost добавляет разрешение имён. На многих машинах он резолвится и в ::1, и в 127.0.0.1, порядок задают ОС и политика рантайма. DNS и порядок lookup — в следующей главе. В 9.1 используйте числовые адреса, если хотите наблюдать границу сокета без шума от имён.

Выбор семейства адресов меняет и форму ошибки. Сервер на 127.0.0.1 — IPv4-слушатель. Клиент на ::1 целится в IPv6 loopback. Порт может совпадать, а соединение всё равно упадёт: семейство указывает на другую запись в таблице сокетов. Когда в багрепорте «порт 3000 открыт», уточняйте семейство и локальный адрес.

Интерфейсы важны и для исходящих соединений. Код обычно даёт удалённый адрес и порт:

1
2
3
4
5
6
7
import net from 'node:net';

const socket = net.connect(80, '93.184.216.34');

socket.on('connect', () => {
    console.log(socket.localAddress, socket.localPort);
});

ОС выбирает локальный адрес и порт, если вы их не задали. Локальный адрес обычно с интерфейса, выбранного lookup маршрута. Локальный порт обычно эфемерный. Вместе они — локальная конечная точка соединения.

Bind и connect — разные решения. Bind сервера выбирает, где процесс принимает трафик. Connect клиента выбирает удалённую конечную точку; затем ОС выбирает локальную, с которой до неё можно дойти.

Порты и адреса сокетов

Порт — 16-битный номер транспортного уровня для TCP и UDP, идентифицирующий локальную конечную точку в рамках IP-адреса и протокола. Диапазон 065535. ОС обычно резервирует низкие порты для привилегированного использования или политики bind. Точное правило зависит от ОС и конфигурации.

Адрес сокета — IP-адрес плюс порт с семейством адресов. В Node это часто { address, port, family } или отдельные host и port. На границе ОС — структурированные бинарные данные для bind() и connect().

1
2
3
server.listen({ host: '127.0.0.1', port: 3000 }, () => {
    console.log(server.address());
});

Локальный адрес сокета — 127.0.0.1:3000 в семействе IPv4. Для слушающего сервера это адрес, где ОС принимает попытки подключения. У подключённого сокета два адреса конечных точек: локальный и удалённый.

Полную идентичность TCP-соединения обычно описывают кортежем:

1
2
3
4
5
protocol
local IP
local port
remote IP
remote port

Протокол важен: TCP и UDP — разные транспортные пространства. Локальная и удалённая стороны важны: на одном порту сервера может быть много подключённых TCP-сокетов. Веб-сервер слушает 0.0.0.0:443 и принимает соединения с разными remote IP и remote port. У каждого принятого сокета свой кортеж.

1
2
3
4
socket.on('connect', () => {
    console.log(socket.localAddress, socket.localPort);
    console.log(socket.remoteAddress, socket.remotePort);
});

Локальная конечная точка — ваша сторона. Удалённая — пир. TCP различает соединения по кортежу конечных точек. Один процесс Node может иметь много исходящих соединений к одному серверу: у каждого свой локальный эфемерный порт.

Эфемерный порт — временный локальный порт, который ОС выбирает для исходящего соединения или для bind на порт 0. ОС берёт из настроенного эфемерного диапазона и отслеживает использование в таблице сокетов.

1
2
3
4
5
const server = net.createServer();

server.listen(0, '127.0.0.1', () => {
    console.log(server.address());
});

Порт 0 просит ОС выбрать свободный порт. В тестах это часто, чтобы не хардкодить 3000 при параллельных прогонах. Выбранный порт всё равно в таблице сокетов ядра. После listening читайте его и передавайте клиентам точное значение.

Эфемерные порты есть и на стороне клиента:

1
2
3
4
5
const socket = net.connect(3000, '127.0.0.1');

socket.on('connect', () => {
    console.log(socket.localPort);
});

Напечатанный локальный порт пришёл от ОС. Если тесты быстро создают тысячи коротких исходящих соединений, можно исчерпать эфемерный диапазон. Состояние TCP после close может удерживать порты от повторного использования. Подглава 9.3 про TIME_WAIT и разрыв соединения; первый симптом часто здесь: локальные тесты падают с ошибками адреса или connect, хотя удалённый сервер в порядке.

Владение портом — по протоколу и семейству адресов. TCP-слушатель и UDP-сокет — разные транспортные протоколы. IPv4 и IPv6 могут вести себя по-разному при bind в зависимости от socket options и дефолтов платформы. Wildcard bind может конфликтовать с bind на конкретный адрес: ОС должна решить, какой сокет получит трафик на тот же адрес и порт.

При отладке bind используйте точные адреса. 127.0.0.1:3000, ::1:3000 и 0.0.0.0:3000 — разные запросы локальной привязки. localhost:3000 — имя плюс порт, и имя может резолвиться в несколько адресов.

Привилегированные порты — ещё деталь политики хоста. В Unix-подобных системах bind ниже 1024 традиционно требовал повышенных прав; capabilities и настройки контейнера меняют это. Node не даёт лишних привилегий: вызывает ОС и отдаёт результат. Bind на 80 может упасть из‑за политики прав, даже если порт никто не занял.

Порт 0 при bind имеет особый смысл: попросить ОС выделить порт. Порт 0 не сервисный порт для обычного клиентского трафика. После успешного bind у сокета реальный порт от ОС; server.address().port — значение для публикации внутри процесса или теста.

Путь ядра для одной записи

Одна запись даёт самый простой след.

1
socket.write('GET / HTTP/1.0\r\n\r\n');

Строка становится байтами по правилам кодировки потока. Node принимает байты на writable-стороне net.Socket. Если запись может продолжиться, Node создаёт или расширяет нативное состояние записи и просит libuv отправить операцию. libuv использует дескриптор сокета ОС. Пересекается граница системного вызова. Ядро получает указатель на память user space и длину, затем копирует или иначе размещает байты по платформенному пути.

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

Объект сокета в ядре отслеживает протокол, локальную конечную точку, удалённую при подключении, буферы send/receive, состояние ошибки и поля протокола. Для TCP в подключённом состоянии ещё sequence, повторная передача, таймеры, congestion и окно пира. Эти поля TCP — позже. Для 9.1 нужна граница владения: Node владеет JavaScript-объектом и нативной обёрткой; ядро — состоянием передачи.

Сегментация ниже Node. socket.write() может передать 20 байт или 200 KiB. TCP решает, как резать поток байтов при текущих ограничениях. IP оборачивает каждый сегмент в пакет. Канальный уровень кадрирует пакет для выбранного интерфейса. Драйвер интерфейса ставит в очередь на передачу.

Решение о маршруте уже участвует до ухода пакета. Для подключённого TCP-сокета ОС выбрала маршрут к удалённому адресу. Это влияет на исходный адрес, исходящий интерфейс и next hop. Если удалённый адрес — loopback, путь остаётся внутри хоста. Если в непосредственно подключённой сети — пакет уходит через этот интерфейс на link-layer адрес пира. Иначе — на шлюз из таблицы маршрутизации.

На приёме то же разделение. Node читает только после того, как ядро приняло данные в буфер приёма сокета и сообщило о готовности. Если JavaScript перестаёт читать, данные копятся в буферах потока Node и буфере приёма ядра. TCP может уменьшить объявленное окно приёма и замедлить пира. Backpressure пересекает слои, но каждый слой говорит своим сигналом.

Ошибки поднимаются вверх. Неудачный lookup маршрута — ошибка соединения. Сброс пира — ошибка сокета. Запись после teardown — ошибка в духе broken pipe. Точный код зависит от платформы и тайминга. Node переносит их в JavaScript с полем code, но источник часто — переход состояния сокета ядра до колбэка.

Для UDP след менее чистый из‑за границ сообщений; путь UDP — в подглаве 9.5. Форма стека та же: Node отдаёт байты datagram-сокету, ОС создаёт UDP-дейтаграммы, IP несёт их, интерфейс отправляет кадры.

Маршрутизация выбирает интерфейс

Таблица маршрутизации — упорядоченный набор правил хоста, куда отправить IP-пакет дальше. Она сопоставляет диапазоны адресов назначения с локальной доставкой, интерфейсом или шлюзом next hop. Ядро обращается к ней для исходящих пакетов.

В Linux таблицу IPv4 показывает ip route:

1
ip route

Типичный вывод: маршрут по умолчанию плюс локальные сети. Default route — когда нет более специфичного совпадения. Обычно указывает на шлюз через один интерфейс.

Точный вывод зависит от хоста, но форма часто такая:

1
2
default via 192.0.2.1 dev wlan0
192.0.2.0/24 dev wlan0 proto kernel src 192.0.2.10

Вторая строка: хост достигает 192.0.2.0/24 напрямую через wlan0, предпочтительный исходный адрес для этого маршрута — 192.0.2.10. Строка default: остальные IPv4-назначения идут на 192.0.2.1 через wlan0.

Lookup маршрута предпочитает наиболее специфичный совпадающий маршрут. Loopback совпадает с локальным маршрутом loopback. Адрес локальной подсети — с маршрутом напрямую подключённой сети. Публичный удалённый адрес на маленьком хосте — с default route. У серверов, контейнеров, VPN и policy routing правил больше, но локальная модель та же: адрес назначения на входе, результат маршрута на выходе.

В Linux можно показать результат маршрута для одного назначения:

1
ip route get 93.184.216.34

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

У IPv6 своя таблица:

1
ip -6 route

На dual-stack хосте IPv4-маршрут может работать, а IPv6 — нет, или наоборот. Для Node оба случая — сетевые операции. Семейство адресов, выбранное до маршрутизации, решает, какая таблица важна.

Ядро выбирает исходящий интерфейс для обычного net.connect(). Node просит подключиться к удалённому адресу сокета. ОС выбирает исходный адрес и маршрут по назначению и опциональным настройкам локального bind.

1
2
3
4
5
const socket = net.connect({
    host: '93.184.216.34',
    port: 80,
    localAddress: '192.0.2.10',
});

localAddress ограничивает исходный адрес. ОС всё равно проверяет, что адрес есть на хосте и подходит для этого маршрута. Если локального адреса нет — connect падает. Если адрес есть, но политика блокирует маршрут — connect тоже падает.

Маршрутизация loopback особенная: остаётся внутри стека хоста:

1
net.connect(3000, '127.0.0.1');

Назначение совпадает с loopback. Путь пакета — локальная доставка хоста, а не физический NIC. TCP всё равно работает. Ядро отслеживает конечные точки. Но разрешение на канальном уровне и внешние устройства пропускаются: назначение локально для хоста.

Сетевой стек контейнера делает границу видимой. Внутри контейнера 127.0.0.1 — сетевой namespace контейнера. Сервис на loopback в одном контейнере локален для этого namespace. Достичь другого контейнера или хоста нужен адрес и маршрут через границу namespace. Детали Kubernetes — позже; одно правило экономит время: loopback ограничен тем network namespace, где выполняется lookup.

Wildcard bind взаимодействует с маршрутизацией со стороны приёма. Сервер на 0.0.0.0:3000 может принимать соединения на любой подходящий локальный IPv4-адрес. Адрес назначения входящего пакета выбирает локальный адрес для принятого соединения. server.address() у слушающего сервера может печатать 0.0.0.0, но у каждого принятого сокета есть конкретный localAddress.

1
2
3
net.createServer((socket) => {
    console.log(socket.localAddress, socket.localPort);
}).listen(3000, '0.0.0.0');

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

Сбои маршрутизации часто выглядят как таймауты, unreachable или соединения не с тем сервером. При проверке маршрута смотрите адрес назначения, а не строку URL. Разрешение имени может выбрать одно семейство, маршрутизация решает, как двигать этот адрес. Имя — в подглаве 9.2.

Lookup маршрута объясняет сюрпризы с исходным адресом на хостах с несколькими активными интерфейсами. Wi‑Fi, Ethernet, VPN и контейнерные интерфейсы одновременно. Адрес назначения выбирает маршрут. Маршрут — предпочтительный исходный адрес. Если в логах только удалённый URL, нижний выбор теряется. Запись socket.localAddress при установке соединения делает выбранный маршрут видимым без захвата пакетов.

Локальная доставка — тоже результат маршрута. Если назначение — один из адресов самого хоста, ядро может доставить внутри. Это возможно не только для 127.0.0.1: подключение к собственному не-loopback адресу с той же машины может остаться в локальных путях в зависимости от ОС. Конечные точки сокета всё равно показывают выбранные адреса — их инспекция лучше, чем угадывание по именам интерфейсов.

ARP, Neighbor Discovery и MTU

Маршрутизация выбирает интерфейс и, возможно, next hop. На локальном канале всё ещё нужна цель доставки.

ARP (Address Resolution Protocol) сопоставляет IPv4-адрес в локальном сегменте с link-layer адресом, например MAC. Если маршрут говорит, что IPv4-пакет идёт напрямую к пиру или шлюзу в Ethernet-подобной сети, хосту нужен link-layer адрес этого next hop. ARP его даёт и кэширует.

Neighbor Discovery — механизм IPv6 для разрешения соседей и связанных задач локального канала. Для 9.1 релевантна параллель с ролью ARP в отладке: локальная доставка IPv6 нуждается в информации о соседе до отправки кадров по каналу.

Node обычно видит ARP и Neighbor Discovery через симптомы. Connect может зависнуть, пока ОС разрешает next hop. В захвате пакетов перед TCP могут быть ARP-запросы. Маршрут верный, а сосед next hop недоступен. JavaScript видит задержку прогресса connect или ошибку после отказа ОС.

MTU — Maximum Transmission Unit, максимальный размер payload пакета, который канал несёт на этом уровне без фрагментации. У Ethernet MTU IP-пакета часто 1500 байт, хотя в средах бывают другие значения.

MTU важен, потому что Node пишет потоки байтов, а сеть шлёт ограниченные единицы. Большой socket.write() не становится одним гигантским пакетом. Стек режет байты под ограничения транспорта, IP и канала. Если пакет слишком велик для сегмента пути, нужна фрагментация или отбрасывание с сигналом ошибки — в зависимости от протокола, флагов и сети.

В продакшене MTU встречают по симптомам: малые запросы проходят, крупные payload зависают, VPN ведёт себя иначе, в захвате — повторные передачи у границы размера. Node видит медленные записи, зависшие чтения, reset или таймауты. Исправление может быть ниже Node: MTU интерфейса, туннель, firewall, path MTU discovery.

Размер прикладного payload и размер пакета могут сильно расходиться:

1
socket.write(Buffer.alloc(64 * 1024));

Запись отдаёт 65 536 байт в путь сокета. Ethernet с MTU 1500 не унесёт это одним IP-пакетом. Сегментация TCP и offload меняют картину в захвате; драйвер может делать работу поздно в пути передачи. Число байт в JavaScript остаётся прикладным.

Инструменты захвата путают ещё сильнее из‑за offload. Захват до segmentation offload может показывать большие «псевдопакеты» больше физического MTU. В другом месте — меньшие кадры на проводе. Относитесь к захвату как к наблюдению в конкретной точке стека.

Держите лексику захвата точной при отладке этого слоя:

1
2
3
4
Ethernet frame
  -> IP packet
  -> TCP segment
  -> application bytes

«Пакет 1514 байт на проводе» может включать Ethernet-обрамление. «MTU 1500» обычно про размер IP-пакета в Ethernet. «TCP payload» — прикладные байты внутри TCP-сегмента после заголовков TCP и IP. Числа различаются из‑за заголовков на каждом слое.

Длина Buffer в Node — число прикладных байтов, не размер пакета. Buffer на 64 KiB может стать многими TCP-сегментами. Несколько малых записей могут сливаться ниже в зависимости от буферизации и TCP. Nagle, delayed ACK и socket options — позже. На этом слое нужно только разделение: границы прикладных байтов и границы пакетов принадлежат разному коду.

У ARP и Neighbor Discovery есть кэши. Хост обычно разрешает next hop один раз, сохраняет и переиспользует до истечения или смены записи. Первая попытка connect к пиру может платить стоимостью разрешения; следующие — нет. Устаревшее состояние соседа даёт сбои, которые проходят после истечения кэша или смены интерфейса. В net нет специального API для этого кэша; при симптомах ниже IP-маршрутизации — инструменты ОС.

Наблюдение за хостом из Node

os.networkInterfaces() даёт JavaScript-вид адресов интерфейсов. Не полную таблицу маршрутов, кэш соседей или таблицу сокетов. Но это первая хорошая проверка: какие адреса хост сейчас отдаёт процессу Node.

1
2
3
import os from 'node:os';

console.dir(os.networkInterfaces(), { depth: null });

В каждой записи есть address, netmask, family, mac, internal, CIDR где доступно. internal: true — loopback-подобные адреса. family — IPv4 или IPv6.

Эти данные помогают объяснить поведение bind:

1
2
3
4
5
6
import net from 'node:net';

const host = process.argv[2] || '127.0.0.1';
net.createServer().listen(3000, host, () => {
    console.log(`listening on ${host}:3000`);
});

Запустите с 127.0.0.1, затем с реальным адресом интерфейса из os.networkInterfaces(), затем с 0.0.0.0. Код сервера почти не меняется. Запрос bind в ядре меняется полностью.

Конфликты портов нужно смотреть в таблице сокетов. В Linux слушающие TCP показывает ss:

1
ss -ltnp

Вывод называет локальные адреса и порты. С правами — и процесс. Это вид при ошибке bind в Node. Начните с адреса сокета, уже занятого в таблице ядра, затем найдите процесс-владельца.

Node показывает выбранный адрес после bind:

1
2
3
4
5
const server = net.createServer();

server.listen(0, '127.0.0.1', () => {
    console.log(server.address());
});

Для параллельных тестов лучше, чем фиксированные порты: пусть ОС выберет, прочитайте порт, передайте клиенту, закройте сервер в конце. Фиксированные порты привязывают тесты к глобальному состоянию хоста.

Удалённые соединения показывают локальное состояние, выбранное маршрутом:

1
2
3
4
5
6
const socket = net.connect(80, '93.184.216.34');

socket.on('connect', () => {
    console.log(socket.localAddress, socket.localPort);
    socket.end();
});

Локальный адрес — от выбора маршрута. Локальный порт — от эфемерного выделения. На Wi‑Fi, VPN, в контейнере и на CI runner значения различаются из‑за интерфейсов и маршрутов хоста.

Принятые сокеты показывают обе стороны входящего состояния:

1
2
3
4
5
6
net.createServer((socket) => {
    console.log({
        local: `${socket.localAddress}:${socket.localPort}`,
        remote: `${socket.remoteAddress}:${socket.remotePort}`,
    });
}).listen(3000, '0.0.0.0');

Такой лог лучше общего «client connected». Видно, на какой локальный адрес пришло соединение и какой удалённый endpoint сообщило ядро. В средах с прокси, NAT или публикацией портов поля могут отражать ближайшего пира на этом слое. Прокси-заголовки и идентичность выше — в следующих главах.

Ошибкам нужна та же дисциплина границы:

1
2
3
server.on('error', (err) => {
    console.error(err.code, err.address, err.port);
});

Ошибки bind часто включают попытавшийся адрес и порт. Логируйте их. Сервис с текстом «failed to start» выбрасывает точный адрес сокета, который отклонила ОС.

Несколько локальных ошибок читаются как подсказки границы сокета:

1
2
3
4
5
6
bind failed
  -> local address absent, busy, or blocked by policy
connect failed
  -> remote path, peer state, or local route problem
write failed
  -> connected socket state changed below JavaScript

Точное значение code зависит от операции и платформы. EADDRINUSE при bind — адрес уже занят в таблице сокетов или недоступен из‑за состояния и опций. EADDRNOTAVAIL — локальный адрес, который хост не может использовать для этого bind/connect. ECONNREFUSED при connect — хост активно отклонил попытку на транспортном уровне, часто потому что никто не слушал эту конечную точку. Полный TCP- и socket-options контекст — в следующих подглавах. На этом уровне все несут одно: ОС отклонила или изменила операцию сокета ниже JavaScript.

Поэтому обработчики ошибок должны печатать поля, которые даёт Node:

1
2
3
4
5
6
7
socket.on('error', (err) => {
    console.error({
        code: err.code,
        address: err.address,
        port: err.port,
    });
});

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

Loopback даёт более чистый локальный след:

1
2
3
4
5
6
7
8
const server = net.createServer((s) => s.end('ok\n'));

server.listen(0, '127.0.0.1', () => {
    const { port } = server.address();
    const client = net.connect(port, '127.0.0.1');
    client.on('close', () => server.close());
    client.pipe(process.stdout);
});

Внешняя сеть не нужна. Сервер на IPv4 loopback с эфемерным портом. Клиент на тот же адрес сокета. TCP работает. У принятого сокета есть локальные и удалённые конечные точки. Маршрутизация выбирает loopback. Разрешение на канальном уровне вне пути.

IPv6 loopback — свой адрес:

1
2
3
4
5
6
7
8
const server = net.createServer((s) => s.end('ok\n'));

server.listen(0, '::1', () => {
    const { port } = server.address();
    const client = net.connect(port, '::1');
    client.on('close', () => server.close());
    client.pipe(process.stdout);
});

Если падает на машине — сначала IPv6 и локальная политика, не Node. ::1 и 127.0.0.1 — разные адреса сокетов. localhost после lookup может выбрать любой из них.

Краткий путь отладки на этом слое обычно механический:

1
2
3
4
5
6
numeric destination address
  -> address family
  -> local bind address, if any
  -> route result
  -> socket table entry
  -> interface and neighbor state

Оставайтесь на числах, пока маршрут и bind не станут понятны. Имена — позже. DNS добавляет ещё одно движущееся звено; ему — своя глава.

Wildcard, localhost и другие острые края

Адреса wildcard bind — инструкции на стороне приёма.

0.0.0.0 — все подходящие локальные IPv4-адреса для этого порта. :: — unspecified IPv6. В зависимости от платформы и socket options IPv6 wildcard может принимать или не принимать IPv4-mapped соединения. Считайте это зависимым от платформы и опций; dual-stack — в подглаве 9.6.

Адрес слушающего сервера может выглядеть менее конкретно, чем фактический трафик:

1
2
3
4
5
const server = net.createServer((socket) => {
    console.log('accepted on', socket.localAddress);
});

server.listen(3000, '0.0.0.0');

server.address() — адрес bind слушателя. socket.localAddress на каждом принятом сокете — конкретный локальный адрес этого соединения. Нужен принятый сокет, чтобы знать, до какого адреса интерфейса дошёл клиент.

localhost — локальное имя хоста, резолвящееся в один или несколько адресов. Обычно через hosts и политику резолвера; может дать ответы IPv6 и IPv4. Сервер только на 127.0.0.1, клиент на localhost — клиент может сначала пробовать ::1 и упасть до IPv4 в зависимости от lookup и политики клиента. Это тема следующей подглавы, но fix на стороне bind уже виден: совпадите семейство адресов или bind так, чтобы поддерживать оба семейства под политикой платформы.

Исчерпание эфемерных портов — баг локального состояния. Появляется, когда процесс или тесты создают исходящие соединения быстрее, чем ОС переиспользует локальные порты. Удалённый сервис здоров. Ограничивает локальная машина. Симптомы зависят от ОС и тайминга; корень один: кортежу локальной конечной точки нужен уникальный эфемерный порт, а в таблице сокетов ядра слишком много активного или недавно закрытого состояния.

Тесты с портом 0 на сервере избегают конфликтов listen-порта, но могут создавать много исходящих клиентских сокетов. Закрытие сервера ≠ закрытие всех клиентских соединений. Тест со сотнями локальных TCP должен закрывать принятые сокеты, ждать close и не считать порт свободным в момент, когда JavaScript отпустил ссылку.

Контейнеры добавляют область адресов. Сервис в контейнере на 127.0.0.1 слушает в namespace контейнера. Публикация порта через runtime не сделает loopback-only сервис слушателем на всех интерфейсах контейнера, если runtime или прокси не создают отдельный путь форвардинга. Bind на 0.0.0.0 внутри контейнера открывает сервис на IPv4 интерфейсах контейнера; правила публикации на хосте решают, что достижимо снаружи.

VPN и несколько интерфейсов дают сюрпризы маршрутов. Вчера работавшее назначение сегодня идёт в туннель. Код Node не менялся. Менялась таблица маршрутизации. socket.localAddress — часто первая подсказка: исходный адрес для соединения. Если он с VPN или контейнерного интерфейса, маршрутизация уже уводит трафик не туда, куда вы ожидали.

Баги размера пакета редко называют себя MTU. Выглядят как частичный прогресс. Малые payload проходят. Крупные зависают или сбрасываются. TLS и HTTP делают симптом «высокоуровневым», но путь может быть MTU канала или фрагментацией. Держите MTU в списке кандидатов при подозрительных границах размера, особенно через туннели.

Три пути из одного и того же кода

Один и тот же вызов Node может пройти три разных пути хоста в зависимости от адреса назначения.

1
2
3
4
5
import net from 'node:net';

const socket = net.connect(3000, process.argv[2]);

socket.on('connect', () => socket.end('ping\n'));

Запустите с 127.0.0.1, с адресом в той же локальной сети и с публичным удалённым адресом. JavaScript тот же. Меняется адрес сокета. Путь ядра меняется после того, как connect() пересёк границу системного вызова.

Для IPv4 loopback результат маршрута локальный:

1
2
3
4
127.0.0.1:random -> 127.0.0.1:3000
  -> loopback route
  -> local TCP processing
  -> peer socket in the same host

Без ARP. Без шлюза. Без передачи через физический интерфейс. Пакет всё равно проходит IP и TCP, но хост доставляет внутри. Поэтому loopback-тесты быстрые и стабильные по сравнению с удалёнными. И поэтому успех на loopback доказывает меньше, чем хочется: процесс умеет bind, локально маршрутизировать и говорить через локальный TCP-стек.

Для назначения в той же локальной сети lookup обычно выбирает напрямую подключённый интерфейс:

1
2
3
4
192.0.2.10:random -> 192.0.2.40:3000
  -> route matches local subnet
  -> ARP for 192.0.2.40
  -> frame leaves wlan0 or eth0

Исходный адрес, скорее всего, с того же интерфейса. ARP разрешает link-layer адрес пира, если в кэше нет записи. Кадр уходит через выбранный интерфейс. Ядро пира принимает и сопоставляет со слушающим или подключённым сокетом.

Для удалённого назначения lookup обычно выбирает шлюз:

1
2
3
4
192.0.2.10:random -> 203.0.113.20:3000
  -> default route via 192.0.2.1
  -> ARP for the gateway
  -> frame leaves toward the gateway

Цель канального уровня — шлюз, IP-назначение остаётся удалённым адресом. В захвате Ethernet-кадр идёт на next hop, IP-пакет — на конечное назначение для этого участка пути. Маршрутизаторы повторяют forwarding, пока пакет не дойдёт до сети назначения или не упадёт.

Node этого напрямую не видит. Видит прогресс connect, ошибки, читаемость и записываемость сокета. Таймаут может значить: удалённый хост не ответил, шлюз отбрасывает пакеты, firewall блокирует путь, маршрут увёл в туннель, neighbor resolution упала, TCP ушёл в retry. Ошибка JavaScript приходит поздно и часто без нижних деталей.

Лог кортежа конечных точек даёт конкретную отправную точку:

1
2
3
4
socket.on('connect', () => {
    console.log(socket.localAddress, socket.localPort);
    console.log(socket.remoteAddress, socket.remotePort);
});

Локальный адрес — какой исходный адрес выбрало ядро. Удалённые поля — до какого числового пира дошёл сокет после этапа lookup. Вместе с ip route get для удалённого адреса и ss для локального состояния сокетов путь хоста становится проверяемым без смены логики приложения.

Входящий трафик делится так же. Сервер на 0.0.0.0:3000 может получить loopback, LAN или маршрутизированное соединение через шлюз или границу port forwarding. JavaScript получает одинаковую форму колбэка connection. Поля принятого сокета несут локальные и удалённые конечные точки для этого пути.

1
2
3
4
5
net.createServer((socket) => {
    console.log(socket.localAddress);
    console.log(socket.remoteAddress);
    socket.end();
}).listen(3000, '0.0.0.0');

Подключитесь с того же хоста через 127.0.0.1, затем через его не-loopback адрес, затем с другого хоста. Напечатанные адреса показывают разные решения маршрутизации и доставки при одном объекте сервера. В этом смысл полей адреса на сокете. Wildcard слушателя — только политика приёма. Подключённый сокет фиксирует конкретный путь.

Что API Node намеренно скрывают

Сетевые API Node отдают достаточно состояния для прикладного кода. Скрывают достаточно деталей ядра, чтобы API оставался переносимым.

net.Socket показывает итоговые локальные и удалённые адреса. Инструменты ОС — запись таблицы маршрутов для соединения. os.networkInterfaces() — адреса интерфейсов. Инструменты ОС — кэш соседей. server.listen() — успех или ошибка и привязанный адрес. Платформенные утилиты — socket options и состояние ядра за результатом.

Эта форма — осознанный дизайн API. Node работает на Linux, macOS, Windows, BSD, в контейнерах и управляемых средах. У socket API общие концепции, но таблицы маршрутов, кэши соседей, имена интерфейсов, модели привилегий и дефолты dual-stack различаются. JavaScript-поверхность сосредоточена на стабильных кроссплатформенных операциях: bind, connect, read, write, close и инспекция адресов конечных точек.

Скрытые поля всё равно существуют. Для них — инструменты ОС.

1
2
3
4
5
Node API                 Host detail
os.networkInterfaces()   interface addresses
ss -ltnp                 listening TCP sockets
ip route get ADDRESS     route decision
ip neigh                 neighbor cache

Это разделение держит отладку честной. Node — что процесс запросил и какой endpoint получил. ОС — как хост решил двигать пакеты. Захват пакетов — когда вопрос дошёл до кадров, пакетов, сегментов и повторных передач. Каждый инструмент — у слоя, которому принадлежит состояние.

Есть и тайминг. Node может прочитать адрес сокета после bind или connect. Маршрут и соседи могут измениться позже. Интерфейсы могут упасть. Появятся VPN-маршруты. Шлюз перестанет отвечать. DNS позже вернёт другой адрес. Сокет, успешно подключившийся, может упасть на следующей записи, потому что нижнее состояние изменилось после setup.

Долгоживущим сервисам нужен этот mindset. Проверки при старте доказывают только состояние старта. ОС продолжает принимать решения о маршруте, соседях, буферах и интерфейсах для каждого пакета после лога «listening».

Где заканчивается Node

Низкоуровневые сетевые API Node дают объекты, события, потоки, буферы, адреса, порты и ошибки. Они над сетевым стеком хоста. Таблица сокетов ядра решает, валиден ли bind. Таблица маршрутизации — исходящий интерфейс. ARP или Neighbor Discovery разрешают next hop на локальных каналах. MTU ограничивает размер пакета. Интерфейс отправляет и принимает кадры.

Это хорошее инженерное давление. Держите прикладной код честным относительно того, чем он владеет.

Если баг «сервер не эмитит connection» — смотрите адрес слушателя и таблицу сокетов до смены логики приложения. Если «клиент подключается с неверного исходного адреса» — выбор маршрута до retry-кода. Если «localhost работает, контейнер не достучался» — область loopback и адрес bind до HTTP. Если «крупные записи зависают» — буферизация, backpressure и MTU до обвинения сериализации.

Выше лежат протоколы. DNS превратит имена в кандидаты адресов. TCP добавит жизненный цикл, flow control, повторную передачу и teardown. node:net откроет API сокетов с семантикой потоков. UDP даст ориентированные на сообщения дейтаграммы. HTTP и TLS добавят своё состояние над транспортом.

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

Связанное чтение

Комментарии