PHP 内核分析:Zend 虚拟机

澳门新葡亰娱乐在线 2

PHP 是一门解释型的语言。诸如 Java、Python、Ruby、Javascript
等解释型语言,我们编写的代码不会被编译成机器码运行,而是会被编译中间码运行在虚拟机(VM)上。运行
PHP 的虚拟机,称之为 Zend 虚拟机,今天我们将深入内核,探究 Zend
虚拟机运行的原理。

HHVM

HHVM是什么?

HHVM(HipHop
VM)是Fackbook推出用于在执行PHP代码的虚拟机,是一个PHP的JIT编译器,具有产生快速代码和及时编译的优点。

HHVM能干什么?

HHVM脚本主要应用服务器端脚本和命令行脚本两大领域,专注于服务器端脚本,如收集表单数据、生成动态页面、发送接受COOKIE等。

HHVM为什么比ZendEngine快?

HHVM是Facebook开发的高性能PHP虚拟机,宣称比官方Zend快9倍。

PHP使用的Zend虚拟机(VM),首先会先将PHP代码编译成二进制指令opcode,然后逐条执行,每条opcode指令都对应一个C函数。对于PHP的用户函数、运行时局部变量、常量会存在一个Hashtable中。

执行一次C函数的开销

  • 参数的入栈出栈
  • CPU寄存器状态保存

例如:在PHP中执行1000w次累加

<?php
$sum = 0;
// 发生1000w次C函数调用
for($i=0; $i<10000000; $i++){
  $sum += $i;
}

若编译为机器码情况是什么样的呢?

主频2.0GHZ的CPU每秒执行20亿次指令,函数调用则1秒只能运行1000W次。

因此,编译为机器码执行语言如C、C++、Golang…,或拥有JIT的语言如Java、NodeJS、LuaJIT、HHVM…,单从指令执行角度上看至少比PHP快几十倍。

对于字符串处理、JSON编码解码、iconv编码解码、数组操作等,
PHP比C++、Java慢吗?

在PHP中此类操作都是C扩展函数完成的,性能与编译型语言一致。

PHP到底比编译型语言慢的原因在哪里呢?

PHP代码中用户函数、类、对象操作等。

运算密集型 vs IO密集型

运算密集型程序指的是需大量执行内存复制操作、循环、运行指令等,瓶颈在CPU上,提升性能的解决方案就是提升CPU硬件配置、改进算法、提升语言/工具的执行性能。对于此类程序,PHP性能问题很明显,执行相同的逻辑,比C/C++慢几十倍甚至百倍,这是不可接受的。

IO密集型程序瓶颈在IO等待,例如HTTP请求执行100ms后返回,其中90ms查询数据库,8ms读写文件,
那么无论C/C++还是PHP,请求响应时间总是100ms左右,语言性能优化只有2ms的空间。

如何优化PHP呢

  • PHP语言层面优化
  • 优化PHP官方实现ZendEngine
  • 将PHP编译为其他语言字节码(bytecode),借助于其他语言虚拟机来运行。
  • 将PHP转成C/C++,编译成本地代码。
  • 开发更快的PHP虚拟机

Zend的执行过程可分为两个环节

  • 将PHP编译为opcode
  • 执行opcode

优化opcode可编码重复解析PHP与静态编译优化,由于PHP的动态性,这种优化方式是有局限,乐观估计可提升20%的性能。

优化opcode架构本身,工作量大投入产生比不高。

优化opcode执行,Zend解释器interpreter在读到opcode后会根据不同opcode调用不同函数(switch),在函数中执行语言相关的操作。优化空间不大。

优化Zend执行性能,对于函数调用的开销,通过inline
threading来优化,其原理如C中的inline关键字。

更快的虚拟机

HHVM 为什么更快,原因是JIT。

JIT操作本身是耗时的,对于简单程序或许比interpreter慢。HHVM的发展就是不断优化、优化、在优化。

澳门新葡亰娱乐在线 1

