Программирование сокетов HOWTO
- Автор
-
Гордон Макмиллан
Аннотация
Сокеты используются практически повсеместно, но являются одной из наиболее непонятых технологий. Это обзор сокетов на высоком уровне. Это не учебник — вам всё равно придется поработать, чтобы заставить всё функционировать. Он не охватывает все тонкости (и их много), но, надеюсь, даст вам достаточно базовых знаний, чтобы начать использовать их разумно.
Сокеты
Я буду говорить только о сокетах 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()
На самом деле существуют 3 основных способа работы этого цикла: отправка потока для обработки clientsocket, создание нового процесса для обработки clientsocket, или реструктуризация приложения для использования неблокирующих сокетов и мультиплексирования между нашим «серверным» сокетом и любыми активными clientsocket с помощью select. Подробнее об этом позже. Важно понять сейчас следующее: это всё, что делает «серверный» сокет. Он не отправляет данные. Он не получает данные. Он просто создаёт «клиентские» сокеты. Каждый clientsocket создаётся в ответ на то, что какой-то другой «клиентский» сокет выполняет connect() к хосту и порту, к которым мы привязаны. Как только мы создали этот clientsocket, мы возвращаемся к прослушиванию новых подключений. Два «клиента» могут свободно общаться — они используют динамически назначенный порт, который будет повторно использован после завершения разговора.
IPC
Если вам нужна быстрая IPC между двумя процессами на одной машине, обратите внимание на каналы или общую память. Если вы всё же решите использовать сокеты AF_INET, привяжите «серверный» сокет к 'localhost'. На большинстве платформ это обеспечит обход нескольких слоёв сетевого кода и будет значительно быстрее.
См. также
Модуль multiprocessing интегрирует межпроцессное взаимодействие на нескольких платформах в 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. Конечно, это плохо сочетается с сообщениями фиксированной длины. Решения, решения.
Отключение
Строго говоря, вы должны использовать 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 могут возвращаться, ничего не сделав. У вас (конечно) есть несколько вариантов. Вы можете проверить код возврата и коды ошибок и, в целом, свести себя с ума. Если вы мне не верите, попробуйте это в какой-то момент. Ваше приложение станет большим, глючным и будет забирать CPU. Поэтому давайте пропустим решения для слабоумных и сделаем это правильно.
Используйте 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 для этого сокета вернёт *что-то*. Такая же идея для списка для записи. Вы сможете отправить *что-то*. Возможно, не всё, что вы хотите, но *что-то* лучше, чем ничего. (На самом деле, любой относительно здоровый сокет вернёт запись как «доступную» — это просто означает, что в буфере вывода есть место.)
Если у вас есть сокет «сервера», поместите его в список потенциальных для чтения. Если он появляется в списке для чтения, ваш accept (почти наверняка) сработает. Если вы создали новый сокет для connect кому-то другому, поместите его в список потенциальных для записи. Если он появляется в списке для записи, у вас есть неплохие шансы на то, что он подключился.
На самом деле, select может быть полезен даже с блокирующими сокетами. Это один из способов определения того, заблокируетесь ли вы — сокет возвращается как «читаемый», когда в буферах есть что-то. Однако это всё ещё не помогает в решении проблемы определения того, закончил ли другой конец работу или просто занят чем-то ещё.
Предупреждение о портативности: в Unix select работает как с сокетами, так и с файлами. Не пробуйте это в Windows. В Windows select работает только с сокетами. Также обратите внимание, что в C многие расширенные параметры сокетов обрабатываются по-разному в Windows. На самом деле, в Windows я обычно использую потоки (которые работают очень хорошо) с моими сокетами.
© 2001–2023 Python Software Foundation
Licensed under the PSF License.
https://docs.python.org/3.10/howto/sockets.html