Программная Транзакционная Память На Свободных Монадах

Понимая, что я давно не писал на Хабре ничего полезного про FP и Haskell, и что для технической статьи есть вполне отличный повод, я решил стряхнуть былые времена.

В статье пойдет речь Программная транзакционная память (STM) , который мне удалось реализовать на монадах Free с участием ADT (алгебраических типов данных) и MVars (конкурентных изменяемых переменных).

И вообще, Proof of Concept оказался предельно простым по сравнению с «настоящим» STM. Давайте обсудим это.



Программная транзакционная память

STM — это подход к программированию конкурентной модели данных.

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

Транзакционность аналогична базе данных, но есть ряд отличий.

Допустим, вы хотите изменить некоторые данные в своем коде.

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

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

Разве оно не изменилось? Хорошо, новые значения будут записаны.

Кто-то добрался туда до тебя? Это конфликт. Пожалуйста, перезапустите расчеты на новых данных.

Схематически это можно представить так:

Программная транзакционная память на свободных монадах

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

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

«Транзакции составной памяти» , ведь он отличается рядом замечательных свойств:

  • разделены понятия модели данных и вычислений на ней;
  • Вычисление является монадой STM и компонуется в полном соответствии с парадигмой FP;
  • есть концепция ручного перезапуска расчета (retry);
  • наконец, есть отличная реализация для Haskell, которую я, правда, рассматривать не буду, а остановлюсь на своей, похожей по интерфейсу.

Моделью может быть любая структура данных.

Вы можете преобразовать любую вашу обычную модель в транзакционную; для этого библиотеки STM предоставляют различные примитивы: переменные ( ТВар ), очереди ( TQueue ), массивы ( TArray ) и многие другие.

Нетрудно догадаться, что транзакционные переменные — TVars («существа») — уже минимально достаточны для полноценного STM, а все остальное выражается через них.

Рассмотрим, например, проблема обедающих философов .

Мы можем думать о форках как об общем ресурсе, к которому нам нужно создать конкурентный доступ:

  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
   

data ForkState = Free | Taken type TFork = TVar ForkState data Forks = Forks { fork1 :: TFork , fork2 :: TFork , fork3 :: TFork , fork4 :: TFork , fork5 :: TFork }

Эта модель самая простая: каждый форк хранится в своей переменной транзакции, и работать с ними нужно парами: (вилка1, вилка2), (вилка2, вилка3), … (вилка5, вилка1) .

Но такая структура будет работать хуже:

type Forks = TVar [ForkState]

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

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

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

Вот как может выглядеть расчет в монаде STM для модели с отдельными существами-вилками:

data ForkState = Free | Taken type TFork = TVar ForkState takeFork :: TFork -> STM Bool takeFork tFork = do forkState <- readTVar tFork when (forkState == Free) (writeTVar tFork Taken) pure (forkState == Free)

Функция возвращает Истинный , если форк был свободен и его успешно «взяли», то есть перезаписали тфорк .

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

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

Ситуаций может быть пять:

  • Оба бесплатны
  • Левый занят (левый сосед), правый свободен
  • Левый свободен, правый занят (правым соседом)
  • Оба заняты (соседями)
  • Оба заняты (нашим философом)
Запишем теперь взятие обеими вилками нашим философом:

takeForks :: (TFork, TFork) -> STM Bool takeForks (tLeftFork, tRightFork) = do leftTaken <- takeFork tLeftFork rightTaken <- takeFork tRightFork pure (leftTaken && rightTaken)

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

Функция взятьВилки конечно вернусь ЛОЖЬ в данном случае, а как быть с тем, что одна вилка оказалась в руках нашего философа? Он не сможет есть один, поэтому ему нужно положить его обратно и еще некоторое время подумать.

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

Но «возвращение назад» в рамках СТМ реализуется несколько иначе, чем в рамках других конкурентных структур.

Мы можем считать, что обе переменные - tLeftFork И tRightFork - это локальные копии, не связанные с одним и тем же ресурсом у других философов.

Поэтому можно не «поставить» вилку обратно, а сказать расчету, что она не удалась.

Тогда наш один взятый форк не будет записан в общие ресурсы - это все равно, как если бы не было успешного вызова взять вилку .

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

Существует специальный метод отмены.

повторить попытку , давай перепишем взятьВилки используй это:

takeForks :: (TFork, TFork) -> STM () takeForks (tLeftFork, tRightFork) = do leftTaken <- takeFork tLeftFork rightTaken <- takeFork tRightFork when (not leftTaken || not rightTaken) retry

Расчет будет успешным, если наш философ взял обе вилки одновременно.

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

В этой версии мы не возвращаемся Бул , потому что нам не нужно знать, были ли оба ресурса успешно получены.

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

После того, как мы взяли вилки, нам, вероятно, нужно будет сделать что-то еще, например, перевести философа в состояние «Еда».

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

