查看源码 跟踪
实现
调用、返回和异常跟踪
跟踪的实现方式是在被跟踪的函数中设置断点,并在命中这些断点时发送相应的跟踪消息。
- 调用跟踪消息会立即发送。
- 返回跟踪会将一个帧压入堆栈,该帧返回到一个在遇到时发送跟踪消息的指令。
- 异常跟踪也会将一个帧压入堆栈,但只有在抛出异常时进行堆栈扫描时遇到该帧,才会发送跟踪消息。
这意味着必须小心,不要在永远不会返回的函数上使用返回或异常跟踪,因为每次调用都会压入一个永远不会被移除的帧。
另一个限制是,由于断点位于被调用者而不是调用者中,我们只能获取函数入口处的信息。这意味着我们实际上无法知道是谁调用了我们:由于我们只能检查堆栈,我们只能说我们将要返回到哪里,这与知道谁调用了我们并不完全相同。
举例来说,当启用 caller
选项时,来自 bar/1
的所有跟踪消息都会说它们是从 foo/0
调用的,即使它在途中经过了一堆其他函数。
foo() ->
lots(),
ok.
lots() ->
'of'().
'of'() ->
indirections().
indirections() ->
bar(10).
bar(0) ->
done;
bar(N) ->
bar(N - 1).
导出跟踪
在解释器中,断点设置在导出入口的代码跳转指令内部,并且它们的地址向量被更新以指向它们。这样,只有远程调用会命中断点,而对同一函数的本地调用则不受影响,但其行为方式与本地断点相同。
在 JIT 中情况会稍微复杂一些。有关更多详细信息,请参阅 BeamAsm.md
。
设置断点
简介
在 OTP R16 之前,当通过 erlang:trace_pattern
更改跟踪设置时,虚拟机中的所有其他执行都会暂停,而跟踪操作在单线程模式下执行。与代码加载类似,这会对可用性造成严重问题,并且会随着核心数量的增加而加剧。
在 OTP R16 中,断点在代码中设置,而不会阻塞虚拟机。Erlang 进程可以在整个操作过程中并行地继续执行,不受干扰。与代码加载使用相同的基本技术。准备一个断点的暂存区域,然后通过单个原子操作使其生效。
断点轮的重新设计
为了在不使用单线程模式的情况下更容易管理断点,对断点机制进行了重新设计。旧的“断点轮”数据结构是每个被检测函数的断点的圆形双向链表。它是在 SMP 仿真器之前发明的。为了在 SMP 仿真器中支持它,它基本上被扩展为每个调度器一个断点轮。随着添加的断点类型越来越多,实现变得混乱且难以理解和维护。
在新设计中,旧的轮被丢弃,而是用一个结构体 (GenericBp
) 来保存每个被检测函数的所有类型断点的数据。使用位标志字段来指示启用了哪些不同类型的断点操作。
似是而非,但又不同
即使 trace_pattern
使用与非阻塞代码加载相同的技术(具有复制的数据结构生成和原子切换),它们的实现也是完全独立的。最初的一个想法是使用现有的代码加载机制来执行一个虚拟加载操作,该操作会复制受影响的模块。然后,可以在该副本中设置断点,然后再使用与代码加载相同的原子切换使其可访问。这种方法似乎很直接,但存在许多缺点,其中一个缺点是在检测许多模块时会占用大量的内存。另一个问题是执行如何访问新的检测代码。通常,加载的代码只能通过外部函数调用来访问。跟踪设置必须立即激活,而无需外部函数调用。
选择的解决方案是,跟踪改为对断点的数据结构应用复制技术。保留两代断点,并通过索引 0 和 1 进行标识。全局原子变量 erts_active_bp_index
将决定运行代码将使用哪一代断点。
没有原子操作的原子性
不使用代码加载生成(或任何其他代码复制)意味着 trace_pattern
必须在某个时候写入活动的 Beam 代码,以便运行的进程可以访问暂存的断点结构。这可以通过每个被检测函数执行一个原子写入操作来完成。但是,Beam 指令字是通过普通的内存加载读取的,而不是通过原子 API 读取的。我们唯一需要保证的是,写入的指令字被视为原子的。要么完全写入,要么根本不写入。这对于我们使用的所有硬件架构上的字对齐写入操作都是正确的。
添加新断点
这是一个简化序列,描述了添加新断点时 trace_pattern
所经历的过程。
获取独占代码修改权限(暂停进程直到获取到权限)。
分配断点结构
GenericBp
,包括两代。将活动区域设置为禁用,标志字段为零。将原始指令字保存在断点中。在第一个指令
ErtsFuncInfo
标头偏移量-sizeof(UWord)
处写入一个指向断点的指针。将断点的暂存区域设置为启用,并包含指定的断点数据。
等待线程进度。
将
op_i_generic_breakpoint
作为函数的第一个指令写入。此指令将执行在偏移量-sizeof(UWord)
处找到的断点。等待线程进度。
通过切换
erts_active_bp_index
提交断点。等待线程进度。
“整合”。通过将断点的新的暂存区域(旧的活动区域)更新为与新的活动区域相同,为下次调用
trace_pattern
做准备。释放代码修改权限并从
trace_pattern
返回。
在步骤 1 中获取的代码修改权限“锁”也由代码加载获取。这确保一次只有一个进程可以暂存新的跟踪设置,并且还可以防止并发代码加载,并确保我们在整个序列中看到 Beam 代码的一致视图。
在步骤 6 和 8 之间,运行的进程可能会执行写入的 op_i_generic_breakpoint
指令。它们将获取步骤 3 中写入的断点结构,读取 erts_active_bp_index
并执行断点的相应部分。在步骤 8 中的切换变得可见之前,它们将执行断点结构的禁用部分,除了执行保存的原始指令之外,什么也不做。
步骤 10 中的整合将使新的暂存区域与新的活动区域相同。这将使下次调用可能不会影响所有现有断点的 trace_pattern
更简单。所有未受影响的断点的暂存区域都可以变为活动状态,而无需 trace_pattern
访问。
更新和删除断点
以上序列仅描述了添加新断点的过程。我们基本上执行相同的序列来更新现有断点的设置,除了可以跳过步骤 2、3 和 6,因为它们已经完成。
要删除断点,还需要更多步骤。其思路是首先将断点暂存为禁用状态,执行切换,等待线程进度,然后通过还原原始 Beam 指令来删除禁用的断点。
这是一个更完整的序列,其中包含添加、更新和删除断点。
获取独占代码修改权限(暂停进程直到获取到权限)。
分配新的断点结构,其中包含禁用的活动区域和原始 Beam 指令。在偏移量
-sizeof(UWord)
处的ErtsFuncInfo
标头中写入指向断点的指针。更新所有受影响的断点的暂存区域。禁用要删除的断点。
等待线程进度。
将
op_i_generic_breakpoint
作为所有具有新断点的函数的第一个指令写入。等待线程进度。
通过切换
erts_active_bp_index
提交所有暂存的断点。等待线程进度。
卸载。为禁用的断点还原原始 Beam 指令。
等待线程进度。
整合。通过更新所有已启用断点的新暂存区域(旧的活动区域),为下次调用
trace_pattern
做准备。取消分配禁用的断点结构。
释放代码修改权限并从
trace_pattern
返回。
所有等待线程进度
在上面的序列中,有四轮等待线程进度。在代码加载序列中,我们牺牲了三代内存开销来避免第二轮线程进度。trace_pattern
的延迟不应该是一个大问题,因为它通常不会以快速序列调用。
步骤 4 中的等待是为了确保所有线程在通过步骤 5 中写入的 op_i_generic_breakpoint
指令访问断点结构后,都能看到断点结构的更新视图。
步骤 6 中的等待是为了使新跟踪设置的激活“尽可能原子化”。不同的核心可能会在不同的时间看到 erts_active_bp_index
的新值,因为它是在没有任何内存屏障的情况下读取的。但这已经是我们在没有更昂贵的线程同步的情况下所能做到的最好的了。
步骤 8 中的等待是为了确保在我们知道没有线程仍在访问禁用断点的旧启用区域之前,我们不会为禁用的断点还原原始 Bream 指令。
步骤 10 中的等待是为了确保没有残留线程仍在访问要在步骤 12 中取消分配的禁用断点结构。
全局跟踪
带有 global
选项的调用跟踪仅影响外部函数调用。这早些时候是通过在不使用断点的情况下在导出入口中插入特殊的跟踪指令来处理的。使用新的非阻塞跟踪,我们希望避免对全局跟踪进行特殊处理,并利用断点机制内的暂存和原子切换。解决方案是为全局调用跟踪创建相同类型的断点结构。与本地跟踪的区别在于,我们在导出入口而不是代码中插入 op_i_generic_breakpoint
指令(其指针在偏移量 -4 处)。
未来工作
当为被跟踪的模块加载新代码时,或者当存在默认跟踪模式时加载代码时,我们仍然会进入单线程模式。这并非无法修复,但这需要跟踪 BIF 和加载器 BIF 之间更紧密的合作。