聊聊PHP虚拟机

PHP技术
432
0
0
2023-07-24
标签   虚拟机

作者: MeetMax

出处:

什么是 虚拟机 ?

“虚拟机”是个非常大的概念,从字面意思理解,“虚拟机”就是“虚拟的计算机”,我们在学习服务端编程时,相信大部分同学都接触过虚拟机。有这样一种场景,由于我们日常使用的计算机大部分是Windows操作系统,但绝大多数的服务端软件却都运行在Linux系统上,假设我们在Windows上进行编程,就无法直接在Windows上进行测试,非常不方便。基于这样的场景于是就有了虚拟机,它的作用是可以在windows系统的基础上运行Linux系统,然后我们就可以很方便的在windows系统上测试Linux系统的程序。这个Linux操作系统是通过某种技术手段虚拟出来的,中间的过程非常复杂,无法用三言两语来描述。

今天想聊的虚拟机和上面说的虚拟机略有不同,但是它们要解决的问题是一样的。上面说的虚拟机,它虚拟出了一个完整的操作系统,我把它称之为“操作系统级虚拟机”。而我们今天要聊的虚拟机它是针对 编程语言 的,它能达到的效果是,同一份代码运行在不同的操作系统上输出相同的结果,可以实现一次编写到处运行,我把它称为“语言级虚拟机”。我们非常熟悉的 Java 、PHP、Python等编程语言,实际上都是基于虚拟机的语言,它们都具备跨平台性,我们只需要编写一次代码,就可以运行在不同的操作系统上,并且输出几乎完全相同的结果。

了解过系统编程的同学应该都知道,不同操作系统对于同一个功能所提供的“系统 API ”可能是不一样的。例如 Windows和Linux系统都提供了网络监听的API,但是它们对应的SOCKET API却不同,假设我们使用平台相关的编程语言(例如:C、C++),我们在编程时就必须要注意这样的区别,并且针对不同的操作系统做相应的兼容处理,否则程序在Linux系统上能正常运行,但是Windows就会报错。这种类似的区别非常多,具体细节要看对应的系统编程手册才知道。有的系统API完全不同,而有的仅仅是个别参数不同,方法名完全相同,程序员在编写代码时需要时刻注意这些,才能编写出健壮的跨平台代码,这对新手来讲是非常困难的,并且这样一来,程序员就需要把很大一部分精力花费在兼容性问题上,而不能专注于实际功能的开发。

有了虚拟机之后,上面的问题就不复存在了。虚拟机的作用简单来说就是中介代理,好比我们初来乍到大城市要租房子,北上广等大城市的房东那么多,如果没有房产中介(虚拟机),我们就需要和N个房东对接,然后才能租到合适的房子;有了房产中介(虚拟机),我们只需要告诉房产中介(虚拟机)我们要租什么样的房子,由房产中介(虚拟机)去协调各个房东,我们就能租到合适的房子,过程不同,最后的结果是相同的。同理,以Socket API调用为例,我们把编写好的代码交给虚拟机,再由虚拟机来负责调用系统API,相当于中间加了一层中介代理,虚拟机将根据操作系统选择正确的Soekct API,来帮我们完成最终的功能。这样的好处是程序员不再需要关注底层API的细节,可以专注于真正功能的编写,虚拟机帮我们屏蔽底层系统API的细节,并且编程的门槛也大大降低,代码健壮性也大大提高。

PHP的执行过程

PHP解释执行过程

了解PHP的同学都知道,PHP是一种解释型语言,也称作脚本语言,它的特点就是轻量、简单易用。传统的编程语言在运行前都需要进行编译、链接,然后才能执行并输出结果。而脚本语言(PHP)则省略了这个过程,直接通过shell命令就能执行执行并输出对应的结果,非常轻量、直观、易上手。不瞒大家说,我在入坑编程时也学过Java,为什么最后入了PHP的坑呢,可能就是这些特点吸引的我。

刚才我们只说了PHP的优点,但是大多数时候都是有得必有失,我想编程语言也一样,PHP非常轻量、易上手那么它必然是牺牲了某种优点为代价的,否则为什么其它编程语言不这么做呢。接下来我们就聊一聊PHP的执行过程,我想了解了PHP的执行过程,就能理解PHP语言设计上的取舍了。

