×Закрыть

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

Потреблять с базовым представлением о 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 устройства). Общая структура с начала проекта не менялась, только добавлялись новые модули, а старые делились на части, когда кода в одном классе становилось слишком много.

Вид на систему из космоса, 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) — ждем, пока пользователь поднимет трубку:

Отмена входящего звонка, 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.

Отмена входящего звонка, 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 каждому компоненту выделяется свой поток и кусок памяти. И вместо синхронных вызовов функций происходит обмен сообщениями.

Отмена входящего звонка, 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 основной код с логикой никак не затрагивают.

Вид на систему из космоса, 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:

Фрагмент иерархии сообщений

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

Итоги

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

LinkedIn

43 комментария

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

Что это такое за Half-Async, я в итоге так и не понял :) Наверняка же что-то известное, но под другой оболочкой. Но по такому описанию не ловится. Может, по книге будет яснее, но это ещё найти её надо.

Сильно удивляет определение proactor и reactor. Обычно всё-таки у них смысл другой, на общем движке событий, но если, например, нужно каждый раз сказать «я хочу прочитать из сокета/файла/etc. и буду ждать callback с данными», то это proactor, а если просто «я тебе дал сокет, и пока не отменю, вызывай меня с приходящими данными», то это reactor. Reactor не годится там, где кроме «вот тебе буфер» нужно ещё что-то (чтение из файла — нужна позиция чтения), а proactor требует о каждой операции заранее чихнуть (см. ASIO).
Но оба крутятся на движке событий, формально могут поддерживать и одну нитку, и много (ASIO — сколько вошло в io_service::run, столько и будет), и т.д.
А то, что ты описывал как reactor — это вообще чисто синхронная модель.
Для таких систем с soft realtime вообще-то синхронная модель неудобна во всех смыслах, так что тут как-то странно у вас получилось.

PJSIP — да, хорошая штука — мы в тестировании частично используем её (хотя у центрального модуля стек вообще свой). Вообще в стандарте действительно много противоречий, ошибок и странностей, а изначально ущербный дизайн заставляет писать дикие извращения (см. 6 шаблонов OAM).

> Это обходится посылкой сообщения B1=>A2, которое будет обработано после того, как A1 завершится и стек раскрутится. Только лекарство не всегда лучше болезни: между A1 и A2 может обработаться постороннее сообщение, меняющее состояние системы (и объекта A).

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

> Это нельзя дебажить.

У нас есть логи, по которым вылавливается 99% таких ситуаций, если надо :) но вообще нужно иметь тесты на машину состояний каждого объекта по отдельности.

> практически в каждом обработчике сообщения (а здесь нет других публичных методов) нужно проверять состояние объекта либо использовать (анти)паттерн State [GoF], что, по моему опыту, ужасно для читаемости кода.

Это очень странный вывод. Многие сущности просто по стандарту описаны в состояниях и переходах между ними (в том же SIP таких большинство). Сочетание типа входящего сообщения и состояния объекта можно формулировать не явным кодом, а метаописаниями, которые можно проверять автоматизированно.

> Пример — завершение звонка до того, как телефонная книга нашла имя звонящего по номеру. В результате или сейчас сохраняем звонок в истории без имени, или нужно временно оставлять его в состоянии «deleting — waiting for caller name».

Ну вот состояние таки есть. Что мешает оставить его вообще в каком-то Finished без подробностей? Коллбэк пришёл, данные не нужны — тут же вышли.

И вслед — неясно, какая часть всех странностей обусловлена ограничениями ресурсов.

Что это такое за Half-Async, я в итоге так и не понял

Есть семейство паттернов. Члены семейства называются Half-X / Half-Y. Наиболее часто упоминаемый — Half-Sync / Half-Async. Первая половина названия указывает на то, как обрабатывается логика приложения (высокоуровневый код), вторая — на то, как обрабатывается периферия (назкоуровневый код). Для классического Half-Sync / Half-Async логика обработки запроса блокируется на однопоточных обработчиках периферии, а они — не блокируются на железе или ОС. Обычно Half-Sync / Half-Async идет вместе со Thread Pool, и для каждого запроса выделяется свой поток.

Может, по книге будет яснее, но это ещё найти её надо.

dou.ua/forums/topic/28077

Сильно удивляет определение proactor и reactor. Обычно всё-таки у них смысл другой, на общем движке событий, но если, например, нужно каждый раз сказать «я хочу прочитать из сокета/файла/etc. и буду ждать callback с данными», то это proactor, а если просто «я тебе дал сокет, и пока не отменю, вызывай меня с приходящими данными», то это reactor.

Тут я не понял вопроса. Судя по интернетам, например (Google: Reactor vs Proactor):
stackoverflow.com/...​59332/reactor-vs-proactor
www.programmersought.com/article/4835195404
www.artima.com/...​s/io_design_patterns.html
Реактор ожидает запрос пользователя, затем — запускает (многошаговый) сценарий обработки запроса. Если шаг сценария уходит в ОС, реактор ожидает, пока ОС сделает свою часть, затем — продолжает обрабатывать тот же запрос. То есть, один поток реактора занимается одним запросом. Хочешь масштабироваться — наплоди потоков или нитей. И да, код обработки запроса по сути синхронный. В нем прописана последовательность шагов для обслуживания запроса.

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

