Concepts
Perfetto中的跟踪是一个异步多写单读管道。从很多方面来看,其架构与现代GPU的命令缓冲区非常相似。
跟踪数据流的设计原则如下:
- 跟踪快速路径基于直接写入共享内存缓冲区。
- 高度优化以实现低开销写入,但未针对低延迟读取进行优化。
- 跟踪数据最终会在跟踪结束或通过IPC通道发出显式刷新请求时提交到中央跟踪缓冲区。
- 生产者是不受信任的,因此不应能够看到彼此的跟踪数据,因为这可能会泄露敏感信息。
在一般情况下,跟踪中涉及两种类型的缓冲区。当从Linux内核的ftrace基础结构中提取数据时,还会涉及第三个阶段的缓冲(每个CPU一个):
- 跟踪服务的中央缓冲区(Tracing service's central buffers)
跟踪服务的中央缓冲区(在上面的图片中为黄色)由用户在跟踪配置文件的buffers部分中定义。在最简单的情况下,一个跟踪会话对应一个缓冲区,无论数据源和生产者的数量如何。
这是跟踪数据最终存储在内存中的地方,这些数据可能来自内核的ftrace基础设施、来自traced_probes中的其他数据源,或者来自使用Perfetto SDK的另一个用户空间进程。在跟踪结束时(或在流式模式下进行时),这些缓冲区的内容会被写入到输出跟踪文件中。
这些缓冲区可以包含来自不同数据源甚至不同生产者进程的跟踪数据包的混合。这些数据包的流向由跟踪配置的buffers mapping部分定义。因此,跟踪缓冲区不会跨进程共享,以避免生产者进程之间的交叉通信和信息泄露。
- 共享内存缓冲区(Shared memory buffers)
每个生产者进程都有一个与跟踪服务一对一共享的内存缓冲区(在上面的图片中为蓝色),无论它托管多少个数据源。这个缓冲区是一个临时的暂存缓冲区,具有两个目的:
- 写路径上的零拷贝(zero-copy):这个缓冲区允许直接从写入快速路径将跟踪数据序列化到一个内存区域,该区域可直接由跟踪服务读取。
- 将写入与跟踪服务的读取解耦(Decoupling):跟踪服务的工作是尽可能快地将跟踪数据包从共享内存缓冲区(蓝色)移动到中央缓冲区(黄色)。共享内存缓冲区隐藏了跟踪服务的调度和响应延迟,允许生产者在跟踪服务暂时阻塞时继续写入而不会丢失数据。
- Ftrace缓冲区
当启用linux.ftrace数据源时,内核将拥有自己的每个CPU的缓冲区。这是不可避免的,因为内核不能直接写入用户空间缓冲区。traced_probes进程将定期读取这些缓冲区,将数据转换为二进制协议(proto),并遵循与用户空间跟踪相同的数据流。这些缓冲区需要足够大,以在两个ftrace读取周期之间保存数据(由
TraceConfig.FtraceConfig.drain_period_ms定义)。
追踪包的生命周期(Life of a trace packet)
以下是对追踪包跨缓冲区数据流的一个概要理解。考虑一个生产者进程托管两个数据源,这两个数据源以不同速率写入数据包,但目标都是同一个中心缓冲区的情况。
当每个数据源开始写入时,它会抓取共享内存缓冲区中的一个空闲页面,并直接将原型编码的追踪数据序列化到该页面上。
当共享内存缓冲区的一个页面被填满后,生产者会向服务发送一个异步进程间通信(IPC),请求服务复制刚刚写入的共享内存页面。然后,生产者会抓取共享内存缓冲区中的下一个空闲页面并继续写入。
当服务接收到IPC时,它会将共享内存页面复制到中心缓冲区,并将该共享内存缓冲区页面再次标记为空闲。此时,生产者内的数据源能够重新使用该页面。
当追踪会话结束时,服务会向所有数据源发送一个刷新请求。作为响应,数据源会提交所有未处理的共享内存页面,即使它们尚未完全填满。服务将这些页面复制到服务的中心缓冲区中。
Buffer sizing
Central buffer sizing
中央缓冲区大小的数学计算相当直接:在默认情况下,即不启用write_into_file的追踪(当追踪文件仅在追踪结束时写入)时,缓冲区将保存由各个数据源写入的所有数据。
追踪的总长度将是 (buffer size) / (aggregated write rate)。如果所有生产者的总写入速率为2 MB/s,那么一个16 MB的缓冲区将能够保存大约8秒的追踪数据。
写入速率高度依赖于配置的数据源和系统的活动情况。在Android追踪中,包含调度器追踪时,1-2 MB/s是一个典型数值,但如果启用了更详细的数据源(如系统调用或页面错误追踪),则很容易上升一个数量级或更多。
在使用流式模式时,缓冲区需要能够在两个file_write_period_ms周期(默认为5秒)之间保存足够的数据。例如,如果file_write_period_ms = 5000(即5秒),并且写入数据速率为2 MB/s,则中央缓冲区至少需要是5 * 2 = 10 MB,以避免数据丢失。
Shared memory buffer sizing
共享内存缓冲区的大小取决于:
- 底层系统的调度特性,即追踪服务在调度器队列上被阻塞的时间长短。这是内核配置和被追踪进程的优先级(nice-ness level)的函数。
- 生产者进程中所有数据源的最大写入速率。
假设一个生产者的最大写入速率为8 MB/s。如果被追踪的数据被阻塞了10毫秒,那么共享内存缓冲区至少需要8 * 0.01 = 80 KB来避免数据丢失。
根据经验测量,在大多数Android系统上,128-512 KB的共享内存缓冲区大小已经足够。
默认的共享内存缓冲区大小是256 KB。当使用Perfetto客户端库时,可以通过设置
TracingInitArgs.shmem_size_hint_kb来调整这个值。
警告:如果数据源以单批次写入非常大的追踪包,那么要么需要使共享内存缓冲区足够大以处理这些包,要么必须使用
BufferExhaustedPolicy.kStall策略。
Debugging data losses
- Ftrace kernel buffer losses
当使用Linux内核的ftrace数据源时,如果traced_probes进程被阻塞了太长时间,那么在内核到用户空间的路径中可能会发生数据丢失。
在trace proto级别,这条路径上的丢失会被记录下来:
- 在FtraceCpuStats消息中,这些消息在追踪的开始和结束时都会发出。如果overrun字段非零,则表示数据已经丢失。
- 在FtraceEventBundle.lost_events字段中。这允许我们精确定位数据丢失发生的点。
在TraceProcessor SQL级别,这些数据在stats表中可用。
> select * from stats where name like 'ftrace_cpu_overrun_end'
name idx severity source value
-------------------- -------------------- -------------------- ------ ------
ftrace_cpu_overrun_e 0 data_loss trace 0
ftrace_cpu_overrun_e 1 data_loss trace 0
ftrace_cpu_overrun_e 2 data_loss trace 0
ftrace_cpu_overrun_e 3 data_loss trace 0
ftrace_cpu_overrun_e 4 data_loss trace 0
ftrace_cpu_overrun_e 5 data_loss trace 0
ftrace_cpu_overrun_e 6 data_loss trace 0
ftrace_cpu_overrun_e 7 data_loss trace 0
这些丢失可以通过增加
TraceConfig.FtraceConfig.buffer_size_kb或减少
TraceConfig.FtraceConfig.drain_period_ms来减轻。
- 共享内存丢失(Shared memory losses)
在追踪被阻塞期间,由于突发写入,共享内存中可能会发生追踪数据丢失。
在trace proto级别,这条路径上的丢失会被记录下来:
- 在TraceStats.BufferStats.trace_writer_packet_loss中。
- 在TracePacket.previous_packet_dropped中。但请注意:每个数据源发出的第一个数据包也会被标记为previous_packet_dropped=true。这是因为服务无法判断那是否确实是第一个数据包,还是之前的一切都丢失了。
在TraceProcessor SQL级别,这些数据在stats表中可用。
> select * from stats where name = 'traced_buf_trace_writer_packet_loss'
name idx severity source value
-------------------- -------------------- -------------------- --------- -----
traced_buf_trace_wri 0 data_loss trace 0
- 中央缓冲区丢失
中央缓冲区中的数据丢失可能由两个不同原因造成:
- 当使用fill_policy: RING_BUFFER时,由于环形缓冲区的特性,较旧的追踪数据会被新数据覆盖。这些丢失在trace proto级别被记录在TraceStats.BufferStats.chunks_overwritten中。
- 当使用fill_policy: DISCARD时,如果缓冲区已满,则新提交的追踪数据会被丢弃。这些丢失在trace proto级别被记录在TraceStats.BufferStats.chunks_discarded中。
在TraceProcessor SQL级别,这些数据在stats表中可用,每个中央缓冲区对应一个条目。
> select * from stats where name = 'traced_buf_chunks_overwritten' or name = 'traced_buf_chunks_discarded'
name idx severity source value
-------------------- -------------------- -------------------- ------- -----
traced_buf_chunks_di 0 info trace 0
traced_buf_chunks_ov 0 data_loss trace 0
总结:检测并调试数据丢失的最佳方法是使用Trace Processor并发出查询:select * from stats where severity = 'data_loss' and value != 0。
原子性和顺序保证
“写入者序列”是指由特定TraceWriter从数据源发出的跟踪包序列。在几乎所有情况下,1个数据源等于1个或多个TraceWriter。一些支持从多个线程写入的数据源通常会为每个线程创建一个TraceWriter。
- 从序列中写入的跟踪包会按照它们被写入的顺序在跟踪文件中发出。
- 不同序列之间写入的包之间没有顺序保证。序列按设计是并发的,且可能存在多种线性化方式。服务不会尊重不同序列之间的全局时间戳顺序。如果两个序列中的包按全局时间戳顺序发出,服务仍可能以相反的顺序在跟踪文件中发出它们。
- 跟踪包是原子的。如果跟踪包在跟踪文件中被发出,则保证包含数据源写入的所有字段。如果跟踪包很大且跨越了几个共享内存缓冲区页面,服务只有在观察到所有片段都已无间隙提交后,才会将其保存在跟踪文件中。
- 如果跟踪包丢失(例如,由于环形缓冲区的包装或共享内存缓冲区的丢失),则在该序列之前的所有包都被丢弃之前,不会为该序列发出进一步的跟踪包。换句话说,如果跟踪服务最终处于看到序列中的包1、2、5、6的情况,它将只发出1和2。然而,如果写入新包(例如,7、8、9)并覆盖1和2,从而填补了间隔,则将发出完整的序列5、6、7、8、9。但是,在使用流式模式时,此行为不适用,因为在此情况下,周期性读取将消耗缓冲区中的包并清除间隔,从而允许序列重新开始。
跟踪包中的增量状态
在许多情况下,跟踪包是完全独立的,可以在没有进一步上下文的情况下进行处理和解释。然而,在某些情况下,它们可以具有增量状态,其行为类似于帧间视频编码技术,其中一些帧需要关键帧存在才能有意义地进行解码。
以下是两个具体示例:
- Ftrace调度切片和/proc/pid扫描:Ftrace调度事件以线程ID为键。在大多数情况下,用户希望将这些事件映射回父进程(线程组)。为了解决这个问题,当在Perfetto跟踪中同时启用linux.ftrace和linux.process_stats数据源时,后者会从/proc伪文件系统捕获进程<>线程关联,每当ftrace看到一个新的线程ID时。这种情况下的典型跟踪如下所示:
# From process_stats's /proc scanner.
pid: 610; ppid: 1; cmdline: "/system/bin/surfaceflinger"
# From ftrace
timestamp: 95054961131912; sched_wakeup: pid: 610; target_cpu: 2;
timestamp: 95054977528943; sched_switch: prev_pid: 610 prev_prio: 98
/proc条目每个进程只发送一次,以避免增加跟踪的大小。在没有数据丢失的情况下,这足以能够重建该pid的所有调度事件。然而,如果process_stats包在环形缓冲区中被丢弃,那么将无法为所有引用该PID的其他ftrace事件计算出进程详细信息。
- Perfetto SDK中的Track Event库广泛使用字符串内部化。大多数字符串和描述符(例如,关于进程/线程的详细信息)只发送一次,并在以后使用单调ID进行引用。如果描述符包丢失,那么这些事件就无法完全理解。
Trace Processor具有内置机制,可以检测内部化数据的丢失,并跳过引用丢失的内部化字符串或描述符的包。
在使用环形缓冲区模式的跟踪时,这种类型的数据丢失非常可能发生。
有两种缓解这种情况的方法:
- 通过TraceConfig.IncrementalStateConfig.clear_period_ms定期使增量状态失效。这将导致使用增量状态的数据源定期丢弃内部化/进程映射表,并在下一次出现时重新发送描述符/字符串。只要clear_period_ms比中央跟踪缓冲区中跟踪数据的估计长度低一个数量级,这种方法就可以很好地缓解环形缓冲区跟踪中的问题。
- 将增量状态记录到专用缓冲区中(通过DataSourceConfig.target_buffer)。这种技术在前面提到的ftrace + process_stats示例中非常常用,将process_stats包记录在不太可能包装的专用缓冲区中(ftrace事件比新进程的描述符更频繁)。
刷新和窗口化跟踪导入
涉及多个数据源的跟踪中遇到的另一个常见问题是跟踪提交的非同步性质。如上所述,跟踪数据包仅在共享内存缓冲区的完整内存页被填满时(或在跟踪会话结束时)才提交。在大多数情况下,如果数据源以固定节奏产生事件,页面会很快被填满,事件会在几秒钟内被提交到中央缓冲区。
然而,在其他情况下,数据源可能只偶尔发出事件。想象一下,当显示器打开/关闭时发出事件的数据源。这种不频繁的事件可能会长时间停留在共享内存缓冲区中,并在事件发生数小时后提交到跟踪缓冲区。
另一个可能发生这种情况的场景是在使用ftrace时,当某个CPU大部分时间处于空闲状态或被热插拔(ftrace使用每CPU缓冲区)。在这种情况下,一个CPU可能在几分钟内记录很少或没有数据,而其他CPU每秒泵送数千个新的跟踪事件。
这会导致两个副作用,最终会破坏用户期望或导致错误:
- UI可能会显示一个异常长的时间线,中间有一个巨大的空白。对于UI来说,事件的包顺序并不重要,因为事件在导入时会根据时间戳进行排序。在这种情况下,跟踪将包含非常近期的事件以及一些发生在数小时前的陈旧事件。为了正确性,UI将尝试显示所有事件,先显示一些早期事件,然后是一个巨大的时间空白(当时没有发生任何事情),然后是近期事件的流。
- 在记录长跟踪时,Trace Processor可能会显示“XXX事件无序”的导入错误。这是因为,为了限制导入时的内存使用量,Trace Processor使用滑动窗口对事件进行排序。如果跟踪包过于无序(跟踪文件顺序与时间戳顺序不一致),则排序将失败,并且会丢弃一些包。
缓解措施
对于这类问题的最佳缓解措施是在跟踪配置中指定flush_period_ms(对于大多数情况,10-30秒通常足够了),尤其是在记录长跟踪时。
这将导致跟踪服务向数据源发出定期刷新请求。刷新请求会导致数据源将共享内存缓冲区页面提交到中央缓冲区,即使它们没有完全填满。默认情况下,刷新仅在跟踪结束时发出。
对于没有设置flush_period_ms的长跟踪,另一个选项是在导入跟踪时向trace_processor_shell传递--full-sort选项。这样做将禁用窗口排序,但代价是更高的内存使用量(跟踪文件将在解析前完全缓冲在内存中)。