Сокеты в Python: как устроен обмен байтами под TCP
Сокет отдаёт recv() половину сообщения — и код на клиенте зависает в ожидании. Разбираем, почему TCP не знает границ сообщений и как строить обмен поверх потока байтов.

Клиент отправил серверу строку PING\n и ждёт PONG\n. На локальной машине всё работает. В проде под нагрузкой сервер иногда получает PIN, а через миллисекунду G\nPING\n — два запроса склеились, а первый разорвался пополам. Виноват не баг в коде, а фундаментальное свойство TCP: это поток байтов, а не поток сообщений. Разберём, что реально происходит на уровне сокета в CPython 3.12, где ломается наивный recv() и как поверх потока байтов построить обмен сообщениями.
Что такое сокет и почему recv() не то, чем кажется
Сокет — это дескриптор, через который ядро операционной системы отдаёт вам данные, пришедшие по сети, и принимает данные на отправку. В Python это объект из модуля socket стандартной библиотеки. Ключевое: TCP-сокет не хранит понятие «сообщение». Он хранит два буфера — на приём и на отправку — и работает с ними как с непрерывной лентой байтов.
Когда вы вызываете sock.send(data), вы не гарантируете, что все байты уйдут одним сегментом. Когда вызываете sock.recv(4096), вы получаете от 1 до 4096 байт — сколько ядро успело накопить в приёмном буфере к этому моменту. Это может быть половина вашего сообщения, ровно одно сообщение, полтора или три.
TCP гарантирует, что байты придут в том же порядке и без потерь. Он не гарантирует, что они придут теми же порциями, которыми вы их отправляли. Границы сообщений — целиком ваша забота.
Демонстрация проблемы
Наивный эхо-сервер, который «работает» ровно до первого стресс-теста:
# Python 3.12, наивный вариант — так делать НЕ надо
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 9000))
server.listen()
conn, addr = server.accept()
with conn:
# ОШИБКА: считаем, что одно recv() == одно сообщение
data = conn.recv(1024)
print("получено:", data)
conn.sendall(data)
Пока клиент шлёт по одному короткому сообщению и ждёт ответа — иллюзия целостности держится. Как только клиент отправит два сообщения подряд, не дожидаясь ответа, одно recv() вернёт их склейку. А большое сообщение придёт по кускам.
Как правильно читать из потока байтов
Раз границ нет, их надо ввести самим. Есть два рабочих способа разметить, где кончается одно сообщение и начинается другое.
Способ 1: разделитель
Договариваемся, что сообщения разделены байтом-разделителем, например \n. Читаем в буфер, пока не встретим разделитель. Годится для текстовых протоколов, где сами данные разделитель не содержат.
# Python 3.12
def recv_until_newline(sock, buffer=b""):
"""Возвращает одно сообщение до \n и остаток буфера."""
while b"\n" not in buffer:
chunk = sock.recv(4096)
if not chunk:
# соединение закрыто, а полного сообщения нет
return None, buffer
buffer += chunk
message, _, rest = buffer.partition(b"\n")
return message, rest
Обратите внимание на rest: если в один recv() прилетело полтора сообщения, вторую половину нельзя выбрасывать — её надо передать в следующий вызов. Именно потерю остатка чаще всего забывают новички.
Способ 2: префикс длины
В начале каждого сообщения кладём его длину фиксированным полем. Это надёжнее разделителя: данные могут содержать любые байты, включая тот, что вы выбрали разделителем. Так устроено большинство бинарных протоколов.
# Python 3.12
import struct
def recv_exactly(sock, n):
"""Читает ровно n байт или None, если соединение закрылось раньше."""
buffer = bytearray()
while len(buffer) < n:
chunk = sock.recv(n - len(buffer))
if not chunk:
return None
buffer.extend(chunk)
return bytes(buffer)
def recv_message(sock):
"""Читает 4-байтовый префикс длины, затем тело сообщения."""
header = recv_exactly(sock, 4)
if header is None:
return None
length = struct.unpack("!I", header)[0] # big-endian unsigned int
return recv_exactly(sock, length)
def send_message(sock, payload: bytes):
header = struct.pack("!I", len(payload))
sock.sendall(header + payload) # sendall дошлёт всё, не как send
Здесь два важных момента. Первый — sendall вместо send: send может отправить не все байты и вернёт число реально отправленных, а sendall крутит цикл до конца. Второй — формат !I в struct: восклицательный знак задаёт сетевой порядок байтов (big-endian), чтобы клиент и сервер на разных архитектурах поняли друг друга одинаково.
Сравнение двух способов
| Критерий | Разделитель | Префикс длины |
|---|---|---|
| Данные с произвольными байтами | Требуют экранирования | Работают как есть |
| Читаемость в логах | Высокая (текст) | Низкая (бинарь) |
| Знаем размер тела заранее | Нет, читаем до маркера | Да, из заголовка |
| Защита от гигантских сообщений | Сложно ограничить | Проверяем length до чтения тела |
| Где уместен | Текстовые протоколы, отладка | Бинарный обмен, продакшн |
Когда что выбирать
- Протокол текстовый, поверх него удобно смотреть трафик глазами, данные заведомо не содержат разделителя — берите разделитель.
- Передаёте бинарь, файлы, произвольные строки от пользователя, хотите заранее знать размер и отсекать слишком большие сообщения — берите префикс длины.
- Оба способа работают под нагрузкой одинаково: узкое место не в них, а в том, забыли ли вы про остаток буфера и про частичные
send.
Что ломается на практике
Даже с правильной рамкой сообщений остаётся несколько ловушек, на которые натыкаются регулярно.
- Пустой
recv()— это не ошибка, а сигнал. Когда удалённая сторона закрыла соединение,recv()возвращает пустойb"". Если не проверять это, цикл чтения крутится вхолостую и упирается в бесконечное ожидание. - Доверие к
lengthиз заголовка. Если клиент прислал длину в 4 гигабайта,recv_exactlyчестно попытается накопить их в память. Всегда ограничивайте максимум: еслиlengthбольше разумного порога — разрывайте соединение. - Блокирующий сокет держит поток. По умолчанию сокет блокирующий:
recv()усыпляет поток до появления данных. На одном соединении это нормально, на тысяче — нет. Тут либо потоки/процессы, либо неблокирующий режим иselectors, либоasyncio. - Забытый
SO_REUSEADDR. После перезапуска сервераbindпадает сOSError: [Errno 98] Address already in use, потому что порт ещё держится в состоянии TIME_WAIT. Опцияsetsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)доbindснимает проблему.
Куда двигаться от голых сокетов
Ручная работа с socket нужна, чтобы понимать, что происходит под капотом. В реальном сервисе на голых сокетах пишут редко.
- Много соединений в одном процессе без потоков — смотрите модуль
selectorsиз стандартной библиотеки (мультиплексирование черезepoll/kqueue) или переходите наasyncioс егоStreamReader/StreamWriter, которые уже умеют читать до разделителя и до N байт. - Обмен структурированными сообщениями между сервисами — не изобретайте свой протокол, возьмите готовый: HTTP через
http.client, gRPC, или очередь сообщений. - Мерить под нагрузкой стоит две вещи: сколько соединений держит процесс одновременно и как растёт задержка ответа при их числе. Блокирующая модель начинает деградировать заметно раньше, чем неблокирующая.
Следующий разумный шаг — переписать пример с префиксом длины на asyncio и сравнить, как оба варианта ведут себя на нескольких сотнях одновременных клиентов. Разница в задержке покажет, стоила ли ручная работа с сокетами свеч в вашем случае.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: socket — Low-level networking interface, Python docs, asyncio — Streams, Python docs
Частые вопросы
Чем send отличается от sendall?
У меня asyncio, а не блокирующие сокеты — этот код подойдёт?
Почему recv() иногда возвращает пустые байты, хотя соединение вроде живо?
Что делать с ошибкой Address already in use при перезапуске сервера?
Меняется ли поведение сокетов между версиями Python 3.x?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.