TCP и UDP в Go
Эта статья вводит уровень "сырого" сетевого программирования в Go. Он нужен, когда стандартный HTTP-слой уже абстрагирует слишком много и требуется контроль над форматом пакетов, таймаутами и поведением соединений.
См. также: Асинхронность и горутины · WebSocket · Веб на stdlib · Простые приложения.
Уровни сетевого стека
| Уровень | Пакет Go | Пример |
|---|---|---|
| HTTP | net/http | REST, веб-страницы |
| TCP/UDP | net | Сокеты, игры, брокеры, кастомные протоколы |
| 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/http | net TCP/UDP |
|---|---|---|
| Формат | Запрос/ответ, заголовки | Свой протокол |
| Инструменты | Middleware, JSON, шаблоны | Полный контроль |
| Типичное применение | API, сайты | Игры, VPN-части, агенты |
Для обучения достаточно echo-сервера; для продакшена API чаще выбирают HTTP (веб на stdlib или фреймворки).
Для закрепления темы полезно пройти механику гонок данных и интерфейсы: они напрямую влияют на стабильность сетевых сервисов.
Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.