Как построить полусинхронную архитектуру на примере telecom-приложения

by

Потреблять с базовым представлением о 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-«железки». Значит, пишем свой, маленький и непрожорливый. На роутере стоит линукс, что несколько облегчает задачу:

  1. Программу можно будет запускать и дебажить на компьютере (совместимый код) без дополнительной разработки OS Abstraction Layer. Большой плюс.
  2. Линукс дает pthread и потоки с приоритетами. В результате обработка логики сможет прерывать работу с файлами или индексацию телефонной книги, а передача голоса между USB и сетевыми сокетами — логику.
  3. Можно втянуть небольшие библиотеки. В данном случае — libusb и PJSIP. Последняя просто спасла ситуацию: написать с нуля поддержку SIP со всеми дополнениями нереально.

Ограничения:

  1. Soft real-time (voice) — голосовые пакеты бегают каждые 10-20 мс (в зависимости от «железа») для каждого звонка. Если задержать пару пакетов, в голосе будет щелчок. То есть, чем бы приложение ни занималось в данный момент, оно всегда должно быть готово обработать голос: либо по выставленному на 20 мс таймеру, либо когда голосовой пакет приходит.
  2. Soft real-time (logic) — когда пользователь нажимает кнопку (или поднимает трубку) на телефоне, он ожидает какую-то реакцию. Можно протупить 100 или 200 мс, но за полсекунды очень желательно отреагировать: начать гудеть при поднятой трубке, оборвать звонок при положенной, разобраться, соответствует ли номер с новой набранной цифрой одному из правил набора. Если да — отправить запрос создания звонка на сервер через нужную учетку. При этом неважно, что в данный момент делают другие пользователи с другими трубками, есть ли связь с сервером или насколько эта связь тормозит — действия пользователя должны немедленно приводить к ожидаемому результату.
  3. Процессор — кодеки на таком лучше не запускать: они начнут тормозить систему, торренты остановятся — и покупатели выбросят роутер через окно.
  4. Флеш (размер программы и библиотек) — очень ограничен. Файловая система read-only.
  5. Язык — С или С++, смотри пункт 4.

Стандарты SIP, DECT и обработка FXS довольно сильно отличаются, так что просто преобразованием сообщений и данных не обойтись — будет много логики и состояний. Кроме того, пользователи хотят плюшек, иначе это «тупая звонилка». Сюда входят, например, телефонная книга и история звонков, правила выбора учетки и сервера в зависимости от набранного номера, переключение между звонками или перенос вызова при нажатии комбинаций кнопок... Еще одна фича — работа с чужими серверами и трубками. В случае проблем совместимости виноват тот, кто пришел на рынок последним.

Обзор архитектуры

Далее описывается последняя версия приложения (с поддержкой USB FXS устройства). Общая структура с начала проекта не менялась, только добавлялись новые модули, а старые делились на части, когда кода в одном классе становилось слишком много.

https://s.dou.ua/storage-files/image5_yTgZk20.png
Вид на систему из космоса, Adapter

Использованный подход называется 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 мс, обрабатывает все каналы и опять засыпает.

Синхронность и асинхронность

Сравним:

  1. Блокирующий синхронный Thread per Request (Reactor [POSA2]).
  2. Неблокирующий полусинхронный Half-Async/Half-Async (Proactor [POSA2]).
  3. Асинхронный Actors (Active Objects [POSA2]).

Для примера возьмем относительно простой, но показательный сценарий:

  1. Приходит входящий вызов из сети (INVITE).
  2. Создаем звонок.
  3. Ищем номер звонящего в телефонной книге, чтобы отобразить имя.
  4. Рассылаем звонок ({CC-SETUP}) на 3 зарегистрированные DECT-трубки.
  5. Отвечаем 100 Trying (приняли звонок, обрабатываем) серверу.
  6. Вторая трубка включена и громко звонит ({CC-ALERTING}).
  7. В то же время от сервера приходит отмена звонка (CANCEL).
  8. Отсылаем 180 Ringing (устройство играет мелодию звонка) на сервер.
  9. Отсылаем 487 Terminated (звонок завершен) на сервер.
  10. Рассылаем завершение вызова ({CC-RELEASE}) на трубки.
  11. Сохраняем звонок в истории звонков.

Синхронная блокирующаяся обработка звонка (Reactor) — ждем, пока пользователь поднимет трубку:

https://s.dou.ua/storage-files/image4_82xONLk.png
Отмена входящего звонка, Reactor

Диаграмму взаимодействия не удается дорисовать из-за следующих проблем:

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

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

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

https://s.dou.ua/storage-files/image1_ALGzsUF.png
Отмена входящего звонка, Proactor

Здесь видим, что весь сценарий, который невозможно было решить синхронной блокирующей парадигмой Reactor, нормально описывается полусинхронной (синхронное взаимодействие между компонентами логики и асинхронное — с периферией) неблокирующей обработкой трех событий в парадигме Proactor. В данном случае сообщение CANCEL от SIP-сервера попадает в очередь — в момент его получения поток-обработчик обслуживает {CC-ALERTING} от трубки радиотелефона. Когда поток завершает обработку {CC-ALERTING}, стек раскручивается, поток оказывается в основном цикле, вынимает из очереди следующее сообщение (CANCEL) и начинает его обрабатывать. Таким образом события не конфликтуют: они сериализуются очередью, которая является единой точкой входа в систему.

Тем не менее за все нужно платить. Если для Reactor весь процесс установки звонка — от его создания до поднятия трубки — можно было бы (если бы парадигма сработала) пошагово отдебажить, то для Proactor такое невозможно. Например, на диаграмме вверху создание и разрыв звонка начинаются с обработчиков разных событий. Пошагово можно пройти реакцию только на одно событие, после этого поток выпадет в main loop и займется неизвестно чем, вероятно, каким-то другим событием из другого сценария с другими объектами. С другой стороны, как только выполнение остановилось на брейкпоинте в обработчике события, никто не может прервать отладку или как-то изменить данные. Любые изменения состояния являются результатом обработки событий, а события мирно накапливаются и ждут своей очереди. Если, конечно, не забыли отключить watchdog в конфиге.

Также неприятностью может быть reentrancy. Когда метод A1 объекта A вызывает метод B1 объекта B, который вызывает A2 в объекте A. В этом случае:

  1. Во время выполнения A1 может нарушаться инвариант, тогда A2 начнет выполняться на объекте с неправильным состоянием.
  2. Если A2 меняет состояние A, внутри A1 нужны проверки состояния: закешированные в локальных переменных данные могут стать устаревшими в результате выполнения A2.

Это обходится посылкой сообщения B1=>A2, которое будет обработано после того, как A1 завершится и стек раскрутится. Только лекарство не всегда лучше болезни: между A1 и A2 может обработаться постороннее сообщение, меняющее состояние системы (и объекта A). И в любом случае код A1-B1-A2, который ранее вызывался синхронно и легко отслеживался в IDE (Ctrl+click), становится разбит на несвязанные части A1-B1 и A2. Альтернатива — написать в коде комментарий, что здесь «грабли» (reentrancy).

Полезно будет пройти еще один шаг в сторону асинхронности. Если в Proactor вся логика жила в одном потоке, то для Active Objects каждому компоненту выделяется свой поток и кусок памяти. И вместо синхронных вызовов функций происходит обмен сообщениями.

https://s.dou.ua/storage-files/image3_X5HGh70.png
Отмена входящего звонка, Actors

В принципе такая архитектура соответствует требованиям — можно нарисовать диаграмму взаимодействия объектов. Однако при практическом применении выяснится:

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

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

Half-Async/Half-Async

Мы рассмотрели архитектуру для бизнес-логики. Еще есть:

  1. Работа с библиотеками и API подключаемых устройств.
  2. Работа с файлами.
  3. Взаимодействие с управляющим демоном (или тестировщиком).

Условие неблокировки центрального потока с бизнес-логикой приводит к асинхронной границе (обмен сообщениями) между центральным потоком и обслуживающей периферией. Как плюс — бизнес-логика сильно абстрагирована от периферии, и небольшие изменения библиотек или API основной код с логикой никак не затрагивают.

https://s.dou.ua/storage-files/image6_3enq3QQ.png
Вид на систему из космоса, Layers

Здесь та же система, что и на первой диаграмме, изображена послойно. Видим синхронную 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:

https://s.dou.ua/storage-files/image2_simkS4v.png
Фрагмент иерархии сообщений

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

Итоги

В статье мы познакомились с условиями работы и общей архитектурой 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).

Темы: C++, embedded, tech