Learning Platform
Глоссарий Troubleshooting
Урок 09.03 · 22 мин
Средний
TCPReliabilityRetransmissionSACK

Надёжность TCP — ACK, retransmission и sliding window

В прошлом уроке мы научились устанавливать TCP-соединение. Теперь — главное: как TCP делает то, что является его основной фичей. Как из ненадёжного пакетного канала (IP, который теряет пакеты, переставляет местами, может задерживать) получается надёжный поток байт без потерь и в правильном порядке.

Ответ — комбинация трёх вещей:

  1. Sequence numbers для нумерации байт.
  2. Acknowledgments (ACK) от получателя для подтверждения.
  3. Retransmission при отсутствии ACK.

Плюс умные оптимизации: sliding window (несколько сегментов «в полёте»), SACK (selective acknowledgment), RTT estimation для адаптивных таймаутов. Разберём всё по порядку.


Sequence numbers — байты, а не пакеты

Главное, что нужно понять: TCP нумерует байты, а не сегменты. Каждый байт в потоке имеет свой sequence number.

Допустим, клиент после handshake (с ISN=1000) отправил три сегмента:

Segment 1: seq=1001, data="hello"  (5 байт: байты 1001..1005)
Segment 2: seq=1006, data="world"  (5 байт: байты 1006..1010)
Segment 3: seq=1011, data="!"      (1 байт: байт 1011)

Получатель видит, что байты приходят последовательно. Если получит все три — он шлёт ACK с ack=1012 («жду следующий байт 1012»). Это и есть cumulative ACK: одно подтверждение покрывает все байты до указанного.

Cumulative ACK -- одно подтверждение для нескольких сегментов
Sender
Receiver
seq=1001 'hello' (5 bytes)seq=1006 'world' (5 bytes)seq=1011 '!' (1 byte)ACK ack=1012

Это умно: вместо одного ACK на каждый сегмент, получатель может скучковать несколько и послать один общий. На быстрой сети это значит, что соотношение data:ACK обычно 2:1 (один ACK на два сегмента — это delayed ACK).


Что значит «получатель потерял сегмент»

Допустим, второй сегмент потерялся в пути. Что видит получатель?

Segment 1: seq=1001, data="hello"   <- пришёл
Segment 2: seq=1006, data="world"   <- ПОТЕРЯН
Segment 3: seq=1011, data="!"        <- пришёл

Receiver: next expected = 1006 (после segment 1)
Receiver видит сегмент с seq=1011 -- это НЕ то, что он ждёт!

Получатель не может отдать приложению байты 1011 ('!'), потому что приложению нужны байты в правильном порядке, а 1006-1010 ещё не пришли. Поэтому получатель:

  1. Сохраняет байт 1011 в reassembly buffer (на потом).
  2. Шлёт duplicate ACK с ack=1006 — «я всё ещё жду байт 1006».

Этот ACK называется duplicate, потому что он повторяет предыдущий ACK (если первый сегмент уже подтверждался). На каждый out-of-order сегмент получатель шлёт duplicate ACK.

seq=1011 пришёл -> ACK ack=1006 (DUP)
(другие сегменты после потерянного тоже приходят out-of-order)
seq=1015 пришёл -> ACK ack=1006 (DUP)
seq=1020 пришёл -> ACK ack=1006 (DUP)

Отправитель видит подряд несколько ACK с тем же ack number — это сигнал потери. Он знает: «байт 1006 не дошёл, надо переслать».


Retransmission — два механизма

TCP пересылает потерянные сегменты двумя путями: по таймауту и по fast retransmit.

Retransmission Timeout (RTO)

Каждый отправленный сегмент имеет таймер. Если ACK не пришёл за RTO (Retransmission Timeout), отправитель пересылает сегмент. Дефолтный начальный RTO в Linux — 1 секунда (RFC 6298), но потом он адаптивно вычисляется из измеренного RTT.

Базовая формула (упрощённо):

SRTT = α * SRTT + (1-α) * измеренный_RTT       // smoothed RTT
RTTVAR = β * RTTVAR + (1-β) * |SRTT - измеренный_RTT|   // дисперсия
RTO = SRTT + 4 * RTTVAR

Идея: RTO держится «выше» среднего RTT на 4 стандартных отклонения. Это значит, что 99.9% обычных пакетов успеют ACK’нуться без false retransmit. Если ACK не пришёл — это, скорее всего, действительно потеря.

