9.13. TCP-сокеты

TCP-сокеты бывают двух видов, которые выглядят по-разному, но используют один и тот же базовый тип: клиентские сокеты, которые вызывают connect() для подключения к удалённому серверу, и серверные сокеты, которые вызывают bind(), listen() и accept() для приёма входящих подключений. Обе роли используют один и тот же класс socket, представленный на странице Объекты сокетов; различаются лишь вызываемые на них методы.

9.13.1. TCP-клиент

Простейший клиент открывает соединение, отправляет запрос, читает ответ и закрывается:

import socket

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("192.168.1.20", 9000))

s.send(b"hello\n")
reply = s.recv(1024)
print("reply:", reply)

s.close()

connect() выполняет тройное рукопожатие, описанное на странице TCP – надёжный поток байтов, и возвращает управление, когда соединение установлено. send() записывает байты в соединение; recv() читает из него до заданного числа байтов. Когда приложение завершило работу, close() закрывает соединение.

Тот же скрипт, обёрнутый в идиому с инструкцией with из Объекты сокетов, так что сокет закрывается даже в случае возникновения исключения:

import socket

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect(("192.168.1.20", 9000))
    s.send(b"hello\n")
    print(s.recv(1024))

9.13.1.1. Чтение до полного получения данных

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

chunks = []
while True:
    chunk = s.recv(1024)
    if not chunk:                  # empty bytes -> other side closed
        break
    chunks.append(chunk)
reply = b"".join(chunks)

Цикл завершается, когда recv() возвращает пустой объект bytes. Это происходит, когда другая сторона корректно закрыла свою половину соединения; приложение читает «конец потока» как то же самое, что и «конец сообщения», в протоколах такого рода.

9.13.1.2. Отправка до полной передачи данных

К send() применима противоположная оговорка: он может отправить меньше байтов, чем запрошено, возвращая количество фактически записанных байтов. Для больших объёмов данных повторяйте отправку неотправленного остатка:

payload = some_big_bytes
while payload:
    n = s.send(payload)
    payload = payload[n:]

sendall() выполняет этот цикл внутри себя, поэтому большинство кода может просто вызвать его и избежать ручного повтора:

s.sendall(some_big_bytes)

9.13.2. TCP-сервер

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

import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("0.0.0.0", 9000))
server.listen(1)
print("listening on port 9000")

while True:
    conn, addr = server.accept()
    print("connection from", addr)

    while True:
        data = conn.recv(1024)
        if not data:
            break
        conn.send(data)            # echo back

    conn.close()

Шаг за шагом:

  • bind() занимает хост и порт на камере. "0.0.0.0" принимает соединения на любом интерфейсе; замена этого значения на конкретный IP ограничивает прослушивание этим интерфейсом.

  • listen() переводит сокет из обычного сокета в прослушивающий. Аргумент — это очередь ожидания (backlog): сколько ожидающих соединений MicroPython будет ставить в очередь, пока приложение занято. Выбирайте небольшое число; в большинстве случаев достаточно 1.

  • accept() блокируется, пока не подключится клиент, затем возвращает (conn, addr): новый сокет, представляющий это конкретное соединение, и адрес клиента. Сам прослушивающий сокет остаётся открытым для приёма новых подключений.

  • Все байты обмена проходят через conn — новый сокет. Чтение и запись используют те же вызовы recv() / send(), что и на стороне клиента.

  • Когда клиент закрывает соединение, recv() возвращает b""; внутренний цикл завершается, и сервер закрывает свою сторону с помощью close().

Внешний цикл while True возвращается к accept(), чтобы ожидать следующего клиента. В таком виде сервер обрабатывает по одному клиенту за раз; для параллельной работы с несколькими клиентами потребуются либо потоки, либо asyncio. Последнему посвящена следующая страница.

9.13.3. Распространённые ошибки

  • Восприятие recv() как ориентированного на сообщения. Это не так. Два вызова send(b"hi") могут прийти как один recv(4) со значением b"hihi" или как два recv(2). Приложению приходится добавлять разметку (framing), если границы сообщений важны — символ новой строки, префикс длины, что угодно.

  • Забывают повторять отправку при коротких передачах. Используйте sendall() для всего, что больше нескольких сотен байтов.

  • Забывают закрыть принятый сокет. Каждый conn — это отдельный сокет; закрытие прослушивающего сокета не закрывает принятые. Блоки with для обоих делают такую ошибку маловероятной:

    while True:
        with server.accept()[0] as conn:
            # ... talk on conn ...
    
  • Повторная привязка к порту, всё ещё находящемуся в состоянии TIME_WAIT. Когда сервер перезапускается в течение нескольких секунд после закрытия, bind() может завершиться ошибкой «address in use», потому что MicroPython всё ещё удерживает порт для предыдущего соединения. Вызов server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) перед bind() устраняет это.

9.13.4. Что дальше

Блокировка на accept() означает, что сервер может обслуживать лишь одного клиента за раз. Блокировка на recv() означает, что один медленный клиент подвешивает весь цикл. Стандартное решение на камере — asyncio: запускайте каждое соединение как отдельную задачу, позволяя циклу событий переключаться между ними. Следующая страница рассматривает asyncio-версии всего, что описано на этой.