Как стать автором
Обновить
89.57
Skillfactory
Онлайн-школа IT-профессий

Руководство по программированию сокетов на Python. Клиент, сервер и несколько соединений

Время на прочтение20 мин
Количество просмотров23K
Автор оригинала: Nathan Jennings


К концу руководства вы освоите основные функции и методы модуля Python socket, научитесь применять пользовательский класс для отправки сообщений и данных между конечными точками и работать со всем этим в собственных клиент-серверных приложениях. Материалом делимся к старту курса по Fullstack-разработке на Python. Для удобства чтения первая часть — за спойлером.

Первая часть

История сокетов


История у сокетов давняя. Их применение началось с ARPANET в 1971 году и продолжилось в 1983-м, когда в операционной системе Berkeley Software Distribution (BSD) появился API под названием «сокеты Беркли».


В 1990-х годах вместе со Всемирной паутиной возникло и сетевое программирование. Преимущества сокетов и первых подключаемых сетей применялись не только в веб-серверах и браузерах — широко стали применяться клиент-серверные приложения разных типов и размеров.


Базовые протоколы API сокетов развивались многие годы, появились и новые, а низкоуровневый API остался прежним.


Самые распространённые сегодня приложения с сокетами — это клиент-серверные приложения, где одна сторона действует как сервер и ожидает подключения клиентов. Именно такое приложение вы напишете благодаря руководству. А конкретнее, сосредоточимся на API сокетов для интернет-сокетов. Иногда их называют сокетами Беркли, или сокетами BSD. Есть и сокеты домена Unix, которые используются для взаимодействия между процессами внутри только одного компьютера.


Обзор API сокетов


В модуле socket есть интерфейс к API сокетов Беркли.


Вот основные функции и методы этого API:


  • .socket()
  • .bind()
  • .listen()
  • .accept()
  • .connect()
  • .connect_ex()
  • .send()
  • .recv()
  • .close()

В Python имеется удобный и последовательный API, напрямую сопоставленный с системными вызовами, то есть аналогами функций из списка выше на C. В следующем разделе вы узнаете, как эти функции используются вместе.


Кроме того, в стандартной библиотеке Python есть классы, которые упрощают применение этих функций. Хотя в этом руководстве socketserver не рассматривается, с этим фреймворком для сетевых серверов можно ознакомиться по ссылке.


Доступно много модулей, где реализованы интернет-протоколы уровня выше, например HTTP и SMTP. Обзор этих протоколов смотрите в разделе документации Python «Интернет-протоколы и их поддержка».


TCP-сокеты


С помощью socket.socket() вы создадите объект сокета с указанием типа сокета socket.SOCK_STREAM. При этом по умолчанию применяется протокол управления передачей (TCP). Возможно, это то, что вам нужно.


Но зачем вам TCP? Вот его особенности:


  • TCP надёжен. Отброшенные в сети пакеты обнаруживаются и повторно передаются отправителем.
  • Данные доставляются с сохранением порядка очерёдности. В приложении данные считываются в порядке их записи отправителем.

Для сравнения: сокеты, которые создаются через socket.SOCK_DGRAM протокола пользовательских датаграмм ненадёжны: данные могут считываться получателем с изменением порядка очерёдности записей отправителя. Почему это важно? Сети — это система негарантированной доставки. Нет гарантии, что данные дойдут до места назначения или что отправленные данные будут получены.


Сетевые устройства — маршрутизаторы и коммутаторы — также обладают конечной полосой пропускания и собственными, системными ограничениями. Как на клиентах и на серверах, у них есть процессоры, память, шины и интерфейсные буферы пакетов. С TCP при этом не нужно беспокоиться о потере пакетов, поступлении данных с изменением порядка очерёдности пакетов данных, а также о других подводных камнях.


Чтобы разобраться лучше, ознакомьтесь с последовательностью вызовов API сокетов и с потоком данных TCP.


Ниже слева сервер, а справа клиент:


Поток TCP-сокетов
Поток TCP-сокетов. В центре изображения показан обмен данными между клиентом и сервером с помощью вызовов .send() и .recv().


Внизу соответствующие сокеты закрываются на клиенте и на сервере. (источник изображения)


