Назад Вверх Вперёд

Глава II. Общие проблемы, не зависящие от формата.

II.1. Как будет устроено опознание и согласование применяемых версий протокола?

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

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

1. Разрешение значений посылок, до этого являвшихся недопустимыми, и придание им необходимого смысла.

2. Согласованная смена контекста и фактический переход на другой протокол (который имеет другие форматы и правила разбора посылок даже при сохранении правил взаимодействия).

Кроме того, желательно обеспечить совместимость тем участникам, которые не подготовлены к работе с новыми форматами данных и положениями протокола.

Рассмотрим эти вопросы и варианты подробнее.

II.2. Для того, чтобы заложить возможность расширения за счёт ранее недопустимых значений, необходимо (далее почти тавтология, но это необходимо для полного изложения довода):

1. Чтобы cуществовали значения посылок, недопустимые на момент определения протокола и его форматов данных; на следующем цикле разработки эти значения будут переведены в допустимые и им придан смысл. Следствием является правило, что какой бы протокол ни был, необходимо обеспечить значения, которые недопустимы и могут использоваться в будущем - то есть, на любом этапе развития запрещается занимать всё пространство возможных значений.

ASN.1 с его обычными представлениями (BER с уточнениями, а также XER) представляет собой пример формата, который невозможно ограничить от расширения. Например, какую бы структуру данных не передавали, всегда можно назначить значение тегу и вставить в произвольное место структуры поле данных, используя тегирование (неважно, явное или неявное). Аналогичное можно сказать про XML: какие бы мы теги ни ввели, в следующей версии можно ввести другие; впрочем, последнее сходится с общими свойствами текстовых форматов.

2. Чтобы участвующие стороны адекватно реагировали на возможное поступление посылок, не соответствующих известным им форматам. Реализация этого реагирования может существенно зависеть от задачи, но в любом случае адекватным является наиболее либеральная реакция на одиночные попытки и умеренно жёсткая - на систематические (с поправкой на защиту от вредоносных воздействий). Если подложка типа 1 (соединение) и запрос не может быть воспринят, должна быть возможность ответить посылкой с сообщением об ошибке. Например, HTTP и SIP различают случаи неизвестного метода, неподдерживаемого расширения и синтаксической ошибки разбора запроса.

В общем случае рекомендуется при изначальном проектировании иметь возможность спросить специальной командой у другой стороны её способности. Это может быть сделано:

1. Посылкой запроса поддерживаемых возможностей: EHLO в SMTP, OPTIONS в SIP. Такой запрос больше ничего не делает, но позволяет узнать возможности другой стороны. Запрос может быть параметризованным (например, команда CPUID в процессорах x86 получает на вход 32-битный код запроса, отдавая на выходе 128 бит данных соответственно коду).

2. Посылкой запроса проверки определённой возможности.

Далее реализация возможности расширения начинает различаться для разных типов форматов данных (двоичного, текстового, XML), подробности будут описаны в соответствующих им главах.

II.3. Текстовые строки и строки произвольного содержания.

Текстовые строки могут быть представлены:

Сразу заметим, что здесь не рассматриваются вопросы содержания текста в строке; это определяется протоколом и, кроме того, будет достаточно подробно рассматриваться в главе про текстовые протоколы.

Строки произвольного содержания (мы избегаем тут термина "двоичные") могут иметь другие особенности содержания (зависящие от характера их содержания), которые тоже нужно согласовывать, кроме случаев, когда содержание "прозрачно" для протокола. Фактически, к этому случаю относится также глобальный общий вариант "полезной нагрузки" (payload) протоколов всех уровней. Поэтому мы будем рассматривать методы их представления объединённо, используя общий термин "строка" для любой порции содержания, которое передаётся рассматриваемыми методами.

