为什么这段代码会出错
在使用C++11的标准库来实现一个无锁栈的时候,发现设计pop操作的存在一个问题。
template<typename T>
T* lockFreeQueue::popNode()
{
T *oldNode = queueHead.load(); //opt 1
while(oldNode != nullptr&& !queueHead.compare_exchange_weak(oldNode, oldNode->next));
//while(opt 2 && opt 3)
return oldNode;
}
原本以为opt 2到opt 3的过程中,其它线程会把oldNode置空,但是不可能,因为oldNode是queueHead的一个拷贝值,其它线程改变queueHead的值,不会影响到oldNode。
但是oldNode的指向的地址可能会被删除。因为oldNode在被当前线程持有期间(在opt 1操作之后的所有操作),其它线程仍然可以对其地址内容进行操作。
这样会导致线程并发访问冲突的风险。
解决方法
1、风险指针(hazard point)
先提出一个形象的例子来形容风险指针,在一家人计划去旅游的时候,只有在所有人都同意前往的时候才会出发,当有人提出异议的时候,需要进行再次商议的。
在设计一个无锁队列的时候,在pop的时候进行节点的删除同样也是借鉴这种方式。
- 在一个线程进行pop操作的时候,将需要进行CAS操作的节点先放入到风险指针的队列中。
- 当这个线程的CAS操作成功,在对这个节点完成数据转移或者业务处理等业务处理操作之后,需要将这个节点进行删除。
- 在对这个节点进行删除的时候,需要遍历这个风险指针的队列,如果发现风险指针队列中存在这个节点,那么需要将这个节点进行延后删除处理(在下一次pop的时候进行删除)。如果在队列中没有找到存在这个节点则对这个节点直接进行删除。
ps:步骤3中,在风险指针队列中找到这个节点就意味着有其他的线程在对这个节点进行CAS操作,不能将这块内存直接进行释放,否则可能会产生UD行为,,只有没有找到这个节点才意味着可以安全的删除这个节点
实现一个风险指针
template<typename T>
struct node_t
{
T* nodeValue;
node_t<T> *next;
node_t():
nodeValue(nullptr),
next(nullptr)
{
}
};
struct hazardPoint_t
{
std::atomic<std::thread::id> threadID;
std::atomic<void*> hazardStorePoint;
};
const int MAX_HAZARD_POINT_NUMBER = 100; //1
hazardPoint_t hazardPointArray[MAX_HAZARD_POINT_NUMBER]; //2
struct hpThreadOwn
{
hazardPoint_t *thisThreadHp;
hpThreadOwn():
thisThreadHp(nullptr)
{
std::thread::id defaultInitThreadID;
for(int i = 0; i < MAX_HAZARD_POINT_NUMBER; i++)
{
if(hazardPointArray[i].threadID.compare_exchange_strong(defaultInitThreadID, std::this_thread::get_id())) //3
{
thisThreadHp = &hazardPointArray[i];
break;
}
}
if(thisThreadHp == nullptr)
{
std::cout<<"error: cant init this thread hazard point" << std::endl;
}
}
bool findConflictNode(void *catchNode)
{
for(int i = 0; i < MAX_HAZARD_POINT_NUMBER; i++)
{
if(hazardPointArray[i].hazardStorePoint.load() == catchNode) //4
{
return false;
}
}
return true;
}
~hpThreadOwn()
{
thisThreadHp->hazardStorePoint.store(nullptr); //5
thisThreadHp->threadID.store(std::thread::id());
}
};
template<typename T>
class lockFreeStack
{
public:
T* popNode();
void pushNode(T* value);
void deleteWaitQueue(hpThreadOwn *hazardPoint);
void addDeleteQueue(node_t<T> *toDeleteNode);
private:
std::atomic<node_t<T> *> storeStack;
std::list<node_t<T>*> toDeleteQueue;
};
template<typename T>
T* lockFreeStack<T>::popNode()
{
static thread_local hpThreadOwn thisThreadHazardPos; //6
node_t<T> *catchNode;
T *catchValue;
node_t<T> *tempNode;
catchNode = storeStack.load();
do
{
do
{
tempNode = catchNode;
thisThreadHazardPos.thisThreadHp->hazardStorePoint.store((void*)catchNode); //7
catchNode = storeStack.load(); //8
} while(tempNode != catchNode);
} while (catchNode != nullptr && !storeStack.compare_exchange_strong(catchNode, catchNode->next)); //9
if(catchNode != nullptr)
{
catchValue = catchNode->nodeValue;
catchNode->nodeValue = nullptr;
thisThreadHazardPos.thisThreadHp->hazardStorePoint.store(nullptr);
if(thisThreadHazardPos.findConflictNode((void*)catchNode) == false) //10
{
addDeleteQueue(catchNode);
}
else
{
delete catchNode;
}
}
deleteWaitQueue(&thisThreadHazardPos); //11
return catchValue;
}
template<typename T>
void lockFreeStack<T>::deleteWaitQueue(hpThreadOwn *hazardPoint)
{
node_t<T> *waitToDeleteHead = toDeleteQueue.exchange(nullptr); //12
if(waitToDeleteHead != nullptr)
{
node_t<T> *tempDeleteNode = waitToDeleteHead;
while(waitToDeleteHead)
{
if(hazardPoint->findConflictNode(waitToDeleteHead) == false) //13
{
addDeleteQueue(tempDeleteNode);
}
else
{
tempDeleteNode = waitToDeleteHead->next;
delete waitToDeleteHead;
waitToDeleteHead = tempDeleteNode;
}
}
}
}
template<typename T>
void lockFreeStack<T>::addDeleteQueue(node_t<T> *toDeleteNode)
{
node_t<T> *tempNode = toDeleteQueue.load();
while(toDeleteQueue.compare_exchange_weak(tempNode->next, tempNode));
}
代码分析:
- 在备注1中,设置了100个风险指针的数组,当超过100个线程来使用这个栈的pop操作时,将有一部分线程无法初始化这个风险指针,无法保证删除节点的安全性。
- 实现线程持有各自的风险指针的操作。(1)备注6中,使用了 thread_local 关键字来修饰 hpThreadOwn 变量,表示这个变量是储存在线程私有缓存中的,同时会默认带有static语义(在这里写出static关键字,为了更加浅显地展示代码),同时 hpThreadOwn 定义了构造函数和析构函数(备注5),当线程初次使用 popNode 会将初始化和从风险指针数组中占有一个风险指针,当线程退出时,将风险指针还原。 (2)备注3中,通过比较风险指针中的 thread_id 来判断该风险指针是否被其他线程所占有。(3)备注7中,更改该线程的风险指针持有的资源节点,这个需要注意的内容看第三点总结。
- 保证对于同一资源节点而言,将其修改为风险指针持有的节点操作(备注7)与对其进行CAS操作,同时进行CAS操作与对其删除操作(备注9)都为 “ happen before ” 关系,如果出现违背这种关系的情况,则更改节点。即如图1所示。备注8中,是保持这个性质的关键一步。分析在这个程序中会出现UD行为的原因是删除节点的操作刚好发生在进行CAS操作的时候。而究其原因,可以设想到,遍历A节点的风险指针的操作的线程1发生在修改风险指针的线程2之前,同时修改风险指针的线程2后续执行到对A节点CAS操作。但是因为有备注8的操作,所以线程2是不会执行到对A节点进行CAS操作的。
4. 待删除节点队列中的删除操作。在对待删除节点队列waitDeleteQueue待删除的时候,可能很容易想到使用锁来解决临界区的同步访问的问题,但是这样子做违反了设计了无锁栈的初衷,而且使用锁的话无法最大化地提升并发性能。所以在这里的操作是如备注12所示,在删除队列操作中,进行pop操作的线程负责删除整个队列的节点。
使用场景
在需要完全释放节点的情况下,风险指针和引用计数是比较常用的,不过在需要循环的使用节点(比如用于内存池)这些情况下,是不需要是用风险指针和引用计数这种方法的。