Содержание


Пример разработки простого многопоточного сетевого сервера

Часть 8. Исполнение команд клиента

Comments

Серия контента:

Этот контент является частью # из серии # статей: Пример разработки простого многопоточного сетевого сервера

Следите за выходом новых статей этой серии.

Этот контент является частью серии:Пример разработки простого многопоточного сетевого сервера

Следите за выходом новых статей этой серии.

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

Протокол

Правила общения клиента с сервером и сервера с клиентом описываются протоколом этого самого «общения», называемым протоколом взаимодействия. В описание протокола входят: перечень возможных запросов/ответов, порядок следования запросов и ответов, представление данных, временные задержки и другие характеристики, которые авторы протокола сочтут важными для своего детища. Ряд характеристик, например, величину задержек, порядок запросов/ответов и используемые представления данных, можно обозначить как "предварительное знание", т.е. набор параметров, которые и клиент, и сервер должны знать заранее и чётко соблюдать их соответствие требуемым значениям для того, чтобы взаимодействие вообще состоялось и прошло успешно.

Запросы и ответы, кроме таких параметров как собственно содержание запроса и действие по этому запросу, могут также иметь другие параметры: исходные данные для выполнения этого запроса и результаты его выполнения. Используя для аналогии материал других статей цикла, можно представить себе запрос или ответ как конфигурационный файл. Применяя несколько разных конфигурационных файлов с разными наборами и значениями опций, можно задавать разные состояния программы, при этом каждую конфигурацию можно для удобства связывать с уникальным символьным именем. Перенося это всё на запросы/ответы в контексте использования протоколов, получим:

отдельная конфигурация (файл) -> запрос/ответ, как сущность
опция конфигурации -> параметр запроса или элемент/поле ответа
значение опции -> значение параметра или поля

Эту аналогию я привлёк здесь неспроста. Разумеется, протоколы бывают разных классов (уровней) (низкоуровневые, среднеуровневые, высокоуровневые). Понятно, что границы этих классов сильно размыты и, подчас, зависят от задачи или точки зрения. Тем не менее, вспоминая материал о конфигурационных файлах, можно в качестве аналога низкоуровневого протокола рассматривать самый первый вариант файла — с чётким указанием порядка следования полей и их размеров. Аналогом высокоуровневого протокола можно считать файл в формате XML. Прелесть этих аналогий в том, что данные извлекаются из запросов таким же образом, как и из конфигурационных файлов. Разница только в способе получения самих запросов - обычно из локальных [файлов] данные считываются надёжнее и быстрее.

Строго говоря, иерархия сетевых протоколов, например, широко распространённого сейчас семейства TCP/IP, строится по другим принципам, основным из которых является инкапсуляция, процесс, при котором каждый уровень иерархии работает только со "своими" данными, "чужие" включаются «как есть» и не анализируются. При приёме из сети "чужие" данные отделяются от "своих" и передаются на следующий уровень. В обратном направлении, при отправке в сеть, "свои" данные присоединяются к "чужим", поступившим с более высокого уровня, и передаются следующему, более низкому уровню. Однако, для нашей, приведённой выше аналогии, главное – не иерархия, а детальность протокола. А в этом смысле все сетевые протоколы, содержащие описание "сетевого кадра/пакета" с явным указанием порядка следования полей и их размеров, являются одинаково низкоуровневыми.

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

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

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

    if (команда_одна) {
        результат = вызываем_одну_функцию(параметры_запроса);
    }
    else if (команда_другая) {
        результат = вызываем_другую_функцию(параметры_запроса);
    }
    else if (команда_третья) {
        результат = вызываем_третью_функцию(параметры_запроса);
    }
    и т.д.

Разумеется, здесь можно применить и оператор switch() или назвать функции так же, как и команды и вызывать их "сразу" – в данном случае это не принципиально, кому и как больше нравится. Более интересные моменты возникают, когда либо клиент по каким-то причинам не может ожидать выполнения запроса дольше определенного времени, либо сервер в состоянии только принять запрос, а выполнить его может лишь через некоторое время.

Стол заказов

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

  • уникальный идентификатор задания;
  • идентификатор учётной записи, от имени которой размещено задание;
  • время размещения задания;
  • запрошенное время запуска задания;
  • фактическое время запуска задания;
  • фактическое время окончания задания.

Удобно использовать эти поля также в качестве индикаторов процессов. Например, если фактическое время запуска установлено в 0, а время размещения содержит актуальное значение, то можно считать, что задание размещено, но ещё не запущено. Или, если фактическое время запуска содержит актуальное значение, а время окончания содержит 0, то это значит, что задание ещё выполняется. Все эти проверки можно выполнять в коде, работающем с заданиями, без введения каких-то дополнительных переменных-флагов.

Порядок исполнения

Если запросы исполняются не сразу после их получения (по желанию клиента и/или из-за высокой загруженности сервера), то необходимо как-то сохранять информацию о них, например, в виде упомянутых выше наборов полей. Но здесь есть «тонкий момент» -- выбор способа хранения таких наборов. Простейший подход - представить набор в виде структуры языка С, и все структуры связать друг с другом в односвязный список без цикла, т.е. в очередь (FIFO). Информацию о новых запросах всегда добавлять в конец списка, запросы на обработку всегда извлекать из его начала. В целях защиты от переполнения запросами длину очереди стоит ограничивать и последующие запросы отбрасывать.

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

Задачу можно решить следующим образом. Сформировать для каждого уровня приоритетов свою, отдельную очередь и задать алгоритм обхода всех имеющихся очередей, например, один из следующих:

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

