voidlearn
 

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 на собесе.

Трёхстороннее рукопожатие

Открытие соединения: три пакета, один RTT Клиент Сервер CLOSED LISTEN SYN seq=x опции: MSS 1460, window scale 7, SACK permitted, timestamps SYN_SENT SYN_RECEIVED SYN + ACK seq=y, ack=x+1 ESTABLISHED ACK ack=y+1 (сюда же можно класть данные) ESTABLISHED 1 RTT Зачем именно три шага Каждая сторона должна сообщить свой начальный номер ISN и получить подтверждение, что его услышали. Это четыре события, но SYN сервера и ACK на SYN клиента едут одним пакетом - отсюда три, а не четыре. ISN выбирается псевдослучайно: иначе заблудившийся сегмент старого соединения с той же пятёркой сойдёт за свой. TCP Fast Open умеет класть данные прямо в SYN - по куки, выданной при прошлом соединении.
Три пакета, один RTT. Данные можно отправлять уже с третьим пакетом, поэтому «рукопожатие стоит один круг», а не полтора.
Глубже: две очереди слушающего сокета и SYN flood

На сервере полуоткрытые соединения живут в 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).

Подвох: Nagle плюс delayed ACK

Алгоритм Нейгла копит мелкие записи, чтобы не слать пакет на каждый байт: пока есть неподтверждённые данные, новый маленький сегмент придерживается. 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, пока окно не откроется.

Скользящее окно над потоком байтов у отправителя SND.UNA SND.NXT предел окна подтверждено в полёте, ACK не пришёл можно слать прямо сейчас за окном: слать нельзя размер окна = min(rwnd приёмника, cwnd отправителя) Приёмник: rwnd - это свободное место в буфере приёма приложение ещё не прочитало свободно = rwnd объявляется в каждом ACK Приложение не читает -> буфер полон -> rwnd = 0 -> отправитель молчит и шлёт window probe до открытия окна. Опция window scale поднимает потолок окна с 64 КБ до 1 ГБ - без неё быстрый канал с большим RTT не разогнать.
Окно отправителя. Слева направо: подтверждённое, в полёте, разрешённое к отправке и запрещённое. Окно «скользит» вправо по мере прихода ACK.

Отсюда формула: максимальная пропускная способность одного TCP-соединения ограничена window / RTT. При RTT 100 мс и окне 64 КБ это ~5 Мбит/с, сколько бы гигабит ни было в канале. Это и есть bandwidth-delay product, поэтому на длинных «толстых» линках включают window scaling и поднимают net.ipv4.tcp_rmem/tcp_wmem.

Управление перегрузкой

Окно получателя ничего не знает о том, что творится между хостами. Перегрузку сети TCP оценивает сам, по косвенным признакам (потери, а в современных алгоритмах ещё и рост задержки), и держит второе окно, cwnd. Реально в полёте может быть min(rwnd, cwnd) байт.

cwnd время, RTT ssthresh slow start: cwnd удваивается каждый RTT (экспонента) congestion avoidance: +1 MSS за RTT (линейно) три dup ACK -> fast retransmit, cwnd падает: Reno вдвое, CUBIC до 0,7 таймаут RTO: cwnd = 1, заново со slow start Потеря по dup ACK - сеть жива, значит окно режут умеренно. Потеря по таймауту - сеть, возможно, легла: откат в самое начало. CUBIC (по умолчанию в Linux) растёт кубически от времени последней потери. BBR не ждёт потерь, а меряет полосу и минимальный RTT.
Пила congestion window. Экспоненциальный разгон до ssthresh, дальше осторожный линейный рост, и два разных наказания за потерю - мягкое по dup ACK и жёсткое по таймауту.
ФазаЧто делаетКогда включается
Slow startcwnd += 1 MSS на каждый подтверждённый сегмент, то есть удвоение за RTTстарт соединения; после таймаута RTO; после долгого простоя
Congestion avoidancecwnd += 1 MSS за RTT - аддитивный росткогда cwnd дорос до ssthresh
Fast retransmitповтор потерянного сегмента сразу, по трём dup ACKпотеря одиночного сегмента
Fast recoveryssthresh = cwnd/2, cwnd = ssthresh, продолжаем без возврата в slow startсразу после fast retransmit
Таймаут RTOssthresh = cwnd/2, cwnd = 1, полный slow startACK не пришёл вообще - худший сценарий

