首页 存档 技术 查看内容

PyFlame:Uber工程中用于Python的ptracing分析器

2018-3-30 13:00 |来自: 互联网 309 0

摘要: Python部落(python.freelycode.com)组织翻译,禁止转载,欢迎转发。 在Uber,我们尽力写出高效的后端服务,来保持我们的低计算成本。随着我们业务的增长,这变得越来越重要;看似稍微的低效就会被极大地放大。我们发 ...


Python部落(python.freelycode.com)组织翻译,禁止转载,欢迎转发。

在Uber,我们尽力写出高效的后端服务,来保持我们的低计算成本。随着我们业务的增长,这变得越来越重要;看似稍微的低效就会被极大地放大。我们发现flame graphs是了解服务的CPU和内存性能的有效工具,我们已在GoJavaScripts服务上应用并取得成效。为了使用python服务获得高质量的flame graphs,我们使用C 实现编写了一个名为Pyflame的高性能分析器。在这篇文章中,我们将探讨Pyflame成为分析python代码的一个更好的替代方案的设计思路以及一些独特的实现。

确定性性能分析

Python通过profile和cProfile模块提供了几个内置的确定性性能分析器。Python中的确定性性能分析(profile和cProfile)通过使用sys.settrace()在函数里各个感兴趣的点安装跟踪功能,例如每个函数的开始和结束,以及代码每个逻辑行的开始。这种机制可以产生高分辨率的分析信息,但也有一些缺点。

开销大

第一个缺点是极高的开销:我们通常看到它使程序的速度成倍缩小。更糟的是,我们发现这种开销在许多情况下导致分析不准确。cProfile模块不能为高速运行的方法准确地报告计时统计,因为分析器本身的开销也很重要。许多工程师不会使用分析器的信息因为他们无法信任其准确性。

缺乏完整的调用堆栈信息

第二个问题是内置的确定性性能分析器没有记录全部的堆栈调用信息。内置的分析模块只记录最新调用的一个堆栈信息,这限制了模块的实用性。例如,当一个装饰器被应用于大量的函数,装饰器在callee和caller部分频繁地显示分析器的输出,这样就掩盖了真正的调用信息。这种混乱使得很难理解真正的callee和caller的信息。

缺少为分析编写的服务

最后,内置的确定性性能分析器要求代码能被显示检测以用于分析。对我们来说一个常见的问题是,许多服务是在没有考虑分析器的情况下写的。在高负载的情况下,我们希望快速搜集分析信息,服务可能会遇到严重的性能问题。因为代码并不是为分析器而设计,所以无法立即开始搜集分析信息。如果负载足够重,我们可能还需要工程师编写代码来实现确定性性能分析器(通常通过添加RPC方法或者转储分析数据)。然后代码还需要审查,测试和部署。整个周期可能需要几个小时,这对我们来说还不够快。

采样分析器

也有一些为python服务的第三方采样分析器。这些采样分析器通常需要安装POSIX定时器,它周期性中断进程并运行信号处理程序来记录堆栈信息。采样分析器对分析过程进行抽样,而不是确定性地收集分析信息。这种技术是有效的,因为采样分辨率可以向上或向下调整。当采样的分辨率高的时候,分析数据更为准确,但是性能会受到影响。例如,可以设置高的 采样分辨率,以获得详细的分析,但相应的开销也会变高,也可以设置为低采样分辨率,以较少的开销来获得较少的分析。

采样分析器也有一些局限性。首先,它们一般具有较高的开销,因为它们是用python实现的。Python本身并不快,尤其相较于C或C 。事实上,cProfile确定性性能分析器是用C语音实现的。使用这些采样分析器,获得可接受的性能通常意味着将定时器频率设置为相对粗的粒度。

另一个限制是代码需要明确地针对分析器设计,就像确定性性能分析器。因此,现有的采样分析器存在和之前一样的问题:在高负载下,我们想分析一些代码,就必须先重写其实现。

使用Pyflame

使用Pyflame,我们希望维持分析器的所有可能的优点:

  • 收集完整的Python栈

  • 格式化数据使之能生成火焰图(flame graph)

  • 具有低开销

  • 适用于没有明确针对分析器设计的程序

