От блокирующих сокетов к асинхронности

Дабы исчерпать до дна тему сокетов в Python я решил изучить все возможные способы их использования в данном языке. Чтобы всех их можно было испытать и попробовать на зуб, были созданы 19 версий простого эхо-сервера: от простейшего возможного использования класса socket до asyncio. Блокирующие и неблокирующие сокеты, процессы и потоки, select'ы и selector'ы, колбеки и сопрограммы — все эти темы расположены в эволюционном порядке, чтобы один пример плавно перетекал в другой.

Отдельно разобрано появление асинхронности в Python. На примерах детально показано, как и зачем появились итераторы, из них — генераторы, сопрограммы. Ближе к концу построен учебный макет библиотеки asyncio с минимально необходимым кодом, чтобы любой (даже такой, как я) смог разобраться, как на самом деле устроена асинхронность, как там все внутри работает.

Пишу подробно, чтобы случайно чего не пропустить. Поэтому понятно должно быть всем.

Сокеты как часть ОС

Для начала договоримся о терминах. Программа — это определенная последовательность инструкций и данных, решающих определенную задачу, которая хранится в памяти. Программа, которая выполняется, называется процессом. Самый главный процесс — это операционная система (ОС). Она в свою очередь может запускать другие программы как процессы ОС. Программы, которые являются частью ОС, называются системными. Все остальные — прикладными, или приложениями. Они запускаются пользователем и предназначены для решения его задач.

Программа может сама управлять только ходом собственного исполнения (инструкции if, else, while, for и так далее) и вычислениями (+, -, *, /). Всё же остальное — выделение или освобождение блока памяти, вывод на экран, обращение к мыши и клавиатуре, сети, файлам, жестким дискам и другим устройствам ввода-вывода — программа может получить, только обратившись к ОС через системный вызов. Системный вызов (system call, или syscall) — это запрос к ОС на выполнение той или иной функции. Например, условлено, что если, например, в Linux на 32-битной машине установить в регистр eax 1, а в ebx — 0, а потом вызывать прерывание 0x80, то произойдет выход из текущего процесса с кодом 0, так как вызовется системный вызов exit(0):

mov     eax, 1 // interrupt code for exit syscall
mov     ebx, 0 // argument, in this case: return value
int     0x80   // make exit system call

Системные вызовы используются также и для разных способов обмена данных между процессами — межпроцессного взаимодействия (Inter-process communication, IPC). Для каждого есть свой код и свой набор аргументов, каждому из которых отведен свой регистр. К данным механизмам относятся в частности: pipes, shared memory (разделяемую память). К ним относятся также и сокеты. Если первые работают только с процессами, находящимися на одной машине, то сокеты этим не ограничены. Сокеты отлично работают даже если системы и машины абсолютно разные (кросс-платформенное сообщение). Рассмотрим вкратце, как появились сокеты.

Сокеты как таковые

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

Так получилось с сокетами. Придуманные в Беркли в 1982 году как часть ОС BSD Unix, они моментально разошлись по интернету как очень простой и удобный способ коммуникации между разными системами, и сейчас в том или ином виде реализованы практическими всеми ОС. Поэтому программный интерфейс (API), который мы будем тут рассматривать, и называется сокетами Беркли. Решение было настолько абстрактным и универсальным, что одинаково годилось и для локального использования, и для работы по сети.

Сокет переводится с англ. как розетка, разъем и является в данном механизме конечной точкой соединения. Обмен байтами происходит между двумя такими точками. Одну точку можно отличить от другой по ее адресу. Адрес сокета состоит из двух частей: адрес машины (host), представленный IP-адресом, и номер порта, для различения сокетов внутри одной машины. В URL-форме, которая используется в браузерах, обе части находятся в одной строке, разделенной двоеточием. Например, в "192.168.0.11:1234" 192.168.0.11 — это IP-адрес, а 1234 — номер порта. IP-адрес может заменяться для удобства доменным именем: "my.site.com:1234". В конечном итоге доменное имя всегда преобразуется в IP-адрес, т.к. сеть работает только с численными адресами. Этим преобразованием занимается специальная служба доменных имен (DNS).

Сокеты бывают двух типов: клиентские и серверные. Серверные не делают ничего, кроме приема запросов на установление соединения и создания клиентского сокета для каждого нового подключения. Клиентские сокеты не делают ничего, кроме как обмениваются данными. И на сервере, и на клиенте клиентские сокеты идентичны. Соединение между клиентскими сокетами является одного ранга — peer-to-peer.

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

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

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

