在 PHP5中,从 php 脚本到 opcodes 的执行的过程是:
- Lexing:词法扫描分析,将源文件转换成 token 流;
- Parsing:语法分析,在此阶段生成 opcodes。
PHP7 中在语法分析阶段不再直接生成 opcodes,而是先生成 AST,所以过程多了一步:
- Lexing:词法扫描分析,将源文件转换成 token 流;
- Parsing:语法分析,从 token 流生成抽象语法树;
- Compilation:从抽象语法树生成 opcodes。
所以本文是基于PHP7+,PHP8的版本。
无opcache
PHP是解释性的语言,程序的执行是由解释器来完成的,比如 PHP-FPM, CLI 都是解释器,分别用于不同的场景下。PHP解释器会将脚本以及依赖的其他文件加载到内存,成为待运行的代码。PHP解释器的过程如下:
- 将PHP代码转换成语言片段
Tokens
,这个过程让解释器知道各个程序都写了哪些代码。 这一步称为Lexing 或 Tokenizing
。 - 将这些
Tokens
转换成抽象语法树(AST
),这个过程成为Parse
。AST 是一组简单而有意义的表达式。 - 有了
AST
,可以更轻松地理解操作和优先级。然后基于AST
编译得到Opcodes
。 - 使用
Optimizer
组件优化Opcodes
得到Optimized Opcodes
,比如将多条Opcode
合并成一条,以减少调用Zend VM
的次数。 - 最后由
Zend VM
来逐条执行Opcode
,在Zend VM
中为每一个Opcode
定义了一个处理函数,成为Opcode Handler
。Zend VM
是由C语言编写的程序,是一个编译好的程序,绕了一大圈,就是为了将PHP代码转换成 Zend VM 的参数。
以上过程图形化如下:
从这个过程可以看出PHP解释器是组件化的设计,这种设计便于扩展和优化。PHP解释器的核心结构如下:
至于 Tokens,AST,Opcode 更多细节就不在这里说了,但是你要知道计算机中运算操作也就那么多,比如算数运算,逻辑运算,位运算,创建函数,运行函数,声明类,创建对象等等,任何一门语言解析到底层后都是计算机的基础操作,所以,Zend Engine 2 Opcodes 有 136 个,官方文档:https://www.php.net/manual/en/internals2.opcodes.list.php 一个简单的 opcode 示例:
<?php
$foo = 'foo';
$bar = 'bar';
echo $foo, $bar;
对应的 opcode :
number of ops: 5
compiled vars: !0 = $foo, !1 = $bar
line #* E I O op fetch ext return operands
-------------------------------------------------------------------------------------
2 0 E > ASSIGN !0, 'foo'
3 1 ASSIGN !1, 'bar'
4 2 ECHO !0
3 ECHO !1
5 4 > RETURN 1
branch: # 0; line: 2- 5; sop: 0; eop: 4; out1: -2
path #1: 0,
一个 opcode 为一行。
opcode 类似于java中的字节码,先使用 javac Hello.java 将java脚本编译为字节码,再使用 java Hello 调用 jvm 来运行字节码,而PHP是将这两步合并到一起了。
开启opcache
考虑到每次运行PHP程序都要经过上面的5个步骤,如果代码没有改变的话,前面4步都是没有必要的,可以直接将 opcodes 缓存下来,这样就能节省一些时间。注意 opcache
是 Zend Extension
,不是 PHP Extension
。
流程图如下:
开启了 Opcache 之后,Opcode将会以文件为单位被缓存下来,存储在共享内存中。然后可以设置每隔一定时间去遍历这个列表中的文件名,看他们在文件系统上有没有发生改变,默认是每隔2秒检查一次,线上环境可以设置为10秒以提高性能。
Opcache配置详解:https://www.php.net/manual/zh/opcache.configuration.php
从配置可以看出,有设置最大文件数和最大使用内存,所以,随着项目的不断庞大, 文件数增多,消耗内存增加,包括很多文件我们已经不调用了但是依然存在Opcache中等等问题,为此Opcache提供了一些函数
- opcache_compile_file — 无需运行,即可编译并缓存 PHP 脚本,
用在 preload 功能上。
- opcache_get_configuration —
获取缓存的配置信息
- opcache_get_status — 获取缓存的状态信息,
包括内存,命中率,文件数,缓存了哪些文件等等信息。
- opcache_invalidate — 废除脚本缓存,
删除某个文件的缓存。
- opcache_is_script_cached —
查询某个文件是否在缓存中。
- opcache_reset —
清空缓存。
如果项目发生了重大修改导致某些文件或包没有被用到,那么可以使用 opcache_reset 给 Opcache 做个瘦身,可以节省资源和提高效率。因为内核去判断某个文件是否在Opcache中,也就是去判断其是否存在于列表中,并且内存,缓存文件数上限都是有设置的。
一般建议开启 Opcache 以大幅度提高程序的性能,并且会以一定的频率去检查文件是否有更新从而决定是否删除该文件对应的缓存 Opcodes,这些都是可以通过 Opcache 配置项来配置的。
Opcodes 只是对PHP代码的一个梳理归纳,它并不关心某个变量的类型(PHP的弱类型特点),所以只要文件没有改变就可以读取缓存的 Opcodes 来运行,至于实际的运算是由 Zend VM 来完成的,此时才需要关心变量的具体类型,因为C语言是强类型的语言。
大多数的时候,我们都是使用框架来开发项目的,而框架的源码几乎是不会去经常升级的,所以,如果对 Opcache 做一个扩展,使开发人员可以将框架代码的 Opcodes 长久的缓存下来是不是更好呢,有人会说了,Opcache 本来就会缓存框架源码撒,如果源码没变也不会去删除其缓存的,相对于长久的缓存,那为什么还单独去搞呢,实际上 Opcache 有个检查频率来控制缓存的更新,而每次都会去检查框架源码这种不变的文件是不是有点浪费性能呢,所以从PHP7.4开始有了预加载 preload功能,就是在PHP-FPM启动的时候会去执行一个配置的PHP脚本程序,在这个程序中去逐个加载要预加载的PHP文件,这样这些文件的 Opcodes 就长久的存在了,目前没有更新机制,只有重启 PHP-FPM 进程,关于 preload 的更多细节可自行查阅资料。
启用JIT
JIT (Just In Time) 即即时编译,也就是在运行的时候编译,解释型语言一般使用 Just In Time 来提升执行效率,实际上是从解释性语言到半编译型语言的转变,因为编译型语言的执行效率的确很诱人,例如 luajit,与之相对的是编译型语言,先编译再运行 Ahead Of Time ,比如 c, c++, golang。
前面已经提到,即使使用了 Opcache ,依然需要依赖 Zend VM 来执行 Opcodes ,如果能绕过 Zend VM 是不是能有一定的性能提升呢,实际上 luajit 的性能提升是很明显的。
PHP JIt 是放在 Opcodes 之后,也就是说 JIT 编译器是将 Opcodes 编译成CPU可以直接运行的机器码,既然要直接和CPU打交道,那么就需要去适应不同架构的CPU,官方说目前只支持x86架构的CPU,PHP8.0.0版本。
PHP 的 JIT 使用了名为 DynASM (Dynamic Assembler) 的库,该库将一种特定格式的一组 CPU 指令映射为许多不同 CPU 类型的汇编代码。因此,编译器只需要使用 DynASM 就可以将 Opcodes 转换为特定结构体的机器码。于是有人就会想了,前面提到 preload 这个东西,那么我们可不可以将所有的代码编译成 Opcodes ,然后再编译成机器码呢,这样PHP不就成了编译型语言呢,实际上第一步是完全可以实现的,但是第二步就是个问题了,因为PHP是弱类型语言,Zend VM 中一个很重要的步骤是实时推断变量的类型,没有了 Zend VM 那么变量的类型推断由谁来做呢?使用机器码执行类型推断逻辑是不可行的,并且可能变得更慢。
现在我们知道无法很好的推断类型来提前编译。我们也知道在运行时进行编译的运算成本很高。那么 JIT 对 PHP 有何好处呢?
为了寻求平衡, PHP 的 JIT 尝试只编译有价值的 Opcodes 。为此, JIT 会分析 Opcodes 并检查可能编译的地方,好吧,希望官方能出来一个使用建议能够使更多的代码被JIT编译器编译。