При повторной потере RTO удваивается (exponential backoff). Если первый RTO был 1 сек, следующий — 2, потом 4, 8, 16. После N попыток (Linux default: 15) TCP сдаётся и закрывает соединение с ошибкой.

Fast Retransmit

Ждать RTO — медленно. На быстрой сети RTO может быть 200 мс — это много. Поэтому TCP использует fast retransmit: если отправитель получил 3 duplicate ACK на тот же sequence number, он не ждёт RTO, а пересылает потерянный сегмент сразу.

Sender:                            Receiver:
seq=1001 -->                       <- ACK 1006 (segment 1 OK)
seq=1006 --> (LOST)                
seq=1011 -->                       <- ACK 1006 (DUP, out of order)
seq=1016 -->                       <- ACK 1006 (DUP)
seq=1021 -->                       <- ACK 1006 (DUP)
                                      ^^^ 3 dup ACKs -> trigger fast retransmit
seq=1006 (retransmit) -->          <- ACK 1026 (cumulative, всё дошло)

Это значит, что потеря восстанавливается за ~1 RTT вместо ждать RTO (несколько RTT). На быстрой сети fast retransmit спасает.


Sliding Window — почему ACK на каждый пакет = медленно

Если бы отправитель ждал ACK на каждый сегмент перед отправкой следующего, скорость была бы катастрофически низкой. На RTT 100 мс и MSS 1460 байт получится 1460 байт / 100 мс = 14.6 KB/s. На гигабитной линии.

Решение — sliding window: отправитель шлёт несколько сегментов подряд, не дожидаясь ACK. По мере получения ACK «окно» сдвигается вперёд.

Sliding window -- несколько сегментов 'в полёте'
Sent + ACKedБайты, для которых отправлены данные и получено подтверждение от получателя. Можно забыть из памяти, удалить из retransmission queue
Sent, not ACKedБайты, которые отправлены, но ACK ещё не пришёл. Они 'в полёте'. Если не придёт ACK за RTO -- пересылка. Хранятся в send buffer для возможной retransmission
Can sendБайты, которые можно отправить прямо сейчас (помещаются в window). Это размер 'окна' минус то, что уже in-flight
Can't send yetБайты приложения, которые приложение хочет отправить, но они не помещаются в текущее окно. Они ждут, пока окно не сдвинется (придёт ACK)

Размер окна определяется двумя факторами:

  1. Receive window (rwnd) — сколько получатель может принять. Заявлено в TCP-заголовке (поле Window).
  2. Congestion window (cwnd) — оценка отправителя, сколько сеть выдержит. Адаптивная, об этом — урок 5.

Реальное окно = min(rwnd, cwnd). Отправитель не шлёт больше этого размера.

Теоретическая максимальная пропускная способность TCP-соединения:

Throughput = Window / RTT

Пример: RTT 100 мс, window 64 KB -> max throughput 640 KB/s = ~5 Mbps. Чтобы насытить гигабитную линию (RTT 100 мс), нужно окно ~12.5 MB. Это и есть bandwidth-delay product, или «как много данных может физически находиться в трубе».

Поэтому Window Scaling — критичная опция: без неё максимальное окно 65535 байт (16 бит в TCP-заголовке), и на быстрых линиях с большим RTT (transcontinental) TCP не может насытить канал. С Window Scaling — окно может быть гигабайты.


Selective ACK (SACK) — что подтверждать out-of-order

Cumulative ACK имеет недостаток: если потерялся сегмент в начале большого окна, все последующие сегменты не подтверждаются (получатель шлёт duplicate ACK на потерянный). Отправитель не знает, какие именно сегменты потерялись, какие дошли — он видит только «жду 1006, всё после 1006 unknown». Если потерь несколько, отправитель может пересылать слишком много (то, что уже дошло), потеряя время.

SACK (Selective ACK, RFC 2018) решает это. Это TCP-опция, согласуемая в handshake (SACK Permitted). Получатель в ACK добавляет «диапазоны байт, которые я уже получил вне порядка».

Sender:                          Receiver:
seq=1001 (OK) -->                <- ACK 1006 (good)
seq=1006 (LOST) -->              
seq=1011 (OK) -->                
seq=1016 (LOST) -->              
seq=1021 (OK) -->                <- ACK 1006, SACK=[1011-1016, 1021-1026]
                                    ^^ DUP cumulative + SACK ranges

