标签 - php7内核剖析

php7内核剖析    2019-09-16 15:02:07    8    0    0

7.1 概述

扩展是PHP的重要组成部分,它是PHP提供给开发者用于扩展PHP语言功能的主要方式。开发者可以用C/C++定义自己的功能,通过扩展嵌入到PHP中,灵活的扩展能力使得PHP拥有了大量、丰富的第三方组件,这些扩展很好的补充了PHP的功能、特性,使得PHP在web开发中得以大展身手。ext目录下有一个standard扩展,这个扩展提供了大量被大家所熟知的PHP函数:sleep()、usleep()、htmlspecialchars()、md5()、strtoupper()、substr()、array_merge()等等。

C语言是PHP之母,作为世界上非常优秀的一门语言,自它诞生至今,C语言早就了大量优秀、知名的项目:Linux、Nginx、MySQL、PHP、Redis、Memcached等等,感谢里奇带给这个世界如此伟大的一份礼物。C语言的优秀也折射到PHP身上,但是PHP内核提供的功能终究有限,如果你发现PHP在某些方面已经满足不了你的需求了,那么不妨试试扩展。

常见的,扩展可以在以下几个方面有所作为:

  • 介入PHP的编译、执行阶段: 可以介入PHP框架执行的那5个阶段,比如opcache,就是重定义了编译函数
  • 提供内部函数: 可以定义内部函数扩充PHP的函数功能,比如array、date等操作
  • 提供内部类
  • 实现RPC客户端: 实现与外部服务的交互,比如redis、mysql等
  • 提升执行性能: PHP是解析型语言,在性能方面远不及C语言,可以将耗cpu的操作以C语言代替
  • ......

当然扩展也不是万能,它只允许我们在PHP提供的框架之上进行一些特定的处理,同时限于SAPI的差异,扩展也必须要考虑到不同SAPI的实现特点。

PHP中的扩展分为两类:PHP扩展、Zend扩展,对内核而言这两个分别称之为:模块(module)、扩展(extension),本章主要介绍是PHP扩展,也就是模块。

php7内核剖析    2019-09-16 14:56:24    11    0    0

6.1 介绍

在C语言中声明在任何函数之外的变量为全局变量,全局变量为各线程共享,不同的线程引用同一地址空间,如果一个线程修改了全局变量就会影响所有的线程。所以线程安全是指多线程环境下如何安全的获取公共资源。

PHP的SAPI多数是单线程环境,比如cli、fpm、cgi,每个进程只启动一个主线程,这种模式下是不存在线程安全问题的,但是也有多线程的环境,比如Apache,或用户自己嵌入PHP实现的环境,这种情况下就需要考虑线程安全的问题了,因为PHP中有很多全局变量,比如最常见的:EG、CG,如果多个线程共享同一个变量将会冲突,所以PHP为多线程的应用模型提供了一个安全机制:Zend线程安全(Zend Thread Safe, ZTS)。

6.2 线程安全资源管理器

PHP中专门为解决线程安全的问题抽象出了一个线程安全资源管理器(Thread Safe Resource Mananger, TSRM),实现原理比较简单:既然共用资源这么困难那么就干脆不共用,各线程不再共享同一份全局变量,而是各复制一份,使用数据时各线程各取自己的副本,互不干扰。

6.2.1 基本实现

TSRM核心思想就是为不同的线程分配独立的内存空间,如果一个资源会被多线程使用,那么首先需要预先向TSRM注册资源,然后TSRM为这个资源分配一个唯一的编号,并把这种资源的大小、初始化函数等保存到一个tsrm_resource_type结构中,各线程只能通过TSRM分配的那个编号访问这个资源;然后当线程拿着这个编号获取资源时TSRM如果发现是第一次请求,则会根据注册时的资源大小分配一块内存,然后调用初始化函数进行初始化,并把这块资源保存下来供这个线程后续使用。

