[翻译转载] 风险指针: 无锁对象的安全内存回收机制

2021/10/2 7:14:20

本文主要是介绍[翻译转载] 风险指针: 无锁对象的安全内存回收机制,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

本文翻译文章 Hazard Pointers: Safe Memory Reclamation for Lock-Free Objects

风险指针: 无锁对象的安全内存回收机制

摘要: 无锁对象提供了比传统有锁对象更高的性能和可靠性. 然而, 仍缺少一种高效可移植的回收动态节点内存的方法, 阻碍了无锁对象被更广泛的在实践中使用. 这篇论文提出风险指针, 一种允许任意重用的内存回收的内存管理机制. 根据我们的实验结果,该方法十分高效. 它不仅适用于用户级别的程序也适用于系统级别, 不依赖特定内核和调度器. 并且是无等待的. 它的核心操作仅需要单字级别的内存读写, 允许将内存回收到操作系统. 并且提供一个只使用特定单字指令的无锁方案来解决ABA问题. 经过实验,新方法基于它在内存回收和硬件独立上的质量优势在多处理器系统上表现往往比其他内存管理技术显著优秀. 我们同样验证了使用风险指针的无锁实现在无竞争,无多线程条件下比有锁实现的性能更好. 在适度的多线程/竞争条件下,除了保证连续性和可用性之外, 即使出现线程故障和任意延迟情况下, 性能优势也十分明显.

关键词: 无锁, 同步, 并发编程, 内存管理, 多道程序, 动态数据结构.

1. 简介

如果共享对象保证无论何时一个线程在对象上执行有限步操作时, 一些线程(可能是另一个)必须在这些操作进行过程中自身对对象的操作也能够推动,则称为无锁的(也叫非阻塞). 因此不同于传统有锁对象的是, 无锁对象在线程失败的情况下也不会产生死锁, 甚至在任意的线程延迟下也有很好的鲁棒性.

有许多其他无锁动态对象的算法已经被开发出来了, 然而, 关于这些对象的主要问题是对删除后的节点内存进行回收. 在有锁对象下, 当线程将节点从对象中移除, 可以保证没有其他线程会在重用/重分配之前访问节点的内存. 因此, 删除线程直接将被删节点的内存回收(比如使用free)是安全的, 从而让内存得到重用(如malloc).

当在不支持自动垃圾回收的编程环境中, 经典的无锁动态结构不是这种情况. 为了能够保证无锁进度, 每个线程必须在任意时刻都有不被限制的操作对象的机会. 当一个线程移除一个节点时,有可能其他一些竞争线程——在它的无锁操作过程中——已经读取了对该节点的引用,并且即将访问它的内容. 如果删除线程要将被删节点回收, 竞争线程可能会损坏对象, 或者其他某些对象恰好占据了被删对象的空间, 从而返回错误的结果, 或者因解引用非法指针而报错. 不仅如此, 假如回收的内存已经返回到了操作系统(如使用munmap), 再次访问内存地址可能会造成非法内存访问. 简单来说, 内存回收问题就是怎样回收被删除节点的内存(被重用或返回给操作系统), 同时保证没有线程在访问被释放的内存, 并且如何通过无锁操作实现.

之前允许无锁对象的节点重用方法可分为3类: 1) IBM 标记法(更新计数), 不允许任意重用的内存回收且要求有双字指令,从而无法在64位机器上使用. 2) 无锁引用计数, 低效且使用不可用的强多地址原子原语来做内存回收. 3) 依赖聚合引用计数或线程时间戳的方法. 如果没有特殊调度器支持, 这类方法是阻塞的. 因此, 任意线程的失败或延迟都可以阻止聚合引用计数降为0或前移时间戳, 从而阻碍了无边界内存的重用.

