Как построить полусинхронную архитектуру на примере telecom-приложения
by Denys PoltorakПотреблять с базовым представлением о multithreading и design patterns.
Меня зовут Денис, я свитчер из химии, за 10 лет в embedded так и не научился паять и пользоваться осциллографом. В статье попытаюсь объяснить разницу между синхронным, асинхронным и смешанным подходами обработки событий, в частности — для soft real time систем с относительно большим количеством логики.
Итак, SIP<->(DECT|FXS) gateway внутри Wi-Fi роутера.
Действующие лица
DECT — стандарт, по которому работают радиотелефоны. Описывает связь между трубкой и базой. Разбивка по уровням похожа на OSI-стек. Томов много, но большую часть (нижние уровни стека) имплементят производители «железа» в фирмваре, так что в приложении остается совсем ничего, какие-то три тома: GAP (звонки), CAT-iq 1 (согласование кодеков), CAT-iq 2 (параллельные звонки, телефонная книга, история звонков).
FXS — разъем, в который вставляется телефонный шнурок. Есть стандарты ее поведения, но они старые и отличаются в зависимости от страны. Поэтому их никто не читал и читать не будет. Сам разъем обычно обрабатывает специализированная плата — SLIC. Ею можно управлять с нормального процессора через несколько десятков регистров, пару ножек и голосовую шину.
SIP — семейство стандартов для IP-телефонии. Вернее, стандарт и 100500 дополнений. Точное их количество неизвестно, на практике всегда найдется что-то, о чем даже специалисты с годами опыта не имеют представления. Одни дополнения меняют рекомендованное поведение других дополнений и описывают это — от видеозвонков до мессенджеров. Из-за объема и невнятности накодить самому поддержку нереально. Нужно брать чужие готовые библиотеки, протестированные на ежегодных конференциях по совместимости.
Вочдог (watchdog) — большой начальник, инвестор. Если ему долго не рапортовать о результатах работы (либо имитации бурной деятельности), забирает деньги, увольняет всех и закрывает контору.
Дебаг (отладка, debug) — аудиторы реальности. Приходят с проверкой пошагово разбирать сложные производственные процессы, если кому-то наверху кажется, что что-то пошло не так.
Мютексы (mutex) — управленцы. Если назначить мютекса начальником кабинета, он туда пустит потоки только по одному и после заполнения соответствующих бумаг. К сожалению, это связано с большим количеством бюрократии, в которой легко запутаться. Кроме того, если мютексы сидят в соседних комнатах и потоки столкнутся в дверях между ними — работа остановится, потому что потоки не любят уступать дорогу. Такая ситуация называется «дедлок».
Потоки (thread) — хорошие ребята, рабочие лошадки. Амбициозны: когда два потока займутся одним и тем же делом, будет конфликт и они побьют друг другу данные.
Actors — выдаем каждому потоку право владения своей комнатой (как в НИИ завлабы). Каждый поток делает свою работу у себя, а с соседями обменивается только пробирками и матами.
Adapter — переводчик стихов. Часто пишет отсебятину в надежде, что понимает оригинал лучше автора.
Layers — организация конторы в соответствии с уровнем заработной платы. Джуны работают, сеньоры пьют чай, руководители чем-то заняты.
Mediator — риелтор.
OS/Vendor/Hardware Abstraction Layer — демократические выборы. Неважно, кто и за кого голосует — по сути ничего не изменится.
Proactor — находим одного доктора Манхеттена и выдаем ему во владение весь НИИ. Он быстро бегает и все делает сразу, при этом нигде не останавливается и никого не ждет, пока есть работа. Из-за занятости единственный вид коммуникации — электронная почта или посылки.
Reactor — менее талантливый парень. Интроверт. Если ему что-то нужно сделать, он сядет под кабинетом и будет ждать своей очереди. В результате, пока занят одним делом, не может начать следующее.
State — подход, когда в одной комнате посменно работают бригады разного вида деятельности.
Upper/Lower Half — разделение общества на пролетариат и буржуазию.
Visitor — подход службы занятости. Все стоят в одной очереди, заполняют бумаги с указанием профессии, расходятся по домам. А потом каждому перезванивает начальник по его профилю и приглашает к себе на собеседование.
Описание системы
Есть домашние Wi-Fi роутеры с USB-портами. Есть USB DECT база (обеспечивает связь с трубками радиотелефонов). Нужно, чтобы при подключении USB DECT-базы роутер превращался в телефонную станцию. Чтобы можно было с радиотелефонной трубки звонить через сеть, используя семейство стандартов связи SIP. После трех лет в продакшене понадобилось добавить также поддержку проводных телефонов, сделав свое USB FXS-устройство.
В итоге имеем embedded telecom soft real-time application. Астериск в роутер не влазит по размеру (флеша всегда мало), да он и не поддерживает нужные USB-«железки». Значит, пишем свой, маленький и непрожорливый. На роутере стоит линукс, что несколько облегчает задачу:
- Программу можно будет запускать и дебажить на компьютере (совместимый код) без дополнительной разработки OS Abstraction Layer. Большой плюс.
- Линукс дает pthread и потоки с приоритетами. В результате обработка логики сможет прерывать работу с файлами или индексацию телефонной книги, а передача голоса между USB и сетевыми сокетами — логику.
- Можно втянуть небольшие библиотеки. В данном случае — libusb и PJSIP. Последняя просто спасла ситуацию: написать с нуля поддержку SIP со всеми дополнениями нереально.
Ограничения:
- Soft real-time (voice) — голосовые пакеты бегают каждые 10-20 мс (в зависимости от «железа») для каждого звонка. Если задержать пару пакетов, в голосе будет щелчок. То есть, чем бы приложение ни занималось в данный момент, оно всегда должно быть готово обработать голос: либо по выставленному на 20 мс таймеру, либо когда голосовой пакет приходит.
- Soft real-time (logic) — когда пользователь нажимает кнопку (или поднимает трубку) на телефоне, он ожидает какую-то реакцию. Можно протупить 100 или 200 мс, но за полсекунды очень желательно отреагировать: начать гудеть при поднятой трубке, оборвать звонок при положенной, разобраться, соответствует ли номер с новой набранной цифрой одному из правил набора. Если да — отправить запрос создания звонка на сервер через нужную учетку. При этом неважно, что в данный момент делают другие пользователи с другими трубками, есть ли связь с сервером или насколько эта связь тормозит — действия пользователя должны немедленно приводить к ожидаемому результату.
- Процессор — кодеки на таком лучше не запускать: они начнут тормозить систему, торренты остановятся — и покупатели выбросят роутер через окно.
- Флеш (размер программы и библиотек) — очень ограничен. Файловая система read-only.
- Язык — С или С++, смотри пункт 4.
Стандарты SIP, DECT и обработка FXS довольно сильно отличаются, так что просто преобразованием сообщений и данных не обойтись — будет много логики и состояний. Кроме того, пользователи хотят плюшек, иначе это «тупая звонилка». Сюда входят, например, телефонная книга и история звонков, правила выбора учетки и сервера в зависимости от набранного номера, переключение между звонками или перенос вызова при нажатии комбинаций кнопок... Еще одна фича — работа с чужими серверами и трубками. В случае проблем совместимости виноват тот, кто пришел на рынок последним.
Обзор архитектуры
Далее описывается последняя версия приложения (с поддержкой USB FXS устройства). Общая структура с начала проекта не менялась, только добавлялись новые модули, а старые делились на части, когда кода в одном классе становилось слишком много.