TSRM中通过两个结构分别保存资源信息以及具体的资源:tsrm_resource_type、tsrm_tls_entry,前者是用来记录资源大小、初始化函数等信息的,具体分配资源内存时会用到,而后者用来保存各线程所拥有的全部资源:

struct _tsrm_tls_entry {
    void **storage; //资源数组
    int count; //拥有的资源数:storage数组大小
    THREAD_T thread_id; //所属线程id
    tsrm_tls_entry *next;
};

typedef struct {
 
php7内核剖析    2019-09-16 14:55:36    7    0    0

5.2 垃圾回收

5.2.1 垃圾的产生

前面已经介绍过PHP变量的内存管理,即引用计数机制,当变量赋值、传递时并不会直接硬拷贝,而是增加value的引用数,unset、return等释放变量时再减掉引用数,减掉后如果发现refcount变为0则直接释放value,这是变量的基本gc过程,PHP正是通过这个机制实现的自动垃圾回收,但是有一种情况是这个机制无法解决的,从而因变量无法回收导致内存始终得不到释放,这种情况就是循环引用,简单的描述就是变量的内部成员引用了变量自身,比如数组中的某个元素指向了数组,这样数组的引用计数中就有一个来自自身成员,试图释放数组时因为其refcount仍然大于0而得不到释放,而实际上已经没有任何外部引用了,这种变量不可能再被使用,所以PHP引入了另外一个机制用来处理变量循环引用的问题。

下面看一个数组循环引用的例子:

$a = [1];
$a[] = &$a;

unset($a);

unset($a)之前引用关系:

gc_1

注意这里$a的类型在&操作后已经转为引用,unset($a)之后:

gc_2

可以看到,unset($a)之后由于数组中有子元素指向$a,所以refcount = 1,此时是无法通过正常的gc机制回收的,但是$a已经已经没有任何外部引用了,所以这种变量就是垃圾,垃圾回收器要处理的就是这种情况,这里明确两个准则:

1) 如果一个变量value的refcount减少到0, 那么此value可以被释放掉,不属于垃圾

2) 如果一个变量value的refcount减少之后大于0,那么此zval还不能被释放,此zval可能成为一个垃圾

针对第一个情况GC不会处理,只有第二种情况GC才会将变量收集起来。另外变量是否加入垃圾检查buffer并不是根据zval的类型判断的,而是与前面介绍的是否用到引用计数一样通过zval.u1.type_flag记录的,只有包含IS_TYPE_COLLECTABLE的变量才会被GC收集。

目前垃圾只会出现在array、object两种类型中,数组的情况上面已经介绍了,object的情况则是成员属性引用对象本身导致的,其它类型不会出现这种变量中的成员引用变量自身的情况,所以垃圾回收只会处理这两种类型的变量。

#define IS_TYPE_COLLECTABLE
|     type       | collectable |
+-------
php7内核剖析    2019-09-16 14:55:00    12    0    0

5.1 Zend内存池

zend针对内存的操作封装了一层,用于替换直接的内存操作:malloc、free等,实现了更高效率的内存利用,其实现主要参考了tcmalloc的设计。

源码中emalloc、efree、estrdup等等就是内存池的操作。

内存池是内核中最底层的内存操作,定义了三种粒度的内存块:chunk、page、slot,每个chunk的大小为2M,page大小为4KB,一个chunk被切割为512个page,而一个或若干个page被切割为多个slot,所以申请内存时按照不同的申请大小决定具体的分配策略:

  • Huge(chunk): 申请内存大于2M,直接调用系统分配,分配若干个chunk
  • Large(page): 申请内存大于3092B(3/4 page_size),小于2044KB(511 page_size),分配若干个page
  • Small(slot): 申请内存小于等于3092B(3/4 page_size),内存池提前定义好了30种同等大小的内存(8,16,24,32,...3072),他们分配在不同的page上(不同大小的内存可能会分配在多个连续的page),申请内存时直接在对应page上查找可用位置

5.1.1 基本数据结构

