Variables mutables, ST, STM (Haskell)

Voir aussi : video youtube - code source

Un langage purement fonctionnel interdit les effets de bord (entrées/sorties, mutations, etc). Cependant, les effets de bord sont généralement indispensables pour implémenter des programmes “utiles”. Pour cela, Haskell propose les monades, c’est-à-dire des contextes dans lesquels on peut réaliser des fonctionnalités particulières, par exemple des effets de bord particuliers. Dans cet article, on va voir comment manipuler des variables mutables, dans différentes monades (IO, ST et STM).

IO

La monade IO permet de faire des entrées/sorties, et donc à peu près tout ce qu’on veut avec la machine qui exécute le programme. Il s’agit du contexte d’exécution du programme principal et de nombreuses fonctions à effets de bord.

La bibliothèque de base propose plusieurs types pour implémenter des variables mutables dans IO. IORef permet de créer et d’utiliser des variables mutables basiques. MVar apporte des fonctionnalités supplémentaires, notamment pour de la programmation concurrente. MVar et IORef sont assez similaires dans leur fonctionnement de base; l’exemple ci-dessous utilise MVar.

On veut implémenter un programme qui permet d’incrémenter un compteur, en distinguant les unités (_counter1) des dizaines (_counter2).

data Model = Model
    { _counter1 :: Int
    , _counter2 :: Int
    } deriving (Show)

incrementCounter1 :: Model -> Model
incrementCounter1 (Model c1 c2) = Model (1+c1) c2

updateCounters :: Model -> Model
updateCounters (Model c1 c2) = Model (c1 `mod` 10) (c1 `div` 10 + c2)

Il s’agit de code Haskell “classique”, avec des fonctions pures et sans effet de bord.

Si on veut utiliser notre compteur dans un programme avec des accès concurrents, on ne peut plus se contenter de prendre un Model et de retourner le Model modifié. Il faut désormais utiliser une variable mutable contenant un Model et la mettre à jour de façon atomique, par exemple avec modifyMVar_.

incrementCounter1Var :: MVar Model -> IO ()
incrementCounter1Var modelVar = 
    modifyMVar_ modelVar (return . incrementCounter1)

updateCountersVar :: MVar Model -> IO ()
updateCountersVar modelVar =
    modifyMVar_ modelVar (return . updateCounters)

On notera que les deux fonctions précédentes ne retournent pas de valeur mais doivent être appelées dans IO, car elles réalisent bien un effet de bord.

On notera également que ces fonctions ne se composent pas. Elle doivent être appelées successivement, sans garantie d’atomicité : un autre accès concurrent peut être réalisé entre deux appels. Si on veut garantir l’atomicité de deux appels, il faut l’implémenter également :

incrementAndUpdateVar :: MVar Model -> IO ()
incrementAndUpdateVar modelVar =
    modifyMVar_ modelVar (return . updateCounters . incrementCounter1)

On peut alors créer et utiliser une variable mutable avec ces fonctions, par exemple :

test :: IO ()
test = do

    -- créer une variable mutable
    modelVar <- newMVar (Model 0 0)

    -- incrémente puis met à jour la variable
    incrementCounter1Var modelVar
    updateCountersVar modelVar

    -- idem mais de façon atomique
    incrementAndUpdateVar modelVar

    -- répète 2 fois une action sur la variable
    replicateM_ 2 $ incrementAndUpdateVar modelVar

    -- lit et affiche le contenu de la variable
    model2 <- readMVar modelVar
    print model2

Évidemment, les variables mutables sont plus utiles dans un contexte avec des accès concurrents. Par exemple, pour un serveur web où la route “/” lit la variable et la route “/inc” la modifie :

main :: IO ()
main = do

    modelVar <- newMVar (Model 0 0)

    scotty 3000 $ do

        get "/" $ do
            Model c1 c2 <- liftIO $ readMVar modelVar
            html $ renderText $ do
                div_ $ toHtml $ show c1 <> " " <> show c2
                div_ $ form_ [action_ "/inc", method_ "post"] $ 
                    input_ [type_ "submit", value_ "increment"]

        post "/inc" $ do
            liftIO $ incrementAndUpdateVar modelVar
            redirect "/"

Ce code fonctionne à peu près correctement mais a deux inconvénients : les fonctions manipulant la variable mutable ne se composent pas; la monade IO est très générale, ce qui signifie que nos fonctions peuvent faire à peu près n’importe quoi, au lieu de seulement manipuler une variable mutable.

ST