После небольшого теоретического введения можно перейти к коду.

Сокеты в Python

Чтобы использовать сокеты в программе, нужно, во-первых, создать объект сокета. Для клиента и сервера он создается одинаково:

server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

AF_INET — означает интернет сокет по протоколу IPv4 (также существуют: AF_INET6 — для IPv6, AF_IPX — для IPX, AF_UNIX — для Unix-сокетов).

SOCK_STREAM — говорит о том, что в качестве транспортного протокола будет использоваться TCP. Второй распространенный вариант — это UDP (SOCK_DGRAM). Первый используется там, где важна надежность, второй — где скорость. TCP гарантирует доставку всех сообщений, причем в том же порядке, в котором они были переданы. UDP возвращает сообщения сразу по прибытии — без проверки, обработки и подтверждений приема. Поэтому он гораздо быстрее TCP.

Далее начинаются различия между серверным и клиентским сокетами. Серверный сокет привязывается к адресу (bind) и начинает слушать по нему подключения (listen). После этого каждый вызов функции accept() будет создавать новый клиентский сокет, как только появится очередное подключение:

server_sock.bind((HOST, PORT)) 
server_sock.listen(5) 
conn, addr = server_sock.accept()

HOST — это IP адрес системы, где запущен сервер. Если сокет должен быть виден только снаружи, из других систем, то нужно взять значение из server_sock.gethostname(). Если он должен быть виден только изнутри, то необходимо задать "localhost" или "127.0.0.1". Значение "" осуществляет привязку ко всем доступным для данной машины адресам: и локальным, и глобальным. Заметим, что использование "localhost" для локального доступа позволяет пропустить несколько слоев сетевого кода, что быстрее, чем использовать HOST="".

PORT — порт сервера. Так как малые значения (до 1024) большей частью зарезервированы, то рекомендуется использовать любое 4-значное число свыше 1024.

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

Для того, чтобы принимать неограниченное число соединений, поместим accept() в бесконечный цикл:

HOST, PORT = "", 12345 
server_sock.bind((HOST, PORT)) 
server_sock.listen(5) 
while True:    
    sock, addr = server_sock.accept()    
    # ... 

Дальше мы используем полученный клиентский сокет sock точно так же, как и на клиенте (о чем ниже).

На клиенте все значительно проще. Мы просто сразу подключаемся по заданному адресу вызовом метода connect():

HOST, PORT = "localhost", 12345 
sock.connect((HOST, PORT))

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

Аналогично, существует буфер приема данных, который пополняется с каждым новым полученным по сети пакетом. В методе recv() мы передаем максимальное число байтов, которое мы желаем получить. Если в буфере меньше данных, чем мы запрашиваем, то функция возвращает, сколько есть. Лучше всего, если число будет степенью двойки и не меньше максимальной ожидаемой длины сообщений, чтобы не требовалось несколько раз вызывать recv() для получения одного и того же сообщения.

Общая схема работы приложения на сокетах проста: событие → send message → receive response → событие. Пользователь или программа создают какое-то событие, на него генерируется соответствующее сообщение. Оно отсылается (от клиента серверу или от сервера клиенту), а потом получается ответ. Принятое сообщение также можно воспринять как событие, в результате чего процесс может повториться.

Вот пример упрощенного клиента, который отправляет введенную строку после каждого нажатия клавиши Enter. Чтобы не ограничивать себя вводом только одной строки, мы обернули код в бесконечный цикл:

# Client 
while True:    
    data = input("Type the message to send:")    
    data_bytes = data.encode()  # (str to bytes)    
    sock.sendall(data_bytes)  # Send    
    data_bytes = sock.recv(1024)  # Receive    
    data = data_bytes.decode()  # (bytes to str)    
    print("Received:", data)

На сервере процесс аналогичный: сообщение получается, обрабатывается, и отправляется назад ответ:

# Server 
while True:
    data = sock.recv(1024)  # Receive    
    data = data.upper()  # Process bytes    
    sock.sendall(data)  # Send

В данных примерах клиент только отсылает сообщения и никак не обрабатывает ответы — лишь отображает. А сервер только обрабатывает полученные сообщения, но не генерирует новые. Но в реальных приложениях обычно обе стороны обрабатывают полученные сообщения, и обе генерируют новые.