更重要的是,我们的目的是避免现有的所有限制。在不做任何牺牲的情况下要求所有的功能,这听起来似乎不可能,但它并非不可能。

使用ptrace的Python性能分析器

大多数Unix系统实现了一个特殊的进程来跟踪系统调用,称为ptrace(2)。Ptrace不属于POSIX规范,但Unix实现,像BSD,Mac OS X和Linux都提供了一个ptrace的实现,允许进程读写任意虚拟内存地址,读写CPU寄存器,传送信号等。如果你曾经使用过像GDB的调试器,那么你应该使用过用ptrace的实现的软件。

可以使用ptrace来实现python分析器。我们的想法是周期性地在进程中使用ptrace,使用内存窥探来获得python堆栈,然后从该进程分离。特别是Linux ptrace,可以使用请求类型PTRACE_ATTACH ,PTRACE_PEEKDATA 和PTRACE_DETACH来实现分析器。理论上,这是非常简单的。而实际上,只使用PTRACE_PEEKDATA请求来恢复堆栈跟踪是非常复杂的,因此该方法低级且不直观。

首先,我们将简要介绍PTRACE_PEEKDAT请求是如何在Linux上工作的。这种请求类型在被跟踪进程中读取虚拟内存地址的数据。在Linux上,ptrace系统调用如下所示:


当使用PTRACE_PEEKDAT,需要提供下列函数参数:

Parameter
Value
request
PTRACE_PEEKDATA
pid
The traced process ID
addr
The memory address to read
data
Unused (NULL by convention)

ptrace(2)的值返回的是长整型(内存地址)。Linux上的GCC,长整型被定义为和当前平台的本地字大小相同,所以在32位的系统上,返回值是一个带符号的32位整数,64位的系统上,返回值是带符号的64位整数。

这里还有另外一个问题。对于错误,ptrace(2)返回值为-1,并设置相应的errno。然而,读取的地址中的数据事实上也可能包含值-1。因此,返回值-1是不明确的:是有错误,或者内存地址确实包含-1?为了解决读取数据时的这个歧义,首先必须清除errno,然后进行ptrace的请求。如果返回值是-1,就检查看是否errno是在ptrace的调用的过程中设置的。奇怪的是,返回值的歧义是CNU libc封装的结果。在Linux底层系统调用使用的返回值来标记错误,并且它将数据存储到data字段,而这正是这个例子中必须提供的参数。

提取线程状态

在内部,Python由一个或多个独立的解释器组成,每个子解释器跟踪一个或多个线程。由于全局解释器锁,实际上在任何给定时间内只有一个线程运行。当前正在执行的线程信息保存在全局变量_PyThreadState_Current。根据这个变量,Pyflame可以找到当前帧对象。整个堆栈跟踪可以从当前帧展开。因此,如上所述,一旦Pyflame定位到_PyThreadState_Current的内存地址 ,它可以通过PTRACE_PEEKDATA恢复其余的堆栈信息。Pyflame根据线程状态指针指向一个帧对象,每帧对象有一个指针回指向另一个帧。最后一帧回指向NULL。每个帧对象保持可用于恢复文件名,行号和该帧的函数名的字段。


每一个Python解释器跟踪一个或多个线程状态对象,每个线程状态有一个指针指向代表该线程调用堆栈的帧的链表。_PyThreadState_Current表示指向活动线程。

最困难的部分实际上是确定_PyThreadState_Current的地址。根据Python解释器是如何被编译的,有两种可能性:

  • 在默认构建模式,_PyThreadState_Current 是一个普通的符号,在文本区域有一个公知的地址,不会改变。地址的实际值取决于使用什么编译器,以及使用哪些编译标志等。

  • 当Python用enable-shared参数编译的时候,_PyThreadState_Current 符号没有内置到Python本身,而是在一个动态库中。在这种情况下,意味着地址空间布局随机化(ASLR),即虚拟存储器地址在解释器每次运行时是不同的。

