Клієнт-серверна архітектура

Протокол UDP та датаграмна передача даних

Розгляд протоколу UDP мовою Go: спостереження сирих датаграм без рукостискання через nc -u та tcpdump, заголовок UDP (RFC 768), обмеження MTU та загроза IP-фрагментації, примітиви net.ListenPacket, net.DialUDP та net.PacketConn, налаштування буферів сокета ОС, режими адресації Unicast, Broadcast і Multicast з протоколом IGMP, архітектура пулу воркерів (Worker Pool) для збору телеметрії та концепція RUDP (Reliable 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 надає прикладній програмі зручну ілюзію надійного двостороннього потоку байтів. Проте за цю ілюзію доводиться платити суттєвими накладними витратами:

  1. Затримка з'єднання: процедура потрійного рукостискання вимагає щонайменше одного часу кругового обігу (Round-Trip Time, RTT) до відправки першого байта прикладних даних.
  2. Блокування черги (Head-of-Line Blocking): втрата єдиного TCP-сегмента зупиняє видачу всіх наступних отриманих байтів у програму доти, доки втрачений сегмент не буде повторно передано та підтверджено.
  3. Об'ємний заголовок: кожен 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

У третьому терміналі запустимо перехоплення на інтерфейсі loopback (lo0 або lo):

sudo tcpdump -nn -i lo0 port 9001

Щойно клієнт надсилає рядок "PING\n", аналізатор трафіку фіксує лише один рядок:

sudo tcpdump -nn -i lo0 port 9001
$ sudo tcpdump -nn -i lo0 port 9001
11:20:04.108 IP 127.0.0.1.61420 > 127.0.0.1.9001: UDP, length 5

Порівняно з TCP різниця очевидна:

  • Немає жодного сегмента SYN, SYN-ACK чи ACK.
  • Немає процедури узгодження сесії: перший же пакет несе прикладні дані.
  • Якщо зупинити сервер і надіслати повідомлення з клієнта знову, команда nc -u не повідомить про помилку — клієнтський стек просто викидає датаграму в мережу, не знаючи, чи існує активний отримувач.
Loading diagram...
@startuml
skinparam style plain
autonumber
skinparam backgroundColor #FFFFFF
skinparam sequenceMessageAlign center

box "TCP: Обов'язкове узгодження (1 RTT)" #F1F5F9
actor "Клієнт TCP" as TC #DBEAFE
participant "Сервер TCP" as TS #DCFCE7
end box

TC -> TS : SYN (узгодження ISN)
TS -> TC : SYN-ACK
TC -> TS : ACK
TC -> TS : Прикладні дані (Byte Stream)

|||

box "UDP: Миттєва відправка (0 RTT)" #F1F5F9
actor "Клієнт UDP" as UC #DBEAFE
participant "Сервер UDP" as US #DCFCE7
end box

UC -> US : Датаграма (8B заголовок + дані)
note over UC, US #FEF3C7
  Жодного рукостискання.
  Відправник не знає, чи сокет сервера взагалі відкритий.
end note
@enduml

Структура заголовка UDP та характеристики протоколу

Специфікація UDP (RFC 768) є однією з найкоротших серед інтернет-стандартів. Заголовок датаграми має фіксовану довжину 8 байтів (64 біти) і складається лише з чотирьох полів:

ПолеРозмір (біти)Опис
Source Port16Порт процесу-відправника (може бути 0, якщо відповідь не очікується)
Destination Port16Порт процесу-отримувача
Length16Загальна довжина датаграми в байтах (заголовок 8B + корисні дані)
Checksum16Контрольна сума для перевірки цілісності заголовка та даних

Контрольна сума (Checksum)

Поле контрольної суми — єдиний вбудований захисний механізм UDP. Воно обчислюється над псевдозаголовком IP (IP-адреси, тип протоколу), заголовком UDP та самими даними.

  • Якщо під час передачі байти спотворилися через апаратні завади, контрольна сума на приймальній стороні не зійдеться, і ядро операційної системи мовчки знищить пошкоджену датаграму.
  • В IPv4 використання контрольної суми UDP є необов'язковим (значення 0x0000 вимикає перевірку), проте в IPv6 вона є суворо обов'язковою.

Властивості UDP

  1. Робота без з'єднання (connectionless): немає поняття сесії. Серверний UDP-сокет єдиний для всіх клієнтів і не породжує нових з'єднань через accept().
  2. Відсутність гарантії доставки (no delivery guarantee): якщо маршрутизатор перевантажений або виник збій на каналі, датаграма відкидається без генерації помилок для відправника.
  3. Відсутність збереження черговості (no ordering guarantee): пакети маршрутизуються незалежно; датаграма Завантаження... може дійти швидше за датуграму Завантаження....
  4. Відсутність захисту від дублювання (no duplicate protection): за певних умов маршрутизації один і той самий пакет може бути доставлений декілька разів.
  5. Збереження меж повідомлень (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).

Loading diagram...
@startuml
skinparam style plain
autonumber
skinparam backgroundColor #FFFFFF
skinparam sequenceMessageAlign center

actor "UDP Відправник" as S #DBEAFE
participant "Маршрутизатор\n(MTU = 1500)" as R #E2E8F0
actor "UDP Отримувач" as D #DCFCE7

S -> R : Датаграма 4000 байтів
note over R #FEF3C7
  Розбиття на 3 IP-фрагменти:
  1) 1480B даних + 20B IP
  2) 1480B даних + 20B IP
  3) 1040B даних + 20B IP
end note

R -> D : Фрагмент 1/3 (успішно)
R -x D : Фрагмент 2/3 (ВТРАЧЕНО В МЕРЕЖІ!)
R -> D : Фрагмент 3/3 (успішно)