Однако, часто больше всего сообщений инициирует именно клиент, так как он управляется пользователем — основным источником событий. В то же время сервер будет являться основным обработчиком событий, т.к. именно на сервере реализована большая часть логики всего приложения. Это делается для того, чтобы, будучи изолированной от пользователей, ее никто не смог взломать и подкрутить в свою пользу. Поэтому можно сказать, что клиент работает по циклу send-receive-send, а сервер — по receive-send-receive. В действительности же это один и тот же цикл send-receive, но в разных начальных фазах. Другими словами, жизненный цикл клиента и сервера одинаковый.

Полный же жизненный цикл сокета завершается с закрытием соединения. Закрывать соединение необходимо всегда явным образом — вызовом метода close(). Даже не смотря на то, что close() вызывается автоматически при сборе мусора (периодической очистки памяти от ранее удаленных переменных). Если, например, убить клиентский процесс без вызова close(), то на сервере об этом не узнают и так и будут ждать от него сообщений хоть целую вечность.

Метод close() высвобождает ресурсы, выделенные сокету, но еще не гарантирует немедленного закрытия соединения. Если момент закрытия важен, то нужно явно вызывать метод shutdown(how) с одним из значений SHUT_RD=0, SHUT_WR=1, SHUT_RDWR=2. Так, например, при отсылке HTTP-запроса клиент вызовом shutdown(SHUT_WR) говорит серверу, что передача закончилась и больше данных не будет — можно начинать обработку запроса. При этом тот же сокет все еще может получать данные с сервера и ждет ответ. На сервере recv() при этом возвращает b"" — значение длиной в 0 байтов, что означает состояние End of File (EOF).

Чтобы не вызывать каждый раз close(), весь блок кода, работающего с сокетами, можно также обернуть в конструкцию with:

# Client
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
    sock.connect((HOST, PORT))
    while True:
        data = input("Type the message to send:")
        data_bytes = data.encode()  # str to bytes
        sock.sendall(data_bytes)
        data_bytes = sock.recv(1024)
        data = data_bytes.decode()  # bytes to str
        print("Received:", data)

Класс socket относится к менеджерам контекста (context manager). Это значит, что у него есть два магических метода: __enter__() и __exit__(). Первый вызывается перед началом выполнения блока with, последний — после окончания его выполнения. В методе __exit__() происходит закрытие сокета:

class socket(_socket.socket):
    def __enter__(self):
        return self
    def __exit__(self, *args):
        if not self._closed:
            self.close()
    # ...

Итак, обобщая, всю работу сокетов можно свести к двум парам методов: accept/connect-close и send-recv. Первая отвечает за соединение, вторая — за пересылку данных. Далее мы рассмотрим подробнее, как сокеты применяются на практике.

Исходники

Вперед >

Дополнительная литература:

  1. Подробнее об устройстве сетей и протоколов TCP/IP, поверх которых построены сокеты, можно прочитать в книге Глейзера и Мадхава «Многопользовательские игры. Разработка сетевых приложений".

  2. Про устройство компьютера от момента, когда он еще "был" телеграфом — Петцольд «Код» и Харрисы «Цифровая схемотехника и архитектура компьютера».

UPD. Спасибо Yuribtr за уточнения.

Комментарии (4)


  1. googoosik
    10.07.2022 22:21

    Скажите, будет ли разобран каждый из 19 серверов в дальнейших частях?


    1. maluta Автор
      10.07.2022 22:22

      Только ключевые. Остальные будут понятны по аналогии. Всего будет 5 статей.


  1. Yuribtr
    11.07.2022 11:22
    +1

    Отличная и подробная статья для знакомства с сокетами с азов.
    Немного хотелось бы дополнить:

    Касательно размер буфера при работе с сокетами- те самые магические числа - sock.recv(1024), рекомендуется их делать степенью двойки и как можно ближе к типичному размеру ожидаемых команд. В случае передачи больших данных можно увеличить до размера системного буфера сокетов. Естественно размер буфера приема на сервере и клиенте может быть разным.

    PORT — порт сервера. Так как малые значения большей частью зарезервированы, то рекоммендуется использовать любое 4-значное число.

    Если быть точнее, то номера портов до числа 1024 - зарезервированы и могут использоваться системными службами. Поэтому лучше выбирать номера портов начиная с 1024.


  1. hardkotian
    11.07.2022 15:10

    Отличная статья! Спасибо.