In this post I’ll try and flesh out a thought experiment I had while working
with a few state-carrying MVar’s.If you have an application that carries quite some state that needs to be
accessed by multiple threads concurrently, you have a choice in how to expose
this state to those threads. One option is to put each state component in its
own MVar. Interfacing with these MVar’s is straightforward, but exposes your
application to deadlocks, if for example, multiple threads want to acquire
resources that the other one has.One way to avoid this, is to ensure that resources are always (preemptively)
acquired in a fixed order for any computation. The thought experiment is then,
is it possible to write an interface for which this is possible for any subset
of static locks in an application? Against Betteridge’s law, I think the
following is quite nifty, if verbose.This can be seen as a continuance to, at this point, my series of blog posts on
polyvariadic functions, as the
interface I’d ideally want would just work for any number of locks.-- So an interface akin to the following:
withLocks locks $ \b a -> ...
withLocks locks $ \c b a -> ...
I’ll be working with a running example. Say there’s some state that’s split up
into three locks. And because I’m allowing myself the restriction of working
with a static set of locks, I’ll also add some singletons that’ll be useful
later. I’ll also need quite a few helpers in converting these lock
singletons to and from values that can actually be sorted. See this as just
boilerplate, and try not to look too much at it.
data AllLocks
= ALock_
| BLock_
| CLock_
deriving (Eq, Ord)
data LockRequest l where
ALockRequest :: LockRequest ALock
BLockRequest :: LockRequest BLock
CLockRequest :: LockRequest CLock
lockToValue :: Some LockRequest -> AllLocks
lockToValue s = case s of
Some ALockRequest -> ALock_
Some BLockRequest -> BLock_
Some CLockRequest -> CLock_
valueToLock :: AllLocks -> Some LockRequest
valueToLock s = case s of
ALock_ -> Some ALockRequest
BLock_ -> Some BLockRequest
CLock_ -> Some CLockRequest
data ContentsOf l where
LockOfA :: Int -> ContentsOf ALock
LockOfB :: String -> ContentsOf BLock
LockOfC :: () -> ContentsOf CLock
data Locks = Locks
{ aLock :: MVar Int
, bLock :: MVar String
, cLock :: MVar ()
}
-- Each one represents its respective lock in the state.
data ALock = ALock
data BLock = BLock
data CLock = CLock
Chef’s choice
The hardest part about the above interface is that the polyvariadic function
we’re building is a continuation. We need to acquire all locks in the right
order, and provide the underlying resources to the supplied function. Peeling
this into concrete steps, gives the following.- Identify all locks that are wanted by the underlying function
- Sort these locks according to a fixed ordering
- Acquire the locks
- Provide the lock contents to the underlying function
These four steps can’t be done incrementally on each lock. The second step is
the only real valuable functionality this interface is providing, and can only
be done once the set of requested locks is fully known. One way to do this
is to first accumulate all lock requests, and only then give the concrete
continuation.But what do we actually accumulate? A first attempt might be the following where
we build up the continuation like a list. For each lock we add, we ensure that
the continuation grows with the contents of that lock accordingly. The base case
should look pretty standard as the identity function. When we cons a lock
request, we’d also want supply how we provide the contents of the lock.data LockProvider c where
LockProviderCons
:: ...
-> LockProvider (c -> r)
-> LockProvider ((ContentsOf l -> c) -> r)
LockProviderNil
:: LockProvider (r -> r)
The above captures the signature we want to generate, but doesn’t yet give us
the tools to generate it. We still need ContentsOf, or more importantly, a way
to provide it.A few ideas come to mind. If we were to take the idea of a lock
naively, we could look at the type signature of functions like readMVar or
takeMVar, giving IO (ContentsOf l) for some lock l. That doesn’t ensure
we always unlock the MVar though. For that withMVar would be more apt, which
would correspond to the type signature of forall r. (ContentsOf l -> IO r) -> IO r, which is isomorphic to ContT r IO (ContentsOf l).Note that both takeMVar/readMVar and withMVar are valid. Key observation
here is that if we choose to follow the type signature of either of these
functions, we’re pigeonholing our LockProvider mini-DSL to a specific
behavior. For that reason, and because I’ll actually use both interpretations,
I’ll choose to keep the type generic.data LockProvider f c where
LockProviderCons
:: forall l c r
. LockRequest l
-> (f (ContentsOf l))
-> LockProvider (c -> r)
-> LockProvider ((ContentsOf l -> c) -> r)
LockProviderNil
:: LockProvider (r -> r)
Next is how to convert this datatype into an appropriate continuation. Because
we both gather and sort, we’ll need to keep the order of the lock requests at
least fixed somehow to be able to create a function that satisfies the built-up
signature.Continuationing polyvariadicity
There’s a peculiar disconnect here, we gather and provide the lock
requests and contents respectively in a certain ordering, and we have to acquire
those exact locks in a different one. To not make my life anymore harder than
it needs to be, I’ll enforce the second ordering using evaluation in IO using
IORef’s. Being able to manipulate the evaluation order is what the type
variable f is intended for.-- It's not exactly IO, interacting with the locks safely occurs
-- within continuations. So we need `ContT IO`.
newtype Acquire l = Acquire { acquire :: forall r. ContT r IO l }
deriving Functor
getA :: Locks -> Acquire (ContentsOf ALock)
getA locks = Acquire $ ContT $ \action' -> do
withMVar (aLock locks) $
\contents -> action' (LockOfA contents)
Using what is practically a minimal DSL for specifying how to acquire locks, I
can now play around with this and reorder the acquisitions.Because the DSL is functor-ish, and I’m reusing it in multiple places, I’ll create a few minimal helpers.
type f ∘ g = Compose f g
infixr 8 ∘
data Defer l
= Defer {unDefer :: Acquire l}
| Evaluated l
-- Turn the lock acquisition requests into stateful slots we can use
-- to acquire at a later point in time.
prepareLockProvider ::
LockProvider Acquire continuation ->
IO (LockProvider (IORef ∘ Defer) continuation)
prepareLockProvider lp = do
case lp of
LockProviderCons lockRequest action continuation -> do
ref <- Compose <$> newIORef (Defer action)
rest <- prepareLockProvider continuation
pure $ LockProviderCons lockRequest ref rest
LockProviderNil -> pure LockProviderNil
The next task is to convert the lock requests along with their deferred
acquisitions into something we can sort. The lock requests are already sortable,
so tupling them up should be enough. I’ll need an existential wrapper though
to elide the exact contents of each lock, which we don’t care about yet at this
point.Some exists, so I could have made this an alias to
Some ∘ Pair f g, but this was already quite the mess.
data BiSome f g = forall l. BiSome
{ getBiSome :: (f l, g l)
}
-- Pretty straightforward, recursively iterate over the DSL and
-- accumulate every lock request and deferred acquisition into a list.
gatherLocks ::
LockProvider (IORef ∘ Defer) continuation ->
IO [BiSome LockRequest (IORef ∘ Defer ∘ ContentsOf)]
gatherLocks lp = case lp of
LockProviderCons lockRequest action continuation -> do
let action' = action
rest <- gatherLocks continuation
pure $ BiSome (lockRequest, coerce action') : rest
LockProviderNil -> pure []
Once everything’s in a proper list, we can sort using the LockRequest in
the existential wrapper. And finally, we can acquire every locked resource and
subsequently execute the continuation.-- With the accumulated requests, first sort on the request using the
-- fixed 'Ord' instance and execute once everything has been acquired.
requestLocksSorted
:: forall r
. [BiSome LockRequest (IORef ∘ Defer ∘ ContentsOf)]
-> IO r
-> IO r
requestLocksSorted ls action = do
let eval
:: [BiSome LockRequest (IORef ∘ Defer ∘ ContentsOf)] -> IO r
eval [] = action
eval ((BiSome (_, Compose ioRef)) : rest) = do
ref' <- getCompose <$> readIORef ioRef
case ref' of
Defer (Acquire l) -> do
runContT l $ \l' -> do
writeIORef ioRef (Compose (Evaluated l'))
eval rest
Evaluated _ ->
eval rest
lockValue (BiSome (lr, _ioRef)) = lockToValue (Some lr)
eval (sortOn lockValue ls)
-- The action we execute once everything has been acquired consists of
-- reading every lock's contents and passing it to the continuation.
giveLockContents
:: LockProvider (IORef ∘ Defer) continuation -> IO continuation
giveLockContents lp = case lp of
LockProviderCons _ ioRef continuation -> do
ref <- readIORef (getCompose ioRef)
case ref of
Evaluated l -> do
continue <- giveLockContents continuation
pure $ \f -> continue (f l)
_ -> error "not evaluated yet"
LockProviderNil -> pure id
The final interface is pretty neat.provideAllLocks
:: LockProvider Acquire continuation
-> (continuation -> IO r)
-> IO r
provideAllLocks lp action = do
lp' <- prepareLockProvider lp
gathered <- gatherLocks lp'
let actWithLocks = giveLockContents lp' >>= action
requestLocksSorted gathered actWithLocks
There’s quite a few options in how to build the exact
LockProvider value. To stay in the polyvariadic theme, the
appendix contains how
to have every lock’s contents be inferred from type class resolution. The final
example can look something like this.provideLocks
:: (ProvidesLock continuation)
=> Locks
-> (continuation -> IO r)
-> IO r
provideLocks state = provideAllLocks (provideLock state)
example'
:: Locks
-> (ContentsOf BLock -> IO Int)
-> IO Int
example' locks action = do
provideLocks locks $ \with -> with action
example''
:: Locks
-> (ContentsOf BLock -> ContentsOf ALock -> IO Int)
-> IO Int
example'' locks action = do
provideLocks locks $ \with -> with action
withLocks
:: (ProvidesLock (f -> IO b), Result f ~ IO b)
=> Locks
-> f
-> IO b
withLocks locks action =
provideLocks locks $ \with ->
with action
example''' :: Locks -> IO ()
example''' locks = do
withLocks @(ContentsOf BLock -> ContentsOf ALock -> IO ()) locks $
\b a -> print "contents"