Программирование сокетов на практике: TCP/UDP и конкурентные модели на Python и Go | AdminWiki

Программирование сокетов на практике: TCP/UDP и конкурентные модели на Python и Go

10 сентября 2026 11 мин. чтения

Сокетный API операционной системы дает приложению точку доступа к сетевому обмену. TCP предоставляет упорядоченный поток байтов с подтверждением доставки, UDP передает отдельные датаграммы без гарантии порядка и повторной доставки. Выбор протокола зависит от требований к задержке, надежности и контролю потерь.

Блокирующий вызов останавливает текущий поток или горутину, пока операция не завершится, не истечет тайм-аут или не произойдет ошибка. В сокетах так работают accept(), recv(), send() и их аналоги. Неблокирующий режим возвращает управление сразу: Python получает BlockingIOError, а Go скрывает ожидание ОС за планировщиком горутин.

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

Сначала договоримся, что считать блокировкой

Блокировка относится к ожиданию результата системного вызова, а не к самому факту наличия сетевого соединения. TCP-сервер может держать тысячи установленных соединений, но блокировать обработку каждого из них по-разному: отдельным процессом, потоком, событием в цикле или горутиной.

МодельПоведениеПрактический сценарий
Процесс на соединениеИзоляция памяти, высокая цена создания процессовНебольшое число клиентов и строгая изоляция
Поток на соединениеПростой блокирующий код, общая памятьСетевые сервисы с умеренным числом подключений
selectors или asyncioОдин цикл событий следит за множеством дескрипторовВысокое число соединений с короткими операциями
Горутина GoБлокирующий стиль кода паркует горутину, а не весь потокTCP- и UDP-серверы с большим числом клиентов

Базовый TCP-клиент на Python блокируется в connect() и recv(), пока не сработает тайм-аут:

import socket

with socket.create_connection(('127.0.0.1', 9000), timeout=3) as sock:
    sock.sendall(b'PING\n')
    response = sock.recv(4096)
    print(response.decode())

Вызов setblocking(False) меняет контракт:

sock.setblocking(False)
try:
    sock.connect(('10.0.0.20', 9000))
except BlockingIOError:
    pass

После этого приложение должно следить за готовностью сокета через selectors, poll или epoll. Простая замена режима без цикла событий приводит к активному опросу, лишней нагрузке на CPU и потере контроля над тайм-аутами.

В стандартной сборке CPython с GIL ожидание сети освобождает интерпретатор, поэтому потоковая модель подходит для I/O-нагрузки. CPU-затратный разбор данных внутри Python-потока требует отдельного процесса, очереди задач или сборки Python без GIL с проверкой совместимости библиотек. В Go рантайм отслеживает сетевые события и может выполнять тысячи горутин на небольшом числе системных потоков.

Карта: приложение, хост и два сетевых маршрута

У TCP-соединения есть два направления передачи: клиент отправляет байты серверу, сервер возвращает байты клиенту. На уровне IP каждое направление проходит через таблицу маршрутизации, интерфейсы, NAT и фильтры. У UDP нет рукопожатия TCP, поэтому отправитель может передать датаграмму без предварительного подтверждения доступности получателя.

Серверный сокет обычно проходит четыре шага:

  1. socket() создает дескриптор.
  2. bind() связывает его с IP-адресом и портом.
  3. listen() переводит TCP-сокет в режим ожидания подключений.
  4. accept() возвращает новый сокет для конкретного клиента, а слушающий дескриптор продолжает принимать подключения.

Привязка к 127.0.0.1 разрешает доступ только с этого хоста. Адрес 0.0.0.0 прослушивает IPv4-интерфейсы, но доступ все равно может блокировать firewall. Для IPv6 нужен отдельный учет адреса :: и параметров dual-stack.

import socket

listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(('0.0.0.0', 9000))
listener.listen(128)

while True:
    conn, address = listener.accept()
    conn.sendall(b'connected\n')
    conn.close()