Использованный подход называется Half-Async/Half-Async [POSA2]. Суть в том, чтобы выделить один поток (на картинке — большой квадрат с месивом из стрелочек) под бизнес-логику, ни на чем его не блокировать, а с периферией общаться месседжами. Тогда нет race conditions в основном коде, при этом все данные (как последнее известное состояние периферии) синхронно доступны из прокси, и обработка любой операции происходит быстро (high throughput/interactivity). Бонусом получаем Hardware/Vendor Abstraction Layers.
Если посмотреть на систему, видно, что это гипертрофированный Adapter [GoF] с сотнями настроек, логированием, телефонной книгой и прочими блэкджеками. Соединяет телефонный сервер где-то там далеко и локальное «железо», подключенное через USB.
Начнем с центра:
Call — то, ради чего система существует. Связывает два полиморфных участника, передает между ними события. Разросшийся Mediator [GoF].
Line, HS (Handset), Port — с одной стороны, участники звонков, с другой — Proxy [GoF], моделирующие состояние и обеспечивающие связь с «железом» (или учеткой на сервере). Очередной Adapter между абстрактным внутренним интерфейсом/контрактом звонков и логикой работы соответствующего типа (SIP/DECT/FXS) периферии.
SIP/DECT/FXS Wrapper — адаптеры между внутренним высокоуровневым протоколом и API конкретной «железки» или библиотеки. Mostly stateless. Тут водятся мютексы. Цель прослойки — отделить управление hardware от бизнес-логики и оградить последнюю от любых изменений периферии (версии, вендор) Messaging-интерфейсом (как лабораторию в [Devs]).
DECT/FXS Autotest — эмулятор устройства, управляемый из командной строки. Назначение соответствует названию.
DB + Files — поднятые в память телефонная книга и история звонков. Дают синхронный доступ с последующим асинхронным сохранением изменений в файл.
App + CLI — интерфейс управления из командной строки.
Notify — кормит тролля демона информацией о происходящем.
Timers — стек таймеров. Компонент может подписаться на колбек через Х мс, из колбека кинуть себе сообщение и потом обработать его в основном потоке как событие.
AudioDev — передача аудиофреймов между сетью и USB-устройством. Бежит в максимальном приоритете, просыпается каждые 20 мс, обрабатывает все каналы и опять засыпает.
Синхронность и асинхронность
Сравним:
- Блокирующий синхронный Thread per Request (Reactor [POSA2]).
- Неблокирующий полусинхронный Half-Async/Half-Async (Proactor [POSA2]).
- Асинхронный Actors (Active Objects [POSA2]).
Для примера возьмем относительно простой, но показательный сценарий:
- Приходит входящий вызов из сети (INVITE).
- Создаем звонок.
- Ищем номер звонящего в телефонной книге, чтобы отобразить имя.
- Рассылаем звонок ({CC-SETUP}) на 3 зарегистрированные DECT-трубки.
- Отвечаем 100 Trying (приняли звонок, обрабатываем) серверу.
- Вторая трубка включена и громко звонит ({CC-ALERTING}).
- В то же время от сервера приходит отмена звонка (CANCEL).
- Отсылаем 180 Ringing (устройство играет мелодию звонка) на сервер.
- Отсылаем 487 Terminated (звонок завершен) на сервер.
- Рассылаем завершение вызова ({CC-RELEASE}) на трубки.
- Сохраняем звонок в истории звонков.
Синхронная блокирующаяся обработка звонка (Reactor) — ждем, пока пользователь поднимет трубку:

Диаграмму взаимодействия не удается дорисовать из-за следующих проблем:
- Нам нужно разослать входящий звонок на все зарегистрированные DECT-трубки. Правильным в синхронной парадигме было бы делать RPC для каждой. Вот только проблема: мы не знаем, какие из трубок включены и доступны. DECT — энергосберегающая технология, и кипэлайвы не предусмотрены. То есть база посылает сообщение и ждет, будет ли ответ. После таймаута (порядка 5 секунд) USB DECT донгл пришлет извещение, что трубка не найдена и звонок никуда на пошел. Но ведь пользователь, который нам звонит, не будет ждать на линии бесконечно, пока мы будем на всех когда-то зарегистрированных трубках поочереди получать таймаут по 5 секунд. Звонящий положит трубку, а наш юзер даже звонок услышать не успеет. Второй вариант — что-то в стиле foreach(), таким образом разослать запрос на все трубки. Тут другая проблема: а на чем тогда блокировать выполнение для ожидания ответа, если мы разослали запрос через foreach()?
- Допустим, разослали foreach() и ожидаем, пока какую-то трубку поднимут (нажмут зеленую кнопку). Трубки начинают присылать {CC-ALERTING} — сообщение о том, что играет мелодия. По-хорошему, первое из них нужно преобразовать в 180 Ringing и отправить на сервер. Тогда у звонящего пойдут длинные гудки. Вопрос — как это сделать, если наш поток-обработчик звонка висит и ждет, пока поднимут трубку. Заводим второй поток-обработчик ALERTING и получаем межпоточную синхронизацию с мютексами для доступа к звонку, трубке и линии. И начинаются на каждом шаге проверки того, а живой ли звонок, для которого мы сейчас обрабатываем запрос. Ведь какой-то другой поток, обрабатывающий другой запрос, мог его убить. Второй вариант — блокироваться на обработчике многих событий и выходить из блокировки и когда приняли звонок, и когда трубка что-то другое прислала. Но в этом случае мы начинаем дублировать (рекурсивно вызывать?) main loop нашего потока, только уже с несколькими вызовами методов на стеке. Это некрасиво. Третий вариант — забить на ALERTING. И без него жить можно.
- Допустим, забили на ALERTING. Сервер присылает CANCEL — звонящий передумал. Нужно погасить звонки на трубках, сохранить пропущенный вызов в историю и освободить ресурсы. Как мы это сделаем, когда CANCEL приходит с той же стороны, на которой начинается наш стек вызовов? Мы же ожидаем результат от DECT-трубок, а не отмену звонка из сети. Обрабатываем CANCEL другим потоком как новый запрос — снова получаем мютексы и проверку состояния всех объектов при каждом обращении к ним. И как фаталити — нужно будет потоком-обработчиком CANCEL как-то разбудить поток-обработчик INVITE, который до сих пор ждет, какая из трубок примет звонок.
В итоге синхронная обработка событий с блокировкой на периферии в данной системе приводит к необходимости вводить межпоточную синхронизацию, что сильно усложнит код. А еще там водятся дедлоки, учитывая то, что реакция на сообщения может распространяться по системе в противоположных направлениях.
Нужно отказаться от блокировки потока-обработчика событий. Это рвет сценарий обработки запроса (входящего звонка в данном случае) на несколько асинхронных частей, но спасает от мютексов и дедлоков. Обработчик один на всех, входящие события со всех сторон системы складываются в одну очередь, а поток их по одному вынимает из очереди и процессит. В память никто чужой не лазит, синхронизация не нужна. Enter Proactor.