Начиная с верхнего левого угла, показаны серверные вызовы API на сервере, которые настраивают «прослушиваемый» сокет:


  • socket()
  • .bind()
  • .listen()
  • .accept()

Этим сокетом, как следует из его названия, прослушиваются подключения от клиентов. Чтобы принять или завершить такое подключение, на сервере вызывается .accept().


А чтобы установить подключение к серверу и инициировать трёхэтапное рукопожатие, на клиенте вызывается .connect(). Процесс рукопожатия важен, ведь он гарантирует доступность каждой стороны подключения в сети, то есть то, что клиент может связаться с сервером, и наоборот. Возможно, только один хост, клиент или сервер может связаться с другим.


Эхо-клиент и эхо-сервер


Теперь, когда вы узнали об API сокетов и взаимодействии клиента и сервера, вы готовы создать свои первые клиент и сервер. Начнём с примера, где полученное сервером сообщение просто возвращается клиенту, как эхо.


Эхо-сервер


Вот он:


# echo-server.py

import socket

HOST = "127.0.0.1"  # Standard loopback interface address (localhost)
PORT = 65432  # Port to listen on (non-privileged ports are > 1023)

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, PORT))
    s.listen()
    conn, addr = s.accept()
    with conn:
        print(f"Connected by {addr}")
        while True:
            data = conn.recv(1024)
            if not data:
                break
            conn.sendall(data)

Не пытайтесь понять весь код сразу. В этих нескольких строках много чего происходит. И это только отправная точка, здесь можно увидеть базовый сервер в деле. Но что же происходит в вызове нашего API?



С помощью socket.socket() создаётся объект сокета, которым поддерживается тип контекстного менеджера, который используется в операторе with. Вызывать s.close() не нужно:


with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    pass  # Use the socket object without calling s.close().

Передаваемые в socket() аргументы — это константы, используемые для указания семейства адресов и типа сокетов. AF_INET — это семейство интернет-адресов для IPv4. SOCK_STREAM — это тип сокета для TCP и протокол, который будет использоваться для передачи сообщений в сети.


Метод .bind() применяется для привязки сокета к конкретному сетевому интерфейсу и номеру порта:


# echo-server.py

# ...

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, PORT))
    # ...

Передаваемые в .bind() значения зависят от семейства адресов сокета. В этом примере используется socket.AF_INET (IPv4). Поэтому принимается кортеж с двумя значениями: (host, port).


host может быть именем хоста, IP-адресом или пустой строкой. Если используется IP-адрес, то host должен быть строкой адреса формата IPv4. IP-адрес 127.0.0.1 — это стандартный IPv4-адрес для интерфейса «внутренней петли», когда к серверу подключаются только процессы в хосте. Если передавать пустую строку, подключения на сервере принимаются во всех доступных интерфейсах IPv4.


port — это номер TCP-порта для приёма подключений от клиентов. Это должно быть целое число от 1 до 65535(0 резервируется). В некоторых системах, если номер порта меньше 1024, могут потребоваться привилегии суперпользователя.


Относительно использования имён хостов с .bind() есть замечание:


«Если в хостовой части адреса сокета IPv4/v6 использовать имя хоста, программа может стать непредсказуемой: Python использует первый возвращаемый из разрешения DNS адрес. Адрес сокета будет разрешён в фактический адрес IPv4/v6 по-разному, в зависимости от результатов из DNS-разрешения и/или конфигурации хоста. Чтобы поведение было предсказыемым, в хостовой части используйте числовой адрес». Документация.



Подробнее об этом вы узнаете позже в разделе «Использование имён хостов». А пока достаточно понять, что при использовании имени хоста можно увидеть разные результаты в зависимости от того, чтó возвращается в процессе разрешения имён. Это может быть что угодно: при первом запуске приложения можно получить 10.1.2.3, а в следующий раз получится 192.168.0.1. Дальше может быть 172.16.7.8 и т. д.


В примере ниже подключения на сервере принимаются благодаря .listen(), а сам сервер становится «прослушиваемым» сокетом:


# echo-server.py

# ...

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, PORT))
    s.listen()
    conn, addr = s.accept()
    # ...

У метода .listen() есть параметр backlog. Он указывает число непринятых подключений, которые система разрешит до отклонения новых подключений. С версии Python 3.5 он необязателен. Если его нет, выбирается значение backlog по умолчанию.