Классическую схему (Reno/NewReno) называют AIMD. CUBIC, дефолт в Linux с 2006 года, заменяет линейный рост кубической функцией от времени с последней потери: быстро возвращается к прежнему окну и осторожно щупает выше, и на «толстых» каналах с большим RTT это заметно быстрее. BBR от Google меняет саму модель: он не считает потерю сигналом перегрузки (на Wi-Fi и мобильных потери бывают от помех), а постоянно оценивает доступную полосу и минимальный RTT и держит ровно столько данных в полёте, сколько нужно, чтобы не наполнять буферы промежуточных роутеров. Так он лечит bufferbloat.

Flow control против congestion control - вопрос-разделитель

Их постоянно путают, а разница простая. Flow control защищает приёмник: сколько он готов принять прямо сейчас, знает только он сам, поэтому он и объявляет rwnd явно, в каждом ACK. Congestion control защищает сеть между вами: сколько она выдержит, никто не сообщает - отправитель угадывает сам по потерям и задержкам и держит свою оценку cwnd. Нужны оба: быстрый приёмник за узким каналом требует congestion control, широкий канал с медленным приёмником требует flow control. В полёте разрешено min(rwnd, cwnd).

Закрытие соединения и TIME_WAIT

Закрытие: четыре пакета и TIME_WAIT на активной стороне Активная сторона (закрыла первой) Пассивная сторона ESTABLISHED ESTABLISHED FIN seq=u вызван Close() FIN_WAIT_1 CLOSE_WAIT ACK ack=u+1 полузакрытое состояние: пассивная сторона ещё может слать данные FIN_WAIT_2 FIN seq=v когда её приложение тоже сделало Close() LAST_ACK ACK ack=v+1 CLOSED TIME_WAIT 2 × MSL CLOSED Почему 2 × MSL (в Linux зашито 60 секунд) 1) Дать последнему ACK шанс дойти: если он потеряется, пассивная сторона повторит FIN - и его должно быть кому подтвердить. 2) Дать заблудившимся сегментам этого соединения умереть, чтобы они не были приняты за данные нового соединения с той же пятёркой.
Четыре пакета вместо трёх. FIN закрывает только своё направление, поэтому подтверждение и встречный FIN разнесены во времени - их можно склеить, только если приложение закрылось сразу.

Закрытие четырёхстороннее, потому что TCP-соединение полнодуплексное: FIN говорит «я больше ничего не пришлю», но встречный поток остаётся живым. Это и есть half-close, в Go его делает conn.CloseWrite() на *net.TCPConn. Если приложение на пассивной стороне закрывает сокет сразу, ядро склеивает ACK и FIN, и пакетов получается три.

TIME_WAIT возникает только у того, кто закрыл первым. Это единственное состояние, которое живёт после того, как приложение уже забыло про сокет: ядро держит пятёрку занятой 2 × MSL (в Linux это константа 60 секунд, TCP_TIMEWAIT_LEN, меняется только пересборкой ядра).

Чем TIME_WAIT грозит на нагрузке и как лечится

