Learning Platform
Глоссарий Troubleshooting
Урок 09.04 · 18 мин
Средний
TCPFlow ControlReceive WindowNetworking

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». Отправитель видит это число и не шлёт больше.

Flow control -- receive window сжимается и расширяется
Sender
Receiver
Data (10 KB)ACK, win=54 KBData (40 KB)ACK, win=14 KBData (14 KB)ACK, win=0

Это работает прозрачно для приложений. Отправитель не знает, что приложение получателя медленное — TCP-стек сам распознаёт ситуацию и тормозится. Получатель не знает, что отправитель замедлился — он просто видит, что данных приходит меньше.


Кто решает размер receive window?

Receive window — это минимум из двух вещей:

  1. Свободное место в receive buffer ядра. Если buffer 64 KB, и 20 KB уже занято, advertised window = 44 KB.
  2. 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 -- выход из тупика
Sender
Receiver
ACK, win=0Zero Window Probe (1 byte)ACK, win=0Probe (через 2x времени)ACK, win=64 KB

Без 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) — глупый синдром окна. Возникает, когда:

  1. Приложение получателя читает данные очень маленькими порциями (например, по одному байту через getchar).
  2. После каждого чтения receive buffer освобождается на 1 байт, и получатель шлёт ACK с window=1.
  3. Отправитель видит «можно послать 1 байт» и шлёт 1-байтовый сегмент.
  4. Получатель ACK’ает его, открывает окно на 1 байт снова.
  5. Повторяем бесконечно.

Это катастрофически неэффективно: на каждый байт данных приходится 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.

Что происходит:

  1. Где-то на пути (роутер, кабельный модем, switch) стоит большой буфер.
  2. Когда канал перегружен, пакеты не дропаются — они копятся в буфере.
  3. Это создаёт огромную задержку, но не loss.
  4. TCP не видит loss -> не замедляется -> продолжает шпарить -> буфер накапливает ещё больше.
  5. 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

Проверка знанийKnowledge check
Junior спрашивает: 'Мой Python-сервис через TCP получает данные от внешнего источника. В нагрузке я вижу, что отправитель замедляется, но мой сервис не загружен на 100%. Логи говорят 'TCP socket буфер растёт'. Что происходит и как чинить?'
ОтветAnswer
Это классическая проблема consumer-side bottleneck. Несмотря на то, что CPU не на 100%, что-то в твоём сервисе мешает быстро читать из TCP-сокета. Отправитель видит, что receive window сжимается, и сам замедляется через flow control. Где смотреть: 1. Скорость чтения из сокета. Если ты вызываешь recv() в loop, проверь, что между recv() вызовами нет блокирующих операций. Типичные ловушки: while True: data = sock.recv(4096) process_data(data) # медленно? пишет на диск, ходит в БД, делает CPU-heavy работу? log_data(data) # синхронный лог тоже может тормозить Между recv() и следующим recv() весь process+log должен укладываться, иначе TCP буфер растёт. Решение -- отдельный поток или async для recv(), а обработка через queue. 2. Размер receive buffer. Если буфер маленький, даже короткие 'паузы' в чтении переполняют его. На современных Linux обычно автотюнинг, но проверь: ss -tn dst <peer-ip> | grep -A 2 . # rcv_space, rcv_ssthresh, rcvmem Можно поднять SO_RCVBUF в коде: sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024) # 4 MB 3. GIL (если многопоточный Python). Если у тебя несколько потоков что-то процессят, recv-поток может не получать quantum достаточно часто. Перейди на asyncio или multiprocessing. 4. blocked recv. Если ты используешь блокирующий recv, проверь, не делаешь ли в одном thread тяжёлую работу. Использование asyncio.StreamReader позволяет читать в event loop. 5. Disk I/O. Если data сразу пишется на диск (логи, файлы), и диск медленный, write блокирует read. Лечится async writes или batch'ингом. 6. Application-level bottleneck. Например, ты пишешь в очередь, а очередь backed by Redis, и Redis медленный -- recv() ждёт места в очереди. Это распространяется обратно. Диагностика по шагам: # (1) Смотри размер queue в твоей программе. Если она растёт -- bottleneck downstream # (2) Профилируй recv-loop: # time it между recv() вызовами # (3) Смотри ss -ti -- Recv-Q (это количество байт в receive buffer) # Если Recv-Q растёт постоянно -- твоё приложение читает медленнее, чем приходит # (4) Параметры receive buffer -- увеличь, но это лечит симптом, не причину Главный принцип: receive() не должно блокироваться на чём-либо, что зависит от внешних систем. Делай read как можно быстрее, кидай в in-memory очередь, обрабатывай в других worker'ах. Тогда TCP flow control не будет задерживать sender -- а тебе сервис останется отзывчивым.

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

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

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

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

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

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