Здесь видим, что весь сценарий, который невозможно было решить синхронной блокирующей парадигмой Reactor, нормально описывается полусинхронной (синхронное взаимодействие между компонентами логики и асинхронное — с периферией) неблокирующей обработкой трех событий в парадигме Proactor. В данном случае сообщение CANCEL от SIP-сервера попадает в очередь — в момент его получения поток-обработчик обслуживает {CC-ALERTING} от трубки радиотелефона. Когда поток завершает обработку {CC-ALERTING}, стек раскручивается, поток оказывается в основном цикле, вынимает из очереди следующее сообщение (CANCEL) и начинает его обрабатывать. Таким образом события не конфликтуют: они сериализуются очередью, которая является единой точкой входа в систему.
Тем не менее за все нужно платить. Если для Reactor весь процесс установки звонка — от его создания до поднятия трубки — можно было бы (если бы парадигма сработала) пошагово отдебажить, то для Proactor такое невозможно. Например, на диаграмме вверху создание и разрыв звонка начинаются с обработчиков разных событий. Пошагово можно пройти реакцию только на одно событие, после этого поток выпадет в main loop и займется неизвестно чем, вероятно, каким-то другим событием из другого сценария с другими объектами. С другой стороны, как только выполнение остановилось на брейкпоинте в обработчике события, никто не может прервать отладку или как-то изменить данные. Любые изменения состояния являются результатом обработки событий, а события мирно накапливаются и ждут своей очереди. Если, конечно, не забыли отключить watchdog в конфиге.
Также неприятностью может быть reentrancy. Когда метод A1 объекта A вызывает метод B1 объекта B, который вызывает A2 в объекте A. В этом случае:
- Во время выполнения A1 может нарушаться инвариант, тогда A2 начнет выполняться на объекте с неправильным состоянием.
- Если A2 меняет состояние A, внутри A1 нужны проверки состояния: закешированные в локальных переменных данные могут стать устаревшими в результате выполнения A2.
Это обходится посылкой сообщения B1=>A2, которое будет обработано после того, как A1 завершится и стек раскрутится. Только лекарство не всегда лучше болезни: между A1 и A2 может обработаться постороннее сообщение, меняющее состояние системы (и объекта A). И в любом случае код A1-B1-A2, который ранее вызывался синхронно и легко отслеживался в IDE (Ctrl+click), становится разбит на несвязанные части A1-B1 и A2. Альтернатива — написать в коде комментарий, что здесь «грабли» (reentrancy).
Полезно будет пройти еще один шаг в сторону асинхронности. Если в Proactor вся логика жила в одном потоке, то для Active Objects каждому компоненту выделяется свой поток и кусок памяти. И вместо синхронных вызовов функций происходит обмен сообщениями.

В принципе такая архитектура соответствует требованиям — можно нарисовать диаграмму взаимодействия объектов. Однако при практическом применении выяснится:
- Это нельзя дебажить. Выполнение любого сообщения из внешней среды завершается почти сразу отсылкой сообщений другим объектам. Они будут асинхронно обрабатываться другими потоками, и обработчики будут отсылать еще больше сообщений. Разобраться в таком можно только при помощи логирования и удачи (внутренние сообщения от запуска к запуску будут в разном порядке, и логи будут отличаться).
- По той же причине пересечения сообщений диаграмма стала более запутанной. Логика работы программы становится еще более рваной: те данные, которые раньше возвращались вызовом метода объекта, теперь приходят сообщением и обрабатываются отдельным методом. Пример — работа с именем звонящего (Alice).
- При пересечении нескольких сценариев (на диаграмме — {CC-ALERTING} и CANCEL) внутренние сообщения устаревают. Например, запрос имени звонящего приходит в звонок уже после его завершения. В данном случае ничего страшного не произошло, но практически в каждом обработчике сообщения (а здесь нет других публичных методов) нужно проверять состояние объекта либо использовать (анти)паттерн State [GoF], что, по моему опыту, ужасно для читаемости кода.
- Из-за асинхронности сценариев части данных может не хватать в нужный момент. Тогда либо потребуется отложить выполнение сценария (закешировать сообщение или добавить промежуточное состояние), либо выполнять сценарий, основываясь на неполных данных. Пример — завершение звонка до того, как телефонная книга нашла имя звонящего по номеру. В результате или сейчас сохраняем звонок в истории без имени, или нужно временно оставлять его в состоянии «deleting — waiting for caller name».
В нашем случае разумной архитектурой для бизнес-логики будет синхронный внутри и асинхронный снаружи неблокирующий Proactor (второй рассмотренный случай). Полностью синхронный Reactor не работает в однопоточном варианте, а полностью асинхронный Actors излишне сложен в отладке.
Half-Async/Half-Async
Мы рассмотрели архитектуру для бизнес-логики. Еще есть:
- Работа с библиотеками и API подключаемых устройств.
- Работа с файлами.
- Взаимодействие с управляющим демоном (или тестировщиком).
Условие неблокировки центрального потока с бизнес-логикой приводит к асинхронной границе (обмен сообщениями) между центральным потоком и обслуживающей периферией. Как плюс — бизнес-логика сильно абстрагирована от периферии, и небольшие изменения библиотек или API основной код с логикой никак не затрагивают.

Здесь та же система, что и на первой диаграмме, изображена послойно. Видим синхронную Upper Half, содержащую логику приложения и модели состояния периферии, и (большей частью) асинхронную Lower Half, которая, собственно, занимается периферией. Такое разделение на половины используют в драйверах, но это не значит, что оно бесполезно в других приложениях — в этом суть архитектуры и паттернов. Хорошее решение, найденное один раз, может использоваться в других контекстах, когда условия задачи становятся похожими.
И драйвера, и приложение для телефонии управляют физическими устройствами через низкоуровневый интерфейс. В обоих случаях важна скорость реакции на события и возможность управления устройствами с разным API. Это обеспечивает асинхронная тонкая нижняя половина, инкапсулирующая специфику управляемого устройства. Верхняя половина занимается высокоуровневыми сценариями, превращая их в последовательность запросов к нижней половине и обработчиков нотификаций от нее. При этом интерфейс верхней половины драйвера для клиентских приложений стандартный, в соответствии с API операционной системы. И в телефонии участники звонка (SIP Line, DECT Handset, FXS Port) предоставляют одинаковый интерфейс для звонков, их связывающих.
В итоге, если сравнивать телефонию с user space приложением, пользующимся драйвером устройства в операционной системе, наш Logic layer будет соответствовать приложению, Proxy layer — верхней половине драйвера, Vendor layer — нижней половине драйвера устройства, Library layer — Hardware Abstraction Layer операционной системы.
Верхняя и нижняя половины драйвера могут взаимодействовать синхронно (верхняя половина вызывает метод нижней, нижняя — превращает вызов в команду по шине, блокирует вызывающий поток, ожидает ответ и возвращает результат ожидающему потоку) и асинхронно (обмен через очередь сообщений или мейлбокс). Синхронное управление называется Half-Sync/Half-Async [POSA2] и часто встречается в других областях, не критичных ко времени отклика. Например, оно описывает приложение, работающее с файлами или сокетами.
Обычные чтения или записи блокируют приложение (верхнюю половину), пока операционная система (нижняя половина) проводит много асинхронных действий и вернет результат. Асинхронное управление называется Half-Async/Half-Async [POSA2] и соответствует работе приложения с файлами или сокетами через polling/async IO. В этом случае приложение внутри синхронно (однопоточно), но с периферией (операционной системой), взаимодействует через неблокирующие команды и нотификации (колбеки + мейлбоксы). В результате один поток может параллельно обрабатывать много запросов (используется в высоконагруженном бэкенде).
Можно заметить, что название (и описание) системы зависит от того, как ее повернуть: разные диаграммы одного приложения выглядят как Adapter и как Layers; Half-Sync/Half-Async при исключении нижнего слоя из рассмотрения превращается в Reactor, а Half-Async/Half-Async — в Proactor. Это свойство архитектуры, которая по сути есть набором удобных приемов и правил описания сложных систем. А что можно описать, то можно представить, смоделировать и в нужный момент применить.
Messaging
Важный компонент системы — обмен сообщениями между потоками. Рассмотрим, как это работает.
У каждого Actor (компонента с собственными потоком выполнения и областью памяти под данные состояния) есть очередь входящих сообщений, защищенная мютексом. Поток спит на связанной с мютексом condition variable, ожидая, пока в очередь что-то упадет. Если разбудили, обрабатывает все сообщения из очереди и опять засыпает. Ставить сообщения в очередь может кто угодно, включая владельца очереди.
Если Actor сложный (содержит несколько объектов-получателей сообщений), для распределения сообщений между адресатами используется многоуровневый Visitor [GoF]. В результате получаем древовидную иерархию сообщений, каждое ветвление которой решается через Visitor:

Иерархия сообщений большей частью повторяет иерархию объектов в системе. Это позволяет доставить сообщение из общей очереди на вход любому объекту внутри любого модуля. По сути у нас две взаимопроникающие иерархии — модули с логикой и сообщения, на которые они реагируют. Интересно, что для сообщений иерархия базируется на наследовании, а для объектов — на композиции. То есть композиция и наследование взаимозаменяемы в параллельных иерархиях.
Итоги
В статье мы познакомились с условиями работы и общей архитектурой telecom-приложения, рассмотрели синхронный, асинхронный и промежуточный варианты построения soft real-time системы с большим количеством бизнес-логики; разобрали плюсы и минусы каждого подхода. Обнаружили сходство в принципах работы системных драйверов и пользовательских приложений, увидели, что в зависимости от вошедших компонентов и их расположения на диаграмме одна и та же система описывается разными паттернами.
Полезно почитать
[Devs] Alex Garland et al, Devs (2020).
[GoF] Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software (1994).
[POSA2] Douglas C. Schmidt, Michael Stal, Hans Rohnert, Frank Buschmann. Pattern-Oriented Software Architecture Volume 2: Patterns for Concurrent and Networked Objects (2000).