Протокол UDP та датаграмна передача даних
Протокол UDP та датаграмна передача даних
🎯 Навчальні цілі
- Зрозуміти архітектурний компроміс між надійністю TCP та мінімальною затримкою протоколу UDP (User Datagram Protocol).
- Сформувати модель датаграми (datagram) як самостійної, атомарної одиниці передачі з чіткими межами повідомлення.
- Дослідити 8-байтний заголовок UDP (RFC 768) та розрахувати безпечний розмір корисного навантаження з урахуванням MTU (Maximum Transmission Unit).
- Опанувати роботу з інтерфейсом
net.PacketConn, функціямиnet.ListenPacketтаnet.DialUDP(«connected» сокети). - Налаштувати системні буфери сокетів (
SO_RCVBUF) для захисту від втрати пакетів під час сплесків трафіку. - Реалізувати режими мережевої адресації Unicast, Broadcast (
SO_BROADCAST) та Multicast (протокол IGMP). - Застосувати патерн пулу воркерів (Worker Pool) для паралельної обробки потоку датаграм без ризику вичерпання пам'яті.
- Засвоїти концепцію RUDP (Reliable UDP): порядкові номери, підтвердження ACK/NACK та ковзне вікно.
🛠️ Технологічний стек
- Мова: Go 1.22+
- Стандартні пакети:
net,encoding/binary,time,sync,syscall,math - Діагностичні утиліти:
nc(netcat із прапорцем-u),tcpdump,netstat -u/ss -una
Свідома відмова від гарантій заради продуктивності
Транспортний протокол TCP надає прикладній програмі зручну ілюзію надійного двостороннього потоку байтів. Проте за цю ілюзію доводиться платити суттєвими накладними витратами:
- Затримка з'єднання: процедура потрійного рукостискання вимагає щонайменше одного часу кругового обігу (Round-Trip Time, RTT) до відправки першого байта прикладних даних.
- Блокування черги (Head-of-Line Blocking): втрата єдиного TCP-сегмента зупиняє видачу всіх наступних отриманих байтів у програму доти, доки втрачений сегмент не буде повторно передано та підтверджено.
- Об'ємний заголовок: кожен TCP-сегмент містить щонайменше 20 байтів службової інформації.
Протокол UDP (User Datagram Protocol, RFC 768) є альтернативою: він свідомо відмовляється від усіх перелічених гарантій. UDP не встановлює з'єднання, не відстежує доставку, не сортує пакети та не контролює перевантаження мережі.
Це не недолік, а усвідомлене інженерне рішення. Для багатьох систем своєчасність надходження свіжих даних набагато важливіша за факт отримання запізнілих пакетів:
- Мережеві ігри: координати гравця, надіслані 200 мс тому, застаріли — чекати на їх повторну доставку безглуздо.
- Голосовий та відеозв'язок (VoIP, WebRTC): втрата кількох мілісекунд аудіо сприймається як ледь помітне клацання, тоді як пауза на повторну доставку руйнує живий діалог.
- Моніторинг та телеметрія: показники датчиків IoT надсилаються кожні кілька секунд; втрата одного виміру легко нівелюється наступним.
Спостереження UDP-трафіку через tcpdump і netcat
Переконатися у відсутності службового рукостискання можна за допомогою утиліт nc (netcat) у режимі -u (UDP) та tcpdump.
Відкриємо два термінали для організації передачі:
nc -u -l 9001
nc -u localhost 9001
У третьому терміналі запустимо перехоплення на інтерфейсі loopback (lo0 або lo):
sudo tcpdump -nn -i lo0 port 9001
Щойно клієнт надсилає рядок "PING\n", аналізатор трафіку фіксує лише один рядок:
Порівняно з TCP різниця очевидна:
- Немає жодного сегмента
SYN,SYN-ACKчиACK. - Немає процедури узгодження сесії: перший же пакет несе прикладні дані.
- Якщо зупинити сервер і надіслати повідомлення з клієнта знову, команда
nc -uне повідомить про помилку — клієнтський стек просто викидає датаграму в мережу, не знаючи, чи існує активний отримувач.
Структура заголовка UDP та характеристики протоколу
Специфікація UDP (RFC 768) є однією з найкоротших серед інтернет-стандартів. Заголовок датаграми має фіксовану довжину 8 байтів (64 біти) і складається лише з чотирьох полів:
| Поле | Розмір (біти) | Опис |
|---|---|---|
| Source Port | 16 | Порт процесу-відправника (може бути 0, якщо відповідь не очікується) |
| Destination Port | 16 | Порт процесу-отримувача |
| Length | 16 | Загальна довжина датаграми в байтах (заголовок 8B + корисні дані) |
| Checksum | 16 | Контрольна сума для перевірки цілісності заголовка та даних |
Контрольна сума (Checksum)
Поле контрольної суми — єдиний вбудований захисний механізм UDP. Воно обчислюється над псевдозаголовком IP (IP-адреси, тип протоколу), заголовком UDP та самими даними.
- Якщо під час передачі байти спотворилися через апаратні завади, контрольна сума на приймальній стороні не зійдеться, і ядро операційної системи мовчки знищить пошкоджену датаграму.
- В IPv4 використання контрольної суми UDP є необов'язковим (значення
0x0000вимикає перевірку), проте в IPv6 вона є суворо обов'язковою.
Властивості UDP
- Робота без з'єднання (connectionless): немає поняття сесії. Серверний UDP-сокет єдиний для всіх клієнтів і не породжує нових з'єднань через
accept(). - Відсутність гарантії доставки (no delivery guarantee): якщо маршрутизатор перевантажений або виник збій на каналі, датаграма відкидається без генерації помилок для відправника.
- Відсутність збереження черговості (no ordering guarantee): пакети маршрутизуються незалежно; датаграма Завантаження... може дійти швидше за датуграму Завантаження....
- Відсутність захисту від дублювання (no duplicate protection): за певних умов маршрутизації один і той самий пакет може бути доставлений декілька разів.
- Збереження меж повідомлень (message-oriented): на відміну від TCP, де дані склеюються в неперервний потік, одна датаграма завжди передається як цілісний блок. Один виклик
WriteTo()відповідає рівно одному викликуReadFrom().
Розмір датаграми, MTU та небезпека IP-фрагментації
Теоретично поле довжини в 16 бітів дозволяє відправити датаграму розміром до 65 535 байтів (з урахуванням 8 байтів заголовка максимальне навантаження для IPv4 становить 65 507 байтів). Проте спроба надіслати датаграму такого розміру через реальну мережу призведе до різкого падіння надійності.
Причина полягає в обмеженні канального рівня — MTU (Maximum Transmission Unit). Для стандартних мереж Ethernet значення MTU становить 1500 байтів.
Якщо розмір IP-пакета перевищує MTU мережевого інтерфейсу, операційна система або проміжний маршрутизатор розбивають його на кілька частин — цей процес називається IP-фрагментацією (IP fragmentation).
Наслідки фрагментації для UDP
- Протокол UDP не підтримує повторну відправку окремих фрагментів. Якщо з трьох IP-фрагментів загубиться хоча б один, мережевий стек отримувача не зможе зібрати вихідну датаграму і знищить усі інші отримані фрагменти.
- Якщо ймовірність втрати одного фрагмента становить Завантаження..., то ймовірність успішної доставки датаграми з Завантаження... фрагментів становить Завантаження....
Розрахунок безпечного розміру корисного навантаження
Для запобігання фрагментації в мережах IPv4 розмір корисного навантаження датаграми обчислюється за формулою:
Отже, безпечний розмір корисного навантаження для стандартного Ethernet становить 1472 байти.
Для глобальної мережі Інтернет із підтримкою тунелювання (VPN, PPPoE) та гарантованої сумісності з мінімальним MTU для IPv6 (1280 байтів) індустріальним стандартом вважається обмеження розміру тіла датаграми в межах 1200–1280 байтів (наприклад, такий ліміт застосовується в протоколі DNS та новому стандарті HTTP/3 QUIC).
Мережевий інструментарій Go для роботи з UDP
Для сокетного програмування за протоколом UDP стандартний пакет net надає спеціалізований інтерфейс net.PacketConn:
type PacketConn interface {
ReadFrom(p []byte) (n int, addr Addr, err error)
WriteTo(p []byte, addr Addr) (n int, err error)
Close() error
LocalAddr() Addr
SetDeadline(t time.Time) error
SetReadDeadline(t time.Time) error
SetWriteDeadline(t time.Time) error
}
Ключова відмінність від потокового net.Conn:
ReadFromповертає не лише кількість прочитаних байтів, а й мережеву адресу відправникаaddr.WriteToвимагає обов'язкового передавання адреси призначення для кожної відправленої порції даних.
UDP-сервер та клієнт
Нижче наведено програми, які можна скопіювати в окремі файли та запустити одночасно.
Сервер відлуння (UDP Echo)
Сервер створює сокет через net.ListenPacket і обробляє вхідні датаграми в нескінченному циклі:
package main
import (
"log"
"net"
)
func main() {
// Прив'язка UDP-сокета до локального порту 9001
conn, err := net.ListenPacket("udp", ":9001")
if err != nil {
log.Fatalf("Помилка відкриття сокета: %v", err)
}
defer conn.Close()
log.Println("UDP-сервер відлуння слухає :9001...")
// Виділяємо буфер із запасом під стандартний кадр Ethernet
buffer := make([]byte, 1500)
for {
// Читання чергової датаграми та отримання адреси клієнта
n, clientAddr, err := conn.ReadFrom(buffer)
if err != nil {
log.Printf("Помилка читання датаграми: %v", err)
continue
}
log.Printf("Отримано %d байтів від %s: %s", n, clientAddr, string(buffer[:n]))
// Відправка відповіді безпосередньо за адресою відправника
response := append([]byte("ECHO: "), buffer[:n]...)
if _, err := conn.WriteTo(response, clientAddr); err != nil {
log.Printf("Помилка відправки до %s: %v", clientAddr, err)
}
}
}
ReadFrom зрізу buffer, операційна система поміщає в буфер перші len(buffer) байтів, а залишок датаграми безповоротно знищує. На відміну від TCP, де залишок чекає наступного виклику, в UDP надлишкові байти втрачаються. Завжди виділяйте буфер прийому щонайменше 1500 або 65535 байтів!Клієнт на базі «Connected» сокета (net.DialUDP)
Багатьох інженерів дивує наявність слова Dial («додзвонитися» / встановити з'єднання) стосовно протоколу UDP, адже UDP принципово беззв'язний (connectionless).
Коли програма викликає net.Dial("udp", ...) або net.DialUDP(...):
- Жодних мережевих пакетів не надсилається — тристороннє рукостискання (3-way handshake) відсутнє.
- На рівні ядра операційної системи виконується системний виклик
connect(). - Сокет стає «прив'язаним» (connected UDP socket) до вказаної адреси в пам'яті ядра хоста.
Завдяки прив'язці на рівні ядра:
- Можна використовувати звичайні потокові методи
conn.Write(buf)таconn.Read(buf)замість передачі адрес уWriteTo/ReadFrom(ОС автоматично підставляє адресу призначення і знає, від кого очікувати відповідь). - Ядро ОС автоматично фільтрує та відкидає пакети від будь-яких сторонніх IP-адрес, крім зафіксованої.
- Ядро здатне обробляти зворотні повідомлення
ICMP Port Unreachable(код відсутності слухача на віддаленому порту), повертаючи помилкуconnection refusedпри наступному читанні або записі (звичайний незв'язаний UDP-сокет такі ICMP-повідомлення мовчки ігнорує).
package main
import (
"fmt"
"log"
"net"
"time"
)
func main() {
serverAddr, err := net.ResolveUDPAddr("udp", "localhost:9001")
if err != nil {
log.Fatalf("Помилка визначення адреси: %v", err)
}
// Створення прив'язаного (connected) сокета
conn, err := net.DialUDP("udp", nil, serverAddr)
if err != nil {
log.Fatalf("Помилка підключення: %v", err)
}
defer conn.Close()
message := "Привіт, UDP сервер!"
start := time.Now()
// Використовуємо простий Write (адресу вже зафіксовано сокетом)
if _, err := conn.Write([]byte(message)); err != nil {
log.Fatalf("Помилка відправки: %v", err)
}
log.Printf("Надіслано повідомлення до %s", serverAddr)
// Встановлюємо дедлайн читання на випадок втрати пакета
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
response := make([]byte, 1500)
n, err := conn.Read(response)
if err != nil {
log.Fatalf("Помилка отримання відповіді (таймаут або втрата): %v", err)
}
rtt := time.Since(start)
fmt.Printf("Отримано: %s\nЧас реакції (RTT): %v\n", string(response[:n]), rtt)
}
net.Dial проти net.DialUDP: чому саме DialUDP?
Використовувати загальний виклик net.Dial("udp", "127.0.0.1:9001") можна, і це цілком валідний код у Go. Проте між net.Dial та спеціалізованим net.DialUDP існують дві суттєві відмінності: контроль локальної адреси/порту та тип, що повертається.
1. Контроль локальної адреси та порту (laddr)
Сигнатури функцій у стандартній бібліотеці Go:
// Приймає лише мережу та віддалену адресу
func Dial(network, address string) (Conn, error)
// Дозволяє явно зафіксувати локальну адресу (laddr)
func DialUDP(network string, laddr, raddr *UDPAddr) (*UDPConn, error)
Коли клієнт відправляє UDP-пакет, операційна система за замовчуванням виділяє йому випадковий вільний локальний порт (ephemeral port).
- Якщо передати
laddr = nilуDialUDP, поведінка буде точно такою ж, як уDial(ОС обере порт автоматично). - Але якщо інженерному завданню критично виходити з конкретного локального порту або IP-інтерфейсу (наприклад, у протоколах VoIP/SIP, потоковому медіа RTP, DNS або сценаріях обходу NAT через STUN/TURN), у
net.Dialцього зробити неможливо — тут потрібен самеnet.DialUDP:
localAddr, _ := net.ResolveUDPAddr("udp", "0.0.0.0:40000") // фіксований локальний порт клієнта
serverAddr, _ := net.ResolveUDPAddr("udp", "198.51.100.1:9001")
conn, err := net.DialUDP("udp", localAddr, serverAddr)
2. Тип, що повертається: загальний net.Conn проти конкретного *net.UDPConn
net.Dialповертає абстрактний інтерфейсnet.Conn. Через нього доступні лише базові методи:Read(),Write(),Close(),LocalAddr(),RemoteAddr()таSetDeadline().net.DialUDPповертає конкретну структуру*net.UDPConn. Вона дає прямий доступ до низькорівневих можливостей сокета UDP на рівні ядра ОС:ReadMsgUDP/WriteMsgUDP— читання/запис позасмугових даних (out-of-band / OOB), таких як значення IP TTL, розширені заголовки IPv4/IPv6 або апаратні часові мітки.- Налаштування системних буферів сокета ОС (
SetReadBuffer,SetWriteBuffer) без необхідності вдаватися до приведення типівconn.(*net.UDPConn). - Керування параметрами мультикасту та широкомовлення (TTL групової розсилки, прапори loopback тощо).
Підсумок: коли що обирати
| Сценарій | net.Dial("udp", "host:port") | net.DialUDP("udp", laddr, raddr) |
|---|---|---|
| Повернений тип | net.Conn (інтерфейс) | *net.UDPConn (конкретна структура) |
Фіксація локального порту (laddr) | ❌ Неможливо (тільки випадковий ephemeral port) | ✅ Повний контроль через параметр laddr |
| Низькорівневий тюнінг сокета | Потребує приведення типів .(*net.UDPConn) | ✅ Прямий доступ (SetReadBuffer, OOB control) |
| Коли обирати | Прості клієнти «запит-відповідь» (DNS, метрики StatsD), де влаштовує загальний контракт | VoIP/SIP, медіа-стрімінг, STUN/NAT traversal, сервіси з вимогами до локального порту або буферів ядра |
Системні буфери сокетів (SO_RCVBUF / SO_SNDBUF)
Коли датаграми надходять на мережеву карту хоста, ядро ОС поміщає їх у буфер черги сокета (Receive Buffer). Якщо програма обробляє пакети повільніше, ніж вони надходять, системний буфер переповнюється, і ядро починає мовчки скидати нові датаграми.
Для високонавантажених сервісів розмір буфера збільшують за допомогою методу SetReadBuffer:
if udpConn, ok := conn.(*net.UDPConn); ok {
// Встановлюємо буфер прийому ядра в 4 МБ для згладжування сплесків
_ = udpConn.SetReadBuffer(4 * 1024 * 1024)
}
Режими адресації: Unicast, Broadcast та Multicast
Завдяки ізольованій природі датаграм протокол UDP підтримує три моделі розповсюдження інформації в IP-мережах.
Unicast (Одноадресний режим)
Стандартний режим передачі «один до одного» (one-to-one). Датаграма адресується конкретній IP-адресі хоста і передається за класичною маршрутизацією.
Broadcast (Широкомовний режим)
Передача «один до всіх» (one-to-all) у межах одного сегмента локальної мережі (L2-домен трансляції). Пакети надсилаються на широкомовну адресу (наприклад, 255.255.255.255 або локальну трансляційну адресу підмережі). Маршрутизатори ніколи не пересилають широкомовні пакети за межі локальної мережі.
Для надсилання broadcast операційна система вимагає увімкнення опції SO_BROADCAST:
package main
import (
"log"
"net"
"syscall"
)
func main() {
// Створюємо UDP-сокет на довільному вільному порту
conn, err := net.ListenPacket("udp4", ":0")
if err != nil {
log.Fatalf("Помилка сокета: %v", err)
}
defer conn.Close()
// Отримуємо доступ до низькорівневого дескриптора для встановлення опцій
rawConn, err := conn.(*net.UDPConn).SyscallConn()
if err != nil {
log.Fatalf("Помилка отримання сирого сокета: %v", err)
}
var sockErr error
err = rawConn.Control(func(fd uintptr) {
// Вмикаємо опцію SO_BROADCAST на рівні сокета
sockErr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_BROADCAST, 1)
})
if err != nil || sockErr != nil {
log.Fatalf("Не вдалося активувати SO_BROADCAST: %v / %v", err, sockErr)
}
broadcastAddr, _ := net.ResolveUDPAddr("udp4", "255.255.255.255:9002")
message := []byte("DISCOVERY: пошук пристроїв у локальній мережі")
log.Println("Відправка широкомовної датаграми...")
if _, err := conn.WriteTo(message, broadcastAddr); err != nil {
log.Fatalf("Помилка відправки broadcast: %v", err)
}
log.Println("Широкомовне повідомлення успішно надіслано.")
}
Multicast (Групова розсилка)
Передача «один до багатьох зацікавлених» (one-to-many). Відправник відправляє єдиний екземпляр датаграми на спеціальну групову адресу класу D (діапазон від 224.0.0.0 до 239.255.255.255).
Пакети копіюються комутаторами й маршрутизаторами лише тим хостам, які явно висловили бажання приєднатися до групи через протокол IGMP (Internet Group Management Protocol).
package main
import (
"log"
"net"
)
func main() {
// Групова адреса адміністративного призначення
groupAddr, err := net.ResolveUDPAddr("udp4", "239.255.0.1:9003")
if err != nil {
log.Fatalf("Помилка адреси групи: %v", err)
}
// Приєднання до групи (nil вибирає системний інтерфейс за замовчуванням)
conn, err := net.ListenMulticastUDP("udp4", nil, groupAddr)
if err != nil {
log.Fatalf("Помилка підписки на мультикаст-групу: %v", err)
}
defer conn.Close()
log.Printf("Очікування мультикаст-пакетів на адресі %s...", groupAddr)
buffer := make([]byte, 1500)
for {
n, srcAddr, err := conn.ReadFrom(buffer)
if err != nil {
log.Printf("Помилка читання: %v", err)
continue
}
log.Printf("Отримано %d байтів від %s: %s", n, srcAddr, string(buffer[:n]))
}
}
Проєктування надійних протоколів: концепція RUDP
Якщо застосунку критично потрібні мінімальна затримка та відсутність блокування черги (Head-of-Line Blocking), але водночас необхідна доставка окремих важливих повідомлень, застосовують концепцію RUDP (Reliable UDP).
RUDP — це не один протокол, а набір патернів проектування поверх сирого UDP:
- Порядкові номери (Sequence Numbers): додавання лічильника (наприклад, 4 байти) на початку кожного пакета дозволяє отримувачу розпізнавати пропуски та відновлювати порядок.
- Підтвердження (ACK / NACK):
- ACK (Acknowledgment): отримувач повідомляє про кожен успішно прийнятий пакет.
- NACK (Negative Acknowledgment): отримувач мовчить, поки дані надходять послідовно, і надсилає запит на повтор лише при виявленні пропущеного номера.
- Ковзне вікно (Sliding Window): дозволяє відправнику мати кілька непідтверджених датаграм «у польоті» (in flight) без зупинки відправки.
Сучасний протокол QUIC (RFC 9000), на базі якого функціонує HTTP/3, є промисловою еволюцією ідей RUDP: він працює повністю поверх UDP, забезпечуючи нульову затримку при перепідключенні (0-RTT), вбудоване шифрування TLS 1.3 та незалежні потоки даних, де втрата одного пакета не блокує інші потоки.
Сервер телеметрії з пулом воркерів
При розробці навантажених серверів UDP не слід створювати нову горутину go handle(packet) на кожну вхідну датаграму, оскільки за великого потоку пакетів це призводить до швидкого вичерпання пам'яті (unbounded concurrency).
На практиці використовують фіксований пул воркерів (Worker Pool), які забирають завдання через буферизований канал, та агрегацію результатів у виділеній горутині (патерн CSP).
Нижче наведено два файли: сервер телеметрії та клієнт-генератор.
Сервер телеметрії
package main
import (
"encoding/binary"
"fmt"
"log"
"math"
"net"
"sync"
"time"
)
// TelemetryReading представляє розпарсені дані вимірювання
type TelemetryReading struct {
DeviceID uint32
TimestampMs int64
TemperatureC float32
}
func decodeReading(raw []byte) (TelemetryReading, error) {
if len(raw) != 16 {
return TelemetryReading{}, fmt.Errorf("довжина пакета %dB замість 16B", len(raw))
}
return TelemetryReading{
DeviceID: binary.BigEndian.Uint32(raw[0:4]),
TimestampMs: int64(binary.BigEndian.Uint64(raw[4:12])),
TemperatureC: math.Float32frombits(binary.BigEndian.Uint32(raw[12:16])),
}, nil
}
type packetTask struct {
payload []byte
srcAddr net.Addr
}
func main() {
conn, err := net.ListenPacket("udp", ":9004")
if err != nil {
log.Fatalf("Помилка відкриття сокета: %v", err)
}
defer conn.Close()
// Збільшуємо буфер сокета ОС до 2 МБ
if udpConn, ok := conn.(*net.UDPConn); ok {
_ = udpConn.SetReadBuffer(2 * 1024 * 1024)
}
const workerCount = 4
tasks := make(chan packetTask, 1000)
readings := make(chan TelemetryReading, 1000)
// Запуск пулу воркерів для паралельного парсингу
var wg sync.WaitGroup
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for task := range tasks {
reading, err := decodeReading(task.payload)
if err != nil {
log.Printf("[Воркер %d] Помилка від %s: %v", workerID, task.srcAddr, err)
continue
}
readings <- reading
}
}(i + 1)
}
// Виділена горутина агрегації стану (без м'ютексів, за моделлю CSP)
go func() {
devices := make(map[uint32]TelemetryReading)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case r := <-readings:
devices[r.DeviceID] = r
case <-ticker.C:
log.Printf("📊 Агрегатор: активних датчиків: %d", len(devices))
for id, r := range devices {
log.Printf(" -> Датчик #%d: %.2f°C (час: %d)", id, r.TemperatureC, r.TimestampMs)
}
}
}
}()
log.Printf("Сервер телеметрії слухає :9004 (пул із %d воркерів)...", workerCount)
buffer := make([]byte, 1500)
for {
n, srcAddr, err := conn.ReadFrom(buffer)
if err != nil {
log.Printf("Помилка читання: %v", err)
continue
}
// Обов'язкове копіювання, оскільки buffer буде перезаписано на наступній ітерації
payloadCopy := make([]byte, n)
copy(payloadCopy, buffer[:n])
// Передаємо в пул або відкидаємо пакет при переповненні буфера завдань
select {
case tasks <- packetTask{payload: payloadCopy, srcAddr: srcAddr}:
default:
log.Printf("⚠️ Черга завдань переповнена. Пакет від %s скинуто!", srcAddr)
}
}
}
Генератор тестової телеметрії (Клієнт)
package main
import (
"encoding/binary"
"log"
"math"
"math/rand"
"net"
"time"
)
func main() {
serverAddr, err := net.ResolveUDPAddr("udp", "localhost:9004")
if err != nil {
log.Fatalf("Помилка адреси: %v", err)
}
conn, err := net.DialUDP("udp", nil, serverAddr)
if err != nil {
log.Fatalf("Помилка підключення: %v", err)
}
defer conn.Close()
log.Println("Генератор телеметрії запущено. Відправка пакетів...")
deviceIDs := []uint32{101, 102, 103}
for round := 1; round <= 3; round++ {
for _, devID := range deviceIDs {
temp := 20.0 + rand.Float32()*10.0 // Температура від 20 до 30 °C
packet := make([]byte, 16)
binary.BigEndian.PutUint32(packet[0:4], devID)
binary.BigEndian.PutUint64(packet[4:12], uint64(time.Now().UnixMilli()))
binary.BigEndian.PutUint32(packet[12:16], math.Float32bits(temp))
if _, err := conn.Write(packet); err != nil {
log.Printf("Помилка відправки: %v", err)
}
}
time.Sleep(1 * time.Second)
}
log.Println("Генерацію завершено.")
}
Практичне завдання: утиліта UDP Ping
Розробіть клієнтську CLI-утиліту UDP Ping, яка:
- Надсилає на UDP Echo-сервер (
localhost:9001) послідовність із 5 датаграм виглядуPING seq=N. - Встановлює дедлайн очікування відповіді в 1 секунду на кожен пакет.
- Якщо відповідь надходить вчасно — фіксує точний час RTT у мілісекундах.
- Якщо виникає таймаут — фіксує втрату пакета.
- Наприкінці виводить зведену статистику: кількість надісланих/отриманих пакетів, відсоток втрат (Packet Loss %) та середній RTT.
package main
import (
"fmt"
"log"
"net"
"time"
)
func main() {
serverAddr, err := net.ResolveUDPAddr("udp", "localhost:9001")
if err != nil {
log.Fatalf("Помилка адреси: %v", err)
}
conn, err := net.DialUDP("udp", nil, serverAddr)
if err != nil {
log.Fatalf("Помилка сокета: %v", err)
}
defer conn.Close()
const totalPackets = 5
const timeout = 1 * time.Second
receivedPackets := 0
var totalRTT time.Duration
fmt.Printf("PING %s через UDP (%d пакетів):\n\n", serverAddr, totalPackets)
buffer := make([]byte, 1500)
for seq := 1; seq <= totalPackets; seq++ {
payload := fmt.Sprintf("PING seq=%d", seq)
start := time.Now()
if _, err := conn.Write([]byte(payload)); err != nil {
log.Printf("Помилка відправки seq=%d: %v", seq, err)
continue
}
_ = conn.SetReadDeadline(time.Now().Add(timeout))
n, err := conn.Read(buffer)
if err != nil {
fmt.Printf("Запит seq=%d: втрачено пакет (таймаут %v)\n", seq, timeout)
continue
}
rtt := time.Since(start)
receivedPackets++
totalRTT += rtt
fmt.Printf("Відповідь від %s: seq=%d байтів=%d rtt=%.2fмс (%s)\n",
serverAddr, seq, n, float64(rtt.Microseconds())/1000.0, string(buffer[:n]))
time.Sleep(500 * time.Millisecond)
}
lost := totalPackets - receivedPackets
lossPercent := (float64(lost) / float64(totalPackets)) * 100.0
fmt.Println("\n--- Статистика UDP Ping ---")
fmt.Printf("%d пакетів надіслано, %d отримано, %.1f%% втрат\n", totalPackets, receivedPackets, lossPercent)
if receivedPackets > 0 {
avgRTT := totalRTT / time.Duration(receivedPackets)
fmt.Printf("Середній час RTT: %.2fмс\n", float64(avgRTT.Microseconds())/1000.0)
}
}
Питання для самоперевірки
Read() поверне першу частину, а решта даних залишиться в буфері сокета до наступного виклику. В UDP надлишкові байти датаграми, які не помістилися в буфер, безповоротно обрізаються (truncated) і знищуються ядром операційної системи.connect() для сокета UDP не надсилає жодних пакетів у мережу (handshake відсутній). Він лише фіксує в пам'яті ядра ОС адресу та порт сервера для цього сокета. Це активує фільтрацію стороннього трафіку ядром, дозволяє викликати спрощені методи Read/Write замість ReadFrom/WriteTo, а також дозволяє операційній системі перехоплювати пакети ICMP Port Unreachable і транслювати їх у помилку connection refused.:::accordion-item{label="❓ Чому варто використовувати net.DialUDP замість net.Dial("udp", ...)?" icon="i-lucide-help-circle"}
Обидва виклики створюють connected UDP-сокет, проте net.DialUDP надає дві ключові переваги:
- Дозволяє передати
laddrі жорстко зафіксувати локальний порт або інтерфейс клієнта (необхідно для VoIP, STUN/NAT traversal, DNS), тоді якnet.Dialзавжди виділяє випадковий порт. - Повертає конкретний тип
*net.UDPConnзамість загального інтерфейсуnet.Conn, що відкриває доступ до низькорівневих можливостей ядра: налаштування буферівSetReadBuffer/SetWriteBuffer, позасмугових даних OOB черезReadMsgUDP/WriteMsgUDPта опцій мультикасту.
:::
ReadFrom(), вхідний системний буфер ядра заповнюється, і нові датаграми скидаються ядром без повідомлення про помилку. Збільшення буфера (SetReadBuffer) дає програмі часовий люфт для згладжування пікових навантажень.Підсумки лекції
📡 Характеристики UDP
- UDP — мінімалістичний транспорт (8-байтний заголовок проти 20 байтів у TCP).
- Робота без з'єднання (0-RTT), відсутність гарантій доставки, черговості та захисту від дублювання.
- Межі повідомлень зберігаються природно: одна датаграма відповідає одній операції введення/виведення.
📐 MTU та адресація
- Безпечний розмір корисного навантаження IPv4 становить 1472 байти для уникнення згубної IP-фрагментації.
- Підтримуються три моделі адресації: Unicast (1 -> 1), Broadcast (1 -> всі у підмережі через
SO_BROADCAST) та Multicast (1 -> група підписників через IGMP). net.PacketConnє базовим інтерфейсом сокета, аnet.DialUDPстворює прив'язаний сокет для зручногоRead/Write.
⚙️ Промислові патерни
- Для захисту від падіння пам'яті через наплив пакетів застосовують пул воркерів (Worker Pool) замість
go func(). - Системний буфер
SO_RCVBUFзахищає від тихих втрат датаграм під час сплесків трафіку. - Концепція RUDP (порядкові номери, ACK/NACK, ковзне вікно) реалізує вибіркову надійність на прикладному рівні.
Обидва транспортні протоколи стека — і TCP, і UDP — передають дані у відкритому вигляді. У наступній темі ми перейдемо до криптографічного захисту каналів зв'язку за допомогою протоколу TLS.
Сокетне програмування та протокол TCP у Go
Сокетне програмування мовою Go: сокети Берклі (Berkeley Sockets API) як файлові дескриптори, спостереження TCP-сегментів через tcpdump і netcat, життєвий цикл TCP-сесії (3-Way Handshake, 4-Way Close, стан TIME_WAIT), примітиви net.Dial і net.Listen, архітектура «горутина на з'єднання» з runtime netpoller, потокове введення/виведення з io та bufio, три методи фреймінгу прикладних протоколів (розділювачі, префікс довжини, TLV), транслятор повідомлень на базі Hub (CSP) та плавне завершення (Graceful Shutdown).
Криптографічний захист каналів зв
Повний розгляд криптографічних основ захисту мережевих комунікацій мовою Go: доступне пояснення симетричного та асиметричного шифрування на прикладі листування, хеш-функції та цифрові підписи, протокол TLS 1.3 із детальним розбором фаз рукостискання та захисту від атак Man-in-the-Middle, інфраструктура відкритих ключів (PKI) та сертифікати X.509, центри сертифікації (CA), пакет crypto/tls для створення захищених серверів і клієнтів, взаємна автентифікація (mTLS) та модель Zero Trust на прикладі корпоративного мікросервісу.