Отправитель видит: «байты 1011-1015 и 1021-1025 уже у получателя; пересылать нужно только 1006-1010 и 1016-1020». Это намного эффективнее на сетях с множественными потерями.

SACK поддерживается всеми современными ОС. Если ты захватываешь трафик и видишь опцию SACK в ACK-сегментах — это работает.


RTT Estimation — как TCP измеряет RTT

Чтобы вычислить разумный RTO, TCP должен знать RTT. Как он его измеряет?

Простой способ: засекаешь время отправки сегмента, ждёшь ACK, разница — RTT. Но это даёт одну точку. RTT флуктуирует, нужна усреднённая оценка.

TCP-стек поддерживает Exponentially Weighted Moving Average (EWMA):

SRTT = 0.875 * SRTT + 0.125 * latest_RTT

То есть новый замер влияет на 12.5% оценки, остальное — история. Это плавно реагирует на изменения сети.

Проблема: если сегмент был ретрансмитнут, и потом пришёл ACK, неясно — это ACK на оригинал или на retransmit? Если на оригинал, RTT огромен (плюс RTO). Если на retransmit, RTT обычный. Karn’s algorithm говорит: не использовать измерения с ретрансмиченных сегментов. RTT измеряется только по «чистым» сегментам.

С Timestamps option (TS), которая включается в handshake, эта проблема решается: каждый сегмент несёт timestamp отправки, в ACK возвращается этот же timestamp. Получив ACK, отправитель точно знает RTT — разница между текущим временем и timestamp. Это и точнее, и работает даже при ретрансмитах.

# Посмотри текущие RTT-оценки активных TCP-соединений на Linux:
ss -ti
# Output типа:
# tcp ESTAB 0 0 192.168.1.10:54321 ... rto:204 rtt:1.234/0.567 ato:40 ...
# rto -- current retransmission timeout
# rtt -- smoothed_rtt / rtt_variance

TCP Send Buffer и Retransmission Queue

Когда приложение вызывает send(data), что физически происходит:

Жизненный цикл байта в TCP отправителе
App calls send()Приложение хочет отправить N байт. Если в send buffer есть место -- copy_from_user в kernel buffer. send() возвращается успешно. Если буфер полон -- блокируется (или возвращает EAGAIN, если non-blocking)
copy
Send buffer (kernel)Буфер ядра. По дефолту ~16-256 KB (настраивается через SO_SNDBUF). Здесь данные ждут, пока TCP-стек их не отправит и не получит ACK
TCP segmentationTCP-стек берёт куски из буфера, разбивает на MSS-сегменты, добавляет заголовки, передаёт на IP-уровень для отправки
send
In-flight + retx queueСегмент 'в полёте' до получения ACK. Копия его хранится в retransmission queue на случай, если придётся переслать
ACK receivedПолучатель прислал ACK. Соответствующие байты удаляются из send buffer (release memory). Окно сдвигается, новые байты можно отправить

Важный момент: send() возвращается успешно, когда данные приняты в send buffer, а не когда дошли получателю. Это типичная ошибка junior’ов: думать, что если send() завершился, данные уже на сервере. Нет — они в kernel buffer, ждут передачи. До реальной доставки могут пройти миллисекунды или (на медленной сети) минуты.

Если приложению нужно знать, что данные дошли до приложения на стороне получателя — нужен application-level ACK. TCP даёт только «передал в сеть», не «обработано приложением».

Kafka producer acks — application-level подтверждение поверх TCP

Setting и tuning буферов

Размеры send/receive buffer влияют на максимальный throughput. Если буфер меньше bandwidth-delay product, TCP не может насытить канал:

# Linux: текущие параметры
sysctl net.ipv4.tcp_rmem
# net.ipv4.tcp_rmem = 4096    87380   6291456
# min  default  max
sysctl net.ipv4.tcp_wmem
# net.ipv4.tcp_wmem = 4096    16384   4194304

# Для high-bandwidth серверов часто увеличивают max:
# sudo sysctl -w net.ipv4.tcp_rmem='4096 87380 16777216'
# sudo sysctl -w net.ipv4.tcp_wmem='4096 65536 16777216'

# В Python:
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 1024 * 1024)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024 * 1024)
# 1 MB send/receive buffers

