Руководство по программированию сокетов
- Автор:
-
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 = make_client_thread(clientsocket)
ct.start()
Этот цикл может работать тремя основными способами: запускать поток для обработки clientsocket, создавать новый процесс для обработки clientsocket или перестроить приложение для использования неблокирующих сокетов и мультиплексировать наш «серверный» сокет и любые активные clientsockets с помощью select. Об этом позже. Сейчас важно понять следующее: это всё, что делает «серверный» сокет. Он не отправляет данные. Он не получает данные. Он только создаёт «клиентские» сокеты. Каждый clientsocket создаётся в ответ на то, что какой-то другой «клиентский» сокет выполняет connect() для узла и порта, к которым мы привязаны. Как только мы создали этот clientsocket, мы снова начинаем прослушивать новые подключения. Два «клиента» могут свободно общаться друг с другом — они используют динамически выделенный порт, который будет повторно использован после завершения обмена данными.
IPC
Если вам нужно быстрое IPC между двумя процессами на одном компьютере, рассмотрите каналы или разделяемую память. Если вы всё же решите использовать сокеты AF_INET, привяжите «серверный» сокет к 'localhost'. На большинстве платформ это позволит обойти пару уровней сетевого кода и значительно повысить скорость.
См. также
multiprocessing объединяет кроссплатформенное IPC в API более высокого уровня.
Использование сокета
В первую очередь следует отметить, что «клиентский» сокет веб-браузера и «клиентский» сокет веб-сервера — совершенно одинаковы. Иными словами, это взаимодействие «равный с равным». Или, другими словами, как разработчику, вам придётся определить правила этикета для обмена данными. Обычно сокет connecting начинает обмен данными, отправляя запрос или, возможно, сообщение для входа в систему. Но это решение разработчика — это не правило работы сокетов.
Для обмена данными можно использовать два набора глаголов. Можно использовать 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» означает network (сеть), «h» — host (узел), «s» — short (короткое целое), а «l» — long (длинное целое). Если порядок байтов узла совпадает с сетевым, эти функции ничего не делают; если же порядок байтов в компьютере обратный, они меняют байты местами соответствующим образом.
В наши дни, когда повсюду используются 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 это проще простого, но реализация достаточно близка к варианту на 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 Python Software Foundation
Licensed under the PSF License.
https://docs.python.org/3.14/howto/sockets.html