本文内容大部分翻译自getting into the zend execution engine (php 5),并做了一些调整,原文基于php 5,本文基于php 7。
php : 一门解释型语言php被称为脚本语言或解释型语言。为何? php语言没有被直接编译为机器指令,而是编译为一种中间代码的形式,很显然它无法直接在cpu上执行。 所以php的执行需要在进程级虚拟机上(见virtual machine中的process virtual machines,下文简称虚拟机)。
php语言,包括其他的解释型语言,其实是一个跨平台的被设计用来执行抽象指令的程序。php主要用于解决web开发相关的问题。
诸如java, python, c#, ruby, pascal, lua, perl, javascript等编程语言所编写的程序,都需要在虚拟机上执行。虚拟机可以通过jit编译技术将一部分虚拟机指令编译为机器指令以提高性能。鸟哥已经在进行php加入jit支持的开发了。
推荐教程:《php教程》
使用解释型语言的优点:
代码编写简单,能够快速开发
自动的内存管理
抽象的数据类型,程序可移植性高
缺点:
无法直接地进行内存管理和使用进程资源
比编译为机器指令的语言速度慢:通常需要更多的cpu周期来完成相同的任务(jit试图缩小差距,但永远不能完全消除)
抽象了太多东西,以至于当程序出问题时,许多程序员难以解释其根本原因
最后一条缺点是作者之所以写这篇文章的原因,作者觉得程序员应该去了解一些底层的东西。
作者希望能够通过这篇文章向读者讲明白php是如何运行的。本文所提到的关于php虚拟机的知识同样可以应用于其他解释型语言。通常,不同虚拟机实现上的最大不同点在于:是否使用jit、并行的虚拟机指令(一般使用多线程实现,php没有使用这一技术)、内存管理/垃圾回收算法。
zend虚拟机分为两大部分:
编译:将php代码转换为虚拟机指令(opcode)
执行:执行生成的虚拟机指令
本文不会涉及到编译部分,主要关注zend虚拟机的执行引擎。php7版本的执行引擎做了一部分重构,使得php代码的执行堆栈更加简单清晰,性能也得到了一些提升。
本文以php 7.0.7为示例。
opcode
维基百科对于opcode的解释:
opcodes can also be found in so-called byte codes and other representations intended for a software interpreter rather than a hardware device. these software based instruction sets often employ slightly higher-level data types and operations than most hardware counterparts, but are nevertheless constructed along similar lines.
opcode与bytecode在概念上是不同的。
我的个人理解:opcode作为一条指令,表明要怎么做,而bytecode由一序列的opcode/数据组成,表明要做什么。以一个加法为例子,opcode是告诉执行引擎将参数1和参数2相加,而bytecode则告诉执行引擎将45和56相加。
参考:difference between opcode and bytecode和difference between: opcode, byte code, mnemonics, machine code and assembly
在php中,zend/zend_vm_opcodes.h源码文件列出了所有支持的opcode。通常,每个opcode的名字都描述了其含义,比如:
zend_add:对两个操作数执行加法操作
zend_new:创建一个对象
zend_fetch_dim_r:读取操作数中某个维度的值,比如执行echo $foo[0]语句时,需要获取$foo数组索引为0的值
opcode以zend_op结构体表示:
struct _zend_op { const void *handler; /* 执行该opcode的c函数 */ znode_op op1; /* 操作数1 */ znode_op op2; /* 操作数2 */ znode_op result; /* 结果 */ uint32_t extended_value; /* 额外的信息 */ uint32_t lineno; /* 该opcode对应php源码所在的行 */ zend_uchar opcode; /* opcode对应的数值 */ zend_uchar op1_type; /* 操作数1类型 */ zend_uchar op2_type; /* 操作数2类型 */ zend_uchar result_type; /* 结果类型 */};
每一条opcode都以相同的方式执行:opcode有其对应的c函数,执行该c函数时,可能会用到0、1或2个操作数(op1,op2),最后将结果存储在result中,可能还会有一些额外的信息存储在extended_value。
看下zend_add的opcode长什么样子,在zend/zend_vm_def.h源码文件中:
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)) { if (expected(z_type_info_p(op2) == is_double)) { result = ex_var(opline->result.var); zval_double(result, z_dval_p(op1) + z_dval_p(op2)); zend_vm_next_opcode(); } else if (expected(z_type_info_p(op2) == is_long)) { result = ex_var(opline->result.var); zval_double(result, z_dval_p(op1) + ((double)z_lval_p(op2))); zend_vm_next_opcode(); } } save_opline(); if (op1_type == is_cv && unexpected(z_type_info_p(op1) == is_undef)) { op1 = get_op1_undef_cv(op1, bp_var_r); } if (op2_type == is_cv && unexpected(z_type_info_p(op2) == is_undef)) { op2 = get_op2_undef_cv(op2, bp_var_r); } add_function(ex_var(opline->result.var), op1, op2); free_op1(); free_op2(); zend_vm_next_opcode_check_exception();}
可以看出这其实不是一个合法的c代码,可以把它看成代码模板。稍微解读下这个代码模板:1 就是在zend/zend_vm_opcodes.h中define定义的zend_add的值;zend_add接收两个操作数,如果两个操作数都为is_long类型,那么就调用fast_long_add_function(该函数内部使用汇编实现加法操作);如果两个操作数,都为is_double类型或者1个是is_double类型,另1个是is_long类型,那么就直接执行double的加法操作;如果存在1个操作数不是is_long或is_double类型,那么就调用add_function(比如两个数组做加法操作);最后检查是否有异常接着执行下一条opcode。
在zend/zend_vm_def.h源码文件中的内容其实是opcode的代码模板,在该源文件的开头处可以看到这样一段注释:
/* if you change this file, please regenerate the zend_vm_execute.h and * zend_vm_opcodes.h files by running: * php zend_vm_gen.php */
说明zend_vm_execute.h和zend_vm_opcodes.h,实际上包括zend_vm_opcodes.c中的c代码正是从zend/zend_vm_def.h的代码模板生成的。
操作数类型每个opcode最多使用两个操作数:op1和op2。每个操作数代表着opcode的“形参”。例如zend_assign opcode将op2的值赋值给op1代表的php变量,而其result则没有使用到。
操作数的类型(与php变量的类型不同)决定了其含义以及使用方式:
is_cv:compiled variable,说明该操作数是一个php变量
is_tmp_var :虚拟机使用的临时内部php变量,不能够在不同opcode中复用(复用的这一点我并不清楚,还没去研究过)
is_var:虚拟机使用的内部php变量,能够在不同opcode中复用(复用的这一点我并不清楚,还没去研究过)
is_const:代表一个常量值
is_unused:该操作数没有任何意义,忽略该操作数
操作数的类型对性能优化和内存管理很重要。当一个opcode的handler需要读写操作数时,会根据操作数的类型通过不同的方式读写。
以加法例子,说明操作数类型:
$a + $b; // is_cv + is_cv1 + $a; // is_const + is_cv$$b + 3 // is_var + is_const!$a + 3; // is_tmp_var + is_const
opcode handler我们已经知道每个opcode handler最多接收2个操作数,并且会根据操作数的类型读写操作数的值。如果在handler中,通过switch判断类型,然后再读写操作数的值,那么对性能会有很大损耗,因为存在太多的分支判断了(why is it good to avoid instruction branching where possible?),如下面的伪代码所示:
int zend_add(zend_op *op1, zend_op *op2){ void *op1_value; void *op2_value; switch (op1->type) { case is_cv: op1_value = read_op_as_a_cv(op1); break; case is_var: op1_value = read_op_as_a_var(op1); break; case is_const: op1_value = read_op_as_a_const(op1); break; case is_tmp_var: op1_value = read_op_as_a_tmp(op1); break; case is_unused: op1_value = null; break; } /* ... same thing to do for op2 .../ /* do something with op1_value and op2_value (perform a math addition ?) */}
要知道opcode handler在php执行过程中是会被调用成千上万次的,所以在handler中对op1、op2做类型判断,对性能并不好。
重新看下zend_add的代码模板:
zend_vm_handler(1, zend_add, const|tmpvar|cv, const|tmpvar|cv)
这说明zend_add接收op1和op2为const或tmpvar或cv类型的操作数。
前面已经提到zend_vm_execute.h和zend_vm_opcodes.h中的c代码是从zend/zend_vm_def.h的代码模板生成的。通过查看zend_vm_execute.h,可以看到每个opcode对应的handler(c函数),大部分opcode会对应多个handler。以zend_add为例:
static zend_opcode_handler_ret zend_fastcall zend_add_spec_const_const_handler(zend_opcode_handler_args)static zend_opcode_handler_ret zend_fastcall zend_add_spec_const_cv_handler(zend_opcode_handler_args)static zend_opcode_handler_ret zend_fastcall zend_add_spec_const_tmpvar_handler(zend_opcode_handler_args)static zend_opcode_handler_ret zend_fastcall zend_add_spec_cv_const_handler(zend_opcode_handler_args)static zend_opcode_handler_ret zend_fastcall zend_add_spec_cv_cv_handler(zend_opcode_handler_args)static zend_opcode_handler_ret zend_fastcall zend_add_spec_cv_tmpvar_handler(zend_opcode_handler_args)static zend_opcode_handler_ret zend_fastcall zend_add_spec_tmpvar_const_handler(zend_opcode_handler_args)static zend_opcode_handler_ret zend_fastcall zend_add_spec_tmpvar_cv_handler(zend_opcode_handler_args)static zend_opcode_handler_ret zend_fastcall zend_add_spec_tmpvar_tmpvar_handler(zend_opcode_handler_args)
zend_add的op1和op2的类型都有3种,所以一共生成了9个handler,每个handler的命名规范:zend_{opcode-name}_spec_{op1-type}_{op2-type}_handler()。在编译阶段,操作数的类型是已知的,也就确定了每个编译出来的opcode对应的handler了。
那么这些handler之间有什么不同呢?最大的不同应该就是获取操作数的方式:
static zend_opcode_handler_ret zend_fastcall zend_add_spec_const_const_handler(zend_opcode_handler_args){ use_opline zval *op1, *op2, *result; op1 = ex_constant(opline->op1); op2 = ex_constant(opline->op2); if (expected(z_type_info_p(op1) == is_long)) { /* 省略 */ } else if (expected(z_type_info_p(op1) == is_double)) { /* 省略 */ } save_opline(); if (is_const == is_cv && unexpected(z_type_info_p(op1) == is_undef)) { //<-------- 这部分代码会被编译器优化掉 op1 = get_op1_undef_cv(op1, bp_var_r); } if (is_const == is_cv && unexpected(z_type_info_p(op2) == is_undef)) { //<-------- 这部分代码会被编译器优化掉 op2 = get_op2_undef_cv(op2, bp_var_r); } add_function(ex_var(opline->result.var), op1, op2); zend_vm_next_opcode_check_exception();}static zend_opcode_handler_ret zend_fastcall zend_add_spec_const_cv_handler(zend_opcode_handler_args){ use_opline zval *op1, *op2, *result; op1 = ex_constant(opline->op1); op2 = _get_zval_ptr_cv_undef(execute_data, opline->op2.var); //<-------- op2的获取方式与上面的const不同 if (expected(z_type_info_p(op1) == is_long)) { /* 省略 */ } else if (expected(z_type_info_p(op1) == is_double)) { /* 省略 */ } save_opline(); if (is_const == is_cv && unexpected(z_type_info_p(op1) == is_undef)) { //<-------- 这部分代码会被编译器优化掉 op1 = get_op1_undef_cv(op1, bp_var_r); } if (is_cv == is_cv && unexpected(z_type_info_p(op2) == is_undef)) { //<-------- is_cv == is_cv && 也会被编译器优化掉 op2 = get_op2_undef_cv(op2, bp_var_r); } add_function(ex_var(opline->result.var), op1, op2); zend_vm_next_opcode_check_exception();}
oparrayoparray是指一个包含许多要被顺序执行的opcode的数组,如下图:
oparray由结构体_zend_op_array表示:
struct _zend_op_array { /* common elements */ /* 省略 */ /* end of common elements */ /* 省略 */ zend_op *opcodes; //<------ 存储着opcode的数组 /* 省略 */};
在php中,每个php用户函数或者php脚本、传递给eval()的参数,会被编译为一个oparray。
oparray中包含了许多静态的信息,能够帮助执行引擎更高效地执行php代码。部分重要的信息如下:
当前脚本的文件名,oparray对应的php代码在脚本中起始和终止的行号
/**的代码注释信息
refcount引用计数,oparray是可共享的
try-catch-finally的跳转信息
break-continue的跳转信息
当前作用域所有php变量的名称
函数中用到的静态变量
literals(字面量),编译阶段已知的值,例如字符串“foo”,或者整数42
运行时缓存槽,引擎会缓存一些后续执行需要用到的东西
一个简单的例子:
$a = 8;$b = 'foo';echo $a + $b;
oparray中的部分成员其内容如下:
oparray包含的信息越多,即在编译期间尽量的将已知的信息计算好存储到oparray中,执行引擎就能够更高效地执行。我们可以看到每个字面量都已经被编译为zval并存储到literals数组中(你可能发现这里多了一个整型值1,其实这是用于zend_return opcode的,php文件的oparray默认会返回1,但函数的oparray默认返回null)。oparray所使用到的php变量的名字信息也被编译为zend_string存储到vars数组中,编译后的opcode则存储到opcodes数组中。
opcode的执行opcode的执行是通过一个while循环去做的:
//删除了预处理语句zend_api void execute_ex(zend_execute_data *ex){ dcl_opline const zend_op *orig_opline = opline; zend_execute_data *orig_execute_data = execute_data; execute_data = ex; load_opline(); while (1) { ((opcode_handler_t)opline->handler)(zend_opcode_handler_args_passthru); //执行opcode对应的c函数 if (unexpected(!opline)) { //当前oparray执行完 execute_data = orig_execute_data; opline = orig_opline; return; } } zend_error_noreturn(e_core_error, "arrived at end of main loop which shouldn't happen");}
那么是如何切换到下一个opcode去执行的呢?每个opcode的handler中都会调用到一个宏:
#define zend_vm_next_opcode_ex(check_exception, skip) \ check_symbol_tables() \ if (check_exception) { \ opline = ex(opline) + (skip); \ } else { \ opline = opline + (skip); \ } \ zend_vm_continue()
该宏会把当前的opline+skip(skip通常是1),将opline指向下一条opcode。opline是一个全局变量,指向当前执行的opcode。
额外的一些东西编译器优化在zend/zend_vm_execute.h中,会看到如下奇怪的代码:
static zend_opcode_handler_ret zend_fastcall zend_init_array_spec_const_const_handler(zend_opcode_handler_args){ /* 省略 */ if (is_const == is_unused) { zend_vm_next_opcode();#if 0 || (is_const != is_unused) } else { zend_vm_tail_call(zend_add_array_element_spec_const_const_handler(zend_opcode_handler_args_passthru));#endif }}
你可能会对if (is_const == is_unused)和#if 0 || (is_const != is_unused)感到奇怪。看下其对应的模板代码:
zend_vm_handler(71, zend_init_array, const|tmp|var|unused|cv, const|tmpvar|unused|cv){ zval *array; uint32_t size; use_opline array = ex_var(opline->result.var); if (op1_type != is_unused) { size = opline->extended_value >> zend_array_size_shift; } else { size = 0; } zval_new_arr(array); zend_hash_init(z_arrval_p(array), size, null, zval_ptr_dtor, 0); if (op1_type != is_unused) { /* explicitly initialize array as not-packed if flag is set */ if (opline->extended_value & zend_array_not_packed) { zend_hash_real_init(z_arrval_p(array), 0); } } if (op1_type == is_unused) { zend_vm_next_opcode();#if !defined(zend_vm_spec) || (op1_type != is_unused) } else { zend_vm_dispatch_to_handler(zend_add_array_element);#endif }}
php zend_vm_gen.php在生成zend_vm_execute.h时,会把op1_type替换为op1的类型,从而生成这样子的代码:if (is_const == is_unused),但c编译器会把这些代码优化掉。
自定义zend执行引擎的生成zend_vm_gen.php支持传入参数--without-specializer,当使用该参数时,每个opcode只会生成一个与之对应的handler,该handler中会对操作数做类型判断,然后再对操作数进行读写。
另一个参数是--with-vm-kind=call|switch|goto,call是默认参数。
前面已提到执行引擎是通过一个while循环执行opcode,每个opcode中将opline增加1(通常情况下),然后回到while循环中,继续执行下一个opcode,直到遇到zend_return。
如果使用goto执行策略:
/* goto策略下,execute_ex是一个超大的函数 */zend_api void execute_ex(zend_execute_data *ex){ /* 省略 */ while (1) { /* 省略 */ goto *(void**)(opline->handler); /* 省略 */ } /* 省略 */}
这里的goto并没有直接使用符号名,其实是goto一个特殊的用法:labels as values。
执行引擎中的跳转当php脚本中出现if语句时,是如何跳转到相应的opcode然后继续执行的?看下面简单的例子:
$a = 8;if ($a == 9) { echo "foo";} else { echo "bar";}number of ops: 7compiled vars: !0 = $aline #* e i o op fetch ext return operands------------------------------------------------------------------------------------- 2 0 e > assign !0, 8 3 1 is_equal ~2 !0, 9 2 > jmpz ~2, ->5 4 3 > echo 'foo' 4 > jmp ->6 6 5 > echo 'bar' 6 > > return 1
当$a != 9时,jmpz会使当前执行跳转到第5个opcode,否则jmp会使当前执行跳转到第6个opcode。其实就是对当前的opline赋值为跳转目标opcode的地址。
一些性能tips这部分内容将展示如何通过查看生成的opcode优化php代码。
echo a concatenation示例代码:
$foo = 'foo';$bar = 'bar';echo $foo . $bar;
oparray:
number of ops: 5compiled vars: !0 = $foo, !1 = $barline #* e i o op fetch ext return operands------------------------------------------------------------------------------------- 2 0 e > assign !0, 'foo' 3 1 assign !1, 'bar' 5 2 concat ~4 !0, !1 3 echo ~4 4 > return 1
$a和$b的值会被zend_concat连接后存储到一个临时变量~4中,然后再echo输出。
concat操作需要分配一块临时的内存,然后做内存拷贝,echo输出后,又要回收这块临时内存。如果把代码改为如下可消除concat:
$foo = 'foo';$bar = 'bar';echo $foo , $bar;
oparray:
number of ops: 5compiled vars: !0 = $foo, !1 = $barline #* e i o op fetch ext return operands------------------------------------------------------------------------------------- 2 0 e > assign !0, 'foo' 3 1 assign !1, 'bar' 5 2 echo !0 3 echo !1 4 > return 1
define()和constphp 5.3引入了const关键字。
简单地说:
define()是一个函数调用
conast是关键字,不会产生函数调用,要比define()轻量许多
define('foo', 'foo');echo foo;number of ops: 7compiled vars: noneline #* e i o op fetch ext return operands------------------------------------------------------------------------------------- 2 0 e > init_fcall 'define' 1 send_val 'foo' 2 send_val 'foo' 3 do_icall 3 4 fetch_constant ~1 'foo' 5 echo ~1 6 > return 1
如果使用const:
const foo = 'foo';echo foo;number of ops: 4compiled vars: noneline #* e i o op fetch ext return operands------------------------------------------------------------------------------------- 2 0 e > declare_const 'foo', 'foo' 3 1 fetch_constant ~0 'foo' 2 echo ~0 3 > return 1
然而const在使用上有一些限制:
const关键字定义常量必须处于最顶端的作用区域,这就意味着不能在函数内,循环内以及if语句之内用const 来定义常量
const的操作数必须为is_const类型
动态函数调用尽量不要使用动态的函数名去调用函数:
function foo() { }foo();number of ops: 4compiled vars: noneline #* e i o op fetch ext return operands------------------------------------------------------------------------------------- 2 0 e > nop 3 1 init_fcall 'foo' 2 do_ucall 3 > return 1
nop表示不做任何操作,只是将当前opline指向下一条opcode,编译器产生这条指令是由于历史原因。为何到php7还不移除它呢= =
看看使用动态的函数名去调用函数:
function foo() { }$a = 'foo';$a();number of ops: 5compiled vars: !0 = $aline #* e i o op fetch ext return operands------------------------------------------------------------------------------------- 2 0 e > nop 3 1 assign !0, 'foo' 4 2 init_dynamic_call !0 3 do_fcall 0 4 > return 1
不同点在于init_fcall和init_dynamic_call,看下两个函数的源码:
static zend_opcode_handler_ret zend_fastcall zend_init_fcall_spec_const_handler(zend_opcode_handler_args){ use_opline zval *fname = ex_constant(opline->op2); zval *func; zend_function *fbc; zend_execute_data *call; fbc = cached_ptr(z_cache_slot_p(fname)); /* 看下是否已经在缓存中了 */ if (unexpected(fbc == null)) { func = zend_hash_find(eg(function_table), z_str_p(fname)); /* 根据函数名查找函数 */ if (unexpected(func == null)) { save_opline(); zend_throw_error(null, "call to undefined function %s()", z_strval_p(fname)); handle_exception(); } fbc = z_func_p(func); cache_ptr(z_cache_slot_p(fname), fbc); /* 缓存查找结果 */ } call = zend_vm_stack_push_call_frame_ex( opline->op1.num, zend_call_nested_function, fbc, opline->extended_value, null, null); call->prev_execute_data = ex(call); ex(call) = call; zend_vm_next_opcode();}static zend_opcode_handler_ret zend_fastcall zend_init_dynamic_call_spec_cv_handler(zend_opcode_handler_args){ /* 200多行代码,就不贴出来了,会根据cv的类型(字符串、对象、数组)做不同的函数查找 */}
很显然init_fcall相比init_dynamic_call要轻量许多。
类的延迟绑定简单地说,类a继承类b,类b最好先于类a被定义。
class bar { }class foo extends bar { }number of ops: 4compiled vars: noneline #* e i o op fetch ext return operands------------------------------------------------------------------------------------- 2 0 e > nop 3 1 nop 2 nop 3 > return 1
从生成的opcode可以看出,上述php代码在运行时,执行引擎不需要做任何操作。类的定义是比较耗性能的工作,例如解析类的继承关系,将父类的方法/属性添加进来,但编译器已经做完了这些繁重的工作。
如果类a先于类b被定义:
class foo extends bar { }class bar { }number of ops: 4compiled vars: noneline #* e i o op fetch ext return operands------------------------------------------------------------------------------------- 2 0 e > fetch_class 0 :0 'bar' 1 declare_inherited_class '%00foo%2fhome%2froketyyang%2ftest.php0x7fb192b7101f', 'foo' 3 2 nop 3 > return 1
这里定义了foo继承自bar,但当编译器读取到foo的定义时,编译器并不知道任何关于bar的情况,所以编译器就生成相应的opcode,使其定义延迟到执行时。在一些其他的动态类型的语言中,可能会产生错误:parse error : class not found。
除了类的延迟绑定,像接口、traits都存在延迟绑定耗性能的问题。
对于定位php性能问题,通常都是先用xhprof或xdebug profile进行定位,需要通过查看opcode定位性能问题的场景还是比较少的。
总结希望通过这篇文章,能让你了解到php虚拟机大致是如何工作的。具体opcode的执行,以及函数调用涉及到的上下文切换,有许多细节性的东西,限于本文篇幅,在另一篇文章:php 7 中函数调用的实现进行讲解。
推荐相关文章:《linux系统教程》
以上就是了解什么是php7虚拟机的详细内容。