Linux по дефолту имеет auto-tuning — буфер растёт по мере необходимости до максимума. Это обычно работает хорошо, но в специфических кейсах (например, для очень-очень больших RTT — спутник) может потребоваться явная настройка.


Попробуй сам

# 1. Запиши трафик HTTP-скачивания и посмотри ACK pattern:
sudo tcpdump -i any -n -nn 'tcp port 80 and host example.com' &
curl -s http://example.com > /dev/null
# Ты увидишь чередование сегментов и ACK. Заметь cumulative ACKs

# 2. Симулируй потери и посмотри retransmits в Wireshark:
# Linux:
sudo tc qdisc add dev lo root netem loss 5%
# Запусти HTTP-сервер:
python3 -m http.server 8080 &
# В другом окне захвати:
sudo tcpdump -i lo -n -nn -S 'tcp port 8080' &
curl http://127.0.0.1:8080 > /dev/null
# Будут видны retransmits (TCP помечает их специально в Wireshark)

# Убрать:
sudo tc qdisc del dev lo root

# 3. Посмотри статистику TCP retransmits на сервере:
# Linux:
nstat -az TcpRetransSegs TcpInSegs TcpOutSegs | head
# Или (более подробно):
cat /proc/net/snmp | grep Tcp:

# 4. Замерь TCP throughput с большим RTT:
# (нужны два хоста)
iperf3 -c remote-server
# Покажет actual throughput. Если << bandwidth канала, возможно не хватает window scaling

# 5. Посмотри текущий RTT estimate для активных connections (Linux):
ss -ti | grep -A 1 ESTAB | head -20
# rtt:X/Y где X=SRTT, Y=RTT variance

Проверка знанийKnowledge check
Junior спрашивает: 'Я скачиваю файл через TCP с очень быстрого сервера, но скорость 'упирается' в 5 Mbps, хотя канал гигабитный. Где смотреть проблему?'
ОтветAnswer
Это классический симптом ограничения окном (window-limited throughput). Throughput TCP-соединения = Window / RTT. Если ты получаешь 5 Mbps, и RTT, скажем, 100 мс, то твоё эффективное окно = 5 Mbps * 100 мс = ~65 KB. Это подозрительно близко к 64 KB -- историческому лимиту TCP-окна без Window Scaling. Где смотреть: 1. RTT до сервера. Сначала измерь его -- ping или curl --connect-timeout. Если RTT = 100 мс, а канал гигабитный, тебе нужно окно ~12.5 MB чтобы насытить канал. Если канал 100 Mbps и RTT 50 мс, нужно ~625 KB. 2. Window Scaling включён? Захвати handshake в Wireshark или tcpdump -v. В SYN и SYN-ACK должна быть опция Window Scale. Если её нет (одна из сторон не поддерживает или middle box её срезал) -- окно ограничено 64 KB. 3. Размер receive buffer на стороне клиента. Receive window не может быть больше receive buffer. На Linux: cat /proc/sys/net/ipv4/tcp_rmem # min default max -- три числа Если max маленький (например, 87 KB), даже с Window Scaling реальное окно не превысит этого. Часто на серверах увеличивают: sudo sysctl -w net.ipv4.tcp_rmem='4096 87380 16777216' 4. Размер send buffer на стороне сервера. То же самое для отправителя -- net.ipv4.tcp_wmem. 5. CPU/память клиента. Если клиент не успевает читать (медленный disk write для curl > file, или однопоточный recv), его receive buffer заполняется, advertised window падает, throughput падает. Проверь через ss -ti -- Send-Q, Recv-Q. 6. Промежуточные потери. Если по пути есть 0.5% потерь, TCP с traditional congestion control (Cubic) теряет в throughput сильно -- этот случай известен как 'long fat networks' problem. Linux умеет переключиться на BBR (echo bbr > /proc/sys/net/ipv4/tcp_congestion_control), который менее loss-чувствителен. Что я бы делал в такой ситуации: ss -ti dst <server-ip> # смотреть rwnd, cwnd, retrans, rtt -- это даст основную картину # а потом включить более продвинутый congestion control (BBR) если есть потери echo bbr | sudo tee /proc/sys/net/ipv4/tcp_congestion_control В 95% случаев это проблема буферов или Window Scaling, либо физическая потеря пакетов на пути.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Что нумерует TCP — пакеты или байты?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5