Thread progress 是 Erlang 虚拟机中负责无锁同步的基础设施。它在文档中的描述初看有些抽象,比如“线程返回到一个与任何其他代码都无关的已知状态”。但结合载体迁移等具体场景后,各个概念会逐渐变得清晰。

一、Thread Progress 要解决的两类问题

多线程环境中有两个常见的麻烦。

麻烦一:如何知道所有线程都用完了某个共享结构?

要释放一个全局数据结构,必须确认没有任何线程还在访问它。最直接的办法是引用计数:每次访问加一,退出减一,归零后释放。问题是计数器所在的缓存行会被所有参与的 CPU 核心争抢,产生严重的缓存乒乓。访问越频繁,扩展性越差。我们需要一种读路径开销更低的跟踪手段。

补充:什么是缓存乒乓?

缓存乒乓(Cache Ping-Pong)是指多核 CPU 中同一个缓存行被多个核心反复争抢修改,导致它在不同核心的私有缓存之间来回传输,像乒乓球一样。引用计数每次访问都要原子修改计数器,该计数器所在缓存行就会在所有核心间“乒乓”,严重拖慢性能。Thread progress 正是为了避免这种热路径上的争用。

麻烦二:无锁编程中的内存可见性

现代硬件都会对内存访问重排序,只是程度不同。使用锁时,加锁/解锁自带的屏障让重排对程序员透明。但无锁算法必须主动插入硬件内存屏障来保证顺序,这些指令开销不小,且要尽量减少使用次数。

Thread progress 的设计目标,正是同时应对这两件事:以可控的开销判断“所有线程均已离开临界区”,并在这个过程中自然提供必要的屏障,支撑无锁发布与回收。

二、Thread Progress 的基本思路

Erlang 虚拟机将需要追踪的线程称为 managed threads(受管线程),主要是调度器线程,它们会在运行循环中频繁汇报进度。那些可能长时间阻塞、无法频繁汇报的线程(如异步 I/O 线程)属于 unmanaged threads(非受管线程)。

核心机制是这样:发起一次 thread progress 操作,然后等待所有受管线程在该操作期间内都经历过两个特定事件。一旦确认,就可以安全地推导内存状态。

两个事件是理解整个机制的关键。

事件一:返回到一个独立的“已知状态”

文档中对这个事件的定义是:线程从其他代码返回到 thread progress 功能中的一个已知状态,该状态独立于任何其他代码。拆开来看,主干就是:线程返回到一个已知状态。

这个状态位于调度器线程的主循环中,是一个固定的“打卡点”。当调度器在执行业务代码、GC 或其他运行时逻辑时,它游离在 thread progress 的视野之外;只有跳回这个打卡点,才算退出了可能访问受保护数据结构的上下文。此时我们可以认为该线程不再持有对之前共享数据的任何引用。这个状态与具体业务逻辑完全解耦。

补充:调度器主循环是什么?

一个调度器通常对应一个 OS 线程。调度器线程反复执行一段核心循环代码(while 循环),从运行队列取进程、执行、处理 GC/信号等,然后回到循环顶部。这个循环顶部就是“已知状态”。当线程回到这里,它已不执行任何业务代码,不再持有之前共享数据的引用,是天然的“安全打卡点”。

事件二:执行了一次全内存屏障

每个受管线程在汇报进度时,都会执行一条全内存屏障。它保证此前所有内存写入全局可见,且后续读取不会穿越到屏障之前。这为无锁发布提供了同步基础。

补充:内存屏障的作用

全内存屏障是一堵“墙”:屏障之前的所有内存访问必须完成,之后的内存访问不能提前。它防止编译器和 CPU 对内存操作重排序。在 thread progress 中,线程回到安全点后、更新进度之前执行屏障,确保它之前对共享数据的写入真正刷到全局可见,之后读到的进度值不会被重排到屏障之前。

两个事件必须有序地发生在相关内存操作之间。发起操作时,调用者得到一个进度值,然后轮询等待该值达成。在此期间,每个受管线程必须在发起之后、完成之前至少各经历一次事件一和事件二。通过共享内存上的通信,系统可以确信,操作完成后某些内存修改对所有受管线程已一致可见。

三、与载体迁移的呼应:从逻辑删除到物理释放

将 thread progress 放到载体迁移(carrier migration)的场景里,其作用会变得非常具体。

载体生命周期中有一个典型动作:某个载体从全局无锁池中被取出(逻辑删除),但不能立刻 munmap 物理释放。因为可能有另一个线程在此前的遍历中已经拿到了该载体的指针,正在使用。如果直接释放,就是野指针。

Thread progress 充当了从逻辑删除到物理释放之间的“安全等待期”。一条典型的时间线如下:

T=100
线程 B 进入遍历载体池的临界区。此时线程 B 的进度值为 100(即它上次更新进度后尚未再次更新)。它获得了载体 X 的指针,可能继续使用它。