Но оба крутятся на движке событий, формально могут поддерживать и одну нитку, и много (ASIO — сколько вошло в io_service::run, столько и будет), и т.д.

В реакторе всегда один поток или нить на запрос. В проакторе — всего один поток, он обрабатывает все запросы параллельно.
The Proactor Design Pattern: Concurrency Without Threads
www.boost.org/...​/overview/core/async.html
Обычно в проактор нет смысла пихать несколько потоков, если в бизнес-логике нет тяжелых вычислений. Проактор самомасштабируемый, пока влазит в проц. А реактор — наоборот, для масштабирования требует создания пула потоков или нитей.

А то, что ты описывал как reactor — это вообще чисто синхронная модель.

Да, и вики говорит так же:
Proactor is a software design pattern for event handling in which long running activities are running in an asynchronous part. A completion handler is called after the asynchronous part has terminated. The proactor pattern can be considered to be an asynchronous variant of the synchronous reactor pattern.
en.wikipedia.org/wiki/Proactor_pattern

dou.ua/forums/topic/28077

Спасибо, попробую.

Судя по интернетам, например (Google: Reactor vs Proactor):

Ну вот например из твоей ссылки www.artima.com/...​/io_design_patterns.html

> In Reactor, the event demultiplexor waits for events that indicate when a file descriptor or socket is ready for a read or write operation. The demultiplexor passes this event to the appropriate handler, which is responsible for performing the actual read or write.

То есть какой-то движок событий с механизмом ожидания (типа epoll), который говорит «а теперь можешь читать».
И дальше:

> In the Proactor pattern, by contrast, the handler—or the event demultiplexor on behalf of the handler—initiates asynchronous read and write operations. The I/O operation itself is performed by the operating system (OS).

Про OS тут загнуто — их может исполнять и userland, это описание было скорее под Windows IOCP — но, например, в ASIO ты зовёшь async_read_some() на сокете и тебе в коллбэке присылают уже факт прочтения данных, с заданным буфером и количеством байтиков.

И никакого синхронного заказа с ожиданием результата(!) в reactor нет, и вот это всё:

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

про обычный блокирующий синхронный, но не реактор.

Ну а про fail-safe/fail-fast там унеслись в какие-то непонятные частные случаи. Второй ответ на SO как раз больше в тему.

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

Реактор обрабатывает сколько угодно запросов: ты заслал 100500 сокетов в epoll, сказал на всех, что можешь читать — и когда сможешь что-то с них реально читать (или получить сигнал, что та торона закрылась) — читаешь.
Или создал себе очередь сообщений и читаешь ввод — тот же реактор (хотя по отношению к таким обработчикам обычно его так не называют).

Да, и вики говорит так же:

А ты посмотри, что они там пишут про Reactor pattern:

> The service handler then demultiplexes the incoming requests and dispatches them synchronously to the associated request handlers.

> The demultiplexer sends the resource to the dispatcher when it is possible to start a synchronous operation on a resource without blocking.

Это никак не синхронные вызовы куда-то далеко с блокированием.

А та отдельная фраза, что ты привёл, это какая-то самопротиворечивая чушь со введением бессмысленного «synchronous reactor pattern», это просто оксиморон. Да, после того, как мы получили сигнал, например, из epoll, что можно читать — мы вызвали recv() и синхронно вызвали обработчик — а как иначе? мы подождём, пока он что-то сделает. И в проакторе точно так же: заданный коллбэк вызывается синхронно, вызывающий движок событий ждёт его завершения, чтобы начать делать что-то ещё. А что он мог бы сделать асинхронно — передать ещё кому-то сообщение? Так надо же когда-то остановиться :)