Avant la STM, considérons la monade ST (déjà présentée dans le tuto 69). Celle-ci permet de réaliser un calcul dont l’exécution est réalisée dans un seul thread, en évitant les problèmes d’accès concurrent. STRef permet de manipuler des variables mutables dans le contexte de ST.

On peut donc réécrire nos fonctions pour manipuler une variable mutable. Au lieu de prendre un MVar et de fonctionner dans IO, elles prendront un STRef s et fonctionneront dans ST s (s correspond au thread d’exécution).

incrementCounter1 :: STRef s Model -> ST s ()
incrementCounter1 modelRef =
    modifySTRef' modelRef $ \(Model c1 c2) -> Model (1+c1) c2

updateCounters :: STRef s Model -> ST s ()
updateCounters modelRef =
    modifySTRef' modelRef $ 
        \(Model c1 c2) -> Model (c1 `mod` 10) (c1 `div` 10 + c2)

Ainsi, ces fonctions sont limitées aux fonctionnalités de ST (par exemple modifier une variable mutable) et ne peuvent pas réaliser toutes les fonctionnalités (potentiellement dangeureuses) de IO.

On peut également composer directement ces fonctions (dans le sens de ST).

incrementAndUpdate :: STRef s Model -> ST s ()
incrementAndUpdate modelRef = do
    incrementCounter1 modelRef
    updateCounters modelRef

Pour tester, on peut convertir une séquence d’actions ST dans IO. On peut également calculer cette séquence et récupérer son résultat, “dans du code pur” :

test :: IO ()
test = do
    let res = runST $ do
                modelRef <- newSTRef (Model 0 0)

                incrementCounter1 modelRef
                updateCounters modelRef

                incrementAndUpdate modelRef

                replicateM_ 2 $ incrementAndUpdate modelRef

                readSTRef modelRef

    print res

STM

La mémoire transactionnelle logicielle est une technique de programmation concurrente, qui permet notamment d’écrire du code modulaire et composable. En Haskell, elle est implémentée dans la bibliothèque STM. Celle-ci propose la monade STM (contexte d’exécution avec concurrence) ainsi que différents types et fonctions implémentant diverses abstractions de communication concurrente.

Ainsi, on peut réécrire les fonctions de notre programme d’exemple, en prenant en paramètre une variable mutable TVar et en fonctionnant dans STM :

incrementCounter1 :: TVar Model -> STM ()
incrementCounter1 modelVar =
    modifyTVar modelVar $ \(Model c1 c2) -> Model (1+c1) c2

updateCounters :: TVar Model -> STM ()
updateCounters modelVar = do
    (Model c1 c2) <- readTVar modelVar
    writeTVar modelVar $ Model (c1 `mod` 10) (c1 `div` 10 + c2)

Comme pour la monade ST, la monade STM permet de séquencer ou de composer les actions : si on appelle la fonction incrementCounter1 puis la fonction updateCounters dans le même contexte STM, on pourra exécuter l’ensemble de façon atomique.

incrementAndUpdate :: TVar Model -> STM ()
incrementAndUpdate modelVar = do
    incrementCounter1 modelVar
    updateCounters modelVar

On peut alors exécuter une séquence d’actions STM, de façon atomique, avec la fonction atomically :

test :: IO ()
test = do
    modelVar <- newTVarIO (Model 0 0)

    model2 <- atomically $ do

                incrementCounter1 modelVar
                updateCounters modelVar

                incrementAndUpdate modelVar

                replicateM_ 2 $ incrementAndUpdate modelVar

                readTVar modelVar

    print model2

La STM apporte également de nombreuses fonctionnalités : gestion de l’exécution (check, retry…), abstractions de communication concurrente (variable mutable, queue, channel..).

Conclusion

Haskell propose différentes implémentations de variables mutables, utilisables dans le cadre de la programmation fonctionnelle.

Les types IORef et MVar apportent les fonctionnalités de bases pour gérer des variables mutables à accès concurrents. Ils apparaissent explicitement dans les signatures de fonctions et sont vérifiés par le compilateur. Cependant ils utilisent la monade IO, qui est très (trop) générale, et ne permettent pas de composer des accès concurrents.

La mémoire transactionnelle logicielle (STM) apporte plus de flexibilité. Elle permet notamment de composer des accès concurrents et de les exécuter dans une monade dédiée (un peu comme la monade ST pour le contexte du multi-threading), de manière atomique.

Ces différentes implémentations sont assez largement utilisées en Haskell, par exemple pour du code asynchrone, pour le design pattern ReaderT, etc.