Кодировка определяет набор кодов, используемых для передачи символов текстовой строки, метод кодирования последовательности кодов символов в последовательность байт (октетов), дополнительные особенности представления. Есть несколько общераспространённых кодировок. Наиболее распространено использование набора кодов ASCII или его расширений (включая Unicode) и передача этих кодов последовательностью октетов: по одному октету на символ (семи- и восьмибитные кодировки); по два октета на символ (различные варианты UTF-16, типичные для мира Windows и мира Java); переменное количество октетов на символ (кодировки переменной ширины, из которых самой известной сейчас является UTF-8; в Unicode, UTF-8 является одним из transformation format, но мы в этом описании ограничимся понятием кодировки). Но можно столкнуться и с принципиально иными вариантами - например, EBCDIC в мире S/360 и последователей; в ней '0' имеет код 0xF0, 'A' - 0xC1, 'Z' - 0xE8.

В мире, отличном от англоязычного, исторически существовало множество локальных восьмибитных кодировок; так, для кириллических языков - семейство koi-8 (koi8-r, koi8-u, koi8-ru, iso-ir-111), кодировка cp1251, кодировка iso-8859-5, семейство alt (cp866, cp1125, x-cp866-u). В наших условиях особенно легко столкнуться с ситуацией, например, наивного использования koi-8 в мире Unix и cp1251 в мире Windows (потому что они отображались локально без дополнительной конверсии и потому были удобны для работы); эта причина почти окончательно ушла в середине 2000-х по причине массового перехода на основанные на Unicode дистрибутивы операционных систем, но взамен там же возникла проблема использования UTF-8 и UTF-16 в тех же ролях, Unix системы жёстко предпочитают UTF-8, а для Windows очень широко распространено UTF-16.

Выбранная кодировка и её дополнительные уточнения могут быть назначены протоколом безвариантно (это имеет смысл разве что для Unicode), сообщаться вынесенным указанием или встроенным указанием. Более детально эти подходы будут рассмотрены в главе про текстовые форматы.

Указание длины строки может быть явным указанием длины, использованием терминатора или неявно (потому что закончилась протокольная посылка), причём терминатор может входить или не входить в состав строки. Каждый из этих методов имеет свои преимущества и недостатки. Строка, кроме того, может передаваться целиком; раздельными порциями (фрагментами), но неразрывно (в том смысле, что между представлениями её частей нет других данных); раздельными порциями (фрагментами) разрывно.

Вариант с предшествующим явным указанием длины пригоден для любого содержимого любой длины. Длина является целым числом, и для её представления может использоваться любой разумный метод представления числа; причём часто оказывается достаточно 4 или даже двух октетов. Но для представления строки с длиной есть две очень схожие специфические традиции: "netstring" Д.Бернтшейна и строки Холлерита. В обеих длина кодируется в текстовом виде беззнаковым десятичным числом без ведущих нулей, завершаясь выделенным терминатором (':' для netstring и 'H' для Холлерита), например, строка "Hello, world" будет представлена в netstring как "12:Hello, World," (побайтно: 31 32 3a 48 65 6c 6c 6f 2c 20 57 6f 72 6c 64 2c). Такое представление хорошо для "полутекстовых" протоколов. (Ещё одно различие состоит в том, что для netstring кодировка длины и разделителя - ASCII, а для строки Холлерита - EBCDIC;))

Ряд рассмотренных далее методов (поле длины кадра в IEEE802, поле длины PDU в milter) может быть классифицирован также как представление строки с предшествующей длиной (в двоичном представлении фиксированного размера и порядка), если считать строкой содержимое следующей за ней посылки.

Обратной стороной представления с предшествующей длиной является необходимость знать эту длину заранее. В некоторых случаях это невозможно или явно невыгодно для передающей стороны; но это заведомо более выгодно для приёмной стороны, потому что упрощается выделение ресурсов для принимаемых данных.

Строки с завершающим символом обычно годятся для более ограниченных представлений. Например, ряд интерфейсов ядра Unix (и скопированные с них интерфейсы MS-DOS и MS Windows) использует строки, ограниченные нулевым символом (NUL, ASCII код 0) без явного указания длины. Такой формат строки годится для представления содержимого, в котором не может быть нулевого символа. С точки зрения управления ресурсами, такой формат значительно более выгоден для передающей стороны (ей не надо делать действия по получению длины заранее, что может вылиться в большие затраты ресурсов), но резко невыгоден для приёмной.

