HOWTO по программированию сокетов

Автор:

Гордон Макмиллан

Розетки

Я буду говорить только о сокетах 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()

На самом деле есть 3 общих способа, как этот цикл может работать - диспетчеризация потока для обработки 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 я обычно использую потоки (которые работают очень, очень хорошо) с моими сокетами.