Программирование сокетов 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 более высокого уровня.

Использование сокета

Первое, что следует отметить, это то, что «клиентский» сокет веб-браузера и «клиентский» сокет веб-сервера - одинаковые звери. То есть, это общение «равный с равным». Или, говоря иначе, как дизайнер, вы должны будете решить, каковы правила этикета для разговора. Обычно, сокет 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, если в сообщении есть встроенные \0s).

Самое простое усовершенствование - сделать первый символ сообщения индикатором типа сообщения, а тип определяет длину. Теперь у вас есть два recvs - первый для получения (по крайней мере) первого символа, чтобы вы могли посмотреть длину, а второй в цикле для получения остальных. Если вы решите пойти по пути разделителей, вы будете получать куски произвольного размера (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 я обычно использую потоки (которые работают очень, очень хорошо) с моими сокетами.

Вернуться на верх