Borislav Stanimirov / @stanimirovb
CppCon 2020
#include <iostream>
int main()
{
std::cout << "Hi, I'm Borislav!\n";
std::cout << "These slides are here: is.gd/immutable\n";
return 0;
}
5 min demo of cancer treatment workflow
Creates a Treatment Session
For Business Logic
class Object {
public:
Data& data() { return m_data; }
const Data& data() { return m_data; }
private:
Data m_data;
};
Multiple threads. Races
std::mutex
class Object {
public:
void lock() { m_mutex.lock(); }
void unlock() { m_mutex.unlock(); }
private:
std::mutex m_mutex;
Data m_data;
};
Bad idea™ Readers will have to wait on each other
std::shared_mutex
class Object {
public:
void readLock() { m_mutex.lock_shared(); }
void readUnlock() { m_mutex.unlock_shared(); }
void writeLock() { m_mutex.lock(); }
void writeUnlock() { m_mutex.unlock(); }
private:
std::shared_mutex m_mutex;
Data m_data;
};
Let's try to work with this
std::shared_mutex
class Object {
public:
void readLock() const { m_mutex.lock_shared(); }
void readUnlock() const { m_mutex.unlock_shared(); }
void writeLock() { m_mutex.lock(); }
void writeUnlock() { m_mutex.unlock(); }
private:
mutable std::shared_mutex m_mutex;
Data m_data;
};
std::shared_mutex
class Object {
private:
void readLock() const { m_mutex.lock_shared(); }
void readUnlock() const { m_mutex.unlock_shared(); }
void writeLock() { m_mutex.lock(); }
void writeUnlock() { m_mutex.unlock(); }
mutable std::shared_mutex m_mutex;
Data m_data;
friend class ObjectReadLock;
friend class ObjectWriteLock;
};
class ObjectReadLock {
public:
ObjectReadLock(const Object& obj) : m_object(obj) {
m_object.readLock();
}
~ObjectReadLock() { m_object.readUnlock(); }
const Object* operator->() const { return &m_object; }
const Object* operator*() const { return &m_object; }
private:
const Object& m_object;
};
class ObjectWriteLock {
public:
ObjectWriteLock(Object& obj) : m_object(obj) {
m_object.writeLock();
}
~ObjectWriteLock() { m_object.writeUnlock(); }
Object* operator->() { return &m_object; }
Object* operator*() { return &m_object; }
private:
Object& m_object;
};
CoW – Copy on Write
template <typename T>
class CoW {
std::shared_ptr<T> m_data;
public:
const T& data() const {
return *m_data;
}
T& mutableData() {
if (m_data.use_count() > 1)
m_data = std::make_shared<T>(*m_data);
return *m_data;
}
};
class ObjectWrapper {
std::shared_ptr<Object> m_object;
public:
const Object& read() const {
return *m_object;
}
Object& write() {
if (m_object.use_count() > 1)
m_object = std::make_shared<Object>(*m_object);
return *m_object;
}
};
How do we refer to objects?
ObjectWrapper myobj;
std::thread gui([]() { guiLoop(myobj); });
myobj.write().changeSomething();
...
void guiLoop(ObjectWrapper obj) {
while (true) {
display(obj.read().getSomething());
}
}
ObjectWrapper myobj;
std::thread gui([]() { guiLoop(myobj); });
myobj.write().changeSomething(); // Uh oh! After this line
...
void guiLoop(ObjectWrapper obj) {
while (true) {
display(obj.read().getSomething()); // No way to access changed here
}
}
class ObjectWrapper {
std::shared_ptr<Object> m_object;
public:
const Object& read() const {
return *m_object;
}
Object& write() {
if (m_object.use_count() > 1)
m_object = std::make_shared<Object>(*m_object);
return *m_object;
}
};
using ObjectHandle = std::shared_ptr<ObjectWrapper>;
ObjectHandle myobj;
std::thread gui([]() { guiLoop(myobj); });
myobj->write().changeSomething(); // Uh oh! during this line
...
void guiLoop(ObjectHandle obj) {
while (true) {
display(obj->read().getSomething()); // We race on the shared pointer here
}
}
class ObjectWrapper {
std::shared_ptr<Object> m_object;
public:
std::shared_ptr<const Object> read() const {
return std::atomic_load_explicit(&m_object, std::memory_order_relaxed);
}
Object& write() {
if (m_object.use_count() > 1) {
auto newObject = std::make_shared<Object>(*m_object);
std::atomic_store_explicit(&m_object, newObject,
std::memory_order_relaxed);
}
return *m_object;
}
};
using ObjectHandle = std::shared_ptr<ObjectWrapper>;
ObjectHandle myobj;
std::thread gui([]() { guiLoop(myobj); });
myobj->write().changeSomething(); // Uh oh! during this line
...
void guiLoop(ObjectHandle obj) {
while (true) {
display(obj->read()->getSomething()); // We race on object contents in "Something"
}
}
class ObjectWrapper {
std::shared_ptr<Object> m_object;
std::shared_ptr<Object> m_writeObject;
public:
std::shared_ptr<const Object> read() const {
return std::atomic_load_explicit(&m_object, std::memory_order_relaxed);
}
std::shared_ptr<Object> writeLock() {
m_writeObject = std::make_shared<Object>(*m_object);
return m_writeObject;
}
void writeUnlock() {
std::atomic_store_explicit(&m_object, m_writeObject,
std::memory_order_relaxed);
}
};
using ObjectHandle = std::shared_ptr<ObjectWrapper>;
class ObjectReadLock {
public:
ObjectReadLock(const ObjectHandle& handle) : m_object(handle.read()) {}
const Object* operator->() const { return m_object.get(); }
const Object* operator*() const { return *m_object; }
private:
std::shared_ptr<const Object> m_object;
};
...not necessarily needed
class ObjectWriteLock {
public:
ObjectWriteLock(const ObjectHandle& handle) : m_handle(handle) {
m_object = m_handle.writeLock();
}
~ObjectWriteLock() {
m_handle.writeUnlock();
}
Object* operator->() { return m_object.get(); }
Object* operator*() { return *m_object; }
private:
ObjectHandle m_handle;
std::shared_ptr<Object> m_object;
};
ObjectHandle myobj;
std::thread gui([]() { guiLoop(myobj); });
ObjectWriteLock(myobj)->changeSomething();
...
void guiLoop(ObjectHandle obj) {
while (true) {
display(ObjectReadLock(obj)->getSomething());
}
}
This can totally work if we promise to only write from a single thread
... but what if we want to write from several threads?
class ObjectWrapper {
std::shared_ptr<Object> m_object;
std::shared_ptr<Object> m_writeObject; // Make this thread safe?
// Where is the *real* data then?
std::shared_ptr<const Object> read() const {
return std::atomic_load_explicit(&m_object, std::memory_order_relaxed);
}
std::shared_ptr<Object> writeLock() {
m_writeObject = std::make_shared<Object>(*m_object);
return m_writeObject;
}
void writeUnlock() {
std::atomic_store_explicit(&m_object, m_writeObject,
std::memory_order_relaxed);
}
};
using ObjectHandle = std::shared_ptr<ObjectWrapper>;
class ObjectWrapper {
std::shared_ptr<Object> m_object;
std::shared_ptr<Object> m_writeObject;
std::mutex m_accessMutex;
std::shared_ptr<const Object> read() const {
return std::atomic_load_explicit(&m_object, std::memory_order_relaxed);
}
std::shared_ptr<Object> writeLock() {
m_accessMutex.lock();
m_writeObject = std::make_shared<Object>(*m_object);
return m_writeObject;
}
void writeUnlock() {
std::atomic_store_explicit(&m_object, m_writeObject,
std::memory_order_relaxed);
m_accessMutex.unlock();
}
};
using ObjectHandle = std::shared_ptr<ObjectWrapper>;
And these are immutable objects
Think std::string_view
You don't change. You replace.
In this case with a copy.
Remember the optional CoW?
What if we still want that?
class ObjectWrapper {
std::shared_ptr<Object> m_object;
std::shared_ptr<Object> m_writeObject;
std::mutex m_accessMutex;
std::shared_ptr<const Object> read() const {
return std::atomic_load_explicit(&m_object, std::memory_order_relaxed);
}
std::shared_ptr<Object> writeLock() {
m_accessMutex.lock();
m_writeObject = (m_object.use_count() > 1)
? std::make_shared<T>(*m_object);
: m_object; // readers suffer from races
return m_writeObject;
}
void writeUnlock() {
...
class ObjectWrapper {
std::shared_ptr<Object> m_object;
std::shared_ptr<Object> m_writeObject;
std::mutex m_accessMutex;
std::shared_ptr<const Object> read() const {
std::lock_guard l(m_accessMutex);
return std::atomic_load_explicit(&m_object, std::memory_order_relaxed);
}
std::shared_ptr<Object> writeLock() {
m_accessMutex.lock();
m_writeObject = (m_object.use_count() > 1)
? std::make_shared<T>(*m_object);
: m_object;
return m_writeObject;
}
void writeUnlock() {
...
class ObjectWrapper {
std::shared_ptr<Object> m_object;
std::mutex m_accessMutex;
std::shared_ptr<const Object> read() const {
std::lock_guard l(m_accessMutex);
return std::atomic_load_explicit(&m_object, std::memory_order_relaxed);
}
std::shared_ptr<Object> writeLock() {
m_accessMutex.lock();
if (m_object.use_count() > 1) {
auto newObject = std::make_shared<Object>(*m_object);
std::atomic_store_explicit(&m_object, newObject,
std::memory_order_relaxed);
}
return m_object;
}
void writeUnlock() {
m_accessMutex.unlock();
}
class ObjectWriteLock {
public:
ObjectWriteLock(const ObjectHandle& handle) : m_handle(handle) {
m_object = m_handle.writeLock();
}
~ObjectWriteLock() {
m_handle.writeUnlock(); // we unlock the mutex but retain a ref
}
Object* operator->() { return m_object.get(); }
Object* operator*() { return *m_object; }
private:
ObjectHandle m_handle;
std::shared_ptr<Object> m_object;
};
class ObjectWriteLock {
public:
ObjectWriteLock(const ObjectHandle& handle) : m_handle(handle) {
m_object = m_handle.writeLock();
}
~ObjectWriteLock() {
m_object.reset();
m_handle.writeUnlock();
}
Object* operator->() { return m_object.get(); }
Object* operator*() { return *m_object; }
private:
ObjectHandle m_handle;
std::shared_ptr<Object> m_object;
};
class ObjectWriteLock {
public:
ObjectWriteLock(const ObjectHandle& handle) : m_handle(handle) {
m_object = m_handle.writeLock().get();
}
~ObjectWriteLock() {
m_handle.writeUnlock();
}
// OMG THE PERF GAIN
Object* operator->() { return m_object; }
Object* operator*() { return *m_object; }
private:
ObjectHandle m_handle;
Object* m_object = nullptr;
};
At first we chose this
std::shared_ptr::use_count
uses a relaxed memory orderif (m_object.use_count() > 1)
do?use_count
will be 1~shared_ptr()
uses release on the use count, so this can't happen:(
What can we do?
std::shared_ptr
with a method use_count_acquire
class Object {
public:
void Object::incReadLockCounter() const {
m_numActiveReadLocks.fetch_add(1, std::memory_order_relaxed);
}
void Object::decReadLockCounter() const {
m_numActiveReadLocks.fetch_sub(1, std::memory_order_release);
}
bool Object::hasReadLocks() const {
return m_numActiveReadLocks.load(std::memory_order_acquire) > 0;
}
private:
mutable std::atomic_int32_t m_numActiveReadLocks = {};
};
std::shared_ptr<Object> ObjectWrapper::writeLock() {
m_accessMutex.lock();
if (m_object->hasReadLocks()) {
...
std::shared_ptr
with a method use_count_acquire
class ObjectWriteLock {
public:
ObjectWriteLock(const ObjectHandle& handle) : m_handle(handle) {
m_object = m_handle.writeLock().get();
}
~ObjectWriteLock() {
m_handle.writeUnlock();
}
// OMG THE PERF GAIN
Object* operator->() { return m_object; }
Object* operator*() { return *m_object; }
private:
ObjectHandle m_handle;
Object* m_object = nullptr;
};
class ObjectWriteLock {
public:
ObjectWriteLock(const ObjectHandle& handle) : m_handle(handle) {
m_object = m_handle.writeLock();
}
~ObjectWriteLock() {
m_object.reset();
m_handle.writeUnlock();
}
// OMG THE PERF LOSS :(
Object* operator->() { return m_object.get(); }
Object* operator*() { return *m_object; }
private:
ObjectHandle m_handle;
std::shared_ptr<Object> m_object;
};
class ObjectWrapper {
std::shared_ptr<Object> m_object;
std::shared_ptr<Object> m_writeObject;
std::mutex m_accessMutex;
std::shared_ptr<const Object> read() const {
return std::atomic_load_explicit(&m_object, std::memory_order_relaxed);
}
std::shared_ptr<Object> writeLock() {
m_accessMutex.lock();
m_writeObject = std::make_shared<Object>(*m_object);
return m_writeObject;
}
void writeUnlock() {
std::atomic_store_explicit(&m_object, m_writeObject,
std::memory_order_relaxed);
m_accessMutex.unlock();
}
};
class ObjectWrapper {
std::shared_ptr<Object> m_object;
std::shared_ptr<Object> m_writeObject;
std::mutex m_accessMutex;
std::shared_ptr<const Object> detach() const {
return std::atomic_load_explicit(&m_object, std::memory_order_relaxed);
}
std::shared_ptr<Object> writeLock() {
m_accessMutex.lock();
m_writeObject = std::make_shared<Object>(*m_object);
return m_writeObject;
}
void writeUnlock() {
std::atomic_store_explicit(&m_object, m_writeObject,
std::memory_order_relaxed);
m_accessMutex.unlock();
}
};
class ObjectWrapper {
std::shared_ptr<Object> m_object;
std::shared_ptr<Object> m_writeObject;
std::mutex m_accessMutex;
std::shared_ptr<const Object> detach() const {
return std::atomic_load_explicit(&m_object, std::memory_order_relaxed);
}
std::shared_ptr<Object> beginTransaction() {
m_accessMutex.lock();
m_writeObject = std::make_shared<Object>(*m_object);
return m_writeObject;
}
void endTransaction() {
std::atomic_store_explicit(&m_object, m_writeObject,
std::memory_order_relaxed);
m_accessMutex.unlock();
}
};
class ObjectWrapper {
std::shared_ptr<Object> m_object;
std::shared_ptr<Object> m_writeObject;
std::mutex m_transactionMutex;
std::shared_ptr<const Object> detach() const {
return std::atomic_load_explicit(&m_object, std::memory_order_relaxed);
}
std::shared_ptr<Object> beginTransaction() {
m_transactionMutex.lock();
m_writeObject = std::make_shared<Object>(*m_object);
return m_writeObject;
}
void endTransaction() {
std::atomic_store_explicit(&m_object, m_writeObject,
std::memory_order_relaxed);
m_transactionMutex.unlock();
}
};
class ObjectWrapper {
std::shared_ptr<Object> m_object;
std::shared_ptr<Object> m_writeObject;
std::mutex m_transactionMutex;
std::shared_ptr<const Object> detach() const {
return std::atomic_load_explicit(&m_object, std::memory_order_relaxed);
}
std::shared_ptr<Object> beginTransaction() {
m_transactionMutex.lock();
m_writeObject = std::make_shared<Object>(*m_object);
return m_writeObject;
}
void endTransaction(bool store) {
if (store) {
std::atomic_store_explicit(&m_object, m_writeObject,
std::memory_order_relaxed);
}
m_transactionMutex.unlock();
}
};
shared_ptr
wrapperIndeed, at first we didn't think much of it
Hmm... smells like React.js
...in JavaScript
imagingState = {
patient: {
orientation: null,
weight: null,
},
image: {
name: '',
notes: '',
size: {x: 0, y: 0, z: 0},
buffer: null,
progress: 0,
},
table: {
pos: {x: 0, y: 0, z: 0},
progress: 0,
},
};
buildGUI({
patient: {
orientation: HeadFirstSuppine,
weight: 80000,
},
image: {
name: 'My Image',
notes: 'Notes on my image',
size: {x: 200, y: 200, z: 200},
buffer: Loader.loadImage('test.img'),
progress: 1,
},
table: {
pos: {x: 2, y: 3, z: 4},
progress: 0.5,
},
});
function setImageName() {
return { image: { name: "Cool image" }}};
}
function shiftTable(state) {
return { table: { pos: { y: state.table.pos.y + 20 }}}};
}
Thanks to JS reflection. These will be spliced onto the state
function setImageName() {
return { image: { name: "Cool image" }}};
}
state:
patient:
orientation: HeadFirstSuppine
weight: 80000
image:
name: "My image"
notes: "Notes on my image"
size:
x:200 y:200 z:200
buffer: [0,3,132,34,12...
progress: 1
table:
pos:
x:2 y:3 z:4
progress: 0.5
function setImageName() {
return { image: { name: "Cool image" }}};
}
let oldState = state;
state = reactSplice(state, setImageName);
console.assert(state !== oldState, "Uh-oh"); // Uh-oh
Only the names have changed - Jon Bon Jovi
function setImageName() {
return { image: { name: "Cool image" }}};
}
state:
patient:
orientation: HeadFirstSuppine
weight: 80000
image:
name: "My image"
notes: "Notes on my image"
size:
x:200 y:200 z:200
buffer: [0,3,132,34,12...
progress: 1
table:
pos:
x:2 y:3 z:4
progress: 0.5
function setImageName() {
return { image: { name: "Cool image" }}};
}
let oldState = state;
state = reactSplice(state, setImageName);
console.assert(state !== oldState, "Uh-oh"); // Everything is fine
if (state != oldState) return null;
if (state.image !== oldState.image) return null;
return <img src="...
React.js-style state in C++?
That would be pretty cool!
auto state = getState();
buildGUI(state);
auto state = getState();
findBladder(state->image);
findFemurs(state->image);
calculateElectronDensity(state->ctImage, state->structures["ptv"]);
undoAction->old = getState()->structures["bladder"];
applyManualUserEditsTo("bladder");
undoAction->cur = getState()->structures["bladder"];
But most importantly...
auto state = getState();
buildGUI(state);
What if...
auto state = somePredefinedState();
buildGUI(state);
assert(currentGUI == expectedGUI);
Then what if...
auto state = somePredefinedState();
treatment(state, receivedNewImageFromMRI, loader.loadImage("test.img"));
assert(deepCompare(getState(), expectedState));
Big Bang Integration Testing
auto state = somePredefinedState();
imaging(state, receivedNewImageFromMRI, loader.loadImage("test.img"));
assert(deepCompare(getState(), expectedState));
Bottom-Up Integration Testing
auto state = somePredefinedState();
// Mock
auto imaging = [](State& s){ if (!s.image) s.image=loader.loadImage("test.img");};
treatment(state);
assert(deepCompare(getState(), expectedState));
Top-Down Integration Testing
State doSomething(const State&)
That got us intrigued
So... back to our objects...
shared_ptr
Wrapper and React.jsa == b
compares addressesstring_view
anymoreBut first...
class ObjectWrapper {
std::shared_ptr<Object> m_object;
std::shared_ptr<Object> m_writeObject;
std::mutex m_transactionMutex;
std::shared_ptr<const Object> detach() const {
return std::atomic_load_explicit(&m_object, std::memory_order_relaxed);
}
std::shared_ptr<Object> beginTransaction() {
m_transactionMutex.lock();
m_writeObject = std::make_shared<Object>(*m_object);
return m_writeObject;
}
void endTransaction(bool store) {
if (store) {
std::atomic_store_explicit(&m_object, m_writeObject,
std::memory_order_relaxed);
}
m_transactionMutex.unlock();
}
};
template <typename T>
class ObjectWrapper {
std::shared_ptr<Object> m_object;
std::shared_ptr<Object> m_writeObject;
std::mutex m_transactionMutex;
std::shared_ptr<const Object> detach() const {
return std::atomic_load_explicit(&m_object, std::memory_order_relaxed);
}
std::shared_ptr<Object> beginTransaction() {
m_transactionMutex.lock();
m_writeObject = std::make_shared<Object>(*m_object);
return m_writeObject;
}
void endTransaction(bool store) {
if (store) {
std::atomic_store_explicit(&m_object, m_writeObject,
std::memory_order_relaxed);
}
m_transactionMutex.unlock();
}
};
template <typename T>
class ObjectWrapper {
std::shared_ptr<T> m_object;
std::shared_ptr<T> m_writeObject;
std::mutex m_transactionMutex;
std::shared_ptr<const T> detach() const {
return std::atomic_load_explicit(&m_object, std::memory_order_relaxed);
}
std::shared_ptr<T> beginTransaction() {
m_transactionMutex.lock();
m_writeObject = std::make_shared<T>(*m_object);
return m_writeObject;
}
void endTransaction(bool store) {
if (store) {
std::atomic_store_explicit(&m_object, m_writeObject,
std::memory_order_relaxed);
}
m_transactionMutex.unlock();
}
};
template <typename T>
class Object {
std::shared_ptr<T> m_object;
std::shared_ptr<T> m_writeObject;
std::mutex m_transactionMutex;
std::shared_ptr<const T> detach() const {
return std::atomic_load_explicit(&m_object, std::memory_order_relaxed);
}
std::shared_ptr<T> beginTransaction() {
m_transactionMutex.lock();
m_writeObject = std::make_shared<T>(*m_object);
return m_writeObject;
}
void endTransaction(bool store) {
if (store) {
std::atomic_store_explicit(&m_object, m_writeObject,
std::memory_order_relaxed);
}
m_transactionMutex.unlock();
}
};
template <typename T>
class Object {
std::shared_ptr<T> m_data;
std::shared_ptr<T> m_transactionData;
std::mutex m_transactionMutex;
std::shared_ptr<const T> detach() const {
return std::atomic_load_explicit(&m_data, std::memory_order_relaxed);
}
std::shared_ptr<T> beginTransaction() {
m_transactionMutex.lock();
m_transactionData = std::make_shared<T>(*m_data);
return m_transactionData;
}
void endTransaction(bool store) {
if (store) {
std::atomic_store_explicit(&m_data, m_transactionData,
std::memory_order_relaxed);
}
m_transactionMutex.unlock();
}
};
template <typename T>
class Transaction { // used to be WriteLock
public:
Transaction(Object<T>& obj) : m_object(obj) {
m_object.beginTransaction();
}
~Transaction() {
m_object.endTransaction(std::uncaught_exceptions() == 0);
}
Object* operator->() { return m_object.m_transactionData.get(); }
Object* operator*() { return *m_object.m_transactionData; }
private:
Object<T>& m_object;
};
struct ImagingState {
struct PatientData {
Orientation orientation;
grams weight;
};
PatientData patient;
struct ImageData {
string name;
string notes;
point3 size;
vector<byte> buffer;
float progress = 0;
};
ImageData image;
struct TableData {
point3 pos;
float progress = 0;
};
TableData table;
};
using ImagingStateObject = Object<ImagingState>;
Obviously that's not enough
We copy everything
struct ImagingState {
struct PatientData {
Orientation orientation;
grams weight;
};
Object<PatientData> patient;
struct ImageData {
string name;
string notes;
point3 size;
vector<byte> buffer;
float progress = 0;
};
Object<ImageData> image;
struct TableData {
point3 pos;
float progress = 0;
};
Object<TableData> table;
};
using ImagingStateObject = Object<ImagingState>;
struct ImagingState {
struct PatientData {
Object<Orientation> orientation;
Object<grams> weight;
};
Object<PatientData> patient;
struct ImageData {
Object<string> name;
Object<string> notes;
Object<point3> size;
Object<vector<byte>> buffer;
Object<float> progress = 0;
};
Object<ImageData> image;
struct TableData {
Object<point3> pos;
Object<float> progress = 0;
};
Object<TableData> table;
};
using ImagingStateObject = Object<ImagingState>;
struct ImagingState {
struct PatientData {
Orientation orientation;
grams weight;
};
Object<PatientData> patient;
struct ImageData {
Object<string> name;
Object<string> notes;
Object<point3> size;
Object<vector<byte>> buffer;
float progress = 0;
};
Object<ImageData> image;
struct TableData {
Object<point3> pos;
float progress = 0;
};
Object<TableData> table;
};
using ImagingStateObject = Object<ImagingState>;
Ok. Let's try this...
function setImageName() {
return { image: { name: "Cool image" }}};
}
*Transaction(Transaction(Transaction(state)->image)->name)) = "Cool image";
auto oldImage = state->detach()->image->detach();// That's a lot of detach(), Batman
*Transaction(oldImage->name) = "something different";
auto curImage = state->detach()->image->detach();
assert(*oldImage->name->detach() != *curImage->name->detach());
// Uh-oh, they both are "something different"
detach
promise template <typename T>
class RootObject {
std::shared_ptr<T> m_data;
std::shared_ptr<T> m_transactionData;
std::mutex m_transactionMutex;
std::shared_ptr<const T> detach() const {
return std::atomic_load_explicit(&m_data, std::memory_order_relaxed);
}
std::shared_ptr<T> beginTransaction() {
m_transactionMutex.lock();
m_transactionData = std::make_shared<T>(*m_data);
return m_transactionData;
}
void endTransaction(bool store) {
if (store) {
std::atomic_store_explicit(m_data, m_transactionData,
std::memory_order_relaxed);
}
m_transactionMutex.unlock();
}
};
template <typename T>
class Node {
std::shared_ptr<T> m_data;
const T* operator->() const { return m_data.get(); }
const T& operator*() const { return *m_data; }
T* operator->() {
cow();
return m_data.get();
}
T& operator*() {
cow();
return *m_data;
}
void cow() { // moo
m_data = std::make_shared<T>(*m_data); // no need for atomic
}
std::shared_ptr<const T> payload() const { return m_data; }
};
struct ImagingState {
struct PatientData {
Orientation orientation;
grams weight;
};
Node<PatientData> patient;
struct ImageData {
Node<string> name;
Node<string> notes;
Node<point3> size;
Node<vector<byte>> buffer;
float progress = 0;
};
Node<ImageData> image;
struct TableData {
Node<point3> pos;
float progress = 0;
};
Node<TableData> table;
};
using ImagingStateObject = RootObject<ImagingState>;
function setImageName() {
return { image: { name: "Cool image" }}};
}
*Transaction(state)->image->name = "Cool image";
Shallow copy-on-write on state-image-name
auto oldState = state.detach();
auto name = oldState->image->name.payload();
*Transaction(state)->image->name = "something different";
auto curState = state.detach();
assert(oldState != curState); // shallow compare
assert(oldState->image != curState->image); // shallow compare
assert(oldState->image->name != curState->image->name); // shallow compare
assert(*oldState->image->name != *curState->image->name); // string compare
This is the React.js way
*Transaction(state)->image->name = "Cool name";
// we copied the old name, only to replace it with "Cool name"
Transaction t(state);
t->image->name = "foo";
t->image->notes = "bar";
// image gets CoW'd twice
Transaction t(state);
if (t->image->buffer->empty()) {
t->image->name = "<empty>";
}
// we copy-on-write even if we change nothing
TBD
*Transaction(state)->image->name = "Cool name";
// no copies of name
Transaction t(state);
t->image->name = "foo";
t->image->notes = "bar";
// image gets CoW'd once
Transaction t(state);
if (t.r()->image->buffer->empty()) {
t->image->name = "<empty>";
}
// no copies if we don't change
struct MRIStudy {
std::vector<Image> images;
// ...
};
// copy vector on every change?
struct MRIStudy {
Node<std::vector<Image>> images;
// ...
};
// copy all images on every change?
struct MRIStudy {
Node<std::vector<Node<Image>>> images;
// ...
};
Transaction(state)->images->emplace_back();
What happens here?
state
. Fineimages
. Er... fineemplace_back
needs to reallocate. Ew... fineimages
. This is were I draw the line!
template <typename T>
class NodeVector {
std::shared_ptr<std::vector<T>> m_data;
auto& emplace_back() {
auto old = m_data;
m_data = std::make_shared<std::vector<T>>();
m_data.reserve(old.size());
m_data = old;
return m_data.emplace_back();
}
// ...
};
The same for other containers
Transaction(state)->imaging->image[0]->progress->value = 0.4f;
// 4 allocs here multiple times per second
std::shared_ptr
?... Batman
setImageVoxel(point3, value)
bool shouldTakeScan(const State& state) {
return state.image->empty() || *state.image->retake;
}
struct Imaging { Node<Image> image; };
struct Treatment { Node<Imaging> imaging; };
struct Diagnosis { Node<Imaging> phase1; }
How can we reuse shouldTakeScan
?
bool shouldTakeScan(const State& state) {
auto image = query<GetImaging>(state)->image;
return image->empty() || *image->retake;
}
// Chaining
bool shouldTakeScan(const State& state) {
auto image = query<GetImaging>(state).q<GetImage>();
return image->empty() || *image->retake;
}
// Mutable
void calculateBladder(Transaction<State>& state) {
auto& b = query<GetOrgans>(state).q<GetOrgan>("bladder");
b = calcBladder(query<GetImaging>(state.r()).q<GetImage>());
}
class SetNameOfScan {
int n = 0;
std::string name;
template <typename State>
void operator()(State& state) {
auto imaging = query<GetImaging>(state); // may throw "No imaging"
if (canSetNameOfScan(imaging, n)) {
imaging->images[n]->name = name;
}
else {
throw BadAction(action);
}
}
};
template <typename Action, typename State>
void applyAction(Action& action, RootObject<State>& state) {
try {
auto t = Transaction(state);
action(*t);
}
catch (Exception& e) {
// most likely notify user that their action is invalid
// or add fatal error to state (long story)
}
}
Can a valid action lead to an invalid state invariant?
What if we want to enforce unique names?
class SetNameOfScan {
int n = 0;
std::string name;
template <typename State>
void operator()(State& state) {
auto imaging = query<GetImaging>(state); // may throw "No imaging"
if (canSetNameOfScan(imaging, n, name)) {
imaging->images[n]->name = name;
}
else {
throw BadAction(action);
}
}
};
No way to check validity of state unless we set the name
No way to validate SetEntireState
void checkValidImageNames(const Imaging& img) {
if (!allNamesUnique(img.images)) {
throw BadState();
}
}
template <typename State>
void checkValidImaging(const State& state) {
auto imaging = query<GetImaging>(state);
checkValidImageNames(imaging);
checkAllImagesOfSameSize(imaging);
checkNoSimultaneousTableMoveAndScan(imaging);
// ...
}
template <typename State>
class App {
RootObject<State> state;
std::vector<std::function<void(const State&)>> guards;
void runGuards(const State&);
};
Or...
class TreatmentApp {
RootObject<TreatmentState> state;
void runGuards(const State& state) {
checkPatient(state);
checkImaging(state);
checkDelivery(state);
}
};
template <typename Action, typename App>
void applyAction(Action& action, App& app) {
try {
auto t = Transaction(app.state);
action(*t);
app.runGuards(*t);
}
catch (Exception& e) {
// most likely notify user that their action is invalid
// or add fatal error to state (long story)
}
}
That's a lot of guard calls
struct CheckImaging {
std::shared_ptr<const Imaging> lastValidatedImaging;
template <typename State>
void operator()(const State& state) {
auto imaging = query<GetImaging>(state);
if (lastValidatedImaging == imaging) return;
checkValidImageNames(imaging);
checkAllImagesOfSameSize(imaging);
checkNoSimultaneousTableMoveAndScan(imaging);
// ...
lastValidatedImaging = imaging;
}
};
Guards are not pure anymore :(
Promising results on memoizing guards and queries on the state
void endTransaction(bool store) {
std::shared_ptr<const T> newData;
if (store) {
std::atomic_store_explicit(m_data, m_transactionData,
std::memory_order_relaxed);
newData = std::move(m_transactionData);
}
m_transactionMutex.unlock();
if (newData) {
notifySubscribers(newData);
}
}
Notify on each state change, but not while locked
treatmentState.subscribe([uiThread]() {
uiThread->wakeUp();
});
// ...
void UIThread::run() {
waitUntilWokenUp();
buildGUI(treatmentState.detach()); // ok if we skip states
}
// ...
void UIThread::onSetImageName(UserPtr user, int i, std::string name) {
treatmentSession.pushAction<SetImageName>(user, i, name);
}
void UIThread::onCalculateBladder(UserPtr user) {
// ???
}
void UserThread::run() {
waitUntilWokenUp();
std::future<std::optional<Action>> f
= makeDecision(myBrainState, observedAppState);
f.wait();
auto action = f.get();
if (action) app.pushAction(*action);
}
We translate declarative to imperative code
void bladderObserver() {
if (bladderCalculation.running()) {
if (query<GetImage>(appState) != query<GetImage>(myState)) {
bladderCalculation.abort();
}
else {
return;
}
}
if (!query<GetBladder>(appState)) {
bladderCalculation.start();
return;
}
if (query<GetBladder>(appState)->manualCalcRequested) {
bladderCalculation.start();
return;
}
}
std::thread t(func);
class SetBladder {
void operator(State& s) {
auto worker = workers("Bladder");
if (!worker->hasResult()) throw BadAction("no bladder");
auto result = worker->collectResult();
// only now is the bladder worker complete
if (query<GetImage>(state.r()) != result.image {
throw BadAction("outdated bladder");
}
query<GetBladder>(state) = result.bladder;
}
};
Imperative to declarative code
auto state = load<TreatmentState>("treatment-delivery-beam-on.state");
auto gui = generateGui(state);
auto expectedGUI = load<GUI>("treatment-delivery-beam-on.gui");
assert(gui == expectedGUI);
auto patient = load<Patient>("john-doe.patient");
auto app = newTreatment(patient);
WorkerManager::setThreads(0);
WorkerManager::overrideWorker("MRI.takeImage", []() {
return load<Image>("test.img"); // mock
});
WorkerManager::exaustWorkers(); // synchronous
assert(hasAllImages(app.state)); // auto imaging worked
assert(hasAllOrgans(app.state)); // auto contouring worked
Borislav Stanimirov / ibob.github.io / @stanimirovb
These slides: ibob.github.io/slides/immutable-obj/
Slides license Creative Commons By 4.0