Промежуточным вариантом между выгодой для приёмной и передающей стороны является передача по частям, с выделенным маркером завершения всей строки, и передачей каждой части с предшествующей длиной. В HTTP этот метод называется "chunked transfer encoding", обязателен к поддержке и применяется в случае заранее неизвестного размера содержимого (как правило, это выдача скрипта). (Следует помнить, что обсуждаемая "строка" для данного случая - это body для HTTP, которое может тоже содержать строки уже HTTP или своей полезной нагрузки.) Маркером завершения целой строки обычно является строка нулевой длины. Этот вариант позволяет оптимизировать обращение с ресурсами и на передающей, и на приёмной стороне. Для строк, гарантированно не превышающих нескольких килобайт, он излишен (кроме случаев особо жёсткого ограничения по ресурсам). Аналогичный подход реализуется в BER (indefinite length).

II.4. Дата, время, абсолютные значения и временные интервалы.

Представления даты, времени и интервала отличаются чудовищным разнобоем и частой неполнотой или неадекватностью. Параметры, по которым может отличаться представление и интерпретация:

1. Чему равен дискрет представления? Дата и время могут представляться с точностью до года, дней, секунд, миллисекунд, микросекунд, сотен наносекунд, наносекунд...

2. Какая модель времени используется? Модель, применяемая в Unix и Windows, формально может определять время как UTC (Universal Coordinated Time), но дополнительные секунды настоящего UTC она не учитывает (некоторые пишут, что это GMT, а не UTC, но астрономы могут привести значительно более тонкие особенности применённой модели времени). Далее мы будем называть это время "GMT".

3. Представляется ли время в GMT, или локально, а если локально - как указывается его смещение от GMT? Представление в GMT - удобно отсутствием проблем унификации времени различных поясов и летнего/зимнего времени, но неудобно в каждый конкретный момент для тех, кто думает в локальном времени; локальное время требует указания смещения от GMT (иначе возникают тяжело устранимые проблемы с определением этого смещения). Смещение от GMT тоже может быть недостаточно адекватно указано - например, при указании трёхбуквенными названиями зон есть конфликт различения американских и австралийских зон.

Следует также учитывать, что локальное время может быть недопустимым или недостаточно определённым; так, для Киева не существует времени между 3:00 и 4:00 29 марта 2009 года, а период с 3:00 до 4:00 25 октября 2009 года повторяется дважды (первый раз - по летнему времени, второй - по зимнему). Это дополнительно показывает важность указания смещения от GMT даже в пределах одного часового пояса. Дополнительную пикантность вопросу придают случаи разного в прошлом счёта времени в разных областях, имеющих сейчас одинаковый счёт.

4. Какой календарь используется? Какой сегодня день - 7 сентября или 3 элула? А год?;)) В 99.999% случаев вы будете иметь дело только с григорианским солнечным календарём, но надо быть готовым к местным особенностям.

5. Какие особенности представления отдельных компонент даты/времени (если представление покомпонентное)? В struct tm в языке C, год представлен уменьшенный на 1900, номер месяца уменьшен на 1 (январь - 0, декабрь - 11), смещение часового пояса от GMT (в расширении BSD) выражено в секундах.

Хорошим примером грамотной реализации абсолютного времени является формат GeneralizedTime из ASN.1, несмотря на его "полутекстовость". Пример: "20051106210627.3-0500" (6 ноября 2005 г., локальное время 21:06:27.3, смещение от GMT - минус 5 часов). Другим известным вариантом являются time_t, struct timeval и struct timespec из интерфейса Posix: время представляется по GMT независимо от локального пояса, в секундах с фиксированного момента (00:00:00 1 января 1970 г. GMT), поле долей секунды может указывать время с точностью вплоть до наносекунды. Эти же представления могут использоваться и для указания интервала (относительного времени). В последнее время распространён ISO8601 формат (в общем похожий на GeneralizedTime), тот же пример времени выглядел бы в нём как 2005-11-06T21:06:27.3-0500. Их имеет смысл использовать и в двоичном протоколе. Представление времени в Windows - 64-разрядное число без знака - тики (10 миллионов в секунду) с условного момента полночи 01.01.1601 по Григорианскому календарю (т.наз. ANSI date); оно лучше для двоичного протокола, чем для текстового, и в некоторых чертах удобнее unixtime. Все эти представления соответствуют стандартному счёту, принятому и в Unix, и в Windows, "формально UTC, но без вставных секунд", и достаточно для гражданских применений; подробнее можно прочитать в этой статье.

