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

Автор:Гордон МакМиллан

Краткое изложение

Сокеты используются почти повсюду, но это одна из самых неправильно понимаемых технологий. Это обзор сокетов c высоты 10 000 футов. На самом деле это не учебное пособие — вам всё равно придется поработать, чтобы всё заработало. Он не охватывает тонких моментов (а их много), но автор надеется, что он предоставит достаточно информации, чтобы начать их использовать.

Сокеты

Я собираюсь говорить только о INET сокетах (то есть IPv4), но они составляют не менее 99% используемых сокетов. И будет разговор только о STREAM сокетах (т. е. TCP) — если вы действительно не знаете, что делаете (в этом случае это HOWTO не для вас!), вы получите лучшее поведение и производительность от сокета STREAM, чем что-нибудь ещё. Я постараюсь раскрыть тайну того, что такое сокет, а также несколько подсказок, как работать с блокирующими и неблокирующими сокетами. Но начну с блокировки сокетов. Вам нужно знать, как они работают, прежде чем иметь дело с неблокирующими сокетами.

Отчасти проблема с пониманием этих вещей заключается в том, что «сокет» может означать несколько разных вещей в зависимости от контекста. Итак, сначала давайте проведём различие между «клиентским» сокетом — конечной точкой диалога и «серверным» сокетом, который больше похож на оператор switch. Клиентское приложение (например, ваш браузер) использует исключительно «клиентские» сокеты; веб-сервер, с которым он разговаривает, использует как «серверные», так и «клиентские» сокеты.

История

Из различных форм IPC Сокеты являются наиболее популярными. На любой платформе, вероятно, будут другие формы IPC, которые работают быстрее, но для межплатформенного взаимодействия сокеты — это почти единственная игра в городе.

Они были изобретены в Беркли как часть разновидности BSD Unix. Они распространяются по интернету, как лесной пожар. Не зря комбинация сокетов с INET делает общение с произвольными машинами по всему миру невероятно простым (по крайней мере, по сравнению с другими схемами).

Создание сокета

Грубо говоря, когда вы нажимали на ссылку, которая привела вас на эту страницу, ваш браузер делал что-то вроде следующего:

# создание INET, STREAM сокета
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# теперь подключение к веб-серверу через порт 80 - обычный порт http
s.connect(("www.python.org", 80))

После завершения connect сокет s можно использовать для отправки запроса текста страницы. Тот же сокет прочитает ответ и затем будет уничтожен. Правильно, уничтожен. Клиентские сокеты обычно используются только для одного обмена (или небольшого набора последовательных обменов).

То, что происходит на веб-сервере, немного сложнее. Сначала веб-сервер создаёт «серверный сокет»:

# создание INET, STREAM сокета
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# привязывание сокета к общедоступному хосту и известному порту
serversocket.bind((socket.gethostname(), 80))
# стать серверным сокетом
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:
    # принимать подключения извне
    (clientsocket, address) = serversocket.accept()
    # теперь сделать что-нибудь с клиентским сокетом
    # в этом случае мы представим, что это многопоточный сервер
    ct = client_thread(clientsocket)
    ct.run()

На самом деле существует 3 общих способа, которыми этот цикл может работать — диспетчеризация потока для обработки clientsocket, создание нового процесса для обработки clientsocket или реструктуризация этого приложения для использования неблокирующих сокетов и мультиплексирование между нашим «серверным» сокетом и любым активным clientsocket с использованием select. Подробнее об этом позже. Сейчас важно понять следующее: это всё, что делает «серверный» сокет. Он не отправляет никаких данных. Он не получает никаких данных. Он просто производит «клиентские» сокеты. Каждый clientsocket создается в ответ на то, что какой-то другой «клиентский» сокет выполняет connect() для хоста и порта, к которому мы привязаны. Как только мы создали clientsocket, мы вернёмся к прослушиванию дополнительных подключений. Два «клиента» могут свободно общаться — они используют какой-то динамически выделяемый порт, который будет повторно использован по окончании разговора.

IPC

Если вам нужен быстрый IPC между двумя процессами на одной машине, вам следует изучить каналы (pipes) или общую память. Если вы всё же решите использовать сокеты AF_INET, привяжите «серверный» сокет к 'localhost'. На большинстве платформ это позволит сократить несколько уровней сетевого кода и будет работать немного быстрее.