这篇论文提出风险指针, 针对无锁动态对象的内存回收方法. 高效: 每个退休节点(即线程不再需要的已删除节点)有恒定的预期摊销时间. 无论是线程失败或延迟,它提供了退休节点尚不符合重用条件数量的上界. 即任意数量线程的失败或延迟只会影响有限数量的退休节点被重用. 该方法不要求使用双字指令或强多地址原子原语. 它只用单字读写内存访问来做核心操作. 它是无等待的, 活动线程的进度是单独保证的, 而不是集体的; 因此它也同样适用于无等待算法而不削弱其进度保. 它允许将内存回收到操作系统, 不依赖内核或调度器的特殊支持.

核心思想是将多个(通常为1或2个)单写入多读的共享指针(风险指针)与每一个倾向于访问无锁动态对象的线程结合起来. 风险指针要么包含空值,要么指向节点, 该节点可能会后续被其他线程不经过验证的访问. 每个风险指针只能被拥有它的线程写入, 可以被其他线程读.

该方法要求无锁算法保证当动态节点可能被对象移除时时,没有线程可以访问到它, 除非从节点肯定可以从对象的根结点访问到起, 某个线程的风险指针在一直指向了该节点. 方法要求当退休节点被从一个或多个线程的一个或多个危险指针连续指向时, 不可以被释放。

当线程不再使用节点, 它将节点保存到一个私有列表里. 当退休节点累积到某个数量R时, 线程扫描其他线程的风险指针,并与退休节点的列表比较, 如果退休节点没有被其他风险指针指到, 那么删除它就是安全的, 否则线程将它保留在列表里, 等待下次扫描.

将非空风险指针的指向组成的私有列表的快照记录在常量搜索时间的哈希表中, 并且如果\(R = H + \Omega(H)\), 其中H为风险指针的个数, 那么这个方法可以保障在每次扫描风险指针时,在\(O(R)\)的期望时间里,可将\(\Theta(R)\)个节点标记为可以被任意重用. 因此,处理每个退休节点直到它可以被重用的预期摊销时间复杂度是恒定的.

注意每个线程可以使用小数量的风险指针, 来支持任意数量的对象, 只要该数量足以单独支持每个对象. 例如, 在程序中每个线程可能需要操作上百级别的共享对象, 每次操作每个线程至多需要两个风险指针(如 哈希表, FIFO队列, LIFO栈, 链表, 工作队列和优先队列), 因此每个线程只需要两个风险指针.

在IBM RS/6000 多处理器系统的实验性结果显示, 这项技术应用在重要对象类型的无锁实现上时, 通常可以得到比其他内存管理方法更显著优越的性能, 并且还有在内存回收和特殊硬件支持的独立性方面的质量优势. 我们同样验证了, 使用风险指针的重要对象类型的无锁实现在无争用和无多道程序的情况下提供与基于锁的高效实现相当的性能,并且在适度的多道程序和/或争用情况下明显优于它们, 此外即使在出现线程故障和任意延迟的情况下, 也能保证持续的进展和可用性.

论文的余下部分分为如下内容: 在第2段, 我们讨论我们方法的计算模型和无锁对象的内存管理问题. 在第3段, 我们展示了风险指针技术. 在第4段, 讨论了将风险指针应用到无锁算法中. 在第5段, 我们展示了实验的性能结果. 在第6段, 讨论了相关的工作并对我们的结果做了总结.

2. 预言

模型

方法基本的计算模型是异步共享内存模型. 模型的正式描述在文献中. 非正式得来说一系列的线程通过对一系列共享内存位置的原始内存访问进行通信. 线程可以运行在任意的速度和受任意延迟的影响. 线程也不需要假设其他线程的速度或状态, 即不需要假设其他线程的状态到底是运行/延迟/崩溃和暂停,恢复或失败的持续时长. 如果线程崩溃了, 它立即暂停执行.

共享对象占据了一些共享内存位置. 对象就是一个抽象对象类型的实现实例, 抽象类型定义了可在对象上进行操作的语义.

原子原语

为了能够原子读写, 在共享内存上的原语操作可能需要如比较并交换(CAS)和链接加载/条件存储(LL/SC)等强原子原语. CAS需要三个参数, 内存地址, 期望值和一个新值. 当且仅当内存地址的内容与期望值相同, 新值将会原子地写入到内存上. 布尔返回值表示是否值被重写. 即, CAS(addr, exp, new) 原子地执行下列表达式:

\[\{if (*addr \neq exp) return false; *addr <- new; return true;\} \]

LL 需要一个参数: 内存地址, 返回它的内容. SC 需要两个参数: 内存地址和新值. 只有自从当前线程最后一次通过LL读取后, 没有其他线程再进行写入, 那么新值会原子地写入. 布尔返回值表示写入是否发生. 另一个相关的指令 验证(VL), 接收内存地址, 返回从上次通过LL读取后是否有其他线程的写入.

出于实际的架构原因, 没有一个支持LL/SC (Alpha, MIPS, PowerPC)的架构支持VL或者上述定义的理想的LL/SC 语义. 同时, 所有这些架构, 偶尔的(不会无限频繁)允许SC错误的失败; 如, 即使自从上次当前线程通过LL读取到内容后,没有其他线程对其进行写入也可能返回false. 对于本文出现的所有算法中, CAS(addr, exp, new) 可被有限制的 LL/SC 实现:

\[\{do \{if (LL(addr) \neq exp) return false;\} \]

\[until SC(addr, new); return true;\}. \]

大多数主流处理器架构在对齐单字上支持CAS或有限制的LL/SC. 大多数32位架构(如对64位指令的支持)对齐双字的CAS和LL/SC, 但64位架构(如支持128位指令.)上没有该支持

ABA 问题

ABA问题是一个不同但相关的内存回收问题. 它几乎影响了所有无锁算法. 它首次在IBM370系统上关于CAS的文档上被指出. 问题的描述如下, 某线程读取某个共享位置的值为A, 之后其他线程将该位置上的值修改为不同的值B, 之后又修改为A. 之后, 原来的线程再次检查该位置时(如读或CAS), 对比会成功, 然后线程错误的在没有被修改的假设下,继续处理. 因此, 线程可能会损坏该对象或返回一个错误结果.

ABA问题是一个基本问题, 无论什么内存回收方式, 都必须防止. 它与内存回收的关系是前一个问题的解决方案, 例如自动垃圾收集 (GC) 和新方法, 通常可以防止 ABA 问题作为副作用而很少或没有额外开销.

对于大多数无锁动态对象来说都是如此。 但是,应该注意的是,一个常见的误解是 GC 在所有情况下都固有地防止了 ABA 问题。 然而,考虑一个在两个列表之间来回移动动态节点的程序(例如,LIFO 堆栈)。 在这种情况下,ABA 问题是可能出现的,即使是完美的 GC。

在无锁算法中的 ABA 预防方面, 新方法与 GC 一样强大。 也就是说, 如果一个无锁算法在 GC 下是 ABA 安全的, 那么对它应用危险指针使它在没有 GC 的情况下是 ABA 安全的. 正如我们在最近的一份论文中所讨论的那样,无锁算法总是可以在 GC 下成为 ABA 安全的,以及在没有 GC 的情况下使用危险指针. 在本文的其余部分, 当讨论在不支持 GC 的情况下使用危险指针预防 ABA 时,我们假设无锁算法在 GC 下已经是 ABA 安全的.

方法介绍

新方法主要基于以下观察: 在用于无锁动态对象的绝大多数算法中, 一个线程仅持有少量引用, 这些引用以后可能无需进一步验证即可用于访问动态节点的内容, 或者作为易受 ABA 影响的原子比较操作的目标或预期值.

新方法的核心想法是将一些单写多读的共享指针(风险指针)与每个可能操作共享对象的线程结合. 每线程风险指针的数量与相关对象的算法有关, 并且根据线程想要访问对象的类型的不同也有可能不同. 这个数量通常是1或2, 为了简化表示, 我们假设每个线程都有k个风险指针.

该方法只通过风险指针和线程调用的程序RetireNode(传递地址来淘汰节点)来通信. 方法分两部分, 操作退休节点的算法和无锁算法必须满足的条件从而保障内存回收的安全和避免ABA 问题.

