C++ Concurrency In Action 笔记(四) - lock free 结构的内存回收方法

参考:

实验环境:

  • 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 head 原子操作的对象超过 64bit,编译时需要加上 -latomic 参数:

  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 中描述的几种内存回收方法。理论上可能不复杂,但是实际编写代码要保证正确高效的运行可能比较困难,这需要平时多多学习积累。

posted @ 2022-07-22 09:51  小夕nike  阅读(267)  评论(0编辑  收藏  举报