Сучасна диджитал-освіта для дітей — безоплатне заняття в GoITeens ×
Mazda CX 30
×

Как построить полусинхронную архитектуру на примере 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).

Все про українське ІТ в телеграмі — підписуйтеся на канал DOU

👍ПодобаєтьсяСподобалось0
До обраногоВ обраному8
LinkedIn

Схожі статті




75 коментарів

Підписатись на коментаріВідписатись від коментарів Коментарі можуть залишати тільки користувачі з підтвердженими акаунтами.

Вот еще старая дискуссия на тему BlockingConsideredHarmful и как раз о телефонии
wiki.c2.com/...​ternativeToMultiThreading

Чисто поинтересоваться: с нашим SIP PBX (3CX) на совместимость пробовали?

Да, были проблемы у пользователей:
1) 3CX на каждый Hold и Unhold пересылает по два re-INVTE, причем с разным набором кодеков.
2) Были проблемы с переносом вызова, но уже не могу откопать, в чем именно.

К самому серверу и конторе следующие претензии:
А) Если клиент крешится, а потом перезапускается и пытается еще раз зарегистрироваться, сервер игнорит новую регистрацию. В результате после креша телефония не работает, пока не истечет таймаут на севере.
Б) Не удалось получить демо лицензию — форма регистрации на сайте не работала, поддержка игнорировала запросы. Пришлось отлаживаться с сервером пользователя.

О, как интересно! Никогда не видел два реинвайта на холд.анхолд. Логи не сохранились у Вас? Я бы поправил если что.

сервер игнорит новую регистрацию

Как именно игнорит? Вообще не отвечает? Для меня это как-то нереально звучит.
Перед перерегистрацией не пробовали анрегистрировать? В любом случае, подобное наш кьюей не пропустил бы. Ни один другой сип-клиент (а их у нас тестируется десятка три разных) этим не страдает. Вы точно рандомный транзакшн айди и новый кол-айди прописываете в REGISTER?

Насчет демо-лицензии — не в курсе, не моя парафия, но должно работать. Даже без активации лицензии основной функционал доступен и так.

Чтоб расставить точки над «ё» — Вы с какой версией 3СХ дружить пытались?

Это было в 2018, ПМ, который разбирался, сейчас в отпуске. Если на следующей неделе напомните — спрошу старые логи с 3СХ.

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

Вероятно.
Мы уже не занимаемся работой с 3СХ.

Интересно в таком случае с каким(и) РВХ Вам таки удалось «подружиться»?

С большинством провайдеров. pjsip хорошо оттесчен на SIP форуме.
www.sipit.net/Main_Page

Эх, вам бы нашего Пьера Журдена на недельку откоммандировать — он бы вам такой стресс-тест устроил... тут кагбе немного другие критерии оттестированности

Что это такое за 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 ответила. В чём-то может быть общее состояние (где они воздействуют на звонок), и это необходимо; в чём-то — нет. Что плохого в общем состоянии там, где это нужно?
И что тогда получается, если может отработать только одно сообщение — зачем такое вообще нужно? Какие-то странные вещи получаются.

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

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

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

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

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

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

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

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

Цитаты по проактору я привёл в соседнем ответе, а что ещё — уже ты говори, с чем не согласен.

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

Что-то я не понял, как у тебя ОС вошла в категорию «низкоуровневые потоки», но даже если так, то я не понимаю, что ты видишь в этом плохого. Да, я спокойно могу подписаться в том же реакторном движке на юниксовые сигналы, сообщения через dbus и тому подобное... пуркуа бы и не па?

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

Ну нету там такого...

Если пришло колбеком из другого потока — ты влип в мютексы.

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

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

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

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

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

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

В результате тебе все равно нужно будет положить в очередь сообщение об этом сигнале

Да, кроме особо критических сигналов так и делается.

И я бы даже не сказал, что сигнал работает в потоке — он поток прерывает.

Далеко не обязательно.
Сейчас типовой вариант это заблокировать через sigprocmask() немедленную доставку сигнала, и наблюдать поступление через signalfd. В результате они просто идут через оповещение в тот же цикл. До signalfd были аналогичные подходы через pselect, ppoll (которые можно делать и на epoll десктрипторе).