T=101
线程 A 将 X 从池中逻辑删除,并为 X 打上回收标签 tag = 100。这个 tag 的含义是:所有进度值 <= 100 的线程都可能持有 X 的指针,因此 X 暂时不能释放。

T=102
线程 B 完成遍历,退出临界区,逻辑上不再持有 X 的指针。

T=103
线程 B 回到调度器主循环的安全点(事件一),执行全内存屏障(事件二),然后更新自己的进度值,使其从 100 变为 101。

与此同时,线程 A(发起回收操作的线程)一直在轮询所有受管线程的进度值,例如在 erts_thr_progress_wait(100) 中循环检查。当线程 A 观察到所有线程的进度都已超过 100 时,它确认安全条件达成:所有在 tag=100 之前可能持有 X 指针的线程,都已经退出了临界区并完成了内存发布。

T=104
线程 A 确认全局进度已越过 100,于是 X 可以被安全回收或重新入池。

补充:谁在轮询?

这里明确是发起操作的线程 A 在轮询,而不是一个独立的“系统线程”。线程 A 调用了类似 erts_thr_progress_wait(tag) 的函数,内部循环检查所有受管线程的进度值,直到全部超过 tag。

整个过程中,线程 B 读取载体指针时不需要任何原子操作或屏障。它只管读,退出临界区后正常汇报进度。后台的 thread progress 批量确认“所有人都已离开”。这与引用计数的取舍可以这样对比:

方案读路径开销回收时机
引用计数每次取得/释放引用都需原子修改计数器,读路径开销大引用归零立即释放
thread progress读路径零开销回收会延迟,需等待宽限期

在高并发读多写少的场景下,这种置换非常划算。载体迁移正是靠这一点,才敢在调度器的高频路径上使用无锁池。

四、两种经典使用模式

按照两个事件的性质,thread progress 支持两种典型用法。

模式一:基于事件一的无引用计数释放

假设功能 F 使用数据结构 D,线程进入 F 时获取 D 的引用,离开前放弃。如果先撤掉全局能找到 D 的入口(例如将指针置空,或切换到新版本),然后等待所有受管线程都经历事件一,就可以安全释放旧 D。事件一的“返回已知状态”正是线程离开 F 代码的标志。这个模式和前面载体回收的逻辑完全一致。

模式二:基于事件二的复杂修改无锁发布

写者先在私有区域准备好新数据,执行一条全屏障,然后等待所有受管线程都经历事件二(即各自的全屏障),最后才把新数据指针发布到全局可见位置。此后,任何受管线程读取这块内存时无需额外屏障,就能看到一致的修改。

读端不需要屏障的原因在于,每个线程在上一次汇报进度时已经执行过全屏障,那个屏障顺便充当了读端的同步点,很接近 RCU 的读端无锁模式。载体池的无锁双向链表操作(插入和获取采用不同遍历方向)之所以能够正确并发,也依赖了这种内存顺序保证。

五、实现中的几个关键决策

Thread progress 使用一个全局计数器,在所有受管线程都汇报进度后递增。为避免热点,每个线程在专属缓存行里写确认标志,由一个动态选出的领导线程(leader)统一读取并推动全局计数。通信方向是领导广播,其他线程点对点回复,跨核流量很低。

erts_thr_progress_later() 返回的值是当前已知全局值加 2。这是因为在调用时,其他线程可能已经确认了加 1,但不可能已经确认加 2。等待这个值就能可靠保证操作期间所有线程都更新过进度,即经历了两个事件。

补充:为什么加 1 可能已发生,加 2 不可能?

全局进度要推进 1,需要所有受管线程都更新本地进度超过当前值。假设当前全局值为 G,调用者读到 G,但可能其他线程刚刚完成了更新,使实际全局值变为 G+1。所以读到 G 时可能已落后一步。但全局要推进到 G+2,需要所有线程(包括调用者自己)都更新超过 G+1,而调用者尚未更新,所以不可能已经达到 G+2。等待 G+2 迫使所有线程即使已完成 G+1,也必须再完成一轮更新,从而确保每个线程都至少经历了一次事件一和事件二。

非受管线程偶尔也要访问受保护数据,但它们不能频繁汇报进度。它们通过 unmanaged_delay 临时阻止进度推进,给自己开一个安全窗口,操作完后用 unmanaged_continue 解除。实现上用了两个引用计数器(current 和 waiting)防止永久阻塞。延迟只阻止下一次全局递增,尽量减少干扰。但频繁调用 delay/continue 会把计数器缓存行变成新热点,因此文档强调要谨慎使用。

补充:非受管线程延迟机制的细节

  1. 非受管线程进入临界区时递增 current 计数器,并获得一个句柄。
  2. 退出时用句柄递减之前递增的那个计数器。
  3. Leader 线程在递增全局进度前检查 waiting 是否为零;若为零则交换 current 和 waiting,然后递增全局计数器。
  4. 交换后,新的 waiting 记录的是交换前 current 中的活跃线程数,这些线程退出时会递减它;新的 current 继续接收新的延迟请求。

这样设计避免单计数器被不同线程持续占用的活锁,且延迟只影响第二次全局递增,减少干扰。

在载体迁移中,异步 I/O 线程就是通过这套接口安全地短暂持有载体引用,而无需变成受管线程。

六、看得见的收益

ETS 表原本用引用计数管理主结构生命周期,每次操作都在热路径上争抢计数器。后来改为删除时向每个调度器发送“确认删除”作业来回收,虽然缓解了热路径压力,但删除操作本身需要分配与调度器数量相等的作业块,代价不低。

引入 thread progress 后,只需调度一个 thread_progress_later 回调来完成延迟释放。代码大幅简化,在四核机器上跑 Mnesia TPCB 基准测试,每秒事务数提升超过 10%。

补充:ETS 源码中的具体印证

在 Erlang/OTP 源码中,erl_db.cschedule_free_dbtable 函数是 ETS 释放表结构的入口。它调用 erts_schedule_thr_prgr_later_cleanup_op(free_dbtable, (void *) tb, &tb->release.data, sizeof(DbTable)),将 free_dbtable 注册为延迟回调。erts_schedule_thr_prgr_later_cleanup_operts_schedule_thr_prgr_later_op 的变体,会在回调执行后自动释放 ErtsThrPrgrLaterOp 所在的内存块(此处是整个 DbTable)。ErtsThrPrgrLaterOp 结构体被直接嵌入到 DbTable(通过 tb->release.data)和 DbTableCATreeNode(ordered_set 树的节点,字段名 free_item)中,避免了额外内存分配。其他如 persistent_term、端口、追踪器也使用了相同机制。

补充:澄清“删除表”与“删除记录”

Thread progress 用于删除整张 ETS 表时释放表的主结构,而不是删除表中的某一条记录。删除单条记录由表级锁或分段锁保护,不涉及表结构的生命周期,不需要 thread progress。只有当整个表被删除,需要回收表结构内存时,才使用 thread progress 安全等待。

载体迁移同样受益于这一改造。原本复杂的确认作业机制被统一基础设施替代,一个载体从池中移除后,注册一个回调,thread progress 在安全时机自动触发回收。随着更多内部组件(进程表、端口表等)迁移到这套机制,系统总体的缓存一致性通信反而下降:原本分散的锁、引用计数、独有屏障被统一的宽限期检查均摊和消解。

补充:ETS 分配器与 thread progress 的关系

ERTS 中有一个专门的 ets_alloc 分配器,通常每个调度器(CPU 核心)一个实例,与调度器线程绑定,以减少分配路径上的锁竞争。ets_alloc 负责实际的内存分配/释放,而 thread progress 负责确定“何时释放才安全”。两者协同:逻辑删除表后,thread progress 延迟到安全时机,再调用分配器的释放函数归还内存。

七、API 概览(对话补充新增)

Thread progress 对外暴露的核心 API 包括:

API说明
ErtsThrPrgrVal erts_thr_progress_later(void)发起操作,返回当前全局进度 + 2 的目标值。
int erts_thr_progress_has_reached(ErtsThrPrgrVal val)非阻塞检查是否已达到目标进度。
void erts_thr_progress_wakeup(ErtsSchedulerData *esdp, ErtsThrPrgrVal val)请求在进度达到 val 时唤醒调用线程(阻塞等待)。
int erts_thr_progress_update(ErtsSchedulerData *esdp)受管线程更新自己的进度,若返回非零需调用 leader_update。
int erts_thr_progress_leader_update(ErtsSchedulerData *esdp)领导线程推进全局进度。
ErtsThrPrgrDelayHandle erts_thr_progress_unmanaged_delay(void) / void erts_thr_progress_unmanaged_continue(ErtsThrPrgrDelayHandle handle)非受管线程延迟/恢复进度。
void erts_schedule_thr_prgr_later_op(void (*funcp)(void *), void *argp, ErtsThrPrgrLaterOp *memp)注册回调,进度达成后自动执行。另有 _cleanup_ 变体自动释放内存。

使用 later() + has_reached() 可以轮询;使用 wakeup() 可以阻塞等待;使用 schedule_thr_prgr_later_op() 可以异步回调,适合发起后继续工作的场景。

八、总结

Thread progress 是一种基于宽限期的无锁同步框架。它用定期汇报进度加分布式确认的方式,把昂贵的内存屏障开销固定化,同时向上提供“等待所有线程离开临界区”和“无锁发布”两种原语。

它与载体迁移的关系可以这样看:载体迁移通过所有权与经营权分离、无锁池、方向分离搜索和自属载体优先等策略实现了内存的高效流转与 NUMA 本地性;而 thread progress 是这些机制安全运转的基石。它让跨线程的内存回收不必引入引用计数热点,使读取路径完全无锁。

搞清楚两个事件的真正含义,再看到它们在载体生命周期时间线里的精确对应,thread progress 就不再是一堆抽象概念,而是一把量尺:量出线程们走到了哪里,告诉你什么时候可以安心处理旧资源