А если на сервере получается много одновременных запросов на подключение, значение backlog можно увеличить через установку максимальной длины очереди для отложенных подключений. Это предельное значение зависит от системы. Например, на Linux смотрите /proc/sys/net/core/somaxconn.


Методом .accept() выполнение блокируется, и ожидается входящее подключение. При подключении клиента возвращается новый объект сокета, который представляет собой подключение и кортеж с адресом клиента. В кортеже содержится (host, port) — для подключений IPv4 или (host, port, flowinfo, scopeid) — для IPv6. Подробнее о значениях кортежей рассказывается в справочном разделе «Семейства адресов сокетов».


Итак, теперь у вас есть новый объект сокета из .accept(). Это важно потому, что сокет будет использоваться для взаимодействия с клиентом. Он отличается от прослушиваемого, который применяется на сервере для приёма новых подключений:


# echo-server.py

# ...

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, PORT))
    s.listen()
    conn, addr = s.accept()
    with conn:
        print(f"Connected by {addr}")
        while True:
            data = conn.recv(1024)
            if not data:
                break
            conn.sendall(data)

После того как в .accept() клиенту предоставляется объект сокета conn, для перебора блокирующих вызовов в conn.recv() используется бесконечный цикл while. Так любые отправляемые от клиента данные считываются и передаются обратно с помощью conn.sendall().


Если в conn.recv() возвращается пустой объект bytes и b'', значит, в клиенте подключение закрыто и цикл завершён. Чтобы автоматически закрыть сокет в конце блока, с conn применяется оператор with.


Эхо-клиент


Перейдём к клиенту:


# echo-client.py

import socket

HOST = "127.0.0.1"  # The server's hostname or IP address
PORT = 65432  # The port used by the server

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect((HOST, PORT))
    s.sendall(b"Hello, world")
    data = s.recv(1024)

print(f"Received {data!r}")

По сравнению с сервером клиент довольно прост. В нём создаётся объект сокета. Для подключения к серверу используется .connect(), и для отправки сообщения вызывается s.sendall(), s.recv() считывает ответ, а затем этот ответ выводится.


Запуск эхо-клиента и эхо-сервера


В этом разделе запускаем клиент и сервер, следим за их поведением и за происходящим.


Если вам не удаётся запустить из командной строки примеры или собственный код, прочитайте How Do I Make My Own Command-Line Commands Using Python? или How to Run Your Python Scripts (англ.). Если у вас Windows, ознакомьтесь с Python Windows FAQ («Часто задаваемыми вопросами по Python для Windows»).

Откройте терминал или командную строку, перейдите в каталог со скриптами, убедитесь, что в переменной PATH у вас есть Python 3.6 или новее, а затем запустите сервер:


$ python echo-server.py

Терминал зависнет, потому что сервер заблокирован или находится в состоянии ожидания, в .accept():


# echo-server.py

# ...

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, PORT))
    s.listen()
    conn, addr = s.accept()
    with conn:
        print(f"Connected by {addr}")
        while True:
            data = conn.recv(1024)
            if not data:
                break
            conn.sendall(data)

Ожидается подключение клиента. Затем откройте другое окно терминала или командную строку и запустите клиента:


$ python echo-client.py 
Received b'Hello, world'

В окне сервера вы должны заметить что-то такое:


$ python echo-server.py 
Connected by ('127.0.0.1', 64623)

Здесь на сервере выведен кортеж addr, возвращаемый из s.accept(). Это IP-адрес клиента и номер TCP-порта — 64623 (скорее всего, он будет другим, когда вы запустите сервер на своём компьютере).


Просмотр состояния сокета


Чтобы увидеть текущее состояние сокетов на хосте, используйте netstat. На macOS, Linux и Windows он доступен по умолчанию.


А ниже вывод netstat из macOS после запуска сервера:


$ netstat -an
Active Internet connections (including servers)
Proto Recv-Q Send-Q  Local Address          Foreign Address        (state)
tcp4       0      0  127.0.0.1.65432        *.*                    LISTEN

Обратите внимание: Local Address здесь 127.0.0.1.65432. Если бы в echo-server.py был HOST = "", а не HOST = "127.0.0.1", в netstat отображалось бы это:


$ netstat -an
Active Internet connections (including servers)
Proto Recv-Q Send-Q  Local Address          Foreign Address        (state)
tcp4       0      0  *.65432                *.*                    LISTEN

Local Address здесь *.65432. Это означает, что для приёма входящих подключений будут задействованы все поддерживающие семейство адресов доступные интерфейсы хоста. В этом примере в вызове socket() используется socket.AF_INET (IPv4) — смотрите tcp4 в столбце Proto.


Здесь показывается только вывод эхо-сервера. Скорее всего, полный вывод будет гораздо больше, это зависит вашей системы. Стóит обратить внимание на столбцы Proto, Local Address и (state). В последнем примере netstat показывает, что на эхо-сервере используется TCP-сокет IPv4 (tcp4) в порте 65432 на всех интерфейсах (*.65432) и он находится в состоянии прослушивания (LISTEN).


Другой способ получить к нему доступ (и дополнительную полезную информацию) — использовать программу lsof, которая выводит список открытых файлов. На macOS она доступна по умолчанию, а на Linux её можно установить пакетным менеджером:


$ lsof -i -n
COMMAND     PID   USER   FD   TYPE   DEVICE SIZE/OFF NODE NAME
Python    67982 nathan    3u  IPv4 0xecf272      0t0  TCP *:65432 (LISTEN)

Если lsof используется с параметром -i, в её выводе предоставляется COMMAND, PID (идентификатор процесса) и USER (идентификатор пользователя) открытых интернет-сокетов. Выше показан процесс эхо-сервера.


netstat и lsof различаются в зависимости от ОС, у них много опций. Загляните в их man или документацию, на них определённо стóит потратить немного времени. На macOS и Linux используйте man netstat и man lsof. На Windows — netstat /? .


При попытке подключения к порту без прослушиваемого сокета есть типичная ошибка:


$ python echo-client.py 
Traceback (most recent call last):
  File "./echo-client.py", line 9, in <module>
    s.connect((HOST, PORT))
ConnectionRefusedError: [Errno 61] Connection refused

Здесь либо указан неверный номер порта, либо не запускается сервер. Или, может быть, на пути стоит брандмауэр, которым подключение блокируется (об этом легко забыть). Также может быть сообщение об ошибке Connection timed out («Превышено время ожидания подключения»). Чтобы клиент подключался к TCP-порту, добавьте соответствующее правило брандмауэра!



Разбор взаимодействия клиента и сервера


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


Интерфейс «внутренней петли» сокетов


При работе с интерфейсом «внутренней петли» (IPv4 127.0.0.1 или IPv6 ::1) данные никогда не покидают хост и не касаются внешней сети. Выше интерфейс внутренней петли находится внутри хоста. Такова его природа: подключения и данные, которые пропускаются через него, локальны для хоста. Поэтому интерфейс внутренней петли и IP-адреса 127.0.0.1 и ::1 и называются localhost.


В приложениях этот интерфейс используется для взаимодействия с другими процессами, которые запускаются на хосте, а также в целях безопасности и предоставления изолированности от внешней сети. Внутренний, доступный только изнутри хоста, этот интерфейс недоступен извне.


Посмотреть на это в действии можно, если у вас есть сервер приложений со своей, закрытой базой данных. Если эта БД не используется на других серверах, наверное, она конфигурируется на прослушивание подключений только в интерфейсе localhost. Если так, то другим хостам сети нельзя подключиться к нему.


Если не использовать в приложениях адреса 127.0.0.1 или ::1, вероятно, отличный от них адрес привяжется к интерфейсу Ethernet, подключённому к внешней сети. Это шлюз к другим хостам за пределами localhost:


Интерфейс сокетов Ethernet


Поосторожнее там! Это неприглядный, жестокий мир. Прежде чем отважиться покинуть безопасный localhost, обязательно прочитайте раздел «Использование имён хостов». Там есть примечания по безопасности, которое применяется, даже если вы используете не имена хостов, а только IP-адреса.


Обработка нескольких подключений


У эхо-сервера определённо имеются ограничения. Самое большое — им обслуживается только один клиент, после чего работа завершается. У эхо-клиента такое же ограничение, но имеется дополнительная проблема. Когда в клиенте используется s.recv(), из b'Hello, world' можно вернуть только один байт (b'H'):