поэтому взятие двух мютексов почти гарантированно задедлочит.

Специфика понятна. Но я повторюсь, что не обязательно разделять мьютексы на разных объектах в пределах реально _одного_ звонка, на сколько бы сторон он ни раскинулся.

Мютекс на очереди — ок, мютексы в логике — сильно много кода там, и страшно.

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

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

Да. Но получив заказ на асинхронное чтение с диска, уже он определяет, как тот заказ будет исполняться. Может, простой 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.

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

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

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

Начнём с вики:

> The Proactive Initiator starts the asynchronous operation via the Asynchronous Operation Processor and defines the Completion Handler. Completion Handler is a call at the end of the operation from the Asynchronous Operation Processor.

то есть без заказа через Initiator операции нет и Completion Handler не вызовется.

С твоей ссылки Proactor.pdf:

> An asynchronous Web server would utilize the Proactor pattern by first having the Web server issue an asynchronous operation to the OS and registering a callback with a Completion Dispatcher that will notify the Web server when the operation completes.

> 2. The Acceptor initiates an asynchronous accept with the OS and passes itself as a Completion Handler and a reference to the Completion Dispatcher that will be used to notify the Acceptor upon completion of the asynchronous accept;

> 8. The HTTP Handler initiates an asynchronous operation to read the request data from the client and passes itself as a Completion Handler and a referenceto the Completion Dispatcher that will be used to notify the HTTP Handler upon completion of the asynchronous read;

И снова „by first having the Web server issue an asynchronous operation”, то есть, заказ является обязательной частью.

POSA-2:

> In detail: for every service offered by an application, introduce asynchronous operations that initiate the processing of service requests ’proactively’ via a handle, together with completion handlers that process completion events containing the results of these asynchronous operations. An asynchronous operation is invoked within an application by an initiator, for example, to accept incoming connection requests from remote applications. It is executed by an asynchronous operation processor. When an operation finishes executing, the asynchronous operation processor inserts a completion event containing that operation’s results into a completion event queue.

„An asynchronous operation is invoked within an application by an initiator”.

Кроме вики, всё те же источники, что ты сам предложил.

Ну, вызовы асинхронного интерфейса. А о чем спор был?
В той же оригинальной статье 6 страница внизу:
www.dre.vanderbilt.edu/...​~schmidt/PDF/Proactor.pdf
When applications invoke Asynchronous Operations, the operations are performed without borrowing the application’s thread of control.
In contrast, the reactive event dispatching model steals the applica-tion’s thread of control to perform the operation synchronously.
Вроде, в этом и разница.

А о чем спор был?

Ну если тебе облом подымать историю, то мне тоже ;)

When applications invoke Asynchronous Operations, the operations are performed without borrowing the application’s thread of control.

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

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

Насколько я понимаю, реактор — это обобщенное название потока-обработчика событий.
Проактор — это его вариант, когда нет блокирующих операций, и один поток может параллельно обрабатывать несколько асинхронных сценариев.
Актор (активный объект) — другой (ортогональный) вариант, когда поток связывается с объектом, и больше никто не лезет в его память.
Leader/Followers — третий вариант, когда пул потоков связан в список, один поток ждет событий как реактор, а когда событие пришло — он передает эстафету ожидания следующему потоку из пула, а сам обрабатывает пришедшее событие.

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

Проактор — это его вариант, когда нет блокирующих операций, и один поток может параллельно обрабатывать несколько асинхронных сценариев.

И да и нет. Разница в том, что операции типа чтения/записи файла или «сырого» диска, как операции прямого доступа, не могут быть представлены в стиле реактора (просто включили заказ событий по объекту и ждём), их надо каждую заказывать явно. И на проактор, где все операции задаются явно, даже если это операции с сокетом или пайпом, это единообразно ложится. А вот в варианте «подписались и ждём готовности» (см. select(), epoll и все аналоги) операции прямого доступа невольно получаются блокирующими.

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

Актор (активный объект) — другой (ортогональный) вариант, когда поток связывается с объектом, и больше никто не лезет в его память.

Да.

Leader/Followers — третий вариант, когда пул потоков связан в список, один поток ждет событий как реактор, а когда событие пришло — он передает эстафету ожидания следующему потоку из пула, а сам обрабатывает пришедшее событие.

