Learning Platform
Глоссарий Troubleshooting
Урок 09.01 · 18 мин
Начальный
TCPUDPReliabilityTransport Layer

Почему TCP сложный — что он гарантирует

В прошлом модуле мы изучили UDP — минималистичный транспорт без гарантий. UDP легко описать («просто шли байты в пакетах») и легко реализовать. Можно за вечер написать рабочий UDP-сервер и не упереться ни в одну стену.

С TCP всё наоборот. RFC 793 (где описан изначальный TCP) — это 85 страниц плотного текста. С тех пор добавилось десятки RFC с улучшениями: SACK, TCP fast open, timestamps, RACK, BBR, ECN. Целые книги (TCP/IP Illustrated, vol. 1 от Стивенса — 600 страниц) посвящены анатомии этого протокола. Linux-ядро содержит десятки тысяч строк кода на TCP. И всё это ради одной задачи: превратить ненадёжный пакетный канал (IP) в надёжный поток байт.

В этом уроке мы посмотрим, что именно TCP гарантирует и как эти гарантии меняют способ программирования. Это «контракт», который ты получаешь, когда выбираешь TCP вместо UDP. Все следующие уроки модуля — про как TCP это обеспечивает. Этот урок — про что.


Гарантия 1: Надёжная доставка

TCP гарантирует: если ты успешно вызвал send(data) и сокет не выдал ошибку, то data рано или поздно будет полностью доставлена получателю — или TCP попытается всеми силами и сообщит тебе об ошибке.

«Рано или поздно» здесь важно. TCP не обещает, что доставка будет мгновенной. Если пакеты теряются — TCP пересылает. Если получатель медленный — TCP замедляет отправителя. Если сеть сломана — TCP терпеливо ждёт. В крайних случаях один send() может фактически отправиться через секунды.

TCP: gaps in transit, but no gaps in data
Sender appПриложение вызвало send(b'hello world'). Считает, что данные ушли. Не знает (и не должно знать), сколько раз TCP их перешлёт, какие пакеты потеряются по дороге
send()
TCP layerРазбивает на сегменты, шлёт. Если ACK не приходит за RTO (retransmission timeout) -- шлёт повторно. Делает столько попыток, сколько настроено (Linux default: 15 попыток, ~13-30 минут до отказа)
Lost packetNetwork drop. TCP замечает по отсутствию ACK через RTO (retransmission timeout). На быстрой сети ~200 мс, на медленной -- секунды
retry
Successful retryПовторная отправка. Если ACK пришёл -- TCP знает, что данные дошли. Если опять loss -- ждёт двойной таймаут (exponential backoff)
Receiver appВидит в recv() ровно 'hello world', без duplicates, без перепутанного порядка. Приложение не знает, было ли по пути 0 retransmits или 5

Это сильное обещание. Сравните с UDP: там «send и забудь, что будет, то будет». В TCP send() это «send и моя ответственность донести». Если донести нельзя — TCP закроет соединение с ошибкой (RST или таймаут), и твой recv() вернёт 0 (EOF) или твой send() выдаст ECONNRESET.

# TCP даёт надёжность -- мы знаем, что если send() не упал,
# то данные либо дошли, либо мы получим явную ошибку
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("example.com", 80))

