Search code examples
c++asynchronousshared-ptr

What shared_ptr policy to use with asynchronous scheme?


I have my own multi-threaded service which handles some commands. The service consists of command parser, worker threads with queues and some caches. I don't want to keep an eye on each object's life-cycle, so I use shared_ptr's very extensive. Every component uses shared_ptr's in its own way:

  • command parser creates shared_ptr's and stores them in cache;
  • worker binds shared_ptr's to functors and puts them to queue.
  • cache temporary or permanently holds some shared_ptr's.
  • the data that is referenced by shared_ptr can also hold some other shared_ptr's.

And there is another underlying service (for example, command receiver and sender) that has the same structure, but uses his own cache, workers and shared_ptr's. It's independent from my service and is maintained by another developer.

It's a complete nightmare, when I try to track all shared_ptr dependencies to prevent cross-references.

Is there a way to specify some shared_ptr "interface" or "policy", so I will know which shared_ptr's I can pass safely to the underlying service without inspecting the code or interacting with the developer? Policy should involve the shared_ptr owning-cycle, for example, the worker holds the functor with binded shared_ptr since the dispatch() function call and only til some other function call, while the cache holds the shared_ptr since the cache's constructor call and til the cache's destructor call.

Especially, I'm curious about shutdown situation, when the application may freeze while waiting the threads to join.


Solution

  • There is no silver bullet... and shared_ptr certainly is not one.


    My first question would be: do you need all those shared pointers ?

    The best way to avoid cyclic references is to define the lifetime policy of each object and make sure they are compatible. This can be easily documented:

    • you pass me a reference, I expect the object to live throughout the function call, but no more
    • you pass me a unique_ptr, I am now responsible for the object
    • you pass me a shared_ptr, I expect to be able to keep a handle to the object myself without adversely affecting you

    Now, there are rare situations where the use of shared_ptr is indeed necessary. The indication of caches lead me to think that it might be your case, at least for some uses.

    In this case, you can (at least informally) enforce a layering approach.

    • Define a number of layers, from 0 (the base) to infinite
    • Each type of object is ascribed to a layer, several types may share the same layer
    • An object of type A might only hold a shared_ptr to an object of type B if, and only if, Layer(A) > Layer(B)

    Note that we expressly forbid sibling relationships. With this scheme, no circle of references can ever be formed. Indeed, we obtain a DAG (Directed Acyclic Graph).

    Now, when a type is created, it must be ascribed a layer number, and this must be documented (preferably in the code).

    An object may change of layer, however:

    • if its layer number decreases, then you must reexamine the references it holds (easy)
    • if its layer number increases, then you must reexamine all the references to it (hard)

    Note: by convention, types of objects which cannot hold any reference are usually in the layer 0.

    Note 2: I first stumble upon this convention in an article by Herb Sutter, where he applied it to Mutexes and tried to prevent deadlock. This is an adaptation to the current issue.


    This can be enforced a bit more automatically (by the compiler) as long as you are ready to work your existing code base.

    We create a new SharedPtr class aware of our layering scheme:

    template <typename T>
    constexpr unsigned getLayer(T const&) { return T::Layer; }
    
    
    template <typename T, unsigned L>
    class SharedPtrImpl {
    public:
      explicit SharedPtrImpl(T* t): _p(t)
      {
        static_assert(L > getLayer(std::declval<T>()), "Layering Violation");
      }
    
      T* get() const { return _p.get(); }
    
      T& operator*() const { return *this->get(); }
      T* operator->() const { return this->get(); }
    
    private:
      std::shared_ptr<T> _p;
    };
    

    Each type that may be held in such a SharedPtr is given its layer statically, and we use a base class to help us out:

    template <unsigned L>
    struct LayerMember {
      static unsigned const Layer = L;
    
      template <typename T>
      using SharedPtr<T> = SharedPtrImpl<T, L>;
    };
    

    And now, we can easily use it:

    class Foo: public LayerMember<3> {
    public:
    
    private:
      SharedPtr<Bar> _bar; // statically checked!
    };
    

    However this coding approach is a little more involved, I think that convention may well be sufficient ;)