Learning Platform
Глоссарий Troubleshooting
Урок 13.02 · 22 мин
Средний
TCPPythonServerClientSockets

TCP-сервер и клиент на Python — echo шаг за шагом

В прошлом уроке мы обсудили sockets API концептуально. Сейчас напишем работающий TCP-сервер и клиент. Echo-сервер — классический пример: что клиент пришлёт, то сервер вернёт обратно. Простая логика помогает сфокусироваться на сетевой части, не отвлекаясь на бизнес.

Цель — не только написать работающий код, а понять КАЖДУЮ строку. После этого урока вы сможете писать собственные TCP-сервисы и debug’ить чужие.


Минимальный echo-сервер

Начнём с самого простого. Сервер на 25 строк, который слушает порт, принимает один клиент за раз, эхает каждое полученное сообщение:

# tcp_echo_server.py
import socket

HOST = '0.0.0.0'  # слушать на всех интерфейсах
PORT = 9999

# 1. Создать TCP-сокет
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# 2. Разрешить bind после crash (TIME_WAIT)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

# 3. Привязать к адресу
server_sock.bind((HOST, PORT))

# 4. Перейти в режим прослушивания, backlog=5
server_sock.listen(5)
print(f"Listening on {HOST}:{PORT}")

# 5. Основной цикл -- accept и обработка
while True:
    client_sock, client_addr = server_sock.accept()
    print(f"New client: {client_addr}")

    try:
        while True:
            data = client_sock.recv(4096)
            if not data:
                print(f"Client {client_addr} disconnected")
                break
            print(f"Received {len(data)} bytes from {client_addr}: {data!r}")
            client_sock.sendall(data)  # echo
    except ConnectionResetError:
        print(f"Client {client_addr} reset connection")
    finally:
        client_sock.close()

Разберём по строкам:

Lifecycle серверного сокета
socket()Создаём TCP-сокет. AF_INET = IPv4, SOCK_STREAM = TCP. Возвращает file descriptor, в Python обёрнут в socket объект
setsockopt(SO_REUSEADDR)Разрешить bind на TIME_WAIT адреса. Без этого после перезапуска сервера получим Address already in use в течение минуты
bind('0.0.0.0', 9999)Привязать к local address. 0.0.0.0 -- все интерфейсы (eth0, lo, ...). Конкретный IP -- только на этом интерфейсе
listen(5)Перейти в LISTEN state. Backlog 5 -- максимум 5 completed connections в очереди до accept
accept()Блокируется, пока клиент не подключится. Возвращает новый socket для клиента + (ip, port) клиента
recv/sendall loopЧитаем сообщения от клиента, эхаем обратно. Когда recv вернёт b'' (FIN от клиента), выходим из loop
client_sock.close()Закрываем соединение с этим клиентом. server_sock продолжает слушать -- следующий accept даст следующего клиента

Теперь клиент.


Простой клиент

# tcp_echo_client.py
import socket

HOST = '127.0.0.1'  # localhost
PORT = 9999

# 1. Создать сокет
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# 2. Подключиться (3-way handshake)
sock.connect((HOST, PORT))

# 3. Отправить сообщение
message = b'Hello, server!'
sock.sendall(message)
print(f"Sent: {message!r}")

# 4. Получить эхо
response = sock.recv(4096)
print(f"Received: {response!r}")

# 5. Закрыть
sock.close()

Запуск:

# Терминал 1
python3 tcp_echo_server.py
# Output: Listening on 0.0.0.0:9999

# Терминал 2
python3 tcp_echo_client.py
# Output:
# Sent: b'Hello, server!'
# Received: b'Hello, server!'

# Сервер видит:
# New client: ('127.0.0.1', 51234)
# Received 14 bytes from ('127.0.0.1', 51234): b'Hello, server!'
# Client ('127.0.0.1', 51234) disconnected

Работает. Это и есть базовый TCP клиент-сервер.


Что если клиент шлёт несколько сообщений

Слегка усложним клиента:

# tcp_chatty_client.py
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('127.0.0.1', 9999))

for i in range(5):
    message = f"Message {i}".encode()
    sock.sendall(message)
    response = sock.recv(4096)
    print(f"Sent: {message!r}, got: {response!r}")

sock.close()

Ожидаемое поведение: пять сообщений отправлено, пять ответов получено.

В реальности результат может быть таким:

Sent: b'Message 0', got: b'Message 0'
Sent: b'Message 1', got: b'Message 1Message 2'  # !
Sent: b'Message 2', got: b'Message 3Message 4'  # !!!
Sent: b'Message 3', got: b''                     # !!!
Sent: b'Message 4', got: b''

Что произошло? TCP не имеет message boundaries. Когда клиент быстро шлёт Message 0, Message 1, Message 2, ядро их склеивает в один TCP-сегмент. Сервер делает один recv(4096), получает все три сразу, эхает обратно одной операцией. Клиент с одного recv получает Message 1Message 2.

Это классический баг TCP-программирования. Решение — framing protocol: явно обозначать границы сообщений.


Framing через newline-delimiter

Простой способ — разделять сообщения переводом строки:

# tcp_line_server.py
import socket

server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind(('0.0.0.0', 9999))
server_sock.listen(5)

while True:
    client_sock, client_addr = server_sock.accept()
    print(f"New client: {client_addr}")

    # Используем makefile -- buffered I/O с готовым readline()
    rfile = client_sock.makefile('rb')

    try:
        for line in rfile:
            line = line.rstrip(b'\n')
            print(f"Received: {line!r}")
            client_sock.sendall(line + b'\n')
    finally:
        client_sock.close()
        rfile.close()

Ключевая идея: makefile('rb') оборачивает socket в file-like object с буферизацией. Когда читаешь for line in rfile, Python делает буферированные recv и разбивает на строки по \n. Это идиоматичный Python способ работать с line-based протоколами.

Клиент:

import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('127.0.0.1', 9999))
rfile = sock.makefile('rb')

for i in range(5):
    sock.sendall(f"Message {i}\n".encode())
    response = rfile.readline().rstrip(b'\n')
    print(f"Got: {response!r}")

sock.close()
rfile.close()

Теперь работает корректно — по строкам.


Framing через length-prefix

Альтернатива: каждое сообщение начинается с 4 байт длины (binary, big-endian), потом тело:

# tcp_lenprefix_server.py
import socket
import struct

def recv_exactly(sock, n):
    """Прочитать ровно n байт из сокета."""
    data = b''
    while len(data) < n:
        chunk = sock.recv(n - len(data))
        if not chunk:
            raise ConnectionError("Connection closed prematurely")
        data += chunk
    return data

server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind(('0.0.0.0', 9999))
server_sock.listen(5)

while True:
    client_sock, client_addr = server_sock.accept()

    try:
        while True:
            # Читаем длину (4 байта)
            length_bytes = recv_exactly(client_sock, 4)
            length = struct.unpack('!I', length_bytes)[0]

            # Sanity check -- защита от ОOM-атак
            if length > 10 * 1024 * 1024:
                print(f"Message too large: {length}")
                break

            # Читаем тело
            body = recv_exactly(client_sock, length)
            print(f"Received {length}-byte message")

            # Эхо: длина + тело
            client_sock.sendall(struct.pack('!I', length) + body)
    except (ConnectionError, ConnectionResetError):
        print(f"Client {client_addr} disconnected")
    finally:
        client_sock.close()

Клиент:

import socket
import struct

def recv_exactly(sock, n):
    data = b''
    while len(data) < n:
        chunk = sock.recv(n - len(data))
        if not chunk:
            raise ConnectionError("Connection closed")
        data += chunk
    return data

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('127.0.0.1', 9999))

for i in range(5):
    message = f"Message {i}".encode()
    # Отправляем 4 байта длины + тело
    sock.sendall(struct.pack('!I', len(message)) + message)

    # Читаем ответ -- сначала длина, потом тело
    length = struct.unpack('!I', recv_exactly(sock, 4))[0]
    body = recv_exactly(sock, length)
    print(f"Sent {message!r}, got {body!r}")

sock.close()

Length-prefix framing — стандарт для бинарных протоколов (gRPC, Cap’n Proto, Redis RESP). Он эффективнее delimiter (не надо сканировать на символ), позволяет переносить любые данные включая бинарные с переводами строк.

WARNING

Sanity check на length critically важен. Без него атакующий шлёт length=0xFFFFFFFF (4ГБ), сервер пытается аллоцировать 4ГБ для будущего body и убивается OOM. В production всегда есть верхний bound на размер сообщения.


Обработка disconnects и errors

Реальный TCP-код должен правильно обрабатывать ситуации:

  • Клиент закрыл соединение нормальноrecv возвращает b''.
  • Клиент crash/закрылся резкоrecv кидает ConnectionResetError.
  • Сеть пропалаrecv зависает (нужен timeout) или ConnectionError.
  • Клиент шлёт мусор — наш парсер должен это обработать без crash сервера.

Минимальный robust handler:

import socket
import struct

def handle_client(client_sock, client_addr):
    print(f"Handling client {client_addr}")
    client_sock.settimeout(60.0)  # таймаут на recv

    try:
        while True:
            try:
                length_bytes = recv_exactly(client_sock, 4)
            except ConnectionError:
                break  # клиент disconnected normally

            length = struct.unpack('!I', length_bytes)[0]
            if length > 1024 * 1024:
                print(f"Invalid length {length} from {client_addr}")
                break

            try:
                body = recv_exactly(client_sock, length)
            except ConnectionError:
                print(f"Incomplete message from {client_addr}")
                break

            # Обработать body, ответить
            response = process(body)
            client_sock.sendall(struct.pack('!I', len(response)) + response)

    except socket.timeout:
        print(f"Timeout from {client_addr}")
    except Exception as e:
        print(f"Error handling {client_addr}: {e}")
    finally:
        client_sock.close()
        print(f"Closed {client_addr}")

Pattern: один try/except/finally оборачивает весь сеанс с клиентом. Любая ошибка ведёт к close и continue — сервер не падает из-за одного плохого клиента.


Проблема одновременных клиентов

Серверы выше — sequential. Один клиент обрабатывается в момент времени. Если клиент медленный (slow read, медленный network) — все остальные ждут.

Простейший fix — thread per client:

import socket
import threading

def handle_client(client_sock, client_addr):
    try:
        while True:
            data = client_sock.recv(4096)
            if not data:
                break
            client_sock.sendall(data)
    finally:
        client_sock.close()

server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind(('0.0.0.0', 9999))
server_sock.listen(128)
print("Listening on 0.0.0.0:9999")

while True:
    client_sock, client_addr = server_sock.accept()
    thread = threading.Thread(
        target=handle_client,
        args=(client_sock, client_addr),
        daemon=True
    )
    thread.start()

Каждый клиент — свой thread. Один медленный не блокирует других. Минусы: thread expensive (1-2 МБ stack каждый). 10000 одновременных клиентов = 10-20 ГБ только на stacks. Не масштабируется.

Лучшие модели (async, epoll) — в уроке 4.


Реальный пример: тестируем через telnet и nc

telnet и nc (netcat) — быстрые TCP-клиенты для тестирования.

# В одном терминале -- запустить сервер
python3 tcp_echo_server.py

# В другом терминале -- использовать nc как клиент
nc localhost 9999
# Просто пишите что-нибудь, Enter, сервер ehoaeт обратно
# Ctrl-D -- закрыть соединение

# Или через telnet
telnet localhost 9999
# Аналогично

Это очень удобно для тестирования: не надо писать клиента, просто nc.


Попробуй сам

# 1. Сохранить server из этого урока в tcp_echo_server.py и запустить
# 2. Подключиться через nc и потестить
echo 'hello' | nc localhost 9999

