Перейти к основному содержимому

TCP и UDP в Go

Разработчику

Эта статья вводит уровень "сырого" сетевого программирования в Go. Он нужен, когда стандартный HTTP-слой уже абстрагирует слишком много и требуется контроль над форматом пакетов, таймаутами и поведением соединений.

См. также: Асинхронность и горутины · WebSocket · Веб на stdlib · Простые приложения.


Уровни сетевого стека

УровеньПакет GoПример
HTTPnet/httpREST, веб-страницы
TCP/UDPnetСокеты, игры, брокеры, кастомные протоколы
IP и нижеnet, syscallРедко в прикладном коде

HTTP поверх TCP уже разбирает заголовки, тело, keep-alive. Ниже — поток байтов без семантики запрос/ответ.


TCP-сервер (echo)

Код ITЗагрузка примера кода…

Разбор:

  • net.Listen("tcp", ":9000") открывает TCP-сокет на порту 9000 и возвращает net.Listener, который принимает новые клиентские подключения.
  • defer ln.Close() гарантирует закрытие listening-сокета при завершении main, чтобы порт не остался занят после аварийного выхода.
  • Бесконечный for с ln.Accept() — серверный цикл: каждую новую TCP-сессию принимает в отдельный net.Conn.
  • Проверка if err != nil после Accept важна: временные сетевые ошибки логируются, но сервер не падает и продолжает принимать клиентов.
  • go handleConn(conn) запускает обработку соединения в отдельной горутине, поэтому один медленный клиент не блокирует остальных.
  • В handleConn вызов defer c.Close() освобождает сокет клиента после окончания работы обработчика.
  • io.Copy(c, c) читает байты из соединения и тут же пишет их обратно в то же соединение — это классический echo-паттерн, удобный для теста TCP.
  • Благодаря тому, что net.Conn реализует io.Reader и io.Writer, код остаётся коротким и не зависит от конкретной реализации транспорта.

net.Conn реализует io.Reader и io.Writer — удобно стыкуется с буферами и протоколами.


TCP-клиент

conn, err := net.Dial("tcp", "localhost:9000")
if err != nil {
log.Fatal(err)
}
defer conn.Close()

_, err = conn.Write([]byte("ping\n"))
buf := make([]byte, 1024)
n, err := conn.Read(buf)

Разбор:

  • net.Dial("tcp", "localhost:9000") инициирует исходящее TCP-подключение к серверу и возвращает net.Conn для чтения/записи.
  • defer conn.Close() закрывает клиентскую сторону сокета после завершения взаимодействия, чтобы не копить "висящие" соединения.
  • conn.Write([]byte("ping\n")) отправляет сырой набор байтов; \n часто используется как разделитель сообщений в текстовых протоколах.
  • buf := make([]byte, 1024) создаёт буфер для чтения ответа; размер буфера задаёт максимум байтов за один Read.
  • n, err := conn.Read(buf) читает данные в буфер и возвращает фактическое количество байтов, поэтому использовать нужно именно buf[:n].
  • Один Read не гарантирует "полное сообщение": TCP — поток байтов, и ответ может прийти частями; это ключевая причина вводить протокол фрейминга.

Dial с таймаутом:

d := net.Dialer{Timeout: 5 * time.Second}
conn, err := d.DialContext(ctx, "tcp", host)

Разбор:

  • net.Dialer позволяет тонко управлять параметрами соединения, в отличие от короткого net.Dial.
  • Поле Timeout: 5 * time.Second ограничивает время установления TCP-сессии: если удалённый хост недоступен, клиент не будет ждать бесконечно.
  • DialContext(ctx, "tcp", host) добавляет поддержку отмены через context.Context: при ctx.Done() попытка подключения прерывается.
  • Такой паттерн важен для сервисов с SLA: таймауты и отмена не дают зависать воркерам и экономят ресурсы под нагрузкой.

UDP

UDP — дейтаграммы без установления сессии:

addr, _ := net.ResolveUDPAddr("udp", ":9001")
conn, _ := net.ListenUDP("udp", addr)
defer conn.Close()

buf := make([]byte, 65507) // макс. размер UDP payload
n, remote, err := conn.ReadFromUDP(buf)
_, err = conn.WriteToUDP(buf[:n], remote)

Разбор:

  • net.ResolveUDPAddr("udp", ":9001") парсит строковый адрес в структуру *net.UDPAddr, с которой работают низкоуровневые UDP API.
  • net.ListenUDP("udp", addr) открывает UDP-сокет на указанном порту без установки постоянной "сессии", как в TCP.
  • buf := make([]byte, 65507) выделяет максимально возможный payload UDP (без учёта IP/UDP-заголовков), чтобы не обрезать датаграмму.
  • ReadFromUDP(buf) возвращает сразу три вещи: размер полезных данных n, адрес отправителя remote и ошибку чтения.
  • WriteToUDP(buf[:n], remote) отправляет ответ конкретному отправителю; здесь реализован UDP-echo на уровне отдельных дейтаграмм.
  • В UDP каждое чтение/запись работает с отдельным сообщением, поэтому семантика отличается от "непрерывного потока" TCP.

Клиент: net.Dial("udp", "host:9001") или ListenUDP только на стороне сервера.

Подходит для DNS-подобных запросов, телеметрии, игр; порядок и доставка не гарантированы — это закладывают в протокол.


Буферизация и протокол