# echo-client.py

# ...

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect((HOST, PORT))
    s.sendall(b"Hello, world")
    data = s.recv(1024)

print(f"Received {data!r}")

Аргумент bufsize (1024) — это максимальный объём данных, получаемых одновременно. Он не означает, что из .recv() вернётся 1024 байт.


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


«В приложениях нужно проверять, что отправлены все данные. Если передана только их часть, нужно пробовать доставить оставшиеся данные». (Источник)


В примере выше этого удалось избежать благодаря .sendall():


«В отличие от send(), в этом методе отправка байт данных продолжается, пока не будут отправлены все данные или не возникнет ошибка. В случае успеха метод возвращает None». (Источник)


Здесь возникают две проблемы:


  • Обработка нескольких подключений одновременно.
  • Необходимость вызывать .send() и .recv(), пока все данные не будут отправлены или получены.

Что можно сделать? В плане конкурентности подходов много. Один из популярных — применение асинхронного ввода-вывода. asyncio появился в стандартной библиотеке в Python 3.4. Традиционное решение — использовать потоки.


Проблема конкурентности заключается в сложности её правильного понимания. Здесь нужно учитывать и остерегаться многих нюансов. Стóит проявиться одному из них, и приложение внезапно может просто-напросто выйти из строя.


Это не должно отбить желание осваивать и применять конкурентное программирование. Если приложение нужно масштабировать, использование более одного процессора или ядра необходимо. Но это руководство опирается на дедушку системных вызовов – более традиционый метод .select(), объяснить который проще.


Метод .select() проверяет завершение ввода-вывода более чем на одном сокете. Вызовите его, чтобы посмотреть, у каких сокетов ввод-вывод готов для чтения и/или для записи. Кроме .select(), есть ещё кое-что: чтобы использовать самую эффективную реализацию независимо от ОС, будет применяться стандартный модуль selectors:


«Этим модулем обеспечивается высокоуровневое и эффективное мультиплексирование ввода-вывода, основанное на примитивах модуля select. Пользователям рекомендуется применять этот модуль, если нет необходимости точно управлять примитивами, которые используются на уровне ОС». (Источник)


Тем не менее, применяя .select(), вы не сможете обеспечить конкурентное выполнение. В то же время, в зависимости от рабочей нагрузки, этот подход всё равно может оказаться достаточно быстрым. Это определяется количеством поддерживаемых в приложении клиентов и тем, что именно в нём должно делаться при обслуживании запроса.


Модуль asyncio для управления задачами использует однопоточную кооперативную многозадачность и цикл событий. С помощью .select() вы напишете собственную версию цикла событий, пусть даже проще и синхроннее. При использовании нескольких потоков, даже если присутствует конкурентность, сейчас приходится применять GIL (глобальную блокировку интерпретатора) с CPython и PyPy. Этим, так или иначе, ограничивается объём работы, выполняемой параллельно.


Всё это свидетельствует о том, что использование .select() может быть идеальным выбором. Не думайте, что придётся задействовать asyncio, потоки или новейшую асинхронную библиотеку. В сетевом приложении задача обычно, так или иначе, ограничена вводом-выводом: это может быть ожидание в локальной сети конечных точек на другой стороне сети, ожидание записи на диск и т. д.


Если вы получаете запросы от клиентов, которыми инициируется связанная с ЦП работа, обратите внимание на модуль concurrent.futures. В нём есть класс ProcessPoolExecutor. Этот класс для асинхронного выполнения вызовов использует пул процессов.


Если вы задействуете несколько процессов, в операционной системе может планироваться параллельное выполнение кода Python на нескольких процессорах или ядрах без глобальной блокировки интерпретатора. Идеи и вдохновение можно почерпнуть из доклада на PyCon John Reese — Thinking Outside the GIL with AsyncIO and Multiprocessing — PyCon 2018 («Джон Риз. AsyncIO и многопроцессорная обработка: выходя за рамки глобальной блокировки интерпретатора. PyCon 2018»).


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


Клиент и сервер с несколькими подключениями


В следующих разделах вы напишете сервер и клиента, где несколько подключений обрабатываются с помощью объекта selector из модуля selectors.