以下是PHP在开启了 Opcache 缓存后程序运行的主要过程。

图-1

图-1 中可以看到,载入PHP代码文件后,首先通过 词法分析器(re2c/lex) ,从代码中提取出 单词符号(token) ,然后再经过 语法分析器(yacc/bison) ,从token中发现语法结构后,生成 抽象语法树(AST) ,再经由 静态编译器 生成 Opcode ,最后由 解释器 模拟机器指令来执行每一条 Opcode

另外,当PHP开起了Opcache后,ZendVM会对Opcode进行缓存处理,缓存在共享内存中。不仅如此,ZendVM还会对编译后的Opcode进行优化,编译的优化技术包括 方法内联 常量传播 重复代码删除 等。有了Opcache后,不仅可以省略掉 词法分析、语法分析、静态编译等步骤,同时Opcode也被额外优化了,程序的执行效率比首次执行时的速度更快。

以上就是PHP解释执行的过程,虽然解释执行对程序员非常友好,省略了静态编译的步骤,但实际上这个过程并没有省略,只是由虚拟机帮我们完成了,以牺牲一部分性能为代价,换来了轻量、易用性、灵活性。其中 词法分析、语法分析、静态编译、解释执行 这些流程都是在执行时完成的。

编译型语言执行过程

了解过解释型语言的执行过程后,作为对比我们再来看下 编译型语言 的执行过程,来看看它相比比解释型语言有什么不同。

图-2

图-2 中我们可以看到,虚线框中的执行过程包括:词法分析、语法分析、编译,这3步在PHP解释执行时也同样有,唯一的区别是,C/C++这3步是提前由编译器在编译过程中完成的,这样可以在运行时节省大量的时间和开销。生成汇编代码后,第4步是 链接 汇编文件,并生成可执行文件,这里的可执行文件指的是二进制的机器码,CPU可以直接执行不需要再额外翻译,这4个步骤合起来称为 静态编译 。可以很明显的看到, 编译型语言 相对 解释型语言 在前期需要做更多的工作,但换来的是更高的性能和执行效率。因此,一般在大型的项目中,由于对性能要求比较高,代码量也很大,如果采用解释型语言会大大降低执行效率,使用静态编译型能够获得更好的执行效率,降低服务器采购成本。

什么是JIT?

JIT可以说是虚拟机中最有技术含量的技术,刚才我们分别讲了解释型语言和编译型语言执行的过程,也分析了它们各自的优势和劣势,我们可以思考一下,有没有一种技术,既有解释型语言轻量、易上手的优点,同时也拥有编译型语言的高性能,结论就是JIT。下面我们要介绍的就是编程语言中的JIT技术,它的全称是“即时编译”,具体指的是什么呢?我们先来看下维基百科对即时编译的定义。

在计算机技术中,即时编译(英语:just-in-time compilation,缩写为JIT;又译及时编译、实时编译),也称为动态翻译或运行时编译,是一种执行计算机代码的方法,这种方法涉及在程序执行过程中(在运行期)而不是在执行之前进行编译。通常,这包括源代码或更常见的字节码到机器码的转换,然后直接执行。实现 JIT编译器 的系统通常会不断地分析正在执行的代码,并确定代码的某些部分,在这些部分中,编译或重新编译。

刚才我们说了,JIT既拥有解释型语言的轻量易用性,同时拥有高性能,那么它是如何实现的呢?以PHP8中加入了JIT的特性为例,下图描述了PHP开启了JIT特性后的执行流程,PHP8-JIT是在Opcache优化的基础上更进一步,将Opcache中保存的Opcode优化后再进行编译,将Opcode编译成CPU可识别的可执行文件,也就是二进制文件,相当于C++编译后的可执行文件,只不过这个过程不需要在运行前完成,而是在运行时,虚拟机开启后台线程,将Opcode转换成二进制文件,有了二进制文件缓存后,当下次执行该逻辑时,CPU就可以直接执行,不需要再经过解释,理论性能和C++一样。这样的好处就是既保留了PHP语言的易用性、灵活性,同时也获得了高性能。

图-3

JIT的触发条件

JIT实际上就是把运行时的一部分代码,转换成可执行文件并缓存起来,加速下次代码的执行。那么JIT是程序启动后就会触发吗?

