Spec-Zone.ru › Python 3.13

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

Автор:

Gordon McMillan

Аннотация

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

Сокеты

Я буду говорить только о сокетах 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-битные компьютеры, текстовое представление двоичных данных часто занимает меньше места, чем двоичное. Это связано с тем, что удивительно часто большинство целых чисел имеют значение 0 или, возможно, 1. Строка "0" займёт два байта, тогда как полноценное 64-битное целое число займёт 8 байтов. Конечно, это плохо сочетается с сообщениями фиксированной длины. Вот и выбирайте.

Разрыв соединения

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

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

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

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

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

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

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

Spec-Zone.ru

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