代码成诗

阅读《 PHP7 内核剖析》的感悟笔记

deng-dev  发布在  更新于 PHPPHP7内核感悟经验

PHP 7 内核剖析

概述

PHP7 内核剖析 是一本以 php-src-7.0.12 源码为范本的 PHP 优秀内核解读书籍。作者结合 PHP7 源代码详细讲述了 PHP7 的架构、Zend 虚拟机、基础语法实现、扩展开发等等多个方面,从底层剖析了 PHP 核心原理和实现方式,对于想了解PHP7 内核实现的同学( Yes, it‘s me ! 2333 )来说是一部不可多得的好作品。

俗话说好记性不如烂笔头,本文就是我在阅读 PHP7 内核剖析之后记录的一些感悟笔记,把对自己加深理解 PHP 的地方和帮助我们书写更规范 PHP 代码的感悟标注、记录了出来,以备后续查阅。也希望能给看到这篇文章的你些许有效的帮助。

笔记

FPM

fpm的实现就是创建一个master进程,在master进程中创建并监听socket,然后fork出多个子进程,这些子进程各自accept请求,子进程的处理非常简单,它在启动后阻塞在accept上,有请求到达后开始读取请求数据,读取完成后开始处理然后再返回,在这期间是不会接收其它请求的,也就是说fpm的子进程同时只能响应一个请求,只有把这个请求处理完成后才会accept下一个请求,这一点与nginx的事件驱动有很大的区别,nginx的子进程通过epoll管理套接字,如果一个请求数据还未发送完成则会处理下一个请求,即一个进程会同时连接多个请求,它是非阻塞的模型,只处理活跃的套接字。

fpm的master进程与worker进程之间不会直接进行通信,master通过共享内存获取worker进程的信息,比如worker进程当前状态、已处理请求数等,当master进程要杀掉一个worker进程时则通过发送信号的方式通知worker进程。

fpm可以同时监听多个端口,每个端口对应一个worker pool,而每个pool下对应多个worker进程,类似nginx中server概念。

fpm 通过 master fork 出 worker进程,每一个 worker独自处理accept,且是阻塞模型,一个 worker同时只能处理一个请求。


master是如何管理worker进程的,首先介绍下三种不同的进程管理方式:

static: 这种方式比较简单,在启动时master按照pm.max_children配置fork出相应数量的worker进程,即worker进程数是固定不变的

dynamic: 动态进程管理,首先在fpm启动时按照pm.start_servers初始化一定数量的worker,运行期间如果master发现空闲worker数低于pm.min_spare_servers配置数(表示请求比较多,worker处理不过来了)则会fork worker进程,但总的worker数不能超过pm.max_children,如果master发现空闲worker数超过了pm.max_spare_servers(表示闲着的worker太多了)则会杀掉一些worker,避免占用过多资源,master通过这4个值来控制worker数

ondemand: 这种方式一般很少用,在启动时不分配worker进程,等到有请求了后再通知master进程fork worker进程,总的worker数不超过pm.max_children,处理完成后worker进程不会立即退出,当空闲时间超过pm.process_idle_timeout后再退出

这就是我们在 php-fpm.ini 中配置参数的用途。


PHP 的生命周期

PHP 的生命周期

通过这张生命周期图可以看到,整个 PHP 内核工作的流程,我只能说 666666。。。


变量

array是PHP中非常强大的一个数据结构,它的底层实现就是普通的有序HashTable


PHP中的 引用只可能有一层不会出现一个引用指向另外一个引用的情况 ,也就是没有C语言中指针的指针的概念。


硬拷贝带来的一个问题是效率低,比如我们定义了一个变量然后赋值给另外一个变量,可能后面都只是只读操作,假如硬拷贝的话就会有多余的一份数据,这个问题的解决方案是: 引用计数+写时复制 。PHP变量的管理正是基于这两点实现的。


用到引用计数的类型:

|     type       | refcounted |
+----------------+------------+
|simple types    |            |
|string          |      Y     |
|interned string |            |
|array           |      Y     |
|immutable array |            |
|object          |      Y     |
|resource        |      Y     |
|reference       |      Y     |

写时复制是指:

多个变量可能指向同一个value,然后通过refcount统计引用数,这时候如果其中一个变量试图更改value的内容则会重新拷贝一份value修改,同时断开旧的指向,写时复制的机制在计算机系统中有非常广的应用,它只有在必要的时候(写)才会发生硬拷贝,可以很好的提高效率

这里我们可以结合一下自己的 PHP 代码,仔细考虑下面这个场景:

有一个保存配置的 array数组,这个配置信息很多,数组占用内存也会多一些。当我们向函数传值的时候你是不是考虑过会多占用一个配置数组内存的问题。毕竟函数传值使用的是copy的方式。看到写时复制,你就大可放心,完全可以直接把整个数组传进去,只要在函数内部没有对数组进行写入操作,那就是零拷贝,只是使原数组的引用计数值+1,不会有内存上涨问题。


不是所有类型都可以copy的,比如对象、资源,实时上只有string、array两种支持

写时复制发生写操作的时候,实际上只能在 stringarray类型上生效。如果是对象:$a = new user;$b = $a;$a->name = "dd";这种情况是不会复制object的,$a、$b指向的对象还是同一个。


基于引用计数的垃圾回收其实是存在内存泄露风险的,比如:

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

unset($a);

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

unset($a)之后:

可以看到,unset($a)之后由于数组中有子元素指向$a,所以refcount > 0,无法通过简单的gc机制回收,这种变量就是垃圾,垃圾回收器要处理的就是这种情况,目前垃圾只会出现在array、object两种类型中,所以只会针对这两种情况作特殊处理:当销毁一个变量时,如果发现减掉refcount后仍然大于0,且类型是IS_ARRAY、IS_OBJECT则将此value放入gc可能垃圾双向链表中,等这个链表达到一定数量后启动检查程序将所有变量检查一遍,如果确定是垃圾则销毁释放。


PHP代码的编译

C程序在编译时将一行行代码编译为机器码,每一个操作都认为是一条机器指令,这些指令写入到编译后的二进制程序中,执行的时候将二进制程序load进相应的内存区域(常量区、数据区、代码区)、分配运行栈,然后从代码区起始位置开始执行,这是C程序编译、执行的简单过程。

同样,PHP的编译与普通的C程序类似,只是PHP代码没有编译成机器码,而是解析成了若干条opcode数组,每条opcode就是C里面普通的struct,含义对应C程序的机器指令,执行的过程就是引擎依次执行opcode,比如我们在PHP里定义一个变量:$a = 123;,最终到内核里执行就是malloc一块内存,然后把值写进去。

所以PHP的解析过程任务就是将PHP代码转化为opcode数组,代码里的所有信息都保存在opcode中,然后将opcode数组交给zend引擎执行,opcode就是内核具体执行的命令,比如赋值、加减操作、函数调用等,每一条opcode都对应一个处理handle,这些handler是提前定义好的C函数。

编译 PHP 代码,其实就是把 PHP 的代码转化成 Zend 引擎能识别的操作码(这里叫 opcode ),每一条操作码都对应 Zend 引擎事先定义好的处理函数。执行编译后的 opcode ,就是在执行 Zend 引擎预定义的 C 函数。所以我们可以看到在一些场合下跑 PHP 的基准测试,速度飞快(甚至比 gojava还要快),原因就在于此。


类的自动加载

在实际使用中,通常会把一个类定义在一个文件中,然后使用时include加载进来,这样就带来一个问题:在每个文件的头部都需要包含一个长长的include列表,而且当文件名称修改时也需要把每个引用的地方都改一遍,另外前面我们也介绍过,原则上父类需要在子类定义之前定义,当存在大量类时很难得到保证,因此PHP提供了一种类的自动加载机制,当使用未被定义的类时自动调用类加载器将类加载进来,方便类的同一管理。

在内核实现上类的自动加载实际就是定义了一个钩子函数,实例化类时如果在EG(class_table)中没有找到对应的类则会调用这个钩子函数,调用完以后再重新查找一次。这个钩子函数保存在EG(autoload_func)中。

PHP中提供了两种方式实现自动加载:__autoload()spl_autoload_register()


__autoload():这种方式比较简单,用户自定义一个__autoload()函数即可,参数是类名,当实例化一个类是如果没有找到这个类则会查找用户是否定义了__autoload()函数,如果定义了则调用此函数


spl_autoload_register():相比__autoload()只能定义一个加载器,spl_autoload_register()提供了更加灵活的注册方式,可以支持任意数量的加载器,比如第三方库加载规则不可能保持一致,这样就可以通过此函数注册自己的加载器了,在实现上spl创建了一个队列来保存用户注册的加载器,然后定义了一个spl_autoload函数到EG(autoload_func),当找不到类时内核回调spl_autoload,这个函数再依次调用用户注册的加载器,没调用一个重新检查下查找的类是否在EG(class_table)中已经注册,仍找不到的话继续调用下一个加载器,直到类成功注册为止。

bool spl_autoload_register ([ callable $autoload_function [, bool $throw = true [, bool $prepend = false ]]] )

参数$autoload_function为加载器,可以是函数名,第2个参数$throw用于设置autoload_function 无法成功注册时, spl_autoload_register()是否抛出异常,最后一个参数如果为true时spl_autoload_register() 会添加函数到队列之首,而不是队列尾部。

我们用 PHP 代码实现的自动加载类,本质上就是 Zend 引擎调用钩子函数,钩子函数去调用我们注册的 __autoload() 或者 spl_autoload_register() 方法的过程,每调用完成一次,就去检查class_table中该方法是否被注册,直到注册成功或者调用全部方法完成之后停止。

所以当我们使用spl_autoload_register() 注册了多个自动加载器,典型的如使用composer 引入了多个包之后,这个自动查找流程就会相对比较长(虽然还是会很快)。


线程安全

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


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


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

我们下载 PHP 扩展的时候,经常会看到 NTS TS字样的扩展标识,它就是上述线程不安全和安全的意思。一般情况下搭配Nginx的就是 NTSApache的是 TS。命令行是NTS.


扩展的钩子函数

PHP为扩展提供了5个钩子函数,PHP执行到不同阶段时回调各个扩展定义的钩子函数,扩展可以通过这些钩子函数介入到PHP生命周期的不同阶段中去


这几个钩子函数执行的先后顺序:module startup -> request startup -> 编译、执行 -> request shutdown -> post deactivate -> module shutdown。


module_startup_func:这个函数在PHP模块初始化阶段执行,通常情况下,此过程只会在SAPI启动后执行一次。这个阶段可以进行内部类的注册,如果你的扩展提供了类就可以在此函数中完成注册;除了类还可以在此函数中注册扩展定义的常量;另外,扩展可以在此阶段覆盖PHP编译、执行的两个函数指针:zend_compile_file、zend_execute_ex,从而可以接管PHP的编译、执行,opcache的实现原理就是替换了zend_compile_file,从而使得PHP编译时调用的是opcache自己定义的编译函数,对编译后的结果进行缓存。

大名鼎鼎的 C 扩展应用框架Cphalcon就是在这步初始化自身的,从而保持框架在内存中,在之后的运行过程不会再耗费额外的解析编译资源。

鸟哥的 YafYac,查询IP归属的GeoIP也是如此。


request_startup_func:此函数在编译、执行之前回调,fpm模式下每一个http请求就是一个request,脚本执行前将首先执行这个函数。如果你的扩展需要针对每一个请求进行处理则可以设置这个函数,如:对请求进行filter、根据请求ip获取所在城市、对请求/返回数据加解密等。


request_shutdown_func:这个函数比较特殊,一般很少会用到,实际它也是在请求结束之后调用的,它比request_shutdown_func更晚执行


module_shutdown_func:模块关闭阶段回调的函数,与module_startup_func对应,此阶段主要可以进行一些资源的清理


结语

这本书中还有很多重要的知识点,包括词法语法分析、编译的整个流程、函数和类的实现等等,都没有在文章中贴出来。原因就是我的C仅仅是入门水平,太过高深的写法和原理还不能够理解透彻,为防止误导别人,还是建议你直接查看书中的详细解释。

通读整本书,最后,我只想说:

PHP 是最好的语言,没有之一 [微笑]

Program End Flag ……

deng-dev
保持敏锐的技术嗅觉,去探知无尽的想象力