Документация Python неофициальный перевод
Содержание страницы

Руководство по программированию сокетовSocket Programming HOWTO

Автор

Gordon McMillan

Аннотация

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

СокетыSockets

Я расскажу только о сокетах INET (т.е. IPv4), но они составляют как минимум 99% используемых сокетов. И я буду говорить только о потоковых сокетах (т.е. TCP) – если вы не знаете точно, что делаете (в этом случае этот HOWTO не для вас!), то от потокового сокета вы получите лучшие характеристики и производительность, чем от чего-либо другого. Я постараюсь развеять тайну того, что такое сокет, а также дать несколько советов по работе с блокирующими и неблокирующими сокетами. Но начну я с блокирующих сокетов. Вам нужно понять, как они работают, прежде чем переходить к неблокирующим.

Одна из трудностей в понимании этих вещей заключается в том, что слово «сокет» может означать несколько слегка разных вещей в зависимости от контекста. Итак, сначала давайте разграничим «клиентский» сокет – конечную точку беседы, и «серверный» сокет, который больше похож на оператора телефонной станции. Клиентское приложение (например, ваш браузер) использует только «клиентские» сокеты; веб-сервер, с которым оно общается, использует как «серверные», так и «клиентские» сокеты.

ИсторияHistory

Среди различных форм IPC сокеты, безусловно, самые популярные. На любой платформе, вероятно, существуют другие, более быстрые формы IPC, но для межплатформенного взаимодействия сокеты – это практически единственный доступный вариант.

Они были изобретены в Беркли как часть BSD-версии Unix. Они распространились со скоростью лесного пожара вместе с интернетом. И не зря – сочетание сокетов с INET делает связь с любыми машинами по всему миру невероятно простой (по крайней мере, по сравнению с другими подходами).

Создание сокетаCreating a Socket

Если говорить приблизительно, то когда вы щёлкнули по ссылке, которая привела вас на эту страницу, ваш браузер сделал примерно следующее:

# создать INET-сокет для потока данных (STREAM)
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# теперь подключиться к веб-серверу на порту 80 – обычный порт HTTP
s.connect(("www.python.org", 80))

После завершения connect сокет s можно использовать для отправки запроса на получение текста страницы. Тот же сокет прочитает ответ, а затем будет уничтожен. Да, именно уничтожен. Клиентские сокеты обычно используются только для одного обмена (или небольшого набора последовательных обменов).

То, что происходит на веб-сервере, немного сложнее. Сначала веб-сервер создаёт «серверный» сокет:

# создать INET-сокет для потока данных (STREAM)
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# привязать сокет к публичному хосту и известному порту
serversocket.bind((socket.gethostname(), 80))
# стать серверным сокетом
serversocket.listen(5)

Пара моментов, на которые стоит обратить внимание: мы использовали socket.gethostname(), чтобы сокет был виден из внешнего мира. Если бы мы использовали s.bind(('localhost', 80)) или s.bind(('127.0.0.1', 80)), у нас всё равно был бы «серверный» сокет, но доступный только в пределах той же машины. s.bind(('', 80)) указывает, что сокет доступен по любому адресу, который есть у машины.

Второе, что стоит отметить: порты с низкими номерами обычно зарезервированы для «хорошо известных» служб (HTTP, SNMP и т.д.). Если вы просто экспериментируете, используйте подходящий высокий номер (4 цифры).

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

Теперь, когда у нас есть «серверный» сокет, прослушивающий порт 80, мы можем войти в основной цикл веб-сервера:

while True:
    # принимать подключения извне
    (clientsocket, address) = serversocket.accept()
    # теперь выполнить действия с clientsocket
    # в данном случае предполагается, что это многопоточный сервер
    ct = client_thread(clientsocket)
    ct.run()

На самом деле есть три общих способа организации этого цикла: породить поток для обработки clientsocket, создать новый процесс для обработки clientsocket, или перестроить приложение для использования неблокирующих сокетов и мультиплексировать между нашим «серверным» сокетом и любыми активными clientsocket с помощью select. Об этом позже. Важно понять сейчас: это и есть всё, что делает «серверный» сокет. Он не отправляет данные. Он не принимает данные. Он просто создаёт «клиентские» сокеты. Каждый clientsocket создаётся в ответ на то, что какой-то другой «клиентский» сокет выполняет connect() к хосту и порту, к которому мы привязаны. Как только мы создали этот clientsocket, мы возвращаемся к ожиданию новых подключений. Два «клиента» могут свободно общаться – они используют некоторый динамически выделенный порт, который будет переиспользован после завершения разговора.

Межпроцессное взаимодействиеIPC

Если вам нужен быстрый IPC между двумя процессами на одной машине, рассмотрите каналы (pipes) или разделяемую память. Если вы всё же решите использовать сокеты AF_INET, привяжите «серверный» сокет к 'localhost'. На большинстве платформ это позволит сократить путь, обойдя несколько уровней сетевого кода, и будет значительно быстрее.