Сырые Read могут вернуть часть сообщения. Для текстовых протоколов — bufio.Scanner или bufio.Reader.ReadString('\n'). Для бинарных — длина кадра в заголовке (4 байта LE + payload).

scanner := bufio.NewScanner(conn)
for scanner.Scan() {
line := scanner.Text()
// обработка строки
}

Разбор:

  • bufio.NewScanner(conn) оборачивает net.Conn и читает входной поток токенами (по умолчанию — строками до \n).
  • for scanner.Scan() выполняется, пока удаётся получить следующий токен; цикл естественно заканчивается на EOF или ошибке.
  • scanner.Text() возвращает текущую строку уже без символа перевода строки, что удобно для текстовых протоколов команд.
  • Такой подход скрывает низкоуровневую работу с буфером и сокращает риск ошибок при ручном разборе "кусков" TCP-потока.
  • После цикла обычно проверяют scanner.Err(), чтобы отличить нормальное завершение соединения от сетевой ошибки.

Установите scanner.Buffer и MaxScanTokenSize, если строки длинные.


Таймауты и дедлайны

Без дедлайнов сетевой код может "зависнуть" на чтении или записи при проблемах сети.

_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))

Разбор:

  • SetReadDeadline(...) задаёт крайний срок для операций чтения: после него Read вернёт timeout-ошибку вместо вечной блокировки.
  • SetWriteDeadline(...) аналогично ограничивает запись, если сеть "залипла" или принимающая сторона перестала читать.
  • time.Now().Add(5 * time.Second) формирует абсолютный дедлайн, который применяется к последующим операциям сокета.
  • Возврат ошибки присваивается в _, но в прод-коде лучше её проверять и логировать, чтобы не пропустить проблемы с управлением таймингом.
  • Дедлайны — обязательная защита сетевого кода: они превращают "подвисания" в управляемые ошибки, которые можно ретраить или эскалировать.

Для клиентов удобнее использовать контекст и DialContext, чтобы отмена запроса прекращала и сетевую операцию.

Пример чтения построчного протокола с проверкой ошибки сканера:

scanner := bufio.NewScanner(conn)
for scanner.Scan() {
line := scanner.Text()
log.Printf("recv: %s", line)
}
if err := scanner.Err(); err != nil {
log.Printf("scan error: %v", err)
}

Разбор:

  • scanner.Scan() читает токены до EOF или ошибки, что удобно для line-based протоколов.
  • scanner.Text() возвращает строку без \n, обычно уже готовую к парсингу команд.
  • Проверка scanner.Err() после цикла обязательна: иначе можно пропустить сетевой сбой и принять его за "нормальное закрытие".
  • В сетевых сервисах это влияет на диагностику — вы явно видите, где disconnect штатный, а где деградация канала.

Связь с горутинами

Паттерн "одно TCP-соединение — одна горутина" масштабируется на тысячи клиентов за счёт лёгких горутин (асинхронность). Блокирующий Read в горутине не блокирует весь сервер.

Ограничение нагрузки: семафор, net.Listen с лимитом через обёртку, или пул воркеров с очередью соединений.

Пример ограничения числа одновременных соединений:

var sem = make(chan struct{}, 100) // максимум 100 активных клиентов

func handleWithLimit(c net.Conn) {
sem <- struct{}{} // занять слот
defer func() { <-sem }() // освободить слот
defer c.Close()

// здесь основная обработка клиента
_, _ = c.Write([]byte("ok\n"))
}

Разбор:

  • make(chan struct{}, 100) создаёт буферизированный канал-семафор на 100 слотов.
  • sem <- struct{}{} блокирует новую обработку, когда лимит достигнут, и тем самым защищает сервер от перегрузки.
  • defer func() { <-sem }() гарантирует освобождение слота даже при раннем return или ошибке.
  • struct{} используется как "пустой токен" без лишних аллокаций полезных данных.
  • Этот паттерн часто проще и надёжнее, чем сложный пул воркеров, если нужен только верхний предел concurrency.

UNIX domain sockets

Сокеты домена unix — IPC на одной машине без сетевого стека. Быстрее localhost TCP, часто используют для nginx ↔ app, Docker, агентов.

Код ITЗагрузка примера кода…

Клиент:

conn, err := net.Dial("unix", "/tmp/myapp.sock")

Разбор:

  • После остановки сервера удаляют файл сокета (os.Remove), иначе повторный Listen упадёт с "address already in use".
  • API совпадает с TCP — тот же net.Conn, bufio, deadlines.
  • На Windows поддержка ограничена; на Unix-like системах domain sockets — обычный IPC-паттерн. Для кроссплатформы проверяйте runtime.GOOS.

Для двустороннего браузерного канала поверх HTTP — WebSocket.


TLS поверх TCP

tls.Listen / tls.Dial оборачивают TCP шифрованием. HTTP-сервер с TLS обычно настраивают через http.Server и ListenAndServeTLS, а не вручную на сыром сокете.


HTTP и сырой сокет

Критерийnet/httpnet TCP/UDP
ФорматЗапрос/ответ, заголовкиСвой протокол
ИнструментыMiddleware, JSON, шаблоныПолный контроль
Типичное применениеAPI, сайтыИгры, VPN-части, агенты

Для обучения достаточно echo-сервера; для продакшена API чаще выбирают HTTP (веб на stdlib или фреймворки).

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


Основа по протоколу

Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.