Сервер с несколькими подключениями


Обратимся сначала к серверу с несколькими подключениями. В первой части настраивается прослушиваемый сокет:


# multiconn-server.py

import sys
import socket
import selectors
import types

sel = selectors.DefaultSelector()

# ...

host, port = sys.argv[1], int(sys.argv[2])
lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
lsock.bind((host, port))
lsock.listen()
print(f"Listening on {(host, port)}")
lsock.setblocking(False)
sel.register(lsock, selectors.EVENT_READ, data=None)

Самое большое отличие этого сервера от эхо-сервера — вызов настройки сокета в неблокируемом режиме внутри lsock.setblocking(False). Вызовы в этот сокет больше не будут блокирующими. Когда этот вызов применяется вместе с sel.select(), можно ожидать события в одном или нескольких сокетах, а затем считывать и записывать данные по мере их готовности. Демонстрацию вы увидите ниже.


С помощью sel.register() сокет регистрируется, а через sel.select() в нём отслеживаются нужные события. Для прослушиваемого сокета, например, нужны события чтения, то есть selectors.EVENT_READ.


Чтобы сохранять любые произвольные данные, которые бы понадобились вместе с сокетом, и отслеживать, чтó в нём отправлено и получено, используется data, возвращаемый вместе с .select().


Вот цикл событий:


# multiconn-server.py

# ...

try:
    while True:
        events = sel.select(timeout=None)
        for key, mask in events:
            if key.data is None:
                accept_wrapper(key.fileobj)
            else:
                service_connection(key, mask)
except KeyboardInterrupt:
    print("Caught keyboard interrupt, exiting")
finally:
    sel.close()

sel.select(timeout=None) realpython.com/python-sockets//#blocking-calls, пока не будет сокетов, готовых для ввода-вывода. В нём возвращается список кортежей — по одному для каждого сокета. В каждом кортеже есть key и mask. key — это namedtuple [именованный кортеж] SelectorKey, в котором содержится атрибут fileobj. key.fileobj — это объект сокета, а maskмаска события готовых операций.


Если key.data будет None, то понятно, что это из прослушиваемого сокета, и подключение нужно принять. Чтобы получить новый объект сокета и зарегистрировать его с селектором, вызывается ваша функция accept_wrapper(). Она показана чуть ниже.


Если key.data не None, то понятно, что это сокет уже принятого клиента, и его нужно обслуживать. Затем вызывается service_connection() с аргументами key и mask. Это всё, что нужно для работы с сокетом.


Вот что делается в функции accept_wrapper():


# multiconn-server.py

# ...

def accept_wrapper(sock):
    conn, addr = sock.accept()  # Should be ready to read
    print(f"Accepted connection from {addr}")
    conn.setblocking(False)
    data = types.SimpleNamespace(addr=addr, inb=b"", outb=b"")
    events = selectors.EVENT_READ | selectors.EVENT_WRITE
    sel.register(conn, events, data=data)

# ...

Прослушиваемый сокет зарегистрирован для события selectors.EVENT_READ, поэтому он должен быть готов к считыванию. Вызывается sock.accept(), а затем, для перевода сокета в неблокируемый режим, – conn.setblocking(False).


И это – основная цель данной версии сервера, ведь нужно, чтобы он не блокировался. Если сервер заблокируется, то он весь остановится до возврата. Это означает, что другие сокеты остаются в ожидании, даже если сервер не используется активно – вот и жутковатое «зависания», в котором серверу находиться нежелательно.


Дальше при помощи SimpleNamespace создаётся объект для хранения данных, которые должны идти вместе с сокетом. Поскольку вам нужно знать, когда подключение клиента готово для чтения и записи, оба этих события задаются с помощью побитового «ИЛИ»:


# multiconn-server.py

# ...

def accept_wrapper(sock):
    conn, addr = sock.accept()  # Should be ready to read
    print(f"Accepted connection from {addr}")
    conn.setblocking(False)
    data = types.SimpleNamespace(addr=addr, inb=b"", outb=b"")
    events = selectors.EVENT_READ | selectors.EVENT_WRITE
    sel.register(conn, events, data=data)

# ...

Маска events, сокет и объекты данных затем передаются в sel.register().