data PhilosopherState = Thinking | Eating data Philosopher = Philosopher { pState :: TVar PhilosopherState , pLeftFork :: TFork , pRrightFork :: TFork } changePhilosopherActivity :: Philosopher -> STM () changePhilosopherActivity (Philosopher tState tLeftFork tRightFork) = do state <- readTVar tState case state of Thinking -> do taken <- takeForks tFs unless taken retry -- Do not need to put forks if any was taken! writeTVar tAct Eating pure Eating Eating -> error "Changing state from Eating not implemented."

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

До сих пор мы описывали только логику модели транзакций, но не конкретизировали ее.

ТВар мы еще ничего не создали и не запустили.

Давай сделаем это:

philosoperWorker :: Philosopher -> IO () philosoperWorker philosopher = do atomically (changePhilosopherActivity philosopher) threadDelay 5000 philosoperWorker philosopher runPhilosophers :: IO () runPhilosophers = do tState1 <- newTVarIO Thinking tState2 <- newTVarIO Thinking tFork1 <- newTVarIO Free tFork2 <- newTVarIO Free forkIO (philosoperWorker (Philosopher tState1 tFork1 tFork2)) forkIO (philosoperWorker (Philosopher tState2 tFork2 tFork1)) threadDelay 100000

Комбинатор атомарно :: STM a -> IO a выполняет расчет в монаде СТМ атомарно.

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

ИО .

Код STM не должен иметь никакого эффекта.

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

Поэтому можно предположить, что в монаде СТМ есть только чистые вычисления, и их выполнение — атомарная операция, которая, однако, не блокирует другие вычисления.

Функции создания тоже нечистые.

TVar newTVarIO :: a -> IO (TVar a) , но ничто не мешает вам создавать новые ТВар внутри СТМ использование чистого комбинатора newTVar :: a -> STM (TVar a) .

Нам это просто было не нужно.

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

Подведем итог.

Минимальная реализация СТМ должен содержать следующие функции для работы с ТВар :

newTVar :: a -> STM (TVar a) readTVar :: TVar a -> STM a writeTVar :: TVar a -> a -> STM ()

Выполнение расчетов:

atomically :: STM a -> IO a

Наконец, огромным плюсом будет возможность перезапустить расчет:

retry :: STM a

Конечно, в библиотеках есть огромное количество других полезных конструкций, например экземпляр класса типа Альтернатива Для СТМ , но оставим это для самостоятельного изучения.



STM на свободных монадах

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

Я слышал, что реализации в Haskell и Clojure на данный момент являются лучшими, а в других языках STM не совсем реален.

Можно предположить, что монадические СТМ с возможностью перезапуска вычислений и управления эффектами не существуют ни в одном императивном языке.

Но это все праздные предположения, и я могу ошибаться.

К сожалению, я даже не понимаю внутренностей библиотеки Haskell. стм , не говоря уже о других экосистемах.

Однако с точки зрения интерфейса совершенно ясно, как код должен вести себя в многопоточной среде.

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

Любой DSL, построенный на свободных монадах, будет иметь следующие характеристики:

  • Чистый DSL, скомпилированный монадически, то есть по-настоящему функциональный;
  • Код на таком DSL не будет содержать ничего лишнего, кроме предметной области, а значит, его будет легче читать и понимать;
  • Интерфейс и реализация фактически разделены;
  • Реализаций может быть несколько, и их можно заменять во время выполнения.

Свободные монады являются очень мощным инструментом, и их можно считать «правильным», чисто функциональным подходом к реализации Инверсия контроля .

На мой взгляд, любую проблему в домене можно решить и с помощью Free-monadic DSL. Поскольку эта тема очень широка и затрагивает многие вопросы проектирования и архитектуры программного обеспечения в функциональном программировании, я оставлю другие детали в стороне.

Желающие могут обратиться к многочисленным источникам в Интернете или к моей полукниге.

«Функциональный дизайн и архитектура» , где Свободным монадам уделяется особое внимание.

Теперь давайте посмотрим на код моей библиотеки без stm .

Поскольку STM — предметно-ориентированный язык создания транзакционных моделей, он чистый и монадический, то можно предположить, что для минимального STM Free DSL должен содержать те же методы для работы с TVar, и они будут автоматически компонуемы и чисты в эта самая Свободная-монада.

Сначала давайте определим, что такое TVar.

type UStamp = Unique newtype TVarId = TVarId UStamp data TVar a = TVar TVarId

Нам нужно будет дифференцировать наших «тварей», чтобы каждый экземпляр был идентифицирован по уникальному значению.

Пользователю библиотеки это знать не обязательно; он будет ожидать использования типа ТВар а .

Работа со значениями такого типа — это половина всего поведения нашего маленького STM. Поэтому мы определяем А.

Д.

Т.

соответствующими методами:

data STMF next where NewTVar :: a -> (TVar a -> next) -> STMF next WriteTVar :: TVar a -> a -> next -> STMF next ReadTVar :: TVar a -> (a -> next) -> STMF next

И здесь нужно остановиться подробнее.

Почему мы должны это делать? Суть в том, что монада Free должна быть построена поверх какого-то eDSL. Самый простой способ определить это — определить возможные методы как конструкторы ADT. Конечный пользователь не будет работать с этим типом, но мы будем использовать его для интерпретации методов с некоторым эффектом.

Очевидно, что метод NewTVar следует интерпретировать с результатом «создается новый TVar и возвращается как результат».

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

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

Если бы мы просто написали STMF следующий , а уже было бы общим для всего кода, где связано несколько вызовов НовыйTVar :

data STMF next a where NewTVar :: a -> (TVar a -> next) -> STMF next

Но это бессмысленно, потому что мы все равно хотим использовать НовыйTVar для своих произвольных типов и чтобы они не давили друг на друга.

Вот почему мы убираем а в локальную видимость только конкретного метода.

Примечание .

Фактически, чтобы ускорить работу над доказательством концепции, введите а У меня есть ограничение на сериализуемость (экземпляр класса ТоJSON/ИзJSON из библиотеки Эсон ).

Дело в том, что мне нужно будет хранить в карте эти разные типы ТВаров, но я не хочу возиться с Типизированный/динамический или, тем более, с HL-списки .

В реальном типе STM а Это может быть абсолютно что угодно, даже функции.

Этим вопросом я тоже займусь когда-нибудь позже.

Что это за поле? следующий в том, что? Здесь мы сталкиваемся с требованием монады Free. Ей нужно где-то хранить продолжение текущего метода, и просто поле следующий она не удовлетворена — ADT должен быть функтором над этим полем.

Да, метод НовыйTVar должен вернуться ТВар а , и мы видим, что продолжение (TVar a -> следующий) просто ждет нашей новой входной переменной.

На другой стороне, WriteTVar не возвращает ничего полезного, поэтому продолжение имеет тип следующий , - то есть он ничего не ожидает на входе.

Создайте тип функтора СТМФ не сложно:

instance Functor STMF where fmap g (NewTVar a nextF) = NewTVar a (g .

nextF) fmap g (WriteTVar tvar a next ) = WriteTVar tvar a (g next) fmap g (ReadTVar tvar nextF) = ReadTVar tvar (g .

nextF)

Более интересный вопрос — где находится наша собственная монада для STM. Вот она:

type STML next = Free STMF next

Мы завернули тип СТМФ в типе Бесплатно , а вместе с ним появились и все необходимые нам монадические свойства.

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

СТМФ :

newTVar :: ToJSON a => a -> STML (TVar a) newTVar a = liftF (NewTVar a id) writeTVar :: ToJSON a => TVar a -> a -> STML () writeTVar tvar a = liftF (WriteTVar tvar a ()) readTVar :: FromJSON a => TVar a -> STML a readTVar tvar = liftF (ReadTVar tvar id)

В результате мы уже можем оперировать транзакционной моделью в виде ТВаров.

На самом деле, можно взять пример обедающих философов и просто заменить СТМ на СТМЛ :

data ForkState = Free | Taken type TFork = TVar ForkState takeFork :: TFork -> STML Bool takeFork tFork = do forkState <- readTVar tFork when (forkState == Free) (writeTVar tFork Taken) pure (forkState == Free)

Легкая победа! Но есть вещи, которые мы пропустили.

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

Легко добавить:

data STMF next where Retry :: STMF next instance Functor STMF where fmap g Retry = Retry retry :: STML () retry = liftF Retry

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

Это не фундаментальное ограничение, а артефакт быстрой разработки PoC, и я исправлю это в будущем.

Однако даже этот код останется без изменений, кроме замены самой монады:

takeForks :: (TFork, TFork) -> STML () takeForks (tLeftFork, tRightFork) = do leftTaken <- takeFork tLeftFork rightTaken <- takeFork tRightFork when (not leftTaken || not rightTaken) retry

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

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



atomically :: Context -> STML a -> IO a

Контекст хранит пользовательские данные в виде TVar, поэтому вы можете иметь несколько разных контекстов.

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

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

Тогда имеет смысл разделить контексты, чтобы вторая модель не испытывала проблем при выполнении из-за «раздутия» соседа.

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

Код запуска философов теперь выглядит так:

philosoperWorker :: Context -> Philosopher -> IO () philosoperWorker ctx philosopher = do atomically ctx (changePhilosopherActivity philosopher) threadDelay 5000 philosoperWorker ctx philosopher runPhilosophers :: IO () runPhilosophers = do ctx <- newContext

Теги: #stm #haskell #free-monads #monads #haskell #Параллельное программирование #Функциональное программирование

Вместе с данным постом часто просматривают: