Boost.Asio, шаблон «Активный объект» и многопоточные приложения

17 апреля 2011 г.

Статья нацелена на тех, что считает себя «Experienced C++ Developer».
Предполагается, что читатель имеет достаточный опыт работы с C++, а также:

  • знает, что такое RAII и умеет (любит) использовать этот принцип в C++;
  • «не боится» шаблонов, исключений, «умных» указателей и «modern C++ design» в целом;
  • знает на уровне минимальных попыток применения своих знаний, что такое сокеты и как с ними работают в операционных системах семейства Windows (особенно IOCP);
  • в общих чертах знает ACE (ADAPTIVE Communication Environment) – для чего нужен этот framework и что он дает программисту.

Кроме того, предполагается, что читатель имеет опыт работы с Boost C++ Libraries): Smart Ptr, Thread, Bind, Utility, Asio; и, главное, полностью (!) прочитал документацию по Boost.Asio. Если же «Experienced C++ Developer» не знает Boost и Boost.Asio, то ему не составит труда представить «что это за фрукт», прочитав документацию и потеряв около одного рабочего дня пару выходных (для чего, собственно, и приводятся ссылки).

Все описанное ниже (для тех, кто восклицает: «покажите мне код») реализовано в работающем коде. Однако читать его без некоторого введения не каждому по зубам понравится.

Прежде всего, хочу сказать, что в свете тех публикаций, что удается найти в интернете, складывается мнение, что создавать высоконагруженные многопоточные системы на C++ (например, HTTP-сервер) в наше время считается или экономически невыгодным или заоблачно сложным. Данная статья не имеет целью доказать обратное. Для каждой задачи следует выбирать адекватный инструмент, но иногда то, что кажется слишком сложным для реализации на C++, может иметь вполне приемлемое и отлаженное решение, о котором почему-то не знает Google.

Итак, рассказывать про шаблон «Proactor» (см. книги по ACE – C++ Network Programming) и Boost.Asio я не стану. Все это не поместится в рамки одной статьи. Рассказ про шаблон «активный объект» (Active Object) тоже не входит в мои планы – лучше всего на этот вопрос ответит Google.

Я знаком с Boost.Asio со времени его появления в Boost C++ Libraries. При этом знаком (на уровне «тотального» чтения исходников) лишь с той его частью, что работает под Windows. Как раз перед тем, как обратить внимание на Boost.Asio, я изучал работу с сокетами, а именно, работу с механизмом «IO Completion Port» по книге «Network Programming for Microsoft Windows». Более того, я написал простенький эхо-сервер, в котором цикл incoming-connection-acceptance выполнялся в режиме ожидания события на сокете, а сам ввод-вывод шел в режиме IOCP. Уже тогда передо мной встала задача того, как, не блокируя рабочие потоки на все время обработки очередного пакета завершения ввода вывода (completion packet), обрабатывать пакеты завершения, относящиеся к одной сессии, но пришедшие друг за другом в разные рабочие потоки. Позже элегантное решение этой задачи было найдено в Boost.Asio – boost::asio::io_service::strand.

Для тех, кто знаком с IOCP и сокетами, следует напомнить, что еще в «Windows для профессионалов. Создание эффективных Win32-пpилoжeний с учетом специфики 64-разрядной версии Windows» Рихтер рассматривал IOCP еще и как способ построения эффективного пула потоков. При этом (по памяти – книги под рукой нет уже давно) он ссылался на то, что Windows весьма эффективно переключает (планирует) потоки, ожидающие очередного пакета завершения. Это может служить неким дополнительным плюсом к той теме, что будет описана ниже.

Развитие идеи

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

Однако, выделять на каждый активный объект своего исполнителя (читайте поток) слишком расточительно для программы, использующей множество активных объектов, моделирующих некие выделенные в логике ее работы сущности. Примерами таких сущностей могут быть сеансы связи сервера с клиентами, «менеджеры», resolver-калькуляторы, объекты игрового мира в игровом движке, renderer-ы и пр. объекты (как «мелкие» так и «комплексные» – собранные из «мелких»), которые в логике программы выделены как «распараллеленные». «Исторически» после столкновения решения «использовать в лоб по отдельному потоку там, где требуется параллельное исполнение» на помощь всегда приходит решение «а давайте-ка сделаем пул потоков».

Однако и здесь, в конце концов, возникает некоторый диссонанс – большое количество задач, генерируемых некоторыми активными объектами (или медленное выполнение таких задач), может привести к тому, что задачи других активных объектов будут оставаться «в стороне». Да, пул потоков можно построить так, чтобы ставить в очередь задачи с приоритетами, но это не всегда возможно, если пул потоков строится на каком-то специализированном механизме ОС (хотя уже в примерах Boost.Asio как раз таки приводится такой пул с приоритетами), да и не гарантирует, что низкоприоритетные задачи вообще дойдут до исполнения при большом количестве высокоприоритетных задач.