См. также

multiprocessing интегрирует межплатформенный IPC в API более высокого уровня.

Использование сокета Using a Socket

Первое, что следует отметить: «клиентский» сокет веб-браузера и «клиентский» сокет веб-сервера – это одинаковые звери. То есть это диалог «равный с равным». Или, другими словами, как разработчик, вы должны решить, каковы правила этикета для этого диалога. Обычно сокет connect начинает разговор, отправляя запрос или, возможно, приветствие. Но это проектное решение – а не правило работы сокетов.

Теперь есть два набора функций для общения. Вы можете использовать send и recv, или преобразовать клиентский сокет в файлоподобную сущность и использовать read и write. Последний способ – это то, как Java представляет свои сокеты. Я не буду здесь об этом говорить, за исключением предупреждения: вам нужно использовать flush на сокетах. Это буферизованные «файлы», и распространенная ошибка – write что-то, а затем read ответа. Без flush в этом случае вы можете ждать ответа вечно, потому что запрос все еще может находиться в выходном буфере.

Теперь мы подошли к главному камню преткновения сокетов – send и recv работают напрямую с сетевыми буферами. Они не обязательно обрабатывают все байты, которые вы им передаете (или ожидаете от них), потому что их основная задача – работа с сетевыми буферами. В общем случае они возвращают управление, когда соответствующие сетевые буферы заполнены (send) или опустошены (recv). Затем они сообщают, сколько байтов было обработано. Это ваша задача – вызывать их снова, пока ваше сообщение не будет полностью обработано.

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

Протокол вроде HTTP использует сокет только для одной передачи. Клиент отправляет запрос, затем читает ответ. Вот и всё. Сокет отбрасывается. Это означает, что клиент может определить конец ответа, получив 0 байт.

Но если вы планируете повторно использовать сокет для дальнейших передач, нужно понимать, что не существует EOT на сокете. Повторяю: если send или recv возвращает управление после обработки 0 байт, соединение разорвано. Если соединение не разорвано, вы можете ждать на recv вечно, потому что сокет не сообщит вам, что больше нечего читать (пока). Теперь, если немного подумать, можно прийти к фундаментальной истине сокетов: сообщения должны быть фиксированной длины (фу), или быть разделёнными ограничителями (пожимание плечами), или указывать свою длину (гораздо лучше), или завершаться закрытием соединения. Выбор полностью за вами (но некоторые способы более правильные, чем другие).

Если вы не хотите завершать соединение, самое простое решение – сообщение фиксированной длины:

class MySocket:
    """только демонстрационный класс
      написан для ясности, а не для эффективности
    """

    def __init__(self, sock=None):
        if sock is None:
            self.sock = socket.socket(
                            socket.AF_INET, socket.SOCK_STREAM)
        else:
            self.sock = sock

    def connect(self, host, port):
        self.sock.connect((host, port))

    def mysend(self, msg):
        totalsent = 0
        while totalsent < MSGLEN:
            sent = self.sock.send(msg[totalsent:])
            if sent == 0:
                raise RuntimeError("socket connection broken")
            totalsent = totalsent + sent

    def myreceive(self):
        chunks = []
        bytes_recd = 0
        while bytes_recd < MSGLEN:
            chunk = self.sock.recv(min(MSGLEN - bytes_recd, 2048))
            if chunk == b'':
                raise RuntimeError("socket connection broken")
            chunks.append(chunk)
            bytes_recd = bytes_recd + len(chunk)
        return b''.join(chunks)

Приведённый здесь код отправки подходит почти для любой схемы передачи сообщений – в Python вы отправляете строки и можете использовать len() для определения их длины (даже если они содержат встроенные символы \0). В основном сложнее становится код получения. (А в C ненамного хуже, за исключением того, что нельзя использовать strlen, если сообщение содержит встроенные \0.)

Самое простое улучшение – сделать первый символ сообщения индикатором типа сообщения, и пусть тип определяет длину. Теперь у вас есть два recv: первый – чтобы получить (как минимум) этот первый символ, чтобы узнать длину, и второй – в цикле для получения остального. Если вы решите пойти по пути разделителей, вы будете получать данные произвольными порциями (4096 или 8192 часто хорошо соответствуют размерам сетевых буферов) и сканировать полученное на предмет разделителя.

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

Префикс сообщения с его длиной (скажем, 5 числовых символов) усложняет задачу, потому что (верите или нет) вы можете не получить все 5 символов за один recv. При экспериментах это может сойти с рук, но при высокой сетевой нагрузке ваш код очень быстро сломается, если вы не используете два цикла recv: первый для определения длины, второй для получения данных сообщения. Отвратительно. И именно тогда вы обнаружите, что send не всегда удаляет всё за один проход. И несмотря на то, что вы это прочитали, вы всё равно на этом обожжётесь!

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