Теперь посмотрите, как обрабатывается готовое подключение клиента в service_connection():


# multiconn-server.py

# ...

def service_connection(key, mask):
    sock = key.fileobj
    data = key.data
    if mask & selectors.EVENT_READ:
        recv_data = sock.recv(1024)  # Should be ready to read
        if recv_data:
            data.outb += recv_data
        else:
            print(f"Closing connection to {data.addr}")
            sel.unregister(sock)
            sock.close()
    if mask & selectors.EVENT_WRITE:
        if data.outb:
            print(f"Echoing {data.outb!r} to {data.addr}")
            sent = sock.send(data.outb)  # Should be ready to write
            data.outb = data.outb[sent:]

# ...

Вот сердце простого сервера с несколькими подключениями. key — это namedtuple, возвращаемый из .select(), в котором содержится объект сокета fileobj и объект данных. В mask находятся готовые события.


Если сокет готов к считыванию, mask & selectors.EVENT_READ окажется равным True, поэтому вызывается sock.recv(). Любые считанные данные добавляются в data.outb, их можно отправить позже.


Обратите внимание на блок else:. В нём проверяется, не получены ли данные:


# multiconn-server.py

# ...

def service_connection(key, mask):
    sock = key.fileobj
    data = key.data
    if mask & selectors.EVENT_READ:
        recv_data = sock.recv(1024)  # Should be ready to read
        if recv_data:
            data.outb += recv_data
        else:
            print(f"Closing connection to {data.addr}")
            sel.unregister(sock)
            sock.close()
    if mask & selectors.EVENT_WRITE:
        if data.outb:
            print(f"Echoing {data.outb!r} to {data.addr}")
            sent = sock.send(data.outb)  # Should be ready to write
            data.outb = data.outb[sent:]

# ...

Если данные не получены, значит, сокет на клиенте закрыли, поэтому на сервере нужно сделать то же самое. Но не забудьте перед закрытием вызвать sel.unregister(), чтобы сокет не отслеживался через .select().


Когда сокет готов к записи (работоспособный сокет всегда должен быть готов к ней), любые полученные данные, которые хранятся в data.outb, передаются клиенту с помощью sock.send(). Затем отправленные байты удаляются из буфера для пересылки:


# multiconn-server.py

# ...

def service_connection(key, mask):

    # ...

    if mask & selectors.EVENT_WRITE:
        if data.outb:
            print(f"Echoing {data.outb!r} to {data.addr}")
            sent = sock.send(data.outb)  # Should be ready to write
            data.outb = data.outb[sent:]

# ...

В методе .send() возвращается число отправленных байтов, с помощью этого числа позже байты можно отбросить при помощи нотации среза в буфере .outb.


Клиент с несколькими подключениям


Теперь обратимся к multiconn-client.py, клиенту с несколькими подключениями. Он очень похож на сервер. Только вместо прослушивания подключений он начинается с их инициирования через start_connections():


# multiconn-client.py

import sys
import socket
import selectors
import types

sel = selectors.DefaultSelector()
messages = [b"Message 1 from client.", b"Message 2 from client."]

def start_connections(host, port, num_conns):
    server_addr = (host, port)
    for i in range(0, num_conns):
        connid = i + 1
        print(f"Starting connection {connid} to {server_addr}")
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.setblocking(False)
        sock.connect_ex(server_addr)
        events = selectors.EVENT_READ | selectors.EVENT_WRITE
        data = types.SimpleNamespace(
            connid=connid,
            msg_total=sum(len(m) for m in messages),
            recv_total=0,
            messages=messages.copy(),
            outb=b"",
        )
        sel.register(sock, events, data=data)

# ...

Значение num_conns считывается из командной строки. Это количество создаваемых подключений к серверу. Аналогично серверу каждый сокет настраивается на неблокируемый режим.


С помощью .connect() сразу выдаётся исключение BlockingIOError, поэтому вместо него используется метод .connect_ex(), где изначально возвращается индикатор ошибки errno.EINPROGRESS, а не выдаётся исключение, из-за которого подключение затрудняется. Когда подключение завершено, готовый к чтению и записи сокет возвращается из .select().