Опасен он на стороне, которая закрывает первой. Если это клиент (или твой сервис в роли клиента к чужому 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.

СвойствоTCPUDP
Соединениеесть, рукопожатие 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-aliveHTTP 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
Суть: TCP превращает ненадёжную доставку пакетов в надёжный упорядоченный поток байт. Цена - рукопожатие в 1 RTT, состояние на обеих сторонах и два независимых окна: rwnd защищает приёмник, cwnd защищает сеть.

Рукопожатие

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 означает только «скопировано в буфер отправки ядра». Соединение может умереть после этого, и приложение узнает об этом в лучшем случае на следующей операции. Сквозную гарантию даёт только прикладной уровень: подтверждение от самого приложения-получателя.

Суть: закрытие четырёхстороннее, потому что соединение дуплексное и каждая сторона закрывает своё направление отдельно. TIME_WAIT висит 2 × MSL (60 с в Linux) только у той стороны, что закрыла первой, и на нагруженном клиенте он выжигает эфемерные порты.

Последовательность

FIN от активной стороны (FIN_WAIT_1) → ACK от пассивной (та переходит в CLOSE_WAIT, активная - в FIN_WAIT_2) → пассивная сторона, когда её приложение тоже закроется, шлёт свой FIN (LAST_ACK) → активная подтверждает и уходит в TIME_WAIT на 2 × MSL. Между вторым и третьим пакетом соединение полузакрыто: пассивная сторона всё ещё может слать данные. Если она закрывается сразу, ACK и FIN склеиваются и пакетов получается три.

Зачем TIME_WAIT

  1. Дать последнему ACK шанс дойти. Если он потеряется, пассивная сторона по таймауту повторит FIN, и кто-то должен на него ответить. Иначе она получит RST и решит, что соединение оборвалось аварийно.
  2. Дать умереть заблудившимся сегментам. Сегмент старого соединения, застрявший в сети, не должен быть принят за данные нового соединения с той же пятёркой. 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 - утечка. На собесе разницу между этими двумя ответами видно сразу.

Суть: UDP - это IP плюс порты плюс контрольная сумма, 8 байт заголовка. Ни соединения, ни доставки, ни порядка, ни защиты от дублей, ни управления перегрузкой. Взамен - нулевая задержка на старте и сохранение границ сообщений.

Чего нет

  • Доставки: датаграмма может пропасть молча, отправитель не узнает.
  • Порядка: вторая может обогнать первую.
  • Дедупликации: сеть может размножить пакет, приложение получит два.
  • 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 выбирают не потому, что не нужна надёжность, а потому, что нужна своя надёжность».

Суть: TCP по умолчанию. UDP - когда опоздавшие данные бесполезны, когда обмен помещается в одну датаграмму, когда нужен multicast, или когда строишь свой транспорт.
СценарийЧто беруПочему
REST/gRPC API между сервисамиTCPнужен каждый байт и порядок; соединения долгоживущие, рукопожатие амортизируется
Работа с БД, очередями, S3TCPто же плюс пул соединений; потеря байта означает битые данные
Передача файлов, бэкапыTCPважна целостность, не важна задержка; congestion control сам займёт свободную полосу
DNS-запросUDP (fallback TCP)вопрос и ответ помещаются в одну датаграмму; рукопожатие ради 60 байт дороже самой работы. При TC=1 повтор по TCP; зонные трансферы AXFR - всегда TCP
NTP, DHCP, метрики StatsDUDPfire-and-forget: потерять один тик метрики дешевле, чем платить за соединение
VoIP, видеозвонок (WebRTC)UDPкадр, опоздавший на 300 мс, уже не нужен; лучше артефакт, чем пауза. Надёжность частичная, через FEC и джиттер-буфер
Быстрые игры (шутеры)UDPшлём состояние мира тиками; устаревшая позиция игрока бесполезна, ретрансмиссия только добавит лаг
Видеостриминг YouTube/NetflixTCP (HLS/DASH поверх HTTP)вопрос-ловушка: это не realtime, а загрузка сегментов в буфер на 10–30 секунд - целостность важнее задержки. Realtime-стрим (WebRTC, SRT) - уже UDP
Многопользовательская рассылка в LAN, service discovery (mDNS)UDPmulticast в TCP невозможен
HTTP/3UDPQUIC строит свою надёжность поверх UDP, чтобы обойти TCP-HOL и ускорить рукопожатие
Как аргументировать выбор, а не перечислять

Сильный ответ строится на одном критерии: полезны ли данные после задержки. Если да - TCP, повтор имеет смысл. Если нет - UDP, повтор только вредит, увеличивая задержку и занимая канал. Второй критерий касается размера обмена относительно накладных расходов: три пакета рукопожатия ради одного 60-байтного вопроса не окупаются. И сразу назови ловушку со стримингом: интервьюер часто ждёт «стриминг = UDP», а правильный ответ различает буферизованное видео (TCP) и realtime-связь (UDP).

Суть: одноимённые, но про разное. TCP keep-alive - механизм ядра, который пингует простаивающее соединение, чтобы понять, жив ли пир. HTTP keep-alive - договорённость прикладного уровня не закрывать соединение после ответа и слать по нему следующий запрос.

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.Dialer keep-alive включён по умолчанию (15 секунд) - в отличие от голого сокета в C.
Суть: первый элемент очереди задерживает всех за собой, хотя они уже готовы. В вебе это два разных явления: HOL прикладного уровня (HTTP/1.1 отвечает строго по очереди) и HOL транспортного уровня (TCP обязан отдавать байты по порядку, потеря пакета останавливает всё).

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, и порядок доставки у каждого потока свой - номера пакетов и подтверждения общие на соединение, но потерянные байты задерживают только свой поток. Потеря пакета тормозит только тот поток, чьи байты потерялись; остальные едут дальше.
Где HOL встречается за пределами HTTP

Так ведут себя любые упорядоченные очереди. В партиции 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 решают другие этапы соединения.