Почему 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() может фактически отправиться через секунды.
Это сильное обещание. Сравните с 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: байты — не пакеты, не сообщения. Если тебе нужны границы сообщений (а они почти всегда нужны), ты сам должен:
- Использовать разделитель. HTTP/1.1 использует
\r\n\r\nкак конец заголовков. SMTP использует\r\n.\r\n. - Передавать длину сообщения в заголовке. HTTP/2 frames, Protocol Buffers с varint-длиной, custom binary protocols.
- Использовать готовый формат с фреймингом. 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 — правильный выбор
В подавляющем большинстве случаев — 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