try:
    sock.sendall(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
    # sendall() гарантирует отправку всех байт -- иначе exception
    response = sock.recv(4096)
    # recv() либо вернёт данные, либо 0 (соединение закрыто), либо exception
except (BrokenPipeError, ConnectionResetError) as e:
    # TCP сообщил нам: что-то сломалось
    print(f"Connection failed: {e}")

В UDP таких гарантий нет: sendto() всегда «успешен», даже если пакет уйдёт в никуда.


Гарантия 2: Порядок байт сохраняется

Второе обещание TCP: байты приходят в том порядке, в котором были отправлены. Если ты сделал send(b"hello") и потом send(b"world"), на стороне получателя recv() вернёт сначала «h», потом «e», потом «l»… и так далее — последовательность букв точно такая же.

Внутри TCP пакеты могут идти в любом порядке (IP не гарантирует порядок). Но TCP их собирает и отдаёт приложению последовательно. Это делается через sequence numbers в TCP-заголовке (32 бита, считает байты, а не пакеты).

Важный нюанс: TCP сохраняет порядок байт, но не сохраняет границы сообщений. Это значит, что:

# Отправитель шлёт два логических "сообщения":
sock.send(b"hello")  # 5 байт
sock.send(b"world")  # 5 байт

# Получатель может увидеть в одном recv:
data = sock.recv(4096)
# Возможные варианты:
#   b"helloworld"        -- оба сообщения склеились
#   b"hello"            -- только первое (второе придёт позже)
#   b"hellow"           -- первое + часть второго
#   b"he"               -- только начало первого, остальное позже
# Гарантия: байты будут в правильном порядке.
# Никаких 'world' раньше 'hello'

Это часто удивляет junior’ов: «я отправил два сообщения, а получил одно/полтора». Это и есть stream-семантика TCP: байты — не пакеты, не сообщения. Если тебе нужны границы сообщений (а они почти всегда нужны), ты сам должен:

  1. Использовать разделитель. HTTP/1.1 использует \r\n\r\n как конец заголовков. SMTP использует \r\n.\r\n.
  2. Передавать длину сообщения в заголовке. HTTP/2 frames, Protocol Buffers с varint-длиной, custom binary protocols.
  3. Использовать готовый формат с фреймингом. WebSocket, HTTP, gRPC, Kafka — все имеют свой framing над TCP.

Этот «framing problem» — главная вещь, на которую junior натыкается, переходя от UDP к TCP.


Гарантия 3: Никаких дубликатов

TCP гарантирует: каждый отправленный байт будет доставлен ровно один раз. Если случился retransmit и оба пакета (оригинал и повтор) дошли, получатель использует только один. TCP видит совпадающие sequence numbers и отбрасывает дубликаты.

В UDP — никакой защиты от дубликатов нет. Если ваше приложение реализует свой retry поверх UDP и оригинал успел дойти, получатель увидит сообщение дважды. Это нужно явно обрабатывать на application-level (идемпотентность, dedupe).


Гарантия 4: Целостность данных (с оговоркой)

TCP вычисляет checksum от каждого сегмента и проверяет его на получателе. Если checksum не сошёлся — сегмент молча отбрасывается, отправитель не получает ACK и пересылает.

Оговорка: TCP checksum — это 16-битная CRC, она ловит большинство ошибок, но не все. Современные сети полагаются ещё и на checksum’ы на Ethernet (CRC32) и часто внутри файрволов. Но в редких случаях возможно «invisible corruption» — байт изменился, а checksum случайно совпал. Поэтому критичные приложения добавляют свой checksum (SHA-256 в Git, BLAKE2 в репликации баз данных).

TCP не даёт защиту от злоумышленника. Если кто-то на пути может модифицировать пакеты, он легко пересчитает checksum. Защиту от tampering даёт TLS поверх TCP, не сам TCP.


Гарантия 5: Flow control — не захлёбывание получателя

TCP реализует flow control: получатель в каждом ACK сообщает «у меня свободно столько-то байт в буфере». Это называется receive window. Отправитель не шлёт больше, чем «помещается» в этом окне.

Это критично, когда отправитель быстрый, а получатель медленный. Без flow control быстрый сервер забил бы receive buffer медленного клиента, и пакеты дропались бы. TCP автоматически замедляет отправителя.

Подробности — в уроке 4 этого модуля. Пока важно знать: с TCP не нужно беспокоиться о захлёбывании получателя — TCP сам разрулит.


Гарантия 6: Congestion control — не убийство сети

TCP реализует congestion control: алгоритмы, которые замедляют отправителя, когда сеть перегружена. Главный сигнал — потери пакетов. Если TCP замечает loss, он считает, что сеть переполнена, и снижает скорость.

Это делает TCP-приложения «дружелюбными» к сети. Если бы все слали со 100% скоростью независимо от состояния сети, интернет бы рухнул в congestion collapse (исторически 1986 — Van Jacobson описал именно эту проблему и предложил TCP congestion control). Сейчас congestion control TCP — это причина, по которой интернет вообще работает.

Подробности — урок 5. Пока запомни: TCP сам адаптируется под нагрузку. Если канал медленный, TCP не пытается шпарить со 100% скоростью.


Гарантия 7: Управляемое закрытие соединения

В TCP есть явные начало и конец. SYN — начало, FIN — нормальное закрытие, RST — аварийное закрытие. Когда TCP-соединение закрылось, оба конца знают об этом. У приложения есть возможность корректно flush’нуть оставшиеся данные перед закрытием (half-close pattern).

В UDP такого нет: «нет соединения» — нет и явного закрытия. Просто перестали приходить пакеты.


Цена этих гарантий

TCP даёт много, но платишь ты за это вполне конкретными вещами:

HTTP keep-alive и connection pooling — amortizing TCP handshake cost
Цена TCP-гарантий
Latency3-way handshake перед первым байтом -- 1 RTT. Если 100 мс RTT -- это 100 мс задержки перед первой передачей данных. Для коротких запросов (DNS) это убийственно
MemoryTCB на сервере -- несколько KB на соединение. Миллион connections = гигабайты RAM. UDP-сервер на миллион клиентов -- десятки MB
CPUACK processing, retransmission queue, congestion control state -- всё это CPU-cycles. Серверам типа CDN приходится оптимизировать TCP-стек для miллионов connections/sec
Head-of-line blockingЕсли один сегмент потерялся, TCP не отдаёт следующие приложению, пока не получит потерянный. Для HTTP/2 multiplexing это особенно больно (один lost пакет блокирует все streams)
Slow startВ начале соединения TCP шлёт медленно, постепенно ускоряясь. Это значит, что первые ~10-20 KB данных идут медленнее их полного потенциала. Для коротких HTTP-запросов это значимый overhead
Connection migrationTCP-соединение привязано к (src_ip, src_port, dst_ip, dst_port). Если сменился IP (Wi-Fi -> 4G) -- соединение рвётся. QUIC это решает, TCP -- нет

Когда TCP — правильный выбор

В подавляющем большинстве случаев — TCP. Это не преувеличение. Простое правило: если ты не знаешь точно, что нужен UDP, бери TCP. Конкретные кейсы для TCP:

  • HTTP, HTTPS — основа веба.
  • Базы данных — PostgreSQL, MySQL, MongoDB, Redis (хотя есть UDP-варианты), etcd, ClickHouse.
  • Message brokers — Kafka, RabbitMQ, NATS (в основном).
  • SSH, Git, FTP — управление серверами и передача файлов.
  • SMTP, IMAP, POP3 — почта.
  • RPC-фреймворки — gRPC (HTTP/2), Thrift, JSON-RPC.
  • Передача файлов любого размера — TCP идеален.
  • Streaming данных, где порядок важен — Kafka consumer groups, replication базы данных.

Когда нужен UDP — обсуждали в прошлом модуле. Кейсов меньше, и обычно ты знаешь сразу.

Kafka: строгий порядок и надёжность доставки — зачем TCP

Простой TCP-сервер на Python

В отличие от UDP, TCP-сервер требует больше шагов: listen(), accept(), обработка disconnect. Базовый код:

# tcp_server.py
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", 9999))
sock.listen(5)  # queue up to 5 pending connections
print("TCP server listening on :9999")

while True:
    conn, addr = sock.accept()
    print(f"New connection from {addr}")

    # Простой echo: пока клиент не отключился, читаем и шлём обратно
    try:
        while True:
            data = conn.recv(4096)
            if not data:
                # 0 байт = клиент закрыл соединение
                break
            conn.sendall(b"echo: " + data)
    finally:
        conn.close()
        print(f"Disconnected from {addr}")

Клиент:

# tcp_client.py
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("127.0.0.1", 9999))
sock.sendall(b"hello from client")
data = sock.recv(4096)
print(f"Got: {data!r}")
sock.close()

Заметь: в коде нет ни намёка на sequence numbers, ACKs, retransmits, congestion control — всё это ядро делает прозрачно. Приложение видит просто «поток байт». В этом сила TCP: сложность скрыта от приложения.


Попробуй сам

# 1. Запусти TCP-сервер и подключись к нему через nc:
nc -l 9999 &
nc 127.0.0.1 9999
# Печатай -- увидишь обмен. Это TCP-stream.
# Заметь: каждое нажатие Enter создаёт новую "строку",
# но на уровне TCP это просто новые байты в потоке

# 2. Понаблюдай за TCP-handshake через tcpdump:
sudo tcpdump -i lo0 -n 'tcp port 9999' &
nc -l 9999 &
nc 127.0.0.1 9999
# Увидишь: SYN, SYN-ACK, ACK -- это handshake
# Потом обычные сегменты данных и ACK на них

# 3. Проверь, что TCP сохраняет порядок, но не границы:
python3 << 'EOF'
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 9999))
sock.listen(1)
print("Listening...")
conn, addr = sock.accept()

# Шлём 3 куска подряд
import time
for msg in [b"A" * 100, b"B" * 100, b"C" * 100]:
    conn.sendall(msg)
    time.sleep(0.01)
conn.close()
EOF
# В другом окне:
python3 << 'EOF'
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("127.0.0.1", 9999))
data = sock.recv(50)
print(f"Got {len(data)} bytes: {data[:20]!r}...")
# Возможно получишь 50 байт 'A' -- только часть первой посылки
# или все 300 байт в одном recv -- зависит от Nagle, MTU, etc
EOF

# 4. Посмотри активные TCP-соединения твоего хоста:
# Linux:
ss -tan | head
# macOS:
netstat -an -p tcp | head

Проверка знанийKnowledge check
Junior спрашивает: 'Я делаю чат-приложение через TCP-сокеты. Шлю с клиента сообщения 'msg1', 'msg2', 'msg3'. Иногда на сервере приходит 'msg1msg2msg3' одной строкой, иногда 'msg' + '1ms' + 'g2msg3'. Это баг TCP? Как с этим работать?'
ОтветAnswer
Это не баг TCP -- это его задизайненная семантика. TCP -- это поток байт (stream), а не последовательность сообщений (datagrams). TCP сохраняет порядок байт, но НЕ сохраняет границы между send() вызовами. На стороне отправителя несколько send() могут быть склеены в один TCP-сегмент (Nagle's algorithm специально это делает для эффективности), и на стороне получателя один recv() может вернуть данные от любого количества send() -- от половины одного до нескольких целых. Это называется 'framing problem', и его всегда нужно решать в TCP-приложениях. Три стандартных подхода: 1. Delimiter (разделитель). Каждое сообщение заканчивается специальным байтом или последовательностью, которая не встречается в payload. HTTP/1.1 использует \r\n для границ строк и \r\n\r\n для конца заголовков. Хорошо для текстовых протоколов, плохо если в payload могут быть любые байты. 2. Length prefix. Каждое сообщение начинается с фиксированного числа байт, где написана длина остатка. Например, 4 байта big-endian uint32, потом столько байт payload. HTTP/2 fram-ы устроены так, gRPC, Kafka. Универсально работает с бинарными данными. 3. Готовый протокол с фреймингом. Используй HTTP, WebSocket, gRPC, или message-broker (Kafka, RabbitMQ) -- все они сами решают framing problem. Конкретный код на Python -- простейший length-prefix фрейминг: import struct def send_msg(sock, msg): sock.sendall(struct.pack('!I', len(msg)) + msg) def recv_msg(sock): raw_len = recv_exactly(sock, 4) msg_len = struct.unpack('!I', raw_len)[0] return recv_exactly(sock, msg_len) def recv_exactly(sock, n): data = b'' while len(data) < n: chunk = sock.recv(n - len(data)) if not chunk: raise EOFError data += chunk return data Это базовый паттерн любого TCP-приложения с дискретными сообщениями. UDP такой проблемы не имеет (там границы сохраняются), но TCP взамен даёт надёжность и порядок.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Какое из этих утверждений о TCP неверно?

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

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

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

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