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