Постановка задачи
Моделируется простая система массового обслуживания, состоящая из генератора, буфера и обслуживающего автомата. Если буфер переполняется, заявки откидываются. Время генерации и обработки заявок распределено по экспоненциальному закону.
Условные обозначения:
Г - генератор заявок
Б - буфер заявок
ОА - обслуживающий автомат
Входные данные модели:
- среднее время между заявками
m - емкость буфера
- среднее время обработки заявки
Объявим структуру, которая будет содержать входные данные:
data Input = Input {
generationTime :: Double,
processingTime :: Double,
bufferCapacity :: Int
}
Выходные данные модели:
Вероятность отказа системы
Средний размер очереди в буфере
Среднее количество заявок в системе
Среднее время ожидания в буфере
Объявим структуру, которая будет содержать выходные данные:
data Output = Output {
failChance :: Double,
queueSize :: Double,
requestsCount :: Double,
awaitingTime :: Double
}
Необходимо написать функцию, которая вычислит Output
на основе данного Input
:
simulate :: Input -> Simulation Output
Возвращаемый тип обернут в монаду Simulation
, которая отвечает за вычисления внутри симуляции. В рамках описанной задачи понимание концепции монады не требуется, читатель может рассматривать ее как контейнер, содержащий значение и правила его вычисления. Желающим ознакомиться предлагаем прочитать статью.
Введение в Aivika
Aivika - библиотека для имитационного моделирования, поддерживающая несколько парадигм. Однако мы рассмотрим только одну из них - моделирование на основе процессов.
Библиотека использует сложную иерархию монад для представления различных типов вычислений. Перечислим основные монады, которые потребуются нам для решения задачи:
Simulation
- вычисления внутри симуляции. Является основной монадой в Aivika, так какSimulation a
обозначает имитационную модель, которая вычисляет значение типаa
и описывает все аспекты модели.Event
- значение внутри этой монады является функцией от времени со строгой синхронизацией с очередью событий. Вычисления в монадеEvent
не могут быть приостановлены. Пример использования: отправка сообщения в буфер.Process
- вычисление, которое может быть приостановлено в любой момент и возобновлено позже. В нашей задаче данная монада будет использована для представления процессов генератора и обслуживающего автомата.Parameter
- значение внутри этой монады является внешним параметром для имитационной модели. Пример использования: получение максимального времени, до которого проводится симуляция; вычисление случайных величин.
Данные монады выстраивают иерархию, которая показана на рисунке ниже. Значение из менее общей монады можно преобразовать в значение более общей монады. Обратное преобразование запрещено, так как происходит потеря контекста. По схожей логике, целые числа можно преобразовать в дробные без потери информации, а дробные в целые - просходит потеря информации о дробной части числа.
Стрелка от монады A
к монаде B
означает, что вычисление A t
может быть приобразовано в вычисление B t
с помощью специальных функций, имена которых начинаются с lift
(например, liftSimulation
может перевести вычисление Simulation t
в Event t
и Process t
):
Построение обслуживающих автоматов
В данной модели буфер можно представить как FIFO (first in, first out), иначе называемую FCFS (first come, first served), очередь. В aivika есть подходящий тип очереди: FCFSQueue
:
type Buffer a = FCFSQueue a
Здесь a
отвечает за тип элементов, которые хранятся в очереди. Также для краткости определим синоним функции для создания нового буфера:
newBuffer :: Int -> Event (Buffer a)
newBuffer = newFCFSQueue
Функция принимает максимальную емкость буфера, после которой элементы будет отбрасываться. Также нужно обратить внимание: так как новый буфер обернут в монаду Event
, создание очереди происходит мгновенно в модельном времени.
Теперь определим обслуживающий автомат. Его будем моделировать с помощью зацикленного вычисления в монаде Process
. Так как обслуживающий автомат использует входной буфер, то нам понадобится функция-обертка, которая предварительно создаст буфер и передаст его в обработчик обслуживающего автомата (далее ОА):
withBuffer :: Int -> (Buffer a -> Process b) -> Event (Process b, Buffer a)
withBuffer capacity f = do
buffer <- newBuffer capacity
return (f buffer, buffer)
Функция принимает размер буфера ОА, функцию-обработчик и возвращает кортеж из двух элементов: новый обработчик ОА, готовый к запуску симуляции; и созданный буфер, который будет использоваться в других ОА.
В качестве заявок, проходящих через систему будем использовать следующий тип:
data Request = Request
Он не несет никакой дополнительной информации в себе, значение этого типа просто обозначает наличие заявки.
Обрабатывающий автомат
Теперь мы можем определить обработчик ОА с буфером:
processor :: Event (Process (), Buffer Request)
processor = withBuffer bufferCapacity $ \buffer-> forever $ do
_ <- dequeue buffer
holdExponential processingTime
Для создания ОА мы использовали только что определенную функцию withBuffer
. ОА бесконечно (благодаря функции forever
) забирает заявки из буфера с помощью dequeue
, уничтожает их (так как заявка покидает систему после этого ОА и больше не нужна), и имитирует обработку путем остановки на случайное количество секунд, распределенных экспоненциально с парамером λ равным processingTime
секунд. Для приостановки процесса используется вспомогательная функция holdExponential
, которую мы определим как:
holdExponential :: Double -> Process ()
holdExponential t = do
htime <- liftParameter $ randomExponential t
holdProcess htime
Нужно заметить, что функция из aivika randomExponential
имеет тип:
randomExponential :: Double-> Parameter Double
Поэтому мы поднимаем вычисление из монады Parameter
в монаду Process
с помощью liftProcess
.
Функция holdProcess
из aivika приостанавливает выполнение процесса на переданное ей количество времени.
Генератор заявок
Определим генератор заявок, он будет бесконечно создавать новые Request
и класть их во входной буфер ОА:
generator :: Buffer Request -> Process ()
generator buffer = forever $ do
holdExponential generationTime
liftEvent $ enqueueOrLost_ buffer Request
Функция enqueueOrLost_
в aivika пытается поставить заявку в очередь, и если буфер переполнен, то отбрасывает ее. Функция выполняется в монаде Event
, поэтому нам необходимо использовать liftEvent
, чтобы поднять вычисление в монаду Process
.
Собираем систему
Определим функцию, которая создаст описание модели, готовой для запуска симуляции:
simulate :: Input -> Simulation ()
simulate Input{..} = runEventInStartTime $ do
(processorProc, buffer) <- processor
generatorProc <- generator buffer
liftSimulation $ do
runProcessInStartTime processorProc
runProcessInStartTime generatorProc
where
generator :: Buffer Request -> Process ()
generator buffer = forever $ do
holdExponential generationTime
liftEvent $ enqueueOrLost_ buffer Request
processor :: Event (Process (), Buffer Request)
processor = withBuffer bufferCapacity $ \buffer-> forever $ do
_ <- dequeue buffer
holdExponential processingTime
Входной параметр с настройками модели записан с помощью расширения языка RecordWildcards, поэтому поля структуры Input
видны в функциях generator
и porcessor
.
runEventInStartTime
позволяет запустить вычисление в монаде Event
в момент старта симуляции, в этот момент мы создаем генератор и ОА и связываем их вместе, а функция runProcessInStartTime
запускает обработчики генератора и ОА в момент старта симуляции.
Сбор статистики
Модель готова, но ничего полезного из нее сейчас нельзя получить, поэтому добавим сбор статистики из генератора и ОА.
Для отслеживания количества заявок в системе добавим переменную-счетчик Var Int
, которая будет увеличиваться, когда заявка выходит из генератора и уменьшаться, когда ОА заканчивает обработку заявки. Особенность Var
в том, что этот тип переменной сохраняет историю изменений, что позволит посчитать среднее количество заявок в системе:
generator :: Buffer Request -> Event (Process (), Var Int)
generator buffer = do
stats <- liftSimulation $ newVar 0
return $ (, stats) $ forever $ do
holdExponential generationTime
liftEvent $ do
modifyVar stats (+1)
enqueueOrLost_ buffer Request
processor :: Var Int -> Event (Process (), Buffer Request)
processor stats = withBuffer bufferCapacity $ \buffer-> forever $ do
_ <- dequeue buffer
holdExponential processingTime
liftEvent $ modifyVar stats (subtract 1)
В функции создания генератора создается новая переменная с помощью stats <- liftSimulation $ newVar 0
, и увеличивается на 1 каждый раз, когда новая заявка появляется в системе.
Для ОА переменная является входным аргументом, каждый раз, когда заявка покидает систему, переменная уменьшается на единицу.
Однако при создании генератора и ОА в функции simulate
мы столкнемся с рекурсивной зависимостью:
simulate :: Input -> Simulation ()
simulate Input{..} = runEventInStartTime $ do
(processorProc, buffer) <- processor stats
(generatorProc, stats) <- generator buffer -- рекурсивная зависимость
liftSimulation $ do
runProcessInStartTime processorProc
runProcessInStartTime generatorProc
where
generator :: Buffer Request -> Process ()
generator buffer = forever $ do
holdExponential generationTime
liftEvent $ enqueueOrLost_ buffer Request
processor :: Event (Process (), Buffer Request)
processor = withBuffer bufferCapacity $ \buffer-> forever $ do
_ <- dequeue buffer
holdExponential processingTime
Переменная stats
используется в processor
до того, как она создается в generator
, но поменять порядок выполнения мы не можем, так как processor
создает буфер buffer
, который используется в generator
.
Для разрешения этой проблемы воспользуемся расширением RecursiveDo
,оно позволит "отложить" использование рекурсивной перменной stats
, чтобы передать ее в processor
:
simulate :: Input -> Simulation ()
simulate Input{..} = runEventInStartTime $ do
-- ключевое слово rec обозначает блок с рекурсивными зависимостями
rec (processorProc, buffer) <- processor stats
(generatorProc, stats) <- generator buffer
liftSimulation $ do
runProcessInStartTime processorProc
runProcessInStartTime generatorProc
where
generator :: Buffer Request -> Process ()
generator buffer = forever $ do
holdExponential generationTime
liftEvent $ enqueueOrLost_ buffer Request
processor :: Event (Process (), Buffer Request)
processor = withBuffer bufferCapacity $ \buffer-> forever $ do
_ <- dequeue buffer
holdExponential processingTime
Теперь настало время заполнить структуру Output
с помощью средств для сбора статистических данных в aivika. Функция simulate
теперь будет возвращать тип Output
:
simulate :: Input -> Simulation Output
А в конец функции мы добавим код с вычислением выходных данных модели:
runEventInStopTime $ do
-- Вытаскиваем из буфера статистику по размеру
sizeStats <- queueCountStats buffer
-- Достаем из буфера количестов откинутых заявок и конвертируем в Double
lostCount <- fromIntegral <$> enqueueLostCount buffer
-- Достаем из буфера общее количество заявок за все вермя
totalCount <- fromIntegral <$> enqueueCount buffer
-- Статистика со временем ожидания заявки в буфере
awaiting <- queueWaitTime buffer
-- Вытаскиваем статистику из нашей переменной
requestsCountStats <- statsFromVar stats
return Output {
-- Считаем вероятность отказа через определение вероятности
failChance = lostCount / (lostCount + totalCount),
-- Вычисляем среднее значение размера буфера
queueSize = timingStatsMean sizeStats,
-- Вычисляем среднее количество заявок в системе
requestsCount = samplingStatsMean requestsCountStats,
-- Вычисляем среднее время ожидания заявки в буфере
awaitingTime = samplingStatsMean awaiting
}
Итого функция, описывающая модель поставленной задачи:
simulate :: Input -> Simulation Output
simulate Input{..} = runEventInStartTime $ do
rec (processorProc, buffer) <- processor stats
(generatorProc, stats) <- generator buffer
liftSimulation $ do
runProcessInStartTime processorProc
runProcessInStartTime generatorProc
runEventInStopTime $ do
sizeStats <- queueCountStats buffer
lostCount <- fromIntegral <$> enqueueLostCount buffer
totalCount <- fromIntegral <$> enqueueCount buffer
awaiting <- queueWaitTime buffer
requestsCountStats <- statsFromVar stats
return Output {
failChance = lostCount / (lostCount + totalCount),
queueSize = timingStatsMean sizeStats,
requestsCount = samplingStatsMean requestsCountStats,
awaitingTime = samplingStatsMean awaiting
}
where
generator :: Buffer Request -> Event (Process (), Var Int)
generator buffer = do
stats <- liftSimulation $ newVar 0
return $ (, stats) $ forever $ do
holdExponential generationTime
liftEvent $ do
modifyVar stats (+1)
enqueueOrLost_ buffer Request
processor :: Var Int -> Event (Process (), Buffer Request)
processor stats = withBuffer bufferCapacity $ \buffer-> forever $ do
_ <- dequeue buffer
holdExponential processingTime
liftEvent $ modifyVar stats (subtract 1)
Весь описанный выше код размещен в отдельном проекте, где его можно изменять и запускать:
Вероятность отказа: 0.80
Средний размер буфера: 1.83
Среднее число заявок в системе: 4.29
Среднее время ожидания в буфере: 99.99
В предоставленном проекте входные параметры изменены так, чтобы была возможность задавать закон распределения генерации заявок:
-- | Входные параметры модели
testInput :: Input
testInput = Input {
-- | Закон распределения потока заявок
-- Указывается один из: exponential, erlang, normal, uniform
-- каждому закону нужно указать список параметров
-- для exponential нужен 1 параметр (среднее время между заявками)
-- для других (erlang и т.д.) нужно по 2 параметра
generationDistribution = generationDistr "hyperexponential" [10, 0.5, 120, 0.5],
-- | Закон распределения обработки заявок в обслуживающем автомате
-- параметры аналогичны.
processingDistribution = generationDistr "exponential" [50],
-- | Емкость буффера
bufferCapacity = 2,
-- | Время иммитационного моделирования
simulationTime = 500000.0,
-- | Количество знаков после запятой в выводе результатов
outputPrecision = 5
}
Поддерживаемые законы:
exponential
- экспоненциальное распределение, принимает один параметр (среднее время между событиями). Пример:generationDistr "exponential" [50]
hyperexponential
- гиперэкспоненциальное распределение, принимает пары параметров: вероятность i-ого экспоненциального распределения и его параметр (среднее время между событиями). Пример:generationDistr "hyperexponential" [10, 0.5, 120, 0.5]
erlang
- эрланговское распределение, принимает два параметра: среднее время (плавающая запятая) и порядок (положительное целое). Пример:generationDistr "erlang" [50, 2]
normal
- нормальное распределение, принимает два параметра: среднее значение (плавающая запятая) и среднеквадратичное отклонение (плавающая запятая). Пример:generatonDist "normal" [25.5, 2.5]
uniform
- равномерное распределение, принимает два параметра: минимальное время и максимальное (плавающая запятая). Пример:generationDist "uniform" [10, 20]