Проверяйте путь отдельно от приложения: ss -ltnp показывает слушающие TCP-порты и процессы, ss -uanp помогает найти UDP-сокеты, а ip route get 10.0.0.20 показывает выбранный интерфейс и исходный адрес. Для полной проверки периметра полезна пошаговая методика аудита сетевой инфраструктуры с анализом открытых портов и правил firewall.

Для лаборатории удобно поднять два небольших сервера на VDS или облачном хосте. Например, Timeweb Cloud предоставляет VDS, сети и Kubernetes, поэтому можно проверить bind-адрес, правила входящего трафика, задержку и поведение сервиса при нескольких клиентах.

Hook: приложение проверяет действие до выполнения

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

TCP не сохраняет границы сообщений. Один вызов send() на клиенте может соответствовать нескольким вызовам recv() на сервере. Поэтому протоколу нужен фрейминг: фиксированный заголовок с длиной, разделитель строк или формат с самостоятельным определением конца сообщения. Лимит кадра задайте заранее, например 1 MiB, чтобы поврежденный заголовок не заставил сервер ждать гигабайты данных.

ALLOWED_ACTIONS = {'PING', 'GET_STATUS'}
MAX_FRAME = 1024 * 1024

def dispatch(request, user):
    action = request.get('action')
    if action not in ALLOWED_ACTIONS:
        raise ValueError('action is not allowed')
    if not user.is_authenticated:
        raise PermissionError('authentication required')
    return handlers[action](request)

В этом hook проверяет имя действия, аутентификацию и затем передает запрос обработчику. Для write-операций добавьте проверку прав на объект, идемпотентный идентификатор запроса и запись результата в журнал. Ошибка валидации должна закрывать кадр или соединение по правилам протокола, но не оставлять сокет в неопределенном состоянии.

Для UDP граница сообщения сохраняется: recvfrom() получает одну датаграмму. Это упрощает фрейминг, но размер датаграммы ограничен MTU и настройками ОС. Большие UDP-сообщения могут фрагментироваться или теряться, поэтому прикладной протокол должен выдерживать повтор, переупорядочивание и пропуск пакетов.

MCP-wrapper: проверка между клиентом и сервером инструментов

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

Прокси проверяет имя инструмента, размер сообщения, идентификатор клиента, тайм-аут и частоту запросов. Если транспорт зашифрован между клиентом и сервером, промежуточный wrapper видит только зашифрованный поток. Для проверки содержимого TLS завершают на контролируемом прокси, после чего нужно заново шифровать соединение до сервера.

ALLOWED_TOOLS = {'health', 'read_metrics'}


def allow_request(message, identity):
    if identity not in {'monitoring', 'operator'}:
        return False
    if message.get('tool') not in ALLOWED_TOOLS:
        return False
    if len(message.get('arguments', {})) > 32:
        return False
    return True

В TCP-wrapper нельзя бездумно копировать поток порциями. Сначала прочитайте заголовок кадра, получите заявленную длину, проверьте ее лимит, дочитайте ровно это число байтов и только потом разбирайте сообщение. Тайм-ауты должны действовать на чтение, запись и установку соединения. Иначе медленный клиент займет дескриптор на неопределенный срок.

Браузер: кнопка «Удалить» тоже выполняет действие

Браузер отправляет HTTP-запрос через TCP, а при HTTPS шифрует его до точки завершения TLS. Сетевой firewall видит IP-адреса, порты и состояние соединения. Он не различает кнопку «Удалить» и кнопку «Обновить», пока трафик не попадет на HTTP-прокси или сервер после расшифровки.

DELETE /api/items/42 HTTP/1.1
Host: app.internal
Authorization: Bearer token
X-Request-ID: 8f31

Приложение должно проверить метод, маршрут, токен, право на объект и защиту от повторного запроса. Для браузерных сессий добавьте CSRF-защиту и проверку происхождения запроса. Запись в журнале должна содержать пользователя, объект, результат, IP-адрес и идентификатор запроса.

