Hazard Pointers (part 2)

published: Wed, 10-Jan-2007   |   updated: Wed, 10-Jan-2007

Last time in this series on hazard pointers I looked at how the concept could help provide a node reuse manager for a lock-free stack using C#, admittedly without fleshing out the details of how hazard pointers would work under the hood. This time I just want to look at the lock-free queue in the same way, to see how hazard pointers would help provide lock-free memory management. Next time, we'll delve into the C# code for the hazard pointers themselves.

With the stack we were lucky in that only the Pop() method required any real changes for lock-free management. For the queue, things are a lot different: the Enqueue() method also requires protection from the ABA problem.

Let's look at the (simplified) lock-free Enqueue() method (shown here with line numbers):

01    public void Enqueue(T item) {
02      Node<T> oldTail = null;
03      Node<T> oldNext = null;
04
05      // create and initialize the new node
06      Node<T> node = new Node<T>();
07      node.Item = item;
08
09      // loop until we have managed to update the tail's Next link 
10      // to point to our new node
11      bool UpdatedNewLink = false;
12      while (!UpdatedNewLink) {
13
14        // make local copies of the tail and its Next link, but in 
15        // getting the latter use the local copy of the tail since
16        // another thread may have changed the value of tail
17        oldTail = tail;         
18        oldNext = oldTail.Next; 
19
20        // providing that the tail field has not changed...
21        if (tail == oldTail) {
22
23          // ...and its Next field is null
24          if (oldNext == null) {
25
26            // ...try to update the tail's Next field
27            UpdatedNewLink = CAS(ref tail.Next, null, node);
28          }
29
30          // if the tail's Next field was non-null, another thread
31          // is in the middle of enqueuing a new node, so try and 
32          // advance the tail to point to its Next node
33          else {
34            CAS(ref tail, oldTail, oldNext);
35          }
36        }
37      }
38
39      // try and update the tail field to point to our node; don't
40      // worry if we can't, another thread will update it for us on
41      // the next call to Enqueue()
42      CAS(ref tail, oldTail, node);
43    }

The first line of interest is line 17. The lines prior to that are merely operating on local variables and consequently have no issues in a multithreaded lock-free environment. But line 17 makes a local copy of the queue's tail reference, which by now should alert you to a possible hazard coming up.

And, lo and behold, in the very next line, line 18, we dereference our copy of the tail reference to get its Next reference. As discussed last time, this is a problem since the reference we have may have been reused in between our copying it and our using it. Hence our oldTail variable is a hazard pointer, and so we must mark it as such.

If you look at the remainder of the method you'll see that neither oldTail or OldNext (the next shared reference we make a copy of) is dereferenced and so we have no other hazard pointers to worry about.

The code changes then are as we've seen before: mark it as a hazard pointer, check to see if it has changed, and if so, go round the loop again:

17        oldTail = tail;         
17a       MagicMemoryManager.MarkAsHazardPointer(oldTail);
17b       if (oldTail != tail)
17c         continue; 
18        oldNext = oldTail.Next; 

Now the Dequeue() method. Here it is in its simplified form:

01    public T Dequeue() {
02      T result = default(T);
03
04      // loop until we manage to advance the head, removing 
05      // a node (if there are no nodes to dequeue, we'll exit
06      // the method instead)
07      bool HaveAdvancedHead = false;
08      while (!HaveAdvancedHead) {
09
10        // make local copies of the head, the tail, and the head's Next 
11        // reference
12        Node<T> oldHead = head;
13        Node<T> oldTail = tail;
14        Node<T> oldHeadNext = oldHead.Next;
15
16        // providing that the head field has not changed...
17        if (oldHead == head) {
18
19          // ...and it is equal to the tail field
20          if (oldHead == oldTail) {
21
22            // ...and the head's Next field is null
23            if (oldHeadNext == null) {
24
25              // ...then there is nothing to dequeue
26              return default(T);
27            }
28
29            // if the head's Next field is non-null and head was equal to the tail
30            // then we have a lagging tail: try and update it
31            CAS(ref tail, oldTail, oldHeadNext);
32          }
33
34          // otherwise the head and tail fields are different
35          else {
36
37            // grab the item to dequeue, and then try to advance the head reference
38            result = oldHeadNext.Item;
39            HaveAdvancedHead = CAS(ref head, oldHead, oldHeadNext);
40          }
41        }
42      }
43      return result;
44    }

The first line of any interest is line 12 where we copy the head reference into a local variable. Line 13 could also be interesting: we're copying the tail reference. Line 14 is a dereference of our local copy of a shared reference, so we can immediately identify oldHead as being a hazard pointer and slot in the (by now) usual code.

12        Node<T> oldHead = head;
12a       MagicMemoryManager.MarkAsHazardPointer(oldHead);
12b       if (oldHead != head)
12c         continue; 
13        Node<T> oldTail = tail;

The next line where there's anything of real importance is line 38 where we deference oldHeadNext to get at the item. Alarm bells should be ringing by now: this is a hazard, and so we should mark oldHeadNext as a hazard pointer and insert the usual code.

14        Node<T> oldHeadNext = oldHead.Next;
14a       MagicMemoryManager.MarkAsHazardPointer(oldHeadNext);
15

Notice though we can make a small optimization: we don't need to check to see if the value of oldHead.Next has changed. Why not? Well, oldHead has already been marked and verified as a hazard pointer. oldHead.Next is therefore guaranteed not to change from its assumed value. Furthermore the very next line checks to see whether head has changed from when we made the copy. If it has, it doesn't matter what value of head.Next we have, we're just going to ignore it as we go round the loop again.

The next thing to notice is that, although we make a copy of the tail reference, we never dereference it. It therefore does not pose a hazard and does not have to be marked as a hazard pointer.

Also, if you think about it, the Dequeue() method needs to maintain two hazard pointers at the same time: there's oldHead and there's oldHeadNext. That means our eventual hazard pointer implementation will have to store at least two hazard pointers per thread, not one.

And with that we'll finish this installment. Next time we'll be looking at the actual implementation in C# of the hazard pointer system.