Такого не видел.

Вообще, самое вкусное для простого программиста это когда пишешь линейный код, а всё переключение делается рантаймом. Но это сложно уложить на C++ (как минимум до 20-го, который ещё надо очень осторожно пробовать).

Опять. Берем оригинальную статью по проактору.

When applications invoke Asynchronous Operations, the operations are performed without borrowing the application’s thread of control.
In contrast, the reactive event dispatching model steals the application’s thread of control to perform the operation synchronously.

Вот разница — у реактора синхронные операции с периферией, у проактора — асинхронные. В обоих статьях один автор, и это — его мнение.
www.dre.vanderbilt.edu/...​t/PDF/reactor-siemens.pdf
www.dre.vanderbilt.edu/...​~schmidt/PDF/Proactor.pdf

Leader/Followers в том же POSA2.

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

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

Вот разница — у реактора синхронные операции с периферией, у проактора — асинхронные. В обоих статьях один автор, и это — его мнение.

Ну я это же и говорю. Реактор выдаёт готовность чтения из сокета, а дальше ты синхронно зовёшь read() на полученное. Проактор получает запрос «прочитай до 1024 байт из этого сокета и свистни когда получится (или когда будет ошибка)».
Только вот в случае сокета такая синхронная операция не будет блокировать нить, а в случае прямого доступа — будет.
Тут всё так же как в наше время.

operations are performed without borrowing the application’s thread of control.

А вот это уже показывает, что подразумевается заказ операций у ОС (aio_read/etc. для Unix, overlapped-операции для Windows, и тому подобное).

Leader/Followers в том же POSA2.

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

Это и есть реактор, где один поток линейно обрабатывает один запрос, блокируясь на функциях ОС.

Да не обязательно блокируется. Наоборот, стараются не блокировать — например, ставить O_NONBLOCK на сокеты.

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

Просто в лоб никак оно не решается, если хочешь параллельно процессить несколько запросов.

Ну а если у меня уже есть корутины, то чем писать на них по их правилам не «в лоб»?

Я называю паттерн целиком Реактор/Проактор, и весь паттерн занимается обработкой запроса целиком — от получения нотификации из сокета до отправки ответа в сокет.

Корутины сами себя не запустят. Тебе нужно будет
1) сделать список корутин, ожидающих выполнения асинхронного действия.
2) в обработчике сигнала от ОС раскрутить этот список, найти корутину, к которой относится полученный сигнал, и руками передать ей управление.
3) в коде самой корутины после каждого асинхронного шага проверять, не запортили ли другие корутины глобальное состояние сценария, пока мы спали.

В общем, получается примерно настолько же весело:
+ корутины позволяют писать код сценариев подряд (процедурно а не event-based)
— овердофига сложного кода под капотом для переключения корутин, его надо писать руками
— ни разу не поможет рулить железом и протоколами, это тоже будет под капотом, перемешанное с движком корутин
— каждый асинхронный шаг может привести к инвалидации состояния, и его нужно проверять руками
— локальные переменные на стеке могут протухать (не соответствовать состоянию системы)
— нет мультикаста (не можешь из корутины разослать сообщение нескольким объектам)
— требуется четкий интерфейс запрос/ответ от всех модулей, с которыми хочешь работать асинхронно (у тебя список корутин, ждущих конкретного ответа)
— чтобы отменить сценарий, бегущий в корутине, нужно послать ей исключение из основного потока или другой корутины, а в обработчике исключения понять, что корутина уже успела изменить, и откатить изменения.
По результату, насколько я понял, корутины хорошо, когда под низом что-то очень простое (файловая система или связь с сервером), и вся логика в юзер сторис, и этих кейсов очень много. А если надо делать поддержку протокола — то только огребешь огромную сложность, разделяя юз кейсы от реакции на события.

Я называю паттерн целиком Реактор/Проактор, и весь паттерн занимается обработкой запроса целиком — от получения нотификации из сокета до отправки ответа в сокет.

Ну пусть так :)

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