При HTTP/2 несколько запросов используют одно TCP-соединение, поэтому количество сокетов не совпадает с количеством действий пользователя. При WebSocket после рукопожатия сервер получает сообщения в длительном соединении. В обоих случаях смысл действия появляется на прикладном уровне, а не в recv().

Контроль на хосте: что делают программы и их потомки

Хост видит процесс, который держит дескриптор сокета, локальный порт и состояние соединения. В Linux для первичной проверки используйте ss -tpn, lsof -nP -iTCP:9000 -sTCP:LISTEN и просмотр лимита ulimit -n. Превышение лимита файловых дескрипторов часто выглядит как сетевой сбой, хотя удаленный узел доступен.

Потомок процесса может унаследовать открытый дескриптор после fork(). Из-за этого старый worker продолжает удерживать порт, а graceful restart не завершает соединение ожидаемым образом. Закрывайте лишние дескрипторы, используйте явный жизненный цикл workers и проверяйте процессное дерево.

В Go конкурентный TCP-сервер часто запускает отдельную горутину для каждого принятого соединения:

package main

import (
    `log`
    `net`
)

func main() {
    listener, err := net.Listen(`tcp`, `:9000`)
    if err != nil {
        log.Fatal(err)
    }
    defer listener.Close()

    for {
        conn, err := listener.Accept()
        if err != nil {
            log.Print(err)
            continue
        }
        go serve(conn)
    }
}

Горутина потребляет меньше памяти, чем отдельный поток, но бесконечное создание горутин все равно исчерпает память при атаке или ошибке клиента. Ограничивайте число активных соединений семафором, закрывайте idle-сессии и задавайте дедлайны через SetReadDeadline и SetWriteDeadline.

Linux: что даёт eBPF

eBPF позволяет наблюдать сетевые события на уровне ядра: вход в connect, accept, sendmsg, recvmsg, сетевые пакеты и события cgroup. Это помогает связать сетевую попытку с PID, командой и контейнером, когда журналы приложения неполны.

sudo bpftrace -e 'tracepoint:syscalls:sys_enter_connect { printf("%d %s", pid, comm); }'

Эта трассировка показывает факт входа в системный вызов, но сама по себе не доказывает успешное соединение. Для результата нужен контроль выхода из вызова, а для адреса назначения требуется разобрать структуру sockaddr. Проверяйте корреляцию с ss, журналом сервиса и правилами firewall.

Для ограничений исходящих соединений используют политики cgroup и подходящие BPF-программы, но доступные точки подключения зависят от версии ядра, типа hook и загрузчика. eBPF не заменяет обычный firewall и не дает прикладному коду сведения о данных, скрытых TLS. На рабочем хосте сначала включите наблюдение, соберите список легитимных направлений, затем добавляйте блокирующие правила с аварийным способом отката.

Для контейнеров фиксируйте не только IP и порт, но и имя workload, namespace, UID процесса и время события. IP контейнера может измениться после пересоздания, поэтому правило по одному адресу быстро устаревает.

Windows: ETW наблюдает, блокировка требует своего механизма

ETW собирает события Windows и сетевых компонентов, но запись события не прекращает соединение. Для воспроизводимой диагностики можно снять трассу:

netsh trace start capture=yes report=no tracefile=C:\Temp\net.etl
netsh trace stop

Файл ETL помогает сопоставить время подключения, процесс и сетевую ошибку. Команда Test-NetConnection 10.0.0.20 -Port 9000 проверяет TCP-доступность порта, а Get-NetTCPConnection -LocalPort 9000 показывает состояние локальных соединений.

Блокирование выполняет Windows Filtering Platform через правила Windows Defender Firewall или корпоративный агент. В правиле указывают профиль, направление, приложение или службу, протокол, локальный и удаленный порт, адреса и действие. При проблеме с политикой полезен пошаговый алгоритм поиска блокировки сетевого доступа в Windows: он разделяет DNS, маршрут, прокси, VPN, firewall и доменную политику.

