Spec-Zone.ru › Python 3.9

Программирование сокетов HOWTO

Автор

Гордон Макмиллан

Аннотация

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

Сокеты

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

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

История

Из различных форм IPC сокеты являются наиболее популярными. На любой платформе, скорее всего, есть другие формы IPC, которые быстрее, но для кроссплатформенного взаимодействия сокеты — практически единственный вариант.

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

Создание сокета

Грубо говоря, когда вы кликнули по ссылке, которая привела вас на эту страницу, ваш браузер сделал что-то вроде следующего:

# create an INET, STREAMing socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# now connect to the web server on port 80 - the normal http port
s.connect(("www.python.org", 80))

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

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

# create an INET, STREAMing socket
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# bind the socket to a public host, and a well-known port
serversocket.bind((socket.gethostname(), 80))
# become a server socket
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:
    # accept connections from outside
    (clientsocket, address) = serversocket.accept()
    # now do something with the clientsocket
    # in this case, we'll pretend this is a threaded server
    ct = client_thread(clientsocket)
    ct.run()

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

IPC

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

См. также

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

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

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

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

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

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

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

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

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

class MySocket:
    """demonstration class only
      - coded for clarity, not efficiency
    """

    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 не всегда удаётся очистить всё за один проход. И несмотря на то, что вы это прочитали, вы рано или поздно об этом споткнётесь!

В интересах экономии места эти улучшения оставлены в качестве упражнения для читателя. Перейдём к очистке.

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

Отправка двоичных данных по сокету вполне возможна. Главная проблема в том, что не все машины используют одинаковый формат двоичных данных. Например, чип Motorola будет представлять 16-битовое целое число со значением 1 как два шестнадцатеричных байта 00 01. Intel и DEC, однако, используют обратный порядок байт — то же 1 будет 01 00. Библиотеки сокетов имеют функции для преобразования 16- и 32-битовых целых чисел — ntohl, htonl, ntohs, htons, где «n» означает сетевой, а «h» означает хостовой, «s» означает короткий, а «l» означает длинный. Там, где сетевой порядок совпадает с хостовым, эти функции ничего не делают, но если машина имеет обратный порядок байт, они меняют байты местами.

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

Отключение

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

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

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

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

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

Неблокирующие сокеты

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

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

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

Используйте 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 является блокирующим, но вы можете указать таймаут. Это обычно разумно — задайте разумно большой таймаут (например, минуту), если у вас нет веских причин поступить иначе.

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

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

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

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

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

© 2001–2022 Python Software Foundation
Licensed under the PSF License.
https://docs.python.org/3.9/howto/sockets.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API