|
| 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解释器使用将字节码偏移与行号偏移相关联的压缩数据结构代替存储和更新每个帧中行号字段。字节码-行号的数据结构针对每个代码对象计算一次。行号可以由任何字节码指令隐式计算出。
行号表是交错的字节代码和行号增量的数组。对于给定字节码地址的行号是通过计算字节码增量和行号增量的和得出。
在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是分析Python代码极为有用的工具,并找到有效的代码进行优化。我们在Apache 2.0许可下发布Pyflame免费软件。试用时如果你发现任何bug请让我们知道。我们一如既往地喜欢引入需求,所以如果你有改进的建议请发送给我们。
英文原文:https://eng.uber.com/pyflame/
译者:flqzdzxx