HHVM是如何超过HPHPc

什么是JIT,如何实现一个JIT?

动态语言中基本都会有一个eval(),作用是传入一段字符串来执行。JIT做着类似的事,不过它要拼接的不是字符串,而是不同平台下的机器码,然后执行。在JIT中更重要的优化是根据类型来生成特定的指令,从而减少指令数量和条件判断。

类型推导

JIT的关键是猜测类型,变量的类型要是老是变就很难优化。HHVM工程师考虑在PHP语法上做手脚,加上类型的支持,推出Hack。

<?hh
class Point
{
  // 使用静态类型可让HHVM更好的优化性能,不过这也意味着和PHP语法不兼容。
  public float $x,$y;
  public function __construct(float $x, float $y)
  {
    $this->x = $x;
    $this->y = $y;
  }
}

HHVM提升PHP执行性能

HHVM生成和执行PHP的在中间字节码,执行时通过JIT(Just In
Time即时编译,软件优化技术,指在运行时才会去编译字节码为机器码)转换为机器码执行。JIT将大量重复执行的字节码在运行时编译为机器码,达到提高执行效率的目的。通常触发JIT的条件是代码或函数被多次重复调用。

什么是字节码?

澳门新葡亰娱乐在线 2

字节码

ZendEngine做法是先编译为opcode,逐条执行,每条指令对应的是C语言级别的函数。

HHVM服务器最开始的少数请求会比其余的慢,因为它必须在执行PHP和Hack代码之前将它们编译成机器码,这个效果是非常明显的,所以你不应当立即把一个新设置的HHVM服务器应用到生产环境中。你应该先发送一些人工模拟的请求到这个HHVM服务器上,对它进行热身。
事实上,服务器启动的时候,并不会编译任何代码。初始的请求就是在HHVM的字节码解释器下运行的。原理就是:对于一个web服务器来说,最初的几个请求是不寻常的。在这个期间,开始了初始化,还对缓存进行填充等等。对这些代码路径的编译对整体性能的表现是非常糟糕的,因为一旦对服务器进行了预热,这些过程是不会被经常调用的。HHVM还利用这些请求,来收集一些代码所用到的数据类型分析的工作。所以它可以稍后更加有效地进行编译。你可以使用选项
hhvm.jit_profile_interp_requests 来调整这个门槛。
对于发送预热请求,颗通过命令行或其它类似的地方,简单地使用 curl
这个命令功能。为了得到最好的结果:
使用你希望在产品中看到的,能够代表最常见的请求的混合集合。例如,如果你期待所有对这个产品的请求中的40%都是到达
index.php 的,那么你的 40% 的预热请求都 应该是到 index.php 的请求。
避免并行发送多个预热请求,若你真的并行发送了多个请求,那么并不会出现什么问题。单对于JIT编译器来说,若没有同时工作在多个请求上的话,它往往能够生成更好的代码。
最终,你最好有个进程脚本用于服务器热身,这样的话,颗在命令行里仅仅执行一个命令就可以做到热身了。但是在最初期的时候,你还需要一些人工的参与,要实际计算出用于热身的请求数量是非常微妙的,
这主要取决于你的程序本身。

OPCODE

什么是 OPCODE?它是一种虚拟机能够识别并处理的指令。Zend
虚拟机包含了一系列的 OPCODE,通过 OPCODE 虚拟机能够做很多事情,列举几个
OPCODE 的例子:

  • ZEND_ADD 将两个操作数相加。
  • ZEND_NEW 创建一个 PHP 对象。
  • ZEND_ECHO 将内容输出到标准输出中。
  • ZEND_EXIT 退出 PHP。

诸如此类的操作,PHP 定义了186个(随着 PHP 的更新,肯定会支持更多种类的
OPCODE),所有的 OPCODE
的定义和实现都可以在源码的 zend/zend_vm_def.h 文件(这个文件的内容并不是原生的
C 代码,而是一个模板,后面会说明原因)中查阅到。