Двоичные данные Binary Data

Вполне возможно отправлять двоичные данные через сокет. Основная проблема в том, что не все машины используют одинаковые форматы двоичных данных. Например, сетевой порядок байтов – big-endian, со старшим байтом первым, поэтому 16-битное целое со значением 1 будет представлено двумя шестнадцатеричными байтами 00 01. Однако большинство распространённых процессоров (x86/AMD64, ARM, RISC-V) используют little-endian, с младшим байтом первым – то же самое 1 будет равно 01 00.

Библиотеки сокетов содержат функции для преобразования 16- и 32-битных целых чисел – ntohl, htonl, ntohs, htons, где «n» означает network (сетевой), «h» – host (хостовый), «s» – short (короткий), «l» – long (длинный). Если сетевой порядок совпадает с хостовым, эти функции ничего не делают, но если порядок байтов машины обратный, они соответствующим образом переставляют байты.

В наши дни 64-битных машин ASCII-представление двоичных данных часто оказывается меньше, чем двоичное представление. Это потому, что удивительно часто многие целые числа равны 0 или, может быть, 1. Строка "0" будет занимать два байта, в то время как полное 64-битное целое – 8. Конечно, это плохо сочетается с сообщениями фиксированной длины. Решения, решения.

Отключение Disconnecting

Строго говоря, полагается использовать shutdown на сокете перед тем, как close его. shutdown – это уведомление для сокета на другом конце. В зависимости от переданного аргумента оно может означать: «Я больше не буду отправлять, но продолжу слушать» или «Я не слушаю, скатертью дорога!». Однако большинство библиотек сокетов настолько привыкли к тому, что программисты пренебрегают этим правилом этикета, что обычно close эквивалентно shutdown(); close(). Поэтому в большинстве ситуаций явный вызов shutdown не требуется.

Один из способов эффективного использования shutdown – в обмене, подобном HTTP. Клиент отправляет запрос, а затем выполняет shutdown(1). Это говорит серверу: «Клиент закончил отправку, но всё ещё может принимать». Сервер может обнаружить «конец файла» по получению 0 байт. Он может считать, что получил полный запрос. Сервер отправляет ответ. Если send завершается успешно, значит, клиент действительно всё ещё принимал.

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

Когда сокеты умирают When Sockets Die

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

Неблокирующие сокеты Non-blocking Sockets

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

В Python вы используете socket.setblocking(False), чтобы сделать сокет неблокирующим. В C это сложнее (например, нужно выбирать между BSD-версией O_NONBLOCK и практически неотличимой POSIX-версией O_NDELAY, которая полностью отличается от TCP_NODELAY), но идея та же. Это делается после создания сокета, но перед его использованием. (На самом деле, если вы ненормальный, можете переключаться туда-сюда.)

Главное механическое отличие в том, что send, recv, connect и accept могут вернуться, ничего не сделав. У вас (конечно) есть несколько вариантов. Вы можете проверять коды возврата и коды ошибок и в целом свести себя с ума. Если не верите, попробуйте как-нибудь. Ваше приложение разрастётся, станет глючным и будет пожирать процессор. Так что давайте пропустим тупые решения и сделаем правильно.

Используйте select.

В C программирование select довольно сложно. В Python это раз плюнуть, но оно достаточно близко к версии на C, так что если вы понимаете select в Python, у вас будет мало проблем с ним в C:

ready_to_read, ready_to_write, in_error = \
               select.select(
                  potential_readers,
                  potential_writers,
                  potential_errs,
                  timeout)

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

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

Если сокет находится в списке readable на выходе, вы можете быть настолько уверены, насколько это вообще возможно в нашем деле, что recv на этом сокете вернёт что-то. Та же идея для списка writable. Вы сможете отправить что-то. Возможно, не всё, что хотели, но что-то лучше, чем ничего. (На самом деле, любой достаточно здоровый сокет будет возвращаться как writable – это просто означает, что доступно место в исходящем сетевом буфере.)

Если у вас есть «серверный» сокет, поместите его в список potential_readers. Если он оказывается в списке readable, ваш accept (почти наверняка) сработает. Если вы создали новый сокет для connect к кому-то другому, поместите его в список potential_writers. Если он появляется в списке writable, есть хороший шанс, что соединение установлено.

На самом деле, select может быть полезен даже с блокирующими сокетами. Это один из способов определить, произойдёт ли блокировка: сокет становится readable, когда в буферах что-то есть. Однако это всё равно не решает проблему определения, закончил ли другой конец работу или просто занят чем-то ещё.

Предупреждение о переносимости: В Unix select работает как с сокетами, так и с файлами. Не пытайтесь делать это в Windows. В Windows select работает только с сокетами. Также обратите внимание, что в C многие более продвинутые опции сокетов в Windows реализованы иначе. На самом деле, в Windows я обычно использую потоки (которые работают очень и очень хорошо) со своими сокетами.