Keep Calm (and call Clone)
Simple shared types for multi-threaded Rust programs: keepcalm gives you permission to simplify your synchronization code in concurrent Rust applications.
Name inspired by @luser's Keep Calm and Call Clone.
Overview
This library simplifies a number of shared-object patterns that are used in multi-threaded programs such as web-servers.
Advantages of keepcalm:
- You don't need to decide on your synchronization primitives up-front. Everything is a [
Shared] or [SharedMut], no matter whether it's a mutex, read/write lock, read/copy/update primitive, or a read-only shared [std::sync::Arc]. - Everything is [
project!]able, which means you can adjust the granularity of your locks at any time without having to refactor the whole system. If you want finer-grained locks at a later date, the code that uses the shared containers doesn't change! - Writeable containers can be turned into read-only containers, while still retaining the ability for other code to update the contents.
- Read and write guards are
!Send, but an opt-inread_send/write_sendis available when you genuinely need aSendguard and are aware of the potential deadlock consequences. - Each synchronization primitive transparently manages the poisoned state (if code
panic!s while the lock is being held). If you don't want to poison onpanic!, constructors are available to disable this option entirely. staticGlobally-scoped containers for bothSyncand!Syncobjects are easily constructed using [SharedGlobal], and can provide [Shared] containers. Mutable global containers can similarly be constructed with [SharedGlobalMut].- The same primitives work in both synchronous and
asynccontents (caveat: the latter being experimental at this time): you can simplyawaitan asynchronous version of the lock usingread_asyncandwrite_async. - Minimal performance impact: benchmarks shows approximately the same performance between the raw
parking_lotprimitives/tokioasync containers and those inkeepcalm.
Performance
A rough benchmark shows approximately equivalent performance to both tokio and parking_lot primitives in async and sync contexts. While
keepcalm shows performance slightly faster than parking_lot in some cases, this is probably measurement noise.
| Benchmark | keepcalm |
tokio |
parking_lot |
|---|---|---|---|
| Mutex (async, uncontended) | 23ns | 49ns | n/a |
| Mutex (async, contented) | 1.3ms | 1.3ms | n/a |
| RwLock (async, uncontended) | 14ns | 46ns | n/a |
| RwLock (async, contended) | (untested) | (untested) | (untested) |
| RwLock (sync) | 6.8ns | n/a | (untested) |
| Mutex (sync) | 7.3ns | n/a | 8.5ns |
Container types
The following container types are available:
| Container | Equivalent | Notes |
|---|---|---|
[SharedMut::new] |
Arc<RwLock<T>> |
This is the default shared-mutable type. |
[SharedMut::new_mutex] |
Arc<Mutex<T>> |
In some cases it may be necessary to serialize both read and writes. For example, with types that are not Sync. |
[SharedMut::new_rcu] |
Arc<RwLock<Arc<T> |
When the write lock of an RCU container is dropped, the values written are committed to the value in the container. |
[Shared::new] |
Arc |
This is the default shared-immutable type. Note that this is slightly more verbose: [Shared] does not [std::ops::Deref] to the underlying type and requires calling [Shared::read]. |
[Shared::new_mutex] |
Arc<Mutex<T>> |
For types that are not Sync, a Mutex is used to serialize read-only access. |
[SharedMut::shared] |
n/a | This provides a read-only view into a read-write container and has no direct equivalent. |
The following global container types are available:
| Container | Equivalent | Notes |
|---|---|---|
[SharedGlobal::new] |
static T |
This is a global const-style object, for types that are Send + Sync. |
[SharedGlobal::new_lazy] |
static Lazy<T> |
This is a lazily-initialized global const-style object, for types that are Send + Sync. |
[SharedGlobal::new_mutex] |
static Mutex<T> |
This is a global const-style object, for types that are Send but not necessarily Sync |
[SharedGlobalMut::new] |
static RwLock<T> |
This is a global mutable object, for types that are Send + Sync. |
[SharedGlobalMut::new_lazy] |
static Lazy<RwLock<T>> |
This is a lazily-initialized global mutable object, for types that are Send + Sync. |
[SharedGlobalMut::new_mutex] |
static Mutex<T> |
This is a global mutable object, for types that are Send but not necessarily Sync. |
Choosing a mutable primitive
All three mutable backings are interchangeable through the [SharedMut] interface, so you can start
with the default and change your mind later. They differ in a few properties worth knowing:
| Constructor | Backing | Poison on panic | Reads | Concurrent reads | Writes | Participates in deadlock graph |
|---|---|---|---|---|---|---|
[SharedMut::new] |
RwLock |
yes (default) | blocks writers | yes | exclusive | yes |
[SharedMut::new_mutex] |
Mutex |
yes (default) | exclusive | no | exclusive | yes |
[SharedMut::new_rcu] |
RwLock<Arc<T>> |
no (ignored) | lock-free Arc snapshot |
yes | clone-on-write (or [SharedMut::set], which skips the clone) |
no |
The stand-out row is RCU. A read is just an Arc snapshot of the current value: it never blocks a
writer, and — because it takes no lock that any other acquisition can wait on — it can never be part
of a lock-order cycle. See Deadlock detection for why that makes it the
escape hatch for lock-ordering bugs.
Every mutable container can also be replaced wholesale with [SharedMut::set] (or the non-blocking
[SharedMut::try_set]) instead of *container.write() = value. For RCU this is not just sugar: it
installs the new value without the read-copy that a write guard performs.
Basic syntax
The traditional Rust shared object patterns tend to be somewhat verbose and repetitive, for example:
# use ;
#
let foo = Foo ;
use_string;
If we want to switch our shared fields from [std::sync::Mutex] to [std::sync::RwLock], we need to change four lines just for types, and
switch the lock method for a read method.
We can increase flexibility, and reduce some of the ceremony and verbosity with keepcalm:
# use *;
#
let foo = Foo ;
use_string;
If we want to use a Mutex instead of the default RwLock that [SharedMut] uses under the hood, we only need to change [SharedMut::new] to
[SharedMut::new_mutex]!
SharedMut
The [SharedMut] object hides the complexity of managing Arc<Mutex<T>>, Arc<RwLock<T>>, and other synchronization types
behind a single interface:
# use *;
let object = "123".to_string;
let shared = new;
shared.read;
By default, a [SharedMut] object uses Arc<RwLock<T>> under the hood, but you can choose the synchronization primitive at
construction time. The [SharedMut] object erases the underlying primitive and you can use them interchangeably:
# use *;
let shared = new;
use_shared;
let shared = new_mutex;
use_shared;
Managing the poison state of synchronization primitives can be challenging as well. Rust will poison a Mutex or RwLock if you
hold a lock while a panic! occurs.
The SharedMut type allows you to specify a [PoisonPolicy] at construction time. By default, if a synchronization
primitive is poisoned, the SharedMut will panic! on access. This can be configured so that poisoning is ignored:
# use *;
let shared = new_with_policy;
Shared
The default [Shared] object is similar to Rust's [std::sync::Arc], but adds the ability to project. [Shared] objects may also be
constructed as a Mutex, or may be a read-only view into a [SharedMut].
Note that because of this flexibility, the [Shared] object is slightly more complex than a traditional [std::sync::Arc], as all accesses
must be performed through the [Shared::read] accessor.
Globals
While static globals may often be an anti-pattern in Rust, this library also offers easily-to-use alternatives that are compatible with
the [Shared] and [SharedMut] types.
Global [Shared] references can be created using [SharedGlobal]:
# use *;
static GLOBAL: = new;
Similarly, global [SharedMut] references can be created using [SharedGlobalMut]:
# use *;
static GLOBAL: = new;
Both [SharedGlobal] and [SharedGlobalMut] offer a new_lazy constructor that allows initialization to be deferred to first
access:
# use *;
# use HashMap;
static GLOBAL_LAZY: =
new_lazy;
Globals can be projected in const contexts using [project_global!], so a field of a global
can itself be exposed as a static [Shared] or [SharedMut] — without allocation, and while
still sharing the root's lock:
# use *;
static APP: = new_lazy;
static NAME: = project_global!;
static PORT: = project_global!;
*NAME.write += "-1";
assert_eq!;
assert_eq!;
Deadlock detection
NOTE: This requires the --feature deadlock_detection flag
With the deadlock_detection feature enabled, keepcalm tracks the order in which locks are
acquired across the whole process (in the style of the Linux kernel's lockdep). If a thread
is about to acquire a lock in an order that is inverted somewhere else in the program — the
classic recipe for a deadlock — it panic!s immediately with a report that includes a
backtrace of where the opposite ordering was first observed. Re-acquiring a lock the current
thread already holds (including through a projection, which shares its root's lock) is also
reported.
The check happens before the acquisition blocks, so a lock-order bug is caught the first time the inverted order runs — the threads don't have to actually race into the deadlock:
# use *;
let a = new;
let b = new;
let _b = b.write;
let _a = a.write; // panics: b -> a inverts the recorded order
Because every container funnels through the same erased lock implementation, this works
uniformly across RwLock, Mutex, globals and projected containers. The report names each lock
by the type it guards (e.g. lock #1 (my_app::HotSet)). Plain Arc and RCU containers never
block while held and are exempt. try_read/try_write cannot deadlock and are never reported,
but locks they successfully acquire are tracked as held.
A detected cycle panic!s but does not poison the locks involved: the detector aborts
before acquiring the second lock, so the data under every currently-held lock is still
consistent. This means a single proactive warning brings down one code path (e.g. one request on
a server) rather than cascading into a poison outage across every subsequent access. (A genuine
panic while a lock is held still poisons as usual.)
This feature is intended for debug and test builds: every blocking acquisition consults a
global registry. Enable it in CI (cargo test --features deadlock_detection) and leave it
off in release builds, where it costs nothing.
EXPERIMENTAL: Async
NOTE: This requires the --feature async_experimental flag
This is extremely experimental and may have soundness and/or performance issues!
The [Shared] and [SharedMut] types support a read_async and write_async method that will block using an async runtime's spawn_blocking
method (or equivalent). Create a [Spawner] using make_spawner and pass that to the appropriate lock method.
Note that this relies on an async runtime to provide a blocking task thread-pool, so this may not be suitable for all use-cases.
# use *;
#
static SPAWNER: Spawner = make_spawner!;
#
async
#
Projection
Both [Shared] and [SharedMut] allow projection into the underlying type. Projection can be used to select
either a subset of a type, or to cast a type to a trait. The [project!] and [project_cast!] macros can simplify
this code.
Note that projections are always linked to the root object! A projection shares its source's
lock — and its lock-order identity. Locking a projection locks the whole root, so two projections
of the same source (e.g. state.config and state.sessions obtained via project_fn) are not
independent locks: reading one contends with, and orders against, all the others. Under the
deadlock_detection feature a projection is reported as its root lock for exactly this reason.
Casting:
# use *;
let shared = new;
// Supported for most built-in traits
let shared_asref: = shared.cast;
// Any trait may be projected using `project_cast!`
let shared_asref: = shared.project;
Subset of a struct/tuple — pass the container and the field path to [project!] and get the
projected container back, with no type annotations (tuple-index chains like tuple.0 work too):
# use *;
let shared = new;
let shared_string = project!;
*shared_string.write += "hello, world";
assert_eq!;
assert_eq!;
This form works on both [SharedMut] and [Shared] receivers. For projections that aren't
simple field paths, build a standalone projector with the original form,
shared.project(project!(x: Foo, x.tuple.0)), or supply the projection functions directly
via project_fn.
Read and write guards can also be projected after the lock is taken, using
[SharedReadLock::map] and [SharedWriteLock::map]:
# use *;
let shared = new;
let guard = shared.read.map;
assert_eq!;
Unsized types
Both [Shared] and [SharedMut] support unsized types, but due to current limitations in the language (see [std::ops::CoerceUnsized] for details),
you need to construct them in special ways.
Unsized traits are supported, but you will either need to specify Send + Sync in the shared type, or [project_cast!] the object:
# use *;
// In this form, `Send + Sync` are visible in the shared type
let boxed: = Boxnew;
let shared: = from_box;
// In this form, `Send + Sync` are erased via projection
let shared = new;
let shared_asref: = shared.project;
Unsized slices are supported using a box:
# use *;
let boxed: = Boxnew;
let shared: = from_box;