Так что очередное подтверждение, что википедию часто пишут те, кто слабо понимает, что они описывают :(

И по количеству участвующих тредов всё скорее наоборот: на проакторе (IOCP, ASIO, много других) несколько тредов могут соревноваться за то, кто быстрее схватит следующее событие, а на реакторе (как цикл вокруг epoll) это нелепо — каждый завершившийся epoll_wait получит полный список событий, и им надо не подраться за него. Хотя, если там будет edge triggering на все события и ядро нормально разрулит, кому отдать (не копал эту тему) — можно и попытаться...

То есть какой-то движок событий с механизмом ожидания (типа epoll), который говорит «а теперь можешь читать».

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

Про OS тут загнуто — их может исполнять и userland, это описание было скорее под Windows IOCP — но, например, в ASIO ты зовёшь async_read_some() на сокете и тебе в коллбэке присылают уже факт прочтения данных, с заданным буфером и количеством байтиков.

Юзерленд не имеет доступа к железу жесткого диска. Юзерленд говорит в ОС «запиши файл», и все. А самой записью занимается ОС под капотом. И когда ОС доделает запись — тогда ОС дернет колбек обратно в юзерленд.

И никакого синхронного заказа с ожиданием результата(!) в reactor нет

Ну вот с вики: en.wikipedia.org/wiki/Reactor_pattern
Resources
Any resource that can provide input to or consume output from the system.
Synchronous Event Demultiplexer
Uses an event loop to block on all resources. The demultiplexer sends the resource to the dispatcher when it is possible to start a synchronous operation on a resource without blocking (Example: a synchronous call to read() will block if there is no data to read. The demultiplexer uses select() on the resource, which blocks until the resource is available for reading. In this case, a synchronous call to read() won’t block, and the demultiplexer can send the resource to the dispatcher.)
Dispatcher
Handles registering and unregistering of request handlers. Dispatches resources from the demultiplexer to the associated request handler.
Request Handler
An application defined request handler and its associated resource.

Ресурс — сокет, через который приходят запросы от пользователя.
Демультиплексер — вызов select(), который завершится, когда в сокете будет пакет.
Диспатчер — хрень, заведующая очередью ожидающих потоков-исполнителей.
Обработчик — пользовательский объект, способный целиком обработать запрос из сокета.

Когда обработчик получает запрос, он разрегистрируется из списка свободных потоков в диспатчере. Потом обработчик какое-то время занимается работой, включая блокирующие вызовы в ОС. Когда обработчик завершил все действия и отправил результат — он становится свободным и обратно записывается в список в диспатчер.

select() здесь только на сокете, из которого запросы приходят.
Benefits
The reactor pattern completely separates application-specific code from the reactor implementation, which means that application components can be divided into modular, reusable parts.
То есть, движок реактора ничего не знает о том, как пользовательский код обрабатывает запрос. И о том, есть ли в пользовательском коде read() или async_read_some() и вообще что этот код делает. Движок реактора только раздает каждому запросу из сокета свой поток или нить. Все.

Движок реактора только раздает каждому запросу из сокета свой поток или нить.

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

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

И если ты сделал что-то, что подписалось (или его подписали) на события — да, ему принципиально как можно меньше блокироваться. Надо сделать длительную операцию? Не сидеть и ждать, а заказать выполнение и подписаться на её результат. И это одинаково у обоих подходов.

И вот это всё вот (tm) вызывает смущение по остальной части — что же у тебя на самом деле происходит и как.

Юзерленд не имеет доступа к железу жесткого диска.

Да. Но получив заказ на асинхронное чтение с диска, уже он определяет, как тот заказ будет исполняться. Может, простой read() в отдельной нити из пула, может, aio_read() или ReadFileEx... — уже от рельефа местности зависит.

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

Несколько реакторов на одном сокете — это очень странная ситуация, как по мне. Есть особые случаи типа как в Apache несколько процессов спорят за то, кто примет новое соединение, но и они обычно это доверяют тому, кто смог захватить лок.
По нормальному же такое не делают там, где есть хоть какая-то связь по данным между обработчиками.

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

Это — однопоточный реактор. Поэтому — требование неблокировки. Но он работает только когда в сценарии одно событие, либо когда обработчик может поюзать поток ОС, который дернул второй колбек, из этого потока — дернуть третий, и так далее, до завершения сценария. В результате сценарий получится разбросанным по 100500 колбеков, как и в проакторе. Какой профит?

Вот оригинальная статья по реактору www.dre.vanderbilt.edu/...​t/PDF/reactor-siemens.pdf
Вот обработчик события из статьи:

void Logging_Handler::handle_event (Event_Type et) {
    if (et == READ_EVENT) {
        Log_Record log_record;
        peer_stream_.recv ((void *) log_record, sizeof log_record);
        // Write logging record to standard output.
        log_record.write (STDOUT);
    } else if (et == CLOSE_EVENT) {
        peer_stream_.close ();
        delete (void *) this;
    }
}
Вот его описание:
Logging records are sent to a central logging server, which can write the records to various output devices, such as a console, a printer, a file, or a network management database.
Мы видим в обработчике блокирующие вызовы: запись в стрим/файл и закрытие сокета. Да и неблокирующих вызовов в том далеком году еще (почти?) нигде не было поддержки.

Вот описание твоей проблемы в той же статье:
Non-preemptive: In a single-threaded application process, Event Handlers are not preempted while they are executing. This implies that an Event Handler should not perform blocking I/O on an individual Handle since this will block the entire process and impede the responsiveness for clients connected to other Handles. Therefore, for long-duration operations, such as transferring multi-megabyte medical images, the Active Object pattern may be more effective. An Active Object uses multi-threading or multi-processing to complete its tasks in parallel with the Initiation Dispatcher’s main event-loop.
Проблема

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

возникает только если под реактор не подложить несколько потоков-обработчиков. А если подложить — эти потоки спокойно могут блокироваться, и код становится прямым (одна функция), а не набором колбеков.

Вот еще сравнение блокирующих и неблокирующих реакторов на корутинах, если нужно убедиться, что реакторы могут блокировать ithare.com/...​hreading-with-a-script/2

Это — однопоточный реактор. Поэтому — требование неблокировки. Но он работает только когда в сценарии одно событие, либо когда обработчик может поюзать поток ОС, который дернул второй колбек, из этого потока — дернуть третий, и так далее, до завершения сценария.

«Одно событие» это одновременно или вообще? Если вообще, то это некорректно — потому что их может быть сколько угодно и типов, и самих событий, пока мы не тормозим общую работу той частью, которая выполняется неизбежно синхронно. Если одновременно, то preemting — отдельный вопрос.

Какой профит?

А что плохого?

Вот оригинальная статья по реактору

Разве что в том смысле, что было введено само слово (и то я сомневаюсь). Концепция в целом известна была уже очень давно (как минимум под RT-11, RSX-11 уже писали в этом стиле).

Мы видим в обработчике блокирующие вызовы: запись в стрим/файл и закрытие сокета.

Эээ
я уже говорил, что в очень многих подобных реализациях считают disk I/O не тем, на задержки чего стоит обращать внимания (пока они не начинают убивать таки общую производительность).
В таких реализациях считается, что задержки — там, где секунды и непредсказуемое время (ждёшь новой порции данных с сети — а с той стороны клиент просто занимается своими делами), а диск — даёт фиксированное время.
Ну и закрытие сокета — оно тоже мгновенно, если linger не настраивать.

Так что если их исключить — никакой блокировки на неопределённое время — нет.

Да и неблокирующих вызовов в том далеком году еще (почти?) нигде не было поддержки.

Я не знаю, с чего ты так решил, но перечитай историю. Возможность ставить fcntlʼом O_NONBLOCK это середина 80-х, 4.3BSD-Tahoe это 1988. Аналогичный SysVʼовский O_NDELAY (вымерший из-за кривой семантики) это, AFAIR, 1987. На 1995 эта возможность была древним баяном.
AIO того образца, где aio_read — тоже середина 80-х.
Но вводить их сюда в пример было бы нелепо — он просто учебный.

This implies that an Event Handler should not perform blocking I/O on an individual Handle since this will block the entire process and impede the responsiveness for clients connected to other Handles.

Да. И дисковые операции они сюда не включают, только сетевые.

возникает только если под реактор не подложить несколько потоков-обработчиков. А если подложить — эти потоки спокойно могут блокироваться, и код становится прямым (одна функция), а не набором колбеков.

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

Вот еще сравнение блокирующих и неблокирующих реакторов на корутинах, если нужно убедиться, что реакторы могут блокировать

Ну вот и читаю:

> Real-world examples of such operations-which-we-MAY-handle-as-blocking, often include accessing the local disk and/or database, and sometimes even operations in the Server-Side intra-datacenter LAN.

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

У меня из такого был переход от синхронной работы с RTP proxy (которая вынесена в отдельный процесс и получает там команды) к асинхронной, когда оказалось, что мы начинаем на этом слишком тормозить.

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

«Одно событие» это одновременно или вообще? Если вообще, то это некорректно — потому что их может быть сколько угодно и типов, и самих событий, пока мы не тормозим общую работу той частью, которая выполняется неизбежно синхронно. Если одновременно, то preemting — отдельный вопрос.

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

А что плохого?

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

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

Если утверждаешь, что здесь нестандартные названия — приведи литературу с описанием стандартных названий так, как ты их понимаешь. Пока что я пользовался описанием от авторов терминов Reactor и Proactor. Если есть другие, более распространенные описания с отличным смыслом — приведи, пожалуйста.

Мне она в таком виде ничего нового не даёт — все варианты уже известны, вопрос в том, какие были оптимальны.

Все варианты не использовались — использовался один вариант, в котором вся пользовательская логика живет в одном потоке-проакторе. Остальные два варианта приведены для объяснения, почему выбран именно этот.

Вообще. Иначе влазим в синхронизацию состояния, если есть общее состояние у нескольких запросов. Колбек будет не из потока реактора.

Этого не понял. Вот мне пришло, например, что трубка 1 ответила, а вот — что трубка 2 ответила. В чём-то может быть общее состояние (где они воздействуют на звонок), и это необходимо; в чём-то — нет. Что плохого в общем состоянии там, где это нужно?
И что тогда получается, если может отработать только одно сообщение — зачем такое вообще нужно? Какие-то странные вещи получаются.

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

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

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

Если утверждаешь, что здесь нестандартные названия — приведи литературу с описанием стандартных названий так, как ты их понимаешь.

И те же ссылки, что и у тебя (намеренно не беру пока других), и даже вики (кроме отдельных кривых пассажей типа synchronous reactor — основа статей там как раз совпадает). А у этого it-зайца очевидно как раз странная сущность «(re)actor» — видно, что он не стабилизировался ни на чём внятном. Он устраивает полноценный обмен сообщениями — значит, акторы? — но ему нужно подчеркнуть, что распределяет их что-то реакторо-подобное? и тут же их смешивает в кучу. Он вспоминает, например, Erlang — там так вполне нормальная акторная модель. Ну да, диспетчер над ними это вполне себе реактор (в основном на сообщениях, а не сокетах — сокеты там по сути генераторы таких же сообщений). Зачем смешивать разные сущности в одно? Я не вижу, чтобы он этим добивался чего-то осмысленного.

Все варианты не использовались — использовался один вариант, в котором вся пользовательская логика живет в одном потоке-проакторе.

Ясно.

Этого не понял. Вот мне пришло, например, что трубка 1 ответила, а вот — что трубка 2 ответила. В чём-то может быть общее состояние (где они воздействуют на звонок), и это необходимо; в чём-то — нет. Что плохого в общем состоянии там, где это нужно?
И что тогда получается, если может отработать только одно сообщение — зачем такое вообще нужно? Какие-то странные вещи получаются.

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

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

А расскажи, как у тебя колбеки из ОС или из других низкоуровневых потоков оказались в том же потоке, в котором крутится реактор?

просто брать мьютекс всего актора на входе в передачу сообщения

 А тут будут тормоза и/или дедлоки. Если всю обработку делать под мютексом.

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

Юзерленд не имеет доступа к железу жесткого диска.

Да. Но получив заказ на асинхронное чтение с диска, уже он определяет, как тот заказ будет исполняться. Может, простой read() в отдельной нити из пула, может, aio_read() или ReadFileEx... — уже от рельефа местности зависит.

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

Несколько реакторов на одном сокете — это очень странная ситуация, как по мне.

Нет, на сокете один реактор. Этот один реактор слушает сокет и раздает работу нескольким потокам из пула потоков. Суть реактора — слушать сокет и раздавать работу. А кто и как работу выполняет — его не волнует.

Нет, чтение в любом случае будет выполняться одинаково.

А какое нам дело про то, как оно происходит в ядре (пока оно работает и не тормозит)?

Этот один реактор слушает сокет и раздает работу нескольким потокам из пула потоков.

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

1) всё равно надо сделать edge triggering, иначе несколько последовательных нотификаций могут быть доставлены в разные треды.

