Spec-Zone.ru › Python 3.12

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

Автор:

Gordon McMillan

Аннотация

Сокеты используются почти повсюду, но это одна из самых часто неправильно понимаемых технологий. Здесь представлен общий обзор сокетов с высоты 10 000 футов. Это не совсем руководство: вам всё равно придётся поработать, чтобы всё заработало. Здесь не рассматриваются тонкости (а их немало), но я надеюсь, что этого введения будет достаточно, чтобы начать грамотно пользоваться сокетами.

Сокеты

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

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

История

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

Они были изобретены в Беркли как часть разновидности 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 и т. д.). Если вы экспериментируете, используйте большой номер (четырёхзначный).

Наконец, аргумент 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()

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

IPC

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

См. также

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

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

Бинарные данные

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

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

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

Отключение

Формально перед вызовом close для сокета следует вызвать shutdown. Вызов 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 это проще простого, но вариант на 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 для этого сокета вернёт что-нибудь. То же относится к списку готовых к записи. Вы сможете отправить что-нибудь. Возможно, не всё, что вам нужно, но что-нибудь лучше, чем ничего. (На самом деле любой достаточно исправный сокет будет готов к записи — это лишь означает, что в исходящем сетевом буфере есть свободное место.)

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

Вообще, 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