算法

// Fig.1. Types and structures.

// Hazard pointer record
structure HPRecType {
  HP[K]: *NodeType;
  Next: *HPRecType;
}

// The header of the HPRec list
HeadHPRec : *HPRecType;
// Per-thread private variables
rlist : listType; // initially empty
rcount : integer; // initially 0

// Fig.2 The RetireNode routine
RetireNod(node: *NodeType) {
  rlist.push(node);
  rcount++;
  if (rcount>=R) 
    Scan(HeadHPRec);
}

// Fig.3 The Scan routine
Scan(head:*HPRecType) {
  // Stage 1: Scan HP list and insert non-null values in plist
  plist.init();
  hprec <- head;
  while(hprec != null) {
    for (i <- 0 to k-1) {
      hptr <- hprec.HP[i];
      if (hptr != null) 
        plist.insert(hptr);
    }
    hprec <- hprec.Next;
  }

  // Stage 2: Search plist
  tmplist <- rlist.popAll();
  rcount <- 0;
  node <- tmplist.pop();
  while(node != null) {
    if (plist.lookup(node)) {
      rlist.push(node);
      rcount++
    } else {
      PrepareForReuse(node);
    }
    node <- tmplist.pop();
  }
  plist.free();
}

Fig.1 显示了算法使用的共享和私有数据结构. 主要的数据结构是风险指针(HP)记录的列表. 列表被初始化为为N个线程分别包含一个HP记录. 风险指针的总数是 H = NK. 每个线程有两个静态私有变量, rlist(淘汰列表)和 rcount(淘汰计数), 用来维护一个退休节点都私有列表.

Fig.2 是 RetireNode 程序, 退休节点被插入到当前线程的淘汰列表里,并且更新列表长度. 当淘汰列表的长度达到上限R时, 线程通过Scan扫描风险指针列表. R值可以是任意的. 然而, 为了保证每个退休节点都常量期望均摊处理时间, R必须满足\(R = H+ \Omega(H)\).

Fig.3 是 Scan程序, 包括了两个阶段. 阶段1扫描整个HP列表的非空内容, 并将其插入到本地列表plist中, 该列表可以通过哈希表实现. 2阶段包括检查所有在rlist中的节点是否在plist中. 如果没有找到, 节点被标记为可以重用, 否则还被保存到rlist直到当前线程的下次扫描. plist的插入和查找期望时间是线性的.

如果最差时间复杂度有要求, 可以使用平衡搜索树来实现plist, 插入和查询复杂度都是\(O(log p)\), 其中p是在Scan阶段1中风险指针扫描到的非空指针的数量. 在这种情况下, 每个退休节点的均摊复杂度也是\(O(log p)\).

在实践中, 为了简化和速度, 推荐使用数组来实现plist, 在阶段1结束后对其排序, 在阶段2使用二分查找. 我们在第5段使用了后面这种方式来实现实验. 在这里忽略对哈希表, 平衡二叉树, 排序和二分查找的介绍, 它们都是常见的序列算法.

本文上下文中内存回收方法的任务是确定退休节点何时有资格安全重用,同时允许内存回收。 因此,PrepareForReuse 例程的定义对于多个实现是开放的,并且不是该方法的一个组成部分。 该例程的一个明显实现是使用标准库调用进行内存释放,例如释放,立即回收节点以进行任意重用。 另一种可能性——为了减少为每个节点分配和释放调用 malloc 和 free 的开销——是每个线程可以维护一个有限大小的空闲节点私有列表。 当一个线程用完私有空闲节点时,它会分配新的节点,当它积累了太多私有空闲节点时,它会释放多余的节点。

该算法是无等待的; 期望时间为\(O(R)\) (当使用对数查找结构则最差复杂度为\(R(R log p)\)), 将\(\Theta(R)\)退休节点标记为可重用. 它只使用单字读写, 即使部分或所有线程被延迟或崩溃也提供了尚不符合重用条件的退休节点数量的上限 NR.

算法扩展

本节所展示的可选扩展, 可以增强核心算法的灵活性.