Я писал на корутинах.
Да, перед стартом цикла надо вгрузить в него некоторый начальный набор корутин, которые будут, например, ждать входящих соединений... это не страшно :)
Хуже другое — что, например, прервать await каким-то экстренным сигналом со стороны — это не всегда возможно и местами странно (питоновский asyncio, например, имеет средство доставить генерацию исключения в корутину).

2) в обработчике сигнала от ОС раскрутить этот список, найти корутину, к которой относится полученный сигнал, и руками передать ей управление.

Ну так а нам что, если это уже готово?

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

Местами надо, а местами и нет. Но таки да, серебряная пуля тут не существует.

— овердофига сложного кода под капотом для переключения корутин, его надо писать руками

Python, Go, C# - всё сделано за нас.
C++20 — надо будет изучить, но наверняка тоже уже готово.

— ни разу не поможет рулить железом и протоколами, это тоже будет под капотом, перемешанное с движком корутин

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

— нет мультикаста (не можешь из корутины разослать сообщение нескольким объектам)

Смотря какое сообщение. Вообще функциональность типа await all или await any во многих уже присутствует.

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

Это неизбежно, но разве это плохо? И так вызов чего-то даже как функции это запрос-ответ.

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

Ну, RAII и finally решает 90% этого, а остальное смотрится по месту.

В общем, обычно проблемы меньше, чем кажется для общего случая.

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

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

Ну так а нам что, если это уже готово?

Вроде, не готово, а нужно писать. Есть только команда языка «разбудить ждущую корутину». А как ты ее поюзаешь — твои проблемы.
Вот пример либы с корутинами от чувака, который их рекламирует github.com/node-dot-cpp/node.cpp правда, оно может быть на Бусте а не на С++20, и я там не нашел нормальных примеров. Может, тебе будет интересно разобраться.

Ну смотря что требуется от железа и протоколов.

Ну типа ни SIP ни DECT на корутинах не напишешь.

Это неизбежно, но разве это плохо? И так вызов чего-то даже как функции это запрос-ответ.

Ну вот по SIP ты посылаешь INVITE а ждешь что? А если прийдет 100 Trying, что дальше будешь делать? И кто обработает 180 Ringing, который приходит потом? Вот это вот все не ложится на запрос/ответ.

Ну, RAII и finally решает 90% этого, а остальное смотрится по месту.

Ага, а потом отменишь кусок состояния, на который полагалась другая спящая корутина, и ей жизнь попортишь.

Ну вот по SIP ты посылаешь INVITE а ждешь что? А если прийдет 100 Trying, что дальше будешь делать? И кто обработает 180 Ringing, который приходит потом? Вот это вот все не ложится на запрос/ответ.

Если корутина реализует одну исходящую транзакцию, будет типа такого:

async func out_transaction(owner: TransactionUser, req: SipRequest, mbx: MessageQueue)
{
  // Цикл посылки
  const timeouts: []float = {0.5, 1, 2, 4, 8, 16, 0.5}; // 32 секунды в сумме
  for (i = 0; i<=6; ++i) {
    transport->send(req, resp_to: mbx);
    timer_engine->requestMessageAfter(after: timeouts[i], message: "timeout", queue_to: mbx);
    message = await mbx->get_any();
    if (message != "timeout") { break; }
  }
  if (message == "timeout") { owner->onTimeout(transaction: this); return; }
  if (message == "cancel") { return; }
  // После этой точки cancel не работает
  for(;;) {
    owner->onResponse(transaction: this, response: message); // посылаем и 100, не вредно
    if (isFinalSipResponse(message)) { break; }
    message = await mbx->get_any();
  }
}

Ну да, цена подхода — персональная очередь на транзакцию (дёшево в общем случае); ну и всякие onCancel, onResponse зовутся синхронно, но должны как-то владельцу тоже попасть в его await. Если сделано, например, в стиле Python, то каждый из них превращается в что-то типа

yield TimeoutResponse(this)
yield TransactionResponse(this, response)

Владелец тоже может быть корутиной, которая вызвала один раз startTransaction(), а дальше делает цикл типа