JIT在程序初次启动时并不会起作用,可以理解为PHP/Java代码在首次执行时,其实仍然是以解释的形式运行的,JIT需要在程序运行一段时间后才能真正触发。说到这里,大家有没有跟我有一样的疑问,为什么JIT不在程序启动时,就把所有的代码都转换成可执行文件缓存起来,就像C++一样,这样岂不是效率更高。在Java语言中确实有少部分这样的应用,但并不是主流。主要有以下几方面的原因

  1. 全部编译成二进制文件需要耗费很多时间,程序启动会非常慢,这对于大型项目来说是不可接受的
  2. 并不是所有的代码都有必要进行性能优化,大部分代码在实际场景中用的并不多
  3. 编译成二进制会占用很大的容量
  4. 提前编译好相当于是静态的编译,JIT编译相对于静态编译有很多不可替代的优势

JIT的触发条件,主要是基于“计数器的热点探测”,虚拟机会为每个方法(或者代码块)建立计数器,如果执行次数超过一定的阈值就认为它是“热点方法”,在达到阈值后,虚拟机会开启后台线程将该代码块编译成可执行文件,缓存在内存中,加速下次执行的速度。以上只是简单描述了热点代码的触发规则,实际的虚拟机采用的规则,会比这个更复杂。

JIT&提前编译的优劣势

JIT编译器是在运行时进行的,我们很容易发现,它和提前编译相比有几个很明显的劣势。首先,JIT编译需要消耗运行时的计算资源,原本这些资源可以用来执行程序,不管JIT编译器如何优化(例如:分层编译),这是始终没办法回避的问题,其中最消耗资源的一步是“过程间分析”,比如分析这个方法是否永远不可能被调用,抽象方法是否永远只会调用单一版本的结论,这些信息对生成高质量的代码有非常高的价值,但是要精确的得到这些信息,必须要经过大量的耗时计算,消耗大量运行时的计算资源。反过来,如果这些耗时的工作的提前编译时就完成了,运行时就只需享受高质量代码带来的高性能,最多就是提前编译时稍微慢一点,但这都是可以接受的。

说了这么多,那JIT编译和提前编译相比,在性能优化上就真的没什么优势了吗?结论是不是的,JIT编译有很多提前编译不可替代的优势。正是因为JIT编译器是在运行时进行的,所以JIT编译器能获取到程序真实的数据,通过不断收集程序运行时的监控信息,并对这些数据进行分析,JIT编译器可以对程序做一些激进的优化,这是提前的静态编译器做不到的。

首先是,性能分析制导优化。比如说JIT编译器在运行时,通过程序运行的监控数据,如果发现某些代码块被执行的特别频繁,那可以集中优化这一块代码,例如:给这段代码分配更好的寄存器、缓存等。

然后是激进预测优化。比如说有一个接口,它的实现类有3个,但在真实运行过程中,95%以上的时间都在运行A这个实现类,通过数据的分析,那就可以激进的对它进行预测,每次都执行A,如果发现有几次预测错误了,可以退回到解释状态再次执行,但只是小概率事件,并且不影响程序执行的结果。

最后是链接时优化,传统的编译器的步骤是编译优化和链接是分开的,什么意思呢?加入某个程序需要用到A、B、C 3个库,编译器先各自编译这3个类库,并且进行各种手段的优化,转换成汇编代码保存到文件中,最后一步是将这3个汇编文件链接起来,最终转换成可执行文件。这里存在一个问题,A、B、C 3个库在编译时是分别进行优化的,假设A和B中有些方法是重复执行的,或者可以方法内联来优化,那是无法做到的。但是JIT编译器是的不同之处在于,它是运行时动态链接的,可以针对整个程序的调用栈进行优化,这样的优化更加彻底。

总结

写这篇博客的主要目的,是对自己这段时间学习虚拟机相关技术的一个总结,在我谷歌搜索PHP虚拟机相关文章时,发现可参考的文章寥寥无几。由于Java和PHP的执行原理很相近,我想可以通过学习Java虚拟机来了解ZendVM的工作原理,Java虚拟机非常成熟,可以说是虚拟机的鼻祖,JVM世面上的优秀书籍非常多,JVM打开了我的新世界,让我对虚拟机有了全新的认识,JIT技术更是惊艳到我。

最后,PHP是世界上最好的语言!