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.