while (!isFinalResponse(response = await transaction->getResponse()) {
  ...
}

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

А что тебе кажется неудачным в таком подходе?

(Я могу сам сказать, что мне не нравится, но хочу сначала тебя послушать.)

Вот пример либы с корутинами от чувака, который их рекламирует

Я бы не сказал, что там корутины. Там сплошные коллбэки. Вот это на корутинах было бы или возвратом ошибки из read(), который выше по тексту, или генерацией исключения в нём же.

Ага, а потом отменишь кусок состояния, на который полагалась другая спящая корутина, и ей жизнь попортишь.

Решается, повторюсь, или посылкой исключения в корутину, или явной проверкой на выходе.
Да, «а кому сейчас легко?» (tm)
Зато код линейный.

Выглядит прилично, но сложно.
Что будет со сценариями, включающими 2 корутины? Например, перенос вызова (не помню уже, могут ли там оба вызова быть в состоянии дозвона).
Кто обрабатывает неожиданные сообщения, касающиеся данного звонка (перенос, смена кодека, еще что-то такое)? Получается, их все нужно вносить в out_transaction?
И установленный звонок (после 200) перестает быть корутиной, или остается до конца жизни?

Выглядит прилично, но сложно.

Увы, SIP не прост, и даже чудовищно сложен и громоздок — за пределами минимальной сценарной реализации. Но тут как раз мы в это ещё не влезаем.

Что будет со сценариями, включающими 2 корутины? Например, перенос вызова (не помню уже, могут ли там оба вызова быть в состоянии дозвона).

На уровень транзакций всё это не влияет. Проблемы начинаются выше: уже проблема forked dialogs в UAC может сорвать крышу. Самый же адъ и сектор Газа это когда начинается согласование требований OAM (например, в виде RFC6337) со внутренними требованиями по состоянию звонков. Вот там я без чётко проработанных (а лучше и проверенных на корректность машиной на основании описания) машин состояний со всеми воздействиями — не берусь гарантировать работоспособность.

Кто обрабатывает неожиданные сообщения, касающиеся данного звонка (перенос, смена кодека, еще что-то такое)?

Это всё забота уровня UA, а не транзакций.

Но если у тебя внутри просто некая голосовая дырка, которую надо кормить звуком в фиксированном формате типа 48000/16bit/mono, то проблем по минимуму (даже если эта дырка реально труба куда-то в другую сеть с другими протоколами). А вот если тебе нужно быть посредником в стиле b2bua, минимизируя собственную активность в процессе... вот тогда точно см. выше. Или когда ты начинаешь тупые игры типа «включить видео на ходу до подтверждения установления звонка».

И установленный звонок (после 200) перестает быть корутиной, или остается до конца жизни?

Нет, он стоит ещё 32 секунды (отрабатывая повторы респонсов, на которые надо ответить ACK) и завершается. Я этого в коде не писал, для простоты.
Дальше работает UA, конкретно — уровень диалогов в ней, и уровень сессии.

Тут еще такая деталь:
У тебя в примере кода все равно дергается owner->onResponse(), только через корутину, а не напрямую. Тогда смысл в этом случае заводить корутины, если по результату все скатилось в event-based хэндлеры OnMessage()?

Ты говорил «SIP на корутинах не напишешь». Я говорю — вполне напишу :) Вопрос, будет ли это удобнее аналогов, не стоял.

Что внутри хэндлеров — уже их дело. Скорее всего, да, они будут перекидывателями сообщения в другую корутину, которая стоит в yield в ожидании поступления (а будет она вызвана сразу или нет — уже дело рантайма).

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

А вот логику call session на это не уложишь, там сейчас методы с развилками на ~50 подслучаев...

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

 У вас оно все в базе, которая другим процессом бежит?
Я поднял все полезные данные в память, и к ним синхронный доступ. В результате нет каскада. Правда, у меня клиент, и каких-то огромных баз нету.

У вас оно все в базе, которая другим процессом бежит?

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

Вот на заметку — как народ масштабировался
ithare.com/...​hreading-with-a-script/3
Подняли маппинг базы в память, и доступ к базе сделали однопоточным через проксю.

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

У нас есть логи, по которым вылавливается 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 — можете посмотреть на его свежесть :)
монетизации нет — вот ничего и не будет.
А я туда лично не буду писать ничего осмысленного, пока не будет расписана явно полиси прав на статью, правила поведения редакции, да просто чтобы знать, кто его ведёт, почему и на каких принципах.

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