如果最大线程数量N预先未知, 我们可以使用简单的程序来将新的HP记录加入到HP列表中. 因为最大线程数量是有限的, 该程序可以是无等待的. 该方法也可以为线程动态分配额外的危险指针.

在某些程序中, 线程可能被动态的创建和销毁, 因此可能需要允许HP记录被重用. 加入一个指示HP是否在被使用或可以被重用的标志. 线程退出时将标志置为可重用, 线程创建时到HP列表里查找可用的HP, 并通过测试和设置(TAS)来获取. 如果没有找到, 则可按上文创建新的HP.

由于线程可能有剩余的退休节点尚未确定为可重用, 因此可以将两个字段添加到 HP 记录结构中, 以便要退出的线程可以将其 rlist 和 rcount 变量的值传递给继承 HP 记录的下一个线程

此外, 可能需要保证每个符合重用条件的节点最终都被释放, 除非线程失败. 为此, 在执行Scan后, 线程会执行HelpScan, 检查每个 HP 记录. 如果 HP 记录处于非活动状态, 则线程使用 TAS 锁定它并从其 rlist 中弹出节点. 每当线程累积 R 个节点时, 它就会执行一次扫描. 因此, 即使一个线程退出并留下一个带有非空 rlist 的 HP 记录并且它的 HP 记录碰巧没有被重用, rlist 中的节点仍将被其他执行 HelpScan 的线程处理.

Fig. 4显示了包含上述扩展的算法版本. 该算法仍然是无等待的, 并且只使用单字指令.

// Fig. 4 Algorithm extensions.

structure HPRecType {
  HP[K]: *NodeType; 
  Next: *HPRecType;
  Active: Boolean;
  rlist: listType;
  rcount: integer;
}

// Shared variables
HeadHPRec : *HPRecType; // initially null
H : integer; // initially 0
// Per-thread private variable
myhprec: *HPRecType; // initially nyll

AllocateHPRec() {
  // First try to reuse a retired HP record
  for (hprec <- HeadHPRec; hprec != null; hprec <- hprec.Next) {
    if (hprec.Active) continue;
    // TAS(addr) == !CAS(addr, false, true)
    if (TAS(&hprec.Active)) continue;
    // Succeeded in locking an inactive HP record
    myhprec <- hprec;
    return; 
  }
  // No HP records available for reuse
  // Increment H, then allocate a new HP and push it
  do { // wait-free - max. num. of thread is finite.
    oldcount <- H;
  } until CAS(&H, oldcount, oldcount+K);
  // Allocate and push a new HP record
  hprec <- NewHPRec();
  Initialize the fields of the new HP record.
  do { // wait-free - max. num. of threads is finite
    oldhead <- HeadHPRec;
    hprec.Next <- oldhead;
  } until CAS(&HeadHPRec, oldhead, hprec);
  myhprec <- hprec;
}

RetireHPRec() {
  for (i <- 0 to K-1)
    myhprec.HP[i] <- null;
  myhprec.Actice <- false;
}

RetireNode(node: *NodeType) {
  myhprec.rlist.push(node);
  myhprec.rcound++;
  head <- HeadHPRec;
  if (myhprec.rcound >= R(H)) { // R(H) = H + Omega(H)
    Scan(head);
    HelpScan();
  }
}

// Scan() is the same as in Figure 3 except that rlist and rcount
// are fields of *myhprec instead of being private variables.

HelpScan() {
  for (hprec <- HeadHPRec; hprec != null; hprec <- hprec.Next) {
    if (hprec.Active)
      continue;
    if (TAS(&hprec.Active))
      continue;
    while (hprec.rcount > 0) {
      node <- hprec.rlist.pop();
      hprec.rcount--;
      myhprec.rlist.push(node);
      myhprec.rcount++;
      head <- HeadHPRec;
      if (myhprec.rcount >= R(H))
        Scan(head);
    }
    hprec.Active <- false;
  }
}

前置条件

