V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
heitao1
V2EX  ›  程序员

从 PHP 语法糖剖析 Zend VM 引擎

  •  1
     
  •   heitao1 · 2016-11-04 11:51:45 +08:00 · 2510 次点击
    这是一个创建于 2942 天前的主题,其中的信息可能已经有所发展或是发生改变。

    1.

    先说个 PHP5.3+ 的语法糖,通常我们这样写:

    <?php
        $a = 0;
        $b = $a ? $a : 1;
    

    语法糖可以这样写:

    <?php
        $a = 0;
        $b = $a ?: 1;
    

    执行结果$b = 1 ,后面写法更简洁,但通常不太建议用太多语法糖,特别是容易理解混淆的,比如 PHP 7 新增加??如下:

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

    相当于:

    <?php
        $b = isset($a) ? $a : 1;
    

    ?: 和 ?? 你是不是容易搞混,如果这样,我建议宁可不用,代码可读性强,易维护更重要。

    语法糖不是本文的重点,我们的目的是从语法糖入手聊聊 Zend VM 的解析原理。

    2.

    分析的 PHP 源码分支 => remotes/origin/PHP-5.6.14 ,关于如何通过 vld 查看 opcode ,请看我之前写的这篇文章:
    http://www.yinqisen.cn/blog-680.html

    <?php
        $a = 0;
        $b = $a ?: 1;
    

    对应的 opcdoe 如下:

    number of ops:  5
    compiled vars:  !0 = $a, !1 = $b
    line     #* E I O op                           fetch          ext  return  operands
    -------------------------------------------------------------------------------------
       2     0  E >   ASSIGN                                                   !0, 0
       3     1        JMP_SET_VAR                                      $1      !0
             2        QM_ASSIGN_VAR                                    $1      1
             3        ASSIGN                                                   !1, $1
       4     4      > RETURN                                                   1
    
    branch: #  0; line:     2-    4; sop:     0; eop:     4; out1:  -2
    path #1: 0,
    

    vim Zend/zend_language_parser.y +834

    834 ›   |›  expr '?' ':' { zend_do_jmp_set(&$1, &$2, &$3 TSRMLS_CC); }
    835 ›   ›   expr     { zend_do_jmp_set_else(&$$, &$5, &$2, &$3 TSRMLS_CC); }
    

    如果你喜欢,可以自己动手,重新定义 ?: 的语法糖。遵循 BNF 文法规则,使用 bison 解析,有兴趣可以自行 Google 相关知识,继续深入了解。

    从 vld 的 opcode 可以知道,执行了 zend_do_jmp_set_else ,代码在 Zend/zend_compile.c 中:

    void zend_do_jmp_set_else(znode *result, const znode *false_value, const znode *jmp_token, const znode *colon_token TSRMLS_DC)
    {
    ›   zend_op *opline = get_next_op(CG(active_op_array) TSRMLS_CC);
    
    ›   SET_NODE(opline->result, colon_token);
    ›   if (colon_token->op_type == IS_TMP_VAR) {
    ›   ›   if (false_value->op_type == IS_VAR || false_value->op_type == IS_CV) {
    ›   ›   ›   CG(active_op_array)->opcodes[jmp_token->u.op.opline_num].opcode = ZEND_JMP_SET_VAR;
    ›   ›   ›   CG(active_op_array)->opcodes[jmp_token->u.op.opline_num].result_type = IS_VAR;
    ›   ›   ›   opline->opcode = ZEND_QM_ASSIGN_VAR;
    ›   ›   ›   opline->result_type = IS_VAR;
    ›   ›   } else {
    ›   ›   ›   opline->opcode = ZEND_QM_ASSIGN;
    ›   ›   }
    ›   } else {
    ›   ›   opline->opcode = ZEND_QM_ASSIGN_VAR;
    ›   }
    ›   opline->extended_value = 0;
    ›   SET_NODE(opline->op1, false_value);
    ›   SET_UNUSED(opline->op2);
    
    ›   GET_NODE(result, opline->result);
    
    ›   CG(active_op_array)->opcodes[jmp_token->u.op.opline_num].op2.opline_num = get_next_op_number(CG(active_op_array));
    
    ›   DEC_BPC(CG(active_op_array));
    }
    

    3.

    重点两个 opcode , ZEND_JMP_SET_VAR 和 ZEND_QM_ASSIGN_VAR ,怎么接着读代码呢?下面说下 PHP 的 opcode 。

    PHP5.6 有 167 个 opcode ,意味着可以执行 167 种不同的计算操作,官方文档看这里http://php.net/manual/en/internals2.opcodes.list.php

    PHP 内部使用_zend_op 这个结构体来表示 opcode, vim Zend/zend_compile.h +111

    111 struct _zend_op {
    112 ›   opcode_handler_t handler;
    113 ›   znode_op op1;
    114 ›   znode_op op2;
    115 ›   znode_op result;
    116 ›   ulong extended_value;
    117 ›   uint lineno;
    118 ›   zend_uchar opcode;
    119 ›   zend_uchar op1_type;
    120 ›   zend_uchar op2_type;
    121 ›   zend_uchar result_type;
    122 }
    

    PHP 7.0 略有不同,主要区别在针对 64 位系统 uint 换成 uint32_t ,明确指定字节数。

    你把 opcode 当成一个计算器,只接受两个操作数(op1, op2),执行一个操作(handler, 比如加减乘除),然后它返回一个结果(result)给你,再稍加处理算术溢出的情况(extended_value)。

    Zend 的 VM 对每个 opcode 的工作方式完全相同,都有一个 handler (函数指针),指向处理函数的地址。这是一个 C 函数,包含了执行 opcode 对应的代码,使用 op1 , op2 做为参数,执行完成后,会返回一个结果( result ),有时也会附加一段信息( extended_value)。

    用我们例子中的操作数 ZEND_JMP_SET_VAR 说明, vim Zend/zend_vm_def.h +4995

    4942 ZEND_VM_HANDLER(158, ZEND_JMP_SET_VAR, CONST|TMP|VAR|CV, ANY)
    4943 {
    4944 ›   USE_OPLINE
    4945 ›   zend_free_op free_op1;
    4946 ›   zval *value, *ret;
    4947
    4948 ›   SAVE_OPLINE();
    4949 ›   value = GET_OP1_ZVAL_PTR(BP_VAR_R);
    4950
    4951 ›   if (i_zend_is_true(value)) {
    4952 ›   ›   if (OP1_TYPE == IS_VAR || OP1_TYPE == IS_CV) {
    4953 ›   ›   ›   Z_ADDREF_P(value);
    4954 ›   ›   ›   EX_T(opline->result.var).var.ptr = value;
    4955 ›   ›   ›   EX_T(opline->result.var).var.ptr_ptr = &EX_T(opline->result.var).var.ptr;
    4956 ›   ›   } else {
    4957 ›   ›   ›   ALLOC_ZVAL(ret);
    4958 ›   ›   ›   INIT_PZVAL_COPY(ret, value);
    4959 ›   ›   ›   EX_T(opline->result.var).var.ptr = ret;
    4960 ›   ›   ›   EX_T(opline->result.var).var.ptr_ptr = &EX_T(opline->result.var).var.ptr;
    4961 ›   ›   ›   if (!IS_OP1_TMP_FREE()) {
    4962 ›   ›   ›   ›   zval_copy_ctor(EX_T(opline->result.var).var.ptr);
    4963 ›   ›   ›   }
    4964 ›   ›   }
    4965 ›   ›   FREE_OP1_IF_VAR();
    4966 #if DEBUG_ZEND>=2
    4967 ›   ›   printf("Conditional jmp to %d\n", opline->op2.opline_num);
    4968 #endif
    4969 ›   ›   ZEND_VM_JMP(opline->op2.jmp_addr);
    4970 ›   }
    4971
    4972 ›   FREE_OP1();
    4973 ›   CHECK_EXCEPTION();
    4974 ›   ZEND_VM_NEXT_OPCODE();
    4975 }
    

    i_zend_is_true 来判断操作数是否为 true ,所以 ZEND_JMP_SET_VAR 是一种条件赋值,相信大家都能看明白,下面讲重点。

    注意zend_vm_def.h这并不是一个可以直接编译的 C 的头文件,只能说是一个模板,具体可编译的头为zend_vm_execute.h(这个文件可有 45000 多行哦),它并非手动生成,而是由zend_vm_gen.php这个 PHP 脚本解析zend_vm_def.h后生成(有意思吧,先有鸡还是先有蛋,没有 PHP 哪来的这个脚本?),猜测这个是后期产物,早期 php 版本应该不会用这个。

    上面 ZEND_JMP_SET_VAR 的代码,根据不同参数 CONST|TMP|VAR|CV 最终会生成不同类型的,但功能一致的 handler 函数:

    static int ZEND_FASTCALL  ZEND_JMP_SET_VAR_SPEC_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
    static int ZEND_FASTCALL  ZEND_JMP_SET_VAR_SPEC_TMP_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
    static int ZEND_FASTCALL  ZEND_JMP_SET_VAR_SPEC_VAR_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
    static int ZEND_FASTCALL  ZEND_JMP_SET_VAR_SPEC_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
    

    这么做的目的是为了在编译期确定 handler ,提升运行期的性能。不这么做,在运行期根据参数类型选择,也可以做到,但性能不好。当然这么做有时也会生成一些垃圾代码(看似无用),不用担心, C 的编译器会进一步优化处理。

    zend_vm_gen.php 也可以接受一些参数,细节在 PHP 源码中的 README 文件 Zend/README.ZEND_VM 有详细说明。

    4.

    讲到这里,我们知道 opcode 怎么和 handler 对应了。但是在整体上还有一个过程,就是语法解析,解析后所有的 opcode 是怎么串联起来的呢?

    语法解析的细节就不说了,解析过后,会有个包含所有 opcode 的大数组(说链表可能更准确),从上面代码我们可以看到,每个 handler 执行完后,都会调用 ZEND_VM_NEXT_OPCODE(),取出下一个 opcode ,继续执行,直到最后退出,循环的代码 vim Zend/zend_vm_execute.h +337 :

    ZEND_API void execute_ex(zend_execute_data *execute_data TSRMLS_DC)
    {
    ›   DCL_OPLINE
    ›   zend_bool original_in_execution;
    
    
    
    ›   original_in_execution = EG(in_execution);
    ›   EG(in_execution) = 1;
    
    ›   if (0) {
    zend_vm_enter:
    ›   ›   execute_data = i_create_execute_data_from_op_array(EG(active_op_array), 1 TSRMLS_CC);
    ›   }
    
    ›   LOAD_REGS();
    ›   LOAD_OPLINE();
    
    ›   while (1) {
        ›   int ret;
    #ifdef ZEND_WIN32
    ›   ›   if (EG(timed_out)) {
    ›   ›   ›   zend_timeout(0);
    ›   ›   }
    #endif
    
    ›   ›   if ((ret = OPLINE->handler(execute_data TSRMLS_CC)) > 0) {
    ›   ›   ›   switch (ret) {
    ›   ›   ›   ›   case 1:
    ›   ›   ›   ›   ›   EG(in_execution) = original_in_execution;
    ›   ›   ›   ›   ›   return;
    ›   ›   ›   ›   case 2:
    ›   ›   ›   ›   ›   goto zend_vm_enter;
    ›   ›   ›   ›   ›   break;
    ›   ›   ›   ›   case 3:
    ›   ›   ›   ›   ›   execute_data = EG(current_execute_data);
    ›   ›   ›   ›   ›   break;
    ›   ›   ›   ›   default:
    ›   ›   ›   ›   ›   break;
    ›   ›   ›   }
    ›   ›   }
    
    ›   }
    ›   zend_error_noreturn(E_ERROR, "Arrived at end of main loop which shouldn't happen");
    }
    

    宏定义, vim Zend/zend_execute.c +1772

    1772 #define ZEND_VM_NEXT_OPCODE() \
    1773 ›   CHECK_SYMBOL_TABLES() \
    1774 ›   ZEND_VM_INC_OPCODE(); \
    1775 ›   ZEND_VM_CONTINUE()
    
    329 #define ZEND_VM_CONTINUE()         return 0
    330 #define ZEND_VM_RETURN()           return 1
    331 #define ZEND_VM_ENTER()            return 2
    332 #define ZEND_VM_LEAVE()            return 3
    

    while 是一个死循环,执行一个 handler 函数,除个别情况,多数 handler 函数末尾都调用 ZEND_VM_NEXT_OPCODE() -> ZEND_VM_CONTINUE(), return 0 ,继续循环。

    注:比如 yield 协程是个例外,它会返回 1 ,直接 return 出循环。以后有机会我们再单独对 yield 做分析。

    希望你看完上面内容,对 PHP Zend 引擎的解析过程有个详细的了解,下面我们基于原理的分析,再简单聊聊 PHP 的优化。

    5. PHP 优化注意事项

    5.1 echo 输出

    <?php
        $foo = 'foo';
        $bar = 'bar';
        echo $foo . $bar;
    

    vld 查看 opcode :

    number of ops:  5
    compiled vars:  !0 = $foo, !1 = $bar
    line     #* E I O op                           fetch          ext  return  operands
    -------------------------------------------------------------------------------------
       2     0  E >   ASSIGN                                                   !0, 'foo'
       3     1        ASSIGN                                                   !1, 'bar'
       4     2        CONCAT                                           ~2      !0, !1
             3        ECHO                                                     ~2
       5     4      > RETURN                                                   1
    
    branch: #  0; line:     2-    5; sop:     0; eop:     4; out1:  -2
    path #1: 0,
    

    ZEND_CONCAT 连接 $a 和$b 的值,保存到临时变量~2 中,然后 echo 出来。这个过程中涉及要分配一块内存,用于临时变量,用完后还要释放,还需要调用拼接函数,执行拼接过程。

    如果换成这样写:

    <?php
        $foo = 'foo';
        $bar = 'bar';
        echo $foo, $bar;
    

    对应的 opcode :

    number of ops:  5
    compiled vars:  !0 = $foo, !1 = $bar
    line     #* E I O op                           fetch          ext  return  operands
    -------------------------------------------------------------------------------------
       2     0  E >   ASSIGN                                                   !0, 'foo'
       3     1        ASSIGN                                                   !1, 'bar'
       4     2        ECHO                                                     !0
             3        ECHO                                                     !1
       5     4      > RETURN                                                   1
    
    branch: #  0; line:     2-    5; sop:     0; eop:     4; out1:  -2
    path #1: 0,
    

    不需要分配内存,也不需要执行拼接函数,是不是效率更好呢!想了解拼接过程,可以根据本文讲的内容,自行查找 ZEND_CONCAT 这个 opcode 对应的 handler ,做了好多事情哦。

    5.2 define()和 const

    const 关键字是从 5.3 开始引入的,和 define 有很大差别,和 C 语言的#define倒是含义差不多。

    • define() 是函数调用,有函数调用开销。
    • const 是关键字,直接生成 opcode ,属于编译期能确定的,不需要动态在执行期分配。

    const 的值是死的,运行时不可以改变,所以说类似 C 语言的 #define ,属于编译期间就确定的内容,而且对数值类型有限制。

    直接看代码,对比 opcode :

    define 例子:

    <?php
        define('FOO', 'foo');
        echo FOO;
    

    define opcode:

    number of ops:  6
    compiled vars:  none
    line     #* E I O op                           fetch          ext  return  operands
    -------------------------------------------------------------------------------------
       2     0  E >   SEND_VAL                                                 'FOO'
             1        SEND_VAL                                                 'foo'
             2        DO_FCALL                                      2          'define'
       3     3        FETCH_CONSTANT                                   ~1      'FOO'
             4        ECHO                                                     ~1
       4     5      > RETURN                                                   1
    

    const 例子:

    <?php
        const FOO = 'foo';
        echo FOO;
    

    const opcode:

    number of ops:  4
    compiled vars:  none
    line     #* E I O op                           fetch          ext  return  operands
    -------------------------------------------------------------------------------------
       2     0  E >   DECLARE_CONST                                            'FOO', 'foo'
       3     1        FETCH_CONSTANT                                   ~0      'FOO'
             2        ECHO                                                     ~0
       4     3      > RETURN                                                   1
    

    5.3 动态函数的代价

    <?php
        function foo() { }
        foo();
    

    对应 opcode :

    number of ops:  3
    compiled vars:  none
    line     #* E I O op                           fetch          ext  return  operands
    -------------------------------------------------------------------------------------
       2     0  E >   NOP
       3     1        DO_FCALL                                      0          'foo'
       4     2      > RETURN                                                   1
    

    动态调用的代码:

    <?php
        function foo() { }
        $a = 'foo';
        $a();
    

    opcode:

    number of ops:  5
    compiled vars:  !0 = $a
    line     #* E I O op                           fetch          ext  return  operands
    -------------------------------------------------------------------------------------
       2     0  E >   NOP
       3     1        ASSIGN                                                   !0, 'foo'
       4     2        INIT_FCALL_BY_NAME                                       !0
             3        DO_FCALL_BY_NAME                              0
       5     4      > RETURN                                                   1
    

    可以 vim Zend/zend_vm_def.h +2630 ,看看 INIT_FCALL_BY_NAME 做的事情,代码太长,这里不列出来了。动态特性虽然方便,但一定会牺牲性能,所以使用前要平衡利弊。

    5.4 类的延迟声明的代价

    还是先看代码:

    <?php
        class Bar { }
        class Foo extends Bar { }
    

    对应 opcode :

    number of ops:  4
    compiled vars:  none
    line     #* E I O op                           fetch          ext  return  operands
    -------------------------------------------------------------------------------------
       2     0  E >   NOP
       3     1        NOP
             2        NOP
       4     3      > RETURN                                                   1
    

    调换声明顺序:

    <?php
        class Foo extends Bar { }
        class Bar { }
    

    对应 opcode :

    number of ops:  4
    compiled vars:  none
    line     #* E I O op                           fetch          ext  return  operands
    -------------------------------------------------------------------------------------
       2     0  E >   FETCH_CLASS                                   0  :0      'Bar'
             1        DECLARE_INHERITED_CLASS                                  '%00foo%2FUsers%2Fqisen%2Ftmp%2Fvld.php0x103d58020', 'foo'
       3     2        NOP
       4     3      > RETURN                                                   1
    

    如果在强语言中,后面的写法会产生编译错误,但 PHP 这种动态语言,会把类的声明推迟到运行时,如果你不注意,就很可能踩到这个雷。

    所以在我们了解 Zend VM 原理后,就更应该注意少用动态特性,可有可无的时候,就一定不要用。

    转自: http://www.yinqisen.cn/blog-723.html

    8 条回复    2016-11-07 22:06:33 +08:00
    wujunze
        1
    wujunze  
       2016-11-04 13:16:57 +08:00
    感谢分享
    Light3
        2
    Light3  
       2016-11-04 13:23:50 +08:00
    吊吊吊
    lslqtz
        3
    lslqtz  
       2016-11-04 13:32:07 +08:00
    感谢分享
    young
        4
    young  
       2016-11-04 13:33:41 +08:00
    666
    ylsc633
        5
    ylsc633  
       2016-11-04 14:04:09 +08:00
    正好是我要看的! 先马
    kideny
        6
    kideny  
       2016-11-04 14:58:38 +08:00
    战略性 mark
    t0byxdd
        7
    t0byxdd  
       2016-11-04 15:01:51 +08:00
    mark
    mingyun
        8
    mingyun  
       2016-11-07 22:06:33 +08:00
    厉害了我的哥
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   3325 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 23ms · UTC 00:49 · PVG 08:49 · LAX 16:49 · JFK 19:49
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.