6. Слегка продвинутые приемыЭти приемы не совсем продвинутые, но они выходят за рамки начального уровня, который мы уже прошли. По сути, если ты добрался сюда, то уже освоил основы сетевого программирования под Unix! Поздравляю! А сейчас мы погружаемся в мир более хитроумных и загадочных вещей, которые все же стоит знать о сокетах. Приступим! 6.1. БлокированиеБлокирование. Ты слышал о нем - но что же это такое? По-простому, "блокировать" значит "заснуть". Ты, наверное, заметил, что когда ты запускаешь listener, он ждет, пока ему не пришлют пакет. На самом деле, при вызове recvfrom() не было входящих данных, и поэтому она "блокировала" (т.е. "заснула") до тех пор, пока не пришли данные. Многие функции блокируют: accept(), все разновидности recv(). Они блокируют потому, что им разрешено. Когда ты открываешь дескриптор сокета с помощью socket(), ОС устанавливает его тип в "блокирующий". Если ты хочешь сделать его "неблокирующим", нужно вызвать fcntl():
Ты можешь опрашивать неблокирующий сокет для получния информации. Если ты читаешь из неблокирующего сокета, когда нет входящих данных, он не может блокировать, и поэтому возвращает -1, при errno будет установлена в EWOULDBLOCK. Вообще-то такой способ опроса плох. Если твоя программа только и делает, что спрашивает у сокета, есть ли данные, она будет съедать немеряно процессорного времени. Более элегантное решение описано в следующем разделе о select(). 6.2. select() - Синхронное мультиплексирование ввода/выводаЭта функция немного непонятная, но зато очень полезная. Например, если сервер должен одновременно принимать новые соединения и получать данные из уже существующих. Не вопрос, скажешь ты, вызываем accept() и пару раз recv(). Не так быстро! Как же ты будешь принимать данные, если вызов accept() заблокировал и ждет соединения? Использовать неблокирующие сокеты? Так не пойдет. Мы не хотим съедать все процессорное время в системе. Что дальше? select() дает тебе возможность следить за несколькими сокетами в одно и то же время. Она будет сообщать, какие сокеты готовы к приему, какие - к передаче, и какие сокеты вызвали исключения, если тебе и это интересно. Синтаксис select():
Итак, select следит за "наборами" дескрипторов: readfds, writefds, и exceptfds. Если ты хочешь проверять ввод из стандартного ввода и сокета sockfd, просто добавь дескрипторы 0 и sockfd в набор readfds. Параметр numfds должен быть установлен в наибольший номер дескриптора, плюс один (sockfd+1) После вызова select() в readfds будет указано, какие дескрипторы из выбранных готовы к чтению. Для получения самих дескрипторов можно использовать макрос FD_ISSET(), читай дальше. Наборы дескрипторов имеют тип fd_set. С ним можно работать через следующие макросы:
Наконец, что это за struct timeval? Иногда нужно, чтобы ожидание прерывалось по истеканию какого-то времени. Может, ты хочешь выводить на экран "Работаю..." каждые 96 секунд, даже если никакое событие не произошло. Эта структура позволяет указать время ожидания. Если оно истекает, select() завершается, даже если не наблуюдалось никакой активности. Поля struct timeval:
Просто укажи в tv_sec количество секунд, а в tv_usec количество микросекунд. Да ,микросекунд, а не миллисекунд. В секунде 1,000 миллисекунд, а миллисекунде 1,000 микросекунд. То есть, в секунде 1,000,000 микросекунд. Почему "usec"? Буква "u" похожа на греческую букву μ (мю), которой обозначают "микро". После вызова, timeout может содержать оставшееся время. Это зависит от варианта Unix. Класс! У есть нас таймер с разрешением в микросекунду! Что ж, не рассчитывай на него. Разрешение юниксового таймера около 100 миллисекунд, и этого таймера тоже. Другие интересные вещи: если установить struct timeval в 0, то select() вернется сразу после опроса всех дескрипторов в списке. Если передать NULL вместо timeout'а, то функция будет ждать бесконечно. И, наконец, если тебе не нужен один из наборов, можно вместо него передать NULL. Этот пример ждет стандартного ввода 2.5 секунд:
Если твой терминал использует построчный ввод, то событие произойдет после нажатия ENTER, а не первой клавиши. Теперь ты можешь решить, что это хороший способ получения данных с датаграммного сокета - я ты прав: может быть. Некоторые юниксы поддерживают select с датаграммными сокетами, а некоторые - нет. Посмотри в справке. Некоторые версии Unix сохраняют в struct timeval время, оставшееся до истечения ожидания. А другие нет. Не стоит надеяться на это, если заботишься о портабельности. (Используй gettimeofday() если надо узнать прошедшее время. Это неудобно, но другого способа нет.) А что происходит если сокет, из которого ты хочешь читать, закрывает соединение? Тогда select() отмечает его как "готовый к чтению", а когда ты вызываешь recv(), она возвращает 0. Так ты узнаешь, что клиент закрыл соединение. Если нужно узнать, есть ли новое соединение с сокетом, который слушает с помощью listen(), добавь его дескриптор в набор readfds . Это был краткий обзор возможностей всемогущей функции select(). Но, по многочисленным просьбам, я написал более серьезный пример. Эта программа - простой многопользовательский чат-сервер. Открой ее, а потом соединяйся telnet'ом ("telnet hostname 9034") - несколько раз. Когда ты напишешь что-нибудь в одном из окон telnet текст появится во всех остальных.
Заметь, что в программе есть два набора дескрипторов: master и read_fds. Главный, master, хранит дескрипторы всех подключенных сокетов и слушающего сокета. Набор master нужен потому, что select() изменяет набор, который ты передаешь. Но мне нужно помнить полный список сокетов, и поэтому я храню его отдельно. А для вызова select() я копирую master в read_fds, в который потом возвращается результат. Но это значит, что каждое новое соединение нужно добавлять в набор master? Да! А когда соединение закрывается, его нужно удалять из этого набора? Да. Обрати внимание, что я проверяю, когда сокет listener готов к приему. Это значит, что у него новое соединение, и я вызываю accept() и добавляю его в список master. А когда клиентское соединение готово к приему, а recv() возвращает 0, это значит, что клиент отсоединился и я удаляю его из списка master. Если же recv() возвращает не ноль, то клиент прислал данные. Тогда я пробегаю по всему списку master и отылаю их всем остальным клиентам. Это был не такой краткий обзор возможностей всемогущей функции select(). 6.3. Пересылка по частямВ разделе о send() я писал, что send() может не послать все байты, которые ты ей передал? То есть, ты хотел послать 512, а она вернула только 412. Куда делись остальные 100 байт? Они все еще в твоем буфере, ждут отправки. Из-за обстоятельств, над которыми мы не имеем власти, ОС решила не слать все данные одним куском, и теперь мы должны отослать их остаток. Ты можешь написать функцию вроде следующей:
В этом примере, s - это сокет, buf - буфер с данными, а len - указатель на количество байт в буфере. Функция возвращает -1 при ошибке (а errno хранит код ошибки - оставшийся от вызова send().) Количество реально отосланных байт возвращается в len. Оно должно быть равно тому количеству, которое нужно было отослать, если не произошла ошибка. sendall() сделает все возможное, чтобы отослать данные, но все равно не сможет ничем помочь при ошибке. И пример вызова этой функции:
А что происходит, когда получатель получает только часть пакета? Если пакеты разной длины, как он узнает, где начало и конец пакета? Да, реальный мир предоставляет достаточно проблем даже для самых отважных! Помнишь о инкапсуляции (из раздела о инкапсуляции данных в самом начале руководства)? Читай дальше! 6.4. Применяем инкапсуляциюА что вообще значит "инкапсулировать данные"? В простейшем случае, это значит прилепить к ним заголовок с какой-либо идентифицирующей информацией, длиной пакета, или и тем, и другим Как должен выглядеть твой заголовок? Как кусок двоичных данных, который отражает все, что нужно для успешной передачи данных. Вот это пространное определение. Хорошо. Допустим, у тебя есть многопользовательский чат на основе SOCK_STREAM. Когда пользователь что-то вводит, тебе нужно передать на сервер две вещи: кто послал сообщение и что он послал. Пока все нормально. "В чем проблема?", спросишь ты. Проблема в том, что сообщения могут иметь произвольную длину. Один парень по имени "петя" может сказать "Тест", а другой парень, "Василий", может сказать "Эй, привет, как дела?" И ты шлешь все эти данные клиентам. Исходящий с сервера поток данных может выглядеть как:
И так далее. Как клиент узнает, где заканчивается одно сообщение и начинается другое. Конечно, можно сделать все сообщения одной длины, и потом пересылать их с помощью sendall(), описанной выше. Но это же отнимает кучу трафика! Мы не хотим отсылать 1024 байт только дла того, чтобы "петя" сказал "Тест". Таким образом, мы инкапсулируем эти данные в пакет вместе с маленьким заголовком. И клиенты, и сервер знают, как упаковывать и распаковывать данные. И сейчас мы определим протокол, который описывает взаимодействие клиента и сервера! В этом случае. предположим имя пользователя имеет фиксированную длину в 8 символов, или дополненное до десяти символов нулями '\0'. А данные пусть имеют переменную длину, но не больше 128 символов. Рассмотрим структуру пакета, которую можно использовать в такой ситуации:
Почему я выбрал ограничения в 8 и 128 байт? Я их придумал сам, руководствуясь здравым смыслом. Возможно, 8 байт недостаточно для имени, и ты можешь выделить для него 30 байт или даже больше. Выбор за тобой. Используя этот протокол, первый пакет может выглядеть как (в шестнадцатеричной и тексте):
А второй:
(Конечно, длина должна хранится в Сетевом порядке байт. В этом конкретном случае длина - всего один байт, так что это не важно, но вообще нужно передавать все числа в твоих пакетах в Сетевом порядке.) Когда ты отсылаешь такой пакет, нужно использовать команду вроде sendall(), чтобы все данные были отправлены наверняка, даже если для этого нужно несколько раз вызывать send(). Аналогично, когда ты получаешь эти данные, нужно их обработать. Ведь ты можешь получить только часть пакета (например, только "2B C2 E0 F1 E8" от Василия, за один вызов recv()). Нужно вызывать recv() снова и снова, пока не будет получен весь пакет. Но как? Мы знаем, сколько всего байт в пакете, поскольку длина указана в самом начале. Еще мы знаем, что максимальная длина пакета 1+8+128, то есть 137 байт (при нашем определении пакета.) Нужно создать буфер, в котором поместятся два пакета. Это будет рабочий буфер, в котором ты будешь собирать пакеты по мере поступления. Каждый раз, когда ты получаешь данные, ты отправляешь их в рабочий буфер, и проверяешь, собран ли пакет. То есть, проверяешь, что количество байт в буфере больше либо равно длине, указанной в заголовке (+1, так как длина пакета не включает сам байт длины.) Если буфере нет ни одного байта, очевидно, что пакет не готов. Это нужно обрабатывать отдельно, так как первый байт буфера не содержит длину пакета. Когда пакет собран, можно его обрабатывать как тебе угодно. Обработай пакет и удали его из рабочего буфера. Но могло случиться, что за один вызов recv() ты получил конец одного и начало другого пакета! Тогда в буфере содержится один целый пакет и еще кусочек. Хе-хе. Вот почему буфера должно хватать на два пакета! Поскольку мы знаем длину первого пакета и число байт в буфере, можно вычесть первое из второго и узнать, сколько байт принадлежат ко второму, незаконченному пакету. Когда первый пакет обработан, нужно сместить второй в начало буфера, чтобы приготовить его к следующему вызову recv(). (Некоторые читатели заметят, что перемещение пакета в начало буфера занимает время, и без этого можно обойтись, используя циклический буфер. Увы, описание циклического буфера выходит за рамки этого руководства. Если тебе интересно, почитай книгу по структурам данных.) Я не говорил, что будет просто. Ладно, я говорил, что будет просто. И это действительно несложно: если будешь упражняться, довольно скоро все поймешь. Клянусь Экскалибуром! 6.5. Широковещательные пакеты - Hello, World!Пока что мы рассматривали передачу данных от одной машины к другой. Но возможно также пересылать данные сразу нескольким машинам! Используя UDP (UDP, а не TCP) и стандарт IPv4, это делается с помощью механизма, который называется широковещание. Для IPv6 (который здесь не рассматривается... пока), широковещание не поддерживается, и приходится использовать более продвинутую технологию мультикастинга. Но не стоит заглядываться в будущее - пока что мы застряли в 32-битном настоящем. Но нельзя просто так взять и начать широковещать ни с того ни с сего. Перед тем, как это сделать, нужно установить флаг сокета SO_BROADCAST. Это как предохранитель над кнопкой запуска ракеты! Вот сколько мощи у тебя в руках! Говоря совершенно серьезно, использование широковещательных пакетов небезопасно. Всякая система, получившая пакет, должна развернуть все уровни инкапсуляции данных, чтобы узнать, на какой порт он послан, и передать его соответствующей программе - или отбросить. В любом случае, она проделывает немало работы, и поскольку через это проходят все машины в локальной сети, большинство, скорее всего, тратит время зря. Эта проблема была в сетевом коде первой версии игры Doom, и вызвала немало нареканий. Да, я сказал в локальной сети. Как правило, широковещательные пакеты ограничиваются локальной сетью, как их не отсылать. И вся соль вот в чем: как указать адрес назначения для широковещательного пакета? Есть два способа.
Что же происходит когда ты отсылаешь данные на адрес широковещания, не установив флаг сокета SO_BROADCAST? Давай запустим пример talker и listener и посмотрим.
Да, все не так уж хорошо... а все потому, что мы не установили флаг SO_BROADCAST. Сделав это, можно слать куда угодно... и кого угодно! Это единственное отличие широковещающего приложения UDP от не-широковещающего. Откроем программу talker и добавим SO_BROADCAST. Назовем новую программу broadcaster.c:
Как эта ситуация отличается от "нормальной" ситуации UDP клиент-сервер? Никак! За тем исключением, что клиент может слать широковещательные пакеты. Так что можешь запустить listener в одном окне, broadcaster в другом, и предыдущий пример должен заработать
А listener должен сообщать о получении этих пакетов. Это уже интересно. Но запусти теперь listener на двух машинах в сети одновременно и снова broadcaster на адрес широковещания... Обе программы listener получили пакет, хотя ты отослал его только раз! Здорово! Если listener получает пакеты, посланные на его адрес, но не широковещательные пакеты, их блокирует брандмауэр на локальной машине. Будь осторожен с широковещательными пакетами. Каждая машина в локальной сети должна будет их обрабатывать, и это конкретно загружает всю сеть. Широковещание должно использоваться редко и в соответствующей ситуации.
|
|||||||||||||||||||
© 2007 coldFlame aka Леонид Шевцов | |||||||||||||||||||