15. Порты, сокеты и Socket API
Порты и сокеты: Логические и физические точки контакта
"В Unix всё является файлом; если что-то не является файлом — это процесс."
— Фундаментальный принцип архитектуры Unix
Когда мы пишем на Go лаконичный сервер http.ListenAndServe(":8080", handler), язык бережно скрывает под капотом целую вселенную системных концепций, изобретенных в начале 1980-х годов в университете Беркли (BSD).
Для бэкенд-разработчика понимание портов и сокетов — это мост между абстрактным HTTP-сервером и реальным сетевым стеком операционной системы. В Go мы редко пишем сырые socket() вызовы вручную, но производительность, стабильность и масштабируемость вашего сервиса напрямую зависят от того, как вы понимаете их внутреннее устройство.
Что такое порт?
Сетевой кабель или Wi-Fi антенна доставляют пакеты на конкретную физическую сетевую карту с определенным MAC- и IP-адресом. Но на компьютере одновременно работают десятки программ: браузер, база данных PostgreSQL, демон SSH, мессенджер и ваш собственный Go-сервис. Как ядро операционной системы понимает, какому именно процессу предназначен входящий пакет?
Порт — это 16-битное логическое число (от 0 до 65535), которое идентифицирует конкретное приложение или службу внутри одного IP-узла. Порт не существует в физической сети; он является исключительно механизмом мультиплексирования на уровне хоста.
Все 65 536 портов разделены IANA на три стандартизированные категории:
- Well-known ports (0–1023): Системные порты. Исторически требуют привилегий суперпользователя
root(или Linux-capabilityCAP_NET_BIND_SERVICE) для биндинга. Это защищает систему от перехвата привилегированных служб обычными непривилегированными процессами: HTTP (80), HTTPS (443), SSH (22), DNS (53). - Registered ports (1024–49151): Зарегистрированные порты. Назначаются IANA для конкретных общеизвестных протоколов и баз данных: PostgreSQL (
5432), Redis (6379), MySQL (3306), gRPC/HTTP-серверы (8080,8443). Не требуют прав root. - Ephemeral ports (по IANA/RFC 6335 — 49152–65535; в Linux по умолчанию — 32768–60999, см.
net.ipv4.ip_local_port_range): Эфемерные (динамические) порты. Выделяются ядром ОС автоматически для исходящих клиентских соединений, когда ваша программа делает исходящий вызовnet.Dialк внешнему API или базе данных.
В Go порт указывается строкой в вызове net.Listen(":8080") (или net.Listen("tcp", ":8080")). Язык автоматически обрабатывает как IPv4, так и IPv6 (dual-stack), создавая сокет с поддержкой обоих протоколов (через флаг ядра IPV6_V6ONLY=0), если ядро это поддерживает.
Что такое сокет?
Если IP-адрес — это адрес жилого дома, а порт — номер квартиры, то сокет — это открытая дверь этой квартиры, через которую идет непрерывный разговор.
Сокет — это конечная точка взаимодействия (endpoint) в сетевом стеке, абстрагированная через классический BSD Socket API. С точки зрения транспортного протокола уникальность любого сетевого соединения определяется кортежем:
$$\mathbf{IP + Port + Protocol} \quad \text{или полный 5-tuple: } \{\text{Proto, SrcIP, SrcPort, DstIP, DstPort}\}$$
В классическом языке C сокет — это просто дескриптор файла (FD, File Descriptor, целое число int). В Go стандартная библиотека предоставляет абстракцию net.Conn — интерфейс, инкапсулирующий сырой сокет, буферы, дедлайны таймаутов и состояние соединения.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph NODE["Сетевой хост (Linux Server)"]
IP["IP-адрес: 192.168.1.10"]:::process
P80["Порт :80 (HTTP)"]
P443["Порт :443 (HTTPS)"]
P5432["Порт :5432 (Postgres)"]
IP --> P80
IP --> P443
IP --> P5432
P80 --> S1["Сокет FD=4<br>net.Conn"]
P443 --> S2["Сокет FD=5<br>net.Conn"]
P5432 --> S3["Сокет FD=6<br>net.Conn"]
end
Socket API: Жизненный цикл и системные вызовы
Независимо от языка программирования, базовый жизненный цикл TCP-сервиса опирается на классические системные вызовы BSD: socket, bind, listen, accept, connect. В Go они бережно упакованы в элегантный сетевой пакет net, но понимание их работы критично для отладки высоконагруженных систем.
// Идиоматичный Go-код, скрывающий под капотом каскад системных вызовов ядра
package main
import (
"errors"
"log"
"net"
)
func main() {
// 1. Создает сокет, связывает с адресом и переводит в режим прослушивания
// Вызовы: socket() -> bind() -> listen()
ln, err := net.Listen("tcp", ":8080")
if err != nil {
log.Fatalf("listen failed: %v", err)
}
defer ln.Close() // Вызовет close() syscall и освободит FD
for {
// 2. Ожидает входящего клиента и извлекает готовое соединение из очереди ядра
// Вызов: accept4()
conn, err := ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return // слушатель закрыт: выходим, а не крутим цикл вхолостую
}
log.Printf("accept failed: %v", err)
continue
}
// 3. Запускает легковесную горутину для обслуживания клиента
// Каждая goroutine управляет своим собственным сетевым соединением и FD
go handleConn(conn)
}
}
func handleConn(c net.Conn) {
defer c.Close()
buf := make([]byte, 1024)
for {
// Вызовы read() / write() в неблокирующем режиме под управлением netpoller
n, err := c.Read(buf)
if err != nil {
return
}
if _, err := c.Write(buf[:n]); err != nil {
return
}
}
}
Разберем каждый системный вызов ядра Linux в отдельности:
sequenceDiagram
autonumber
participant Client as Клиент (net.Dial)
participant Kernel as Ядро Linux (Сетевой стек)
participant Server as Go Сервер (net.Listen)
Server->>Kernel: 1. socket(AF_INET6, SOCK_STREAM|SOCK_NONBLOCK|SOCK_CLOEXEC, 0) -> FD=3 (dual-stack для ":8080")
Server->>Kernel: 2. bind(FD=3, 0.0.0.0:8080)
Server->>Kernel: 3. listen(FD=3, backlog=somaxconn)
Server->>Kernel: 4. accept4(FD=3, O_NONBLOCK) -> ожидание в netpoll
Client->>Kernel: 5. connect(Server_IP, 8080) -> SYN
Kernel-->>Client: SYN-ACK
Client-->>Kernel: ACK (Соединение в Established)
Kernel->>Server: netpoll будит горутину: accept4() возвращает новый FD=4!
Note over Server,Kernel: Оригинальный FD=3 продолжает слушать порт :8080
Client->>Server: write(FD_Client, data) -> read(FD=4, buf)
Server->>Client: write(FD=4, response) -> read(FD_Client, buf)
Server->>Kernel: close(FD=4) -> Отправка FIN
Kernel-->>Client: FIN / ACK (Закрытие соединения)
1. socket() → socket() syscall
Создает новую конечную точку взаимодействия и выделяет структуру сокета в ядре.
int socket(int domain, int type, int protocol);
- Возвращает File Descriptor (FD) — неотрицательное целое число, служащее индексом в таблице открытых дескрипторов процесса.
- В Go создаваемый FD немедленно переводится в неблокирующий режим (
O_NONBLOCK) с помощью флагаSOCK_NONBLOCKили вызоваfcntl(fd, F_SETFL, O_NONBLOCK). Это принципиально для архитектуры Go: среда выполнения никогда не позволяет системным потокам ОС зависать на блокирующих сокетах!
2. bind() → bind() syscall
Связывает созданный безымянный сокет с локальным сетевым адресом и портом (sockaddr_in или sockaddr_in6).
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
Если порт уже используется другим процессом или удерживается ядром в состоянии TIME_WAIT, системный вызов завершается с ошибкой EADDRINUSE (Address already in use).
3. listen() → listen() syscall (только TCP)
Переводит сокет из пассивного закрытого состояния в активное состояние LISTEN, объявляя ядру готовность принимать входящие клиентские подключения:
int listen(int sockfd, int backlog);
Параметр backlog задает максимальную длину очереди входящих соединений, ожидающих подтверждения или обработки.
4. accept() → accept4() syscall
Берет готовое соединение из очереди, созданной клиентским connect(). Извлекает первое полностью установленное соединение из очереди Accept Queue сокета-слушателя:
int accept4(int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags);
- Системный вызов создает и возвращает совершенно новый FD для установленного соединения (например,
FD=4). - Оригинальный сокет-слушатель (
FD=3) остается в неизменном состоянииLISTEN, продолжая принимать новых клиентов на порту 8080! - В Go используется оптимизированный вызов Linux
accept4с флагамиSOCK_CLOEXEC | SOCK_NONBLOCK. Это позволяет атомарно за один системный вызов создать дескриптор, уже переведенный в неблокирующий режим и защищенный от утечки в дочерние процессы приfork/exec. - Вызов
ln.Accept()в Go является неблокирующим для системного потока ОС. Если очередь соединений пуста, горутина регистрируется вnetpollи засыпает до прихода аппаратного прерывания и событияEPOLLIN.
5. read() / write() / close()
Передача данных осуществляется через системные вызовы read(2) и write(2) (или recv/send):
- Запись (
write) помещает байты в системный кольцевой буфер отправки ядра сокета. - Чтение (
read) извлекает байты из буфера приема ядра.
Системный вызов close() на сокете инициирует корректное завершение соединения (Four-Way Handshake) с отправкой пакета FIN.
Однако close() закрывает сокет в обоих направлениях. Если вам нужно закрыть отправку данных, но продолжить чтение ответа от сервера (Half-Close), в Unix используется системный вызов shutdown(sockfd, SHUT_WR). В Go для этого используется метод conn.(*net.TCPConn).CloseWrite().
Под капотом: Сокеты в Linux
Какую физическую структуру представляет собой сетевой сокет внутри операционной системы?
В архитектуре ядра Linux сетевой сокет является многоуровневым гибридным объектом ядра, связывающим виртуальную файловую систему (VFS) с сетевым стеком и драйвером физического устройства:
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph PROCESS["Пользовательский процесс (Go Runtime)"]
FDT["Таблица дескрипторов (FDT)<br>FD=3 -> указатель на struct file"]
end
subgraph VFS["Виртуальная файловая система (VFS)"]
SF["struct file<br>f_op = socket_file_ops<br>private_data -> struct socket"]:::network
end
subgraph BSD_SOCKET["BSD Socket Layer"]
SOCK["struct socket<br>state = SS_CONNECTED<br>ops = inet_stream_ops<br>struct sock *sk"]:::network
end
subgraph INET_SOCK["Транспортный уровень (TCP/IP)"]
SK["struct sock (struct inet_sock / tcp_sock)<br>----------------------------------------<br>sk_receive_queue (очередь sk_buff)<br>sk_send_queue (очередь sk_buff)<br>Окна: snd_wnd, rcv_wnd<br>Таймеры: RTO, KeepAlive, Retransmit"]
end
subgraph HARDWARE["Сетевой адаптер (NIC)"]
NIC["Сетевая карта (NIC)<br>Ring Buffer / DMA контроллер"]
end
FDT --> SF
SF --> SOCK
SOCK --> SK
SK <--> NIC
struct file: Поскольку сокет проецируется на файловый дескриптор, ядро создает объект файла VFS. Его полеprivate_dataсодержит указатель на сетевой сокет.struct socket: Абстракция интерфейса BSD Socket. Содержит указательstruct file *fileи указатель на транспортный сокетstruct sock *sk.struct sock: Фундаментальное ядро сетевого стека Linux. Именно здесь живут:sk_receive_queueиsk_send_queue: Двусвязные очереди сетевых пакетовsk_buffв памяти ядра.- Окна передачи и приема: окно отправки
snd_wndи окно приемаrcv_wnd. - Сетевые таймеры: повторной передачи (
RTO), проверки доступности (keepalive) и ожидания закрытия.
- Копирование данных в памяти:
При вызовеread()илиwrite()ядро осуществляет копирование памяти между адресным пространством ядра и пользовательским буфером процесса с помощью функцийcopy_to_userиcopy_from_user. Это требует переключения контекста процессора из User Space (Ring 3) в Kernel Space (Ring 0) и обратно.
Go и Socket API: Абстракция и Netpoller
Традиционные серверы на C или Java (до эры Project Loom) выделяли один системный поток ОС (pthread) на каждое входящее соединение. При 10 000 соединений это приводило к коллапсу: 10 000 потоков съедали гигабайты памяти на свои стеки (по 1–8 МБ каждый), а планировщик ядра захлебывался в непрерывных переключениях контекста потоков.
В Go сетевая архитектура спроектирована абсолютно иначе. Go использует Netpoller — неблокирующий реактор событий ввода-вывода, интегрированный непосредственно в планировщик рантайма Go (M:N Scheduler). В зависимости от операционной системы Netpoller опирается на:
epollв Linux;kqueueв BSD и macOS;IOCP(I/O Completion Ports) в Windows.
Как Go обрабатывает тысячи соединений?
Механика работы Netpoller в Go выглядит следующим образом:
- При создании сокета через
net.Listen()илиnet.Dial()рантайм Go немедленно переводит файловый дескриптор в режимO_NONBLOCK. - Дескриптор сокета регистрируется в дескрипторе
epollс помощью системного вызоваepoll_ctl(через вызовepoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event)). - Когда горутина вызывает
conn.Read()илиconn.Write(), а системный буфер пуст/полон, системный вызов ядра мгновенно возвращает ошибкуEAGAINилиEWOULDBLOCK. - Рантайм Go перехватывает
EAGAIN. Функцияruntime.goparkпаркует текущую горутину, переводя её структуруgв состояние ожидания (_Gwaiting). Системный поток ОС (M) не блокируется! Он немедленно берет из локальной очередиPдругую готовую горутину и продолжает выполнять полезную работу. - Сетевая карта получает пакет от клиента, генерирует аппаратное прерывание, и ядро помещает данные в буфер сокета
sk_receive_queue. - Поток рантайма Go, который опрашивает поллер (
netpollвызывают планировщик вfindRunnable,sysmonи потокMбез работы; выделенного фонового потока нет) черезepoll_pwait, получает уведомление о готовности дескриптора (EPOLLINилиEPOLLOUT). - Netpoller находит горутину, привязанную к этому дескриптору, переводит её в состояние готовности (
_Grunnable) и возвращает в очередь планировщика. Горутина просыпается ровно в месте прерванного вызоваconn.Read()с готовыми данными в буфере!
Благодаря этой схеме один Go-сервер может легко удерживать сотни тысяч одновременных соединений:
- Начальный размер стека горутины составляет всего ~2 КБ (против мегабайт у потоков ОС).
- Отсутствуют дорогостоящие системные переключения контекста потоков ОС.
- Для программиста код выглядит абсолютно синхронным, последовательным и читаемым!
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph GOROUTINES["Пространство Go Runtime"]
G1["Горутина A<br>conn.Read()"]:::system -->|"EAGAIN"| GP["runtime.gopark()<br>Статус _Gwaiting"]
M1["Поток M (CPU)"]:::system -->|"Берет следующую задачу"| G2["Горутина B<br>Выполняет расчеты"]
end
subgraph NETPOLLER["Netpoller (Reactor)"]
EP["epoll_wait / epoll_pwait"]:::process
end
subgraph KERNEL["Ядро Linux"]
EV["Событие EPOLLIN<br>(Прибыл сетевой пакет)"]
end
GP -.->|"Дескриптор ждет в epoll"| EP
KERNEL -->|"Сетевое прерывание"| EV
EV -->|"Будит epoll_wait"| EP
EP -->|"Переводит в _Grunnable"| G1
Сравнение с другими языками
| Язык / Технология | Модель сетевого ввода-вывода | Достоинства | Недостатки |
|---|---|---|---|
| Go | Асинхронный Netpoller + M:N горутины | Синхронный элегантный код, минимальное потребление RAM (2 КБ на сокет), масштабирование до сотен тысяч соединений | Минимальный оверхед рантайма на интеграцию с планировщиком |
| C / C++ | Ручное управление epoll / io_uring |
Предельная производительность на такт процессора, нулевой оверхед | Высокая сложность ручной диспетчеризации событий. Ошибка в select()/poll() или пропущенное close() ведет к утечке FD и ошибке EMFILE (Too many open files) |
| Java | ServerSocketChannel + Selector (NIO) / Виртуальные потоки (Loom) |
Мощная экосистема, высокая производительность ввода-вывода | Необходимость ручного управления позицией/лимитом в ByteBuffer, паузы Garbage Collector при аллокации сокетных объектов |
| PHP | Исторически блокирующий fsockopen; современные Swoole / RoadRunner |
Простота развертывания, изоляция запросов | В классическом PHP процесс блокируется целиком; во фреймворках (Swoole, RoadRunner) используется надстройка асинхронного цикла над C-библиотеками |
Итог
- Порт — логический 16-битный мультиплексор внутри сетевого хоста (0–65535). Сокет — конечная точка соединения, представляющая собой 5-tuple: протокол, локальный IP/порт и удаленный IP/порт.
- Socket API опирается на классический стек системных вызовов:
socket(создать),bind(связать с портом),listen(слушать входящие),accept(извлечь готовое) иconnect(инициировать связь). - Под капотом сокет — это объект ядра (
struct socket->struct sock) с кольцевыми буферами. Каждыйread/writeстоит переключения контекста и копирования данных черезcopy_to_user/copy_from_user. - Магия Go Netpoller: Перевод дескрипторов в
O_NONBLOCKи тесная интеграция сepoll/kqueueпозволяют горутинам спать во время ожидания сетевых пакетов, освобождая потоки процессора и экономя ресурсы машины. - Оптимизация: Опция
SO_REUSEADDRдля мгновенного перезапуска, увеличение системного параметра ядраsysctl net.core.somaxconnдля предотвращения переполнения очередей backlog, контроль размера очередиaccept(), и понимание разницы междуclose()иshutdown()критичны для стабильных сервисов.
Мы разобрали, как операционная система и среда выполнения Go связывают байты сокетов с кодом нашего приложения. Но как клиент узнает, по какому именно IP-адресу искать нужный сервер, если у него есть лишь строка api.example.com? В следующей статье мы исследуем всемирную распределенную систему разрешения имен: 16. DNS. Как работает преобразование имени в IP.