PHP-Trace-实现原理

技术文档网 2021-04-16
phptrace 实现原理

总体介绍

PHPTrace致力于打造一款实时跟踪PHP函数调用,获取PHP函数调用栈信息以及PHP解释器状态的工具,这个PHP工具应该像系统工具strace/pstack一样强大易用。要设计完成这款工具,需要解决三方面的问题:

  1. 外部命令如何获取PHP进程内部的相关信息。 strace可以通过系统调用ptrace,使被trace的进程每次进入或退出系统调用时暂停, 这样strace通过ptrace(PTRACE_PEEK*)获取此时进程内存的内容,从而获取到系统调用的名称、参数、返回值、调用时间等内容。然而我们却没有一种机制使得PHP虚拟机在执行或退出一个PHP函数时暂停,就算可以暂停,我们也无法简单的通过ptrace从PHP虚拟机中抓取到PHP函数的调用信息。这是因为PHP是动态语言,完全通过脚本语言的解释器来执行PHP代码,并不是传统的C调用模型。 为了解决如何从PHP虚拟机中获取调用信息的难题,我们决定引入PHP扩展,由扩展hook PHP虚拟机, PHP虚拟机在每次执行一个PHP函数或扩展函数时,调用zend_execute_*等系列函数,phptrace通过 hook zend_execute_*系列函数,这样虚拟机在调用PHP函数或扩展函数时,先进入我们的phptrace hook函数,phptrace获取到调用信息后,调用PHP虚拟机真正的执行函数,并在PHP真正执行函数退出后, 获取调用结果:返回值,调用耗时。

  2. 能够实时开启和关闭。 为了定位线上正在运行中的PHP出现的问题,开启trace或stack的功能不能依赖于修改配置文件和写PHP代码,应该能够想strace和pstack那样给一个进程的ID,实时开启或者关闭。 我们形成了phptrace命令跟phptrace扩展共同协作的设计,由命令通知扩展开始trace,扩展收到 通知后才开始真正的抓取PHP调用信息,抓取的信息不断写到共享内存,phptrace工具不断读取共享内存, 从而实现phptrace随时随地实时输出trace结果的目的。

  3. 开启一个进程的trace或stack功能,不影响其它的进程。 开启一个进程的trace或这stack功能,必然会降低该进程执行PHP脚本的性能,strace也是如此。但是其它没有开启该功能的进程应该不受影响。 在phptrace命令通知扩展执行trace之前, 扩展并不会抓取任何信息,仅仅判断trace开关的状态,然后调用了原PHP虚拟机的zend_execute_*函数, 基本不会增加开销。

因为解决了以上三个问题,PHPTrace可以帮助排查线上线下的各种PHP问题。Xdebug虽有trace功能, 但其输出的结果却不能实时开启并打印出来;另外Xdebug挂钩了很多opcode,导致在不启用trace功能时,仍然有大量性能消耗。因此无法达到线上随时trace问题的要求。

具体设计

PHPTrace从设计上分为三大部分: 命令行工具、扩展以及通信共享内存部分。其中命令行工具用于开启某一个进程的trace或stack功能,并输出PHP信息。扩展用于收集PHP解释器中的相关信息。通信共享内存部分负责在命令行工具和扩展之间传递控制信息和数据信息。如下图所示:

[[phptrace_arch.png]]

共享内存

通信共享内存按照作用分为两种类型:控制共享内存和数据共享内存,两者都是基于mmap来实现的。控制部分负责传递“控制”消息,比如命令行工具通知扩展“开始trace”、“结束trace”,跟扩展间保持心跳,都是通过控制共享内存来传递。扩展抓取的PHP信息(如函数调用等)则写到数据共享内存,供命令行工具读取。

控制部分

控制共享内存部分默认对应/tmp/phptrace.ctrl文件,目前大小是固定的,扩展首次启用后创建该文件,创建后不会删除。

该文件的每个字节存储一个进程的控制信息,在Linux内核中,由于PID的Hard Limit最大为410241024,所以该文件的大小为4M。举例来说,该文件的第1235个字节存储的是PID为1234的PHP进程的控制信息(从0开始计算)。

虽然4M的空间有些浪费,真实环境中可能没有这么多进程,但是如果引入动态调整/tmp/phptrace.ctrl文件大小机制,会产生很多逻辑上的问题,增加编程的复杂程度。使用/tmp/phptrace.ctrl文件可以用来控制整个系统的所有PHP进程。

phptrace.ctrl中每个字节对应一个PID, 每个字节不同bit的的状态代表着不同的控制指令:

  • 最低位,为1代表trace开启, 为0代表trace停止,扩展根据该bit来开启和关闭trace操作;
  • 第二位,为1代表trace开启时重新打开tracelog文件,为0代表不重新打开,扩展根据该bit来判断是否一次新的trace的启用,从而打开一个新的tracelog文件;
  • 最高位,为心跳位,命令行工具定时设置为1,扩展定时清空为0,以此表示扩展和命令行工具都处于“活”的状态;
  • 其它位,保留,暂时没用到;

数据部分

数据部分每一个开启trace的进程对应一个文件,该文件叫做tracelog,文件名为/tmp/phptrace.trace.,当命令行工具开启trace时生成该文件,用于保存扩展获取到的PHP调用信息。例如: 执行phptrace -p 1234时,扩展创建对应的tracelog文件/tmp/phptrace.trace.1234。

目前tracelog默认大小为50M,可以通过php.ini的配置选项phptrace.logsize来调整,但是不能小于50M。因为tracelog被设计为一个环形队列,扩展写该队列,如果写到文件尾部,可以从文件开始继续写,命令行工具读该队列。当扩展瞬间写太快时,如果该tracelog文件太小,命令行工具读的位置会被写覆盖,导致命令行工具的退出。

数据格式

tracelog作为数据共享文件,存有PHP调用的所有元数据,被设计为具有一定格式的二进制文件。这样设计的优势是任何人都可以写程序去识别和分析tracelog文件,利用该文件中的PHP函数调用信息,按照自己的需求做进一步的分析。比如可以对这些信息抽样,进一步分析和统计PHP调用的性能(如:xhprof); 也可以进行个性化的展示,比如FlameGraph;甚至集成到已有系统中,做为入侵检测系统的一部分,识别PHP代码是否有危险调用(如:SQL注入)。

目前,一个tracelog文件主要分三大部分:header, records, tailer。大体的数据格式如下图所示:

[[phptrace_protocol.png]]

  • header包含magic number: 0x6563617274706870 用来识别文件格式, version 协议版本, flag 暂时保留;
  • tailer包含magic number: 0x657461746f720000 用来识别tailer, filename 用来执行tracelog的下一个文件,目前filename仍然指向自己;
  • records则记录了具体的数据,目前有两种类型的record:调用信息,调用返回。record类型通过flag区分;

此处仅讨论实现的一些原理及方案的取舍,至于tracelog具体的格式,以后可以另写文章详细阐述,这里 就不再展开了, 想一探究竟的同学可以参考phptrace_protocol.c

扩展实现

扩展的功能大体分为两部分, 一是通过共享内存跟phptrace命令交互,二是hook PHP zend_execute_*函数 并获取PHP函数调用信息和返回信息。

交互的过程在“共享内存”一节已有阐述,这里主要介绍一下第二部分,获取PHP函数调用信息和返回信息。

这里基于PHP-5.4.35进行阐述,其他版本PHP跟这里所述相比可能有些差异,但大体原理是相通的。 phptrace同时支持PHP函数以及PHP扩展函数的追踪,这两种函数hook的实现稍有不同,但大体 原理仍然是相通的,因此这里我们只阐述对“追踪PHP函数”的具体实现。

PHP虚拟机在执行PHP脚本时,对于每个PHP函数,都是调用zend_execute来执行函数对应handler, zend_execute是一个函数指针, 因此只需要hook zend_execute为phptrace_execute,那么PHP每次 函数调用都会执行phptrace_execute,phptrace扩展判断是否开始trace,如果开关没有开启,则直接 调用原来的zend_execute, hook 如下:

    phptrace_old_execute = zend_execute;
    zend_execute = phptrace_execute;

为了代码重用,phptrace具体实现时,实际phptrace_execute 调用了phptrace_execute_core实现具体逻辑。

phptrace_execute_core中首先检查了共享内存的trace开关或者是否设置强制trace phptrace.dotrace=1。 如果不需要trace则直接goto exec调用phptrace_old_execute执行原来的逻辑。

如果trace开关打开,模块则尝试调用phptrace_old_execute之前获取调用信息,调用会后获取返回信息。 PHP虚拟机的很多状态都保存在zend_execute_data类型的全局变量里, 从这个变量我们可以获取本次PHP 调用的函数名,参数,PHP脚本文件名,行号等信息。具体实现可以参考phptrace_get_callinfo等函数。

除了PHP调用信息, 我们还需要PHP函数的返回信息,主要包括函数返回值和函数调用耗时。耗时的获取 比较简单, 就是调用前后两个时间的差,返回值的获取稍微麻烦一些:

  • 在函数调用前, 注册变量地址到全局变量return_value_ptr_ptr,这里需要注意,如果别人已经 注册过这个地址,则我们就不需要注册了
    void phptrace_register_return_value(zval **return_value TSRMLS_DC)
    {
      if (EG(return_value_ptr_ptr) == NULL) {
          EG(return_value_ptr_ptr) = return_value;
      }
    }
    
  • 函数调用后,PHP会将函数返回值写到*EG(return_value_ptr_ptr): ```c void phptrace_get_execute_return_value(phptrace_file_record_t *record, zval *return_value TSRMLS_DC) {
    if (return_value) {
      /*registered by phptrace*/
      RECORD_EXIT(record, return_value) = phptrace_get_return_value(return_value TSRMLS_CC);
      zval_ptr_dtor(EG(return_value_ptr_ptr));
      EG(return_value_ptr_ptr) = NULL;
    
    } else if (*EG(return_value_ptr_ptr)) {
      /*registered by someone else, just fetch it*/
      RECORD_EXIT(record, return_value) = phptrace_get_return_value(*EG(return_value_ptr_ptr) TSRMLS_CC);
    
    }

}

如果返回地址是我们注册的,也就是EG(return_value_ptr_ptr)指向了phptrace自己的变量,则需要通过`zval_ptr_dtor`
释放这个变量。否则我们只需要获取返回值内容即可。

在介绍tracelog时我们知道,有两种类型的record,一种存放了PHP函数的调用信息,并且在PHP调用前写入tracelog,另
一种存放了PHP函数的返回信息,在PHP函数返回后写入tracelog,也就是说PHP函数调用的“函数名”,“参数”跟“返回值”
并不在同一个record里。这也是为什么我们的命令行工具在输出时,将函数返回值单独一行的原因。暂时我们无法做到
函数调用信息和返回信息的同时输出,因为我们的输出时实时的,流试的;对于一个类此`a->b->c->d`的调用链,我们
不能等b,c,d 全部返回,才开始输出a的调用信息和返回信息。

### waitflag

waitflag是tracelog协议的一部分,但却不是具体的数据,仅用来同步phptrace模块的写和phptrace命令的读。

tracelog是一个预先分配大小的文件(默认50M), 模块在写这个文件时,命令工具也同时在读,如果没有一个
同步机制,则很可能phptrace命令读取过快,超过了模块写的数据,这不是我们所希望的。为了同步读写,扩展
实际在写任何一条数据之前,都会写一个64位整数的waitflag(值为-1),命令工具在读到waitflag会等待一段
时间再读,等待时间按指数退避。扩展只有在写完下条数据之前,才清空waitflag,这样既保证了一条数据的原子性,
又解决了phptrace读快于写时的问题。

然而,模块写速度大于phptrace工具读速度的问题却没有解决,因为我们不想引入锁或其他同步机制使得模块堵塞。
这样模块写入过快时,并不会等待,而是直接覆盖phptrace工具可能还没有读到的内容,这时会导致phptrace命令行
报错退出。这也是为什么phptrace.logsize不宜过小的原因。

## stack功能
除了`trace`功能, phptrace还可以在任意时刻抓取PHP函数的调用栈,即`stack`功能, 目前stack的功能
并不依赖扩展,因此对于不想安装扩展的用户,也可以使用phptrace的`stack`功能。

`phptrace -s -p <PID>`使用`stack`功能,如果PHP没在执行任何脚本,则没有所谓的PHP函数调用栈,此时
phptrace尝试等待一段时间并重试,如果一直没有调用栈,则phptrace退出,因此对于有些打印调用栈失败的
情况,可能是PHP此时没有执行任何脚本。

`stack`功能依赖PHP编译的符号表,对于strip过的PHP进程则无能为力。但其并不依赖编译的调试信息,因此
在绝大多数情况下都可以使用。

`stack`功能通过gdb获取几个核心全局变量的地址,然后通过计算,找到函数名,文件名等内容,并用ptrace
获取对应地址的内容。由于不依赖调试信息,函数名,文件名等地址都是通过全局变量加偏移的方式计算,为了
兼容所有PHP版本,我们收集了PHP5.2.5到PHP5.6所有的偏移信息:
```c
static address_info_t address_templates[] = {
    {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
    {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
    {0, 64, 0, 968, 0, 16, 0, 8, 112, 64, 0, 168, 0, 112},
    {0, 64, 0, 1360, 0, 8, 0, 8, 80, 40, 0, 168, 0, 0, 112},
    {0, 64, 0, 1152, 0, 8, 0, 8, 80, 40, 0, 144, 0, 0, 40},
    {0, 64, 0, 1120, 0, 8, 0, 8, 48, 24, 0, 152, 0, 0, 40},
    {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
};

最终我们实现了不依赖扩展,无调试信息打印PHP函数stack的功能!

后续发展

无锁的mmap文件,无法协调phptrace命令和扩展间的读写同步,如果执行trace时,PHP可也接受堵塞,我们可能 会引入锁,或直接用unix domain socket进行tracelog的传输。

另外,我们也有计划推出libphptrace,以避免用户自己解析tracelog,最大限度的让大家可以自由使用phptrace 的数据。

phptrace命令也会增加filter功能, 可根据函数,文件,调用层级等进行过滤,是用户可以直接获取到想要的信息。

我们仍然可能会基于扩展,实现信息更为丰富的stack功能,也会尝试解析PHP段错误后的core文件,来 打印PHP段错误时的PHP函数调用栈来帮助分析crash的问题。

相关文章

  1. 如何通过xhprof分析性能

    使用方法 xhprof_enable(); /** ... 要检查的php代码 ... **/ $xhprof_data = xhprof_disable(); // 引入xhprof_lib i

  2. LUMEN API Controller 规范

    1. 第三方依赖库规范 在使用LUMEN实现API接口时,以下库必须需要包含在composer包依赖中,以实现代码编写的一些规范 dingo/api : 实现API接口库 vlucas/phpdo

  3. PHP文件锁

    共享锁(LOCK_SH) 什么时候加共享锁? 当在读取数据的时候同时进行着其他的写操作,这个时候需要对文件加共享锁,否则无论有没有对写操作加写锁都会写入成功,导致数据不一致 当文件获得共享锁时,其他

  4. Hello-Risen-程序

    首先需要说明的是,您下载到的文件包含两部分,其中src中是开发源码,用于对Risen框架本身的开发,risen 目录中是通过源码生成的包含debug和release版本的框架程序,用于您应用程序的开发

  5. PHP自定义类示例(Weixin消息解析类)

    PHP自定义类示例(Weixin消息解析类) /** * Created by Qingger. * User: jsspf * Date: 2017/3/24 * Time: 10:50

随机推荐

  1. 如何通过xhprof分析性能

    使用方法 xhprof_enable(); /** ... 要检查的php代码 ... **/ $xhprof_data = xhprof_disable(); // 引入xhprof_lib i

  2. LUMEN API Controller 规范

    1. 第三方依赖库规范 在使用LUMEN实现API接口时,以下库必须需要包含在composer包依赖中,以实现代码编写的一些规范 dingo/api : 实现API接口库 vlucas/phpdo

  3. PHP文件锁

    共享锁(LOCK_SH) 什么时候加共享锁? 当在读取数据的时候同时进行着其他的写操作,这个时候需要对文件加共享锁,否则无论有没有对写操作加写锁都会写入成功,导致数据不一致 当文件获得共享锁时,其他

  4. Hello-Risen-程序

    首先需要说明的是,您下载到的文件包含两部分,其中src中是开发源码,用于对Risen框架本身的开发,risen 目录中是通过源码生成的包含debug和release版本的框架程序,用于您应用程序的开发

  5. PHP自定义类示例(Weixin消息解析类)

    PHP自定义类示例(Weixin消息解析类) /** * Created by Qingger. * User: jsspf * Date: 2017/3/24 * Time: 10:50