II.5. Частичные передачи, мультиплекирование и управление потоком.

(Эти проблемы - исключительно протокола, но не формата.)

В простом случае решение вопроса, способна ли приёмная сторона принять какие-то данные, а передающая - соответственно имеет ли шанс их передать без потери, решается подложками, такими, как 1-й класс по классификации данного трактата (SOCK_STREAM в BSD sockets, TCP в TCP/IP). При организации каналов собственными средствами возникает необходимость самим выполнять соответствующий контроль.

Реализацию в TCP можно описать следующим образом. Транспорт логически состоит из двух потоков (от стороны A к стороне B, и наоборот), в каждом из которых есть буфер передачи и буфер приёма. Передающая сторона каждого потока способна добавить данные, если у неё есть что передавать. Подложка формирует посылку - полезную нагрузку IP пакета, или группу таких посылок, выбирая момент передачи и объём передаваемых данных в зависимости от:

Получив посылку или группу посылок, приёмная сторона разбирает их, сохраняя данные в том объёме, который она хочет и способна принять (в приёмный буфер). После этого пользователь способен их забрать (и освободить тем самым место в приёмном буфере для новых данных).

Реализация такого рода требует информационного канала в направлении, обратном направлению потока информации, о количестве (объёме) данных, которые приёмная сторона способна принять. В реализации TCP, как и многих других, используется термин "окно" для обозначения логической сущности - диапазона номеров байтов, которые могут быть приняты; это примерно соответствует буферу приёма, но буфер - это место для хранения данных, а окно - это указание на допустимые данные; размер окна обычно соответствует объёму свободных данных в буфере (с точностью до ограничений представления), но по каким-то соображениям может уменьшаться или увеличиваться. Приёмная сторона может в любой момент выслать нотификацию о размере своего окна (в TCP это делается полем стандартной части TCP заголовка, в посылке, общей для обоих потоков), а передающая должна принять эту нотификацию (если получила посылку) и обработать её, обновив своё состояние.

Рассмотрим сценарий. Пусть размеры передающих и приёмных буферов равны 1MB. При установлении соединения стороны обмениваются посылками, в которых указывают начальный размер окна (1MB будет передан, например, как 32KB при коде масштаба 5). Затем сторона A (передающая - для обсуждаемого потока) начинает передавать данные, в то время как пользователь стороны B (соответственно - приёмной) не забирает их. С каждой новой порцией данных занимается место в приёмном буфере, и приёмная сторона сокращает передаваемый ею размер окна, пока не дойдёт до 0. Приёмная сторона оповещает передающую о сокращении размера окна в каждом своём ACK на порцию данных; но, пока этот ACK идёт по каналам связи, у передающей стороны сохраняется старое значение окна (следовательно, разкмное соображение для алгоритма передачи - отправив какую-то порцию данных, сразу сократить своё представление об окне приёма противоположной стороны; в TCP RFC это прописано). Передающая сторона, получая от приёмной стороны ACK с размером окна, обновляет свои сохранённые данные о размере окна приёмной стороны и определяет свои стремления к передаче - нормально может быть передан минимум из двух чисел - объёма готовых к передаче данных и принятого размера приёмного окна (но, забегая в более сложные технологии, управление потоком (flow control) более высокого уровня может предсказать забор данных с другой стороны за время RTT и явно сказать передать больше). Когда подсчитанный и подтверждённый размер окна падает до 0, передающая сторона в общем случае должна прекратить передачу и ждать обновлений размера окна, независимо от количества данных в буфере передачи (опять же, управление потоком может откорректировать эти правила - если цена за лишнюю передачу меньше выгоды от предположительного разгона потока).

