在Uber,我们努力编写有效的后端服务以保持低价成本低。随着我们的业务增长,这变得越来越重要;看似小的效率低于优步的范围。我们发现了火焰图要成为了解我们服务的CPU和内存特征的有效工具,我们已经使用它们与我们的效果很大去和javascript.服务。为了获得Python服务的高质量火焰图,我们写了一个叫做的高性能分析器pyflame.,在C ++中实现。在本文中,我们探讨了设计考虑因素和一些独特的实现特征,使PyFlame成为分析Python代码的更好的替代方案。
确定性的分析器
Python提供了几种内置的确定性的分析器通过这一点轮廓和cprofile.模块。Python的确定性分析器(轮廓和cprofile.)使用使用的工作sys.settrace()安装以各种兴趣点运行的跟踪功能,例如每个函数的开始和结束以及每个逻辑行的开头。该机制产生了高分辨率的分析信息,但它具有许多缺点。
高度开销
第一个缺点是它非常高的开销:我们通常会看到它通过2倍减慢程序。更糟糕的是,我们发现这个开销在许多情况下导致不准确的分析数字。这cprofile.模块难以准确地报告运行的方法非常快速地运行的时间统计数据,因为在这些情况下,剖析器开销本身是重要的。许多工程师不使用分析信息,因为它们无法相信其准确性。
缺少完整的呼叫堆栈信息
内置确定性分析器的第二个问题是它们不会记录完全调用堆栈信息。内置分析模块仅记录信息上升一个堆叠级别,这限制了这些模块的有用性。例如,当一个装饰器应用于大量功能时,装饰器经常出现在分析输出的呼叫者和呼叫者部分中,因为由于扁平的呼叫堆栈信息,真正的呼叫信息模糊不清。这种杂乱使得难以理解真正的Callee和来电者信息。
缺乏为分析编写的服务
最后,内置的确定性探查器要求将代码明确检测以用于分析。我们的常见问题是许多服务没有用貌相写作。在高负荷下,我们可能会遇到服务的严重性能问题,并希望快速收集分析信息。由于代码尚未为剖析进行录取,因此无法立即开始收集分析信息。如果负载足够严重,我们可能需要一个工程师来编写代码以启用确定性分析器(通常通过添加RPC方法来打开它,另一个用于转储分析数据)。然后需要审核,测试和部署此代码。整个周期可能需要几个小时,这对我们来说并不够快。
抽样分析器
还有许多用于Python的第三方采样分析器。这些采样分析仪通常通过安装a工作POSIX间隔计时器,周期性地中断处理并运行信号处理程序以记录堆栈信息。采样分析器示例示例,而不是确定地收集分析信息。这种技术是有效的,因为可以向上或向下拨打采样分辨率。当采样分辨率很高时,分析数据更准确但性能受到影响。例如,可以将采样分辨率设置为高度以获得具有相应大量开销的详细配置文件,或者可以设置为低电平以获得更少的详细配置,较少开销。
采样分析仪具有一些限制。首先,它们通常具有高开销,因为它们在Python中实现。Python本身并不快,特别是与C或C ++相比。事实上,cprofile.确定型分析器是在C中实施的。利用这些采样分析器,获得可接受的性能通常意味着将定时器频率设置为相对粗糙的东西。
其他限制是,与确定性分析器一样,需要明确地解析代码。因此,现有的采样分析器导致与之前的问题相同:在高负载下,我们想配置一些代码,只能实现我们必须首先重写它。
pyflame到救援
使用PyFlame,我们希望维护所有可能的分析益处:
- 收集完整的python堆栈,一直到root
- 以可用于生成火焰图的格式发出数据
- 有低开销
- 与未明确录取的流程一起工作
更重要的是,我们旨在避免所有现有的限制。在不制定任何牺牲的情况下,它可能会询问所有功能都可能是不可能的。但它的声音并不是不可能!
使用Ptrace进行Python Profiling
大多数UNIX系统都实现了一个调用的特殊过程跟踪系统调用Ptrace(2)。Ptrace不是其中的一部分POSIX.specification, but Unix implementations like BSD, OS X, and Linux all provide a ptrace implementation that allows a process to read and write to arbitrary virtual memory addresses, read and write CPU registers, deliver signals, etc. If you’ve ever used a debugger likeGDB.,然后您已经使用了使用ptrace实现的软件。
可以使用ptrace实现Python Profiler。这个想法是定期的ptrace附到过程中,使用内存偷看程序获取Python堆栈跟踪,然后分离从过程中。具体而言Linux Ptrace.,可以使用请求类型写入分析器Ptrace_Attach.那ptrace_peekdata., 和ptrace_detach.。理论上,这非常简单。在实践中,它的事实是使用仅使用堆栈跟踪恢复的事实ptrace_peekdata.请求是非常低的级别和不完整的。
首先,我们将简要介绍如何ptrace_peekdata.请求在Linux上工作。此请求类型在跟踪过程中的虚拟内存地址处读取数据。Ptrace系统调用Linux的签名看起来像这样:
long ptrace(enum __ptrace_request请求,pid_t pid,void * addr,void * data);
使用时ptrace_peekdata.,提供以下功能参数:
| 范围 | 价值 |
| 要求 | ptrace_peekdata. |
| PID | 追踪的流程ID |
| addr. | 要读取的内存地址 |
| 数据 | 没用过 (空值按照惯例) |
价值Ptrace(2)返回是长在那个内存地址。在Linux上GCC., 这长类型定义为与本机体系结构字大小相同,因此在32位系统上,返回值是符号32位整数,并且在64位系统上返回值是符号64位整数。
这里有一个额外的复杂性。出错,Ptrace(2)返回值-1和seterrno.适当的。但是,我们读取的地址上的数据实际上可以包含值-1。因此,-1的返回值是暧昧的:有错误,还是那个内存地址真的包含-1?要在阅读数据时解决此模糊性,我们必须先清除errno.然后制作Ptrace请求。然后,如果返回值为-1,则检查是否存在errno.在Ptrace呼叫期间被设置。好奇地,返回值解释中的模糊性是GNU Libc包装器的工件。Linux上的底层系统调用使用返回值来发出错误,并且它将偷窥数据存储到数据字段,必须在这种情况下提供。
提取线程状态
在内部,Python用一个或多个独立的解释器构造,每个次口译员跟踪一个或多个线程。因为全球翻译锁定,只有一个线程实际上在任何给定时间运行。当前正在执行的线程信息在命名的全局变量中保持_pythreadstate_current.,通常不会由Python C API导出。从此变量,PyFlame可以找到当前框架对象。从当前框架,整个堆栈跟踪可以展开。因此,一旦PyFlame定位了存储器位置_pythreadstate_current.,它可以使用它可以通过使用恢复其余的堆栈信息ptrace_peekdata., 如上所述。PyFlame遵循线程状态指针到帧对象,并且每个帧对象都有一个背针到另一帧。最终帧具有返回指针到NULL。每个帧对象都保存可用于恢复帧的文件名,行号和功能名称的字段。
这是最困难的部分实际上找到了地址_pythreadstate_current.。根据Python解释器的编译方式,有两种可能性:
- 在默认构建模式下,_pythreadstate_current.是常规的象征有一个知名地址文本区域这不会改变。虽然地址不会改变,但地址的实际值取决于使用的编译器,使用了哪些编译标志等。
- 当python编译时- 可行的共享, 这_pythreadstate_current.符号不构建在Python本身中,但在一个动态库。在这种情况下,地址空间布局随机化(ASLR)意味着每次解释器运行时,虚拟内存地址都不同。
在Linux上的任何一种情况下,可以通过解析符号来定位el来自口译员的信息(或来自libpython.在动态构建中)。Linux系统包括调用头文件elf.h.这具有解析ELF文件的必要定义。pyflame.记忆映射该文件然后使用这些精灵塑造定义解析相关的ELF结构。如果是特殊的精灵。动态的部分表示构建链接libpython.,然后pyflame继续解析该文件。接下来,它找到了_pythreadstate_current.符号在.dynsym.ELF部分,来自Python可执行文件本身或来自libpython.,取决于构建模式。
对于动态Python构建,地址_pythreadstate_current.必须随着ASLR偏移增强。这是通过阅读来完成的/ proc / pid / maps获取此过程的虚拟内存映射偏移。从此文件的偏移量被添加到从中读取的值libpython.获取符号的真实虚拟内存地址。
解释帧数据
在Python解释器的源代码中,您可以看到“取消引用”指针和访问STRUCT字段的常规C语法:
| // frame有类型void * 空白* f_code =(塑造_frame *)帧 - > f_code; 空白* co_filename =(pycodeObject *)f_code-> co_filename; |
相反,PyFlame必须使用Ptrace从Python进程的虚拟内存空间读取并手动实现指针解除引用。以下是来自PyFlame的代表代码片段,用于在上一个代码列表中呈现代码:
| const长f_code =.Ptraceek.(PID,帧+offsetof.<(_frame,f_code)); const长co_filename = Ptraceek.(PID,f_code +offsetof.(PycodeObject,Co_FileName)); |
在这里,一个辅助方法ptraceekeek()用这个呼叫实现ptrace_peekdata.参数和处理错误检查。指针表示为无符号龙头,而且offsetof.宏用于计算结构偏移。PTLAME中的PTRACE代码比常规C代码更冗长,但两个代码列表的逻辑结构完全相同。
实际提取文件名和行号的代码是有趣的。Python 2使用调用类型存储文件名pystringObject.,它只是将字符串数据内联存储(从结构头的固定偏移量)。Python 3由于以下内容具有更加复杂的字符串处理内部统一字符串类型和Unicode类型。对于仅包含ASCII数据的字符串,可以以相同的方式在结构中内联找到原始字符串数据。PyFlame目前只支持Python 3上的所有ASCII文件名。
实现PyFlame的线路编号解码是开发PyFlame的更具挑战性的部分之一。Python在代码对象中的字段中的一个有趣的数据结构中存储在一个有趣的数据结构中的行号数据f_lnotab.。有一个名为的文件lnotab_notes.txt.在解释确切数据结构的Python源代码中。首先,知道Python解释器通过将常规Python代码转换为较低级别的级别字节码表示。通常,一行Python代码扩展到许多字节码指令。因此,字节码指令通常比代码线更快地提升。Python解释器代替在每帧中存储和更新行号字段,而是使用将字节码偏移的压缩数据结构与行号偏移相关联。每个代码对象计算一次字节码到线路编号数据结构。可以隐式地计算行号码任何字节码指令。
分析换货服务/集装箱
在Uber,我们使用Docker在Linux容器中运行大多数服务。建立PyFlame的有趣挑战之一是使其与Linux容器一起使用。通常,主机上的进程不能与容器化进程交互。但是,在在大多数情况下root用户可以ptrace容器化进程,这就是我们如何在优步生产生产中的Pyflame。
Docker容器使用挂载名称空间分离主机和容器之间的文件系统资源。PyFlame必须访问容器中的文件以访问正确的ELF文件并计算符号偏移。pyflame使用该容器的安装名称空间使用setns(2)系统调用。首先,pyflame比较/ proc / self / ns / fs至/ proc / pid / ns / fs。如果它们不同,PyFlame通过调用进入该过程的安装名称空间开放(2)上/ proc / pid / ns / fs然后打电话setns(2)在生成的文件描述符上。通过将打开的文件描述符保留到原始文件/ proc / self / ns / fs,PyFlame可以随后返回其原始命名空间(即,转发容器)。
喜欢你在读什么?尝试自己的pyflame!
我们发现PyFlame是一个非常有用的工具,用于在优步突出Python代码,并找到优化的低效代码路径。我们今天将PyFlame释放为自由软件,在Apache 2.0许可证下。请试试看如果您发现任何错误,请告诉我们。而且,一如既往,我们喜欢获取拉动请求,所以如果您有改进,请发送它们。
Evan Klitzke是一名员工软件工程师之内Uber Engineering.核心基础设施集团。他于2012年9月4日在4年前加入了优步。
标题的照片学分:“火焰“ 经过利兹西,许可cc-by 2.0。图像裁剪标题尺寸。
喜欢你在读什么?注册我们的通讯对于Uber Engineering Blti8 竞猜雷竞技appog的更新。雷竞技到底好不好用