Проверяйте правило на тестовом порту и отдельной учетной записи. Слишком широкое разрешение для svchost.exe или интерпретатора может открыть доступ множеству сервисов, поэтому для production лучше привязывать правило к конкретной программе, службе или группе узлов.

Сетевой шлюз: что проходит через проверку

Шлюз обычно принимает решение по пяти параметрам: исходный IP, исходный порт, целевой IP, целевой порт и протокол. Stateful firewall хранит состояние TCP: SYN, подтверждение рукопожатия, закрытие и тайм-аут. Для UDP он чаще создает временную запись по первой датаграмме и удаляет ее после периода бездействия.

Правило на TCP-порт 9000 не разрешает автоматически UDP-порт 9000. Правило для IPv4 не обязательно покрывает IPv6. NAT меняет адрес или порт, поэтому проверяйте правила до и после трансляции. При asymmetrical routing пакет может прийти через один интерфейс, а ответ уйти через другой, из-за чего stateful firewall отбросит поток.

table inet filter {
    chain input {
        type filter hook input priority filter;
        policy drop;
        ct state established,related accept
        iifname `lo` accept
        ip saddr 10.20.0.0/16 tcp dport 9000 accept
    }
}

Проверяйте конфигурацию осторожно: правило с политикой drop может оборвать SSH-сеанс. Сначала разрешите административный адрес, добавьте временный таймер отката и проверьте входящий и исходящий трафик с двух независимых узлов.

Шлюз не видит PID и имя функции, которая вызвала connect(). TLS скрывает HTTP-метод, путь и тело запроса. Для контроля прикладных действий нужен reverse proxy с завершением TLS, API gateway или проверка внутри сервиса. Для сетевого уровня пригодится практический разбор блокировки IP и подсетей с примерами для iptables, nftables, Nginx и fail2ban.

Почему одного base_url недостаточно

base_url задает предполагаемый адрес сервиса, но не описывает весь сетевой путь. DNS может вернуть несколько A и AAAA-записей, клиент может выбрать IPv6, прокси может изменить маршрут, а библиотека может повторить запрос на другом адресе. Сервис может использовать отдельный endpoint для метрик, DNS, очереди, базы данных или health-check.

Проверяйте фактические адреса непосредственно из среды запуска:

import socket

for item in socket.getaddrinfo('api.internal', 443, type=socket.SOCK_STREAM):
    family, socktype, proto, canonname, address = item
    print(family, address)

В Go аналогичный поиск выполняет net.LookupIP, а фактический сетевой клиент может использовать собственный DialContext, прокси и пул соединений. Поэтому запись в конфигурации приложения нужно сопоставлять с DNS-ответом, маршрутами, переменными прокси и логами библиотеки.

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

  1. Приложение: фрейминг, аутентификация, список разрешенных действий, лимит размера, тайм-аут и журнал.
  2. Хост: слушающий процесс, лимит дескрипторов, исходящие правила, process tree и наблюдение eBPF или ETW.
  3. Сеть: DNS, маршрут, NAT, firewall, балансировщик, TLS-прокси и доступность каждой пары адресов и портов.

Логи нужно сопоставлять по времени, IP, порту и идентификатору запроса. Для Nginx и Apache пригодится практический анализ логов с командами grep и awk: сначала найдите код ответа и задержку, затем сравните запись proxy с журналом приложения и состоянием сокета.

Короткий рабочий алгоритм выглядит так:

  • Проверить, слушает ли процесс нужный IP, порт и семейство адресов.
  • Проверить маршрут и DNS именно на том хосте, где работает клиент.
  • Разделить TCP-сбой, UDP-потерю, ошибку TLS и отказ прикладной авторизации.
  • Снять трассировку на хосте или шлюзе и сопоставить ее с логом приложения.
  • Задать тайм-ауты, лимиты соединений и правила повторов до запуска под нагрузкой.

Такой подход показывает, где возник сбой: в системном вызове, конкурентной модели Python или Go, прикладном протоколе, процессе на хосте или сетевом фильтре.

Поделиться:
Сохранить гайд? В закладки браузера