Программирование сокетов HOWTO¶
- Автор
Гордон Макмиллан
Аннотация
Сокеты используются практически везде, но являются одной из самых непонятных технологий. Это обзор сокетов с высоты 10 000 футов. Это не совсем учебник - вам все равно придется поработать над тем, чтобы привести все в рабочее состояние. Он не охватывает все тонкости (а их очень много), но я надеюсь, что он даст вам достаточно знаний, чтобы начать использовать их достойно.
Розетки¶
Я буду говорить только о сокетах INET (т.е. IPv4), но они составляют по крайней мере 99% используемых сокетов. И я буду говорить только о сокетах STREAM (т.е. TCP) - если вы действительно не знаете, что делаете (в таком случае это HOWTO не для вас!), вы получите лучшее поведение и производительность от сокета STREAM, чем от чего-либо другого. Я постараюсь прояснить загадку того, что такое сокет, а также дам несколько подсказок о том, как работать с блокирующими и неблокирующими сокетами. Но начну я с разговора о блокирующих сокетах. Вам нужно будет знать, как они работают, прежде чем работать с неблокирующими сокетами.
Часть проблемы с пониманием этих вещей заключается в том, что «сокет» может означать несколько совершенно разных вещей, в зависимости от контекста. Поэтому сначала давайте проведем различие между «клиентским» сокетом - конечной точкой разговора, и «серверным» сокетом, который больше похож на оператора коммутатора. Клиентское приложение (например, ваш браузер) использует исключительно «клиентские» сокеты; веб-сервер, с которым он общается, использует как «серверные», так и «клиентские» сокеты.
История¶
Из различных форм IPC сокеты, безусловно, наиболее популярны. На любой конкретной платформе, вероятно, существуют другие формы IPC, которые быстрее, но для межплатформенного взаимодействия сокеты являются единственной игрой в городе.
Они были изобретены в Беркли как часть BSD-версии Unix. Они распространились как лесной пожар с появлением Интернета. На то есть веские причины - сочетание сокетов с 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()
На самом деле есть три основных способа, как этот цикл может работать - диспетчеризация потока для обработки clientsocket
, создание нового процесса для обработки clientsocket
, или реструктуризация этого приложения для использования неблокирующих сокетов, и мультиплексирование между нашим «серверным» сокетом и любым активным clientsocket
с помощью select
. Подробнее об этом позже. Сейчас важно понять следующее: это все, что делает «серверный» сокет. Он не отправляет никаких данных. Он не получает никаких данных. Он просто создает «клиентские» сокеты. Каждый clientsocket
создается в ответ на то, что какой-то другой «клиентский» сокет делает connect()
на хост и порт, к которому мы привязаны. Как только мы создали этот clientsocket
, мы возвращаемся к прослушиванию новых соединений. Два «клиента» могут свободно общаться - они используют некоторый динамически выделенный порт, который будет утилизирован, когда разговор закончится.
IPC¶
Если вам нужен быстрый IPC между двумя процессами на одной машине, вам следует обратить внимание на трубы или разделяемую память. Если вы решите использовать сокеты AF_INET, привяжите «серверный» сокет к 'localhost'
. На большинстве платформ это позволит обойти пару слоев сетевого кода и будет значительно быстрее.
См.также
multiprocessing
интегрирует кросс-платформенный IPC в API более высокого уровня.
Использование сокета¶
Первое, что следует отметить, это то, что «клиентский» сокет веб-браузера и «клиентский» сокет веб-сервера - одинаковые звери. То есть, это общение «равный с равным». Или, говоря иначе, как дизайнер, вы должны будете решить, каковы правила этикета для разговора. Обычно, сокет connect
ing начинает разговор, посылая запрос или, возможно, подписываясь. Но это дизайнерское решение - это не правило сокетов.
Теперь есть два набора глаголов, которые можно использовать для связи. Вы можете использовать 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
s).
Самое простое усовершенствование - сделать первый символ сообщения индикатором типа сообщения, а тип определяет длину. Теперь у вас есть два recv
s - первый для получения (по крайней мере) первого символа, чтобы вы могли посмотреть длину, а второй в цикле для получения остальных. Если вы решите пойти по пути разделителей, вы будете получать куски произвольного размера (4096 или 8192 часто хорошо соответствуют размерам сетевого буфера) и сканировать полученные данные на наличие разделителя.
Следует помнить об одном осложнении: если ваш разговорный протокол позволяет отправлять несколько сообщений друг за другом (без какого-либо ответа), и вы передаете recv
произвольный размер чанка, вы можете прочитать начало следующего сообщения. Вам нужно будет отложить его в сторону и хранить до тех пор, пока оно не понадобится.
Префикс сообщения с его длиной (скажем, как 5 числовых символов) становится более сложным, потому что (хотите верьте, хотите нет), вы можете не получить все 5 символов в одном recv
. При игре вы сможете обойтись без этого; но при высокой нагрузке на сеть ваш код очень быстро сломается, если вы не используете два цикла recv
- первый для определения длины, второй для получения части данных сообщения. Неприятно. Здесь же вы обнаружите, что send
не всегда удается избавиться от всего за один проход. И, несмотря на то, что вы это прочитали, в конце концов вы будете укушены!
В интересах экономии места, создания персонажа (и сохранения моих конкурентных позиций), эти улучшения оставлены в качестве упражнения для читателя. Давайте перейдем к уборке.
Двоичные данные¶
Передача двоичных данных через сокет вполне возможна. Основная проблема заключается в том, что не все машины используют одинаковые форматы для двоичных данных. Например, network byte order является 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
на этом сокете вернет что-то. Та же идея для записываемого списка. Вы сможете послать что-то. Может быть, не все, что вы хотите, но что-то лучше, чем ничего. (На самом деле, любой достаточно здоровый сокет вернется как доступный для записи - это просто означает, что буферное пространство исходящей сети доступно).
Если у вас есть «серверный» сокет, поместите его в список potential_readers. Если он окажется в списке читаемых, ваш accept
будет (почти наверняка) работать. Если вы создали новый сокет для connect
кому-то другому, поместите его в список potential_writers. Если он появится в списке записываемых, у вас есть хороший шанс, что он подключился.
На самом деле, select
может быть удобен даже для блокирующих сокетов. Это один из способов определения блокировки - сокет возвращается как доступный для чтения, если в буферах что-то есть. Однако это все равно не помогает решить проблему определения того, закончил ли другой конец или просто занят чем-то еще.
Предупреждение о переносимости: На Unix, select
работает как с сокетами, так и с файлами. Не пытайтесь сделать это в Windows. В Windows, select
работает только с сокетами. Также обратите внимание, что в C многие более продвинутые опции сокетов выполняются по-другому в Windows. На самом деле, в Windows я обычно использую потоки (которые работают очень, очень хорошо) с моими сокетами.