суббота, 9 января 2016 г.

[prog.sobjectizer] Непонятный момент с реализацией параллельных состояний

В иерархических конечных автоматах могут быть т.н. композитные состояния. Т.е. состояния, которые содержат в себе несколько подсостояний, включая и другие композитные состояния. Так, в примере ниже композитное состояние s0 включает в себя два композитных подсостояния s1 и s2. В свою очередь композитное подсостояние s2 имеет композитное подсостояние s21:

Если композитное состояние A содержит два композитных подсостояния B и C, то могут быть следующие варианты:

  • в конкретный момент времени внутри A активным может быть либо подсостояние B, либо подсостояние C. Можно сказать, что B и C скомбинированы внутри A операцией ИЛИ (OR);
  • в конкретный момент времени внутри A активными являются и подостояние B, и подсостояние C. Можно сказать, что B и C скомбинированы внутри A операцией И (AND). Состояния B и C называют параллельными (parallel states) или конкурентными (concurrent states).

На UML-диаграммах параллельные состояния разделяются пунктирными линиями внутри своего объемлющего композитного состояния. Вот хорошая картинка из Интернета, которая наглядно демонстрирует важность параллельных состояний (на примере простенького КА, описывающего поведение стиральной машины):

В SO-5 сейчас реализуются иерархические конечные автоматы. Но композитные подсостояния можно комбинировать только операцией ИЛИ, т.е. параллельные состояния КА не поддерживаются.

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

Возьмем пример с домофоном, на котором сейчас ставятся все эксперименты. Домофон может быть в двух состояниях: inactive (ожидание взаимодействия с пользователем) и active (процесс взаимодействия с пользователем). Внутри состояния active есть свои подсостояния, которые обрабатывают различные ситуации. Ну, скажем, что-то вроде:

inactive:
  key_cancel => active
  key_grid[defer] => active
  key_bell[defer] => active
  key_digit[defer] => active

active:
  on_enter: включить подсветку
  on_exit: выключить подсветку
  key_cancel => wait_activity

  wait_activity[initial_substate]:
    on_enter: очистить дисплей
    key_grid[defer] => special_code
    key_digit[defer] => number_selection

  number_selection:
    on_enter: сбросить номер квартиры в пустое значение
    key_digit: добавить цифру к номеру; обновить инфо на дисплее
    key_bell: если номер не пуст, то инициировать звонок в квартиру (переход в dialling)...

  dialling:
    key_digit[suppress] // во время звонка на нажатые кнопки не реагируем.
    key_bell[suppress] // во время звонка на нажатые кнопки не реагируем.
    key_grid[suppress] // во время звонка на нажатые кнопки не реагируем.
    key_cancel => wait_activity // по кнопке "отмена" прерываем звонок.

    wait_answer[initial_substate]:
      ...

    no_answer:
      ...
...

Но после перехода из inactive в active кроме реализации диалога с пользователем возникает еще одна задача: контроль времени отсутствия действий со стороны пользователя. Т.е. если пользователь долго ничего не нажимает на домофоне, то нужно вернуться из active в inactive.

Но фокус в том, что нам нужно отслеживать время бездействия не от момента входа в active, а от момента последней активности пользователя. Т.е. пользователь нажал на кнопку, мы начинаем отсчет 30 секунд бездействия. Если за это время пользователь нажал на какую-то кнопку, то старый отсчет мы должны прекратить и начать отсчет следующих 30 секунд бездействия заново.

Как это можно сделать?

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

inactive:
  key_cancel => active
  key_grid[defer] => active
  key_bell[defer] => active
  key_digit[defer] => active

active:
  on_enter: включить подсветку, начать отсчет времени бездействия
  on_exit: выключить подсветку
  key_cancel:  начать отсчет времени бездействия, перейти в wait_activity
  no_user_activity => inactive

  wait_activity[initial_substate]:
    on_enter: очистить дисплей
    key_grid[defer] => special_code
    key_digit[defer] => number_selection

  number_selection:
    on_enter: сбросить номер квартиры в пустое значение
    key_digit: добавить цифру к номеру; обновить инфо на дисплее;  начать отсчет времени бездействия
    key_bell:  начать отсчет времени бездействия; если номер не пуст, то инициировать звонок в квартиру (переход в dialling)...

  dialling:
    key_digit: начать отсчет времени бездействия
    key_bell: начать отсчет времени бездействия
    key_grid: начать отсчет времени бездействия
    key_cancel: начать отсчет времени бездействия; переход в wait_activity // по кнопке "отмена" прерываем звонок.

    wait_answer[initial_substate]:
      ...

    no_answer:
      ...
