Flow control в TCP — receive window и zero window probe
В предыдущих уроках мы обсудили, как TCP передаёт данные надёжно: sequence numbers, ACKs, retransmits, sliding window. Но есть отдельная проблема, которая не решается этими механизмами: что делать, если получатель не успевает обрабатывать данные?
Представь: быстрый сервер шлёт данные медленному клиенту. TCP-стек получателя складывает входящие сегменты в receive buffer. Приложение клиента читает медленно (например, из-за бизнес-логики или нагрузки на CPU). Буфер заполняется. Если бы не было flow control, отправитель продолжал бы шпарить, новые данные приходили бы в переполненный буфер, и они либо дропались бы (как в UDP), либо TCP пришлось бы их буферизовать в самом стеке без ограничений.
TCP решает это через flow control — механизм, при котором получатель в каждом ACK сообщает «у меня свободно столько-то места в буфере». Это receive window (rwnd). Отправитель не шлёт больше этого размера.
В этом уроке разберём, как это работает, что такое «zero window», как TCP справляется с медленным получателем, и какие подводные камни.
Receive window — поле в TCP-заголовке
В TCP-заголовке есть 16-битное поле Window. Получатель в каждом ACK кладёт туда «сколько ещё байт я могу принять прямо сейчас, не переполнив свой receive buffer». Отправитель видит это число и не шлёт больше.
Это работает прозрачно для приложений. Отправитель не знает, что приложение получателя медленное — TCP-стек сам распознаёт ситуацию и тормозится. Получатель не знает, что отправитель замедлился — он просто видит, что данных приходит меньше.
Кто решает размер receive window?
Receive window — это минимум из двух вещей:
- Свободное место в receive buffer ядра. Если buffer 64 KB, и 20 KB уже занято, advertised window = 44 KB.
- Maximum receive buffer (
SO_RCVBUF). По дефолту в Linux — 87 KB, max — 16 MB при auto-tuning.
Приложение влияет на это:
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Запросить буфер 1 MB:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024 * 1024)
# Узнать, что фактически дало ядро:
actual = sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF)
print(f"Actual receive buffer: {actual} bytes")
# Linux обычно удваивает запрошенное значение (для overhead),
# и применяет sysctl net.core.rmem_max лимит
Размер receive buffer прямо влияет на максимальный throughput:
Throughput <= Window / RTT
С receive buffer 64 KB и RTT 100 мс ты не получишь больше 640 KB/s даже на гигабитной линии. На гигабитной линии с RTT 100 мс нужен буфер ~12.5 MB. Поэтому на high-throughput серверах буферы увеличивают:
# Linux: текущие настройки
cat /proc/sys/net/ipv4/tcp_rmem
# 4096 131072 6291456
# min default max
# Увеличить max до 16 MB:
sudo sysctl -w net.ipv4.tcp_rmem='4096 131072 16777216'
# Также проверь net.core.rmem_max (это hard limit):
cat /proc/sys/net/core/rmem_max
Window Scaling — больше 64 KB
TCP-заголовок отводит 16 бит на Window. Это даёт максимум 65535 байт. Но bandwidth-delay product современных сетей сильно больше — гигабит на 100 мс RTT = 12.5 MB. Как же быть?
Решение — Window Scaling Option (RFC 7323). В handshake (SYN и SYN-ACK) каждая сторона объявляет shift count S (0..14). Реальное окно = (value in header) << S. С S=14 максимальное окно = 65535 * 2^14 = ~1 GB.
# Посмотри Window Scale в handshake:
sudo tcpdump -i any -n -nn -v 'tcp[13]&2!=0' -c 2
# Это фильтр на SYN-пакеты. -v показывает опции
# Output: ..options [wscale 7,...]
# Значит multiplier 2^7 = 128
Если обе стороны не поддерживают Window Scaling, то фолбэк к 64 KB. На современных ОС всегда поддерживается. Иногда middleboxes (старые файрволы, NAT) вырезают опцию из SYN — это создаёт «invisible» проблему производительности.
Zero Window — что делать, если буфер забит
Если приложение получателя совсем не читает (например, заблокировалось), receive buffer заполняется до конца. Получатель вынужден объявить window=0. Отправитель не может слать ни байта данных.
Но как отправитель узнает, когда окно снова откроется? Получатель должен послать новый ACK с большим окном, когда буфер освободится. Но что если этот ACK потеряется? Тупик — обе стороны ждут друг друга.
Решение — Zero Window Probe. Когда отправитель видит window=0, он стартует таймер. Через какое-то время (начинается с RTO, удваивается) он шлёт «пробный» сегмент — 1 байт данных. Получатель должен ответить ACK. Если окно всё ещё 0, ответ window=0. Если окно открылось — window>0, и отправитель возобновляет передачу.
Без zero window probe TCP в редких случаях мог бы зависнуть навсегда: получатель «застрял», ACK с открытым окном потерялся, отправитель ждёт вечно. Probe гарантирует, что отправитель регулярно проверяет состояние.
# В Wireshark zero-window probe легко найти по фильтру:
# tcp.window_size == 0
# tcp.flags.fin == 0 and tcp.flags.syn == 0 and tcp.len > 0
# или просто:
# tcp.analysis.zero_window_probe
# Часто причина -- медленный consumer:
# приложение получателя зависло, либо CPU перегружен, либо
# слишком медленно вызывает recv()
Silly Window Syndrome
Есть классическая проблема, которая в RFC 813 названа Silly Window Syndrome (SWS) — глупый синдром окна. Возникает, когда:
- Приложение получателя читает данные очень маленькими порциями (например, по одному байту через getchar).
- После каждого чтения receive buffer освобождается на 1 байт, и получатель шлёт ACK с window=1.
- Отправитель видит «можно послать 1 байт» и шлёт 1-байтовый сегмент.
- Получатель ACK’ает его, открывает окно на 1 байт снова.
- Повторяем бесконечно.
Это катастрофически неэффективно: на каждый байт данных приходится 40 байт заголовков TCP+IP. Throughput падает в 40 раз.
Решение — Window Update Throttling на стороне получателя: получатель не объявляет новое окно, пока оно меньше min(MSS, half_buffer). Лучше ждать, накопить место — и сразу объявить большое окно. Это даёт большие сегменты от отправителя.
Аналогично на стороне отправителя — Nagle’s algorithm (см. ниже) собирает мелкие записи в один сегмент.
Nagle’s Algorithm — собираем мелкие записи
Похожая проблема на стороне отправителя: если приложение шлёт по одному байту через много вызовов send(1 byte), каждый раз идёт TCP-сегмент с 40 байтами overhead и 1 байтом данных. Throughput ужасен.
Nagle’s algorithm (RFC 896): отправитель не шлёт «маленький» сегмент сразу, а ждёт пока:
- Накопится full MSS байт, ИЛИ
- Придёт ACK на предыдущий сегмент (значит, ничего «в полёте» нет).
Это значит, что мелкие write-ы накапливаются и отправляются «батчем». Throughput становится разумным.
Минус Nagle: задержка. Если приложение шлёт интерактивный input (например, символы по нажатию клавиш в SSH), пользователь ждёт RTT между нажатием и эффектом. Поэтому для интерактивных протоколов Nagle отключают:
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# TCP_NODELAY = 1 -- отключить Nagle
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
# Теперь send(1 byte) сразу отправится, без ожидания
TCP_NODELAY критически важен для:
- Interactive протоколов: SSH, telnet, gaming.
- Latency-sensitive RPC: gRPC, Thrift, MQTT.
- Web сервисов с маленькими ответами.
Однако его не нужно включать для bulk transfer (curl, wget, file streaming) — там Nagle помогает.
Delayed ACK — оборотная сторона
Получатель тоже не хочет слать ACK на каждый сегмент. Это даёт delayed ACK: получатель ждёт до 200 мс (или другого сегмента, который тоже надо ACK’ать), и шлёт один ACK на несколько сегментов. Это снижает количество ACK-пакетов вдвое-втрое.
Проблема: если Nagle включён у отправителя И delayed ACK у получателя, они могут «зависать» друг на друге:
- Отправитель: «у меня неполный сегмент, жду ACK на предыдущий чтобы отправить»
- Получатель: «жду ещё сегмент, тогда пошлю ACK»
- Оба ждут друг друга 200 мс, потом получатель тайматит и шлёт ACK.
Это так и называется — Nagle + Delayed ACK interaction, исторический баг. Решение — TCP_NODELAY если приложение шлёт interactive данные.
Buffer bloat — почему большие буферы тоже плохо
Может показаться: «давайте сделаем receive buffer гигантским, и не будет проблем с медленным приложением». На самом деле слишком большие буферы создают свою проблему — bufferbloat.
Что происходит:
- Где-то на пути (роутер, кабельный модем, switch) стоит большой буфер.
- Когда канал перегружен, пакеты не дропаются — они копятся в буфере.
- Это создаёт огромную задержку, но не loss.
- TCP не видит loss -> не замедляется -> продолжает шпарить -> буфер накапливает ещё больше.
- RTT вырастает до секунд. Интерактивные приложения становятся неюзабельными.
Bufferbloat — это «преступление» большинства домашних роутеров до ~2015 года. Сейчас Linux и многие роутеры используют CoDel (Controlled Delay) или fq_codel для умного дропа пакетов, не позволяя буферам расти бесконечно.
# Симптомы bufferbloat легко проверить:
# Открой ping в одном окне:
ping -c 100 8.8.8.8 &
# В другом -- параллельно скачивай большой файл:
curl -o /dev/null http://speedtest.io/largefile
# Если RTT в ping растёт со 30 мс до 500 мс -- это bufferbloat
# на пути (либо в твоём роутере, либо у провайдера)
Решение на стороне сервера — использовать current congestion control (BBR в Linux), который старается не наполнять буферы. Это снова к balance между TCP buffer size, congestion control, и нагрузкой.
Буферы ядра и управление памятью в ОСПопробуй сам
# 1. Посмотри Window-related параметры активных соединений:
ss -i | head -20
# rcv_space -- текущий receive window
# wnd_clamp -- максимум receive buffer
# 2. Запусти медленного получателя и быстрого отправителя:
# Терминал 1 (медленный получатель -- читает 10 байт раз в секунду):
python3 << 'EOF'
import socket, time
sock = socket.socket()
sock.bind(("127.0.0.1", 9999))
sock.listen(1)
conn, _ = sock.accept()
while True:
data = conn.recv(10)
if not data: break
print(f"Got {len(data)} bytes")
time.sleep(1)
EOF
# Терминал 2 (быстрый отправитель):
python3 -c "
import socket
sock = socket.socket()
sock.connect(('127.0.0.1', 9999))
for i in range(1000):
sock.send(b'X' * 1024)
print('Sender done')
"
# Терминал 3 -- захвати трафик:
sudo tcpdump -i lo -n 'tcp port 9999' -v
# Ты увидишь, как window уменьшается до 0,
# потом zero-window probes, потом окно открывается порциями
# 3. Сравни с TCP_NODELAY (Nagle off):
# Запусти ping и timeit для маленьких write:
python3 -c "
import socket, time
sock = socket.socket()
sock.connect(('127.0.0.1', 9999))
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # Disable Nagle
start = time.time()
for i in range(100):
sock.send(b'x')
elapsed = time.time() - start
print(f'NODELAY: {elapsed:.3f}s')
"
# 4. Посмотри bufferbloat:
ping -c 10 8.8.8.8 &
curl -o /dev/null https://speed.cloudflare.com/__down?bytes=100000000
wait
# Сравни RTT во время скачивания с idle