Не вникал в epoll, не знаю об edge triggering.

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

Нет, не переходим. Реактор узнал, что в сокете есть данные (сокет готов для чтения); удалил сокет из маски сокетов, которые он поллит; отдал сокет свободному потоку; сел поллить оставшиеся в маске сокеты. Вычитку делает тот поток, который будет обрабатывать запрос. Реактор только узнает, какие сокеты готовы, и раздает эти сокеты исполнителям. Он сам ничего не читает, поэтому очень быстро обрабатывает poll/select.

Разница между реактором и потоком на клиента в том, что при подходе «поток на клиента» количество потоков равно количеству открытых сокетов. У реактора количество потоков равно количеству обрабатываемых в данным момент запросов. Пример: 10000 человек могут держать открытые гугл доки одновременно. Один сервер не сможет их всех обслужить, если на каждого создавать поток. Но эти 10000 человек не сохраняют одновременно свои документы. Поэтому, если делать через реактор, на них с головой хватит 50 потоков-исполнителей (запись дока на диск) + 1 поток-реактор (poll на 10000 сокетов).

Не вникал в epoll, не знаю об edge triggering.

Там всё банально: если источник «взвёл» готовность, то дальше она не повторяется, пока чтением или записью не сделают, что эта внутренняя готовность исчезнет, даже если будут повторяться epoll_wait().
Например, поступило 1000 байт, epoll_wait перечислил сокет; мы вычитали 500 — состояние готовности чтения сохранилось — следующий epoll_wait промолчит про этот сокет. Добавился извне 1 байт — молчит. Вычитали ещё 500 — всё равно молчит. Вычитали один последний — в ядерном буфере стало 0 — внутренний флаг «а мы уже сигнализировали» сбросился. Придёт следующая порция — epoll_wait расскажет про чтение сокета.

