Details
-
Task
-
Resolution: Unresolved
-
P2: Important
-
None
-
6.0
-
None
Description
Lock-based programming is not very convenient. At the very least, what it typically involves is a mutex bound to an object that is being protected. The straightforward approach (somewhat implied by the use of standard library) is to keep these two entities (mutex and object) separated and have additional glue code for thread-safety:
lock the mutex -> perform read/modify/write on the object -> unlock the mutex
To simplify the burden of mutexes and actually make a user focus on what's important - the object interaction, different people implemented some way of a generic locking mechanism, for an example see [2] and [3]. And for a gentle introduction to a "general idea" [1] would be a good starting point.
On a high level, there typically are 2 patterns: functional and object oriented.
A typical functional pattern looks like:
void threadSafeCall(Foo fooFunc) { // lock mutex fooFunc(); // call function // unlock mutex }
An object oriented pattern is more complicated but way more powerful (kind of). In a nutshell, it is a class that wraps a has-to-be-thread-safely-accessed-object and in the most simple way it has an interface akin to smart pointer:
ThreadSafe<FooType> threadSafeFoo(constructFoo()); // holds foo inside threadSafeFoo->fooFunc(); // calls FooType::fooFunc() in a thread-safe manner via operator->() trick
Now, why all this might be interesting?
1. Sometimes lock-based programming is inevitable and you have to use mutexes of some sort
2. Using "raw" mutexes is hard, you need to reason about correctness, you need to avoid deadlocks, etc.
3. Mutexes involve a lot of code bloat in general
4. Consider the following example:
I have an object pointer "d" with thread-safe functions foo() and bar().
d->foo(); // thread-safe, uses mutex inside d->bar(); // thread-safe, uses mutex inside
Now, imagine that someone needs to call foo() and bar() together: d->foo(); d->bar();
What happens inside is you lock and unlock twice (once per function call) in a sequence - this is bad for performance and does not make much sense, typically.
Your next step? d->fooAndBar() with the logic of both functions together with a lock around foo's and bar's internals? What if you have foo(), bar() and baz() now?
There is a scalable solution to this problem and there are different designs that solve the issues of that kind. I think it makes sense to look at existing things, evaluate them and come up with our solution that could be used throughout the code base (Qt, QtCreator, something else).
Things to consider when designing generic locking primitives:
- Differences in mutexes/locking models: already in the standard library there are 4 distinctive mutex models - just a mutex, mutex with recursion support, mutex with timeout locking, "read/write" mutex; add Qt's mutexes to the picture and we have a handful of classes
- Ease of use: single function call vs a sequence of multiple function calls (all should be thread-safe)
- Possibility to mix atomics into the picture (e.g. see how QFutureInterface functions are implemented - there's an atomic state that is checked before a mutex is locked for short-cutting the logic and not wasting extra resources)
- D-ptr in Qt classes (what if we want full d-ptr to be protected? - QFutureInterface case)
- Possibility to associate multiple objects with the same mutex (straightforward solution is to put wrap all classes into a single class and use that for 1:1 correspondence to a mutex)
- CoW classes and what challenges they bring (if at all)
Where to find current usages of mutex + object:
- Qt code:
QFuture/QPromise internals
Any other Qt code that uses QMutex/QMutexLocker and so on - QtConcurrent
- QtCreator
I briefly know about 2 approaches to generic locking, both done in the object oriented paradigm: Folly's approach [2] and CopperSpice's approach [3]. They seem somewhat similar, but Folly implementation has a single "entry point" class (or so it seems), while CopperSpice implementation consists of multiple independent classes. There are likely differences in APIs and usages.
[1]: Andrei Alexandrescu "Generic Locking in C++", NDC Oslo conference talk
[2]: Folly's Synchronized class (showcased in [1])
[3]: CopperSpice analogy - plain_guarded (there are other primitives as well for different kinds of usages - e.g. std::mutex vs std::timed_mutex vs std::shared_mutex)