本文内容
一、前言
与其他几篇文章一样(基于细粒度锁的高并发队列的超详细实现和分析过程,基于细粒度锁的高并发字典(查找表)的实现和分析过程),本文也是基于《C++ Concurrency In Action 2nd》
的翻译和理解来写作的,并且对其中的代码都在VS2019上进行了实现和测试。
通过本文的分析,读者可以了解到在编写无锁数据结构中的一些细节,以及一个非常重要的技术:如何实现无锁数据结构中的内存回收,这也是原书章节和本文最大头的内容。废话不多说,开始吧!
二、创建无锁栈
1. 实现无锁栈的基本结构
众所周知,栈是一个后进先出(LIFO)的数据结构,栈只需要直接访问栈首元素即可,因此,可以使用最简单的单向链表来实现栈。向栈中加入元素的步骤为:
- 创建一个新的节点;
- 将新节点的next指针指向栈首(head元素);
- 将head指向新节点;
上述过程在单线程中很完美,但是在多线程中就有可能发生数据竞争:线程1将新节点1的next指向head之后,发生线程切换,线程2将自己的节点2的next指向head,并将head指向节点2。此时线程1切换回来继续执行,将新的head指向了节点1。这样的话,由于节点1的next是指向的原来的head节点的,导致节点2无法被访问,因而导致节点2丢失。
因此,由上可知,在并发编程中,需要把第二步和第三步结合起来一起执行,使其成为原子操作。这可以使用compare/exchange 操作来实现:
template<typename T>
class lock_free_stack
{
private:
struct node
{
T data;
node* next;
node(const T& data_): data(data_) {}
};
private:
std::atomic<node*> head;
public:
void push(const T& data)
{
node* new_node = new node(data); // 1
new_node->next = head.load(); // 2
while(!head.compare_exchange_weak(new_node->next, new_node)); // 3
}
};
第二步和第三步直接通过表达式3就能完成,而且是原子操作。注意,此处用的是weak
形式,这个在弱CAS平台上会有一定的效率提升。
现在我们再看看如何pop一个元素:
- 读取当前head节点;
- 读取head->next;
- 将head指向head->next;
- 将已经取下来的旧的head节点中的值返回;
- 删除旧的head节点。
在单线程下没问题,但是在多线程下就会出现问题:比如线程1执行第一步后就被挂起了,然后线程2执行了上面所有的步骤,因此线程2删除了之前的head节点。现在线程1回来继续执行的话,执行第三步的时候由于要读取next值,就会出错,因为head已经被删除了,它已经是一个悬垂指针了。还有一个问题是,如果两个线程都读取了同一个head中的值,那么就违反了栈结构的意图。同样,这里我们也可以用compare/exchange操作来更新head:
template<typename T>
class lock_free_stack
{
public:
void pop(T& result)
{
node* old_head = head.load(); // 1
while(!head.compare_exchange_weak(old_head, old_head->next)); // 2
result = old_head->data; // 3
}
};
这样,当线程1执行操作1后被挂起,线程2改变了head后,线程1回来执行操作2的时候就会检测到,并重新更新old_head,这样old_head就能确保是正确的。
不过,上面的代码仍然有问题,首先是它不能处理空stack的情况,如果stack为空,执行第二步的时候,在将形参传入函数中之前,会解引用head从而得到head->next。但是由于此时head是nullptr,因此就会出错;其次,就是异常安全的问题。如果在第三步拷贝data的时候发生异常,则此时数据已经不在stack中了,就会导致数据丢失;其实还有个问题就是内存泄漏,可以看到上面代码中并没有把old_head给delete掉。不过,书中是在后面才说明并解决的。第一个问题很好解决,在while循环的开头加一个old_head指针的检测。第二个问题只能用指针来解决,在多线程中,你不可能在节点还未摘除的情况下就获得值(这样获得也会因为条件竞争而发生错误),只能先摘除头节点,然后再从摘除后的头结点中获取值,所以最好使用智能指针,因为智能指针在拷贝或移动过程中不会发生异常:
template<typename T>
class lock_free_stack
{
private:
struct node
{
std::shared_ptr<T> data;
node* next;
node(const T& data_) : data(std::make_shared<T>(data_)), next(nullptr) {}
};
private:
std::atomic<node*> head;
public:
void push(const T& data)
{
node* new_node = new node(data);
new_node->next = head.load();
while (!head.compare_exchange_weak(new_node->next, new_node));
}
std::shared_ptr<T> pop()
{
node* old_head = head.load(); // 1
while (old_head && !head.compare_exchange_weak(old_head, old_head->next)); // 2
return old_head ? old_head->data : nullptr; // 3
}
};
这样,在pop()
函数中就可以直接将已经摘除的头结点中的数据返回到函数外面了,因为返回过程中不会产生T类型数据的拷贝或者移动,而返回std::shared_ptr<>
并不会抛出异常,因此此函数就变成异常安全的了。
注意,此数据结构虽然是lock-free
的,但是不一定是wait-free
的(lock-free
只需要满足多个线程中存在线程能够很快执行完成即可,而wait-free
需要所有线程都不发生等待,都很快完成。如果某个线程一直发生饥饿的话,那么就可能一直等待),因为上述两个函数中的compare_exchange_weak()
可能会因为活锁(属于一种特殊的饥饿)而永远不会成功,导致不停地循环。
2. 无锁数据结构中的内存释放问题
上述代码接近完美了,然而还有最后一个问题没有解决,这个问题是无锁编程中非常难以优雅解决的问题,就是如何管理内存释放,这是本文后面的重头戏。上述代码中从old_head
获取数据后,按理说应该要主动使用delete释放这个old_head节点。然而,此处如果直接释放,就可能存在问题。给一个看似可行但是会发生条件竞争的例子:
std::shared_ptr<T> pop()
{
node* old_head = head.load(); // 1
while (old_head && !head.compare_exchange_weak(old_head, old_head->next)); // 2
std::shared_ptr<T> res = old_head ? old_head->data : nullptr; // 3
delete old_head;
return res;
}
上面的代码能够防止内存泄漏,但是会出问题,因为程序无法得知old_head所指向的对象有没有被其他线程访问,如果直接在下面销毁内存,此时如果其他线程仍然持有指向此对象的指针,那么就会出现悬垂指针。其实悬垂指针一般情况下无大碍,只要你不去解引用即可。然而,此处的compare_exchange_weak()
函数正好解引用了old_head
。在执行compare_exchange_weak()
前,代码会先将old_head
解引用并拿取next指针作为compare_exchange_weak()
函数的第二个实参。注意,解引用的时候,compare_exchange_weak()
并未开始执行。此时,假设线程a执行到1处,随后被挂起,然后线程b开始执行,也拿取了head
,然后执行while语句并通过,最后返回了正确的值,然后删除了head
。最后,线程切换回来,线程a开始执行while循环。然而,此时线程a中old_head对应的内存已经被线程b给delete掉了。虽然线程a的old_head存储的地址还在,不为nullptr,但是其地址对应的内容已经没了,属于悬垂指针。此时,执行while循环,首先判断old_head
不为空,因此继续执行compare_exchange_weak()
函数。执行前,先将old_head
作为第一个实参传入,这个没问题。接下来对old_head
解引用,得到next
指针,并传给第二个参数,这里就出问题了,因而就会报出异常,一般会报出地址访问冲突的异常。
(1). 使用线程个数计数法来管理内存释放问题
那什么时候才能删除这个old_head
呢??必须是当只有一个线程引用这个对象时,才能释放它,如果有多个线程都拥有此对象,则不能释放,要延迟释放。由于push()
函数在将节点加入到栈中之前,始终只有一个线程拥有新建的节点,因此push()
函数无需处理,很安全。对于pop()
函数而言,某个线程在执行完while循环之前,另一个线程也进入了pop()
函数,那么就会出现同一个head
被两个线程共享的情况。一旦线程执行完while循环后,其他线程再进入pop()
函数,就安全了。因为一旦执行完成while之后,栈的head
节点就变了,旧的old_head
就已经脱离栈了,old_head
只被当前线程所拥有,其他线程都拿不到当前线程的old_head
了,其他线程的old_head
已经是其他的对象了(被当前线程的compare_exchange_weak()
函数给改变了)。此时当前线程就可以随意处理本线程的old_head
了,这个时候释放old_head
就百分百安全了。
那怎么才能安全删除呢?
有种方法是将这些摘除的节点存放到一个待删除列表里面,当只有一个线程执行pop()
函数时,一次性将这些待删除的节点一起删除。如何才能知道当前只有一个线程在执行pop()
函数呢?当然使用计数方法啦!在pop()
函数的入口处对一个全局原子变量进行递增,在离开时递减即可。我们先看看这种方案的一种实现:
template <typename T>
class lock_free_stack
{
private:
struct node
{
std::shared_ptr<T> data;
node* next;
node(const T& value) : data(std::make_shared<T>(value)), next(nullptr) {}
};
private:
std::atomic<node*> head;
std::atomic<unsigned> threads_in_pop;
std::atomic<node*> to_be_deleted;
private:
static void delete_nodes(node* nodes)
{
while (nodes)
{
node* next = nodes->next;
delete nodes;
nodes = next;
}
}
void chain_pending_nodes(node* first, node* last)
{
// 将last节点接到待删除链表的最前面,这里其实是假设first和last是另外一个链表,
// 然后这里是把另外一个链表插入到已有链表的前面。
// 注意,有可能有多个线程在同时执行chain_pending_nodes,所以下面用到了原子操作
// 这两句语句很通用呀,并发插入节点的操作,非常好!!
last->next = to_be_deleted; // 9
while (!to_be_deleted.compare_exchange_weak(last->next, first)); // 10
}
void chain_pending_node(node* n)
{
// 当只插入一个节点时,last与first是同一个节点
chain_pending_nodes(n, n);
}
void chain_pending_nodes(node* nodes)
{
// 找出被剥离出来的待删除链表的头和尾,然后再插回到to_be_deleted链表里
node* last = nodes;
while (node* next = last->next) // 8
{
last = next;
}
chain_pending_nodes(nodes, last);
}
void try_reclaim(node* old_head)
{
// 如果pop函数中执行完while循环后threads_in_pop还是1,表示pop中在执行while循环前一定没有其他线程进入pop,
// 表示当前线程的这个old_head一定只被一个线程共享,即使后面threads_in_pop不再为1
if (threads_in_pop == 1)
{
node* nodes_to_delete = to_be_deleted.exchange(nullptr); // 4
if (!--threads_in_pop) // 5
{
// 如果执行到为nodes_to_delete初始化后还是1,则此时就可以删除之前所有的待删除节点,因为那些待删除节点
// 一定没有被其他线程共享了
delete_nodes(nodes_to_delete);
}
else if (nodes_to_delete) // 6
{
// 如果在为nodes_to_delete初始化后threads_in_pop已经不再是1,表示本线程在执行try_reclaim期间,其他
// 线程也进入了pop函数,此时,如果另一个线程还未执行到try_reclaim函数,则本线程其实可以直接结束并删除
// old_head节点的。但是这里是为了防止另外一种情况发生,就是另一个线程可能也已经执行到了try_reclaim函数,
// 并发现threads_in_pop不为1(注意,上面的--threads_in_pop非常的细,这样做的话,如果另外只有一个线程也
// 同时进入了try_reclaim函数,则threads_in_pop还是为1,则另一个线程也可以直接删除它自己的old_head,跟
// 本线程无关),说明除了本线程已经执行到了这里外,还有另外至少2个线程也在同时执行,然后另一个线程就会执行
// 下面的else语句,并将线程b的old_head存储到待删除链表中。那么,此时,我们在线程a中还不能删除那些在待删除链表
// 中的节点,又由于上面我们已经把部分待删除节点与to_be_deleted脱钩了,而现在暂时又不能删除nodes_to_delete,
// 所以这里还得把这些已经脱钩了的待删除节点给接回去才行,等下次机会合适的时候再一起删除。
chain_pending_nodes(nodes_to_delete);
}
// 所以这里就可以直接delete掉了
delete old_head;
}
else
{
// 如果threads_in_pop不为1,说明有其他线程也在执行pop,由于不清楚其他线程是在本线程执行完pop中的while语句之后
// 开始执行的还是在执行while语句之前就已经开始执行的,因此无法确定本线程的old_head是否已经被其他线程所共享,所以
// 这里统一认为是被共享的,因此就将此old_head节点存储到待删除链表里,等待时机全部删除。
chain_pending_node(old_head); // 7
--threads_in_pop;
}
}
public:
void push(const T& new_value)
{
node* new_node = new node(new_value);
new_node->next = head;
while (!head.compare_exchange_weak(new_node->next, new_node));
}
std::shared_ptr<T> pop()
{
++threads_in_pop; // 1
node* old_node = head.load();
while (old_node && !head.compare_exchange_weak(old_node, old_node->next));
std::shared_ptr<T> res;
if (old_node)
{
res.swap(old_node->data); // 2
try_reclaim(old_node); // 3
}
return res;
}
};
首先,我们看pop()
里面的改动,现在在函数入口处加了一个threads_in_pop
,然后代码2和3处也有改动。注意2处用的是swap()
,比直接用赋值语句好,因为放入到待删除列表中的节点已经不需要获取其data
数据了(其实其next
数据也可以不需要了,因为虽然其他线程可能会将next
作为第二个实参传给compare_exchange_weak
,但是肯定会执行失败,从而就会将old_head
重新更新为新的head
。因而再次循环时next
数据就正常了。为啥肯定会执行失败呢,因为此节点已经属于待删除节点了,说明已经有一个线程执行完了while循环并更新了head节点,然后才会将old_head
放入到待删除链表里,所以head
一定已经被更新过了的)。所以可以直接将待删除节点中的数据提取出来,不需要对其加一个引用了。然后再看3处,这里直接将待删除节点传给try_reclaim()
函数尝试回收内存。注意,书上这里写错了,书上把try_reclaim()
放到了if条件的外面,这样如果栈为空,就会出问题。
在try_reclaim()
中(代码里面注解可以多看看,有助于理解),首先如果检测到threads_in_pop
为1,表示当前线程在执行完pop()
中的while循环后仍然没有其他线程进入pop()
,说明当前线程的old_head肯定只是被当前的线程所拥有,因此可以放心的删除(在此if分支的最下面)。此外,由于当前只有一个线程进入pop()
,所以除了一定可以删除本线程的old_head
外,还可以尝试删除待删除列表中的其他待删除节点,因为那些节点一定已经没有其他线程共享了。代码4处的处理逻辑非常的好,尤其要记住。因为是多线程,所以这里会先将待删除列表给移出来,这样如果发生线程切换,其他线程也在处理待删除列表,那么此处已经移出来的列表就不会对公共的待删除列表产生影响。一旦将待删除列表通过代码4处的方式移出来,当前线程就可以随意的处置这个待删除列表了,而不会对公共的待删除列表有任何影响(这个技巧很有用,以后还会遇到)。此处为啥代码5处还需要判断一下呢?因为在执行4之前,有可能其他线程将一个新的待删除节点存入到了待删除列表中,导致执行5时threads_in_pop
不一定仍然为1(即使此时不为1了,仍然不影响当前线程的old_head
,因为如果此时threads_in_pop
不为1,说明当前线程已经执行完pop()
中while循环后,其他线程才开始进入pop()
函数的,那么当前线程的old_head
节点仍然只被当前线程所拥有,因此仍然可以安全删除它)。也就是说当前待删除列表里面新进入的待删除节点有可能是被多个线程共享的。如果5处判断出threads_in_pop
仍为1,表示刚刚分离出来的待删除列表中的所有待删除节点都不再被其他线程拥有,则可以将他们全部安全删除。如果此处判断threads_in_pop
不再为1了,说明刚刚分离出来的待删除列表中有新的节点,因此这里不能直接删除,所以还得把这个待删除列表给接回到公共的待删除列表里面。最后我们看代码7处,如果一进来就发现threads_in_pop
不为1,表明当前的old_head
可能被其他线程共享,因此需要将当前线程的old_head
节点加入的公共的待删除列表里面。
最后我们看看chain_pending_nodes()
是如何将一个待删除列表加入到公共的待删除列表里的(单个节点属于容量为1的链表)。首先在代码8处找到已经分离出来的待删除列表的表头和表尾,然后通过代码9将公共待删除列表的表头接到分离出来的待删除列表的表尾,最后通过代码10,使用原子操作(这里就类似于pop()
或push()
函数中的对应操作,所以这些操作都是模式化的,非常有用),将公共待删除列表的表头指向已经分离的待删除列表的表头,这样就把已经分离的待删除列表插入到了公共待删除列表的前面。
总结来说,这个方法就是将待删除节点先暂存起来,等到只有一个线程执行pop()
时,再将这个待删除节点一起删除掉。
上面的代码在硬件负载不高的情况下效果不错。但是如果在高负载情况下,比如CPU使用率满了,且线程个数远大于CPU核心数,那么上述方法的效率就大打折扣。从上面代码可看出,只有当try_reclaim()
中的代码5处的条件满足时,才能把公共待删除列表中的待删除节点给删除掉,如果此时有其他线程进入pop()
,则就无法删除,很可能会导致公共待删除列表变得非常庞大。当负载很高时,有非常大的概率使得同时会有多个线程进入pop()
,因而在高负载下有相当多的内存仍然无法及时释放。
其实上述方法还有一个潜在的问题,就是如果最后两个执行pop()
的线程同时进入了try_reclaim()
,并且它俩都发现threads_in_pop
不为1,因此两个线程都会把各自的待删除节点加入到公共待删除列表里面。假如这两个线程执行完后,就再也没有其他线程来执行pop()
了,那么公共待删除列表中的那些待删除节点就永远留在内存中而得不到释放,这也是一个巨大的问题。经过我在电脑上的实测,在高负载情况下很容易导致这种内存泄漏问题(特别是当多个线程pop
的同时,还有其他线程在push
的时候),经常会检测到大量的待删除节点并未被删除,一直留到程序结束,从而导致大量内存占用。
(2). 使用 hazard-point 来管理内存释放问题
hazard-point是一种技术,它的大概原理是:如果一个线程想访问一个可能会被其他线程给释放的内存对象,则此线程会首先将一个hazard-point指向这个对象,这样就会告诉其他线程,我正在使用这个对象,你要是此时删除这个对象的话,是很危险的(is hazardous)。一旦这个对象用完了,就清除hazard-point,这样,其他线程就可以愉快的删除这个对象了。
这个技术就是将一个对象的指针与当前线程的ID关联,然后将“指针-ID”对存储到一个公共区域,其他线程想要删除这个对象之前,需要去此公共区域查找这个对象有没有在公共区域与其他线程关联。
首先,需要准备一个所有线程可见的全局数组,数组的每个元素都会存储线程ID号以及一个对象的指针。当某个线程需要访问某个对象时,首先会把这个对象的地址和这个线程的ID存储到全局数组中。当另一个线程也访问相同的对象并尝试删除此对象时,会先去全局数组里面查找有没有其他线程存储这个对象的指针,如果有,则将待删除对象放到待删除列表中稍后删除,如果没有,则可以直接删除此对象。
我们先看看pop()
函数中可能的实现:
std::shared_ptr<T> pop()
{
std::atomic<void*>& hp = get_hazard_pointer_for_current_thread(); // 1
node* old_head = head.load();
do // 2
{
node* temp;
do // 3
{
temp = old_head;
hp.store(old_head);
old_head = head.load();
} while (old_head != temp);
} while (old_head && !head.compare_exchange_strong(old_head, old_head->next));
// 执行完上面的while循环后,hazard-point就不需要了,就可以删除了,因为old_head已经安全了
hp.store(nullptr); // 4
std::shared_ptr<T> res = nullptr;
if (old_head)
{
res.swap(old_head->data);
if (outstanding_hazard_pointers_for(old_head)) // 5
reclaim_later(old_head);
else
delete old_head;
// 遍历其他节点,将那些已经没有被hazard-point引用的节点都删除
delete_nodes_with_no_hazards(); // 6
}
return res;
}
首先,通过代码1的函数得到数组hazard-point数组中的一个位置,且在获取的时候,就已经将当前线程ID存储到了hp
对应的位置中。然后我们先看一下内存do-while循环。3处的while循环,目的是将old_head
指针存储到hp
里面,为啥这里要用循环来存储呢?是因为有可能在执行hp.store()
的时候,栈的head
已经被其他线程给修改了,导致此处的old_head
已经与栈中的head
不一致了。内部while循环可以确保存储到hp
中的指针就是当前的head
中的指针。而外部while循环也很重要,因为compare_exchange_strong()
有可能失败,如果失败,则old_head
会被重置,此时应该也要重置hazard-point。如果不重置hazard-point,则当前hp
存储的是以前旧的head
,当前新的head
被赋值给old_head
后并没有加入到hazard-point数组中,导致其他线程可以对当前的head
执行删除操作。如果其他线程删除head
后,本线程才执行compare_exchange_strong()
,则又会因为第二个实参解引用已经删除的对象而出错。所以,此处的两个while循环都非常重要,必不可少。另外,这里使用的是strong
版本的原子操作,因为此while循环体中有大量的运算,所以为了避免weak
版本因为伪失败而导致的多次循环,这里直接用strong
版本的会有不错的性能提升。
接下来看代码4处,由于已经执行过compare_exchange_strong()
了,此处的old_head
已经可以从hazard-point数组中移除了,因为此时要么其他线程也拥有此old_head
且也将其加入到了hazard-point数组中,要么其他线程没有引用此old_head
。如果其他线程也拥有此old_head
,则由于其也加入到了hazard-point数组中,所以本线程可以安全移出hazard-point数组,反正另一个线程的此old_head
也在hazard-point数组中,所以其仍然是安全的。如果其他线程没有引用此old_head
,那么本线程将此old_head
从hazard-point数组中移除就更安全了,而且还可以直接删除old_head
。
现在看代码5,此处的outstanding_hazard_pointers_for()
函数是去全局hazard-point数组中查找有没有其他线程关联了这个old_head
指针。其实由于在4处已经将本线程关联的old_head
指针从hazard-point数组中删除了,因此此处只需要遍历hazard-point数组,查看有没有指针的值等于old_head
就可以了,不需要对其线程ID做任何操作。
最后,代码6处的函数是为了删除待删除列表中那些已经不在hazard-point数组中的节点。其会遍历待删除列表的每个元素,然后通过outstanding_hazard_pointers_for()
来判断元素是否在hazard-point数组中,如果不在,则直接删除,如果在,则继续遍历。
现在,我们看看如何实现hazard-point数组和待删除列表。书上为了将其通用化,也就是为了让hazard-point数组和待删除列表不仅可以用于此处,还可以用于其他数据结构,因此写的比较复杂,写成了模板。这里完全按照书上来弄的:
/********************* hazard point **********************/
const unsigned max_hazard_points = 100;
struct hazard_pointer
{
std::atomic<std::thread::id> id;
std::atomic<void*> pointer;
};
hazard_pointer hazard_pointers[max_hazard_points];
class hp_owner
{
private:
hazard_pointer* hp;
public:
hp_owner(const hp_owner&) = delete;
hp_owner& operator=(const hp_owner&) = delete;
hp_owner() : hp(nullptr)
{
for (unsigned i = 0; i < max_hazard_points; ++i)
{
std::thread::id old_id;
if (hazard_pointers[i].id.compare_exchange_strong(old_id, std::this_thread::get_id()))
{
hp = &hazard_pointers[i];
break;
}
}
if (!hp)
{
throw std::runtime_error("No hazard pointers available");
}
}
std::atomic<void*>& get_pointer()
{
return hp->pointer;
}
~hp_owner()
{
// 一定要注意释放的顺序,必须是先释放pointer,再释放id
hp->pointer.store(nullptr);
hp->id.store(std::thread::id());
}
};
std::atomic<void*>& get_hazard_pointer_for_current_thread()
{
// 在hazard初始化时就已经为这个线程分配了一个槽,然后此函数返回这个槽中的指针,
// 外部通过对此指针赋值,将这个槽填充完整。
// 注意这里使用线程局部存储技术,使用static修饰,可以避免同一个线程中多次构造hp_owner对象
static thread_local hp_owner hazard;
return hazard.get_pointer();
}
bool outstanding_hazard_pointers_for(void* p)
{
for (unsigned i = 0; i < max_hazard_points; ++i)
{
if (hazard_pointers[i].pointer.load() == p)
return true;
}
return false;
}
/********************* reclaim resources **********************/
/* 创建一个可重复使用的结构,用于存储待删除节点,并能够将不被hazard point引用的节点删除 */
// 删除函数,用户可以自定义自己的删除函数
template<typename T>
void do_delete(void* p)
{
delete static_cast<T*>(p);
}
struct data_to_recliam
{
void* data;
std::function<void(void*)> deleter; // 用于删除节点的可调用对象
data_to_recliam* next;
template<typename T>
data_to_recliam(T* p) : data(p), deleter(&do_delete<T>), next(nullptr) {}
~data_to_recliam()
{
// 通过析构函数来删除待删除的节点
deleter(data);
}
};
std::atomic<data_to_recliam*> nodes_to_reclaim;
void add_to_reclaim_list(data_to_recliam* node)
{
node->next = nodes_to_reclaim.load();
while (!nodes_to_reclaim.compare_exchange_weak(node->next, node));
}
template<typename T>
void reclaim_later(T* data)
{
add_to_reclaim_list(new data_to_recliam(data));
}
void delete_nodes_with_no_hazards()
{
// 从待删除链表里移出来,这样就可以肆无忌惮的处理这个链表了
data_to_recliam* current = nodes_to_reclaim.exchange(nullptr);
while (current)
{
data_to_recliam* next = current->next;
if (!outstanding_hazard_pointers_for(current->data))
delete current; // 将待删除链表本身的节点删除,然后其通过析构函数就能删除待删除的对象了
else
add_to_reclaim_list(current); // 如果仍然有其他线程在引用这个待删除对象,则要重新加入到公共待删除链表里
current = next;
}
}
首先,创建一个结构体,用于将线程ID与一个指针关联。由于不知道指针指向的对象具体是什么类型,也不需要知道其是什么类型,因此指针使用void*
。接着,定义一个全局数组,数组的大小一般来说是线程个数。然后,就创建一个管理hazard-point数组的类hp_owner
,通过这个类的对象来管理hazard-point数组中指针的赋值以及线程ID的赋值,并通过析构函数来恢复hazard-point数组的元素,恢复为初始状态。注意到hp_owner
的构造函数,当创建一个hp_owner
对象时,就会从全局数组中找到一个未被使用的位置,并将当前线程ID存入进去,然后通过接口get_pointer()
,可以在外部来设置对应的指针。另外要注意析构函数,这里的执行顺序不能反过来,否则有可能与构造函数中通过查找线程ID是否为空来赋值的情况相冲突。最后还要注意get_hazard_pointer_for_current_thread()
函数中使用的是线程局部存储技术(thread_local)。这样,一个线程只需要一个hp_owner
对象,线程中多次调用此函数都会对同一个hp_owner
对象进行操作,而不同线程中hp_owner
对象各不相同。
接下来我们看一下待删除列表的实现以及如何回收内存。首先,我们得把不能删除的数据存储到一个链表里面,链表的每个节点被定义为data_to_recliam
类的对象。注意,此处有个用法,就是将模板放在了构造函数处,而不是类定义的开头处。这种用法挺好的,这样这个类在使用时就不需要传入模板参数了,而只需要在创建对象的时候加上模板参数就可以了(比如上述代码的所有data_to_recliam
指针处都没有加模板参数)。注意,data_to_recliam
里面还定义了一个删除器,可以指定删除数据的方式,这个特别好。当待删除列表的一个节点data_to_recliam
被删除时,就会调用指定的删除器来删除对应的对象。最后看一下delete_nodes_with_no_hazards()
接口,里面用到了上面用到过的技术,先把待删除列表从公共待删除列表里移出来,然后就可以随意处置了。如果某个节点不能删除,则再将那个节点给接回到公共到删除列表里面即可。
最后看一下使用hazard-point技术实现的完整的无锁stack代码:
/********************* hazard point **********************/
const unsigned max_hazard_points = 100;
struct hazard_pointer
{
std::atomic<std::thread::id> id;
std::atomic<void*> pointer;
};
hazard_pointer hazard_pointers[max_hazard_points];
class hp_owner
{
private:
hazard_pointer* hp;
public:
hp_owner(const hp_owner&) = delete;
hp_owner& operator=(const hp_owner&) = delete;
hp_owner() : hp(nullptr)
{
for (unsigned i = 0; i < max_hazard_points; ++i)
{
std::thread::id old_id;
if (hazard_pointers[i].id.compare_exchange_strong(old_id, std::this_thread::get_id()))
{
hp = &hazard_pointers[i];
break;
}
}
if (!hp)
{
throw std::runtime_error("No hazard pointers available");
}
}
std::atomic<void*>& get_pointer()
{
return hp->pointer;
}
~hp_owner()
{
// 一定要注意释放的顺序,必须是先释放pointer,再释放id
hp->pointer.store(nullptr);
hp->id.store(std::thread::id());
}
};
std::atomic<void*>& get_hazard_pointer_for_current_thread()
{
// 在hazard初始化时就已经为这个线程分配了一个槽,然后此函数返回这个槽中的指针,
// 外部通过对此指针赋值,将这个槽填充完整。
// 注意这里使用线程局部存储技术,使用static修饰,可以避免同一个线程中多次构造hp_owner对象
static thread_local hp_owner hazard;
return hazard.get_pointer();
}
bool outstanding_hazard_pointers_for(void* p)
{
for (unsigned i = 0; i < max_hazard_points; ++i)
{
if (hazard_pointers[i].pointer.load() == p)
return true;
}
return false;
}
/********************* reclaim resources **********************/
/* 创建一个可重复使用的结构,用于存储待删除节点,并能够将不被hazard point引用的节点删除 */
// 删除函数,用户可以自定义自己的删除函数
template<typename T>
void do_delete(void* p)
{
delete static_cast<T*>(p);
}
struct data_to_recliam
{
void* data;
std::function<void(void*)> deleter; // 用于删除节点的可调用对象
data_to_recliam* next;
template<typename T>
data_to_recliam(T* p) : data(p), deleter(&do_delete<T>), next(nullptr) {}
~data_to_recliam()
{
// 通过析构函数来删除待删除的节点
deleter(data);
}
};
std::atomic<data_to_recliam*> nodes_to_reclaim;
void add_to_reclaim_list(data_to_recliam* node)
{
node->next = nodes_to_reclaim.load();
while (!nodes_to_reclaim.compare_exchange_weak(node->next, node));
}
template<typename T>
void reclaim_later(T* data)
{
add_to_reclaim_list(new data_to_recliam(data));
}
void delete_nodes_with_no_hazards()
{
// 从待删除链表里移出来,这样就可以肆无忌惮的处理这个链表了
data_to_recliam* current = nodes_to_reclaim.exchange(nullptr);
while (current)
{
data_to_recliam* next = current->next;
if (!outstanding_hazard_pointers_for(current->data))
delete current; // 将待删除链表本身的节点删除,然后其通过析构函数就能删除待删除的对象了
else
add_to_reclaim_list(current); // 如果仍然有其他线程在引用这个待删除对象,则要重新加入到公共待删除链表里
current = next;
}
}
/********************* lock-free stack **********************/
template <typename T>
class lock_free_stack
{
private:
struct node
{
std::shared_ptr<T> data;
node* next;
node(const T& value) : data(std::make_shared<T>(value)), next(nullptr) {}
};
private:
std::atomic<node*> head;
public:
void push(const T& new_value)
{
node* new_node = new node(new_value);
new_node->next = head;
while (!head.compare_exchange_weak(new_node->next, new_node));
}
std::shared_ptr<T> pop()
{
std::atomic<void*>& hp = get_hazard_pointer_for_current_thread();
node* old_head = head.load();
do
{
node* temp;
do
{
temp = old_head;
hp.store(old_head);
old_head = head.load();
} while (old_head != temp);
} while (old_head && !head.compare_exchange_strong(old_head, old_head->next));
// 执行完上面的while循环后,hazard-point就不需要了,就可以删除了,因为old_head已经安全了
hp.store(nullptr);
std::shared_ptr<T> res = nullptr;
if (old_head)
{
res.swap(old_head->data);
if (outstanding_hazard_pointers_for(old_head))
reclaim_later(old_head);
else
delete old_head;
// 遍历其他节点,将那些已经没有被hazard-point引用的节点都删除
delete_nodes_with_no_hazards();
}
return res;
}
上述的hazard-point方法解决了一个在计数方法中的问题,计数法中存在一种可能,待删除列表中仍然留有待删除节点时,程序后面再也没有调用pop()
函数了,导致一些无用的节点一直留在待删除列表中,也属于一种内存泄漏。hazard-point方法中虽然也是在pop()
的结束位置开始删除待删除列表中的数据,但是此处肯定能保证最后一个调用pop()
函数的一定能把待删除列表中所有的节点给删除,因为最后一个执行delete_nodes_with_no_hazards()
的线程如果在删除过程中发现有节点还被其他线程引用,那么引用这个节点的线程一定还未执行到hp.store(nullptr);
,而当这个线程也执行了delete_nodes_with_no_hazards();
时,就没有其他线程了,他就可以删除所有节点了。
上述的hazard-point的实现并不是效率最高的实现,按书上的意思是,这里通过介绍一种简单的hazard-point的实现,来理解其中的思想。上述实现中涉及到遍历大量的原子变量,由于原子变量的操作比非原子变量慢很多很多(书上说能慢100多倍),因此其效率其实不高。我自己在笔记本电脑上对比上述的hazard-point技术和上面的计数方法,发现计数方法要比上述的hazard-point方法快了好几倍(在Debug模式下这两种方法差距较大,在Release模式下差距会小一点,且并发数目越高,差距越小)。
书上给出了几种可能优化hazard-point的方法,比如用空间换取性能。不需要每次进入pop()
函数都遍历待删除列表,可以等到当有2 * max_hazard_pointers
个节点在待删除列表里时才进行一次清除动作,这样,就能保证一次至少能够清除掉max_hazard_pointers
个节点,因此后面再经过max_hazard_pointers
次调用才需要再次清除链表里的内容。这样就极大的减少了遍历次数,可能会好很多。不过,即使这样,由于你需要使用原子变量来给待删除列表计数,查看有多少个节点在待删除列表里面,因此,每个线程还是需要竞争访问待删除列表。效率还是不够高(注意,这种方式已经会产生比较大的内存占用了)。
如果你的内存还有很多,那么可以更激进一点,使用线程局部存储技术,即使用thread_local
来为每个线程单独创建一个待删除列表,这样每个线程只需要访问自己的待删除列表,这样就不会产生多个线程竞争访问同一个待删除列表的问题了。如果某个线程要退出了,而其待删除列表里面的数据还没有完全删除,则可以将这个线程的待删除列表迁移到其他线程中待删除列表上面。不过这种情况下,内存占用会更多。
hazard-point技术的另外一个缺点是,这个技术是有专利的(由IBM申请的专利),因此在某些情况下不太容易使用。不仅是这个技术,很多关于内存回收的无锁实现的技术都是有专利的。书上作者说之所以上面还介绍这种hazard-point方法,是为了让我们了解其工作原理,拓展我们的思维和想法。
那么,有没有效率尚可且无专利壁垒的无锁内存回收技术呢?当然有,比如引用计数方法。
(3). 使用节点引用计数方法来实现内存安全回收
使用智能指针来实现内存自动回收
其实我们应该首先想到的就是使用智能指针shared_ptr<>
技术了,当删除一个shared_ptr<>
节点时,不需要去考虑内存释放问题,等无其他指针引用这个节点,它自己就会自动删除。不过,由于智能指针上的原子操作基本上都是有锁的,并不是lock-free的,因此性能会很低。如果在某种硬件平台上使得std::atomic_is_lock_free(&some_shared_ptr)
的返回值为true
,那就完美了(即智能指针的原子操作都是无锁的),既能够完美的解决内存回收的问题,效率也会非常高。我们看看使用智能指针技术的代码是什么样子的:
template<typename T>
class lock_free_stack
{
private:
struct node
{
std::shared_ptr<T> data;
std::shared_ptr<node> next;
node(const T& data_) : data(std::make_shared<T>(data_)) {}
};
private:
std::shared_ptr<node> head;
public:
void push(const T& data)
{
std::shared_ptr<node> const new_node = std::make_shared<node>(data);
new_node->next = std::atomic_load(&head);
while (!std::atomic_compare_exchange_weak(&head, &new_node->next, new_node));
}
std::shared_ptr<T> pop()
{
std::shared_ptr<node> old_head = std::atomic_load(&head);
while (old_head && !std::atomic_compare_exchange_weak(&head, &old_head, std::atomic_load(&old_head->next))); // 1 这里使用了atomic_load
if (old_head)
{
std::atomic_store(&old_head->next, std::shared_ptr<node>(nullptr)); // 2 注意这行操作
return old_head->data;
}
return nullptr;
}
~lock_free_stack()
{
while (pop());
}
}
上面有两个注意点。首先,因为使用的是std::shared_ptr<>
并且还要对其进行原子操作,因此,相关原子操作函数全部使用的是全局重载的原子操作函数。另外,在代码1处,注意这里的第三个实参是用的std::atomic_load()
来获取next
指针,这里不能直接传入next
,必须使用std::atomic_load()
转化一下。也就是说只要某个智能指针使用std::atomic_store()
进行了存储,那么虽然存储的形式上仍然还是std::shared_ptr<>
,但是如果你要真正使用其中的指针时,必须通过std::atomic_load()
转回为正常的std::shared_ptr<>
对象。C++20以后估计会有专门的智能指针类型的原子变量,到时候就没有这么麻烦了。
第二点是代码2,这里必须要把next
指针给解除关联,不然极有可能会导致栈展开过深,导致程序崩溃。举个例子,比如线程a开始执行pop()
,其将第一个节点取出并给了线程a的old_head
,然而此时线程a挂起,然后线程b开始取出第二个节点并删除。但是由于第一个节点的next
指针仍然指向第二个节点,所以第二个节点并不会真正的删除,只是将引用计数减去1。假如此时线程a仍然处于挂起状态,线程b又开始取出第三个节点并删除,但是由于第二个节点的next
指针仍然指向第三个节点,所以第三个节点也不会被真正的删除…假如现在到了第1000个节点,线程b将第1000个节点取出,并删除,当然由前所述,第1000个节点也不会真正删除。此时,如果线程a切换回来了,开始执行,则线程a肯定会将第一个节点给删除。删除的时候,发现第一个节点的引用计数只有1,因此会将第一个节点给析构掉。析构前肯定会将类中的所有数据成员也析构掉,此时它发现对象中有一个next
的智能指针对象,因此他肯定会先将next
指针给析构掉,也就是会调用next
对象的析构函数,也就是第二个节点对象的析构函数。然后它发现第二个节点对象也只有1个引用计数,因此第二个节点也会被析构,析构前也会将其中的next
给析构掉…然后一直往下找。最终因为嵌套太深,导致栈展开太多,从而导致崩溃(经过测试,的确如此)。
注意到此处代码还加了一个析构函数,之前的两个方案都没有加析构函数。不过可以想象,那两种方法的析构函数与这个方法的类似,都直接调用pop()
即可。
书上还介绍使用std::experimental::atomic_shared_ptr<>
的原子类型来代替上述的std::shared_ptr<>
,不过,这种类型属于实验性质的,当前我在VS的实现上并没有看到相关实现,这里就不说明了。
上述使用std::shared_ptr<>
的方案效率非常低,经过我的测试,效率可能比前两种方案要低10倍左右。不仅是pop()
效率低,就连push()
的效率也很低。
那么,有没有效率比较高的引用计数方法呢?有的,不过只不过很复杂,用到了双引用计数。
使用双引用计数法来实现高效的内存回收
双引用计数法需要一个外部计数器(external counter)和一个内部计数器(internal counter),这两个计数器的和就是引用当前这个节点的个数。外部计数器与指向节点的指针绑定在一起,每当读取一次节点的指针的时候,外部计数器就加1。当读取完成后,内部计数器就会减去1。也就是说,读取这个节点的指针的时候,外部计数器加1,读取完成后,内部计数器减1。当某个节点从stack中取出来后(此时这个节点在其他线程中可能被引用了,也可能没被引用),此节点的内部计数器就要加上此节点的外部计数器的值并减去1(internal_counter = external_counter - 1
)。一旦内部计数器的值为0,就表示没有其他线程引用这个节点了,只有当前线程引用这个节点,因此当前线程就可以安全的回收这个节点的内存了。此外,一旦这个节点已经从stack中摘除,那么这个节点的外部计数器就不需要了,此时只需要内部计数器就行了。内部计数器用于当前线程或者其他线程来判断是否可以安全回收这个节点内存。我们来看一下最终的实现(这里书上给的是优化后的实现,其实比较难以理解,得多看看,注意多看代码里的注释):
template<typename T>
class lock_free_stack
{
private:
struct node;
struct counted_node_ptr
{
// 注意,外部计数器用的是普通变量,因为外部计数器(或者说这个counted_node_ptr结构体对象)
// 一般都是拷贝副本,并不会对单一对象执行并发访问
int external_count = 0;
node* ptr = nullptr;
};
struct node
{
std::shared_ptr<T> data;
std::atomic<int> internal_count; // 内部计数器使用原子变量,因为内部计数器在节点内部,且这个点会被new出来,且被多个线程共享
counted_node_ptr next; // 注意,这里next并不是指针,而是一个结构体,一个包含外部计数器和下一个node指针的结构体,并不是指针
node(const T& data_) : data(std::make_shared<T>(data_)), internal_count(0) {}
};
private:
std::atomic<counted_node_ptr> head; // 注意,head此处不再是指针,而是结构体
private:
// 进入pop函数的第一件事就是将head的外部计数器递增,表示当前有一个线程打算读取node指针
void increase_head_count(counted_node_ptr& old_counter)
{
counted_node_ptr new_counter;
do
{
// 此循环是为了能够确保head能被此线程获取并将其外部节点递增
new_counter = old_counter;
++new_counter.external_count;
} while (!head.compare_exchange_strong(old_counter, new_counter)); // 注意,这里不再是对比指针,而是通过二进制方式对比结构体对象是否相等
// 此处有可能发生线程切换,导致old_counter与当前真正的head不一致,不过没事,因为已经将之前的head的外部节点递增了,另一个线程可以负责处理那个head
old_counter.external_count = new_counter.external_count;
}
public:
void push(const T& data)
{
counted_node_ptr new_node; // 这里并没有new出一个,直接创建一个栈对象
new_node.ptr = new node(data); // 真正的节点内容
new_node.external_count = 1; // 将外部节点初始化为1,因为当前有head在引用
new_node.ptr->next = head.load();
while (!head.compare_exchange_weak(new_node.ptr->next, new_node));
}
std::shared_ptr<T> pop()
{
counted_node_ptr old_head = head.load(); // 先尝试获取当前head,不过,最终获取的以increase_head_count返回的为主
while (true) // 这里是一个大循环,没有退出条件,要么返回值,要么再次循环
{
// 真正获取当前head,并将head的外部计数器递增1。注意,此时等此函数退出时,old_head并不一定等于当前stack的head
increase_head_count(old_head);
// 假如不在获取指针之前对外部计数器递增,则其他线程可能会释放这个ptr,导致当前线程的ptr变成了悬垂指针
node* const ptr = old_head.ptr;
if (!ptr)
{
return nullptr;
}
// 这里的if才是真正尝试将head从stack中移除,如果发现当前的head与刚才获取的old_head不一致,说明当前线程在
// 获取old_head并将head的外部计数器递增1后,另一个线程将这个递增后的head给移除了,并没有轮到当前线程来处理。
if (head.compare_exchange_strong(old_head, ptr->next))
{
// 如果发现head与old_head一致,那么就将head移除,然后将head更新为next。此时本线程就可以放心的处理old_head了。
// 注意,即使当前线程进入到了这里,old_head对应的指针可能也被其他线程的old_head对象所引用,这个就看old_head
// 里的外部计数器的值了。
std::shared_ptr<T> res = nullptr;
res.swap(ptr->data); // 注意,这里是用的swap,因为以后都肯定不会再访问这个data了,所以直接取出来就行,不用留着
// 如果其他线程并没有引用old_head中的node指针,则理论上old_head中的外部计数器的值是2,因为刚才在increase_head_count
// 中对其进行了递增。如果此时其他线程也引用了old_head中对应的node指针,则此时old_head中的外部计数器的值一定大于2,且
// 减去2之后的值就是其他线程引用的个数(或者你可以这么理解,由于当前线程将不再引用这个节点,因此要把外部计数器减去1。然后
// 由于这个head节点已经从stack中移除了,所以stack不再引用这个节点了,因此外部引用计数又再次减去1)。然后就需要比较外部
// 计数器与内部计数器之和是否为0。如果之和为0,则表示现在没有其他线程引用这个节点了,那么就可以安全的删除这个节点了。
// 注意,如果它们之和为0,则表示internal_count之前的数值一定是-count_increase的值,因为fetch_add返回的是旧值,所以你
// 会发现下面比较有点怪异,不是比较0,而是比较旧值与-count_increase。
const int count_increase = old_head.external_count - 2;
if (ptr->internal_count.fetch_add(count_increase) == -count_increase)
{
delete ptr;
}
return res;
}
else if (ptr->internal_count.fetch_sub(1) == 1)
{
// 不管是由于其他线程已经把这个节点的内容返回了,还是其他线程新加了节点到stack中,此时都要重新循环,从而重新得到新的head并pop。
// 但是在重新循环之前,由于上面在获取head时已经将head外部计数器加1了,那么这里需要将内部计数器减去1,表示当前线程不再引用这个节
// 点了。如果发现内部计数器减去1之后变成了0,则表示内部计数器之前是1,所以肯定有其他线程已经返回这个节点的内容了(只要确定内部
// 计数器的值大于0,就表示肯定有其他线程已经进入了上面的if分支并且会把节点中的值返回),且如果正巧发现内部计数器的值为1,则表示
// 当前已经没有其他线程再引用这个节点了(因为当前线程马上就要将内部计数器减1,则内部计数器就变成了0,就表示没有任何线程拥有这个
// 节点了)。因此,此时就可以直接删除这个节点了。
delete ptr;
}
}
}
~lock_free_stack()
{
while (pop());
}
}
首选,一旦你获取head之后,就必须马上将head的外部计数器递增1,表示我现在正在引用这个节点,并且有可能会访问此节点里面对应的node指针。这样,其他线程看到后就不会盲目的将里面的ptr指向的对象回收掉,导致当前线程解引用ptr时候出现异常。这也是使用双引用计数法的主要原因:通过递增外部计数器,可以确保你在访问其中的node指针过程中此指针一直有效,而不会被其他线程给回收掉。
上述代码特别要注意的是两处对内部计数器的递增和递减减处理。在递增处理的地方,将外部计数器的值减去当前线程的引用和stack的引用后与内部计数器的值相加,然后将相加后的结果存入了内部计数器中,而不是第三个临时变量里。此外,如果发现相加的结果为0,则表示当前已经没有任何线程拥有此节点了,那么此时就可以安心的删除此节点了。对于递减的地方,能进入到这个地方表示当前线程已经不能对此节点的值进行返回了,因为其他线程要么已经对这个节点进行了处理,要么在stack中加入了新的节点,导致stack的head已经变了。此时由于刚才在获取old_head的时候已经递增了此节点的外部计数器了,因此这里需要递减此节点的内部计数器。如果递减后发现值为0,表示递减前内部计数器的值大于0,表示一定有其他线程已经对这个节点进行了处理,并且可以确定此时内部计数器的值是外部计数器的值与内部计数器的值之和。如果发现此时它俩的和被减去1后,值为0(所以减去1之前值为1啦),那就表示当前已经没有任何线程拥有这个节点了,并且这个节点的内容也已经被pop了,所以此处就可以安全的删除了(多看看代码里的注释)。
上述的双引用计数器方法不但能够解决内存回收的问题,而且效率非常的高,我自己测试后发现效率几乎和第一种方案一样高。不过,注意,这里效率高的前提是硬件平台支持双字节比较并交换操作,也就是当一个结构体的大小为双字节时,硬件对这个结构体执行比较并交换的原子操作是无锁的。如果你的硬件平台不支持双字节比较并交换操作,但是你知道你的引用计数的值是不超过某个范围的,是一个较小的数值,并且你得知你的硬件平台上指针中有额外的内存空间可用,那么你就可以使用单字节来同时存储指向node的指针ptr以及外部计数器external_counter(比如指针可能只需要用12个bit来存储就够了,那么剩下的4个bit就是空闲的,就可以用这4个bit来存储internal_counter的值)。这样,就可以使用单字节来实现此功能了,并且是无锁的。否则,上述这种实现由于counted_node_ptr
结构体的原子操作并不是无锁的,其速度仍然很低,与使用内置指针的速度差不多(比如你在counted_node_ptr
结构中多加一个无用的占位的成员变量,你就会发现上述这个实现并发执行速度仍然很低)。
对双引用计数法进行内存模型优化
到现在为止,我们所有方案中使用的原子操作都是使用默认的内存序参数:std::memory_order_seq_cst
。在某些平台下,这种内存序效率比较低,因为涉及到大量的Cache通信,以及不必要的代码执行顺序保证。我们可以对这些原子操作加上其他种类的内存序参数,能够提高原子操作的效率,从而提升整个应用程序的效率。如果你对内存模型以及内存序参数不太了解,可以看我的另一篇博文:C++11内存模型完全解读-从硬件层面和内存模型规则层面双重解读。如果你实在不想加上内存序参数(即你实在不想进行更进一步的优化),那么本小节就可以跳过了(这里太难了,我想了好久才想明白一些,真的太难了…)。
在设置内存序之前,我们需要了解代码中相关的执行顺序有什么要求。由于在不同情况下可能相互的依赖顺序是不一样的,我们这里从最简单的一种情况开始:一个线程首先将一个新数据push到stack中,然后另一个线程从stack中pop出这个数据。
这种情况下,有三个数据需要考虑:stack中的head节点,head中的node结构(也即ptr指针)以及对应的node结构中的data数据。在push()
函数中,首先是创建出一个node结构并将data数据存储到node中,最后将node结构存入到head中。在pop()
函数中,首先读取head,然后对head执行compare/exchange循环,用于递增引用计数,最后从head结构中获取next值(next值在ptr指针中)。这里你可能就会看出一些数据依赖关系:由于next是一个普通的非原子变量,所以为了安全地读取这个next,必须确保push线程中对head的store操作happens-before
pop线程中对head的load操作,也就是必须确保head存储完成,才能读取head并得到其中的成员。我们先把完整代码贴出来,然后看着代码分析:
template<typename T>
class lock_free_stack
{
private:
struct node;
struct counted_node_ptr
{
int external_count = 0;
node* ptr = nullptr;
};
struct node
{
std::shared_ptr<T> data;
std::atomic<int> internal_count;
counted_node_ptr next;
node(const T& data_) : data(std::make_shared<T>(data_)), internal_count(0) {}
};
private:
std::atomic<counted_node_ptr> head;
private:
void increase_head_count(counted_node_ptr& old_counter)
{
counted_node_ptr new_counter;
do
{
new_counter = old_counter;
++new_counter.external_count;
} while (!head.compare_exchange_strong(old_counter, new_counter, std::memory_order_acquire, std::memory_order_relaxed)); // 4
old_counter.external_count = new_counter.external_count; // 5
}
public:
void push(const T& data)
{
counted_node_ptr new_node;
new_node.ptr = new node(data); // 1
new_node.external_count = 1; // 2
new_node.ptr->next = head.load(std::memory_order_relaxed); // 2.5
while (!head.compare_exchange_weak(new_node.ptr->next, new_node, std::memory_order_release, std::memory_order_relaxed)); // 3
}
std::shared_ptr<T> pop()
{
counted_node_ptr old_head = head.load(std::memory_order_relaxed);
while (true)
{
increase_head_count(old_head);
node* const ptr = old_head.ptr; // 6
if (!ptr)
{
return nullptr;
}
if (head.compare_exchange_strong(old_head, ptr->next, std::memory_order_relaxed)) // 7
{
std::shared_ptr<T> res = nullptr;
res.swap(ptr->data); // 8
const int count_increase = old_head.external_count - 2;
if (ptr->internal_count.fetch_add(count_increase, std::memory_order_release) == -count_increase) // 9
{
delete ptr; // 10
}
return res;
}
else if (ptr->internal_count.fetch_sub(1, std::memory_order_relaxed) == 1) // 11
{
ptr->internal_count.load(std::memory_order_acquire); // 12
delete ptr; // 13
}
}
}
~lock_free_stack()
{
while (pop());
}
}
接着分析,注意看代码中标注的序号。由于在pop()
中的代码6处需要读取head中的ptr,而在push()
中的代码3处是将新的节点加到stack中,所以这里得确保当另一个push()
线程看到pop()
线程中的head已经更新后,pop()
函数中读取head之前,代码1和2就已经存储到了新的head中,而不是因为发生了代码重排或者指令重排,导致虽然新的节点已经加入了stack,但是新的节点中的ptr指针以及外部计数器还没有初始化(或者说push()
线程还没有感知到这两个成员变量的内存变化,因为这两个成员变量是普通变量,并不是原子变量)。如果pop()
线程没有感知到新的head中ptr以及external_counter的初始化,则pop()
线程后续对这两个变量的操作就会出问题。因此,这里必须至少使用acquire-release
内存模型来确保pop()
线程读取新的head时(这个head是由刚才的phsu()
线程加进去的),pop()
线程已经看到了代码1和2已经完成了。
由于push()
中只有一处对head的写入操作,也就是compare_exchange_weak()
操作,所以此操作成功时(表示成功将新节点加入到了stack中),就得使用release
语义。如果失败了,因为并不会将新节点加入,因此没有影响,直接使用relaxed
语义即可。对于push()
中读取head的操作(即代码2.5处),其本身不需要什么内存顺序保证,因为push()
中的同步点是head的写入操作,因此只要保证对head的写入操作有一定的内存顺序要求即可。这样,所有发生在对head的写入操作之前的写入操作(1, 2和2.5)都会在代码3成功执行之前先执行完成,并且其他线程能够感知到。
现在考虑另一端对head的读取操作,在pop()
中,对pop()
后面执行的代码有效的head读取操作并不在while(true)
语句上面的这一行,而是在函数increase_head_count()
中的compare_exchange_strong()
,因为这里会重新读,直到读取有效的head后才会执行pop()
下面的其他操作,且下面的其他操作用到的old_head都是这里读取出来的。因此,这里的compare_exchange_strong()
操作要与push()
中的compare_exchange_weak()
操作构成一对同步点。这一对同步点中,一个是写入操作,一个是读取操作。写入操作已经用了release
,所以这里的读取操作要使用acquire
语义。注意,当这里的compare_exchange_strong()
操作成功时,才会真正读取head并给下面使用,如果操作失败,则会重新读取。因此,当操作失败时直接就可以使用relaxed
语义就行了。
这样,你就会发现,如果代码4处读取的head值是代码3处写入的head,则3和4就发生了同步,则此时代码1,2以及2.5一定会happens-before
代码4之后的内存读取操作,因此,pop()
中后面的那些对ptr以及external_count等的操作一定发生在push()
中1,2和2.5的写入操作之后,这样就不会出现问题了。
然后,我们发现代码7处也有对head的读取操作,那么此处需要加特别的内存序参数吗?其实这里不需要,只用relaxed
就行了,因为这里如果执行成功,那么就会读取old_head里面的ptr以及external_count等,按理说要确保这里在读取这些值之前,push()
中已经把这些值给成功存储进去了。然而, 这个保证已经在代码4处完成了,因此代码7处不需要这样的保证了。
接下来我们想象一下多个线程同时push()
会怎么样。可以看到,由于push()
中只会读取head,并不会读取的head中的其他成员,因此这里即使乱序,也不会有问题。比如线程1先将一个new_node1存储到了head中,但是假如现在线程1还未将node给成功new出来(也就是代码1正在执行,并且已经返回了一个指针,但是其new 出来的node里面的data数据还未初始化成功,因此此时node已经有了,node中的next也已经有了,只是node中的data还未完全出来。这里代码2是一定执行了的,因为操作2与下面的操作存在依赖关系,所以肯定要等操作2完成才会对new_node执行其他操作。此外,代码3也肯定执行了,不然存储肯定不成功,因为要先判断嘛),导致线程1将stack中的head修改了,但是head中的data数据不完整。此时线程2也打算将自己的new_node2也存储stack,因此它会读取线程1存进去的head,发现没问题(因为它没有读取head中的ptr中的data,所以即使此时data不存在,也不会有问题),并且线程2也成功将new_node2存进去了,变成了新的head。此时线程1终于将刚才的data数据初始化完成了,因此现在其数据才完整。整个过程你会发现,在push()
中,操作1,2和2.5一定是先完成(但是注意操作1中的data初始化不一定完成了),再将新节点加入到stack中,由于push()
并没有读取data,所以即使data未初始化完成也没问题,所以多个push()
一起执行并不会出问题。
多个pop()
之间会如何呢?由于pop()
中会对data进行读取并对node(即ptr指针)进行删除,所以需要一定的先后顺序保证,这个保证通过internal_count
来实现的。可以想到,当执行操作9之前,一定要保证ptr中的data已经读取出来了。否则,如果先执行了操作9,而data还未通过操作8读取出来时,那么另一先pop()
线程可能就会通过操作13来删除这个节点,导致操作8读取数据时读取的是悬垂指针,导致出错。所以其实操作9和操作11需要形成对同步点,要让操作8happens-before
操作13。因此操作9处需要用release
而操作11需要用acquire
(注意,不可能有两个线程同时对一个节点执行操作9,只能是一个线程执行操作9,另一个线程在操作7处就失败从而执行操作11,这样两个pop()
线程才会发生数据竞争)。不过,这里还可以进一步优化。注意到,只有当操作11处if条件成立时才会删除指针,回收内存,如果失败,则不会处理。如果直接在操作11处加上acquire
,那么不管操作11的if条件是否成立,则都强加了内存序要求。其实我们可以只在当其if条件成立时才加上acquire
,因此,我们可以在if条件里面再读取一下,然后加上acquire
,这就是代码中操作12的由来。操作12没有其他任何作用,只是用于与操作9之间形成一个同步点。
因此,push()
线程之间无需happens-before
要求,push()
和pop()
之间通过操作3和操作4完成happens-before
要求,pop()
与pop()
之间通过操作9和操作12完成happens-before
要求。
当有多个push()
以及多个pop()
线程同时执行时,我们只需要考虑push()
与pop()
之间的关系即可,且只需要考虑一个push()
对多个pop()
的情况。因为如果多个pop()
分别各自对应一个push()
的话,那是没问题的,一对push()
和pop()
并不会影响另一对push()
和pop()
。考虑一个push()
对多个pop()
时,由于操作4是RWM操作,因此此时多个pop之间的操作4组成了release-sequence
的一部分。根据release-sequence rule
,这个push()
肯定会happens-before
最后成功的带head并执行后续操作的那些pop()
,因此也没有问题。
三、总结
本文通过循序渐进的方式介绍了如何实现一个效率高且是无锁的stack数据结构。可以看到,在实现无锁数据结构中,最关键的就是如何有效处理内存的回收问题。文章一共列举了三种回收内存的方式,其中:
- 第一种线程个数计数法虽然效率挺高,但是存在内存泄漏问题;
- 第二种hazard-point方法效率也还可以,比第一种低一些,但是不存在内存泄漏问题。不过,这种算法已经被申请专利了,可能在使用时要注意一点;
- 第三种引用计数方法,分为两种。第一种,智能指针方法,实现简单,且容易理解和维护,也不会出现内存泄漏问题,但是,最大的问题就是效率非常低,可能还不如直接使用锁来实现呢。效率低的原因是一般当前的智能指针的原子操作都是有锁的,并不是无锁实现,因此会造成大量的阻塞。假如某个平台上实现了无锁的智能指针的原子操作,那么使用智能指针效率就会相当的高。第二种是双引用计数方法,一般效率非常高,实测已经接近上面的第一种方法了,且不存在内存泄漏问题,也不存在专利问题。但是,这种方法实现复杂,且比较难以理解。此外,如果想更进一步提高其性能,加上内存序参数后,会变得更加难以理解,维护困难。此外,双引用计数法也需要要求硬件平台支持双字节比较并交换操作,否则这个实现效率也极其慢,与智能指针类似,所以这种情况下还不如直接使用智能指针的方式呢。比如你在
counted_node_ptr
结构体中随便再多加一个成员变量,你测试就会发现此时与使用智能指针一样的慢。因此,最后的这种方法很依赖于硬件平台是否支持双字节比较并交换操作。
本文基本上是对照着《C++ Concurrency In Action 2nd》
的7.2节来写作的,边翻译,边加上自己更详细的解释。本文写的比较啰嗦,就怕自己以后或者其他人看了不懂,随意写了很多字(或许写了这么多字还是看不懂?!!!)。有兴趣的可以阅读原书英文版,如果有什么疑问,欢迎留言讨论!