PHP7内核剖析 学习笔记 第五章 PHP的编译与执行(2)
5.4 PHP的执行
ZendVM执行器由指令处理handler与调度器组成:指令处理handler是每条opcode定义的具体处理过程,根据操作数的不同类型,每种opcode可定义25个handler,执行时,由执行器调用相应的handler完成指令的处理;调度器负责控制指令的执行,以及执行器的上下文切换,由调度器发起handler的执行。
5.4.1 handler的定义
所有opcode的处理handler定义在Zend/zend_vm_def.h中,定义时不需要为每一条opcode定义25个handler,Zend提供了一个脚本用于生成不同操作数类型的handler,我们只需要在zend_vm_def.h为opcode定义一个handler即可,然后根据不同操作数类型区分处理即可。即zend_vm_def.h只是handler的定义文件,编译时并不会用到,需要在Zend目录下执行zend_vm_gen.php脚本生成实际的handler文件zend_vm_execute.h。
每条opcode都需要通过ZEND_VM_HANDLER()定义handler,它有四个参数,分别是opcode值、opcode名、可接受的操作数1类型、可接受的操作数2类型,最后gen脚本根据支持的操作数类型生成不同类型组合的handler。
在zend_vm_execute.h中,常看到有些怪异的判断条件,比如IS_CONST == IS_CV
、IS_CONST == IS_CONST
等,这些就是gen脚本根据不同的类型替换导致的。也就是说,我们在zend_vm_def.h中兼容不同的操作数,然后gen脚本将每一种操作数组合替换生成一个handler,这就导致生成的handler中会有部分操作数组合的逻辑是handler不会用到的。如下例,该opcode的操作数1可以接受CONST、CV两种类型,handler定义中针对不同类型进行处理:
ZEND_VM_HANDLER(196, ZEND_TEST_OP, CONST|CV, CONST) {
if (OP1_TYPE == IS_CONST) {
// 操作数1为const类型时的处理
} else if (OP1_TYPE == IS_CV) {
// 操作数1为cv类型时的处理
}
}
上例将生成两个handler,分别是CONST_CONST、CV_CONST组合,gen脚本在生成不同组合的handler时会把OP1_TYPE替换为对应类型,生成的两个handler分别是:
// op1:CONST op2:CONST
static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL
ZEND_TEST_OP_SPEC_CONST_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS) {
if (IS_CONST == IS_CONST) {
// 操作数1为const类型时的处理
// 对本handler而言,这个分支是无用的
} else if (IS_CONST == IS_CV) {
// 操作数1为cv类型时的处理
}
}
// op1:CV op2:CONST
static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL
ZEND_TEST_OP_SPEC_CV_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS) {
// 对本handler而言,这个分支是无用的
if (IS_CONST == IS_CONST) {
// 操作数1为const类型时的处理
} else if (IS_CONST == IS_CV) {
// 操作数1为cv类型时的处理
}
}
所以,这些永远不可能成立的判断条件是其他操作数组合用到的,它们实际上是多余的。直接使用zend_vm_def.h中定义的handler,运行时根据OP1_TYPE动态判断效率比较低,而生成的handler中,一定成立和一定不成立的代码会被编译器优化掉,从而提高性能,减少函数大小。另一方面,按操作数粒度拆开后方便针对某些特殊操作数进行优化,比如在JIT(Just-in Time,即时编译)里,就可针对特定操作数组合进行handler替换。
另外,zend_vm_gen.php提供了部分宏,用于屏蔽不同类型之间操作的差异,通过这些宏,我们不需要再根据各种操作数类型兼容处理,该脚本会替换为各类型具体的处理方法,当然这仅限于部分操作,并不意味着我们不需要关心不同操作数的处理。比如GET_OP2_ZVAL_PTR(),用于获取操作数zval地址的宏,操作数2的类型会被相应地替换为以下值:
$op2_get_zval_ptr = array(
"ANY" => "get_zval_ptr(opline->op2_type, opline->op2, execute_data, &free_op2, \\1)",
"TMP" => "_get_zval_ptr_tmp(opline->op2.var, execute_data, &free_op2)",
"CONST" => "EX_CONSTANT(opline->op2)",
"UNUSED" => "NULL",
"CV" => "_get_zval_ptr_cv_\\1(execute_data, opline->op2.var)",
"TMPVAR" => "_get_zval_ptr_var(opline->op2.var, execute_data, &free_op2)",
);
$a = 123
这种语句将通过EX_CONSTANT(opline->op2)获取变量值123的zval地址,$a = $b
则将通过_get_zval_ptr_cv_BP_VAR_R(execute_data, opline->op2.var)
获取(看起来上面代码中的\1是个占位符,此处被替换为了BP_VAR_R)。
5.4.2 调度方式
handler是每条opcode对应的C语言编写的处理逻辑。ZendVM执行器的调度方式有三种不同的实现:CALL、SWITCH、GOTO,默认方式为CALL。三种模式中,GOTO最快。如果想选用除默认外的其他两种,则需要执行zend_vm_gen.php脚本重新生成,通过--with-vm-kind=CALL|SWITCH|GOTO
参数指定使用的方式,本书介绍的内容均为默认的CALL方式。
1.CALL:将各opcode定义的handler封装为独立的C语言函数,执行指令时依次调用不同函数进行处理。
2.SWITCH:将所有opcode的处理逻辑定义在一个函数中,通过switch的不同分支进行调度执行。
3.GOTO:将所有opcode的处理逻辑定义在一个函数中,通过不同的label标签区分,执行时根据opcode跳转到对应label处执行。
假设opcode数组如下:
int op_array[] = {
opcode_1,
opcode_2,
opcode_3,
...
};
不同调度方式的工作过程如下:
// CALL模式
void opcode_1_handler() {...}
void opcode_2_handler() {...}
...
void execute(int []op_array) {
void *opcode_handler_list[] = {&opcode_1_handler, &opcode_2_handler, ...};
while (1) {
void handler = opcode_handler_list[op_array[i]];
handler(); // call handler
i++;
}
}
// GOTO模式
void execute(int []op_array) {
while (1) {
goto opcode_xx_handler_label;
}
opcode_1_handler_label:
...
opcode_2_handler_label:
...
...
}
// SWITCH模式
void execute(int []op_array) {
while(1) {
switch(op_array[i]) {
case op_code1:
...
case op_code2:
...
}
i++;
}
}
CALL方式下,所有opcode定义的handler按固定排列规则保存在数组里,如果opcode用不了25个handler,则可以把无用的handler定义为空操作:ZEND_NOP_SPEC_HANDLER。25个handler在数组中的排列规则是:按CONST、TMP、VAR、UNUSED、CV的顺序依次两两组合,即首先是CONST与5种类型的组合,接着是TMP与5种类型的组合,依次类推。
// file: zend_vm_execute.h
static const void **zend_opcode_handlers;
void zend_init_opcodes_handlers(void) {
static const void *labels[] = {
ZEND_NOP_SPEC_HANDLER,
ZEND_NOP_SPEC_HANDLER,
...
};
zend_opcode_handlers = labels;
}
zend_opcode_handlers数组也是zend_vm_gen.php自动生成的,根据opcode及操作数类型,可通过zend_vm_get_opcode_handler()方法获取对应handler,具体索引方法如下:
// file: zend_execute.c
#define _CONST_CODE 0
#define _TMP_CODE 1
#define _VAR_CODE 2
#define _UNUSED_CODE 3
#define _CV_CODE 4
// file: zend_vm_execute.h
static const void *zend_vm_get_opcode_handler(zend_uchar opcode, const zend_op *op) {
// 该数组是以操作数类型作为下标作为索引
static const int zend_vm_decode[] = {
_UNUSED_CODE, /* 0 */
_CONST_CODE, /* 1 = IS_CONST */
_TMP_CODE, /* 2 = IS_TMP_VAR */
_UNUSED_CODE, /* 3 */
_VAR_CODE, /* 4 = IS_VAR */
_UNUSED_CODE, /* 5 */
_UNUSED_CODE, /* 6 */
_UNUSED_CODE, /* 7 */
_UNUSED_CODE, /* 8 = IS_UNUSED */
_UNUSED_CODE, /* 9 */
_UNUSED_CODE, /* 10 */
_UNUSED_CODE, /* 11 */
_UNUSED_CODE, /* 12 */
_UNUSED_CODE, /* 13 */
_UNUSED_CODE, /* 14 */
_UNUSED_CODE, /* 15 */
_CV_CODE /* 16 = IS_CV */
};
// 根据op1_type、op2_type、opcode得到对应的handler
return zend_opcode_handlers[opcode * 25 + zend_vm_decode[op->op1_type] * 5 +
zend_vm_decode[op->op2_type]];
}
操作数类型的值是1、2、4、8、16,zend_vm_decode数组实际只有索引为1、2、4、8、16的元素有用。在编译的最后,pass_two函数会遍历所有opline指令设置handler,该过程就是调用zend_vm_set_opcode_handler()完成的。
5.4.3 执行流程
zend_execute_data是ZendVM执行过程中非常重要的一个数据结构,它有两个作用:记录运行时信息、分配动态变量内存。在执行开始前,首先会分配一个zend_execute_data结构,然后将zend_execute_data->opline指向zend_op_array->opcodes第一条指令。随zend_execute_data结构分配的,还有当前zend_op_array中用到的CV、VAR、TMP_VAR变量。分配完成后,执行器从zend_execute_data->opline开始执行,执行完后该指针指向下一条指令继续执行,所有指令执行完后,再把zend_execute_data释放掉。整个过程与C程序的执行机制非常相像,zend_execute_data扮演了C程序执行时的Stack的角色,PHP中局部变量的分配与访问也同C语言的处理一致。
PHP与C、Java这些语言不同,它不需要定义main函数,从PHP脚本开始位置直接执行,这里我们把PHP函数、类之外的代码统称为主代码(main code),本节介绍的就是这种类型。PHP中定义的函数称为用户自定义函数,与此对应的内核或扩展定义的函数称为内部函数。我们看一下主代码的具体执行过程,入口函数为zend_execute():
ZEND_API void zend_execute(zend_op_array *op_array, zval *return_value) {
zend_execute_data *execute_data;
...
// 分配zend_execute_data
execute_data = zend_vm_stack_push_call_frame(ZEND_CALL_TOP_CODE,
(zend_function *)op_array, 0, zend_get_called_scope(EG(current_execute_data)),
zend_get_this_object(EG(current_execute_data)));
...
// execute_data->prev_execute_data = EG(current_execute_data);
EX(prev_execute_data) = EG(current_execute_data);
// 初始化execute_data
i_init_execute_data(execute_data, op_array, return_value);
// 执行opcode
zend_execute_ex(execute_data);
zend_vm_stack_free_call_frame(execute_data);
}
下面具体介绍执行过程中的几个重要步骤:
1.分配zend_execute_data
通过zend_vm_stack_push_call_frame()方法分配一块用于当前作用域的内存空间,即zend_execute_data,分配时会根据zend_op_array->last_var(CV变量数)、zend_op_array->T(临时变量数)计算出动态变量区的内存大小,随zend_execute_data一起分配。
// file: zend_execute.h zend_vm_stack_push_call_frame:
// 计算需要分配的内存大小
uint32_t used_stack = zend_vm_calc_used_stack(num_args, func);
zend_vm_calc_used_stack()的num_args参数为调用func参数表示的函数时实际传入参数数量,在函数分配zend_execute_data时会用到,而func->op_array.num_args为函数的全部参数数量。在用户自定义函数中used_stack = ZEND_CALL_FRAME_SLOT + func->op_array.last_var + func->op_array.T
,而在调用内部函数时则只需要分配实际传入参数的空间即可,内部函数不会有临时变量的概念。
// zend_execute.h
static zend_always_inline uint32_t zend_vm_calc_used_stack(uint32_t num_args,
zend_function *func) {
uint32_t used_stack = ZEND_CALL_FRAME_SLOT + num_args;
// 用户自定义函数
if (EXPECTED(ZEND_USER_CODE(func->type))) {
// MIN(func->op_array.num_args, num_args) = num_args
used_stack += func->op_array.last_var + func->op_array.T -
MIN(func->op_array.num_args, num_args);
}
return used_stack * sizeof(zval);
}
#define ZEND_CALL_FRAME_SLOT \
((int)((ZEND_MM_ALIGNED_SIZE(sizeof(zend_execute_data)) + \
ZEND_MM_ALIGNED_SIZE(sizeof(zval)) - 1) / ZEND_MM_ALIGNED_SIZE(sizeof(zval))))
仍以5.3.2节的示例为例:last_var = 2
,T = 2
,此时计算出used_stack为160字节,其中zend_execute_data自己对齐后占96字节,CV变量(a、b)占32字节、VAR变量占32字节。zend_execute_data分配完成后会将func成员指向要执行的zend_op_array,func类型是zend_function,这是一个union,其中一个成员为zend_op_array类型,主代码用的就是这个成员。分配完成后,zend_execute_data的内部分布以及与zend_op_array的关系如图5-23所示:
在分配zend_execute_data内存时,传入了一个ZEND_CALL_OP_CODE参数,这个值是用来标识执行器的调用类型的,因为PHP主代码执行、调用用户自定义函数、调用内部函数、include/require/eval的执行流程都是相似的,都会分配zend_execute_data,因此,zend_execute_data通过This成员,将调用类型保存下来,This的类型是zval,具体保存在This.u1.v.reserved中。
typedef enum _zend_call_kind {
ZEND_CALL_NESTED_FUNCTION, /* 调用用户自定义函数 */
ZEND_CALL_NESTED_CODE, /* include/require/eval */
ZEND_CALL_TOP_FUNCTION, /* 调用内部函数 */
ZEND_CALL_TOP_CODE /* 主代码执行 */
} zend_call_kind;
2.初始化zend_execute_data
这一步主要初始化zend_execute_data中的一些成员,如opline、return_value。另外还有一个比较重要的操作:zend_attach_symbol_table(),这是一个哈希表,用于存储全局变量。主代码中的全部CV变量都是全局变量,尽管这些变量对于主代码来说是局部变量,但对于其他函数,它们就是全局变量,可以在函数中通过global关键字访问到。
// file: zend_execute.c
static zend_always_inline void i_init_execute_data(zend_execute_data *execute_data,
zend_op_array *op_array, zval *return_value) {
EX(opline) = op_array->opcodes;
EX(call) = NULL;
EX(return_value) = return_value;
if (UNEXPECTED(EX(symbol_table) != NULL) {
...
// 添加全局变量
zend_attack_symbol_table(execute_data);
} else {
...
}
EX_LOAD_RUN_TIME_CACHE(op_array);
EX_LOAD_LITERALS(op_array);
EG(current_execute_data) = execute_data;
ZEND_VM_INTERRUPT_CHECK();
}
经过这一步处理后,zend_execute_data->opline指向了第一条指令,同时将zend_execute_data->literals指向了zend_op_array->literals,便于快速访问,最后将EG(current_execute_data)指向zend_execute_data。现在,执行前的准备工作都完成了,zend_execute_data执行前的状态如图5-24所示:
3.执行
ZendVM的执行调度器就是一个while循环,在这个循环中依次调用opline指令的handler,然后根据handler的返回决定下一步动作。执行调度器为zend_execute_ex,这是函数指针,默认为execute_ex(),可通过扩展进行覆盖。GCC低于4.8情况下,调度器execute_ex展开后如下:
ZEND_API void execute_ex(zend_execute_data *ex) {
zend_execute_data *execute_data = ex;
while (1) {
int ret;
// 执行当前指令
if (UNEXPECTED((ret =
((opcode_handler_t)execute_data->opline->handler)(execute_data)) != 0) {
if (EXPECTED(ret > 0)) {
execute_data = EG(current_execute_data);
} else {
return;
}
}
}
}
执行的第一条opcode为ZEND_ASSIGN,即$a = 123
,该指令由ZEND_ASSIGN_SPEC_CV_CONST_HANDLER()处理,首先根据操作数1、2取出赋值的变量与变量值,其中变量值为CONST类型,保存在literals中,通过EX_CONSTANT(opline->op2)获取它的值。$a
为CV变量,分配在zend_execute_data动态变量区,通过_get_zval_ptr_cv_undef_BP_VAR_W()
取到这个变量的地址,之后将变量值复制到CV变量即可。
static int ZEND_ASSIGN_SPEC_CV_CONST_HANDLER(zend_execute_data *execute_data) {
// USE_OPLINE
const zend_op *opline = execute_data->opline;
...
value = EX_CONSTANT(opline->op2);
variable_ptr = _get_zval_ptr_cv_undef_BP_VAR_W(execute_data, opline->op1.var);
...
// 赋值
value = zend_assign_to_variable(variable_ptr, value, IS_CONST);
...
// ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION()
execute_data->opline = execute_data->opline + 1;
return 0;
}
执行完后会更新opline,指向下一条指令,然后返回到调度器。handler会有几个可能得返回值:
#define ZEND_VM_CONTINUE() return 0
#define ZEND_VM_ENTER() return 1
#define ZEND_VM_LEAVE() return 2
#define ZEND_VM_RETURN() return -1
ZEND_VM_CONTINUE()表示继续执行下一条opcode;ZEND_VM_ENTER()/ZEND_VM_LEAVE()是调用函数时的动作,普通模式下ZEND_VM_ENTER()实际就是return 1
,然后execute_ex()会将execute_data切换到被调函数的结构上;ZEND_VA_RETURN()表示执行完成,返回-1给execute_ex(),比如exit,此时execute_ex()将退出执行。
这就是执行器的基本执行过程,对于函数调用会重新分配一个zend_execute_data,然后将调度器切至被调函数的zend_execute_data,执行完后再跳回去接着执行下面的指令。
一个函数中最后一条执行的指令是ZEND_RETURN,这条执行的处理:
(1)设置返回值,zend_execute()执行时传入了一个return_value,这个过程相当于赋值,ZendVM会把返回值赋给这个地址;
(2)清理动态变量区,对zend_execute_data上的CV、VAR、TMP_VAR进行清理,这里的清理不包括全局变量,即主脚本、include的调用不会对全局变量(即主代码中的“局部变量”)进行清理;
(3)切换至调用前的zend_execute_data,相当于汇编中的ret指令。
不同call_info场景下,ZEND_RETURN的处理也不同。
static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL
zend_leave_helper_SPEC(ZEND_OPCODE_HANDLER_ARGS) {
zend_execute_data *old_execute_data;
// 获取zend_execute_data的call_info
uint32_t call_info = EX_CALL_INFO();
// 调用用户自定义函数
if (EXPECTED(ZEND_CALL_KIND_EX(call_info) == ZEND_CALL_NESTED_FUNCTION) {
...
}
if (EXPECTED((ZEND_CALL_KIND_EX(call_info) & ZEND_CALL_TOP) == 0)) {
...
} else {
if (ZEND_CALL_KIND_EX(call_info) == ZEND_CALL_TOP_FUNCTION) {
...
} else /* if (call_kind == ZEND_CALL_TOP_CODE) */ {
zend_array *symbol_table = EX(symbol_table);
zend_detach_symbol_table(execute_data);
old_execute_data = EX(prev_execute_data);
...
// 还原zend_execute_data
EG(current_execute_data) = EX(prev_execute_data);
}
ZEND_VM_RETURN();
}
}
4.释放zend_execute_data
所有指令执行完后,将调用zend_vm_stack_free_call_frame()释放zend_execute_data。为了避免频繁申请、释放zend_execute_data,这里会对zend_execute_data进行缓存,释放时并不立即归还底层内存系统(即Zend内存池),而是暂时保留,下次使用时直接分配,缓存位置为EG(vm_stack)。
5.4.4 全局execute_data和opline
Zend执行器在opcode的执行过程中,会频繁用到execute_data和opline两个变量。普通的处理方式在执行每条opline指令的handler时,把execute_data地址作为参数传给handler,根据C程序执行机制,传参通过入栈的方式传给被调函数,调用结束后再出栈,这种方式下Zend执行器展开后如下:
ZEND_API void execute_ex(zend_execute_data *ex) {
zend_execute_data *execute_data = ex;
while (1) {
int ret;
// 执行当前指令
if (UNEXPECTED((ret =
((opcode_handler_t)execute_data->opline->handler)(execute_data)) != 0) {
if (EXPECTED(ret > 0)) {
execute_data = EG(current_execute_data);
} else {
return;
}
}
}
}
上一节已经介绍过execute_ex()了,它是一个大循环,执行前execute_data->opline指向第一条指令,执行完后该指针指向下一条指令,execute_data->opline类似eip寄存器的作用,通过这个循环,ZendVM完成opcode指令的执行。
PHP7针对execute_data、opline两个变量地址的存储方式进行了优化,使用全局寄存器变量保存它们的地址,以实现更高效率的读取。这种方式下直接从寄存器读取execute_data、opline的地址,比从栈上读取更快,同时省掉了传参的出入栈流程,在性能上大概有5%的提升。在分析PHP7的优化前,我们先简单介绍一下什么是寄存器变量。
寄存器变量存放在CPU的寄存器中,使用时,不需要访问内存,直接从寄存器中读写。与存储在内存中的变量相比,寄存器变量具有更快的访问速度。在计算机的存储层次中,寄存器的速度最快,其次是内存,最慢的是硬盘。C语言中使用关键字register来声明局部变量为寄存器变量。只有局部自动变量和形参才能被定义为寄存器变量,全局变量和局部静态变量都不能被定义为寄存器变量。而且,一个计算机中寄存器数量是有限的,一般为2到3个,因此寄存器变量数量不能太多。对于在函数中声明的多于2到3个的寄存器变量,C编译程序会自动将寄存器变量变为自动变量。受硬件寄存器长度的限制,寄存器变量只能是char、int、指针类型,不能是其他复杂数据类型。由于register变量使用的是硬件CPU中的寄存器,寄存器变量无地址,因此不能用取地址运算符&
求寄存器变量的地址。
GCC在3.4版本支持了一项新特性:全局寄存器变量(Global Register Variables),即可以把全局变量定义为寄存器变量,从而可以实现函数间共享数据。可通过以下语法告诉编译器使用寄存器来保存数据:
register int *foo asm ("r12"); // r12、%r12
// 或
register int *foo __asm__("r12"); // r12、%r12
这里r12就是指定的寄存器,它必须是运行平台上有效的寄存器,这样就可以像使用普通变量一样使用foo。但foo同样没有地址,无法通过&获取它的地址,在gdb调试时也无法使用foo符号,只能使用对应的寄存器获取数据,例如:
// main.c
#include <stdlib.h>
typedef struct _execute_data {
int ip;
} zend_execute_data;
register zend_execute_data *execute_data __asm__("%r14");
int main(void) {
execute_data = (zend_execute_data *)malloc(sizeof(zend_execute_data));
execute_data->ip = 9999;
return 0;
}
编译命令为gcc -o main -g main.c
,然后通过gdb调试寄存器的存储内容。
这时我们无法再像普通变量那样直接使用execute_data访问数据了,只能通过寄存器r14读取,直接访问execute_data将提示找不到这个符号,这里可以通过(zend_execute_data *)$r14
获取保存的zend_execute_data结构变量的地址:
在PHP7中,execute_ex()执行各opcode指令的过程中,不再将execute_data作为参数传给handler,而是通过寄存器保存execute_data及opline的地址,handler使用时直接从全局变量(寄存器)读取,执行完再把下一条指令更新到全局变量。
因为GCC在4.8版本之前对全局寄存器变量的支持不完善,因此PHP在GCC 4.8以上版本默认开启该优化,可通过--disable-gcc-global-regs
编译参数关闭。以x86_64为例,execute_data使用r14寄存器,opline使用r15寄存器:
// file: zend_execute.c line: 2631
#define ZEND_VM_FP_GLOBAL_REG "%r14"
#define ZEND_VM_IP_GLOBAL_REG "%r15"
// file: zend_vm_execute.h line: 315
register zend_execute_data *volatile execute_data __asm__(ZEND_VM_FP_GLOBAL_REG);
register const zend_op *volatile opline __asm__(ZEND_VM_IP_BLOGAL_REG);
execute_data、opline定义为全局变量,下面看一下这种方式下调度函数execute_ex()的变化:
ZEND_API void execute_ex(zend_execute_data ex) {
const zend_op orig_opline = opline;
zend_execute_data *orig_execute_data = execute_data;
// 将当前execute_data、opline保存到全局变量
execute_data = ex;
opline = execute_data->opline;
while (1) {
((opcode_handler_t)opline->handler)();
if (UNEXPECTED(!opline)) {
execute_data = orig_execute_data;
opline = orig_opline;
return;
}
}
}
此时调用各opcode指令的handler时就不再传入execute_data的参数了,handler使用时直接从全局变量读取,以赋值ZEND_ASSIGN指令为例,handler展开后:
static int ZEND_ASSIGN_SPEC_CV_CONST_HANDLER(void) {
...
// ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION()
opline = execute_data->opline + 1;
return;
}
使用全局寄存器变量只是提升了一些性能,如果你编译的PHP使用全局寄存器变量的方式,那么在调试execute_ex()与各个handler时就需要从寄存器读取了,无法再直接使用execute_data、opline。
automake编译时的命令是CC,而不是GCC,Linux下CC实际指向了GCC,如果更新GCC后发现PHP仍没有支持全局寄存器变量特性,请检查CC是否指向了新的GCC。
5.5 运行时缓存
我们先分析一个普通PHP语法的例子:
class my_class {
public $id = 123;
public function test() {
echo $this->id;
}
}
$obj = new my_class;
for ($i = 0; $i < 10; $i++) {
$obj->test();
}
上例定义了一个类,然后多次调用同一个成员方法,这个成员方法很简单:输出一个成员属性。首先简单介绍成员属性的相关实现:成员属性是定义在类中的一种数据,它的基本信息通过zend_property_info结构保存,包括成员属性的可见性(public、private、protected)、是否为静态属性、编号等,这些结构通过哈希结构(类的properties_info成员)管理。其中普通成员属性属于不同对象独有的数据,在对象实例化时按照各属性的编号依次分配在对象结构中,这个编号保存于zend_property_info结构中,在编译时确定,成员属性的实现与局部变量的相同,可参考zend_execute_data上局部变量的分配方式。
如果想访问对象的某个成员,首先需要知道这个属性在对象中的存储位置,即zend_property_info结构中的offset,然后再根据这个位置从对象的内存中索引到对应属性。因此,访问一个成员属性需要经历以下几步:
1.根据对象获取实例化类的zend_class_entry结构(其中包含了类的信息,定义一个类时,会创建一个对应的zend_class_entry结构)。
2.根据属性名从zend_class_entry.properties_info哈希表中找到对应属性的zend_property_info结构,该结构中保存了属性的偏移量。
3.根据属性的offset信息从zend_object中获取属性值。
每次执行test()时并不需要完整走一遍上面的过程,字面量id
在$this->id
语句中就是用来索引属性的,PHP通过运行时缓存,将id
与查找到的zend_class_entry、zend_property_info.offset建立的关联保存下来。
在执行期,有些操作需要根据名称去不同的哈希表中查找常量、函数、类、成员方法、成员属性等,运行时缓存就是用来缓存这些查找结果的,以便再次执行同一指令时直接复用上次缓存的值,无须重复查找,从而提高执行效率。所以,运行时缓存机制是在同一指令执行多次的情况下才会生效,它缓存的是根据操作数获取的数据。这里的同一指令指的不是opcode值相同的指令,例如echo $a; echo $a;
就不算,因为这是两条指令。
前例中会缓存两个数据:zend_class_entry、zend_property_info.offset,再次执行这条指令时就直接取出上次缓存的两个值,不用再重复上面的查找过程。
运行时缓存只用于CONST操作数,即根据CONST操作数索引数据的操作,因为只有CONST操作数是固定不变的,其他CV、VAR等类型值都不是固定的,既然其值是不固定的,那么缓存的值就无法固定。比如echo $this->$var
这种,操作数类型是CV,正常查找时的zend_property_info是随
v
a
r
值而变的,所以操作数与结果之间没有固定的关联关系,而
‘
var值而变的,所以操作数与结果之间没有固定的关联关系,而`
var值而变的,所以操作数与结果之间没有固定的关联关系,而‘this->id中的
id`是固定不变的,它索引到的zend_property_info也是始终不变的。
运行时缓存统一分配在一块内存上,各操作数通过不同的位置存取自己的缓存数据,这个位置是在编译时申请的:如果操作数需要用到缓存,则申请一个缓存位置,然后把这个位置保存到CONST变量的zval.u2.cache_slot,在运行时就是根据这个位置到缓存空间中存取数据的。缓存位置的分配比较简单,通过op_array->cache_size记录可用位置,编译前它的初始化值为0,当操作数申请缓存位置时,就将当前值返回,然后增大到申请的大小,最终这个值就是需要的全部缓存大小。在ZendVM执行前会根据cache_size大小分配内存,各指令根据编译时申请到的缓存位置到此空间存取数据,缓存空间位于zend_op_array->run_time_cache,在i_init_func_execute_data()操作中分配:
static zend_always_inline void i_init_func_execute_data(zend_execute_data *execute_data,
zend_op_array *op_array, zval *return_value, int check_this) {
...
if (UNEXPECTED(!op->array->run_time_cache)) {
// 分配全部的缓存空间
op_array->run_time_cache = zend_arena_alloc(&CG(arena), op_array->cache_size);
memset(op_array->run_time_cache, 0, op_array->cache_size);
}
...
}
上例在执行时缓存生效的过程如下:
1.第一次执行echo $this->id
时首先根据$this
获取实例化类的zend_class_entry,然后根据id
向zend_class_entry.properties_info哈希表查找属性id的zend_property_info(其中存放了id对应的offset),找到后将属性id对应的offset及zend_class_entry的地址缓存到test()函数的zend_op_array->run_time_cache中。
2.再次执行echo $this->id
时,根据CONST值“id”,得到缓存位置0,然后去zend_op_array->run_time_cache取出缓存的zend_class_entry、offset。
上例缓存了16字节的数据,对应的缓存结构如图5-25所示:
几种常见的用到缓存的情况:
1.调用函数
function test() {
time();
}
test()中调用time函数,执行时需要根据“time”从EG(function_table)中查找zend_function,这里“time”操作数就会缓存zend_function。编译时的处理:
void zend_compile_call(znode *result, zend_ast *ast, uint32_t type) {
...
opline = zend_emit_op(NULL, ZEND_INIT_FCALL, NULL, &name_node);
// 申请缓存的存储位置
// zend_alloc_cache_slot函数申请一个指针大小的缓存空间
zend_alloc_cache_slot(opline->op2.constant);
...
}
除了zend_alloc_cache_slot函数,还有zend_alloc_polymorphic_cache_slot函数,后者申请2个指针大小的空间,目前缓存只用到了这两种规格。
static inline void zend_alloc_cache_slot(uint32_t literal) {
// 获取当前的指令数组zend_op_array
zend_op_array *op_array = CG(active_op_array);
// op_array->cache_size为缓存中的当前位置,为literal参数对应的CONST量分配缓存地址
Z_CACHE_SLOT(op_array->literals[literal]) = op_array->cache_size;
// 申请后向后偏移
op_array->cache_size += sizeof(void *);
}
#define POLYMORPHIC_CACHE_SLOT_SIZE 2
static inline void zend_alloc_polymorphic_cache_slot(uint32_t literal) {
zend_op_array *op_array = CG(active_op_array);
Z_CACHE_SLOT(op_array->literals[literal]) = op_array->cache_size;
// 分配两个指针大小空间
op_array->cache_size += POLYMORPHIC_CACHE_SLOT_SIZE * sizeof(void *);
}
ZEND_INIT_FCALL指令需要根据函数名索引到函数的zend_function,执行时首先会检查缓存是否存在,如果存在则直接使用,如果不存在则按照正常的逻辑到EG(function_table)中查找,然后把zend_function保存到缓存中,以便下次执行时直接使用。
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_PTR_P(fname));
...
fbc = Z_FUNC_P(func);
// 设置缓存
CACHE_PTR(Z_CACHE_SLOT_P(fname), fbc);
}
...
}
CACHE_PTR、CACHED_PTR两个宏分别用于指针规格缓存的设置、获取,与之对应的还有两个用于两个指针规格的宏:CACHE_POLYMORPHIC_PTR、CACHED_POLYMORPHIC_PTR。
// file: zend_execute.h
// 这段宏先通过EX_RUN_TIME_CACHE()获取缓存的基地址,然后将其转换为字节指针
// 字节指针加上偏移量num,得到缓存所在的地址,然后将缓存所在地址转换为void **类型,相当于指针的指针
// 之后取[0],相当于对二级指针解引用,即缓存所在的地址保存了一个指针,CACHED_PTR获取了该指针指向的数据
#define CACHED_PTR(num) \
((void **)((char *)EX_RUN_TIME_CACHE() + (num)))[0]
// 将偏移为num处的指针指向ptr
// 此处的宏用了常见的do{...}while(0)包裹,可以避免一些意料之外的错误
// 比如宏#define M {statement;},可能会这样使用:
// if
// M;
// else
// ...
// 则展开后变为:
// if
// {statement;}; // 此处末尾多了一个分号,会报错
// else
// ...
#define CACHE_PTR(num, ptr) do { \
((void **)((char *)EX_RUN_TIME_CACHE() + (num)))[0] = (ptr); \
} while (0)
// polymorphic的含义应该是多态
// ce应该是指class entry,它是一个类型信息结构
// 如果传入的类型与缓存位置的第一个指针指向的类型是相同的,即缓存是有效的
#define CACHED_POLYMORPHIC_PTR(num, ce) \
(EXPECTED(((void **)((char *)EX_RUN_TIME_CACHE() + (num)))[0] == (void *)(ce)) ? \
((void **)((char *)EX_RUN_TIME_CACHE() + (num)))[1] : \
NULL)
#define CACHE_POLYMORPHIC_PTR(num, ce, ptr) do { \
void **slot = (void **)((char *)EX_RUN_TIME_CACHE() + (num)); \
slot[0] = (ce); \
slot[1] = (ptr); \
} while (0)
2.访问常量、静态变量、全局变量
这几种数据都保存于单独的符号表中,访问时需要根据名称到对应的符号表中查找。以常量为例,编译时的处理:
void zend_compile_const(znode *result, zend_ast *ast) {
...
opline = zend_emit_op_tmp(result, ZEND_FETCH_CONSTANT, NULL, NULL);
opline->op2_type = IS_CONST;
...
// 申请缓存位置
zend_alloc_cache_slot(opline->op2.constant);
}
5.6 Opcache
一个request从请求到最终处理完成总是需要经历编译、执行两个阶段,请求结束后关于这个请求的所有数据就被擦除了,下次请求时需要经历同样的过程。这个过程有一个问题:当多次请求同一个脚本时,需要重复经历编译的过程。只要脚本的内容没有发生变化,那么多次编译的结果实际上是相同的。Opcache会把编译后的结果缓存下来,再次请求时不需要再重新编译,而是使用缓存的内容,直接进入执行阶段,从而大大提高PHP的性能。
Opcache是用于缓存opcodes以提升PHP性能的Zend扩展,它的前身是Zend Optimizer Plus(简称O+),目前Opcache已经是PHP非常重要的一个组成部分,它对于PHP性能的提升有非常显著的作用。PHP编译生成的opcodes指令与Java编译生成的字节码的角色是相同的,Java将字节码保存到了.class文件中,Opcache也可以把opcodes指令缓存到文件中,从而实现跨生命周期使用。
Opcache的工作原理:开启Opcache后,它将PHP的编译函数zend_compile_file替换为persistent_compile_file(),接管PHP代码的编译过程,当有新请求到达时,将调用persistent_compile_file()进行编译。此时Opcache先检查是否有该文件的缓存,如果有则取出,然后进行一系列的验证,如果最终发现缓存可用则直接返回,直接进入执行流程,如果没有缓存或缓存失效,则重新调用系统默认的编译器进行编译,然后将编译后的结果缓存下来,供下次使用。整体处理流程如图5-26所示:
1.初始化
Opcache是一个Zend扩展,在PHP的module startup阶段将调用Zend扩展的startup方法进行启动,Opcache定义的startup方法为accel_startup(),此时Opcache将覆盖一些ZendVM的函数句柄,其中最重要的一个就是上面提到的zend_compile_file。具体过程如下:
// file: ext/opcache/ZendAccelerator.c
static int accel_startup(zend_extension *extension) {
...
// 分配共享内存
switch (zend_shared_alloc_startup(ZCG(accel_directives).memory_consumption)) {
case ALLOC_SUCCESS:
// 初始化,并分配一个zend_accel_shared_globals结构
if (zend_accel_init_shm() == FAILURE) {
accel_startup_ok = 0;
return FAILURE;
}
break;
...
}
...
// 覆盖编译函数:zend_compile_file
accelerator_orig_compile_file = zend_compile_file;
zend_compile_file = persistent_compile_file;
...
}
同时,该过程还会分配一个zend_accel_shared_globals结构,这个结构通过共享内存分配,进程间共享,它保存着所有的opcodes缓存信息,以及一些统计数据,ZCSG()宏操作的就是这个结构。这里暂且不关心其具体结构,只看它的hash成员,它就是存储opcodes缓存的地方。zend_accel_shared_globals->hash是一个哈希表,但并不是PHP内核中的HashTable类型,而是单独实现的,它的结构为zend_accel_hash:
typedef struct _zend_accel_hash {
zend_accel_hash_entry **hash_table;
zend_accel_hash_entry *hash_entries;
uint32_t num_entries; // 已有元素数
uint32_t max_num_entries; // 总容量
uint32_t num_direct_entries;
} zend_accel_hash;
其中hash_table用于保存具体的value,它是一个数组,根据hash_value % max_num_entries
索引存储位置,zend_accel_hash_entry为具体的存储元素,这是一个单链表,用于解决哈希冲突;max_num_entries为最大的元素数,即可缓存的文件数,这个值可通过php.ini配置opcache.max_accelerated_files指定,默认值为2000,但实际的max_num_entries并不是直接使用的这个配置,而是根据配置值选择了一个固定规格,所有规格如下:
static uint prime_numbers[] = {5, 11, 19, 53, 107, 223, 463, 983, 1979, 3907, 7963, 16229,
32531, 65407, 130987, 262237, 524521, 1048793};
实际选择的是可以满足opcache.max_accelerated_files的最小的一个,比如默认值2000最终使用的是3907,即可以缓存3907个文件,超过了这个值Opcache将不再缓存。
2.缓存的获取过程
编译一个脚本调用的是zend_compile_file(),此时将由Opcache的persistent_compile_file()处理。这里首先介绍Opcache中比较重要的几个配置:
(1)opcache.validate_timestamps:是否开启缓存有效期验证,默认值为1,表示开启,开启后每隔opcache.revalidate_freq秒检查一次文件是否更新了;如果不开启则不会检查,脚本文件修改了只能重启服务才能生效。opcache.revalidate_freq默认为2s。
(2)opcache.revalidate_path:验证文件路径,默认值为0,表示关闭。默认opcodes缓存并不是通过完整的文件路径名进行索引的,而是通过一个根据文件名、当前所在目录、include_path生成的key,因此当编译的文件实际已经不存在了但是缓存还在的时候,就会使用已经失效的缓存,如果开启这个选项,将通过完整的文件路径检索缓存,并且检查文件是否存在,而不再使用那个key。
主要的处理流程如下:
zend_op_array *persistent_compile_file(zend_file_handle *file_handle, int type) {
// zend_persistent_script是存储持久化脚本信息的结构
zend_persistent_script *persistent_script = NULL;
...
// 1)获取缓存
// 如果没有开启opcache.revalidate_path,则先根据key获取缓存
if (!ZCG(accel_directives).revalidate_path) {
// 生成缓存键key,并将其长度保存到key_length中
key = accel_make_persistent_key(file_handle->filename,
strlen(file_handle->filename), &key_length);
// 如果生成的缓存键为空
if (!key) {
// 直接调用原始的编译函数编译脚本,并返回编译生成的zend_op_array
return accelerator_orig_compile_file(file_handle, type);
}
// 获取缓存,从共享内存的哈希表中查找key对应的缓存
persistent_script = zend_accel_hash_str_find(&ZCSG(hash), key, key_length);
}
// 如果取不到缓存或开启了opcache.revalidate_path,则根据实际的文件路径查找缓存
if (!persistent_script) {
...
// 直接通过文件路径查找哈希表中的缓存
bucket = zend_accel_hash_find_entry(&ZCSG(hash), file_handle->opened_path);
// 如果找到了缓存
if (bucket) {
// 将缓存转换为zend_persistent_script类型
persistent_script = (zend_persistent_script *)bucket->data;
...
}
}
...
// 2)检查脚本是否更新过
// 如果存在缓存,并且开启了opcache.validata_timestamps时
if (persistent_script && ZCG(accel_directives).validate_timestamps) {
// 每隔opcache.revalidate_freq秒检查一次文件是否更新过,如果文件已被更新
if (validate_timestamp_and_record(persistent_script, file_handle) == FAILURE) {
...
// 将缓存脚本置空
persistent_script = NULL;
}
}
// 校验缓存数据是否合法:根据Adler-32算法,类似crc的一个算法
// 如果缓存脚本非空,且启用了一致性检查,且脚本缓存的访问计数符合一致性检查的条件
if (persistent_script && ZCG(accel_directives).consistency_checks &&
persistent_script->dynamic_members.hits %
ZCG(accel_directives).consistency_check == 0) {
// 计算脚本缓存校验和
unsigned int checksum = zend_accel_script_checksum(persistent_script);
// 如果校验和检查结果为不一致
if (checksum != persistent_script->dynamic_members.checksum) {
...
// 置空缓存脚本
persistent_script = NULL;
}
}
...
// 3)返回缓存或重新编译
if (!persistent_script) { // 无缓存可用
...
// 调用ZendVM默认的编译器进行编译
persistent_script = opcache_compile_file(file_handle, type, key,
key ? key_length : 0, &op_array);
if (persistent_script) {
// 将编译结果缓存到共享内存中
persistent_script = cache_script_in_shared_memory(persistent_script, key,
key ? key_length : 0, &from_shared_memory);
}
...
} else { // 有缓存
...
}
...
// 加载并返回脚本缓存
return zend_accel_load_script(persistent_script, from_shared_memory);
}
前面曾介绍过,存储脚本缓存的结构为共享内存,进程间共享,但是从ZCSG(hash)查找脚本缓存时却没有加锁,假如查询过程中有其他进程在改写数据,那么取到的岂不是脏数据吗?事实上,取到缓存后并不是直接就使用了,而是先校验缓存数据是否被改写了,即检查checksum的过程。checksum通过Adler-32算法计算得到,它是一个比crc更高效的算法。如果计算得到的checksum与原始值不同,则表示缓存无效。这是无锁操作比较常见的一种实现方式,360开源的分布式配置工具QConf也采用了这种方式解决并发导致的数据冲突问题。
zend_accel_load_script()根据缓存的内容,把函数符号表、类符号表等复制到系统默认位置,即CG(function_table)、CG(class_table),最后返回zend_op_array。
3.缓存的生成
在persistent_compile_file()中我们简单介绍了缓存的查询过程,如果没有缓存或缓存失效了,则需要重新编译并缓存结果,其中编译的过程由opcache_compile_file()完成,编译完成后调用cache_script_in_shared_memory()进行缓存。首先看一下Opcache中缓存的结构zend_persistent_script,缓存的数据不仅仅是zend_op_array,还有函数、类的符号表,zend_persistent_script具体结构如下:
typedef struct _zend_persistent_script {
zend_string *full_path; // 完整的脚本文件路径
zend_op_array main_op_array; // 编译生成的zend_op_array
HashTable function_table;
HashTable class_table;
...
accel_time_t timestamp; // 脚本的更新时间
zend_bool corrupted;
zend_bool is_phar;
void *mem; // zend_persistent_script内存的地址
size_t size; // 共享内存的大小
...
struct zend_persistent_script_dynamic_members {
time_t last_used; // 上次使用时间
zend_ulong hits; // 缓存命中次数
unsigned int memory_consumption;
unsigned int checksum; // 缓存的校验和
time_t revalidate;
} dynamic_members;
} zend_persistent_script;
PHP脚本在调用compile_file(可能指的是zend_compile_file函数)编译完成后,将分配一个zend_persistent_script结构,然后将编译生成的数据转移到zend_persistent_script结构中。被Opcache代替的编译过程:
static zend_persistent_script *opcache_compile_file(zend_file_handle *file_handle,
int type, char *key, unsigned int key_length, zend_op_array **op_array_p) {
...
// 分配一个新的zend_persistent_script结构
new_persistent_script = create_persistent_script();
...
// 编译前替换CG(function_table)、EG(class_table)
CG(function_table) = &ZCG(function_table);
EG(class_table) = CG(class_table) = &new_persistent_script->class_table;
// 初始化错误处理函数
ZVAL_UNDEF(&EG(user_error_handler));
...
// 使用zend的异常处理机制
zend_try {
...
// 调用compile_file()编译
op_array = *op_array_p = accelerator_orig_compile_file(file_handle, type);
} zend_catch {
}
...
// 将函数符号表转移到new_persistent_script->function_table
zend_accel_move_user_functions(&ZCG(function_table),
&new_persistent_script->function_table);
new_persistent_script->main_op_array = *op_array;
// 释放op_array,它是编译过程分配的内存,现已不再需要
efree(op_array);
...
return new_persistent_script;
}
此时生成的zend_persistent_script并不在共享内存上,调用cache_script_in_shared_memory()进行缓存时会将其复制到共享内存上,以便供其他进程使用。最终被保存到共享内存上的数据有:zend_persistent_script结构、脚本路径名称、脚本中定义的类、脚本中定义的函数、脚本的zend_op_array,内存结构如图5-27所示:
// 将PHP代码编译结果缓存到共享内存
static zend_persistent_script *cache_script_in_shared_memory(
zend_persistent_script *new_persistent_script, char *key, unsigned int key_length,
int *from_shared_memory) {
...
// 加锁
zend_shared_alloc_lock();
// 检查缓存空间是否够用
if (zend_accel_hash_is_full(&ZCSG(hash))) {
...
}
// 检查缓存是否存在,因为其他进程可能已经生成缓存了,之前的处理过程没有加锁
bucket = zend_accel_hash_find_entry(&ZCSG(hash), new_persistent_script->full_path);
// 如果缓存已存在
if (bucket) {
...
if (!existing_persistent_script->corrupted) {
...
return new_persistent_script;
}
}
// 计算所需内存
memory_used = zend_accel_script_persistent_calc(new_persistent_script, key,
key_length);
// 分配共享内存
ZCG(mem) = zend_shared_alloc(memory_used);
...
// 将new_persistent_script拷贝到共享内存中
new_persistent_script = zend_accel_script_persistent(new_persistent_script, &key,
key_length);
...
// 计算校验和
new_persistent_script->dynamic_members.checksum =
zend_accel_script_checksum(new_persistent_script);
// 将缓存放入索引表,即zend_accel_shared_globals->hash
bucket = zend_accel_hash_update(&ZCSG(hash),
ZSTR_VAL(new_persistent_script->full_path),
ZSTR_LEN(new_persistent_script->full_path), 0, new_persistent_script);
if (bucket) {
// 这里还会以opcache.revalidate_path关闭时生成的那个特殊的key为索引插入hash
if (zend_accel_hash_update(&ZCSG(hash), key, key_length, 1, bucket) {
...
}
}
...
return new_persistent_script;
}
5.6.1 opcode优化
Opcache除了缓存PHP代码编译结果,还起到opcodes优化的作用。在Opcache缓存编译结果前,首先会对编译生成的指令进行优化,然后再进行缓存。PHP5.x中很多在Opcache中的优化已经被直接嵌入了PHP7的编译器中,但Opcache更容易进行优化操作,因为Zend编译器的优化是在编译过程中,它基于的信息是有限的,而Opcache是在编译完成后进行的优化,此时取的信息更多,从而能进行更大的优化。
// 将PHP代码的编译结果缓存到共享内存
static zend_persistent_script *cache_script_in_shared_memory(
zend_persistent_script *new_persistent_script, char *key, unsigned int key_length,
int *from_shared_memory) {
...
// 1)优化编译指令
// 如果优化失败
if (!zend_accel_script_optimize(new_persistent_script)) {
// 直接返回zend_persistent_script结构,不将其写入共享内存
return new_persistent_script;
}
// 2)将缓存写入共享内存
...
}
Opcache可进行的优化分为11个等级,通过opcache.optimization_level配置来指定优化级别,按不同级别设置对应的bit位即可,默认配置为0xffffffff,表示最高级别的优化:
#define ZEND_OPTIMIZER_PASS_1 (1<<0) /* CSE, STRING construction */
#define ZEND_OPTIMIZER_PASS_2 (1<<1) /* Constant conversion and jumps */
#define ZEND_OPTIMIZER_PASS_3 (1<<2) /* ++, +=, series of jumps */
#define ZEND_OPTIMIZER_PASS_4 (1<<3) /* INIT_FCALL_BY_NAME->DO_FCALL */
#define ZEND_OPTIMIZER_PASS_5 (1<<4) /* CFG based optimization */
#define ZEND_OPTIMIZER_PASS_6 (1<<5)
#define ZEND_OPTIMIZER_PASS_7 (1<<6)
#define ZEND_OPTIMIZER_PASS_8 (1<<7)
#define ZEND_OPTIMIZER_PASS_9 (1<<8) /* TMP VAR usage */
#define ZEND_OPTIMIZER_PASS_10 (1<<9) /* NOP removal */
#define ZEND_OPTIMIZER_PASS_11 (1<<10) /* Merge equal constants */
#define ZEND_OPTIMIZER_PASS_12 (1<<11) /* Adjust used stack */
#define ZEND_OPTIMIZER_PASS_13 (1<<12)
#define ZEND_OPTIMIZER_PASS_14 (1<<13)
#define ZEND_OPTIMIZER_PASS_15 (1<<14) /* Collect constants */
具体的优化操作函数为zend_accel_script_optimize(),其中将对main、函数、类的方法的zend_op_array进行优化:
int zend_accel_script_optimize(zend_persistent_script *script) {
...
// 1)main zend_op_array
zend_accel_optimize(&script->main_op_array, &ctx);
...
// 2)优化各函数的指令
for (idx = 0; idx < script->function_table.nNumUsed; idx++) {
p = script->function_table.arData + idx;
if (Z_TYPE(p->val) == IS_UNDEF) continue;
op_array = (zend_op_array *)Z_PTR(p->val);
zend_accel_optimize(op_array, &ctx);
}
// 3)优化各成员方法的指令
...
}
zend_op_array最终由zend_optimize()完成优化:
static void zend_optimize(zend_op_array *op_array, zend_optimizer_ctx *ctx) {
if (op_array->type == ZEND_EVAL_CODE) {
return;
}
// 进行共11级的优化
// pass 1:
if (ZEND_OPTIMIZER_PASS_1 & OPTIMIZATION_LEVEL) {
zend_optimizer_pass1(op_array, ctx);
}
// pass 2:
if (ZEND_OPTIMIZER_PASS_2 & OPTIMIZATION_LEVEL) {
zend_optimizer_pass2(op_array, ctx);
}
...
}
Pass 1:
1.使用实际的常量值代替持久化常量的操作数,例如$a = TEST_CONST
,其中TEST_CONST是某个扩展注册的一个常量,正常操作需要先fetch常量,然后再赋值给$a
,优化后会直接将TEST_CONST替换为实际的value,这条优化实际在Zend中也会进行。
2.在编译时执行一些CONST运算,例如$a = 1 + 3
,优化为$a = 4
。
3.优化ADD_STRING、ADD_CHAR指令,例如$a = "a" . "b"
,优化为$a = "ab"
。
具体优化过程在zend_optimizer_pass1()中。
Pass2:
1.在需要数值类型的操作中,将非数值型的CONST转为数值,例如$a = $b + "100"
,优化为$a = $b + 100
。
2.针对跳转指令的优化,跳转指令是Zend中应用非常频繁的指令,在条件分支、循环结构、中断结构等语法中均使用了跳转指令。例如:
if ($a) {
goto a;
} else {
echo "no";
}
a:
echo "a";
if会编译一条ZEND_JMPNZ指令,这条指令根据条件表达式的结果决定是否需要跳转,如果跳转成立则进入分支执行。即,当$a
为true时,进入分支,执行goto a,跳转到echo "a"
。这种情况将被优化为:如果$a
为true,则直接跳转到echo "a"
。这个例子的优化实际是把JMPZ(X, L1), JMP(L2)
连续的跳转指令优化为JMPZNZ(X, L1, L2)
(JMPZ(X, L1)的含义是,如果X为零,则跳转到L1处,JMP(L2)是无条件跳转指令,JMPZNZ(X, L1, L2)的含义为,当X为零时跳转到L1,非零时跳转到L2,这里的L1是上例中$a
为false时的分支,L2是标签a的位置)。除了上例,还有很多组合情况会进行优化,比如JMPNZ(X, L1), JMP(L2)
优化为JMPZNZ(X, L2, L1)
,JMPZ(X, L1), JMP(L1)
优化为NOP, JMP(L1)
等等。
具体的优化过程由zend_optimizer_pass2()处理。
Pass 3:
1.将$i = $i + expr
优化为$i += expr
,$i = $i + expr
会先将$i + expr
的计算结果保存于TMPVAR,然后再把这个TMPVAR赋值给$i
,共有两步,而$i += expr
则是直接在$i
上加expr。
2.JMP指令的优化,将L: JMP L+1
优化为NOP,也就是跳到下一行的情况。其次还会优化连续跳转的情况,如:
goto A; // 优化为goto B
A:
goto B;
B:
// ...
也就是将:
优化为:
3.在可能的地方将$i++
优化为++$i
,$i++
是先把$i
复制一份赋值给该操作的接收变量,再将$i
加1,如果没有接收变量,则将$i
的副本释放掉,而++$i
则是直接将$i
加1,不会发生复制。
具体处理过程为zend_optimizer_pass3()。
剩下的级别的优化不再一一列举,完整的优化处理可以看zend_optimize()。
5.6.2 JIT
PHP是解释执行的,它的编译过程属于动态编译,即运行时进行编译,与之相对的是运行前编译的静态编译。静态编译是将代码编译为机器指令,而动态编译没有将代码编译为机器指令,而是编译成了解释器可识别的指令。而JIT(Just In Time Compilation,动态编译)是动态编译中的一种技术,它在某段代码第一次执行时进行编译,所以称为即时编译。
JIT是将源代码编译为机器指令执行,且是在运行时实时进行的编译,JIT不会把所有代码全编译为机器码,它只会编译频繁执行的代码。说JIT比解释快,指的是“执行编译后的代码”比“解释器解释执行”要快,而且JIT编译执行通常要比解释执行慢一些,如果对只执行一次的代码进行即时编译,其效率反而要比解释执行慢,JIT之所以快是因为多次执行抵消了编译所占的时间,显得平均效率高。
目前,PHP的JIT版本还没有正式发布,还是测试版本,JIT或是下一代PHP最大的亮点。
原文地址:https://blog.csdn.net/tus00000/article/details/145067132
免责声明:本站文章内容转载自网络资源,如侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!