Skip to content

3. Custom Containers

lukemartinlogan edited this page Mar 17, 2023 · 1 revision

Creating a custom container

There are many cases where a new SHM-compatible data structure must be created. A SHM data structure is divided into two parts: the shared part and the private part.

The shared part is the fraction of the data structure stored in shared memory, which we refer to as the ShmHeader. The ShmHeader is exactly the same across all processes. ShmHeaders are a "piece of data" (POD) type, typically a C-style struct. A POD type consists of simple types (char, int, float, double) and structs/arrays containing only simple types. ShmHeaders cannot contain process-specific information (e.g., virtual function tables, pointers).

The private part is the fraction of the data structure which is private to a particular process, which we refer to as the ShmContainer. The ShmContainer provides a view of the information stored in a ShmHeader. The ShmContainer can contain virtual methods and process-specific pointers.

Constructors

ShmContainers must be passed a valid ShmHeader and Allocator during initialization. The constructors of the ShmContainer will be responsible for properly initializing the ShmHeader contents.

Below is an example of an initialization constructor for MyClass

template<>
struct ShmHeader<MyClass> {
  SHM_CONTAINER_HEADER_TEMPLATE(ShmHeader)
  ShmArchive<list<int>> list_ar_;
  
  /** Called during both copy and move */
  void strong_copy(const ShmHeader &other) {
    length_ = other.length_;
    text_ = other.text_;
  }
};

#define CLASS_NAME MyClass
#define TYPED_CLASS MyClass
#define TYPED_HEADER ShmHeader<MyClass>

class MyClass : public ShmContainer {
  SHM_CONTAINER_TEMPLATE((CLASS_NAME), (TYPED_CLASS), (TYPED_HEADER))
    // This macro provides the following two class variables:
    // ShmHeader<MyClass> *header_;
    // Allocator *alloc_;
  hipc::Ref<list<int>> list_;

  MyClass(ShmHeader<MyClass> *header, Allocator *alloc) {
    shm_init_header(header, alloc);
    list_ = hipc::make_ref<list<int>>(header_->list_ar_, alloc_);
  }
};

In this example, MyClass makes the "list_" hipc::Ref wrap around the ShmArchive "list_ar_" using "make_ref". make_ref also constructs the list using the same allocator that was input. ShmContainers expect non-null inputs for header and alloc.

Destructors

ShmContainers provide a view to some data stored in shared memory. However, the ownership of this data is nontrivial due to the properties of shared memory. Which process actually is responsible for freeing the data? How do we ensure that two processes don't free at the same time? The short answer is that this is the responsibility of Smart Pointers, not of the ShmContainer class itself. ShmContainers should not have a C++ destructor and are not responsible for deallocating the ShmHeader.

Instead of implicit destructors, hshm requires ShmContainers to define three methods: IsNull, SetNull, and shm_destroy. IsNull determines whether or not the object is empty. SetNull will modify some data in the ShmHeader to indicate that the contents of the data structure are no longer valid. shm_destroy actually frees content.

bool IsNull() const {
}

void SetNull() {
}

void shm_destroy() {
  if (IsNull()) { return; }
  shm_destroy_main();
  SetNull(); 
}

Copy Constructor

Copy constructors typically look as follows:

MyClass(const MyClass &other);

However, to make this work, we would have to allocate a ShmHeader within the container itself and track whether or not this object is destructable using a bitfield. This gets clunky quickly. We assume that ShmContainers are allocated using a Smart Pointer class, which will be responsible for both allocating and destroying the ShmHeader.

Thus, the copy constructor for SHM container objects looks as follows:

explicit MyClass(ShmHeader<MyClass> *header, Allocator *alloc, const MyClass &other) {
  shm_init_header(header, alloc);
  shm_strong_copy_construct(other);
}

void shm_strong_copy_construct(const MyClass &other) {
  list_ = hipc::make_ref<list<int>>(header_->list_ar_, alloc_, *other.list_);
}

Note, it may be worthwhile to check if the "other" object is NULL. NULL indicates that an objects has been moved from.

Copy Assignment

Copy assignment is similar to the copy constructor. However, we must ensure that all data currently in "this" object has been destroyed before proceeding with the copy.

MyClass& operator=(const MyClass &other) {
  if (this != &other) {
    shm_destroy();
    shm_strong_copy_op(other);
  }
  return *this;
}

void shm_strong_copy_op(const MyClass &other) {
  (*list_) = (*other.list_);
}

It can be necessary to check if "other" is empty. However, this is handled internally by list in this case, so it is redundant to do it here.

Move Constructor

Move constructors are used to remove the ownership of data from the ShmHeader of a ShmContainer. "This" container has not yet been initialized. SHM move constructors look as follows:

MyClass(ShmHeader<MyClass> *header, Allocator *alloc, MyClass &&other) noexcept {
  shm_init_header(header, alloc);
  if (alloc_ == other.alloc_) {
    list_ = hipc::make_ref<list<int>>(header_->list_ar_, alloc_, std::forward<MyClass>(other));
    other.SetNull();
  } else {
    shm_strong_copy_construct(other);
    other.shm_destroy();
  }
}

There are two primary cases of a move constructor:

  1. What if the allocators are the same? std::move() every object in the other's ShmHeader.
  2. What if the allocators are different? Copy "other" and then destroy it.

Move Assignment

Move assignment operators are similar to move constructors. However, "this" container is initialized. SHM assignment operators look as follows:

MyClass& operator=(MyClass &&other) noexcept {
  if (this != &other) {
    shm_destroy();
    if (alloc_ == other.alloc_) {
      (*list_) = std::move(*other.list_);
      other.SetNull();
    } else {
      shm_strong_copy_op(other);
      other.shm_destroy();
    }
  }
  return *this;
}

SHM Serialization + Deserialization

A ShmContainer can be represented as a "Pointer", "OffsetPointer", "AtomicPointer", or "AtomicOffsetPointer". This can be passed among processes to determine how to locate an object. We refer to this as "serialization". Unlike typical serialization which marshalls the data structure into a byte stream, this is simply converting the data structure into a process-independent pointer.

To enable objects to be serialized into pointers, custom classes must implement:

void shm_deserialize_main(ShmHeader<MyClass> *header, Allocator *alloc);

Case 1: Process-Independent Pointers

Pointer p;
auto x = make_uptr<MyClass>();
x >> p;

uptr<MyClass> y;
y << p;

Case 2: ShmDeserialize object

auto x = make_uptr<MyClass>();
auto y = uptr<MyClass>(x->GetShmDeserialize());