M-vars: Synchronized Boxes
The source of this manual is available on GitHub.
M-vars originate in Haskell, where they are known as MVars. This library is based on the modern API provided by GHC, which differs in some details from their original presentation in Peyton Jones et al. (1996). Most notably, mvar-put! on a full M-var blocks (instead of raising an exception), and mvar-peek is atomic.
If separate processes are tasked with filling and emptying an M-var, it behaves like an asynchronous channel with a buffer size of 1. Producers use mvar-put! to send a value, and consumers use mvar-take! to receive a value.
If an M-var is normally kept full, it behaves like a box protected by a semaphore. Readers use mvar-peek and do not block each other. Writers use mvar-take! to acquire the lock and mvar-put! to both update the value and release the lock.
If an M-var is normally kept empty, it behaves like a nonblocking, broadcast condition variable. mvar-peek is used to wait on the condition, and mvar-put! followed immediately by mvar-take! is used to notify waiters.
If an M-var starts empty, is filled exactly once, and subsequently remains full, mvar-peek-evt can be used to obtain a synchronizable event that remains permanently ready for synchronization once it has been signaled.
1 Guarantees
1.1 Ordering and Fairness
M-var synchronization is fair: if a thread is blocked on an M-var operation, and opportunities for the operation to complete occur infinitely often, the operation is guaranteed to eventually complete. However, the precise order in which threads blocked on a call to mvar-put! or mvar-take! are woken up is not guaranteed.
If a thread is blocked on a call to mvar-peek, the call is guaranteed to complete the next time the M-var is filled, even if another thread is blocked on a call to mvar-take! on the same M-var. In other words, whenever mvar-peek and mvar-take! compete to read the next value of an empty M-var, mvar-peek always wins. Since mvar-peek is not exclusive—that is, it does not preclude another thread from reading the same M-var after it completes—this preference for mvar-peek ensures that the maximum number of threads are woken up each time an M-var is filled.
1.2 Atomicity
All M-var core operations are atomic: so long as their executing thread is not killed or suspended (see Thread Safety), either none or all of their effects will take place. This atomicity is guaranteed regardless of the way in which the M-var is used.
In contrast, derived operations internally perform multiple core operations in sequence, and those sequences are not intrinsically atomic. However, all derived operations currently provided by this library offer a weaker form of atomicity: they rely on the common M-var usage pattern of treating the M-var like a semaphore-protected box, where each use of mvar-put! is preceded by a use of mvar-take! to acquire exclusive access. This exclusive locking discipline ensures that derived operations are atomic with respect to the M-var’s state—
1.3 Thread Safety
M-vars are a concurrent data structure, so all M-var operations are naturally thread-safe. Furthermore, they are also break-safe: if a thread is interrupted via break-thread while executing any M-var operation, its atomicity guarantees will not be compromised, and the M-var will always remain in a valid state.
However, M-vars are not kill-safe. If a thread is killed via kill-thread while executing any M-var operation (including mvar-peek), the operation’s effects may only partially complete, and the M-var may be permanently left in an invalid state. In a similar vein, if a thread is suspended via thread-suspend while executing any M-var operation, the operation may only partially complete, and the M-var may become temporarily unusable until the thread is resumed.
M-var operations also cannot safely be called in atomic mode. Even non-blocking operations like mvar-try-peek may require polling events, which can lead to a deadlock if atomic mode is active.
2 Core Operations
This section documents the complete list of M-var core operations. All core operations are primitive (they cannot be derived from other operations) and atomic (but not kill-safe; see Thread Safety).
procedure
(mvar-put! mv v [ #:enable-break? enable-break?]) → void? mv : mvar? v : any/c enable-break? : any/c = #f
If enable-break? is not #f, breaks are explicitly enabled while waiting on mv. If breaks are disabled when mvar-put! is called, then either mv is filled or an exn:break exception is raised, but not both.
procedure
(mvar-try-put! mv v) → boolean?
mv : mvar? v : any/c
> (define mv (make-mvar)) > mv #<mvar: empty>
> (mvar-try-put! mv 1) #t
> mv #<mvar: 1>
> (mvar-try-put! mv 2) #f
> mv #<mvar: 1>
procedure
(mvar-put!-evt mv v) → evt?
mv : mvar? v : any/c
procedure
(mvar-take! mv [ #:enable-break? enable-break?]) → any/c mv : mvar? enable-break? : any/c = #f
If enable-break? is not #f, breaks are explicitly enabled while waiting on mv. If breaks are disabled when mvar-take! is called, then either mv is emptied or an exn:break exception is raised, but not both.
> (define mv (make-mvar 42)) > mv #<mvar: 42>
> (mvar-take! mv) 42
> mv #<mvar: empty>
procedure
(mvar-try-take! mv [fail]) → any
mv : mvar? fail : failure-result/c = #f
If fail is a procedure, it is applied to zero arguments in tail position to produce the result.
Otherwise, fail is returned as the result.
> (define mv (make-mvar 42)) > mv #<mvar: 42>
> (mvar-try-take! mv) 42
> mv #<mvar: empty>
> (mvar-try-take! mv) #f
procedure
(mvar-take!-evt mv) → evt?
mv : mvar?
procedure
(mvar-peek mv [ #:enable-break? enable-break?]) → any/c mv : mvar? enable-break? : any/c = #f
If enable-break? is not #f, breaks are explicitly enabled while waiting on mv. If breaks are disabled when mvar-peek is called, then either mv is emptied or an exn:break exception is raised, but not both.
Note that mvar-take! followed immediately by a use of mvar-put! to replace the taken value is not equivalent to mvar-peek: since mvar-take! empties the M-var, another thread may fill it with a different value before the removed value can be replaced. In comparison, mvar-peek does not remove the value from the M-var, so it is guaranteed to be atomic. Additionally, a call to mvar-peek is guaranteed to return as soon as the M-var is filled, while mvar-take! is not; see Ordering and Fairness.
procedure
(mvar-try-peek mv [fail]) → any
mv : mvar? fail : failure-result/c = #f
> (define mv (make-mvar 42)) > (mvar-try-peek mv) 42
> (mvar-take! mv) 42
> (mvar-try-peek mv) #f
procedure
(mvar-peek-evt mv) → evt?
mv : mvar?
procedure
(mvar-empty? mv) → boolean?
mv : mvar?
This operation is provided for completeness, but note that if mv has multiple readers, the result of this function could become out of date the moment it returns. It is therefore very rarely the right choice, and it is almost always better to use mvar-try-put!, mvar-try-take!, or mvar-try-peek, instead.
procedure
(mvar-empty-evt mv) → evt?
mv : mvar?
Like mvar-empty?, this operation should be used very carefully: even if the event is selected, another thread might fill mv the instant that sync returns, so it is almost always better to use mvar-put!-evt, instead. However, in programs where mv only has a single writer, it can rarely be useful, so it is provided for completeness.
3 Derived Operations
The bindings documented in this section are derived operations, which codify some common patterns that arise when an M-var is used like a semaphore-protected box. Since they are implemented as sequences of core operations, they are not intrinsically atomic. Instead, atomicity is enforced through a locking discipline: each operation expects to receive an M-var that is normally kept full, and it uses mvar-take! to acquire an exclusive lock on its contents. When the operation returns, it uses mvar-put! to simultaneously update the contents and release the lock.
Since all of the bindings in this section follow this locking discipline, an easy recipe to ensure atomicity is to never use mvar-take! or mvar-put! directly on any M-var used as a semaphore-protected box. Doing this comes at the risk of accidentally failing to either acquire or release the lock, which is especially easy to do if the critical section fails with an exception or is interrupted by a break. The higher-level, derived operations documented in this section make those accidents impossible, so they should be preferred whenever the lower-level, channel-style operations are not needed.
procedure
(mvar-swap! mv v [ #:enable-break? enable-break?]) → any/c mv : mvar? v : any/c enable-break? : any/c = #f
Since mvar-swap! is implemented using mvar-take! followed by mvar-put!, it is not intrinsically atomic. To ensure atomicity, mvar-swap! should only be used on M-vars that follow the required locking discipline.
> (define mv (make-mvar 'old)) > (mvar-swap! mv 'new) 'old
> mv #<mvar: 'new>
procedure
(mvar-update! mv update-proc [ #:enable-break? enable-break?]) → void? mv : mvar? update-proc : (-> any/c any/c) enable-break? : any/c = #f
Since mvar-update! is implemented using mvar-take! followed by mvar-put!, it is not intrinsically atomic. To ensure atomicity, mvar-update! should only be used on M-vars that follow the required locking discipline.
> (define mv (make-mvar 0)) > (mvar-update! mv add1) > mv #<mvar: 1>
procedure
(call-with-mvar mv body-proc [ #:enable-break? enable-break?]) → any mv : mvar? body-proc : (-> any/c any) enable-break? : any/c = #f
call-with-mvar is useful if mv contains a handle to a shared resource that cannot be used by more than one thread at a time. For example, mv might hold an output port, and a writer thread might use call-with-mvar to obtain exclusive access to the port while it writes a packet, which must not be interleaved with other writes. Once the packet has been written and body-proc returns, call-with-mvar puts the output port back in mv, making it available for acquisition by some other thread.
Since call-with-mvar is implemented using mvar-take! followed by mvar-put!, it is not intrinsically atomic. To ensure atomicity, call-with-mvar should only be used on M-vars that follow the required locking discipline.
procedure
(call-with-mvar! mv body-proc [ #:enable-break? enable-break?]) → any mv : mvar? body-proc : (-> any/c (values any/c ...+)) enable-break? : any/c = #f
In other words, call-with-mvar! is effectively a combination of call-with-mvar and mvar-update!: body-proc determines both the value put back into mv and the result (or results) of the overall call.
Since call-with-mvar! is implemented using mvar-take! followed by mvar-put!, it is not intrinsically atomic. To ensure atomicity, call-with-mvar! should only be used on M-vars that follow the required locking discipline.
4 Contracts
If in-ctc and out-ctc are both chaperone contracts, the result will be a chaperone contract. Otherwise, the result will be an impersonator contract.
> (define/contract mv (mvar/c exact-integer?) (make-mvar)) > (mvar-put! mv 'not-an-integer) mv: contract violation
expected: exact-integer?
given: 'not-an-integer
in: a value written to
(mvar/c exact-integer?)
contract from: (definition mv)
blaming: top-level
(assuming the contract is correct)
at: eval:1:0
5 Chaperones and Impersonators
procedure
(impersonate-mvar mv [ #:get get-proc #:put put-proc] prop prop-val ... ...) → mvar? mv : mvar? get-proc : (or/c (-> any/c any/c) #f) = #f put-proc : (or/c (-> any/c any/c) #f) = #f prop : impersonator-property? prop-val : any/c
If get-proc is not #f, the result of each use of mvar-take! or mvar-peek on the impersonator is redirected through get-proc, which must produce a replacement value. Likewise, if put-proc is not #f, the value stored by each use of mvar-put! is redirected through put-proc.
Pairs of prop and prop-val (the number of by-position arguments to impersonate-mvar must be odd) add or override impersonator property values of mv.
procedure
(chaperone-mvar mv [ #:get get-proc #:put put-proc] prop prop-val ... ...) → mvar? mv : mvar? get-proc : (or/c (-> any/c any/c) #f) = #f put-proc : (or/c (-> any/c any/c) #f) = #f prop : impersonator-property? prop-val : any/c
6 Comparison with syncvar
In data/mvar, mvar-put! blocks when applied to an M-var that is already full; in syncvar/mvar, it raises an error. (The error-raising behavior appeared in the original design (Peyton Jones et al. 1996), but the blocking behavior turned out to be significantly more useful.)
data/mvar provides contracts, chaperones, and impersonators on M-vars, while syncvar/mvar does not.
In data/mvar, mvar-put!, mvar-take!, and mvar-peek accept an #:enable-break? keyword argument to allow breaks to be delivered while blocked even if they are disabled in the enclosing context. syncvar/mvar does not, though sync/enable-break can be used as an alternative.
data/mvar provides stronger guarantees for mvar-peek (see Ordering and Fairness).
data/mvar provides the mvar-empty? and mvar-empty-evt operations (though they are of admittedly limited usefulness).
Bibliography
Simon Peyton Jones, Andrew Gordon, and Sigbjorn Finne. Concurrent Haskell. Principles of Programming Languages (POPL), 1996. doi:10.1145/237721.237794 |