Собственно, такой алгоритм и является планировщиком заданий (scheduler – «шедулер»). В нашем случае очереди являются ресурсом, с которым работают множество писателей (обработчики входящих соединений) и один читатель (планировщик), поэтому работу с ними надо защищать, например с помощью мьютексов, или семафоров (mutex, от mutual exclusion — взаимное исключение).

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

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

Более эффективным в таких ситуациях будет, на мой взгляд, другой вид структур - деревья. В древовидной структуре число шагов поиска при увеличении числа элементов растёт существенно медленнее. Разумеется, реализация деревьев сложнее реализации линейных структур, однако оно того стоит. Можно, конечно, разобраться в деталях и написать свой код для работы с деревьями, но можно и не изобретать велосипед, а поискать готовое - таких библиотек достаточно, в том числе и для языка С.

Контекст исполнения

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

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

Работа с результатом \версия: Как хранить результат?\

При работе в "отложенном" режиме клиент не будет ожидать результата запроса "на линии". Очевидно, что от момента завершения выполнения задания и до запроса результата клиентом этот самый результат нужно где-то и как-то хранить. Проще всего, наверно, хранить его в ОЗУ, но у такого подхода есть очевидные минусы:

  • ОЗУ не резиновое;
  • В случае сбоя (крах программы, сбой электропитания и т.д.) результат будет безвозвратно утерян.

Поэтому лучше хранить результат на диске. Конечно, диски тоже не резиновые, но всё-таки в рамках одной системы объём жесткого диска (в среднем) пока намного больше объёма ОЗУ, а при аппаратных сбоях помогут RAID'ы в разных вариантах.

В качестве комбинированного способа можно рассмотреть использование ОЗУ для кэширования (хранения) только части результатов для ускорения последующего доступа к ним. Как обычно, в этом случае необходимо продумать стратегию кэширования - что именно, насколько долго хранить, по каким поводам убирать из кэша и т.д. Также нужно поддерживать актуальность и соответствие данных в кэше и на диске.

Организовать хранение результатов можно по-разному. Например, область памяти с результатом можно сбрасывать в файл «как есть» (в двоичном виде) при помощи оператора write(2), без какого-либо разбора/преобразования, а сам файл именовать идентификатором задания (в этом случае нужно гарантировать уникальность этого идентификатора ещё и среди сохранённых результатов).

Кроме того, нужно надёжно хранить связь между идентификатором клиента и результатом работы его задания, к примеру, в специально созданном для этого файле на диске или в БД. По сути, все это и является надёжным способом хранения упомянутых выше очередей заданий в актуальном состоянии.

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

Безопасность

Выполнение заданий клиентов открывает широкий простор для деятельности злоумышленников, которые способны: во-первых, запустить на исполнение злонамеренный код, например, с целью получения больших привилегий, во-вторых – провести атаку типа «отказ в обслуживании» (DDOS-атаку). Первая угроза может стать реальностью из-за ошибок в коде, чаще всего вида ошибок типа "переполнение буфера". DDOS-атаку можно реализовать разными способами, и разрушив работу всего приложения, и вызвав исчерпание ресурсов (за счет превышения предельной нагрузки на центральный процессор, переполнения ёмкости ОЗУ и (или) жесткого диска). Вот примеры возможных лазеек в нашем сервере, проникнув через которые, можно вызвать отказ в обслуживании из-за исчерпания ресурсов:

  • отсутствие ограничения на количество заданий вообще и от каждого клиента в частности;
  • отсутствие ограничения на минимальный период времени между запросами на задания от клиента;
  • отсутствие ограничения на время выполнения задания;
  • отсутствие ограничения на количество процессов/потоков потомков, которые может порождать задание;
  • отсутствие ограничения на системный приоритет процесса, исполняющего задание;
  • отсутствие ограничения на объём ОЗУ, потребляемый исполняющим задание процессом;
  • отсутствие ограничения на размер результата работы задания;
  • отсутствие ограничения на количество сохранённых результатов вообще и от каждого клиента в частности;
  • и т.д.

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

Заключение

Эта статья - последняя из запланированных в цикле, поэтому хочется прокомментировать весь цикл. Я не ставил перед собой цели написать идеальный код упоминаемого весь цикл сервера, который способен решать все на свете задачи. В первых заметках цикла описывались простые вещи, которые можно было проиллюстрировать простыми же примерами, не в ущерб содержательности и в рамках ограничений на размер статей. Более сложные процедуры привели бы к усложнению и «раздуванию» программного кода, и в итоге вся работа над циклом превратилась бы в разработку полноценного программного продукта. Я видел свою задачу не в этом, а в "архитектурном" описании программы, пояснении структуры ее исполнения, а главное -- в освещении тех аспектов, на которые, на мой взгляд, стоит обратить внимание в процессе разработки. Разумеется, я описал только те аспекты программирования, с которыми сталкивался сам, поэтому ни в коем случае не стоит рассматривать весь цикл как исчерпывающее руководство по разработке программ-серверов, а скорее как один из стилей, подходов. Если читатель найдёт в цикле хотя бы фразу или идею, которая окажется для него полезной - столкнёт с мёртвой точки, породит новые идеи, поможет с выбором реализации каких-то моментов - я сочту свою задачу выполненной.


Ресурсы для скачивания


Похожие темы


Комментарии

Войдите или зарегистрируйтесь для того чтобы оставлять комментарии или подписаться на них.

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Linux, Open source
ArticleID=607400
ArticleTitle=Пример разработки простого многопоточного сетевого сервера: Часть 8. Исполнение команд клиента
publish-date=01112011