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-capability CAP_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
// Идиоматичный 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

Создает новую конечную точку взаимодействия и выделяет структуру сокета в ядре.

C
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).

C
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

Если порт уже используется другим процессом или удерживается ядром в состоянии TIME_WAIT, системный вызов завершается с ошибкой EADDRINUSE (Address already in use).


3. listen() → listen() syscall (только TCP)

Переводит сокет из пассивного закрытого состояния в активное состояние LISTEN, объявляя ядру готовность принимать входящие клиентские подключения:

C
int listen(int sockfd, int backlog);

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


4. accept() → accept4() syscall

Берет готовое соединение из очереди, созданной клиентским connect(). Извлекает первое полностью установленное соединение из очереди Accept Queue сокета-слушателя:

C
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
  
  1. struct file: Поскольку сокет проецируется на файловый дескриптор, ядро создает объект файла VFS. Его поле private_data содержит указатель на сетевой сокет.
  2. struct socket: Абстракция интерфейса BSD Socket. Содержит указатель struct file *file и указатель на транспортный сокет struct sock *sk.
  3. struct sock: Фундаментальное ядро сетевого стека Linux. Именно здесь живут:
    • sk_receive_queue и sk_send_queue: Двусвязные очереди сетевых пакетов sk_buff в памяти ядра.
    • Окна передачи и приема: окно отправки snd_wnd и окно приема rcv_wnd.
    • Сетевые таймеры: повторной передачи (RTO), проверки доступности (keepalive) и ожидания закрытия.
  4. Копирование данных в памяти:
    При вызове 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 выглядит следующим образом:

  1. При создании сокета через net.Listen() или net.Dial() рантайм Go немедленно переводит файловый дескриптор в режим O_NONBLOCK.
  2. Дескриптор сокета регистрируется в дескрипторе epoll с помощью системного вызова epoll_ctl (через вызов epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event)).
  3. Когда горутина вызывает conn.Read() или conn.Write(), а системный буфер пуст/полон, системный вызов ядра мгновенно возвращает ошибку EAGAIN или EWOULDBLOCK.
  4. Рантайм Go перехватывает EAGAIN. Функция runtime.gopark паркует текущую горутину, переводя её структуру g в состояние ожидания (_Gwaiting). Системный поток ОС (M) не блокируется! Он немедленно берет из локальной очереди P другую готовую горутину и продолжает выполнять полезную работу.
  5. Сетевая карта получает пакет от клиента, генерирует аппаратное прерывание, и ядро помещает данные в буфер сокета sk_receive_queue.
  6. Поток рантайма Go, который опрашивает поллер (netpoll вызывают планировщик в findRunnable, sysmon и поток M без работы; выделенного фонового потока нет) через epoll_pwait, получает уведомление о готовности дескриптора (EPOLLIN или EPOLLOUT).
  7. 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-библиотеками


Итог

  1. Порт — логический 16-битный мультиплексор внутри сетевого хоста (0–65535). Сокет — конечная точка соединения, представляющая собой 5-tuple: протокол, локальный IP/порт и удаленный IP/порт.
  2. Socket API опирается на классический стек системных вызовов: socket (создать), bind (связать с портом), listen (слушать входящие), accept (извлечь готовое) и connect (инициировать связь).
  3. Под капотом сокет — это объект ядра (struct socket -> struct sock) с кольцевыми буферами. Каждый read/write стоит переключения контекста и копирования данных через copy_to_user / copy_from_user.
  4. Магия Go Netpoller: Перевод дескрипторов в O_NONBLOCK и тесная интеграция с epoll / kqueue позволяют горутинам спать во время ожидания сетевых пакетов, освобождая потоки процессора и экономя ресурсы машины.
  5. Оптимизация: Опция SO_REUSEADDR для мгновенного перезапуска, увеличение системного параметра ядра sysctl net.core.somaxconn для предотвращения переполнения очередей backlog, контроль размера очереди accept(), и понимание разницы между close() и shutdown() критичны для стабильных сервисов.

Мы разобрали, как операционная система и среда выполнения Go связывают байты сокетов с кодом нашего приложения. Но как клиент узнает, по какому именно IP-адресу искать нужный сервер, если у него есть лишь строка api.example.com? В следующей статье мы исследуем всемирную распределенную систему разрешения имен: 16. DNS. Как работает преобразование имени в IP.