# 3. Несколько клиентов одновременно (на sequential server)
# Терминал 1: запустить server
# Терминал 2:
nc localhost 9999
# Не отправляйте ничего, держите connection
# Терминал 3:
echo 'hello' | nc localhost 9999
# Зависнет -- ждёт, пока первый клиент disconnect

# 4. Завершите первый nc (Ctrl-D), тогда третий проскочит
# Это иллюстрирует sequential server problem

# 5. Запустите thread-based server (с threading)
# Теперь оба nc одновременно работают -- каждый в своём thread

# 6. Эксперимент: что если послать > 4096 байт за раз
# Терминал 1: запустить sequential echo server (recv(4096))
# Терминал 2:
python3 -c "
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('localhost', 9999))
data = b'A' * 100000  # 100 KB
s.sendall(data)
received = b''
while len(received) < len(data):
    chunk = s.recv(4096)
    if not chunk:
        break
    received += chunk
print(f'Sent {len(data)}, received {len(received)}')
s.close()
"
# Видим, что хотя один sendall на 100 KB, на стороне сервера много recv(4096)

# 7. tcpdump -- посмотреть TCP-пакеты сервера
sudo tcpdump -i lo -nn 'port 9999' &
# Затем потестите свой клиент -- увидите SYN, SYN-ACK, ACK, data, FIN
# kill %1 -- остановить tcpdump

Что вы должны вынести

  1. TCP-сервер: socket -> setsockopt(REUSEADDR) -> bind -> listen -> accept loop -> recv/send.
  2. TCP-клиент: socket -> connect -> send/recv -> close.
  3. recv НЕ гарантирует размер — может вернуть любое количество от 1 до запрошенного.
  4. TCP не имеет message boundaries — нужно framing (line или length-prefix).
  5. recv возвращает b” = remote disconnected. Это EOF, не “временно нет данных”.
  6. Обрабатывать ConnectionResetError, ConnectionError, timeout — не дать одному клиенту положить сервер.
  7. Sequential server масштабируется плохо — thread per client как простой fix, лучшее в следующих уроках.

Kernel receive buffers и системные вызовы recv/send изнутри
Проверка знанийKnowledge check
У вас TCP-сервер обрабатывает клиентов, каждый шлёт JSON-сообщения, разделённые \n. Сервер делает 'data = sock.recv(4096); json_obj = json.loads(data.decode())'. Что не так с этим кодом, и какие два архитектурных подхода для исправления?
ОтветAnswer
Бага сразу несколько, все связанные с тем что TCP -- stream, а не messages: (1) recv(4096) может вернуть PARTIAL JSON. Например, клиент шлёт {"name":"long string here"}, сервер делает recv после первых 50 байт TCP-сегмента -- получает {"name":"long stri и пытается parse как JSON -- ошибка. (2) recv может вернуть MULTIPLE messages склеенными. Если клиент быстро шлёт {"a":1}\n{"b":2}\n{"c":3}\n, может прийти всё за один recv -- json.loads на этой строке упадёт. (3) Recv может вернуть message + half next message. {"a":1}\n{"b":2}\n{"c":3}\n частично прочитан -- {"a":1}\n{"b":2}\n{"c":3 -- последний JSON неполный. Архитектурные решения: ПОДХОД 1: Line-based framing с буферизацией. Использовать sock.makefile('rb') и читать через readline(). makefile буферирует, разбивает по \n, отдаёт по одной line. Каждый line -- полный JSON. for line in rfile: msg = json.loads(line). Это idiomatic Python для line-protocols (HTTP, IRC, SMTP). ПОДХОД 2: Length-prefix framing. Каждое сообщение начинается с 4 байт длины. Сервер делает recv_exactly(4) для длины, потом recv_exactly(length) для тела. Затем json.loads(body). Это более эффективно для бинарных данных, gRPC и Redis используют. ПОДХОД 3 (best practice): использовать готовый library который делает framing. asyncio.StreamReader.readline(), Twisted LineReceiver. Свой ручной framing -- источник bugs.

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 6. В TCP-сервере на Python мы делаем server_sock.listen(128) -- что значит число 128?

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

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

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

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