Повторим, что качественная работа с окном сложнее, чем кажется на неопытный взгляд: принятый размер окна отстаёт от того, что соответствует состоянию приёмной стороны; если передающая не будет учитывать, для какого принятого байта этот номер был сформирован, она, уже когда приёмное окно будет заканчиваться, будет передавать ещё данные; приёмная сторона будет вынуждена проигнорировать часть присланных данных, которые не помещаются в приёмном буфере, и подтвердить только те данные, что были сохранены ею. Но никто не мешает передавать и ровно тот объём, который равен последнему принятому размеру окна приёмной стороны - это будет, в общем случае, менее эффективно, но сработает.

Теперь пусть пользователь приёмной стороны (B) начал забирать данные. В приёмном буфере начинает появляться место. Реализация TCP на стороне B, чтобы сторона A начала передавать, должна передать обновлённое значение окна; это делается или сразу по мере освобождения, или с какой-то небольшой задержкой. Получив обновлённое окно приёма, сторона A снова может начинать передавать данные, учитывая, опять же, для какого момента передачи было сообщено значение окна.

Поток данных в противоположную сторону (B->A) подчиняется тем же принципам, с точностью до замены сторон.

Рассмотрим нестандартный сценарий. Что будет, если приёмное окно вдруг начнёт уменьшаться быстрее, чем поступают данные? Это возможно, если приёмный буфер вдруг сократится (по заявке пользователя или из-за исчерпания системных ресурсов). Результатом будет неожиданное (для передающей стороны) игнорирование части переданных данных, с необходимостью передать их снова. Это может, в частности, сбить состояние управления потоком и замедлить передачу; но для такой ситуации это нормальные последствия. Но делать такие движения с окном приёма, если приёмный буфер не сокращался, для приёмной стороны однозначно бессмысленно. (Зато может иметь смысл для промежуточного шейпера.)

Увеличение окна (вследствие увеличения приёмного буфера или по иным причинам) не имеет таких тяжёлых последствий, но всё равно требует посылки ACK на передающую сторону, чтобы оптимизировать работу соединения.

Теперь представим себе, что у нас уже TCP в качестве подложки, и надо организовать несколько каналов, в которых требуется управление потоком, аналогичное описанному выше. Какие отличия от описанного выше важны для понимания происходящих процессов?

Основное существенное отличие - нет необходимости поддерживать последовательные номера байтов; подложка обеспечивает сохранение данных (по известному принципу - если данные дошли, то можно считать, что они дошли без вставок, потерь и изменений). Можно тем не менее продолжать использовать последовательную нумерацию, а можно от неё отказаться. Что получится, если отказаться? Очевидно, такая сверка окна и номера принятого байта, к которому оно относится, уже невозможна. Последствия этого - абсолютное значение размера окна перестаёт иметь смысл.

По сказанному, в реализациях, поддерживающих несколько своих потоков внутри одного потокового транспорта (например, протоколы SSH, SPDY), вместо абсолютного размера окна обычно передаётся инкремент значения, то есть насколько передающая сторона должна увеличить известное ей окно, чтобы актуализировать значение. (Мы не видели ни одной реализации, в которой инкремент был бы отрицательным; в этом случае надо было бы держать копию уже отправленных данных на случай необходимости перепосылки.) Этот подход применим в случае TCP и аналогов, потому что в них (при корректной реализации подложки) доставленные данные считаются корректными. Для передачи поверх подложки класса 2 (IP, UDP), надёжности доставки нет, промежуточные значения инкремента могут потеряться, поэтому годятся только абсолютные значения размера окна, как и сделано в TCP; но тогда нужен и последовательный номер байта (sequence number), чтобы это абсолютное значение можно было сверить с тем, что ожидается на передающей стороне.

Можно ли применить технику окна с абсолютными значениями, как сделано в реализации TCP, в протоколе, который уже работает поверх TCP? Можно, но смысл в этом появляется только в том случае, если необходима возможность сократить приёмное окно и тем самым отменить факт приёма уже части переданных данных; но тогда требуется и встречная присылка подтверждений на приём конкретных данных, а для этого - поддержка нумерации байтов - то есть эмуляция части логики TCP. Практически всегда можно обойтись без этого усложнения, ценой затраты некоторого количества памяти на буфер конкретного потока. Ещё одним фактором является то, что в случае подложки 1-го типа (TCP и аналоги) метод с периодическими оповещениями о размере, который критически важен для работы поверх подложки класса 2, становится, наоборот, вредным, потому что может засорить и без того занятые буфера сокетов. (Это требует отдельного раскрытия, но любая организация туннелирования класса 2 поверх класса 1 принципиально проблемна именно тем, что класс 2 рассчитан на то, что перегрузки приводят к потерям пакетов, а не их задержкам; подход, когда подложка задерживает пакеты, чреват положительной обратной связью в заполнении буферов и сохранением устаревших пакетов, которые должны были быть потеряны при правильной реализации.)

Всё сказанное про байты точно так же может быть применено и на случай передачи посылок более крупного размера (например, сообщений прикладного уровня). Но следует учитывать, что, если размер сообщений не ограничен, или среднее сообщение сильно меньше максимального, могут быть проблемы с выделением памяти.

Имеет ли смысл передавать только размер окна, но не последовательный номер посылки? Да, если трафик - не наливного типа, а какого-то другого. Например, можно заказывать оповещения о состоянии удалённого агента, ограничивая их количество на одно управляющее сообщение; агент, получив разрешение на 5 сообщений, тратит это разрешение и снова переходит в пассивное состояние до следующего разрешения. Подобные методы существуют не только для сетевых соединений - см. режим {active,N} для сокетов в Erlang начиная с R17.

Другой аспект мультиплексирования может быть легко продемонстрирован следующим примером. По каналу, организованному поверх TCP соединения, надо срочно передать важное управляющее сообщение. Но за долю секунды до этого на отправку было поставлено "письмо" размером в 20 гигабайт. Что делать, чтобы важное сообщение передать без задержки на доставку 20 гигабайт?

Очевидно, что когда посылка такого размера начала передаваться без возможности вмешаться, делать уже что-то бесполезно - надо было готовиться заранее. Иногда можно создать параллельно существующему ещё одно соединение, а отправку по текущему - приостановить или хотя бы замедлить. Но если такое невозможно? Тогда необходимый метод складывается из следующих мер:

Как выбирается предельный размер фрагмента? С одной стороны, он не должен быть слишком маленьким, чтобы сократить затраты на сопровождение фрагментов в потоке; с другой стороны, слишком большой размер приводит к тому, что крупные посылки могут задерживать другие, более важные. Лучше всего выбирать максимальный размер фрагмента по скорости среды передачи (установленной фактически) и скоростью переключения, требуемой для задачи; например, при 100Mbit/s и 20 миллисекундах на переключение это даёт около 240KB. Минимальный размер должен быть чуть меньше (с запасом) размера MTU среды передачи минус затраты транспорта, потому что меньшее дробление всё равно неэффективно из-за группировки в транспорте; для IP это, в современных условиях, 1000-1200 байт. Как видим, зазор между границами может быть огромным, и есть возможности для адаптации под требования и обстановку.

В случае SCTP можно организовывать такие внутренние соединения средствами самого протокола (реализации поддерживают не менее 10 потоков внутри одного внешнего соединения) с полностью отделённым управлением потоком. 10 - это даже немного больше, чем нужно собственно для quality of service, сокращённо - QoS (которое мы тут описали, не называя явно). В типичных реализациях DiffServ (таких, как 802.1p) предусматриваются только 8 потоков, на практике они очень мало где задействованы полностью, а используются 2-4. В случае других транспортов деление на фрагменты, тегирование кодом потока и промежуточную буферизацию придётся организовывать самостоятельно.

XXX TBC

Copyright (C) 2006-2015 Valentin Nechayev. All rights reserved.
Разрешается полное или частичное копирование, цитирование. При полном или частичном копировании ссылка на оригинал обязательна.

Назад Вверх Вперёд