...

Очевидно, что это не самый лучший подход, т.к. он усложняет реализацию КА и чреват ошибками. Поэтому более выгодно было бы использовать внутри состояния active два параллельных подсостояния: user_dialog (в котором производится взамодействие с пользователем) и inactivity_watching (в котором отслеживается время отсутствия активности пользователя):

inactive:
  key_cancel => active
  key_grid[defer] => active
  key_bell[defer] => active
  key_digit[defer] => active

active:
  on_enter: включить подсветку
  on_exit: выключить подсветку

  -----
  user_dialog:
    key_cancel => wait_activity

    wait_activity[initial_substate]:
      on_enter: очистить дисплей
      key_grid[defer] => special_code
      key_digit[defer] => number_selection

    number_selection:
      on_enter: сбросить номер квартиры в пустое значение
      key_digit: добавить цифру к номеру; обновить инфо на дисплее
      key_bell: если номер не пуст, то инициировать звонок в квартиру (переход в dialling)...

    dialling:
      key_digit[suppress] // во время звонка на нажатые кнопки не реагируем.
      key_bell[suppress] // во время звонка на нажатые кнопки не реагируем.
      key_grid[suppress] // во время звонка на нажатые кнопки не реагируем.
      key_cancel => wait_activity // по кнопке "отмена" прерываем звонок.

      wait_answer[initial_substate]:
        ...

      no_answer:
        ...
  -----
  inactivity_watching:
    on_enter:  начать отсчет времени бездействия
    key_cancel:  начать отсчет времени бездействия
    key_grid:  начать отсчет времени бездействия
    key_bell:  начать отсчет времени бездействия
    key_digit:  начать отсчет времени бездействия
    no_user_activity => inactive
...

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

Но проблема в том, что если состояния user_dialog и inactivity_watching будут состояниями одного и того же объекта, то возникнет вопрос: а в каком именно порядке нужно будет дергать обработчики событий из этих состояний? Вот приходит сообщение key_cancel и кому его отдать в первую очередь? Состоянию user_dialog или inactivity_watching?

Пример с домофоном еще не так сложен. В нем переход из active в inactive происходит только из обработчика сообщения no_user_activity всего в одном подсостоянии -- active.inactivity_watching. Но могут быть гораздо более навороченные КА, в которых некоторое сообщение M может обрабатываться в параллельных подсостояниях S1,...,Sn и какие-то их этих обработчиков будут переводить КА в разные состояния (например, из A.B.S1 в C.D.E1). Вот что делать в этом случае? Ведь тогда порядок передачи сообщения M обработчикам будет играть важнейшее значение.

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

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

За счет использования разных агентов в примере с домофоном контроль отсутствия активности пользователя решается просто: кроме основного агента controller с состояниями inactive и active, есть еще один агент inactivity_watcher, со своими собственными состояниями inactive и active. Агент inactivity_watcher получает сообщения о нажатых кнопках точно так же, как и агент controller, но обрабатывает их по-своему. Когда inactivity_watcher решает, что пользователь слишком долго ничего не нажимал, он отсылает специальное сообщение deactivate, на которое и реагирует controller, осуществляя переход из inactive в active.

Такой подход, полагаю, имеет свои недостатки. Но, имхо, для пользователя он будет более понятным и удобным, чем возможность описания парралельных состояний внутри одного агента. Например, если есть параллельные состояния, то что считать текущим состоянием агента? Да и кардинальной переделки потрохов диспетчеризации сообщений в SO-5 не потребовалось. Что так же есть хорошо.

Однако, поскольку точка в реализации КА в SO-5 еще не поставлена, то при наличии хороших примеров ситуаций, когда параллельные состояния полезны, можно будет подумать и таки сделать их поддержку в SO-5. Так что, если кто-то может привести примеры из практики, то это будет очень здорово. Даже если это будут совсем тривиальные примеры.

Комментариев нет: