9.2TCP и UDP
Разбери, какие гарантии TCP строит поверх IP: порядок байтов, повторную передачу, flow control и congestion control. Сопоставь их с UDP, закрытием соединения и keep-alive, чтобы понимать цену надёжного потока.
Что именно TCP добавляет поверх IP
IP обещает ровно одно: «постараюсь доставить пакет по адресу». Пакет может пропасть, продублироваться, прийти вторым из трёх, побиться. TCP превращает это в надёжный упорядоченный поток байт между двумя процессами и делает это пятью механизмами:
- Соединение - обе стороны согласуют начальное состояние (рукопожатие), а потом и завершение.
- Порядковые номера и подтверждения: нумеруется каждый байт, приёмник сообщает, до какого места он всё получил.
- Ретрансмиссии: всё, что не подтвердили, уходит заново.
- Управление потоком - чтобы не завалить приёмник.
- Управление перегрузкой - чтобы не завалить сеть между вами.
# Заголовок TCP, 20 байт без опций
src port (2) dst port (2)
sequence number (4) # номер первого байта в сегменте
acknowledgment number (4) # следующий ожидаемый байт (кумулятивно)
data offset + flags (2) # SYN ACK FIN RST PSH URG ECE CWR
window (2) # свободное место в буфере приёма, до 65535 без wscale
checksum (2) urgent (2)
опции: MSS, window scale, SACK permitted, timestamps, TCP Fast Open
TCP передаёт поток байт, а не сообщения. Один Write на
10 КБ может приехать пятью Read, а три Write по 100 байт могут прийти одним.
Границ сообщений в TCP нет, их задают сами: длиной префикса, разделителем или заголовком
Content-Length. Это, кстати, самый частый практический вопрос по TCP на собесе.
Трёхстороннее рукопожатие
На сервере полуоткрытые соединения живут в SYN queue, а готовые к accept() ждут в
accept queue длиной backlog (реально min(backlog, net.core.somaxconn)).
При атаке SYN flood поток SYN с подделанных адресов забивает SYN queue; ответом стали
SYN cookies: сервер не хранит состояние, а зашивает его в ISN в SYN-ACK и восстанавливает из
ACK клиента. И уже без всяких атак: если accept queue переполнена (приложение медленно
делает Accept), ядро молча дропает SYN, и клиент видит долгий таймаут «на ровном
месте». Смотреть в ss -lnt, колонки Recv-Q (текущая длина) и Send-Q (лимит), плюс
счётчик ListenOverflows в nstat.
Порядковые номера, подтверждения, ретрансмиссии
Нумеруются байты, не пакеты. seq в сегменте несёт номер его первого байта,
ack в обратном пакете - номер следующего ожидаемого байта. Подтверждение
кумулятивное: ack=5000 означает «всё до 4999 включительно у меня есть»,
и это подтверждение перекрывает все предыдущие. Поэтому потеря одного ACK не фатальна: следующий
всё закроет.
Потерю замечают двумя способами, и по смыслу они сильно разные:
- Таймаут RTO. Отправитель держит таймер; сработал - сегмент
уходит заново. RTO не константа: ядро считает сглаженное
SRTTи разбросRTTVARпо алгоритму Джекобсона,RTO = SRTT + 4 × RTTVAR, с нижней границей (в Linux 200 мс). Повторный таймаут удваивает RTO (экспоненциальный откат), а по алгоритму Карна замеры RTT по ретрансмиссиям не учитываются, иначе оценка поедет. - Три дублирующих ACK → fast retransmit. Приёмник, получая сегменты «с дыркой», на каждый шлёт один и тот же ack (номер начала дырки). Три одинаковых ack подряд означают «один сегмент потерялся, остальные идут», и отправитель повторяет его сразу, не дожидаясь таймера.
SACK (RFC 2018) чинит главную слабость кумулятивного ACK: без него
приёмник не может сказать «мне не хватает только байтов 5000–6460, а 6460–20000 уже есть».
С SACK он перечисляет полученные диапазоны в опциях, и отправитель повторяет ровно дырки, а не всё
начиная с потерянного места. Без SACK на канале с потерями и большим окном пропускная способность
падает в разы. Договариваются о нём в рукопожатии (SACK permitted).
Алгоритм Нейгла копит мелкие записи, чтобы не слать пакет на каждый байт: пока есть
неподтверждённые данные, новый маленький сегмент придерживается. Delayed ACK на приёмнике
придерживает подтверждение до 40–200 мс, надеясь приклеить его к ответным данным. Вместе они дают
залипание ровно на этот таймер: классические «непонятные 40 мс» в latency у request-response
протоколов поверх TCP. Лечится отключением Нейгла через TCP_NODELAY. В Go он
выключен по умолчанию для net.TCPConn (то есть SetNoDelay(true)
уже применён), и упомянуть это на собесе полезно. Отсюда правило: делай один
Write целым сообщением или пиши через bufio.Writer с явным
Flush, а не по полю за раз.
Управление потоком: окно получателя
Окном называют разрешение отправителю: сколько байт можно послать и не ждать, пока их подтвердят. Байты, которые уже ушли, но ещё не подтверждены, называют «в полёте». Набрал полное окно - стой и жди ACK; пришёл ACK - окно «скользит» вперёд, и можно слать дальше. Без окна отправитель либо ждал бы подтверждение после каждого сегмента (и упёрся бы в один сегмент за RTT), либо завалил бы получателя.
Окон на самом деле два, и действует меньшее из них: rwnd говорит, сколько готов принять получатель, cwnd - сколько отправитель считает безопасным для сети между вами. Этот раздел про первое, следующий про второе.
Приёмник в каждом ACK объявляет rwnd, свободное место в своём буфере приёма.
Отправитель не имеет права держать «в полёте» больше этого объёма. Так защищается медленное
приложение-получатель: если оно не вызывает Read, буфер ядра заполняется, rwnd
падает, и в пределе объявляется нулевое окно: отправитель замолкает и периодически шлёт
window probe, пока окно не откроется.
Отсюда формула: максимальная пропускная способность одного TCP-соединения ограничена
window / RTT. При RTT 100 мс и окне 64 КБ это ~5 Мбит/с, сколько бы гигабит ни было в канале.
Это и есть bandwidth-delay product, поэтому на длинных «толстых» линках включают window scaling и поднимают
net.ipv4.tcp_rmem/tcp_wmem.
Управление перегрузкой
Окно получателя ничего не знает о том, что творится между хостами. Перегрузку сети TCP
оценивает сам, по косвенным признакам (потери, а в современных алгоритмах ещё и рост задержки),
и держит второе окно, cwnd. Реально в полёте может быть min(rwnd, cwnd) байт.
| Фаза | Что делает | Когда включается |
|---|---|---|
| Slow start | cwnd += 1 MSS на каждый подтверждённый сегмент, то есть удвоение за RTT | старт соединения; после таймаута RTO; после долгого простоя |
| Congestion avoidance | cwnd += 1 MSS за RTT - аддитивный рост | когда cwnd дорос до ssthresh |
| Fast retransmit | повтор потерянного сегмента сразу, по трём dup ACK | потеря одиночного сегмента |
| Fast recovery | ssthresh = cwnd/2, cwnd = ssthresh, продолжаем без возврата в slow start | сразу после fast retransmit |
| Таймаут RTO | ssthresh = cwnd/2, cwnd = 1, полный slow start | ACK не пришёл вообще - худший сценарий |
Классическую схему (Reno/NewReno) называют AIMD. CUBIC, дефолт в Linux с 2006 года, заменяет линейный рост кубической функцией от времени с последней потери: быстро возвращается к прежнему окну и осторожно щупает выше, и на «толстых» каналах с большим RTT это заметно быстрее. BBR от Google меняет саму модель: он не считает потерю сигналом перегрузки (на Wi-Fi и мобильных потери бывают от помех), а постоянно оценивает доступную полосу и минимальный RTT и держит ровно столько данных в полёте, сколько нужно, чтобы не наполнять буферы промежуточных роутеров. Так он лечит bufferbloat.
Их постоянно путают, а разница простая. Flow control защищает приёмник: сколько он готов
принять прямо сейчас, знает только он сам, поэтому он и объявляет rwnd явно, в каждом ACK.
Congestion control защищает сеть между вами: сколько она выдержит, никто не сообщает -
отправитель угадывает сам по потерям и задержкам и держит свою оценку cwnd. Нужны оба:
быстрый приёмник за узким каналом требует congestion control, широкий канал с медленным
приёмником требует flow control. В полёте разрешено min(rwnd, cwnd).
Закрытие соединения и TIME_WAIT
Закрытие четырёхстороннее, потому что TCP-соединение полнодуплексное: FIN
говорит «я больше ничего не пришлю», но встречный поток остаётся живым. Это и есть
half-close, в Go его делает conn.CloseWrite() на *net.TCPConn. Если приложение на
пассивной стороне закрывает сокет сразу, ядро склеивает ACK и FIN, и пакетов получается три.
TIME_WAIT возникает только у того, кто закрыл первым. Это единственное состояние, которое
живёт после того, как приложение уже забыло про сокет: ядро держит пятёрку занятой 2 × MSL
(в Linux это константа 60 секунд, TCP_TIMEWAIT_LEN, меняется только пересборкой ядра).
Опасен он на стороне, которая закрывает первой. Если это клиент (или твой сервис в роли
клиента к чужому API), каждое закрытое соединение на минуту занимает эфемерный порт. При
~28 тысячах портов и 60 секундах потолок выходит около 470 новых соединений в секунду к одному
адресу и порту. Дальше connect() начинает возвращать
EADDRNOTAVAIL - «cannot assign requested address», и в логах Go это
dial tcp …: connect: cannot assign requested address.
- Главное лечение - не создавать соединения. Переиспользуй их: настроенный
http.Transportс достаточнымMaxIdleConnsPerHost, пул к БД, gRPC-канал. Один keep-alive-канал вместо тысячи одноразовых снимает вопрос целиком. - Закрывать первым должен клиент, а не сервер, иначе TIME_WAIT копится на сервере (там он не выжигает порты, но ест память и слоты conntrack).
SO_REUSEADDRпозволяетbindна порт, где висят TIME_WAIT-сокеты. Он решает задачу «перезапустить сервер сразу, а не ждать минуту», но не лечит исчерпание эфемерных портов у клиента.- Тюнинг: расширить
net.ipv4.ip_local_port_range, включитьnet.ipv4.tcp_tw_reuse=1(безопасно: переиспользует TIME_WAIT-сокет для нового исходящего соединения, опираясь на timestamps). А вотtcp_tw_recycleломал клиентов за NAT и удалён из ядра с 4.12; назовёшь это на собесе, получишь большой плюс.
Про RST помни отдельно: это не «закрытие», а «аварийный сброс». Он прилетает,
когда пишут в закрытый сокет, когда порт никто не слушает, при SO_LINGER с нулевым
таймаутом. При переполнении очереди приёма Linux по умолчанию RST не шлёт, а молча выбрасывает
SYN; RST будет, только если включён tcp_abort_on_overflow. В Go это connection reset by peer. От FIN он
отличается сильно: FIN закрывает упорядоченно, данные в буферах дойдут, а RST выбрасывает всё немедленно.
UDP: что остаётся, если убрать всё
Заголовок UDP весит 8 байт: порт источника, порт назначения, длина, контрольная сумма. Состояния нет, соединения нет, номеров нет, буквально «IP плюс порты плюс checksum». Отсюда всё остальное:
- Не гарантирует доставку: датаграмма может пропасть, и никто об этом не узнает.
- Не гарантирует порядок - вторая может обогнать первую.
- Не защищает от дублей: маршрутизация может размножить пакет.
- Нет управления перегрузкой - UDP-приложение может утопить канал, ответственность на нём.
- Сохраняет границы сообщений: один
WriteTo= одна датаграмма = одинReadFrom. Это плюс, а не минус: не нужен фреймер.
Зачем он нужен, если такой «плохой»: когда опоздавшие данные бесполезны. В голосовом звонке повторять пакет, который опоздал на 300 мс, бессмысленно - лучше проиграть тишину и идти дальше. Когда запрос и ответ помещаются в одну датаграмму (DNS), рукопожатие ради одного вопроса даёт только накладные расходы. Когда нужен multicast/broadcast, которого в TCP нет вовсе. И когда надёжность хочется реализовать самому, по-своему, как сделал QUIC.
| Свойство | TCP | UDP |
|---|---|---|
| Соединение | есть, рукопожатие 1 RTT | нет, шлём сразу |
| Доставка и порядок | гарантированы | не гарантированы |
| Границы сообщений | нет, поток байт | есть, датаграмма |
| Управление потоком / перегрузкой | есть оба | нет, делай сам |
| Заголовок | 20 байт и больше | 8 байт |
| Multicast / broadcast | нет | да |
| Поведение при потере | всё встаёт до ретрансмиссии (HOL) | дырка в данных, поток идёт дальше |
| Где применяю | REST и gRPC API, БД, очереди, передача файлов, SSH - всё, где важен каждый байт | DNS, NTP, DHCP, метрики StatsD, VoIP и видеозвонки, игровые тики, QUIC/HTTP-3, service discovery по multicast |
«Беру TCP по умолчанию. Ухожу на UDP, если выполняется хотя бы одно: устаревшие данные не нужны (реалтайм-медиа, позиции игроков), обмен помещается в одну датаграмму и рукопожатие дороже смысла (DNS, NTP), нужен multicast, либо я строю свой транспорт и мне мешает надёжность TCP (QUIC). Во всех этих случаях надёжность, если она нужна частично, я реализую на прикладном уровне: секвенсные номера, ACK на важные сообщения, forward error correction».
Keep-alive: TCP и HTTP - это разные вещи
| TCP keep-alive | HTTP keep-alive (persistent connection) | |
|---|---|---|
| Уровень | L4, механизм ядра | L7, поведение протокола |
| Что делает | шлёт пустой пробный сегмент в простаивающее соединение, чтобы понять, жив ли пир | не закрывает TCP-соединение после ответа, чтобы следующий запрос пошёл по нему же |
| Зачем | обнаружить «мёртвого» пира и не держать зомби-сокеты; не дать NAT/файрволу выбросить запись о сессии | убрать из каждого запроса рукопожатие TCP и TLS - экономия двух RTT |
| Настройка | tcp_keepalive_time (по умолчанию 7200 с!), _intvl, _probes; в Go - net.Dialer.KeepAlive / KeepAliveConfig | в HTTP/1.1 включено по умолчанию, отключается Connection: close; в Go - Transport.MaxIdleConnsPerHost, IdleConnTimeout, у сервера - SetKeepAlivesEnabled |
| Кто «страдает» без него | висящие соединения через NAT и балансировщики отваливаются молча | каждый запрос платит 2–3 RTT, а клиент выжигает эфемерные порты через TIME_WAIT |
// TCP keep-alive: ядро само пингует пира. Дефолт ОС в 2 часа почти всегда бесполезен.
d := &net.Dialer{
Timeout: 3 * time.Second,
KeepAliveConfig: net.KeepAliveConfig{ // Go 1.23+: раньше был только KeepAlive time.Duration
Enable: true,
Idle: 30 * time.Second, // начать пробы после 30 с тишины
Interval: 10 * time.Second, // интервал между пробами
Count: 3, // после 3 неудач соединение мертво
},
}
// HTTP keep-alive: транспорт переиспользует соединения из пула.
tr := &http.Transport{
DialContext: d.DialContext,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 100, // дефолт всего 2, главная причина всплеска TIME_WAIT
IdleConnTimeout: 90 * time.Second,
}
client := &http.Client{Transport: tr, Timeout: 5 * time.Second}
Соединение вернётся в пул только если тело ответа дочитано до конца и закрыто. Если ты
сделал defer resp.Body.Close(), но вышел по ошибке, не прочитав тело, соединение
уйдёт в помойку, и весь keep-alive перестанет работать: получишь тысячи TIME_WAIT и «cannot
assign requested address». Правильный шаблон при досрочном выходе:
io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) перед
Close(). Второй нюанс: MaxIdleConnsPerHost по умолчанию равен
двум, и при большой параллельности лишние соединения закрываются сразу после ответа.
Третий, свежий: Go 1.27 частично закрыл эту дыру сам - HTTP/1-транспорт при
Close() пробует дочитать недочитанный остаток и вернуть соединение в пул, но
только если тело не длиннее 256 КиБ (или длина заранее неизвестна) и остаток успевает вычитаться за
50 мс. Большое или медленное тело по-прежнему стоит соединения, поэтому явный
io.Copy(io.Discard, …) остаётся правильной привычкой, к тому же только он
работает одинаково на всех версиях Go.
Head-of-line blocking
«Блокировка головой очереди» случается, когда первый элемент очереди тормозит всех, кто за ним, хотя они уже готовы. В сетях она встречается на двух разных уровнях, и на собесе их надо разделить.
- HOL на уровне HTTP/1.1. В одном соединении запросы обрабатываются строго по очереди: следующий ответ нельзя начать, пока не дописан предыдущий. Обходили это открытием 6 параллельных соединений на домен и доменным шардингом. Конвейеризация должна была помочь, но ответы всё равно обязаны идти в порядке запросов, поэтому один медленный ответ блокировал остальные - её отключили во всех браузерах.
- HOL на уровне TCP. Живёт ниже и не лечится прикладным протоколом. TCP обязан отдать приложению байты по порядку: если потерялся сегмент, все пришедшие после него сегменты лежат в буфере приёма и не отдаются наверх, пока потерянный не будет ретранслирован. Это стоит минимум одного RTT.
Дальше цепочка, которая и есть «правильный» ответ. HTTP/2 мультиплексирует потоки внутри одного TCP-соединения и убирает HOL уровня HTTP - но, положив все потоки в один TCP, делает TCP-HOL больнее: потеря одного пакета останавливает все логические потоки сразу (в HTTP/1.1 встали бы только запросы одного из шести соединений). QUIC закрывает вопрос: он поверх UDP и ведёт порядок и ретрансмиссии отдельно для каждого потока, поэтому потеря пакета тормозит только тот поток, чьи байты потерялись.
Head-of-line blocking свойственен любым очередям, и интервьюер может увести вопрос в сторону: партиция Kafka, где одно «ядовитое» сообщение блокирует всю партицию; пул воркеров с одной общей очередью, где долгая задача держит короткие; ordered-режим в RabbitMQ. Ответ на «как лечить» везде один: разделить поток на независимые очереди или разрешить обработку не по порядку.
Вопросы
6Рукопожатие
SYN (клиент шлёт свой ISN и опции) → SYN-ACK (сервер шлёт свой ISN и подтверждает чужой) → ACK. Три пакета, потому что четыре логических события (два ISN и два подтверждения) склеиваются в три: SYN сервера и ACK на SYN клиента едут вместе. ISN обязательно псевдослучайный: иначе заблудившийся сегмент от старого соединения с той же пятёркой был бы принят за валидный, а ещё это защищает от подделки соединений извне. Данные можно класть уже в третий пакет, поэтому рукопожатие «стоит» ровно один RTT. TCP Fast Open кладёт их даже в SYN, по куки от прошлой сессии.
Состояния: CLOSED → SYN_SENT → ESTABLISHED у клиента,
LISTEN → SYN_RECEIVED → ESTABLISHED у сервера. На сервере полуоткрытые лежат
в SYN queue, готовые - в accept queue длиной backlog.
Гарантии и как они обеспечиваются
Нумеруются байты. ack кумулятивный: «всё до этого номера получено». Приёмник
складывает пришедшее не по порядку в буфер и отдаёт приложению только непрерывный префикс:
отсюда и порядок, и head-of-line blocking. Целостность проверяет контрольная сумма (слабая, 16 бит,
поэтому поверх часто ещё TLS или CRC приложения).
Ретрансмиссии
- По таймауту RTO:
RTO = SRTT + 4 × RTTVAR, оценка по алгоритму Джекобсона, при повторе RTO удваивается, а по алгоритму Карна замеры по ретрансмиссиям не учитываются. - Fast retransmit: три дублирующих ACK подряд означают «одна дырка, остальное идёт», и сегмент повторяют сразу, без таймера.
- SACK позволяет приёмнику перечислить полученные диапазоны, чтобы отправитель повторил ровно дырки, а не всё с потерянного места. На каналах с потерями это разница в разы.
Два окна
Flow control - приёмник в каждом ACK объявляет rwnd, свободное место в своём
буфере. Ноль означает «замолчи», и отправитель шлёт window probe до открытия окна. Опция window
scale поднимает потолок с 64 КБ до 1 ГБ, без неё быстрый канал с большим RTT не разогнать:
скорость упирается в window / RTT.
Congestion control ведёт отправитель: сам оценивает состояние сети и держит cwnd.
Slow start (удвоение за RTT) до ssthresh, дальше congestion avoidance (+1 MSS за RTT).
При потере по трём dup ACK окно режут (Reno вдвое, CUBIC до 0,7) и продолжают (fast recovery), при таймауте
cwnd = 1 и всё заново. В Linux по умолчанию CUBIC; BBR вместо потерь меряет полосу и
минимальный RTT и лечит bufferbloat. В полёте разрешено min(rwnd, cwnd).
«TCP гарантирует доставку - значит, если Write вернул nil, данные у получателя?»
Нет. Write означает только «скопировано в буфер отправки ядра». Соединение может
умереть после этого, и приложение узнает об этом в лучшем случае на следующей операции.
Сквозную гарантию даёт только прикладной уровень: подтверждение от самого приложения-получателя.
Последовательность
FIN от активной стороны (FIN_WAIT_1) → ACK от пассивной
(та переходит в CLOSE_WAIT, активная - в FIN_WAIT_2) → пассивная
сторона, когда её приложение тоже закроется, шлёт свой FIN
(LAST_ACK) → активная подтверждает и уходит в TIME_WAIT на 2 × MSL.
Между вторым и третьим пакетом соединение полузакрыто: пассивная сторона всё ещё может
слать данные. Если она закрывается сразу, ACK и FIN склеиваются и пакетов получается три.
Зачем TIME_WAIT
- Дать последнему ACK шанс дойти. Если он потеряется, пассивная сторона по таймауту повторит FIN, и кто-то должен на него ответить. Иначе она получит RST и решит, что соединение оборвалось аварийно.
- Дать умереть заблудившимся сегментам. Сегмент старого соединения, застрявший в сети, не должен быть принят за данные нового соединения с той же пятёркой. MSL - это максимальное время жизни сегмента, а двойка нужна, потому что сегмент может блуждать в обе стороны.
Чем грозит
Сначала раздели два случая. TIME_WAIT на сервере (сервер закрывает первым,
например отдаёт Connection: close) не съедает порты: порт-то один, 443. Он ест
память ядра и записи в conntrack, и в масштабе сотен тысяч это заметно, но не смертельно.
TIME_WAIT на клиенте гораздо злее: каждое закрытое соединение к одному
(dst IP, dst port) занимает эфемерный порт на 60 секунд. При диапазоне
32768–60999 это ~28 тысяч портов, то есть потолок примерно 470 новых соединений в секунду
к одному бэкенду. Дальше - EADDRNOTAVAIL, в Go
connect: cannot assign requested address. Так и «внезапно ложится» сервис, который
ходит к соседу без переиспользования соединений.
Как лечить, по убыванию правильности
- Переиспользовать соединения. Keep-alive и нормальный пул:
http.Transportс поднятымMaxIdleConnsPerHost(дефолт всего 2!), пул к БД, один gRPC-канал на процесс. Это убирает проблему, а не маскирует. - Обязательно дочитывать тело ответа перед
Close(), иначе соединение не вернётся в пул и keep-alive не заработает. - Пусть закрывает клиент. Настроить сервер так, чтобы он не рвал соединения первым
(аккуратно с
IdleTimeoutиConnection: close). SO_REUSEADDRнужен, чтобы перезапущенный сервер сразу забиндился на порт, где остались TIME_WAIT-сокеты; Go ставит его на слушающие сокеты сам. Проблему клиентских портов не решает.SO_REUSEPORTпро другое: несколько процессов слушают один порт, ядро балансирует между ними.- Тюнинг ядра: расширить
ip_local_port_range, включитьtcp_tw_reuse=1.tcp_tw_recycleиспользовать нельзя: он ломал клиентов за общим NAT и удалён из ядра начиная с 4.12.
// SO_REUSEADDR Go ставит на слушающий сокет сам; SO_REUSEPORT - через Control
lc := net.ListenConfig{
Control: func(network, address string, c syscall.RawConn) error {
var opErr error
err := c.Control(func(fd uintptr) {
opErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
})
if err != nil { return err }
return opErr
},
}
ln, err := lc.Listen(context.Background(), "tcp", ":8080")
Много CLOSE_WAIT всегда означает баг приложения. Пир прислал FIN, ядро ответило ACK, но
твой код не вызвал Close(): забыли defer resp.Body.Close(),
потеряли conn, повисла горутина. Сокеты и файловые дескрипторы копятся до
too many open files. TIME_WAIT - нормальная работа протокола, CLOSE_WAIT -
утечка. На собесе разницу между этими двумя ответами видно сразу.
Чего нет
- Доставки: датаграмма может пропасть молча, отправитель не узнает.
- Порядка: вторая может обогнать первую.
- Дедупликации: сеть может размножить пакет, приложение получит два.
- Flow и congestion control: UDP-приложение способно утопить и приёмник, и канал,
и ответственность целиком на нём. Ядро просто выбросит датаграммы, если буфер приёма полон
(счётчик
RcvbufErrorsвnetstat -su). - Состояния:
net.DialUDPничего не открывает, он только запоминает адрес, чтобы можно было зватьWriteвместоWriteTo.
Что есть
Границы сообщений. Одна запись = одна датаграмма = одно чтение. Фреймер не нужен, «слипания» сообщений, как в TCP, нет. Цена: если буфер чтения меньше датаграммы, хвост просто отрезается. Плюс multicast и broadcast, которых в TCP нет в принципе.
conn, _ := net.ListenPacket("udp", ":9000")
buf := make([]byte, 65535) // берём с запасом: короткий буфер отрежет хвост
for {
n, addr, err := conn.ReadFrom(buf) // ровно одна датаграмма за вызов
if err != nil {
if errors.Is(err, net.ErrClosed) { return }
continue // прочие ошибки чтения на UDP обычно не фатальны
}
go handle(conn, addr, append([]byte(nil), buf[:n]...)) // копия: buf перезапишет следующий ReadFrom
}
Практический размер
Датаграмма теоретически вмещает до 65507 байт, но всё, что больше MTU, фрагментируется на IP-уровне, и потеря одного фрагмента убивает всю датаграмму. Поэтому в реальных протоколах держатся в пределах ~1400 байт: DNS до EDNS0 вообще ограничивал ответ 512 байтами, а QUIC сам следит за размером пакета.
Её строят поверх UDP руками и частично: секвенсные номера, подтверждения только для важных сообщений, forward error correction (шлём избыточность вместо повторов), дедупликация по идентификатору. Так и работает QUIC, причём лучше TCP, потому что ведёт порядок отдельно по каждому потоку. Формулировка для собеса: «UDP выбирают не потому, что не нужна надёжность, а потому, что нужна своя надёжность».
| Сценарий | Что беру | Почему |
|---|---|---|
| REST/gRPC API между сервисами | TCP | нужен каждый байт и порядок; соединения долгоживущие, рукопожатие амортизируется |
| Работа с БД, очередями, S3 | TCP | то же плюс пул соединений; потеря байта означает битые данные |
| Передача файлов, бэкапы | TCP | важна целостность, не важна задержка; congestion control сам займёт свободную полосу |
| DNS-запрос | UDP (fallback TCP) | вопрос и ответ помещаются в одну датаграмму; рукопожатие ради 60 байт дороже самой работы. При TC=1 повтор по TCP; зонные трансферы AXFR - всегда TCP |
| NTP, DHCP, метрики StatsD | UDP | fire-and-forget: потерять один тик метрики дешевле, чем платить за соединение |
| VoIP, видеозвонок (WebRTC) | UDP | кадр, опоздавший на 300 мс, уже не нужен; лучше артефакт, чем пауза. Надёжность частичная, через FEC и джиттер-буфер |
| Быстрые игры (шутеры) | UDP | шлём состояние мира тиками; устаревшая позиция игрока бесполезна, ретрансмиссия только добавит лаг |
| Видеостриминг YouTube/Netflix | TCP (HLS/DASH поверх HTTP) | вопрос-ловушка: это не realtime, а загрузка сегментов в буфер на 10–30 секунд - целостность важнее задержки. Realtime-стрим (WebRTC, SRT) - уже UDP |
| Многопользовательская рассылка в LAN, service discovery (mDNS) | UDP | multicast в TCP невозможен |
| HTTP/3 | UDP | QUIC строит свою надёжность поверх UDP, чтобы обойти TCP-HOL и ускорить рукопожатие |
Сильный ответ строится на одном критерии: полезны ли данные после задержки. Если да - TCP, повтор имеет смысл. Если нет - UDP, повтор только вредит, увеличивая задержку и занимая канал. Второй критерий касается размера обмена относительно накладных расходов: три пакета рукопожатия ради одного 60-байтного вопроса не окупаются. И сразу назови ловушку со стримингом: интервьюер часто ждёт «стриминг = UDP», а правильный ответ различает буферизованное видео (TCP) и realtime-связь (UDP).
TCP keep-alive
Когда в соединении тишина дольше tcp_keepalive_time, ядро шлёт пустой пробный
сегмент. Если после tcp_keepalive_probes попыток с интервалом
tcp_keepalive_intvl ответа нет, соединение объявляется мёртвым, приложение получает ошибку.
В Linux по умолчанию 7200 секунд, то есть два часа, и толку от этого почти нет, поэтому значение
всегда переопределяют на уровне сокета. Реальных задач две: во-первых, обнаружить пира,
который умер молча (выдернули провод, упала виртуалка, и FIN никто не прислал); во-вторых,
не дать NAT или файрволу выбросить запись о сессии по таймауту неактивности.
HTTP keep-alive
В HTTP/1.0 нужно было явно просить Connection: keep-alive; в HTTP/1.1 соединение
постоянное по умолчанию, а закрыть его просят через Connection: close.
Так не приходится платить за TCP- и TLS-рукопожатие в каждом запросе: экономятся два RTT на
запрос, и не тянется шлейф TIME_WAIT. В HTTP/2 и HTTP/3 отдельной такой настройки
нет: там соединение изначально долгоживущее и мультиплексированное.
// сервер: свои таймауты и время жизни простаивающего соединения
srv := &http.Server{
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second, // сколько держим keep-alive без запросов
}
// srv.SetKeepAlivesEnabled(false) выключает HTTP keep-alive, например перед graceful shutdown
- «TCP keep-alive ускоряет работу» - нет, он ничего не ускоряет, он детектирует смерть пира. Ускоряет HTTP keep-alive, потому что убирает рукопожатия.
- Заголовок
Keep-Alive: timeout=5, max=1000относится к HTTP и служит подсказкой, а не приказом; к TCP-опции отношения не имеет. - HTTP keep-alive без TCP keep-alive легко нарывается на зависшие соединения через NAT:
клиент думает, что соединение живо, шлёт запрос - и висит до таймаута. Отсюда правило:
IdleConnTimeoutна клиенте должен быть меньше, чемIdleTimeoutна сервере и таймаут NAT. - В Go у
net.Dialerkeep-alive включён по умолчанию (15 секунд) - в отличие от голого сокета в C.
HOL в HTTP/1.1
В одном соединении ответы обязаны идти в порядке запросов. Медленный первый ответ держит остальные. Конвейеризация (pipelining) позволяла слать запросы, не дожидаясь ответов, но порядок ответов оставался жёстким: выигрыша почти не было, а багов в прокси набралось много, и во всех браузерах её выключили. Реальным обходом стали 6 параллельных соединений на домен и доменный шардинг.
HOL в TCP
Этот HOL сидит уровнем ниже, и прикладной протокол его не лечит. TCP гарантирует порядок, значит, при потере сегмента все пришедшие после него данные лежат в буфере приёма и не отдаются приложению, пока дырка не будет закрыта ретрансмиссией. Простой выходит минимум на один RTT, даже если приложению те, «застрявшие», байты были нужны прямо сейчас и относились к другому логическому запросу.
Цепочка, которую хотят услышать
- HTTP/2 мультиплексирует независимые потоки внутри одного TCP-соединения и убирает HOL прикладного уровня: ответы приходят вперемешку, порядок не важен.
- Но, сложив всё в одно TCP-соединение, HTTP/2 сделал транспортный HOL больнее: потеря одного пакета останавливает все потоки сразу. В HTTP/1.1 с шестью соединениями встали бы запросы только одного из них. На плохой сети HTTP/2 иногда проигрывает HTTP/1.1 именно поэтому.
- QUIC (HTTP/3) решает обе проблемы: он поверх UDP, и порядок доставки у каждого потока свой - номера пакетов и подтверждения общие на соединение, но потерянные байты задерживают только свой поток. Потеря пакета тормозит только тот поток, чьи байты потерялись; остальные едут дальше.
Так ведут себя любые упорядоченные очереди. В партиции Kafka одно «ядовитое» сообщение, которое не удаётся обработать, блокирует всю партицию; спасают dead letter queue и параллелизация по ключу. В пуле воркеров с одной общей очередью долгая задача держит короткие, и помогают отдельные очереди по классам задач. Коммутатор с input queuing: кадр, чей выходной порт занят, блокирует следующие - лечится virtual output queues. Если интервьюер уводит туда, общий рецепт один: разделить поток на независимые очереди либо разрешить обработку не по порядку.
Что стоит запомнить
- TCP даёт приложению упорядоченный поток байтов и скрывает потери через retransmission; границы сообщений нужно задавать протоколом приложения.
- Receive window ограничивает отправителя по ёмкости получателя, congestion control - по состоянию сети.
- TCP keep-alive проверяет простаивающее соединение на уровне ОС; HTTP keep-alive переиспользует соединение для запросов.
Частые ошибки
- Не трактуй успешный Write как подтверждение, что удалённое приложение обработало данные.
- Не забывай, что потеря одного сегмента может задержать последующие байты TCP потока (head-of-line blocking).
- UDP не становится ненадёжным для каждой задачи: он оставляет приложению контроль над потерями, порядком и повторами.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Прочитай TCP keep-alive конфиг
В Go Dialer заданы Enable=true, Idle=30s, Interval=10s, Count=3. Что означают эти числа и в каком случае TCP соединение может быть признано мёртвым?
KeepAliveConfig: net.KeepAliveConfig{
Enable: true,
Idle: 30 * time.Second,
Interval: 10 * time.Second,
Count: 3,
}
Ответ и разбор
После 30 секунд простоя ядро начинает пробы примерно раз в 10 секунд. После трёх последовательных неудачных проб соединение может быть признано недоступным. Это TCP-level механизм обнаружения простаивающего peer, а не HTTP connection reuse и не дедлайн запроса.
Код-ревью
2. Проверь UDP буфер
В обработчике ниже следующая ReadFrom перезапишет общий буфер, пока goroutine читает его. Найди ещё один риск и исправь передачу данных.
buf := make([]byte, 512)
for {
n, addr, err := conn.ReadFrom(buf)
if err != nil { continue }
go handle(buf[:n], addr)
}
Ответ и разбор
Слайс ссылается на общий backing array, который перезаписывается при следующем ReadFrom; скопируй datagram до запуска goroutine: packet := append([]byte(nil), buf[:n]...). Буфер 512 байт может обрезать более крупную UDP datagram; увеличь его до допустимого размера протокола и учитывай максимальный размер UDP payload.
Применение
3. Различи два keep-alive
Инженер выключил HTTP keep-alive, чтобы TCP keep-alive быстрее обнаруживал мёртвые peer. Почему это не одно и то же и какая настройка относится к каждому требованию?
Ответ и разбор
HTTP keep-alive позволяет переиспользовать TCP connection для нескольких HTTP запросов и управляется настройками transport/server. TCP keep-alive посылает probes по простаивающему соединению и настраивается через net.Dialer.KeepAliveConfig/настройки ОС. Выключение HTTP reuse не задаёт интервал TCP probes и может увеличить число handshakes.
Применение
4. Оцени таймауты HTTP-сервера
Сервер принимает заголовки и тело до 2 ГБ, а ReadTimeout установлен в 15 секунд. Какой риск есть у такого общего timeout и как разделить защиту?
Ответ и разбор
Общий ReadTimeout может оборвать медленную, но легитимную загрузку; слишком большой timeout даёт slow-client возможность удерживать ресурсы. Используй ReadHeaderTimeout для быстрого ограничения заголовков, ограничь размер тела через MaxBytesReader и задавай request-level deadline/скорость чтения для upload сценария. WriteTimeout и IdleTimeout решают другие этапы соединения.