C++ Concurrency In Action 笔记(四) - lock free 结构的内存回收方法
参考:
- C++ Concurrency In Action 2rd 第7章
- https://en.wikipedia.org/wiki/ABA_problem
- http://www.cs.cmu.edu/afs/cs/academic/class/15418-f18/www/lectures/17_lockfree.pdf
实验环境:
- system: centos 8.1 / arch: x86_64 / kernel: 4.18.0 / g++: 8.5.0
1. 概述
下文主要以 lock free stack 为例进行讲解。
注意,为了便于理解,所以下文的代码与原文比,省去了智能指针(考虑异常的部分)。
2. ABA 和悬空引用的问题
2.1 ABA 问题
对于 ABA 问题,引用 https://en.wikipedia.org/wiki/ABA_problem 中的例子:
/* Naive lock-free stack which suffers from ABA problem.*/
class Stack {
std::atomic<Obj*> top_ptr;
Obj* Pop() {
while (1) {
Obj* ret_ptr = top_ptr; // 1
if (!ret_ptr) return nullptr;
Obj* next_ptr = ret_ptr->next; // 2
if (top_ptr.compare_exchange_weak(ret_ptr, next_ptr)) { // 3
return ret_ptr;
}
}
}
void Push(Obj* obj_ptr) {
while (1) {
Obj* next_ptr = top_ptr;
obj_ptr->next = next_ptr;
if (top_ptr.compare_exchange_weak(next_ptr, obj_ptr)) {
return;
}
}
}
};
即有一个内容为 head -> A -> B -> C 的栈(head 指向栈顶节点 A):
- T1 线程调用 Pop,在执行完语句 2 后线程被调度换出
- T2 线程接着调用 Pop A --> Pop B --> Push A,即节点 A 的内存并没有归还给系统,而是重复利用
- T1 线程被重新调度,这时语句 3 依然能成功,并将 head -> B,这时错误就发生了
但是如果我们稍微修改一下 Pop 函数:
Obj* Pop() {
while (1) {
Obj* ret_ptr = top_ptr; // 1
if (!ret_ptr) return nullptr;
if (top_ptr.compare_exchange_weak(ret_ptr, ret_ptr->next)) { // 3
return ret_ptr;
}
}
}
即去掉语句 2,并修改一下语句 3,似乎 ABA 问题就不复存在了。
所以,当我们思考 ABA 问题带来的影响时,应该知道:
- 修改后的 Pop 函数,虽然不会破坏栈结构,但是依然重用了节点 A 的内存
- 至于重用节点 A 的内存,会有什么影响,完全取决于节点 A 内部有什么内容,即取决于具体业务
- 如果重用节点 A 对业务逻辑没有任何影响,那么完全不用担心 ABA 问题
想要解决 ABA 问题,一种可行方法是采用 ABA 计数,引用 http://www.cs.cmu.edu/afs/cs/academic/class/15418-f18/www/lectures/17_lockfree.pdf 课件中的示例:
struct Node {
Node* next;
int value;
};
struct Stack {
Node* top;
int pop_count;
};
void init(Stack* s) {
s->top = NULL;
}
void push(Stack* s, Node* n) {
while (1) {
Node* old_top = s->top;
n->next = old_top;
if (compare_and_swap(&s->top, old_top, n) == old_top)
return;
}
}
Node* pop(Stack* s) {
while (1) {
int pop_count = s->pop_count;
Node* top = s->top;
if (top == NULL)
return NULL;
Node* new_top = top->next;
if (double_compare_and_swap(&s->top, top, new_top,
&s->pop_count, pop_count, pop_count+1))
return top;
}
}
如上,为栈增加了一个 pop_count,上面说的 T1 线程在语句 3 就会因为 pop_count 值不一样而 CAS 失败。
但是,此方法需要在 64 位系统中支持 128 位的 Double-CAS 操作。
ABA 问题本质上是内存回收问题,即节点内存没有被系统回收而被重复利用,下文描述的内存回收算法也能很好的解决 ABA 问题。
2.2 悬空引用的问题
继续看上文引用的 https://en.wikipedia.org/wiki/ABA_problem 中的例子:
/* Naive lock-free stack which suffers from ABA problem.*/
class Stack {
std::atomic<Obj*> top_ptr;
Obj* Pop() {
while (1) {
Obj* ret_ptr = top_ptr; // 1
if (!ret_ptr) return nullptr; // 2
Obj* next_ptr = ret_ptr->next; // 3
if (top_ptr.compare_exchange_weak(ret_ptr, next_ptr)) { // 4
return ret_ptr; // 5
}
}
}
void Push(Obj* obj_ptr) {
...
}
};
如上,如果同时有两个线程调用 Pop,T1 线程执行完语句 2 后被挂起,T2 线程随即成功弹出 head 节点并 delete 掉。这时 T1 线程被重新调度,ret_ptr 成为了悬空指针,语句 3 会发生非法内存访问。
对于下面的实现,也会存在同样的问题:
Obj* Pop() {
while (1) {
Obj* ret_ptr = top_ptr; // 1
if (!ret_ptr) return nullptr; // 2
if (top_ptr.compare_exchange_weak(ret_ptr, ret_ptr->next)) { // 4
return ret_ptr; // 5
}
}
}
因为在语句 4 中,可能在 CAS 之前,会先执行 ret_ptr->next。
你也不能将语句 4 改成:
top_ptr.compare_exchange_weak(ret_ptr, top_ptr->next)
因为 top_ptr 代表最新的 head 节点,如果队列是空的,top_ptr->next 也将引起非法内存访问。
发生悬空引用问题的原因是节点被过早释放,当还有其它线程持有对一个节点的引用的时候,那么此节点不应该被 delete 掉。
后文的内存回收方法能很好的解决悬空引用的问题。
3. 内存回收
注意,C++ Concurrency In Action 2rd 第7章介绍的几种内存回收方法,最终目的是在合适的时机将节点内存释放给系统内存,不会再进行重用。
3.1 全局计数器
全局计数器的方法是,在整个栈结构中增加一个计数器,用于计数当前有多少个线程正在访问 pop 函数。
当只有一个线程正在访问 pop 函数时,那么任意节点就不可能正在被其它线程持有,这时就可以安全的进行节点删除:
template <class T>
class MyStack {
private:
struct Node {
Node(T _val): val(std::move(_val)), next(nullptr) {}
T val;
Node* next;
};
public:
MyStack(): head(nullptr), threads_in_pop(0), to_be_deleted(nullptr) {}
~MyStack() {
T val;
while (pop(val));
}
void push(T val) {
Node* new_node = new Node(val);
new_node->next = head.load();
// 忙循环直到 new_node->next 与 head 指向相同的节点,
// 这时可以安全的插入新头节点和更新 head 节点
while (!head.compare_exchange_weak(new_node->next, new_node));
}
bool pop(T& val) {
// 先递增计数器
threads_in_pop++;
Node* old_head = head.load();
// 忙循环直到 old_head 与 head 都指向相同的头节点,
// 这时可以安全弹出头节点和更新 head 节点
while (old_head && !head.compare_exchange_weak(old_head, old_head->next)); // 1
// CAS 成功后, 当前线程拥有对此节点的所有权, 但是还不能直接删除
if (!old_head) {
return false;
}
val = std::move(old_head->val);
// 尝试回收 old_head, 同时也尝试回收历史待删除链表
try_reclaim(old_head);
return true;
}
void try_reclaim(Node* old_head) {
// 如果此时依然只有一个线程访问 pop
if (threads_in_pop == 1) { // 2
// 获得历史待删除链表的头节点,
// 注意, exchange(nullptr) 表明同时只会有一个线程获得历史待删除链表的头节点的机会
Node* nodes_to_delete = to_be_deleted.exchange(nullptr); // 3
if (!--threads_in_pop) { // 4
// 可以安全的删除历史待删除链表
delete_nodes(nodes_to_delete);
} else if (nodes_to_delete) { // 5
// 将旧的历史待删除链表重新加入到历史待删除链表,
chain_pending_nodes(nodes_to_delete);
}
// 删除 old_head
delete old_head; // 6
} else {
// 将 old_head 加入到历史待删除链表
chain_pending_node(old_head); // 7
// 递减计数器
--threads_in_pop;
}
}
// 将节点链表加入到历史待删除链表
void chain_pending_nodes(Node* nodes) {
Node* last = nodes;
while (Node* next = last->next) {
last = next;
}
chain_pending_nodes(nodes, last);
}
// 范围插入, 头插法
void chain_pending_nodes(Node* first, Node* last) {
last->next = to_be_deleted;
while (!to_be_deleted.compare_exchange_weak(last->next, first));
}
// 将单个节点加入到历史待删除链表
void chain_pending_node(Node* n) {
chain_pending_nodes(n, n);
}
// 从 nodes 开始递归删除所有节点
void delete_nodes(Node* nodes) {
while (nodes) {
Node* next = nodes->next;
delete nodes;
nodes = next;
}
}
private:
// 栈头节点
std::atomic<Node*> head;
// 全局计数器, 表明此刻有多少现场正在调用 pop 函数
std::atomic<unsigned int> threads_in_pop;
// 全局待回收链表, to_be_deleted 指向链表头节点
std::atomic<Node*> to_be_deleted;
};
如上:
- 在语句 1 完成后,可能还有其它线程正持有 old_head,所以不能马上释放节点,而是调用 try_reclaim() 尝试回收节点
- 如果语句 2 执行失败,则执行语句 6,即当前有其它线程正在调用 pop,old_head 可能被其它线程持有
- 如果语句 2 执行成功,那么一定可以安全删除 old_head
- 在语句 2 和语句 3 之间,可能有其它线程调用了 pop,且执行了语句 6
- 语句 4 如果判断成功,那么说明当前没有其它线程调用 pop,可以安全的删除历史待删除链表中的所有节点
- 语句 4 如果判断失败,那么可能有其它线程在语句 2 和语句 3 之间执行了语句 6,这时通过语句 5 来将旧的链表添加到新链表上
- 语句 4 如果判断失败,还有一种可能是有其它线程在语句 3 和语句 4 之间执行了语句 6,这时也需要通过语句 5 来将旧的链表添加到新链表上
可以看到,全局计数器能够正确回收节点,但是问题也很明显:如果 pop 被频繁调用,那么待删除链表可能会越来越长。且某些次 pop,需要花费的时间会更多,pop 调用耗时不稳定。
3.2 Hazard Pointers(风险指针)
Hazard Pointers 方法是,为每个线程增加一个风险指针,此指针指向的节点表明此节点正在被使用,请勿删除回收。
个人理解 Hazard Pointers 方法是基于这样一个事实:在某一时刻,同一个节点可能同时被多个线程引用,但是一个线程只能引用最多一个节点:
std::atomic<void*>& get_hazard_pointer_for_current_thread();
void delete_nodes_with_no_hazards();
template <class T>
class MyStack {
private:
struct Node {
Node(T _val): val(std::move(_val)), next(nullptr) {}
T val;
Node* next;
};
public:
MyStack(): head(nullptr) {}
~MyStack() {
T val;
while (pop(val));
}
void push(T val) {
Node* new_node = new Node(val);
new_node->next = head.load();
// 忙循环直到 new_node->next 与 head 指向相同的节点,
// 这时可以安全的插入新头节点和更新 head 节点
while (!head.compare_exchange_weak(new_node->next, new_node));
}
bool pop(T& val) {
// 获得线程私有的 hazard pointer
std::atomic<void*>& hp = get_hazard_pointer_for_current_thread(); // 1
Node* old_head = head.load();
// 忙循环直到 old_head 与 head 都指向相同的头节点,
// 这时可以安全弹出头节点和更新 head 节点
do {
Node* temp;
// 忙循环直到 hazard pointer 存储最新的 head 节点
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)); // 2
// CAS 成功后, 当前线程拥有对此节点的所有权, 但是还不能直接删除
// 清空 hazard pointer
hp.store(nullptr); // 3
if (!old_head) {
return false;
}
val = std::move(old_head->val);
// 检查 old_head 是否正在被其它线程引用
if (outstanding_hazard_pointers_for(old_head)) { // 4
// 添加到待回收链表
reclaim_later(old_head);
} else {
// 直接删除节点
delete old_head;
}
// 尝试删除历史待回收链表的所有节点
delete_nodes_with_no_hazards(); // 5
return true;
}
private:
// 栈头节点
std::atomic<Node*> head;
};
unsigned int const max_hazard_pointers = 100;
struct HazardPointer {
std::atomic<std::thread::id> id;
std::atomic<void*> pointer;
};
HazardPointer hazard_pointers[max_hazard_pointers]; // 6
class HpOwner {
public:
HpOwner(HpOwner const&) = delete;
HpOwner operator=(HpOwner const&) = delete;
HpOwner(): hp(nullptr) { // 7
for (unsigned i=0; i<max_hazard_pointers; ++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");
}
}
~HpOwner() {
// 复位 hazard pointer
hp->pointer.store(nullptr);
// 复位线程 id
hp->id.store(std::thread::id());
}
std::atomic<void*>& get_pointer() {
return hp->pointer;
}
private:
HazardPointer* hp;
};
std::atomic<void*>& get_hazard_pointer_for_current_thread() {
// 每个线程都有自己的风险指针
thread_local static HpOwner hazard; // 8
return hazard.get_pointer();
}
// 检查节点是否正在被其它线程引用
bool outstanding_hazard_pointers_for(void* p) { // 9
// 遍历所有线程的风险指针
for (int i=0; i<max_hazard_pointers; ++i) {
if (hazard_pointers[i].pointer.load() == p) {
return true;
}
}
return false;
}
// 节点删除器
template<typename T>
void do_delete(void* p) {
delete static_cast<T*>(p);
}
// 使用 DataToReclaim 结构来封装待回收节点
struct DataToReclaim {
void* data;
std::function<void(void*)> deleter;
DataToReclaim* next;
template<typename T>
DataToReclaim(T* p):
data(p),
deleter(&do_delete<T>),
next(nullptr) {}
~DataToReclaim() {
// 析构的时候删除节点
deleter(data);
}
};
// 全局待回收链表, nodes_to_reclaim 指向链表头节点
std::atomic<DataToReclaim*> nodes_to_reclaim(nullptr);
void add_to_reclaim_list(DataToReclaim* 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) { // 10
add_to_reclaim_list(new DataToReclaim(data));
}
// 尝试删除历史待回收链表的所有节点
void delete_nodes_with_no_hazards() { // 11
// 获得历史待删除链表的头节点,
// 注意, exchange(nullptr) 表明同时只会有一个线程获得历史待删除链表的头节点的机会
DataToReclaim* current = nodes_to_reclaim.exchange(nullptr);
while (current) {
DataToReclaim* const next = current->next;
if (!outstanding_hazard_pointers_for(current->data)) {
delete current;
} else {
add_to_reclaim_list(current);
}
current = next;
}
}
如上:
- 在语句 1 中会获得一个每个线程唯一的 hazard pointer(语句 7 和语句 8),其将会存储节点的地址
- 语句 6 中,初始化创建了支持 100 个线程的 hazard pointer
- 在语句 2 执行完成后,可能还有其它线程持有对 old_head 节点的引用
- 在语句 4 检查是否有其它线程持有对 old_head 节点的引用,如果有,则现在还不能删除,需要添加到待回收链表中
- 语句 3 表明当前线程已经不再持对有 old_head 节点的引用(已经使用完毕,且因为语句 2,其它线程不可能删除 old_header 造成悬空指针的问题)
- 语句 10 会添加节点到待回收链表中,这里并不像上一节中直接利用 Node 的 next 指针,而是使用了 DataToReclaim 结构体封装了节点,原书中说明是为了通用性考虑(节点使用模板声明)
- 语句 11 中,会尝试回收全局历史待删除链表中的所有节点
与全局计数方法相比,hazard pointers 效率上会更胜一筹,因为即使有多个线程同时调用 pop,只要某个节点没有同时被多个线程引用,就可以被删除。
但是这里展示的方法,也可以看到明显的效率问题,特别是语句 9 和 语句 11,都需要遍历所有 hazard pointers。
虽然一般初始化线程数目不会太多(一般以 cpu 核数为初始化线程数参考),但是考虑到语句 11,依然有较大的效率问题,毕竟待删除链表的节点不一定都能在语句 11 被删除掉。
一种优化方法是等待删除链表的节点数目 = 2 x max_hazard_pointers 时,再进行执行语句 11,这时至少会有 max_hazard_pointers 个节点一定能成功删除。
3.3 双引用计数器
引用计数方法的思想很简单,即为每个节点增加一个计数,当计数为 0 的时候才释放节点,类似 std::shared_ptr。
直接采用 std::shared_ptr 来存储节点,无法实现线程安全,参考 https://blog.csdn.net/solstice/article/details/8547547。
这里采用一种双引用计数的方案:一个外部引用计数和一个内部引用计数:
- 外部引用计数,初始化为 1,表明节点存在于栈中。每当有线程要访问节点时,必须先将节点外部计数加1
- 内部引用计数,初始化为 0,用于在节点被弹出后,记录当前还有多少个线程正持有对该节点的引用
template <class T>
class MyStack {
private:
struct CountedNodePtr;
struct Node {
Node(T _val): val(std::move(_val)), internal_count(0) {}
T val;
// 内部计数
std::atomic<int> internal_count;
CountedNodePtr next;
};
// 将 CountedNodePtr 作为一个整体施加 CAS 操作,同时操作计数器和节点指针,是此设计的关键
struct CountedNodePtr {
// 外部计数
int external_count;
Node* ptr;
};
public:
MyStack() {}
~MyStack() {
T val;
while (pop(val));
}
void push(T val) {
CountedNodePtr new_node;
new_node.ptr = new Node(val);
// 外部计数初始化为 1, 表明节点存在于栈中, 在弹出栈时再减一
new_node.external_count = 1; // 1
new_node.ptr->next = head.load();
// 忙循环直到 new_node.ptr->next 与 head 指向相同的节点,
// 这时可以安全的插入新头节点和更新 head 节点
while (!head.compare_exchange_weak(new_node.ptr->next, new_node));
}
bool pop(T& val) {
// 获取头节点
CountedNodePtr old_head = head.load(); // 2
// 忙循环直到弹出有效节点(或空栈返回)
while (true) {
// 增加头节点外部计数, 表明有线程正持有此节点
increase_head_count(old_head); // 3
Node* const ptr = old_head.ptr;
// 栈为空, 返回
if (!ptr) { // 4
return false;
}
// 如果 old_head 与 head 都指向相同的头节点,
// 这时才可以安全弹出头节点和更新 head 节点
if (head.compare_exchange_strong(old_head, ptr->next)) { // 5
// CAS 成功后, 当前线程拥有对此节点的所有权, 但是还不能直接删除
val = std::move(ptr->val);
// count_increase 表明还有多少个线程正持有当前节点,
// -2 是因为: A. 节点从栈删除(见语句 1) B. 当前线程不再持有此节点
const int count_increase = old_head.external_count - 2;
// 更新内部计数, 即还有多少个线程正持有当前节点
if (ptr->internal_count.fetch_add(count_increase)
== -count_increase) { // 6
// 删除节点
delete ptr;
}
return true;
} else if (ptr->internal_count.fetch_sub(1) == 1) { // 7
// 删除节点
delete ptr;
}
}
}
private:
// 这里通过传递引用, 如果调用此函数时头节点发生变化, 那么可以返回最新的头节点
void increase_head_count(CountedNodePtr& old_counter) {
// 由于 new_counter 是一个栈对象, 所以这里可以认为是一个临时对象
CountedNodePtr new_counter;
// 忙循环直到 head 与 old_counter 指向相同的头节点
do {
new_counter = old_counter;
++new_counter.external_count;
// 如果 CAS 失败, 说明 old_counter 不是最新的头节点, 那么会更新 old_counter 为最新的头节点
// 如果 CAS 成功, 最新头节点外部计数就会+1
} while (!head.compare_exchange_strong(old_counter, new_counter)); // 8
// 更新 old_counter 外部计数, 因为语句 8 完成后并不会更新计数器到 old_counter
old_counter.external_count = new_counter.external_count;
}
private:
// 栈头节点指针
std::atomic<CountedNodePtr> head;
};
注意,由于 std::atomic
g++ test.cpp -o mytest --std=c++11 -latomic
栈结构大致如下(代码中每个节点使用 CountedNodePtr 来进行表示,其中 CountedNodePtr 都是栈对象):
------------------ ------------------
| CountedNodePtr | | CountedNodePtr |
+----------------+ /|+----------------+
| external_count | / | external_count |
| ptr | / | ptr |
+----------------+ / +----------------+
| / |
| / |
V / V
------------------ / ------------------
| Node | / | Node |
+----------------+ / +----------------+
| internal_count |/ | internal_count |
| next | | next |
+----------------+ +----------------+
其中:
- 语句 7 的逻辑是:节点被弹出后,还有 internal_count 个线程持有此节点。语句 5 CAS 对当前线程会失败,那么在语句 7 中减 1 表明当前线程不再持有此节点
- 语句 2 到语句 3 之间,即增加外部计数前,如果栈变空,那么将返回空栈
Q:为什么不用一个引用计数,要用两个?
A:原因在于上述代码将 CountedNodePtr 作为一个整体施加 CAS 操作(见原书 list7.11 后面的相关说明部分),外部计数无法单独原子增减,所以增加一个内部计数,可以单独进行原子增减
4. 总结
本篇文章总结了 C++ Concurrency In Action 中描述的几种内存回收方法。理论上可能不复杂,但是实际编写代码要保证正确高效的运行可能比较困难,这需要平时多多学习积累。