Linux在任一情况下,通过解析从解释器(或者动态生成的libpython)获得的ELF信息可以定位该标记。Linux系统包括一个头文件elf.h,其中具有分析ELF文件的必需的定义。Pyflame内存映射到文件,然后使用这些ELF 结构定义解析出相关的ELF结构。如果特殊的ELF .dynamic部分指示构建链接到libpython,则Pyflame继续解析该文件。接着,它在.dynsym找到_PyThreadState_Current,无论是从Python可执行文件本身或者是从libpython,这取决于构建模式。

对于动态的Python构建,_PyThreadState_Current的地址必须用ASLR偏移来扩充。这是通过读取/proc/PID/maps以获取进程中的虚拟内存映射偏移。从这个文件得到的偏移量加上从libpython读取的值才是该标记真正的虚拟内存地址。

解读帧数据

在Python解释器的源码中,指针解引用和访问结构体的常规C语法:


然而,Pyflame必须使用ptrace读取Python进程的虚拟内存空间并手动实现指针的解引用。以下是Pyflame中模拟之前列出的代码具有代表性的代码片段:


辅助方法PtracePeek() 实现了使用参数PTRACE_PEEKDATA调用ptrace,并处理错误检查。指针为无符号长整形,宏offsetof用来计算结构体偏移量。Pyflame的ptrace的代码比普通的C代码更冗长,但两个代码的逻辑结构是完全一样的。

提取文件名和行号的代码很有趣。Python 2使用PyStringObject的类存储文件名,它只是存储内联字符串数据(从结构体起始的固定偏移量)。Python 3由于内部统一的字符串类型和unicode类型,则有更为复杂的字符串处理。对于只包含ASCII数据的字符串,原始字符串数据可以以大致相同的方式内嵌在结构体中。Pyflame目前只支持Python 3所有的ASCII文件名。

实现行号解码是开发Pyflame具有挑战性的部分之一。Python将行号数据存储在一个“行号表”的数据结构中,即代码中的f_lnotab字段。Python源码中的lnotab_notes.txt有该数据结构的详细解释。首先,要知道Python解释器的工作原理是将Python代码翻译成较低级别的字节码表示。通常情况下,一行Python代码会扩展为许多字节码指令。因此,字节码指令通常比代码速度更快。Python解释器使用将字节码偏移与行号偏移相关联的压缩数据结构代替存储和更新每个帧中行号字段。字节码-行号的数据结构针对每个代码对象计算一次。行号可以由任何字节码指令隐式计算出。


行号表是交错的字节代码和行号增量的数组。对于给定字节码地址的行号是通过计算字节码增量和行号增量的和得出。

分析Dockerized服务/容器

在Uber,我们在Linux容器中使用Docker运行大多数的服务。构建Pyflame中一个有趣的挑战是使其与Linux容器工作。通常,在主机上的进程无法与容器进程交互。然而,在大多数情况下,超级用户可以ptrace容器进程,而这正是我们在Uber如何在生产中运行Pyflame。

Docker容器使用mount namespaces来隔离主机和容器之间的文件系统资源。Pyflame有权访问容器中的文件来获取正确的ELF文件和计算符号的偏移量。Pyflame使用系统调用setns(2)进入mount namespaces。首先,Pyflame比较/proc/self/ns/fs和 /proc/PID/ns/fs。如果它们不同,Pyflame通过调用/proc/PID/ns/fs上的open(2)进入mount namespaces进程,然后在结果文件描述符上调用setns(2)。通过保留一个打开的文件描述符到原来的/proc/self/ns/fs,Pyflame随后可以返回到它原来的命名空间。

喜欢所阅读的内容?尝试下Pyflame吧!

我们发现Pyflame是分析Python代码极为有用的工具,并找到有效的代码进行优化。我们在Apache 2.0许可下发布Pyflame免费软件。试用时如果你发现任何bug请让我们知道。我们一如既往地喜欢引入需求,所以如果你有改进的建议请发送给我们。


英文原文:https://eng.uber.com/pyflame/
译者:flqzdzxx


声明:文章版权归原作者所有 部分文章转自互联网 如有侵权请联系 [邮箱地址] 删除

路过

雷人

握手

鲜花

鸡蛋

相关分类

返回顶部