note over D #FEE2E2
  Стек ОС отримувача чекає фрагмент 2.
  Після таймауту стек ВИКИДАЄ всі фрагменти.
  Прикладна програма не отримує нічого!
end note
@enduml

Наслідки фрагментації для UDP

  • Протокол UDP не підтримує повторну відправку окремих фрагментів. Якщо з трьох IP-фрагментів загубиться хоча б один, мережевий стек отримувача не зможе зібрати вихідну датаграму і знищить усі інші отримані фрагменти.
  • Якщо ймовірність втрати одного фрагмента становить Завантаження..., то ймовірність успішної доставки датаграми з Завантаження... фрагментів становить Завантаження....

Розрахунок безпечного розміру корисного навантаження

Для запобігання фрагментації в мережах IPv4 розмір корисного навантаження датаграми обчислюється за формулою:

Завантаження...
\text{Payload}{\text{safe}} = \text{MTU} - \text{IP}{\text{header}} - \text{UDP}_{\text{header}} = 1500 - 20 - 8 = 1472

Отже, безпечний розмір корисного навантаження для стандартного 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 і обробляє вхідні датаграми в нескінченному циклі:

cmd/server/main.go
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)
        }
    }
}
Особливість обрізання буфера (Truncation): якщо розмір вхідної датаграми перевищує місткість переданого в ReadFrom зрізу buffer, операційна система поміщає в буфер перші len(buffer) байтів, а залишок датаграми безповоротно знищує. На відміну від TCP, де залишок чекає наступного виклику, в UDP надлишкові байти втрачаються. Завжди виділяйте буфер прийому щонайменше 1500 або 65535 байтів!

Клієнт на базі «Connected» сокета (net.DialUDP)

Багатьох інженерів дивує наявність слова Dial («додзвонитися» / встановити з'єднання) стосовно протоколу UDP, адже UDP принципово беззв'язний (connectionless).

Коли програма викликає net.Dial("udp", ...) або net.DialUDP(...):

  1. Жодних мережевих пакетів не надсилається — тристороннє рукостискання (3-way handshake) відсутнє.
  2. На рівні ядра операційної системи виконується системний виклик connect().
  3. Сокет стає «прив'язаним» (connected UDP socket) до вказаної адреси в пам'яті ядра хоста.

Завдяки прив'язці на рівні ядра:

  • Можна використовувати звичайні потокові методи conn.Write(buf) та conn.Read(buf) замість передачі адрес у WriteTo / ReadFrom (ОС автоматично підставляє адресу призначення і знає, від кого очікувати відповідь).
  • Ядро ОС автоматично фільтрує та відкидає пакети від будь-яких сторонніх IP-адрес, крім зафіксованої.
  • Ядро здатне обробляти зворотні повідомлення ICMP Port Unreachable (код відсутності слухача на віддаленому порту), повертаючи помилку connection refused при наступному читанні або записі (звичайний незв'язаний UDP-сокет такі ICMP-повідомлення мовчки ігнорує).
cmd/client/main.go
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-мережах.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
skinparam packageStyle rectangle

package "Unicast (1 -> 1)" #DBEAFE {
  actor "Відправник" as U1
  actor "Отримувач" as U2
  U1 --> U2 : Пакет на унікальну IP
}

package "Broadcast (1 -> Всі)" #FEF3C7 {
  actor "Відправник" as B1
  actor "Вузол 1" as B2
  actor "Вузол 2" as B3
  actor "Вузол 3" as B4
  B1 --> B2
  B1 --> B3
  B1 --> B4
}

package "Multicast (1 -> Група)" #DCFCE7 {
  actor "Відправник" as M1
  actor "Підписник A" as M2
  actor "Підписник B" as M3
  actor "Не підписаний" as M4
  M1 --> M2 : Трафік на групову IP (224.0.0.0/4)
  M1 --> M3
  M1 ..x M4 : Не надсилається
}
@enduml

Unicast (Одноадресний режим)

Стандартний режим передачі «один до одного» (one-to-one). Датаграма адресується конкретній IP-адресі хоста і передається за класичною маршрутизацією.

Broadcast (Широкомовний режим)

Передача «один до всіх» (one-to-all) у межах одного сегмента локальної мережі (L2-домен трансляції). Пакети надсилаються на широкомовну адресу (наприклад, 255.255.255.255 або локальну трансляційну адресу підмережі). Маршрутизатори ніколи не пересилають широкомовні пакети за межі локальної мережі.

Для надсилання broadcast операційна система вимагає увімкнення опції SO_BROADCAST:

cmd/broadcast/main.go
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).

cmd/multicast/main.go
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:

  1. Порядкові номери (Sequence Numbers): додавання лічильника (наприклад, 4 байти) на початку кожного пакета дозволяє отримувачу розпізнавати пропуски та відновлювати порядок.
  2. Підтвердження (ACK / NACK):
    • ACK (Acknowledgment): отримувач повідомляє про кожен успішно прийнятий пакет.
    • NACK (Negative Acknowledgment): отримувач мовчить, поки дані надходять послідовно, і надсилає запит на повтор лише при виявленні пропущеного номера.
  3. Ковзне вікно (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).

Нижче наведено два файли: сервер телеметрії та клієнт-генератор.

Сервер телеметрії

cmd/telemetry/main.go
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)
        }
    }
}

Генератор тестової телеметрії (Клієнт)

cmd/telemetry_client/main.go
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


Питання для самоперевірки


Підсумки лекції

📡 Характеристики 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 на прикладі корпоративного мікросервісу.

Copyright © 2026