澳门新葡亰娱乐在线,我们来看下 PHP 是如何设计 OPCODE 数据结构:

struct _zend_op {
    const void *handler;
    znode_op op1;
    znode_op op2;
    znode_op result;
    uint32_t extended_value;
    uint32_t lineno;
    zend_uchar opcode;
    zend_uchar op1_type;
    zend_uchar op2_type;
    zend_uchar result_type;
};

仔细观察 OPCODE 的数据结构,是不是能找到汇编语言的感觉。每一个 OPCODE
都包含两个操作数,op1和 op2handler 指针则指向了执行该 OPCODE
操作的函数,函数处理后的结果,会被保存在 result 中。

我们举一个简单的例子:

<?php
$b = 1;
$a = $b + 2;

我们通过 vld 扩展看到,经过编译的后,上面的代码生成了 ZEND_ADD 指令的
OPCODE。

compiled vars:  !0 = $b, !1 = $a
line     #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
   2     0  E >   ASSIGN                                                   !0, 1
   3     1        ADD                                              ~3      !0, 2
         2        ASSIGN                                                   !1, ~3
   8     3      > RETURN                                                   1

其中,第二行是 ZEND_ADD 指令的
OPCODE。我们看到,它接收2个操作数,op1 是变量 $bop2 是数字常量1,返回的结果存入了临时变量中。在 zend/zend_vm_def.h 文件中,我们可以找到
ZEND_ADD 指令对应的函数实现:

ZEND_VM_HANDLER(1, ZEND_ADD, CONST|TMPVAR|CV, CONST|TMPVAR|CV)
{
    USE_OPLINE
    zend_free_op free_op1, free_op2;
    zval *op1, *op2, *result;

    op1 = GET_OP1_ZVAL_PTR_UNDEF(BP_VAR_R);
    op2 = GET_OP2_ZVAL_PTR_UNDEF(BP_VAR_R);
    if (EXPECTED(Z_TYPE_INFO_P(op1) == IS_LONG)) {
        if (EXPECTED(Z_TYPE_INFO_P(op2) == IS_LONG)) {
            result = EX_VAR(opline->result.var);
            fast_long_add_function(result, op1, op2);
            ZEND_VM_NEXT_OPCODE();
        } else if (EXPECTED(Z_TYPE_INFO_P(op2) == IS_DOUBLE)) {
            result = EX_VAR(opline->result.var);
            ZVAL_DOUBLE(result, ((double)Z_LVAL_P(op1)) + Z_DVAL_P(op2));
            ZEND_VM_NEXT_OPCODE();
        }
    } else if (EXPECTED(Z_TYPE_INFO_P(op1) == IS_DOUBLE)) {

    ...
}

上面的代码并不是原生的 C 代码,而是一种模板。

为什么这样做?因为 PHP 是弱类型语言,而其实现的 C
则是强类型语言。弱类型语言支持自动类型匹配,而自动类型匹配的实现方式,就像上述代码一样,通过判断来处理不同类型的参数。试想一下,如果每一个
OPCODE
处理的时候都需要判断传入的参数类型,那么性能势必成为极大的问题(一次请求需要处理的
OPCODE 可能能达到成千上万个)。

哪有什么办法吗?我们发现在编译的时候,已经能够确定每个操作数的类型(可能是常量还是变量)。所以,PHP
真正执行时的 C
代码,不同类型操作数将分成不同的函数,供虚拟机直接调用。这部分代码放在了 zend/zend_vm_execute.h 中,展开后的文件相当大,而且我们注意到还有这样的代码:

if (IS_CONST == IS_CV) {

完全没有什么意义是吧?不过没有关系,C
的编译器会自动优化这样判断。大多数情况,我们希望了解某个 OPCODE
处理的逻辑,还是通过阅读模板文件 zend/zend_vm_def.h 比较容易。顺便说一下,根据模板生成
C 代码的程序就是用 PHP 实现的。

执行过程

准确的来说,PHP
的执行分成了两大部分:编译和执行。这里我将不会详细展开编译的部分,而是把焦点放在执行的过程。

通过语法、词法分析等一系列的编译过程后,我们得到了一个名为 OPArray
的数据,其结构如下:

struct _zend_op_array {
    /* Common elements */
    zend_uchar type;
    zend_uchar arg_flags[3]; /* bitset of arg_info.pass_by_reference */
    uint32_t fn_flags;
    zend_string *function_name;
    zend_class_entry *scope;
    zend_function *prototype;
    uint32_t num_args;
    uint32_t required_num_args;
    zend_arg_info *arg_info;
    /* END of common elements */

    uint32_t *refcount;

    uint32_t last;
    zend_op *opcodes;

    int last_var;
    uint32_t T;
    zend_string **vars;

    int last_live_range;
    int last_try_catch;
    zend_live_range *live_range;
    zend_try_catch_element *try_catch_array;

    /* static variables support */
    HashTable *static_variables;

    zend_string *filename;
    uint32_t line_start;
    uint32_t line_end;
    zend_string *doc_comment;
    uint32_t early_binding; /* the linked list of delayed declarations */

    int last_literal;
    zval *literals;

    int  cache_size;
    void **run_time_cache;

    void *reserved[ZEND_MAX_RESERVED_RESOURCES];
};

内容超多对吧?简单的理解,其本质就是一个 OPCODE
数组外加执行过程中所需要的环境数据的集合。介绍几个相对来说比较重要的字段:

  • opcodes 存放 OPCODE 的数组。
  • filename 当前执行的脚本的文件名。
  • function_name 当前执行的方法名称。
  • static_variables 静态变量列表。
  • last_try_catch try_catch_array 当前上下文中,如果出现异常
    try-catch-finally 跳转所需的信息。
  • literals 所有诸如字符串 foo 或者数字23,这样的常量字面量集合。

为什么需要生成这样庞大的数据?因为编译时期生成的信息越多,执行时期所需要的时间就越少。

接下来,我们看下 PHP 是如何执行 OPCODE。OPCODE
的执行被放在一个大循环中,这个循环位于 zend/zend_vm_execute.h 中的 execute_ex 函数:

ZEND_API void execute_ex(zend_execute_data *ex)
{
    DCL_OPLINE

    zend_execute_data *execute_data = ex;

    LOAD_OPLINE();
    ZEND_VM_LOOP_INTERRUPT_CHECK();

    while (1) {
        if (UNEXPECTED((ret = ((opcode_handler_t)OPLINE->handler)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU)) != 0)) {
            if (EXPECTED(ret > 0)) {
                execute_data = EG(current_execute_data);
                ZEND_VM_LOOP_INTERRUPT_CHECK();
            } else {
                return;
            }
        }
    }

    zend_error_noreturn(E_CORE_ERROR, "Arrived at end of main loop which shouldn't happen");
}

这里,我去掉了一些环境变量判断分支,保留了运行的主流程。可以看到,在一个无限循环中,虚拟机会不断调用
OPCODE
指定的 handler 函数处理指令集,直到某次指令处理的结果 ret 小于0。注意到,在主流程中并没有移动
OPCODE
数组的当前指针,而是把这个过程放到指令执行的具体函数的结尾。所以,我们在大多数
OPCODE 的实现函数的末尾,都能看到调用这个宏:

ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION();

在之前那个简单例子中,我们看到 vld 打印出的执行 OPCODE
数组中,最后有一项指令为 ZEND_RETURN 的 OPCODE。但我们编写的 PHP
代码中并没有这样的语句。在编译时期,虚拟机会自动将这个指令加到 OPCODE
数组的结尾。ZEND_RETURN 指令对应的函数会返回
-1,判断执行的结果小于0时,就会退出循环,从而结束程序的运行。

发表评论

电子邮件地址不会被公开。 必填项已用*标注

相关文章

网站地图xml地图