对于使用无锁对象的算法,必须满足一些前置条件来保障内存回收的正确性和避免ABA问题. 当线程将引用(如节点的地址)赋值到风险指针上后, 基本相当于宣告其他线程可能在风险情况下使用该引用(如未验证引用有效性就访问引用的内容), 因此其他线程应该避免在引用被风险指针指向时回收或重用它. 该声明(即,设置危险指针)必须在淘汰节点之前发生,并且危险指针在引用不再危险之前必须继续保持该引用.

为了正式描述这些条件, 有如下定义:

节点: 我们使用术语节点来描述一系列内存位置, 这些位置在某些时候可以通过它在使用危险指针的对象中的实际使用, 或从参与线程的角度被视为一个逻辑实体. 因此, 多个节点可能在物理上重叠, 但仍被视为不同的逻辑实体.

在给定时刻t, 每个节点n都为满足下列一种状态:

  1. 已分配: n 由参与线程分配,但尚未插入到关联的对象中.
  2. 可访问: n 可以被从根节点相关的对象的有效指针访问到.
  3. 已删除: n 已经访问不到, 删除线程仍可能使用它
  4. 已退休: n 已经被删除, 删除线程也不会再使用它, 但还未被释放.
  5. 释放: n 的内存已经可以被重新分配
  6. 不可用: n 的所有内存已经分配给新对象.
  7. 未定义: n 的内存位置当前不能被视为一个节点.

拥有: 如果 n 在 时间 t 时, 状态在线程 j 中的 已分配/已删除/已退休, 那么线程 j 在时间 t 拥有节点 n. 每个节点最多有一个拥有者. 已分配节点的拥有者是分配它的线程. 删除节点的所有者是将其从对象中删除的线程(即将其状态从可达更改为已删除). 退休节点的所有者与删除它的所有者相同.

安全: 在时间 t, 要么 n 可达,要么 j 拥有 n, 则称节点 n 在时间 t 对线程 j 是安全的.

可能不安全: 从线程 j 的角度来看, 如果仅通过检查 j 的私有变量和算法的语义不能肯定地确定在时间 t 节点是安全的, 则节点在时间 t 可能不安全.

访问风险: 如果线程 j 的算法中的步骤 s 可能导致对节点的访问在执行时对 j 可能不安全访问风险.

ABA 风险: 线程 j 的算法中的步骤 s 是 ABA 风险,如果它包含一个可能造成 ABA 的比较, 该比较涉及在执行 s 时对 j 可能不安全的动态节点, 例如 1) 节点的地址(或其算术变化)是易受 ABA 影响的比较的预期值, 或 2) 包含在动态节点中的内存位置是可能造成 ABA 的比较的目标

访问风险的引用: 线程 j 在时间 t 时有一个或多个私有变量持有着 n 的地址或间接地址, 并且 j 被保证(除非它崩溃)到达危险地使用 n 的地址的访问危险 s,即当 n 对 j 可能不安全时访问 n. 叫做线程 j 在时间 t 有对节点 n 的访问风险的引用

ABA 危险引用: 线程 j 在时间 t 持有对节点 n 的 ABA 危险引用, 如果在时间 t, j 的一个或多个私有变量持有 n 的地址或它的间接地址, 并且 j 是有保证的(除非它崩溃)达到一个危险地使用了 n 的地址的* ABA 风险* s.

风险引用: 访问风险ABA危险引用都是风险引用.

非正式的说, 风险引用是一个地址不会做预先的验证就带有风险行为的访问. 即访问可能不安全内存或对目标地址或期望值进行易于的 ABA 比较.

线程持有的风险指针就是告诉其他线程, 它可能后续会对引用做出风险操作, 而不进行验证. 然而, 这种声明如果发生在引用已经有风险, 或者说由于其他线程已经将节点删除并扫描HP列表,没有找到节点的匹配, 而导致节点可能不安全, 后时是没有意义的. 因此, 相关算法必须满足的条件是, 每当一个线程持有对节点的危险引用时, 必须至少有一个线程的危险指针从节点开始时就一直持有该引用, 对于线程来说绝对是安全的. 请注意,此条件意味着在节点退休时,没有线程可以创建对节点的新的危险引用.