То есть метод где-то на такие подходы, как ты описываешь: если надо задумчиво кому-то отдать «а разберись-ка с этим сокетом». Цена — тот, кому отдали это, должен разобраться до конца, читать до тех пор, пока не получит EAGAIN — или же внутри уже своего движка ставить самому себе команду повторить.

Для kqueue, аналогом является добавление EV_CLEAR.

Нет, не переходим. Реактор узнал, что в сокете есть данные (сокет готов для чтения); удалил сокет из маски сокетов, которые он поллит

О! Вот и оно. Раз он удалил из маски, кто-то должен явно попросить туда вернуть, если надо делать следующее чтение. А это и значит проактор — мы заказываем каждую следующую операцию явно.
(В каком-то извращённом дизайне можно сделать это автоматом, но тогда получится ещё более извратно, если обработчик решит, что надо остановить такое чтение. Хотя кому как. Мне удобнее в стиле asyncore, но он оптимизирован на малое количество участвующих сокетов.)

Разница между реактором и потоком на клиента в том

По-моему, я не говорил такого, что бы потребовало этого разъяснения :)

О! Вот и оно. Раз он удалил из маски, кто-то должен явно попросить туда вернуть, если надо делать следующее чтение. А это и значит проактор — мы заказываем каждую следующую операцию явно.

