Руководство по программированию сокетов¶
- Автор:
Гордон Макмиллан
Розетки¶
Я собираюсь рассказать только о сокетах IINET (т.е. IPv4), но на их долю приходится не менее 99% используемых сокетов. И я буду говорить только о ПОТОКОВЫХ (т.е. TCP) сокетах - если вы действительно не знаете, что делаете (в этом случае это руководство не для вас!), вы получите лучшее поведение и производительность от ПОТОКОВОГО сокета, чем от чего-либо другого. Я попытаюсь раскрыть тайну того, что такое сокет, а также дам несколько советов о том, как работать с блокирующими и неблокирующими сокетами. Но я начну с того, что расскажу о блокирующих сокетах. Вам нужно знать, как они работают, прежде чем иметь дело с неблокирующими сокетами.
Одна из проблем, связанных с пониманием этих вещей, заключается в том, что «гнездо» может означать множество неуловимо отличающихся друг от друга вещей, в зависимости от контекста. Итак, сначала давайте проведем различие между «клиентским» сокетом - конечной точкой диалога - и «серверным» сокетом, который больше похож на коммутатор оператора. Клиентское приложение (например, ваш браузер) использует исключительно «клиентские» сокеты; веб-сервер, с которым оно взаимодействует, использует как «серверные», так и «клиентские» сокеты.
История¶
Из различных форм IPC сокеты, безусловно, являются самыми популярными. На любой платформе, вероятно, существуют и другие формы IPC, которые работают быстрее, но для межплатформенного взаимодействия сокеты - это, пожалуй, единственная возможная игра.
Они были изобретены в Беркли как часть BSD-версии Unix. С появлением Интернета они распространились как лесной пожар. И на то есть веские причины - сочетание сокетов с IINET делает общение с любыми компьютерами по всему миру невероятно простым (по крайней мере, по сравнению с другими схемами).
Создание сокета¶
Грубо говоря, когда вы перешли по ссылке, которая привела вас на эту страницу, ваш браузер сделал что-то вроде следующего:
# 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 между двумя процессами на одном компьютере, вам следует изучить каналы или общую память. Если вы решите использовать сокеты AF_INET, привяжите сокет «server» к 'localhost'
. На большинстве платформ это позволит сократить время работы на нескольких уровнях сетевого кода и будет значительно быстрее.
См.также
multiprocessing
интегрирует кроссплатформенный IPC в API более высокого уровня.
Использование сокета¶
Первое, на что следует обратить внимание, это то, что «клиентский» сокет веб-браузера и «клиентский» сокет веб-сервера являются идентичными устройствами. То есть, это «одноранговый» диалог. Или, другими словами, как дизайнеру, вам придется решить, каковы правила этикета для ведения беседы. Обычно сокет connect
начинает беседу, отправляя запрос или, возможно, регистрируясь. Но это дизайнерское решение, а не правило для розеток.
Теперь есть два набора глаголов, которые можно использовать для общения. Вы можете использовать send
и recv
, или вы можете преобразовать свой клиентский сокет в файлообразное устройство и использовать read
и write
. Последнее - это способ, которым Java представляет свои сокеты. Я не собираюсь говорить об этом здесь, за исключением того, что хочу предупредить вас, что вам нужно использовать flush
для сокетов. Это буферизованные «файлы», и распространенной ошибкой является ввод write
чего-либо, а затем read
ответа. Без flush
вы можете вечно ждать ответа, потому что запрос все еще может находиться в вашем выходном буфере.
Теперь мы подходим к главному камню преткновения сокетов - send
и recv
работают с сетевыми буферами. Они не обязательно обрабатывают все байты, которые вы им передаете (или ожидаете от них), поскольку их основная задача - обработка сетевых буферов. Как правило, они возвращаются, когда соответствующие сетевые буферы заполнены (send
) или опустошены (recv
). Затем они сообщают вам, сколько байт они обработали. Вы несете ответственность за повторный звонок до тех пор, пока ваше сообщение не будет полностью обработано.
Когда recv
возвращает 0 байт, это означает, что другая сторона закрыла (или находится в процессе закрытия) соединение. Вы больше не будете получать никаких данных по этому соединению. Когда-либо. Возможно, вам удастся успешно отправить данные; подробнее я расскажу об этом позже.
Протокол, подобный HTTP, использует сокет только для одной передачи. Клиент отправляет запрос, а затем считывает ответ. Вот и все. Сокет будет удален. Это означает, что клиент может определить окончание ответа, получив 0 байт.
Но если вы планируете повторно использовать свой сокет для дальнейших передач, вам нужно понимать, что в сокете нет * :abbr:`EOT (End of Transfer)` *. Я повторяю: если сокет 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
s.)
Самое простое усовершенствование - сделать первый символ сообщения индикатором типа сообщения, и пусть тип определяет длину. Теперь у вас есть два recv
s - первый, чтобы получить (по крайней мере) этот первый символ, чтобы вы могли посмотреть длину, и второй в цикле, чтобы получить остальные. Если вы решите пойти по пути с разделителями, вы будете получать файлы произвольного размера (4096 или 8192 часто подходят для размеров сетевого буфера) и проверять полученные данные на наличие разделителя.
Следует иметь в виду одну сложность: если ваш диалоговый протокол позволяет отправлять несколько сообщений подряд (без какого-либо ответа), и вы передаете recv
произвольный размер фрагмента, вы можете в конечном итоге прочитать начало следующего сообщения. Вам нужно будет отложить это в сторону и придерживать до тех пор, пока это не понадобится.
Добавление к сообщению префикса с указанием его длины (скажем, в виде 5 цифровых символов) становится более сложным, потому что (хотите верьте, хотите нет) вы можете не получить все 5 символов в одном recv
. В процессе игры вам это сойдет с рук; но при высокой нагрузке на сеть ваш код будет очень быстро ломаться, если вы не будете использовать два цикла recv
- первый для определения длины, второй для получения части сообщения, содержащей данные. Противный. Именно тогда вы обнаружите, что от send
не всегда удается избавиться за один раз. И, несмотря на то, что вы прочитали это, рано или поздно это вас доконает!
В интересах сохранения пространства, развития вашего персонажа (и сохранения моей конкурентной позиции) эти улучшения оставлены в качестве упражнения для читателя. Давайте перейдем к наведению порядка.
Двоичные данные¶
Вполне возможно отправлять двоичные данные через сокет. Основная проблема заключается в том, что не все машины используют одинаковые форматы для двоичных данных. Например, network byte order имеет большой порядок следования, с первым старшим байтом, поэтому 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. Конечно, это не очень подходит для сообщений фиксированной длины. Решения, решения.
Отключение¶
Строго говоря, вы должны использовать 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
для этого сокета вернет что-то. Та же идея для списка доступных для записи. Вы сможете отправлять что-то. Возможно, не все, что вы хотите, но что-то все же лучше, чем ничего. (На самом деле, любой достаточно работоспособный сокет будет доступен для записи - это просто означает, что в исходящем сетевом буфере есть свободное место.)
Если у вас есть сокет «server», внесите его в список potential_readers. Если он появится в списке доступных для чтения, ваш accept
(почти наверняка) сработает. Если вы создали новый сокет для connect
для кого-то другого, внесите его в список potential_writers. Если он отображается в списке доступных для записи, у вас есть большая вероятность, что он подключен.
На самом деле, select
может быть удобно даже при блокировании сокетов. Это один из способов определить, будете ли вы блокировать - сокет возвращается как доступный для чтения, когда в буферах что-то есть. Однако это по-прежнему не помогает решить проблему определения того, закончен ли другой конец или просто занят чем-то другим.
Предупреждение о переносимости: В Unix select
работает как с сокетами, так и с файлами. Не пытайтесь использовать это в Windows. В Windows select
работает только с сокетами. Также обратите внимание, что в C многие из более продвинутых параметров сокетов в Windows реализованы по-другому. На самом деле, в Windows я обычно использую потоки (которые работают очень, очень хорошо) с моими сокетами.