前置条件的正式描述如下, 其中\(HP_j\)是线程j的风险指针集合.

forall times t, threads j, and nodes n,
  (at t, j holds a hazardous reference to n) =>
  (any hp in HP_j, t' <= t ::
    (at t', n is safe for j) and
    (forall time during [t', t], hp = &n)).

正确性

以下引理和定理取决于满足 3.3 节中的条件。

\[Lemma 1. \forall times \ t, thread\ j and\ nodes\ n, (\forall hp \in HP_j, t' \leqslant t, (\forall times \ during [t', t], n \ is\ not\ safe\ for\ j) \]

\[\wedge (\exists t' \in [t', t] :: \ at t'', hp \ne \&n)) => (at \ t, j \ does \ not \ hold \ a \ hazardous \ reference \ to \ n) \]

非正式地,如果对线程 j 的危险指针的扫描没有找到与退休节点 n 的匹配项,那么在扫描结束时 j 肯定没有对 n 的危险引用。

证明: 采用反证法, 假设引理为假, 即蕴涵的前件为真, 后件为假. 因此, 在时间 t, j 持有 n 的风险指针. 然后, 根据第 3.3 节中的条件, 我们已经假设在 [t', t] 期间 n 对 j 不安全, 当 n 对 j 安全时, 必须有一段在 t' 之前的时间 \(t_0\), 风险指针指向了 n. 即 j 必须有一个风险指针在 [\(t_0\), t] 指向了 n. 但是,这与最初的假设相矛盾,即对于 j 的每个危险指针,在[t', t]的某个时间没有风险指针指向n. 因此, 初始假设错误, 即引理为真.

\[Lemma 2. \forall times\ t, threads\ j, and\ nodes\ n, (at \ t, n\ is\ identified\ in\ stage\ 2\ of\ Scan\ as\ eligible\ for\ reuse) => \]

\[(\forall hp \in HP_j, \exists t' \in [t_0, t] ::\ at\ t', hp \ne \&n), where\ t_0\ is\ the\ start\ time\ of\ the\ current\ execution\ of\ Scan. \]

非正式地, 退休节点能被标记为可重用仅当所有线程的风险指针都没有指向该节点.

证明 利用反证法. 即, 自 t0 以来, j 的至少有一个危险指针一直指向 n. 然后, 通过控制流(阶段 1), 在阶段 1 结束时, plist 必须包含一个指向 n 的指针. 根据(第 2 阶段的)控制流,n 不符合重用条件. 与最初的假设矛盾.

\[Theorem 1. \forall times\ t, threads\ j, and nodes\ n, (at\ t, n\ is\ identified\ in \ stage\ 2\ of\ Scan\ as\ eligible\ for\ reuse) => \]

\[(at\ t, j\ does\ not\ hold\ a\ hazardous\ reference\ to\ n). \]

非正式地,如果 Scan 识别出一个节点有资格重用,那么一定不存在线程持有对它的危险引用.

证明 当j为执行Scan的线程时, 定理显然为真. 考虑j为其他线程, 假设在时间 t, n 被 Scan阶段2标记为可重用. 因此, 根据安全的定义, n 对于j 从Scan开始就是不安全的, 根据引理2, 对于每个风险指针, 在Scan执行时都会有一段时间没有志向n. 因此根据引理1, 在时间t, j没有持有对n的风险引用.

根据访问危险引用的定义和定理 1, 风险指针方法(即算法和条件)保证当节点空闲或不可用时, 没有线程访问其内容, 即危险指针方法保证安全的内存回收.

根据ABA 危险引用的定义和定理 1, 风险指针方法保证, 当一个节点空闲或不可用时, 任何线程都不能持有对它的引用, 而无需进一步验证以使用该引用作为可能产生 ABA 的比较的目标或预期值. 这与 GC 对 ABA 问题提供的保证相同.



这篇关于[翻译转载] 风险指针: 无锁对象的安全内存回收机制的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程