Нет. Возвращает сокет в маску обработчик запроса, когда он с запросом разобрался, и говорит реактору «добавь меня в очередь свободных обработчиков, а мой сокет, который я полностью обработал — в маску сокетов для поллинга».

Проактор — это когда все асинхронные события складываются в одну очередь, и обрабатываются одним потоком. Или не складываются в очередь, но суть в том, что реагирует на все один поток, он все сериализует. И в его память никто снаружи, включая колбеки, напрямую влезть не может. Хотите что-то сделать — пришлите сообщение. При этом одновременно могут выполняться несколько сценариев от разных клиентов. Многозадачность без многопоточности.

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

Active Object — это когда любые вызовы методов объекта превращаются в события и кладутся в очередь. То же самое, что proactor, только без пользователей и их запросов. Структура одинаковая, разное назначение.

Проактор — это когда все асинхронные события складываются в одну очередь, и обрабатываются одним потоком. Или не складываются в очередь, но суть в том, что реагирует на все один поток, он все сериализует.

Нет. Проактор тривиально реализуется на множестве тредов, и ASIO этому уже классический пример. Каждый тред, вызвавший io_service::run(), будет отрабатывать коллбэки (которые, в свою очередь, могут заказывать новые операции с коллбэками), пока кто-то всему пучку не скажет «приехали, выходите».

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

Странный стиль, но предположим.

Если в сценарии будут колбеки — их никто не синхронизирует с потоком реактора или исполнителя, и получится лажа.

Не получится лажи, если самому отработать положенную синхронизацию.

Active Object — это когда любые вызовы методов объекта превращаются в события и кладутся в очередь. То же самое, что proactor, только без пользователей и их запросов. Структура одинаковая, разное назначение.

Структура совершенно разная, active object это персональный reactor на объект. Ему нет смысла заказывать что-то, что он может вызвать напрямую — он реагирует на запросы.

Нет. Проактор тривиально реализуется на множестве тредов, и ASIO этому уже классический пример. Каждый тред, вызвавший io_service::run(), будет отрабатывать коллбэки (которые, в свою очередь, могут заказывать новые операции с коллбэками), пока кто-то всему пучку не скажет «приехали, выходите».

При этом теряется смысл проактора — отсутствие межпоточной синхронизации. Привет, мютексы.

Не получится лажи, если самому отработать положенную синхронизацию.

Привет, мютексы.

Структура совершенно разная, active object это персональный reactor на объект. Ему нет смысла заказывать что-то, что он может вызвать напрямую — он реагирует на запросы.

Скорее, персональный proactor, если весь доступ к состоянию объекта проходит через очередь запросов. А иначе — привет, мютексы.

При этом теряется смысл проактора — отсутствие межпоточной синхронизации. Привет, мютексы.

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

Я тоже стараюсь обойтись по максимуму без мьютексов, но настолько их бояться это уже бессмысленно...

Скорее, персональный proactor,

Нет. Он ведь не заказывает каждое поступление ответа...

Кто сказал, что в этом его смысл?

Я. Для этого его брал. Или авторы паттерна:
Simplification of application synchronization: As long as Completion Handlers do not spawn additional threads of control, application logic can be written with little or no regard to synchronization issues. Completion Handlers can be written as if they existed in a conventional single-threaded environment.
www.dre.vanderbilt.edu/...​~schmidt/PDF/Proactor.pdf

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

Ради чего? Multi-threading at Business-logic Level is Considered Harmful
accu.org/index.php/journals/2134
Всю логику можно вложить в один поток без мютексов. KISS.

Нет. Он ведь не заказывает каждое поступление ответа...

А вот не понял. Дай линк на литературу по поводу «заказа каждого поступления ответа» в проакторе. Проактор просто складывает все события в очередь и процессит по-одному.

> Это нельзя дебажить.

У нас есть логи, по которым вылавливается 99% таких ситуаций, если надо :) но вообще нужно иметь тесты на машину состояний каждого объекта по отдельности.

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

У нас только небольшое покрытие автотестами в рамках тестирования всего роутера, остальное полагается на ассерты + manual QA + PM + A/B. При редких внешних релизах получается в общем нормально, основные проблемы пользователей — интероп с ранее неизвестными трубками или серверами. Юнит-тесты требуют времени и на написание, и на поддержку. Так как функциональность все время меняется — они бы сильно тормозили разработку.

> практически в каждом обработчике сообщения (а здесь нет других публичных методов) нужно проверять состояние объекта либо использовать (анти)паттерн State [GoF], что, по моему опыту, ужасно для читаемости кода.

Это очень странный вывод. Многие сущности просто по стандарту описаны в состояниях и переходах между ними (в том же SIP таких большинство). Сочетание типа входящего сообщения и состояния объекта можно формулировать не явным кодом, а метаописаниями, которые можно проверять автоматизированно.

С SIP полностью справляется pjsip. А вот для DECT (и теперь уже FXS) в интернете нормального кода не было обнаружено. Пришлось писать самому.

В любом случае — куда проще и компактнее сделать синхронный вызов метода:

for(CallPartyPtrList::Iterator it = original_dest_.Begin(), end = original_dest_.End(); it != end; ++it) {
	ASSERT(*it != source_);
	ASSERT(!participants_.In(*it));

	DisconnectReason reason = DR_NONE;

	switch((*it)->NewCall(*this, number, final, reason)) {
чем рассылать запрос о входящем звонке на 6 трубок, а потом — ждать 6 ответов.
Сам обработчик входящего звонка — 100 строк шаблонного метода с десятком точек выхода по разным ошибкам. Там есть интересные вещи — например, разрыв пейджинга или отсоединяющегося звонка (короткие гудки) если пришел новый звонок. Не думаю, что это легко сделать автогенерацией из метаописания. Да и сам автогенератор нужно задизайнить и написать. А потом — дебажить то, что он нагенерил. Так как у нас все кейсы разные (мало дупликации кода) — думаю, он не окупился бы.
> Пример — завершение звонка до того, как телефонная книга нашла имя звонящего по номеру. В результате или сейчас сохраняем звонок в истории без имени, или нужно временно оставлять его в состоянии «deleting — waiting for caller name».

Ну вот состояние таки есть. Что мешает оставить его вообще в каком-то Finished без подробностей? Коллбэк пришёл, данные не нужны — тут же вышли.

Сейчас звонок удаляется синхронно. И данные из телефонной книги подтягиваются синхронно. И записывается звонок в историю синхронно с удалением. В этом — плюс полусинхронного подхода, когда вся бизнес-логика в одном потоке, по сравнению с асинхронным, когда каждый объект в своем потоке.

Да, состояние есть. Его овердофига. Но код проще — не нужно при убийстве звонка ставить проверку, пришел ли ранее колбек из телефонной книги (в добаку к проверке, участвует ли звонок в сценарии call transfer и к ожиданию, пока участники звонка отключат голос). Ведь если оставить звонок в «Finished без подробностей» — нужно подсчитывать при каждом колбеке выполняются ли все условия для удаления звонка. И сами колбеки сложными становятся. А сейчас — вот синхронный поиск номера в телефонной книге:

LongString	matched;
g_db->phonebook().Match(*number, matched);
if(!matched && number->In('P'))	// Match without postdial
	g_db->phonebook().Match(number->UpTo('P'), matched);
И вслед — неясно, какая часть всех странностей обусловлена ограничениями ресурсов.

В роутере в ресурсы ни разу еще не уперлись. А вот в плане трудозатрат — да, есть ограничения. Поэтому все делается на коленках по-быстрому, насколько возможно. Уже 5 лет)

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

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

Юнит-тесты требуют времени и на написание, и на поддержку. Так как функциональность все время меняется — они бы сильно тормозили разработку.

У нас основное не юниты, а сценарные функциональные тесты. Юниты пробовали, но они вырождаются в написание проверки, что a() вызывает b(), когда это и так видно в коде. Может, оно тоже полезно — искать вещи, которые глаза не видят напрямую — но не в таком объёме.

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

чем рассылать запрос о входящем звонке на 6 трубок, а потом — ждать 6 ответов.

Хм... создал объект HandsetCall, он сам в ответ на CC-ALERTING поставит знак «мне уже отвечают», что ещё надо? Ну да, раутинг сообщений сделать — это не так сложно.

Сейчас звонок удаляется синхронно. И данные из телефонной книги подтягиваются синхронно. И записывается звонок в историю синхронно с удалением.

Ну, если это какая-то простая локальность — то нет проблем (аналогично тому, что я в соседних комментариях писал про disk I/O).

Ведь если оставить звонок в «Finished без подробностей» — нужно подсчитывать при каждом колбеке выполняются ли все условия для удаления звонка.

На C++ можно это делать просто через shared_ptr: последний ушедший коллбэк вызывает delete. А из центрального реестра он уходит просто по факту перехода в finished в соответствующем обработчике (можно это через тот же state pattern завести на какой-нибудь onEnter() класса CallStateDisconnected, мы делаем именно так). Можно ещё подпереть периодической проверкой состояния, если такое выдаление не сработало — но у нас подобные ситуации возникали только при недоработке дико сложных сценариев с участием промежуточных звонков, трансферов и прочих комбинированных ужасогенераторов.

В роутере в ресурсы ни разу еще не уперлись.

В основной статье утверждалось обратное... или это результат изначальной экономии?

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

Эклипс CDT. Прога запускается на компе (у него те же USB разъемы и эзернет), зависимостей особых нету. Просто на компе тестируем и отлаживаем. В редких случаях приходится ходить к QA на его комп и гонять под gdb. Но суть в том, что прога на компе и в роутере одинаковая, и 95% багов кроссплатформенные.

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

А вот проблема — как туда влепить реальную трубку? Припаивать реле ко кнопкам? И как проверить, что трубка на экране показала то, что нужно. В результате — отказались от автоматизации. Тестировщик надежнее, умнее, и найдет намного больше проблем. И его не нужно программировать.

Хм... создал объект HandsetCall, он сам в ответ на CC-ALERTING поставит знак «мне уже отвечают», что ещё надо? Ну да, раутинг сообщений сделать — это не так сложно.

Один звонок из сети идет сразу на 6 трубок. Часть из них недоступна, кто-то уже разговаривает, у них разные поддерживаемые кодеки. Как тут обойтись одним объектом на всех? Все равно, по воздуху нужно отослать 6 сообщений, и получить 6 ответов. И свести их результат: начала хоть одна трубка звонить (можем отослать 180) или нет (нужно отослать 486).

Ну, если это какая-то простая локальность — то нет проблем (аналогично тому, что я в соседних комментариях писал про disk I/O).

Вся логика живет в одном потоке.

На C++ можно это делать просто через shared_ptr: последний ушедший коллбэк вызывает delete. А из центрального реестра он уходит просто по факту перехода в finished в соответствующем обработчике (можно это через тот же state pattern завести на какой-нибудь onEnter() класса CallStateDisconnected, мы делаем именно так).

А потом это дебажить? Не зная, кто где держит звонок, и почему звонки утекают? Та ну нафиг. Все состояние в явном виде, и ни в коем случае не шарить состояние между потоками.

В основной статье утверждалось обратное... или это результат изначальной экономии?

Что там было за 3 года до нас — неизвестно. Я тот код не поднимал. Либо процы тогда в роутерах были намного слабее, либо взяли неправильную RTP либу. Мы когда заэмбедились в роутер (первые пол-года на компе писали и отлаживали), то обнаружили, что 4 параллельных звонка занимают 5% проца. Все. Вопросы об оптимизациях отпали. У меня сейчас в голосовом пути в одну сторону malloc стоит. Думаю, скоро переделаю.

Подумал, что будет много комментов от Дениса. А оказывается, он и наваял сей трактатъ)

Редакция трактатъ малость отцензурировала, поэтому и не узнал)

Вражаюча робота, дякую!

домашние Wi-Fi роутеры
USB DECT
USB FXS

а в чем смысл, почему просто не покупать роутеры, у которых есть сразу DECT, FXS/FXO ?
или это доработка для вдыхания новой жизни в старую модель какого-то вендора?

Смысл в том, что:
1) покупатель не платит за то, что не использует (цена роутера как преимущество)
2) покупатель знает, что если понадобится — он сможет расширить функциональность уже имеющегося роутера (возможности как преимущество)

это как бы у всех так, просто обычно без икебан на usb-хабах, чтобы воткнуть в как правило единственный USB-порт (или что это за такой заводской домашний Wi-Fi роутер с многоюсбпортов?), кроме МФУ/HDD ещё и DECT+FXS

2 порта бывает. Как раз для тех, у кого интернет по модему, и диск тоже хочется. Но обычно такое уже подороже.
Да и хабы глючат под нагрузкой.

Кстати, еще одно преимущество: для обработки FXS портов в роутере нужно менять kernel preemtion model и увеличивать частоту обработки прерываний ядром, что приводит к уменьшению пропускной способности роутера. У нас FXS на отдельной плате, и на характеристики роутера не влияет.

Наткнулся на такую же архитектуру для геймдева:
ithare.com/...​am-threads-and-game-loop

Его же статья о противоречии многопоточности и бизнес-логики:
accu.org/index.php/journals/2134

Отличный технический материал, большое спасибо !

Не имею ничего против pjsip, но если чего есть хорошая альтернатива под BSD лицензией:
www.resiprocate.org

Спасибо. 6 лет назад самым подходящим показался pjsip — маленький и кросс-платформенный. Например, минимальный рабочий клиент — 200 строк кода
github.com/...​rc/samples/simple_pjsua.c
Для меня это существенно, так как с SIP раньше не работал. Да и когда начинали проект, то оказалось, что это уже вторая попытка — за несколько лет до того другие люди пытались поднять телефонию, но не влезли в проц роутера. Поэтому я брал самое легкое, что смог найти и понять, как применить.

Если что — можно будет пересесть на другой стек (асинхронная прослойка позволит), но доводить его до ума будет долго, да и pjsip пока устраивает. По поводу его скорости — везде memory pools. Не знаю, как оно выживает и не запутывается, но работает очень стабильно. Лицензия двойная.

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

Проще всего смотреть технические материалы на DOU можно так:
dou.ua/lenta/tags/tech
dou.ua/forums/tags/tech

Не на том ресурсе вы такие статьи пишете.

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

А куда такую статью стоило бы послать?

на какой-нибудь украинский аналог Хабра. Он же должен существовать, не так ли?

Думаю, он никому ничего не должен.

так я как раз об этом

И чем вам не нравится DOU в этой роли? На днях специально вынесли на заглавную страницу секцию технических статей, так что пишите. Я тут спорю с Денисом по деталям, но что статьи полезны — оно бесспорно.

«Украинский аналог хабра» присутствует в виде devua.co — можете посмотреть на его свежесть :)
монетизации нет — вот ничего и не будет.
А я туда лично не буду писать ничего осмысленного, пока не будет расписана явно полиси прав на статью, правила поведения редакции, да просто чтобы знать, кто его ведёт, почему и на каких принципах.

Подписаться на комментарии