Home > Articles > Programming > C/C++

  • Print
  • + Share This
  • 💬 Discuss
This chapter is from the book

7.13 Smart Pointers and Multithreading

Most often, smart pointers help with sharing objects. Multithreading issues affect object sharing. Therefore, multithreading issues affect smart pointers.

The interaction between smart pointers and multithreading takes place at two levels. One is the pointee object level, and the other is the bookkeeping data level.

7.13.1 Multithreading at the Pointee Object Level

If multiple threads access the same object and if you access that object through a smart pointer, it can be desirable to lock the object during a function call made through operator->. This is possible by having the smart pointer return a proxy object instead of a raw pointer. The proxy object's constructor locks the pointee object, and its destructor unlocks it. The technique is illustrated in Stroustrup (2000). Some code that illustrates this approach is provided here.

First, let's consider a class Widget that has two locking primitives: Lock and Unlock. After a call to Lock, you can access the object safely. Any other threads calling Lock will block. When you call Unlock, you let other threads lock the object.

class Widget
{
   ...
   void Lock();
   void Unlock();
};

Next, we define a class template LockingProxy. Its role is to lock an object (using the Lock/Unlock convention) for the duration of LockingProxy's lifetime.

template <class T>
class LockingProxy
{
public:
   LockingProxy(T* pObj) : pointee_ (pObj)
   { pointee_->Lock(); }
   ~LockingProxy()
   { pointee_->Unlock(); }
   T* operator->() const
   { return pointee_; }
private:
   LockingProxy& operator=(const LockingProxy&);
   T* pointee_;
};

In addition to the constructor and destructor, LockingProxy defines an operator-> that returns a pointer to the pointee object.

Although LockingProxy looks somewhat like a smart pointer, there is one more layer to it—the SmartPtr class template itself.

template <class T>
class SmartPtr
{
   ...
   LockingProxy<T> operator->() const
   { return LockingProxy<T>(pointee_); }
private:
   T* pointee_;
};

Recall from Section 7.3, which explains the mechanics of operator->, that the compiler can apply operator-> multiple times to one -> expression, until it reaches a native pointer. Now imagine you issue the following call (assuming Widget defines a function DoSomething):

SmartPtr<Widget> sp = ...;
sp->DoSomething();

Here's the trick: SmartPtr's operator-> returns a temporary LockingProxy<T> object. The compiler keeps applying operator->. LockingProxy<T>'s operator-> returns a Widget*. The compiler uses this pointer to Widget to issue the call to DoSomething. During the call, the temporary object LockingProxy<T> is alive and locks the object, which means that the object is safely locked. As soon as the call to DoSomething returns, the temporary LockingProxy<T> object is destroyed, so the Widget object is unlocked.

Automatic locking is a good application of smart pointer layering. You can layer smart pointers this way by changing the Storage policy.

7.13.2 Multithreading at the Bookkeeping Data Level

Sometimes smart pointers manipulate data in addition to the pointee object. As you read in Section 7.5, reference-counted smart pointers share some data—namely the reference count—under the covers. If you copy a reference-counted smart pointer from one thread to another, you end up having two smart pointers pointing to the same reference counter. Of course, they also point to the same pointee object, but that's accessible to the user, who can lock it. In contrast, the reference count is not accessible to the user, so managing it is entirely the responsibility of the smart pointer.

Not only reference-counted pointers are exposed to multithreading-related dangers. Reference-tracked smart pointers (Section 7.5.4) internally hold pointers to each other, which are shared data as well. Reference linking leads to communities of smart pointers, not all of which necessarily belong to the same thread. Therefore, every time you copy, assign, and destroy a reference-tracked smart pointer, you must issue appropriate locking; otherwise, the doubly linked list might get corrupted.

In conclusion, multithreading issues ultimately affect smart pointers' implementation. Let's see how to address the multithreading issue in reference counting and reference linking.

7.13.2.1 Multithreaded Reference Counting

If you copy a smart pointer between threads, you end up incrementing the reference count from different threads at unpredictable times.

As the appendix explains, incrementing a value is not an atomic operation. For incrementing and decrementing integral values in a multithreaded environment, you must use the type ThreadingModel<T>::IntType and the AtomicIncrement and AtomicDecrement functions.

Here things become a bit tricky. Better said, they become tricky if you want to separate reference counting from threading.

Policy-based class design prescribes that you decompose a class into elementary behavioral elements and confine each of them to a separate template parameter. In an ideal world, SmartPtr would specify an Ownership policy and a ThreadingModel policy and would use them both for a correct implementation.

In the case of multithreaded reference counting, however, things are much too tied together. For example, the counter must be of type ThreadingModel<T>::IntType. Then, instead of using operator++ and operator——, you must use AtomicIncrement and AtomicDecrement. Threading and reference counting melt together; it is unjustifiably hard to separate them.

The best thing to do is to incorporate multithreading in the Ownership policy. Then you can have two implementations: RefCounting and RefCountingMT.

7.13.2.2 Multithreaded Reference Linking

Consider the destructor of a reference-linked smart pointer. It likely looks like this:

template <class T>
class SmartPtr
{
public:
   ~SmartPtr()
   {
      if (prev_ == next_)
      {
         delete pointee_;
      }
      else
      {
         prev_->next_ = next_;
         next_->prev_ = prev_;
      }
   }
   ...
private:
   T* pointee_;
   SmartPtr* prev_;
   SmartPtr* next_;
};

The code in the destructor performs a classic doubly linked list deletion. To make implementation simpler and faster, the list is circular—the last node points to the first node. This way we don't have to test prev_ and next_ against zero for any smart pointer. A circular list with only one element has prev_ and next_ equal to this.

If multiple threads destroy smart pointers that are linked to each other, clearly the destructor must be atomic (uninterruptible by other threads). Otherwise, another thread can interrupt the destructor of a SmartPtr, for instance, between updating prev_->next_ and updating next_->prev_. That thread will then operate on a corrupt list.

Similar reasoning applies to SmartPtr's copy constructor and the assignment operator. These functions must be atomic because they manipulate the ownership list.

Interestingly enough, we cannot apply object-level locking semantics here. The appendix divides locking strategies into class-level and object-level strategies. A class-level locking operation locks all objects in a given class during that operation. An object-level locking operation locks only the object that's subject to that operation. The former technique leads to less memory being occupied (only one mutex per class) but is exposed to performance bottlenecks. The latter is heavier (one mutex per object) but might be speedier.

We cannot apply object-level locking to smart pointers because an operation manipulates up to three objects: the current object that's being added or removed, the previous object, and the next object in the ownership list.

If we want to introduce object-level locking, the starting observation is that there must be one mutex per pointee object—because there's one list per pointee object. We can dynamically allocate a mutex for each object, but this nullifies the main advantage of reference linking over reference counting. Reference linking was more appealing exactly because it didn't use the free store.

Alternatively, we can use an intrusive approach: The pointee object holds the mutex, and the smart pointer manipulates that mutex. But the existence of a sound, effective alternative—reference-counted smart pointers—removes the incentive to provide this feature.

In summary, smart pointers that use reference counting or reference linking are affected by multithreading issues. Thread-safe reference counting needs integer atomic operations. Thread-safe reference linking needs mutexes. SmartPtr provides only thread-safe reference counting.

  • + Share This
  • 🔖 Save To Your Account

Discussions

comments powered by Disqus