Как раз в этот момент «на встречу выкатывает» решение использовать несколько пулов с разным количеством потоков (возможно с различными приоритетами) в каждом пуле (при этом вполне допускается, что задачи могут мигрировать из одного пула в другой). Подобное решение может оградить отдельные группы задач друг от друга. При этом количество пулов может варьироваться от одного до нескольких и, наравне с количеством потоков и их приоритетом, может задаваться настройками приложения. Так же настройками приложения может задаваться привязка определенных групп задач (групп активных объектов) к пулам потоков – все это позволит «затачивать» приложение под конкретное «железо» с учетом количества процессоров/ядер и пр. (чем не настройка Apache HTTP Server или Apache Tomcat?).

Следует отметить, что с тем вариантом сессий, что неоднократно приводится в примерах Boost.Asio иного способа обращения к сессии (которая уже там, по сути, является активным объектом), как через постановку запроса на исполнение метода в очередь (читай strand) нельзя (имеется в виду эффективная методика, а не «boost::mutex-в-лоб»). При этом тот вариант «размазывания кода по классам», что приводит автор Boost.Asio, является «вполне красивым» и отлично вписывается в логику работы любого сетевого приложения (особенно сервера) – другие возможные решения будут лишь его «обрубками». И это (внимание) характерно не столько для Boost.Asio, сколько для шаблона «Proactor» в целом (посмотрите на код примеров из ACE). А с учетом того, что внешний интерфейс Boost.Asio весьма «чист» в теоретическом плане (и ничто не мешает поправить реализацию, в случае неудовлетворения существующей), то даже с переходом на другую библиотеку/framework, реализующую шаблон «Proactor», идея, изложенная в данной статье должна остаться актуальной.

Таким образом, при выборе шаблона «Proactor» иного варианта, как использовать шаблон «активный объект» для «общения» с объектами-сессиями у нас не остается. Хотя этого и нет в примерах Boost.Asio (там автор всячески старается показать, что Asio можно использовать по-разному, что правильно, так как Asio – не framework, а довольно гибкая библиотека) – вместить все это в документацию библиотеки было бы непросто. Однако где один «активный объект», там уже «тянутся ручки» вставить и еще несколько – уж больно удобная парадигма (так показалось мне и, наверное, разработчики Symbian OS со мной согласятся).

Ниже приводится общая схема построения пулов потоков и связанных с ними активных объектов на базе Boost.Asio. Скажу сразу – это не мое изобретение, а просто развитие той идеи, что неявно приводится автором библиотеки в одном из примеров, входящих в состав документации.

Связь активных объектов с классом boost::asio::io_service и пулами потоков

Пример: TCP-based сервер, в котором incoming-connection-acceptance выполняется на одном пуле потоков (1 поток с обычным приоритетом), а обработка ввода-вывода каждой из принятых TCP-сессий ведется на другом пуле (количество потоков = количество ядер процессора + 1).

В этом случае даже при большом количестве подключений-отключений к серверу на обработку ввода-вывода TCP-сессий (при наличии необходимости в такой обработке, т.е. при полностью загруженном втором пуле – ситуации, когда количество задач не превышает количество рабочих потоков не рассматриваем) будет потрачено не менее чем (количество ядер процессора + 1) / (количество ядер процессора + 2) общего времени работы процессора (имеется в виду время выполнения всего процесса-сервера).

Ниже более подробно рассмотрена архитектура одного активного объекта – построенного на базе Boost.Asio с применением пула потоков, работающих на одном объекте класса boost::asio::io_service, т.е. выполняющих метод boost::asio::io_service::run или любой иной (их несколько – run_one, poll и т.д.).

Общая схема работы одного активного объекта на базе Boost.Asio

Еще ниже приводится схема, описывающая непосредственную реализацию вызова метода объекта через boost::asio::io_service::strand. Следует обратить внимание на то, что все async-методы активного объекта по сути лишь используют boost::bind, active_object::shared_from_this и boost::asio::io_service::post (или boost::asio::io_service::dispatch – из соображений эффективности при малом времени выполнения соответствующего sync-метода) для того чтобы «запланировать» выполнение sync-метода. Это позволяет добиться последовательного выполнения sync-методов активного объекта без блокировки рабочих потоков на все время выполнения очередного sync-метода.

Реализация асинхронного метода активного объекта через boost::asio::io_service::strand

Использование shared_from_this необходимо для гарантии того, что функциональный объект, представляющий вызов sync-метода, оставался валидным на все время нахождения этого функционального объекта во внутренней очереди boost::asio::io_service::strand.

Замечу, что для всех объектов Boost.Asio – «strand»-ы, сокеты и пр. (кроме boost::asio::io_service), считается нормальным, если асинхронный вызов на этом объекте «удерживает» сам объект (в нашем случае это strand). Решается это за счет того, что внутри деструктора boost::asio::io_service в первую очередь выполняется shutdown для всех, входящих в io_service служб (см. документацию по Boost.Asio, если успели забыть все-таки не посчитали нужным прочитать ее всю). Это приводит к тому, что перед непосредственным уничтожением конкретного io_service завершаются все асинхронные операции и разрушаются все «handler»-ы (функциональные объекты), так или иначе связанные с этим io_service (т.е. фактически наши асинхронные вызовы будут удерживаться соотв. объектом класса boost::asio::io_service).

«Финт ушами»

Особая прелесть библиотеки Boost.Asio заключается в ее парадигме параллельного (асинхронного) выполнения в отношении callback-ов и управления памятью/ресурсами (Threads and Boost.Asio, Custom Memory Allocation, Requirements on asynchronous operations).

В отношении данной статьи все решение (ma::handler_storage) лежит здесь. К сожалению, статья так растянулась, что вдаваться в подробности функционала/реализации класса ma::handler_storage нет времени (пока). Думаю, читатель, которого заинтересует статья, сможет самостоятельно разобраться в коде «asio samples».

Заключение

При помощи Boost.Asio и др. библиотек, входящих в состав Boost C++ Libraries, можно создать довольно эффективный (пока в теории, в планах тестирование) многопоточный код, который будет выглядеть как множество активных объектов, взаимодействующих друг с другом. При этом все активные объекты можно распределить по нескольким пулам потоков (к каждому пулу привязывается некоторое количество активных объектов), что в теории позволяет провести некоторое подобие балансировки по приоритетам (за счет количества потоков и их приоритетов в конкретном пуле). При этом сам код не будет иметь «заоблачную» сложность, сохранит ООП-архитектуру, будет вполне поддерживаемым и относительно кроссплатформенным. К минусам такого подхода можно отнести необходимость внимательно выстраивать архитектуру приложения (и особенно схемы «владения» одних объектов другими – например, во избежание циклических ссылок на базе boost::shared_ptr).

Вместо P.S.
  • Хочется упомянуть, что Boost.Asio поддерживает еще и последовательные порты (и у меня есть работающий «asio sample»), файлы, named-pipes и пр. IPC, что встречается в Windows и в *nix.
  • Еще есть такие близкие темы как SEDA и Apache MINA.
  • Интересно сравнить с современными «трендами» в многопоточном программировании – функциональными языками (например, Erlang), языками, специализированными на многопоточном программировании (Scala, Clojure), Python Twisted, greenlet и coroutine. Тут уже нужно писать что-то конкретное и проводить тестирование.
  • «Под Windows» интересна связка вида: GUI на Qt + многопоточный ввод/вывод (Google Protocol Buffers?) и часть логики на Boost.Thread и Boost.Asio + «классически» распараллеленная логика на Intel TBB = современное многопоточное приложение (сервер, клиент и пр., например, игра).
  • Можно упомянуть, что nginx позиционируют не только как готовое решение, но и как framework для создания серверных приложений – интересно сравнить (учитывая, что у nginx, «все свое»). Понятно, что Boost.Asio до nginx еще далеко, но когда в наше время middleware-решения пишут на C, то меня это несколько настораживает – ведь в свете будущего стандарта C++0x, код на C++ будет гораздо более подвержен оптимизации, чем код на C (хотя, думаю, это и сейчас обстоит именно так).
Ссылки
  1. sourceforge.net/projects/asio-samples – код (самый свежий брать с SVN), построенный на описанных в статье идеях.
  2. cplusplus-soup.com/2006/12/06/boost-asio-and-patterns/ – чем-то похожая, но более абстрагированная статья Dean Michael Berries.
  3. www.drdobbs.com/cpp/225700095 – статья про активные объекты в Dr. Dobb’s Journal.
  4. www.drdobbs.com/go-parallel/blog/archives/2009/09/parallelism_sho.html – еще одна статья в Dr. Dobb’s Journal (по сути интересно только одно из ее заключений – «IMPLICATIONS FOR COMPLEX REALTIME SYSTEMS: Complex real-time systems would be a sea of state machine objects interacting with each other. There will be low level state machines which would work together to implement high level state machine behavior»).
Теги:
рубрика Программирование
  • Похожие статьи
  • Предыдущие из рубрики