См.также

multiprocessing интегрирует межплатформенный IPC в 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:
    """только демонстрационный класс
      - закодирован для ясности, а не эффективности
    """

    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 не всегда удается избавиться от всего за один проход. И, несмотря на то, что вы это прочитали, в конечном итоге это вас укусит!

В интересах пространства, построения вашего персонажа (и сохранения моей конкурентной позиции) эти улучшения оставлены в качестве упражнения для читателя. Переходим к уборке.

Двоичные данные

Вполне возможно отправлять двоичные данные через сокет. Основная проблема заключается в том, что не все машины используют одинаковые форматы двоичных данных. Например, микросхема Motorola будет представлять 16-битное целое число со значением 1 как два шестнадцатеричных байта 00 01. Intel и DEC, однако, перевернуты байтами, т. е. та же 1 равна 01 00. В библиотеках сокетов есть вызовы для преобразования 16 и 32-битные целые числа — ntohl, htonl, ntohs, htons, где «n» означает сеть, а «h» означает хост, «s» означает короткий, а «l» означает длинный. Там, где сетевой порядок — это порядок хоста, они ничего не делают, но если машина с инвертированными байтами, они меняют байты соответствующим образом.

В наши дни 32-битных машин представление двоичных данных в формате ascii часто меньше двоичного представления. Причина в том, что удивительно часто все эти длинные значения имеют значение 0 или, может быть, 1. Строка «0» будет состоять из двух байтов, а двоичная — из четырех. Конечно, это не подходит для сообщений фиксированной длины. Решения, решения.

Отключение

Строго говоря, вы должны использовать shutdown на сокете, прежде чем использовать close. shutdown — это совет для сокетов на другом конце. В зависимости от передаваемого аргумента, это может означать «Я больше не буду отправлять, но я все равно буду слушать» или «Я не слушаю, скатертью дорога!». Однако большинство библиотек сокетов настолько используются программистами, которые пренебрегают этим правилом этикета, что обычно close совпадает с shutdown(); close(). Поэтому в большинстве случаев явный shutdown не требуется.

Один из способов эффективного использования shutdown — обмен, подобный HTTP. Клиент отправляет запрос, а затем выполняет shutdown(1). Это говорит серверу: «Этот клиент завершил отправку, но все еще может получать». Сервер может обнаружить «EOF«, получив 0 байтов. Он может предположить, что у него есть полный запрос. Сервер отправляет ответ. Если send завершается успешно, то действительно, клиент всё ещё принимает.

Python делает автоматическое завершение ещё одним шагом вперед и говорит, что когда сокет собирает мусор, он автоматически выполнит close, если это необходимо. Но полагаться на это — очень плохая привычка. Если ваш сокет просто исчезнет, не выполнив close, сокет на другом конце может зависнуть бесконечно, думая, что вы просто медлите. Пожалуйста, close ваши сокеты, когда закончите.

Когда сокеты умирают

Вероятно, худшее в использовании блокирующих сокетов — это то, что происходит, когда другая сторона жёстко прерывает (без выполнения close). Ваш сокет, скорее всего, зависнет. TCP — надежный протокол, и он будет долго ждать, прежде чем отказаться от соединения. Если вы используете потоки, весь поток практически мертв. Вы мало что можете с этим поделать. Пока вы не делаете ничего глупого, например, удерживаете блокировку при выполнении блокирующего чтения, поток на самом деле не потребляет много ресурсов. Не пытайтесь убить поток — одна из причин того, что потоки более эффективны, чем процессы, заключается в том, что они избегают накладных расходов, связанных с автоматическим повторным использованием ресурсов. Другими словами, если вам удастся убить поток, весь ваш процесс, скорее всего, будет испорчен.

Неблокирующие сокеты

Если вы поняли предыдущее, вы уже знаете большую часть того, что вам нужно знать о механике использования сокетов. Вы по-прежнему будете использовать те же вызовы во многом одинаковыми способами. Просто, если вы все сделаете правильно, ваше приложение будет практически вывернутым наизнанку.

В Python вы используете socket.setblocking(0), чтобы сделать его неблокирующим. В 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 я обычно использую потоки (которые работают очень и очень хорошо) с моими сокетами.