自学内容网 自学内容网

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_CVIS_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 = 2T = 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)!