#### 第17章: #### PHP底层设计与内核剖析 #### 17.1 初识PHP7源码整体框架 ##### 解释型语言与编译型语言 大多数高级语言是编译型语言。编译指在程序运行之前就将程序"翻译"成汇编语言,然后再根据软硬件环境编译成目标文件。编译器负责编译工作。 PHP是`解释型语言`。所谓解释型语言就是在程序运行时才被"翻译"成及其语言。由于执行一次"翻译"一次,所以运行效率较低。解释器负责"翻译"源代码的程序。 编译型语言如何变成可执行的二进制文件?例如系统原理章节所讲的C语言: 1. C语言代码通过预处理器预处理。 2. 编译器将C语言程序优化生成目标汇编程序。 3. 汇编器将目标汇编程序汇编成目标程序。 4. 链接器将共享文件、静态库函数等进行链接生成可执行的二进制文件。 编译型语言的编译结果文件已经是针对当前CPU体系的指令。 解释性语言需先编译成中间代码,再由解释型语言的特定虚拟机翻译成CPU体系的指令被执行。所以解释型语言是在运行过程中被翻译成目标平台的指令。 PHP程序被翻译并执行的过程: 1. 运行过程中源代码首先通过词法分析器将代码切割成多个Token字符串单元(Token有100多个,是有固定意义的字符串,对PHP开发者是无感的,因为它由词法分析器处理)。 | Token标识符 | Code | 含义 | | -------------- | ---- | ------------ | | T_REQUIRE_ONCE | 258 | require_once | | T_INCLUDE_ONCE | 261 | include_once | | T_INCLUDE | 262 | include | | T_LOGICAL_OR | 263 | or | | T_LOGICAL_XOR | 264 | xor | | T_LOGICAL_AND | 265 | and | | T_PRINT | 266 | print | | T_YIELD | 267 | yield | ​ 部分Token含义 2. 由于一个个独立的Token是无法表达完整语义的,此时由语法分析器将Token集合转换为`抽象语法树AST`。语法分析器基于Bison实现,使用了BNF来表达语法规则,Bison借助状态机、状态转移表、压栈、出栈等一系列操作生成了抽象语法树,这样保证了指令的运行顺序。 3. 最后解释器将抽象语法树AST转换为一条条机器指令opcode(opcode是有固定意义的字符串,对PHP开发者是无感的,因为它由解释器生成)。 | opcode | Code | 含义 | | -------- | ---- | ---------------------------------------------- | | ZEND_NOP | 0 | 空操作,空函数或者其他无用操作会化作这个opcode | | ZEND_ADD | 1 | 加 | | ZEND_SUB | 2 | 减 | | ZEND_MUL | 3 | 乘 | | ZEND_DIV | 4 | 除 | ​ 部分opcode指令的含义 4. 最后将opcodes交给Zend虚拟机依次执行(Excution函数开始执行)。 :-: ![](https://img.kancloud.cn/88/d7/88d70f043e21b43082a2487ff61837f5_200x349.png) ​ 解释型语言执行示意 #### 17.2 PHP7内核架构 ##### 1. Zend 引擎 词法/语法分析,ATS生成和opcodes执行都在Zend引擎中实现。Zend引擎为PHP提供基础服务,Zend引擎同时支持扩展。此外PHP的变量设计管理、内存管理、进程管理等都在引擎层实现。 ##### 2. PHP层 PHP层负责与外界进行交互。与SAPI层的server交互。 ##### 3. SAPI层 遵循PHP的输入/输出规范与PHP交互的一方称为server。常见的是CLI SAPI、FPM API。只要遵循定义好的SAPI协议就可以完成交互,极大丰富了PHP支持的server类型。与PHP层交互。 ##### 4. Zend扩展 Zend引擎提供了核心能力和接口规范。以此基础开发的扩展为PHP代码的性能和功能的多样性提供了更丰富的选项。 :-: ![](https://img.kancloud.cn/e3/2c/e32cbe41aa06c87ff6747f729f2be02b_500x376.png) ​ PHP源码架构 #### 17.3 PHP7源码结构(未编译) ##### 1. sapi 目录 sapi目录是对输入输出层的抽象,是PHP对外提供服务的规范。 输入可以是来自cgi/fastcgi协议的网络请求或者命令行;输出可以是cgi/fastcgi协议的响应或写到命令行的输出。 sapi规范支持多种场景的交互,丰富了PHP的运行模式。 PHP的运行模式: - ClI命令行模式:对应bin/php二进制程序文件执行,Swoole模式下的运行PHP也是这样的模式。 - 内置模块模式:PHP作为普通函数供Apache或任意C/C++程序调用,不需要二进制文件程序启动。 - CGI模式:对应bin/cgi二进制程序文件执行,通常匹配Apache或IIS服务器(windows系统平台下的Web服务器)。 - FastCGI模式:对应sbin/php-fpm二进制程序文件执行,现在大部分PHP系统都是运行在此模式下,通常用于匹配Nginx服务器。由Nginx这个server遵循PHP的SAPI输入/输出规范,通过FastCGI协议与PHP交互。 ##### 2. Zend目录 Zend目录是PHP的核心代码。主要有: - 内存管理模块:实现PHP的内存管理。 - 垃圾回收:解决循环引用问题和处理垃圾。 - 数组实现:主要是对PHP数组的实现。 ... ##### 3. main目录 main目录是SAPI层和Zend层的粘合剂。 Zend层实现PHP脚本的编译和执行,sapi层实现了输入/输出的抽象。mian目录起到承上启下的作用:解析SAPI的请求,分析要执行的脚本文件和参数;调用Zend引擎前完成初始化工作。比如php_execute_script()方法,它是PHP脚本的通用入口,可以在main目录找到。 ##### 4.ext目录 ext目录是PHP扩展目录,常用的扩函数比如str、array、pdo等都在这里定义。 ##### 5. TSRM目录 线程安全资源管理器源码目录。早期的PHP都是单进程、单线程模型运行。现在大多也是用FastCGI模式下的PHP-FPM进程管理器运行的多进程模式。实际开发中PHP少有多线程场景。可以其编译以支持线程安全,就可以用PHP的多线程扩展进行开发。TSRM给每个线程提供了独立的全局变量副本做到线程之间就算在同一个进程内也完全独立。具体实现是TSRM为线程分配一个独立ID作为当前线程的全局变量内存区索引,这样每个线程就可以使用自己区域的变量。 #### 17.4 生命周期 本章字符解释: - SG:请求信息表 - EG:存储php.ini配置的符号表 PHP的整个生命周期被划分为以下几个阶段:模块初始化阶段、请求初始化阶段、脚本执行阶段、请求关闭阶段、模块关闭阶段。根据不同的SAPI的实现各阶段的执行情况有一些差异。比如CLI模式下每次执行都会完整经历这些阶段,而FastCGI模式下只有第一次启动时执行一次模块初始化阶段,之后的每个请求到PHP只会经历请求初始化阶段、执行脚本阶段、请求关闭阶段,而在SAPI关闭时才会执行一次模块关闭阶段。 :-: ![](https://img.kancloud.cn/5f/11/5f11bd12d5b1375d3ab3c71346490267_250x498.png) ##### 1. 模块初始化阶段 进行PHP框架,Zend引擎初始化操作。入口函数为php_module_startup()。一般只在SAPI启动阶段执行一次,对于FPM而言就是FPM的master进程启动时执行。这个阶段的主要处理: - 激活SAPI:初始化请求信息SG表、设置POST请求的handler回调函数等。模块初始化阶段处理完成后调用sapi_deactivate()析构函数。 - 启动PHP输出:php_output_startup()。 - 初始化垃圾回收器:gc_globals_ctor(),分配zend_gc_globals内存(Zend主机处理垃圾的内存区域)。 - 启动Zend引擎:zend_startup()。 - 注册PHP定义的一些常量:PHP_VERSION、PHP_ZTS、PHP_SAPI等。 - 解析php.ini。 - 将对PHP、Zend的核心php.ini配置加入EG哈希表中。 - 注册获取超全局变量的handler回调函数。例如获取$_POST,$_GET。 - 注册静态编译扩展:php_register_internal_extension_func(),将不需编译PHP自带的静态扩展模块加入到PHP中。 - 注册动态加载模块:php_ini_register_extensions(),将经过编译或者无需编译的php.ini中配置允许的动态扩展加入到PHP中。 - 回调各扩展定义的handler回调函数。 - 注册php.ini中禁用的函数、类:disable_functions、disable_classes。 总结:模块初始化阶段主要负责激活SAPI,初始化超全局变量,注册PHP内部的常量,启动Zend引擎,解析php.ini配置文件,处理配置文件的配置,加载扩展到PHP中。 ##### 2.请求初始化阶段 每一个请求处理前都会经历请求初始化阶段。对于FPM就是worker进程accept一个请求,读取、解析完请求数据后的一个阶段。主要处理: - 激活输出:php_output_activate()。 - 激活Zend引擎:zend_activate()。主要操作: - 重置垃圾回收器:gc_reset()。 - 初始化编译器。 - 初始化执行器。 - 初始化词法扫描器。 - 激活SAPI:sapi_activate()。 - 回调扩展定义的handler回调函数。 ##### 3.执行脚本阶段 由Zend引擎编译、执行PHP代码。执行阶段入口函数为php_execute_script()。 ##### 4. 请求关闭阶段 PHP脚本解释执行后进入请求关闭阶段,这个阶段将flush(flush指将缓冲区的数据冲刷出去,也就是输出)输出内容、发送HTTP响应头、清理全局变量、关闭编译器、关闭执行器等。 ##### 5. 模块关闭阶段 SAPI关闭时执行模块关闭阶段,与模块初始化阶段对应作用相反。主要进行资源的清理,PHP各个模块的关闭操作。 #### 17.5 PHP的内存管理 PHP底层是由C语言写的,但是却不需要像C语言手动分配、释放内存空间。由内核帮我们实现了内存管理,包括分配回收。 ##### 变量的自动GC机制 现代高级语言普遍提供了变量的自动GC机制,由语言自行管理内存,使得开发者可以不再关心内存的分配和释放。比如PHP由`$`声明一个变量,不需要手动销毁,内核清除在什么时候进行释放。 自动GC的简单实现:在函数定义变量时分配一块内存,用于存储变量的结构(在PHP中是zval结构体的value),在函数返回时再将内存释放,如果函数执行期间该变量作为参数调用了其他函数或赋值给了其他变量,则把变量复制一份,变量之间相互独立不出现冲突。但是这样相同值的变量占用空间会越来越多导致内存浪费。解决这个问题的方案:`引用计数+写时复制`。当变量赋值、传递时不是直接进行深拷贝,而是多个变量共同使用一个value,用引用计数来记录value由多少个变量在使用,他们都指向这个value值的空间;当某个变量的value需要发生改变无法与其他变量公用value时,将其深拷贝分离value,这就是写时复制。 ##### 引用计数 在PHP的变量结构体中有引用计数保存在了zend_value中。不同的数据类型结构里都有一个相同的成员:gc。用来保存引用计数,它的类型是zend_refcounted.h。 并不是所有的数据类型都会用到引用计数,只有复杂数据类型(有value结构的数据类型,例如字符串、数组)会用到引用计数。比如整型、浮点型、布尔型、NULL、内部字符串、不可变数组,他们的值直接通过zval保存,他们的会通过深拷贝赋值。PHP的局部变量的zval分配在zend_execute_data结构上。 ``` typedef struct _zend_refcounted_h{ //引用计数 unit32_t refcount; union{ struct{ ZEND_ENDIAN_LOHI_3( //类型 zend_uchar type, zend_uchar flags, //垃圾回收时使用 uint16_t gc_info) }v; unit32_t_type_info; }u; }zend_refcounted_h; ``` 例子: ``` $a = array(); //$a ->zend_array(refcount=1) $b = $a; //$a,$b ->zend_array(refcount=2) $c = $b; //$a,$b,$c ->zend_array(refcount=3) unset($b); //$a,$b ->zend_array(refcount=2) ``` 当unset解开变量$b与这个值之间绑定的关系,$b已不再在这个value的引用计数中。 例子: ``` $a = 1; //$a ->zend_long(refcount = 0) $b = $a; //$b ->zend_long(refcount = 0) ``` 简单类型的值被直接存储在zval中不会使用到引用计数。 内部字符串如PHP内部定义的字面量"hi",它在整个声明周期都是不变的,所以不会使用引用计数。 不可变数组是zend引擎扩展opcache优化出来的。 内部字符串和普通字符串的类型都是IS_STRING,以及其他不同的情况如何区分字符串是否可以使用引用计数呢? 答案是所有PHP变量都遵从zval.u1结构体中的类型掩码type_flag,它可以标识这个value是否可以使用引用计数。 ##### 写时复制 只有在必要的时候(即将发生写的时候)才发生深拷贝,其余时候将空间共享可以有效提升效率。写时复制指资源的复制需要在写的时候才进行,在此之前以只读的方式共享资源。 PHP中,当多个变量使用了引用计数后,其中一个变量修改value的情况时。发生修改的变量会复制一份数据来进行修改。同时断开原来的value指向,指向新的value。 例子: ``` $a = [1,2]; $b = &$a; $c = $a; //发生分离,此时$a的变量的引用计数由3降为2 $c[] = 2; ``` PHP中对象、资源无法复制,所以不能做写时复制。事实上只有string、array两种数据类型支持value分离,与引用计数相同,这个信息也通过zval.u1.type_flag记录,有copyable属性的变量可以使用写时赋值。 另外的情况时,如果变量从literals静态数据区复制到局部变量区,会从literrals中深拷贝一份数据进行赋值。比如$a=array(),赋值时发现value:array()支持copyable,会从literals中深拷贝一份数据进行赋值。再比如$a = 'hi',value:'hi'是内部字符串,不支持copyable,$a会直接指向literals中的value。 ##### 回收时机 在自动GC机制中,在zval断开value的指向时候发现refount=0时则会释放value。也可以使用unset()主动断开变量的引用。 ##### 垃圾回收 PHP7中垃圾回收的实现方式是定期遍历和标记若干存储对象的数组,再通过算法将是垃圾的物理空间回收。 垃圾回收包括`垃圾收集器`和`垃圾回收算法`。 垃圾回收器将可能是垃圾的元素收集在回收池,然后交垃圾回收算法回收。 例如: ``` $a = array(1); $a = &$a; unset($a); ``` 在unset()之前,变量a的引用计数来自$a和$a[1]。unset()之后$a与value断了联系。但是$a的引用计数没有由2变为0,而是1。此时就产生了垃圾。我们直到GC回收内存空间时是refount=0时。这就需要垃圾回收进行判断回收这个空间了。 ##### 内存池 PHP自己实现了一套内存池,用于替换类似C语言的malloc、free操作,以解决内存频繁分配、释放的问题。主要作用:减少内存分配及释放的次数、有效控制内存碎片的产生。 内存池定义了三种粒度的内存块:`chunk`、`page`、`slot`。每个chunk为2MB,page为4KB。每个chunk被分为512个page。而每个page被分为若干个slot。PHP在申请内存时按照不同的申请大小决定申请策略。 申请策略: - Huge:申请内存大小大于2044KB,直接调用系统分配,分配若干个chunk。 - Large(page):申请内存大于3092B(即3/4大小page),小于2044KB(即511个page大小),分配若干个page。 - Small(slot):申请内存小于3092B,内存池提前定义好了30种同等大小内存(8,16,24,32...3072B),被分配在不同的page上,申请内存时直接在对应的page上查找可用的slot。 大内存分配实际上是若干个chunk,通过一个zend_mm_huge_list的链表进行管理。大内存之间构成一个单向链表。 chunk是内存池向系统申请、释放内存的最小粒度,chunk之间构成双向链表。 chunk中的第一个page存储chunk的结构体,用于记录chunk的相关信息,如前后chunk指针,page使用情况。 分配slot时会申请对应数量的page,然后会分配page,page之间组成链表。 相同大小的slot之间会构成单向链表。 #### 17.6 变量 PHP7的变量由zval结构体实现。其中value不再存储复杂数据类型(数组、字符串、对象)。 ``` struct_zval_struct{ zend_value value; union{ struct{ ZEND_ENDIAN_LOHI_4( zend_uchar type, //表明zval类型 zend_uchar type_flags, zend_uchar const_flags, zend_uchar reserved) }v; uint32_t type_info; }u1; union{ uint32_t next; //解决哈希冲突 uint32_t cache_slot; //运行时缓存 uint32_t lineno; //对于zend_ast_zval存行号 uint32_t num_args; //EX(this) 参数个数 uint32_t fe_pos; //foreach 的位置 uint32_t fe_iter_idx; //foreach 游标的标记 uint32_t access_flags; //类的常量访问标识 uint32_t property_guard; //单一属性保护 }u2; }; ``` 其中value字段的结构如下: ``` typedef union _zend_value{ zend_long lval; //整型 double dval; //浮点 zend_refounted *counted; //引用计数 zend_string *str; //字符串类型 zend_array *arr; //数组类型 zend_object *obj; //对象类型 zend_resource *res; //资源类型 zend_reference *ref; //引用类型 zend_ast_ref *ast; //抽象语法树 zval *zv; //zval类型 void *ptr; //指针类型 zend_class_entry *ce; //class类型 zend_function *func; //function类型 struct{ uint32_t w1; uint32_t w2; }ww; }zend_value; ``` ##### PHP7变量类型 指的是PHP7运行时底层的数据类型,不是指我们开发时的PHP变量的数据类型。由20种宏区分,用来标记zval结构体中的u1.type字段。 ``` #define IS_UNDEF 0 //标记未使用类型 #define IS_NULL 1 //NULL #define IS_FALSE 2 //布尔false #define IS_TRUE 3 //布尔true #define IS_LONG 4 //长整型 #define IS_DOUBLE 5 //浮点型 #define IS_STRING 6 //字符串 #define IS_ARRAY 7 //数组 #define IS_OBJECT 8 //对象 #define IS_RESOURCE 9 //资源类型 #define IS_REFERENCE 10 //参考类型(内部使用) #define IS_CONSTANT 11 //常量类型 #define IS_CONSTANT_AST 12 //常量类型AST树 /*伪类型*/ #define _IS_BOOL 13 #define IS_CALLABLE 14 #define IS_ITERABLE 18 #define IS_VOID 19 /*内部伪类型*/ #define IS_INDIRECT 15 //间接类型 #define IS_PTR 17 //指针类型 #define _IS_ERROR 20 //错误类型 ``` ##### 整型和浮点型 对于整型和浮点型,由于其占用空间小,在zval中是直接存储的。 ``` $a = 10; //$a 的zval_1(u1.v.type=IS_LONG,value.lval=10) ``` ##### 字符串类型 字符串类型是特殊类型,zval的value指向字符串类型的结构体: ``` struct _zend_string{ zend_refcounted_h gc; //GC机制,用于引用计数、写实分离和回收 zend_ulong h; //hash value size_t len; //字符串长度 char val[1]; //字符串内容,采用柔性数组 } ``` ##### 引用 zend_reference类型由记录gc信息的zend_refcounted_h结构体和zval结构体组成。 ``` struct _zend_reference}{ zend_refcounted_h gc; zval val; } ``` 例如: ``` $a = 'hello'; //引用计数为1 $b = $a; //引用计数为2 $c = &b; //引用计数为2 ``` 当使用`&`操作时,会创建一种新的中间结构体zend_reference,指向真正的zend_string结构体,所以zend_string结构体的引用计数不变。同时zend_reference的引用计数变为2。此时$b和$c的类型都会变为zend_reference。这样的好处是让原始的zend_string在内存中只有一份。 ##### 间接zval 依照上面的引用章节。$a 就是直接zval,value指向zend_string。而$b和$c结构体的value指向zend_reference。再由间接zval的value再指向zend_string。此时的zend_reference就**间接zval**。 ##### 数组类型 PHP的数组类型是非常强大的数据结构。实际开发会大量使用。PHP使用哈希表(HashTable)存储数组,哈希表是利用哈希函数将特定的键映射到特定的值得一种数据结构,它维护着键和值的一一对应的关系,并且可以根据键快速检索到值,查询效率为O(1)。PHP利用哈希函数和链地址法将数组bucket块通过链表链接在一起并解决了哈希冲突。 PHP7数组的bucket块链表是逻辑上的链表,所有的bucket块都分配在连续的数组内存中,不再通过指针维护上下游关系,每个bucket块只维护下一个bucket块在数组中的索引(因为是连续内存,通过索引能够快速定位到bucket)。 bucket又分为: - 有效bucket:已存储了元素值、数字索引和关联索引。 - 无效bucket:已失效的bucket,使用时会使用它维护的下一个bucket索引跳过它。 - 未使用bucket:还未存储任何数据。 :-: ![](https://img.kancloud.cn/b9/0e/b90efd334d25c53472cb2c905ff9d6a1_600x66.png) ​ PHP7数组bucket分类 bucket的结构体: ``` typedef struct _Bucket{ zval val; //可以存储所有类型,甚至存储其他数组 zend_ulong h;//hash value 对应数字key或者字符串key值,数字数组和关联数组这里会保存其数字Key zend_string *key;//表示字符串key,关联数组这里会保存字符Key,数字数组这里会保存为NULL } ```