Spec-Zone.ru › Python 3.12

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

Автор:

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

Аннотация

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

Сокеты

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

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

История

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

Они были изобретены в Беркли как часть варианта Unix BSD. Они распространились как лесной пожар вместе с интернетом. По понятным причинам — сочетание сокетов с 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, мы возвращаемся к прослушиванию новых подключений. Два «клиента» могут свободно общаться — они используют динамически выделенный порт, который будет повторно использован после завершения разговора.

Взаимодействие между процессами

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

См. также

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

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

Первое, что нужно отметить, это то, что сокет «клиента» веб-браузера и сокет «клиента» веб-сервера идентичны. То есть, это диалог «равноправный с равным». Или, иначе говоря, как разработчик, вы должны будете определить правила этикета для диалога. Обычно сокет 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:
    """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 не всегда удаётся избавиться от всего за один проход. И несмотря на прочтение этого, вы, в конце концов, столкнётесь с этой проблемой!

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

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

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

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

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

Отключение

Строго говоря, вы должны использовать 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 могут возвращаться, ничего не сделав. У вас есть (конечно) ряд вариантов. Вы можете проверить код возврата и коды ошибок и вообще свести себя с ума. Если вы мне не верите, попробуйте однажды. Ваше приложение станет большим, глючным и будет засорять ЦП. Поэтому давайте пропустим решения, которые не работают с головой, и сделаем это правильно.

Используйте 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–2024 Python Software Foundation
Licensed under the PSF License.
https://docs.python.org/3.12/howto/sockets.html

Spec-Zone.ru

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