chunk由512个page组成,其中第一个page用于保存chunk结构,剩下的511个page用于内存分配,page主要用于Large、Small两种内存的分配;heap是表示内存池的一个结构,它是最主要的一个结构,用于管理上面三种内存的分配,Zend中只有一个heap结构。

struct _zend_mm_heap {
#if ZEND_MM_STAT
    size_t             size; //当前已用内存数
    size_t             peak; //内存单次申请的峰值
#endif
    zend_mm_free_slot *free_slot[ZEND_MM_BINS]; // 小内存分配的可用位置链表,ZEND_MM_BINS等于30,即此数组表示的是各种大小内存对应的链表头部
    ...

    zend_mm_huge_list *huge_list;               //大内存链表

    zend_mm_chunk     *main_chunk;     
php7内核剖析    2019-09-16 14:54:13    18    0    0

4.6 异常处理

PHP的异常处理与其它语言的类似,在程序中可以抛出、捕获一个异常,异常抛出必须只有定义在try{...}块中才可以被捕获,捕获以后将跳到catch块中进行处理,不再执行try中抛出异常之后的代码。

异常可以在任意位置抛出,然后将由最近的一个try所捕获,如果在当前执行空间没有进行捕获,那么将调用栈一直往上抛,比如在一个函数内部抛出一个异常,但是函数内没有进行try,而在函数调用的位置try了,那么就由调用处的catch捕获。

接下来我们从两个方面介绍下PHP异常处理的实现。

4.6.1 异常处理的编译

异常捕获及处理的语法:

try{
    try statement;
}catch(exception_class_1 $e){
    catch statement 1;
}catch(exception_class_2 $e){
    catch statement 2;
}finally{
    finally statement;
}

try表示要捕获try statement中可能抛出的异常;catch是捕获到异常后的处理,可以定义多个,当try中抛出异常时会依次检查各个catch的异常类是否与抛出的匹配,如果匹配则有命中的那个catch块处理;finally为最后执行的代码,不管是否有异常抛出都会执行。

语法规则:

statement:
    ...
    |   T_TRY '{' inner_statement_list '}' catch_list finally_statement
            { $$ = zend_ast_create(ZEND_AST_TRY, $3, $5, $6); }
    ...
;
catch_list:
        /* empty */
            { $$ = zend_ast_create_list(0, ZEND_AST_CATCH_LIST); }
    |   catch_list T_CATCH '(' name T_VARIABLE ')' '{' inner_statement_list '}'
            { $$ = zend_ast_list_add($1, zend_ast_create(ZEND_AST_CATCH, $4, $5,
php7内核剖析    2019-09-16 14:52:45    23    0    0

4.4 中断及跳转

PHP中的中断及跳转语句主要有break、continue、goto,这几种语句的实现基础都是跳转。

4.4.1 break与continue

break用于结束当前for、foreach、while、do-while 或者 switch 结构的执行;continue用于跳过本次循环中剩余代码,进行下一轮循环。break、continue是非常相像的,它们都可以接受一个可选数字参数来决定跳过的循环层数,两者的不同点在于break是跳到循环结束的位置,而continue是跳到循环判断条件的位置,本质在于跳转位置的不同。

break、continue的实现稍微有些复杂,下面具体介绍下其编译过程。

上一节我们已经介绍过循环语句的编译,其中在各种循环编译过程中有两个特殊操作:zend_begin_loop()、zend_end_loop(),分别在循环编译前以及编译后调用,这两步操作就是为break、continue服务的。

在每层循环编译时都会创建一个zend_brk_cont_element的结构:

typedef struct _zend_brk_cont_element {
    int start;
    int cont;
    int brk;
    int parent;
} zend_brk_cont_element;

cont记录的是当前循环判断条件opcode起始位置,brk记录的是当前循环结束的位置,parent记录的是父层循环zend_brk_cont_element结构的存储位置,也就是说多层嵌套循环会生成一个zend_brk_cont_element的链表,每层循环编译结束时更新自己的zend_brk_cont_element结构,所以break、continue的处理过程实际就是根据跳出的层级索引到那一层的zend_brk_cont_element结构,然后得到它的cont、brk进行相应的opcode跳转。

各循环的zend_brk_cont_element结构保存在zend_op_array->brk_cont_array数组中,编译各循环时依次申请一个zend_brk_cont_elementzend_op_array->last_brk_cont记录此数组第一个可用位置,每申请一个元素last_brk_cont就相应的增加

php7内核剖析    2019-09-16 14:52:16    17    0    0

4.3 循环结构

实际应用中有许多具有规律性的重复操作,因此在程序中就需要重复执行某些语句。循环结构是在一定条件下反复执行某段程序的流程结构,被反复执行的程序被称为循环体。循环语句是由循环体及循环的终止条件两部分组成的。

PHP中的循环结构有4种:while、for、foreach、do while,接下来我们分析下这几个结构的具体的实现。

4.3.1 while循环

while循环的语法:

while(expression) 
{
    statement;//循环体
}

while的结构比较简单,由两部分组成:expression、statement,其中expression为循环判断条件,当expression为true时重复执行statement,具体的语法规则:

statement:
    ...
    |   T_WHILE '(' expr ')' while_statement { $$ = zend_ast_create(ZEND_AST_WHILE, $3, $5); }
    ...
;

while_statement:
        statement { $$ = $1; }
    |   ':' inner_statement_list T_ENDWHILE ';' { $$ = $2; }
;

从while语法规则可以看出,在解析时会创建一个ZEND_AST_WHILE节点,expression、statement分别保存在两个子节点中,其AST如下:

while编译的过程也比较简单,比较特别的是while首先编译的是循环体,然后才是循环判断条件,更像是do while,编译过程大致如下:

  • (1) 首先编译一条ZEND_JMP的opcode,这条opcode用来跳到循环判断条件expression的位置,由于while是先编译循环体再编译循环条件,所以此时还无法确定具体的跳转值;
  • (2) 编译循环体statement;编译完成后更新步骤(1)中ZEND_JMP的跳转值;
  • (3) 编译循环判断条件expression;
  • (4) 编译一条ZEND_JMPNZ的opcode,这条opcode用于循环判断条件执行完以后跳到循环体的,如果循环条件成立则通过此opcode跳到循环体开始的位置,否则继续往下执行(即:跳出循环)。

具体的编译过程:

void zend
php7内核剖析    2019-09-16 14:51:38    16    0    0

4.2 选择结构

程序并不都是顺序执行的,选择结构用于判断给定的条件,根据判断的结果来控制程序的流程。PHP中通过if、elseif、else和switch语句实现条件控制。这一节我们就分析下PHP中两种条件语句的具体实现。

4.2.1 if语句

If语句用法:

if(Condition1){
    Statement1;
}elseif(Condition2){
    Statement2;
}else{
    Statement3;
}

IF语句有两部分组成:condition(条件)、statement(声明),每个条件分支对应一组这样的组合,其中最后的else比较特殊,它没有条件,编译时也是按照这个逻辑编译为一组组的condition和statement,其具体的语法规则如下:

if_stmt:
        if_stmt_without_else %prec T_NOELSE { $$ = $1; }
    |   if_stmt_without_else T_ELSE statement 
            { $$ = zend_ast_list_add($1, zend_ast_create(ZEND_AST_IF_ELEM, NULL, $3)); }
;

if_stmt_without_else:
        T_IF '(' expr ')' statement { $$ = zend_ast_create_list(1, ZEND_AST_IF,
                                        zend_ast_create(ZEND_AST_IF_ELEM, $3, $5)); }
    |   if_stmt_without_else T_ELSEIF '(' expr ')' statement 
            { $$ = zend_ast_list_add($1, zend_ast_create(ZEND_AST_IF_ELEM, $4, $6)); }
;

从上面的语法规则可以看出,编译if语句时首先会创建一个ZEND_AST_IF的节点,这个节点是一个list,用于保存各个分支的condition、statement,编译每个分支时将创建一个ZEND_AST_IF_ELEM

php7内核剖析    2019-09-16 14:50:36    18    0    0

4.1 类型转换

PHP是弱类型语言,不需要明确的定义变量的类型,变量的类型根据使用时的上下文所决定,也就是变量会根据不同表达式所需要的类型自动转换,比如求和,PHP会将两个相加的值转为long、double再进行加和。每种类型转为另外一种类型都有固定的规则,当某个操作发现类型不符时就会按照这个规则进行转换,这个规则正是弱类型实现的基础。

除了自动类型转换,PHP还提供了一种强制的转换方式:

  • (int)/(integer):转换为整形 integer
  • (bool)/(boolean):转换为布尔类型 boolean
  • (float)/(double)/(real):转换为浮点型 float
  • (string):转换为字符串 string
  • (array):转换为数组 array
  • (object):转换为对象 object
  • (unset):转换为 NULL

无论是自动类型转换还是强制类型转换,不是每种类型都可以转为任意其他类型。

4.1.1 转换为NULL

这种转换比较简单,任意类型都可以转为NULL,转换时直接将新的zval类型设置为IS_NULL即可。

4.1.2 转换为布尔型

当转换为 boolean 时,根据原值的TRUE、FALSE决定转换后的结果,以下值被认为是 FALSE:

  • 布尔值 FALSE 本身
  • 整型值 0
  • 浮点型值 0.0
  • 空字符串,以及字符串 "0"
  • 空数组
  • NULL

所有其它值都被认为是 TRUE,比如资源、对象(这里指默认情况下,因为可以通过扩展改变这个规则)。

判断一个值是否为true的操作:

static zend_always_inline int i_zend_is_true(zval *op)
{
    int result = 0;

again:
    switch (Z_TYPE_P(op)) {
        case IS_TRUE:
            result = 1;
            break;
        case IS_LONG:
            //非0即真
            if (Z_LVAL_P(op)) {
                result = 1;
            }
            break;
        case IS_DOUBLE:
            if (Z_D
php7内核剖析    2019-09-16 14:49:54    16    0    0

3.5 运行时缓存

在本节开始之前我们先分析一个例子:

class my_class {
    public $id = 123;

    public function test() {
        echo $this->id;
    }
}

$obj = new my_class;
$obj->test();
$obj->test();
...

这个例子定义了一个类,然后多次调用同一个成员方法,这个成员方法功能很简单:输出一个成员属性,根据前面对成员属性的介绍可以知道其查找过程为:"首先根据对象找到所属zend_class_entry,然后再根据属性名查找zend_class_entry.properties_info哈希表,得到zend_property_info,最后根据属性结构的offset定位到属性值的存储位置",概括一下这个过程就是:zend_object->zend_class_entry->properties_info->属性值,那么问题来了:每次执行my_class::test()时难道上面的过程都要完整走一遍吗?

我们再仔细看下这个过程,字面量"id"在"$this->id"此条语句中就是用来索引属性的,不管执行多少次它的任务始终是这个,那么有没有一种办法将"id"与查找到的zend_class_entry、zend_property_info.offset建立一种关联关系保存下来,这样再次执行时直接根据"id"拿到前面关联的这两个数据,从而避免多次重复相同的工作呢?这就是本节将要介绍的内容:运行时缓存。

在执行期间,PHP经常需要根据名称去不同的哈希表中查找常量、函数、类、成员方法、成员属性等,因此PHP提供了一种缓存机制用于缓存根据名称查找到的结果,以便再次执行同一opcode时直接复用上次缓存的值,无需重复查找,从而提高执行效率。

开始提到的那个例子中会缓存两个东西:zend_class_entry、zend_property_info.offset,此缓存可以认为是opcode操作的缓存,它只属于"$this->id"此语句的opcode:这样再次执行这条opcode时就直接取出上次缓存的两个值。

所以运行时缓存机制是在同一opcode执行多次的情况下才会生效,特别注意这里的同一opcode指的并不是opcode值相同,而是指内存里的

2/4