После настройки сокета для создания сохраняемых с ним данных используется SimpleNamespace. Сообщения, отправляемые от клиента на сервер, копируются с помощью messages.copy(), потому что при каждом подключении вызывается socket.send() и список меняется. В объекте data хранится всё необходимое для отслеживания того, что именно на клиенте нужно отправить, что именно отправлено и получено (в том числе общее количество байтов в сообщениях).


Вот изменения [добавленные и удалённые строки, + и -] из серверного service_connection() для версии на клиенте:


 def service_connection(key, mask):
     sock = key.fileobj
     data = key.data
     if mask & selectors.EVENT_READ:
         recv_data = sock.recv(1024)  # Should be ready to read
         if recv_data:
-            data.outb += recv_data
+            print(f"Received {recv_data!r} from connection {data.connid}")
+            data.recv_total += len(recv_data)
-        else:
-            print(f"Closing connection {data.connid}")
+        if not recv_data or data.recv_total == data.msg_total:
+            print(f"Closing connection {data.connid}")
             sel.unregister(sock)
             sock.close()
     if mask & selectors.EVENT_WRITE:
+        if not data.outb and data.messages:
+            data.outb = data.messages.pop(0)
         if data.outb:
-            print(f"Echoing {data.outb!r} to {data.addr}")
+            print(f"Sending {data.outb!r} to connection {data.connid}")
             sent = sock.send(data.outb)  # Should be ready to write
             data.outb = data.outb[sent:]

По сути, то же самое, но с важным отличием. В клиенте отслеживается количество полученных от сервера байтов, так что эту сторону подключения можно закрыть. Когда это обнаруживается на сервере, другая сторона подключения тоже закрывается.


Cервер при этом зависит от поведения клиента, когда ожидается, что та сторона подключения закроется после отправки сообщений. Если клиент не закроется, подключение на сервере останется открытым. На практике может понадобиться защита от этого путём реализации на сервере тайм-аута для предотвращения накопления клиентских подключений, если в них по истечении определённого времени не отправляется запрос.


Запуск клиента и сервера с несколькими подключениями


Пора запускать multiconn-server.py и multiconn-client.py. И там и там используются аргументы командной строки. Можно запустить без аргументов и посмотреть варианты.


Для сервера передайте номера host и port:


$ python multiconn-server.py
Usage: multiconn-server.py <host> <port>

А для клиента передайте num_connections, то есть количество создаваемых подключений к серверу:


$ python multiconn-client.py
Usage: multiconn-client.py <host> <port> <num_connections>

Вот вывод сервера при прослушивании в интерфейсе «внутренней петли» в порте 65432:


$ python multiconn-server.py 127.0.0.1 65432
Listening on ('127.0.0.1', 65432)
Accepted connection from ('127.0.0.1', 61354)
Accepted connection from ('127.0.0.1', 61355)
Echoing b'Message 1 from client.Message 2 from client.' to ('127.0.0.1', 61354)
Echoing b'Message 1 from client.Message 2 from client.' to ('127.0.0.1', 61355)
Closing connection to ('127.0.0.1', 61354)
Closing connection to ('127.0.0.1', 61355)

А вот вывод клиента при создании в нём двух подключений к серверу:


$ python multiconn-client.py 127.0.0.1 65432 2
Starting connection 1 to ('127.0.0.1', 65432)
Starting connection 2 to ('127.0.0.1', 65432)
Sending b'Message 1 from client.' to connection 1
Sending b'Message 2 from client.' to connection 1
Sending b'Message 1 from client.' to connection 2
Sending b'Message 2 from client.' to connection 2
Received b'Message 1 from client.Message 2 from client.' from connection 1
Closing connection 1
Received b'Message 1 from client.Message 2 from client.' from connection 2
Closing connection 2

Отлично! Клиент и сервер с несколькими подключениями запущен. В следующем разделе вы расширите применение этого примера.



Поможем разобраться в Python, чтобы вы прокачали карьеру или стали востребованным профессионалом в IT:
Чтобы увидеть все курсы, кликните по баннеру:



Теги:
Хабы:
+1
Комментарии4

Публикации

Информация

Сайт
www.skillfactory.ru
Дата регистрации
Дата основания
Численность
501–1 000 человек
Местоположение
Россия
Представитель
Skillfactory School