In the book C++ Concurrency in Action, the author gave an example of using hazard pointer to implement a lock-free stack data structure. Part of the code is as follows:
std::shared_ptr<T> pop()
{
std::atomic<void*>& hp=get_hazard_pointer_for_current_thread();
node* old_head=head.load();
node* temp;
do
{
temp=old_head;
hp.store(old_head);
old_head=head.load();
} while(old_head!=temp);
// ...
}
The description says that
You have to do this in a
while
loop to ensure that thenode
hasn’t been deleted between the reading of the oldhead
pointer and the setting of the hazard pointer. During this window no other thread knows you’re accessing this particular node. Fortunately, if the oldhead
node is going to be deleted,head
itself must have changed, so you can check this and keep looping until you know that thehead
pointer still has the same value you set your hazard pointer to.
I think the code is flawed because the head
node is subject to the ABA problem. Even if the value of head
remains the same, the node it originally points to may have been deleted. A new head
node is allocated, which happens to have the same address value as the previous one.
The default memory_order
for load()
operations is std::memory_order_seq_cst
, which ensures sequential consistency of all operations (total global ordering):
Each
memory_order_seq_cst
operationB
that loads from atomic variableM
, observes one of the following:
- the result of the last operation
A
that modifiedM
, which appears beforeB
in the single total order- OR, if there was such an
A
,B
may observe the result of some modification onM
that is notmemory_order_seq_cst
and does not happen-beforeA
- OR, if there wasn't such an
A
,B
may observe the result of some unrelated modification ofM
that is notmemory_order_seq_cst
.
So, if the node is modified (deleted) and this happens before second read in the total global order, you are guaranteed to see that change and thus loop will continue to execute. If this modification is ordered after, there is no harm since hazard pointer has been already set.
You have this guarantee, because store to hazard pointer is also done with std::memory_order_seq_cst
. This memory order gives you an acquire operation for loads and release operation for stores, preventing from reordering within the same thread. Thus, "successful" read (old_head==temp
) guarantees that proper data was saved.
Treat these two loads as sync points - since they perform an acquire operation, they are synchronized-with respective release operations that modify those values, causing all writes to become visible.
The issue you described does not flaw the example in any way. pop()
function is meant to remove top element and it will do it. If, in the meantime, element is added/removed, it will pop it, no matter what its address is (it may even be the same the one previously fetched). This is a totally different problem. Consider:
concurrent_stack<int> p;
if (!p.empty() && (p.top() == 5))
{
auto t = p.pop();
assert( t ); // May fail
assert( *t == 5 ); // May fail
}
Both assertions may fail and in case many threads use the stack very intensively, most likely will fail quite often. But this is not due to incorrect implementation of pop()
, but the fact, that you need stronger access restriction to ensure that last checked element is indeed removed from the stack.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With