effectful-core-2.7.0.0: src/Effectful/Internal/Unlift.hs
{-# LANGUAGE MagicHash #-}
{-# LANGUAGE QuantifiedConstraints #-}
{-# LANGUAGE UnboxedTuples #-}
{-# OPTIONS_HADDOCK not-home #-}
-- | Implementation of sequential and concurrent unlifts.
--
-- This module is intended for internal use only, and may change without warning
-- in subsequent releases.
module Effectful.Internal.Unlift
( -- * Unlifting strategies
UnliftStrategy(..)
, Persistence(..)
, Limit(..)
-- * Unlifting functions
, ephemeralConcLimitedUnlift
, ephemeralConcUnlimitedUnlift
, persistentConcUnlift
, persistentConcSingleUnlift
, persistentConcUnlifts
, persistentConcSingleUnlifts
) where
import Control.Concurrent
import Control.Concurrent.MVar.Strict qualified as S
import Control.Monad
import Data.Coerce
import Data.Word
import GHC.Conc.Sync (ThreadId(..))
import GHC.Exts (mkWeak#, mkWeakNoFinalizer#)
import GHC.Generics (Generic)
import GHC.IO (IO(..))
import GHC.Stack (HasCallStack)
import GHC.Weak (Weak(..))
import System.Mem.Weak (deRefWeak)
import Effectful.Internal.Env
import Effectful.Internal.Utils
import Effectful.Internal.Utils.Word64Map qualified as M
----------------------------------------
-- Unlift strategies
-- | The strategy to use when unlifting 'Effectful.Eff' computations via
-- 'Effectful.withEffToIO' or the 'Effectful.Dispatch.Dynamic.localUnlift'
-- family.
data UnliftStrategy
= SeqUnlift
-- ^ The sequential strategy is the fastest and a default setting for
-- t'Effectful.IOE'. Any attempt of calling the unlifting function in threads
-- distinct from its creator will result in a runtime error.
| SeqForkUnlift
-- ^ Like 'SeqUnlift', but all unlifted actions will be executed in a cloned
-- environment.
--
-- The main consequence is that thread local state is forked at the point of
-- creation of the unlifting function and its modifications in unlifted
-- actions will not affect the main thread of execution (and vice versa):
--
-- >>> import Effectful
-- >>> import Effectful.State.Dynamic
-- >>> :{
-- action :: (IOE :> es, State Int :> es) => Eff es ()
-- action = do
-- modify @Int (+1)
-- withEffToIO SeqForkUnlift $ \unlift -> unlift $ modify @Int (+2)
-- modify @Int (+4)
-- :}
--
-- >>> runEff . execStateLocal @Int 0 $ action
-- 5
--
-- >>> runEff . execStateShared @Int 0 $ action
-- 7
--
-- Because of this it's possible to safely use the unlifting function outside
-- of the scope of effects it captures, e.g. by creating an @IO@ action that
-- executes effectful operations and running it later:
--
-- >>> :{
-- delayed :: UnliftStrategy -> IO (IO String)
-- delayed strategy = runEff . evalStateLocal "Hey" $ do
-- r <- withEffToIO strategy $ \unlift -> pure $ unlift get
-- modify (++ "!!!")
-- pure r
-- :}
--
-- This doesn't work with the 'SeqUnlift' strategy because when the returned
-- action runs, @State@ is no longer in scope:
--
-- >>> join $ delayed SeqUnlift
-- *** Exception: version (...) /= storageVersion (0)
-- ...
--
-- However, it does with the 'SeqForkUnlift' strategy:
--
-- >>> join $ delayed SeqForkUnlift
-- "Hey"
--
| ConcUnlift !Persistence !Limit
-- ^ The concurrent strategy makes it possible for the unlifting function to
-- be called in threads distinct from its creator. See 'Persistence' and
-- 'Limit' settings for more information.
deriving stock (Eq, Generic, Ord, Show)
-- | Persistence setting for the 'ConcUnlift' strategy.
--
-- Different functions require different persistence strategies. Examples:
--
-- - Lifting 'pooledMapConcurrentlyN' from the @unliftio@ library requires the
-- 'Ephemeral' strategy as we don't want jobs to share environment changes
-- made by previous jobs run in the same worker thread.
--
-- - Lifting 'Control.Concurrent.forkIOWithUnmask' requires the 'Persistent'
-- strategy, otherwise the unmasking function would start with a fresh
-- environment each time it's called.
--
-- Both cases come down to what happens when the unlifting function is called
-- more than once in the same thread. If a thread calls it only once, the
-- 'Persistence' setting makes no observable difference.
--
-- === Example 1
--
-- Consider a thread that modifies thread local state, then inspects it with a
-- second call to the unlifting function:
--
-- >>> import Control.Concurrent
-- >>> import Control.Monad
-- >>> import Effectful
-- >>> import Effectful.State.Dynamic
--
-- >>> :{
-- modifyThenGet :: UnliftStrategy -> IO Int
-- modifyThenGet strategy = runEff . evalStateLocal @Int 0 $ do
-- withEffToIO strategy $ \unlift -> do
-- result <- newEmptyMVar
-- void . forkIO $ do
-- unlift $ modify @Int (+1)
-- putMVar result =<< unlift (get @Int)
-- takeMVar result
-- :}
--
-- With the 'Persistent' strategy the unlifting function keeps the environment
-- between the calls, so the second call sees the modification from the first
-- one:
--
-- >>> modifyThenGet $ ConcUnlift Persistent (Limited 1)
-- 1
--
-- On the other hand, with 'Ephemeral' each call to the unlifting function
-- starts with a fresh copy of the environment, so the modification is silently
-- lost:
--
-- >>> modifyThenGet $ ConcUnlift Ephemeral (Limited 2)
-- 0
--
-- This also showcases the limit meaning different things for the two settings:
-- for the 'Persistent' strategy it limits the number of threads the unlifting
-- can happen in, for 'Ephemeral' it limits the number of calls to the unlifting
-- function.
--
-- === Example 2
--
-- Consider a situation where a single worker thread runs multiple independent
-- jobs:
--
-- >>> :{
-- twoJobs :: UnliftStrategy -> IO [Int]
-- twoJobs strategy = runEff . evalStateLocal @Int 0 $ do
-- withEffToIO strategy $ \unlift -> do
-- result <- newEmptyMVar
-- void . forkIO $ do
-- let job = unlift $ modify @Int (+1) >> get @Int
-- putMVar result =<< sequence [job, job]
-- takeMVar result
-- :}
--
-- With 'Ephemeral' both jobs start from the environment as it was when the
-- unlifting function was created:
--
-- >>> twoJobs $ ConcUnlift Ephemeral Unlimited
-- [1,1]
--
-- With 'Persistent' the second job inherits changes made by the first one, even
-- though the user would most likely expect them to be independent:
--
-- >>> twoJobs $ ConcUnlift Persistent Unlimited
-- [1,2]
data Persistence
= Ephemeral
-- ^ Don't persist the environment between calls to the unlifting function in
-- threads distinct from its creator.
| Persistent
-- ^ Persist the environment between calls to the unlifting function within a
-- particular thread.
deriving stock (Eq, Generic, Ord, Show)
-- | Limit setting for the 'ConcUnlift' strategy.
data Limit
= Limited !Int
-- ^ Behavior dependent on the 'Persistence' setting.
--
-- For 'Ephemeral', it limits the amount of uses of the unlifting function in
-- threads distinct from its creator to @N@. The unlifting function will
-- create @N@ copies of the environment when called @N@ times and @K+1@ copies
-- when called @K < N@ times.
--
-- For 'Persistent', it limits the amount of threads, distinct from the
-- creator of the unlifting function, it can be called in to @N@. The amount
-- of calls to the unlifting function within a particular threads is
-- unlimited. The unlifting function will create @N@ copies of the environment
-- when called in @N@ threads and @K+1@ copies when called in @K < N@ threads.
| Unlimited
-- ^ Unlimited use of the unlifting function.
deriving stock (Eq, Generic, Ord, Show)
----------------------------------------
-- Unlift functions
-- | Concurrent unlift with limited uses that doesn't preserve the environment
-- between calls to the unlifting function in threads other than its creator.
--
-- @since 2.7.0.0
ephemeralConcLimitedUnlift
:: (HasCallStack, forall r. Coercible (effEs r) (Env es -> IO r))
=> Env es
-> Int
-- ^ Number of permitted uses of the unlift function.
-> ((forall r. effEs r -> IO r) -> IO a)
-> IO a
ephemeralConcLimitedUnlift es0 uses k = do
unless (uses > 0) $ do
error $ "Invalid number of uses: " ++ show uses
tid0 <- myThreadId
-- Create a copy of the environment as a template for the other threads to
-- use. This can't be done from inside the callback as the environment might
-- have already changed by then.
esTemplate <- cloneEnv es0
mvUses <- S.newMVar uses
let getEs = myThreadId >>= \case
tid | tid0 == tid -> pure es0
_ -> S.modifyMVar mvUses $ \case
0 -> error
$ "Number of permitted calls (" ++ show uses ++ ") to the unlifting "
++ "function in other threads was exceeded. Please increase the limit "
++ "or use the unlimited variant."
1 -> pure (0, esTemplate)
n -> do
es <- cloneEnv esTemplate
pure (n - 1, es)
k $ \action -> coerce action =<< getEs
{-# INLINE ephemeralConcLimitedUnlift #-}
-- | Concurrent unlift with unlimited uses that doesn't preserve the environment
-- between calls to the unlifting function in threads other than its creator.
--
-- @since 2.7.0.0
ephemeralConcUnlimitedUnlift
:: (HasCallStack, forall r. Coercible (effEs r) (Env es -> IO r))
=> Env es
-> ((forall r. effEs r -> IO r) -> IO a)
-> IO a
ephemeralConcUnlimitedUnlift es0 k = do
tid0 <- myThreadId
-- Create a copy of the environment as a template for the other threads to
-- use. This can't be done from inside the callback as the environment might
-- have already changed by then.
esTemplate <- cloneEnv es0
let getEs = myThreadId >>= \case
tid | tid0 == tid -> pure es0
_ -> cloneEnv esTemplate
k $ \action -> coerce action =<< getEs
{-# INLINE ephemeralConcUnlimitedUnlift #-}
-- | Concurrent unlift that preserves the environment between calls to the
-- unlifting function within a particular thread.
persistentConcUnlift
:: (HasCallStack, forall r. Coercible (effEs r) (Env es -> IO r))
=> Env es
-> Bool
-> Int
-- ^ Number of threads that are allowed to use the unlift function.
-> ((forall r. effEs r -> IO r) -> IO a)
-> IO a
persistentConcUnlift es0 cleanUp threads k = do
unless (threads > 0) $ do
error $ "Invalid number of threads: " ++ show threads
tid0 <- myThreadId
-- Create a copy of the environment as a template for the other threads to
-- use. This can't be done from inside the callback as the environment might
-- have already changed by then.
esTemplate <- cloneEnv es0
mvEntries <- S.newMVar $ ThreadEntries threads M.empty
let getEs = myThreadId >>= \case
tid | tid0 == tid -> pure es0
tid -> do
te0 <- S.readMVar mvEntries
let wkTid = weakThreadId tid
case wkTid `M.lookup` te0.entries of
Just wkEs -> getWkTidEnv wkEs
-- If the environment is not in the map, there is no point checking
-- again within modifyMVar below, because this is the only thread
-- that can put it there.
Nothing -> S.modifyMVar mvEntries $ \te -> case te.capacity of
0 -> noCapacityError threads
1 -> do
wkTidEs <- mkWeakThreadIdEnv tid wkTid esTemplate mvEntries cleanUp
let newEntries = ThreadEntries
{ capacity = te.capacity - 1
, entries = M.insert wkTid wkTidEs te.entries
}
pure (newEntries, esTemplate)
_ -> do
es <- cloneEnv esTemplate
wkTidEs <- mkWeakThreadIdEnv tid wkTid es mvEntries cleanUp
let newEntries = ThreadEntries
{ capacity = te.capacity - 1
, entries = M.insert wkTid wkTidEs te.entries
}
pure (newEntries, es)
k $ \action -> coerce action =<< getEs
{-# INLINE persistentConcUnlift #-}
-- | Variant of 'persistentConcUnlift' for a single other thread that doesn't
-- need ThreadEntries.
--
-- @since 2.7.0.0
persistentConcSingleUnlift
:: ( HasCallStack, forall r. Coercible (effEs r) (Env es -> IO r))
=> Env es
-> ((forall r. effEs r -> IO r) -> IO a)
-> IO a
persistentConcSingleUnlift es0 k = do
tid0 <- myThreadId
-- Create a copy of the environment for the other thread to use. This can't be
-- done from inside the callback as the environment might have already changed
-- by then.
es <- cloneEnv es0
-- GHC never labels threads as 0.
mvWeakTid <- S.newMVar 0
let getEs = myThreadId >>= \case
tid | tid0 == tid -> pure es0
tid -> do
let wkTid = weakThreadId tid
S.readMVar mvWeakTid >>= \case
0 -> S.modifyMVar mvWeakTid $ \case
0 -> pure (wkTid, es)
_ -> noCapacityError 1
v | v == wkTid -> pure es
| otherwise -> noCapacityError 1
k $ \action -> coerce action =<< getEs
{-# INLINE persistentConcSingleUnlift #-}
-- | Variant of 'persistentConcUnlift' producing two unlifting functions that
-- share the effect storage in each thread.
--
-- @since 2.7.0.0
persistentConcUnlifts
:: ( HasCallStack
, forall r. Coercible (effEs r) (Env es -> IO r)
, forall r. Coercible (effLocalEs r) (Env localEs -> IO r)
)
=> Env es
-> Env localEs
-> Bool
-> Int
-- ^ Number of threads that are allowed to use the unlift function.
-> ((forall r. effEs r -> IO r) -> (forall r. effLocalEs r -> IO r) -> IO a)
-> IO a
persistentConcUnlifts es0 les0 cleanUp threads k = do
unless (threads > 0) $ do
error $ "Invalid number of threads: " ++ show threads
tid0 <- myThreadId
-- Create a copy of the environments sharing the effect storage as a template
-- for the other threads to use. This can't be done from inside the callback
-- as the environment might have already changed by then.
storageTemplate <- cloneStorage es0.storage
esTemplate <- replaceStorage es0 storageTemplate
lesTemplate <- replaceStorage les0 storageTemplate
mvEntries <- S.newMVar $ ThreadEntries threads M.empty
let getEsLes = myThreadId >>= \case
tid | tid0 == tid -> pure (es0, les0)
tid -> do
te0 <- S.readMVar mvEntries
let wkTid = weakThreadId tid
case wkTid `M.lookup` te0.entries of
Just wkEsLes -> getWkTidEnv wkEsLes
-- If the environments are not in the map, there is no point
-- checking again within modifyMVar below, because this is the only
-- thread that can put them there.
Nothing -> S.modifyMVar mvEntries $ \te -> case te.capacity of
0 -> noCapacityError threads
1 -> do
wkTidEsLes <- mkWeakThreadIdEnv tid wkTid (esTemplate, lesTemplate) mvEntries cleanUp
let newEntries = ThreadEntries
{ capacity = te.capacity - 1
, entries = M.insert wkTid wkTidEsLes te.entries
}
pure (newEntries, (esTemplate, lesTemplate))
_ -> do
storage <- cloneStorage storageTemplate
es <- replaceStorage esTemplate storage
les <- replaceStorage lesTemplate storage
wkTidEsLes <- mkWeakThreadIdEnv tid wkTid (es, les) mvEntries cleanUp
let newEntries = ThreadEntries
{ capacity = te.capacity - 1
, entries = M.insert wkTid wkTidEsLes te.entries
}
pure (newEntries, (es, les))
k (\action -> coerce action . fst =<< getEsLes)
(\action -> coerce action . snd =<< getEsLes)
{-# INLINE persistentConcUnlifts #-}
-- | Variant of 'persistentConcUnlifts' for a single other thread that doesn't
-- need ThreadEntries.
--
-- @since 2.7.0.0
persistentConcSingleUnlifts
:: ( HasCallStack
, forall r. Coercible (effEs r) (Env es -> IO r)
, forall r. Coercible (effLocalEs r) (Env localEs -> IO r)
)
=> Env es
-> Env localEs
-> ((forall r. effEs r -> IO r) -> (forall r. effLocalEs r -> IO r) -> IO a)
-> IO a
persistentConcSingleUnlifts es0 les0 k = do
tid0 <- myThreadId
-- Create a copy of the environments sharing the effect storage for the other
-- thread to use. This can't be done from inside the callback as the
-- environment might have already changed by then.
storage <- cloneStorage es0.storage
es <- replaceStorage es0 storage
les <- replaceStorage les0 storage
-- GHC never labels threads as 0.
mvWeakTid <- S.newMVar 0
let getEsLes = myThreadId >>= \case
tid | tid0 == tid -> pure (es0, les0)
tid -> do
let wkTid = weakThreadId tid
S.readMVar mvWeakTid >>= \case
0 -> S.modifyMVar mvWeakTid $ \case
0 -> pure (wkTid, (es, les))
_ -> noCapacityError 1
v | v == wkTid -> pure (es, les)
| otherwise -> noCapacityError 1
k (\action -> coerce action . fst =<< getEsLes)
(\action -> coerce action . snd =<< getEsLes)
{-# INLINE persistentConcSingleUnlifts #-}
----------------------------------------
-- Internal helpers
noCapacityError :: HasCallStack => Int -> a
noCapacityError threads = error
$ "Number of other threads (" ++ show threads ++ ") permitted to "
++ "use the unlifting function was exceeded. Please increase the "
++ "limit or use the unlimited variant."
getWkTidEnv :: HasCallStack => Weak a -> IO a
getWkTidEnv wkTidEnv = deRefWeak wkTidEnv >>= \case
Nothing -> error "Impossible, thread alive but its weak ref dead"
Just env -> pure env
data ThreadEntries a = ThreadEntries
{ capacity :: !Int
, entries :: !(M.Word64Map (Weak a))
}
mkWeakThreadIdEnv
:: ThreadId
-> Word64
-> a
-> S.MVar (ThreadEntries a)
-> Bool
-> IO (Weak a)
mkWeakThreadIdEnv (ThreadId t#) wkTid es v = \case
True -> IO $ \s0 ->
case mkWeak# t# es finalizer s0 of
(# s1, w #) -> (# s1, Weak w #)
False -> IO $ \s0 ->
case mkWeakNoFinalizer# t# es s0 of
(# s1, w #) -> (# s1, Weak w #)
where
-- The finalizer runs only if the corresponding entry is in the map. It
-- might not be there for two reasons:
--
-- 1. Registration of the thread was interrupted by an asynchronous
-- exception after the finalizer was attached, but before the update of
-- the map was committed. The commit was rolled back, so there is
-- nothing to clean up (and if the thread registered successfully
-- afterwards, the entry belongs to the finalizer attached then).
--
-- 2. The thread registered successfully after one or more interrupted
-- attempts, so multiple finalizers run on its death and another one
-- already cleaned up the entry.
IO finalizer = S.modifyMVar_ v $ \te -> do
pure $ case M.updateLookupWithKey (\_ _ -> Nothing) wkTid te.entries of
(Nothing, _) -> te
(Just _, newEntries) -> ThreadEntries
{ capacity = case te.capacity of
-- If the template copy of the environment hasn't been consumed
-- yet, the capacity can be restored.
0 -> 0
n -> n + 1
, entries = newEntries
}