标签归档:PHP内核

PHP的ticks机制

PHP的ticks机制

要过年了,在年前完成这篇文章,如果有缘可以看到,祝福看到的朋友新年快乐,在新的一年里,万事顺意!

按今年的计划每个月至少有两篇文章,而一月份因为各种理由而只有一篇2012的总结,无论什么原因,总归是不对的。这篇算是补上的,也作为今年的开始。

回正题,今天要研究的是PHP的ticks机制。

PHP提供declare关键字和ticks关键字来声明ticks机制。如:declare(ticks = N); 这表示:在当前scope内,每执行N句internal statements(opcodes),就会中断当前的业务语句,去执行通过register_tick_function注册的函数(如果存在的话),然后再继续之前的代码。需要注意的是这里的N是指的PHP的一些OPCODE,而OPCODE与我们见到的PHP语句却不是一一对应的。

最开始我以为PHP内核是在编译时记录是否有ticks机制,在真正执行中间代码时插入判断代码,实现此机制。但是事实上却不是这样滴。

看PHP代码示例1:

    $name = "phppan";
    echo $name;
    class Tipi {
        public function test() {
            echo "test";
        }
    }
    function f_tipi() {
    }

如上代码包括了我们常见的几种语句,赋值,输出,定义类,定义函数。通常我们用VLD查看PHP生成的中间代码,上面的代码通过 php -dvld.active=1 t.php 我们会看到 ECHO、ASSIGN、NOP等中间代码。

现在我们在示例1的代码上添加上ticks机制。如PHP代码示例2:

    declare(ticks=1);
    $name = "phppan";
    echo $name;
    class Tipi {
        public function test() {
            echo "test";
        }
    }
    function f_tipi() {
    }

示例2与示例1相比也就是多了第一条语句: declare(ticks=1); 如果我们此时再次通过VLD查看中间代码,会发现在每个中间代码的后面都多了一句中间代码:TICKS

是否因为ticks=1的原因而在每个中间代码的后面添加了TICKS?将declare(ticks=1);换成declare(ticks=100);,再次VLD,结果没有变化。从以上的结果可以看出,PHP内核在语法分析过程中实现了ticks机制。

从实现过程来说定义ticks机制分为两个过程:一个是定义是否需要执行ticks或者说声明ticks机制,另一个实现在声明了ticks机制的情况下控制语句的执行。

声明ticks机制过程

声明的过程就是调用declare(ticks = N); 在语法分析时根据declare关键字和参数中的ticks关键字来声明ticks机制。通过zend_compile.c文件中的zend_do_declare_begin、declare_statement、zend_do_declare_end三个函数来编译声明ticks机制。在declare_statement函数中我们可以看到:declare除了可以声明ticks之外,还可以声明encoding,这在代码里面就是一个if else的判断。

ticks机制的声明仅在编译过程有用,它为后面的声明控制语句服务。其编译过程中的全局变量为:CG(declarables)。这是一个结构体,它仅有一个成员:ticks。当然后面应该还会有更多的成员出现。

声明控制语句

示例1和示例2已经充分说明在每条语句的语法分析时,会根据是否声明了ticks机制来添加TICKS中间代码,其实现在于每条语句在语法解析时都会添加一条函数调用:zend_do_ticks。从zend_language_parser.y文件中可以看出:zend_do_ticks函数添加在类定义语句,函数定义语句和常规语句的后面。 zend_compile.c文件中的zend_do_ticks函数会根据前面提到的 CG(declarables).ticks 来判断是否生成 ZEND_TICKS 中间代码(在VLD中看到的中间代码都是没有ZEND开头)。

除了声明ticks机制,还有执行。执行过程中关键的变量是在声明时的ticks=N。其实这里的N可以换个角度去理解:ticks指定的数字是指执行了多少次TICKS语句。在TICKS中间代码的执行函数ZEND_TICKS_SPEC_CONST_HANDLER中,会统计执行当前函数的次数,存储变量为EG(ticks_count)。当达到当初声明的界限,就会调用一次所有通过register_tick_function注册的函数,并计数清零。

与当初自己设想的实现相比,PHP内核对ticks机制的实现满足了功能单一原则和松耦合原则。将ticks机制作为一个中间代码添加到整个中间代码的执行体系中,包括状态的转移,函数的切换这些都是直接使用原有的机制。

ticks机制的应用场景

手册上说:Ticks 很适合用来做调试,以及实现简单的多任务,后台 I/O 和很多其它任务。

在调试过程中,对于定位一段特定代码中速度慢的语句比较有用,我们可以每执行两条低级语句就记录一次时间。虽然这个过程也可以用其它方法完成,但用 tick 更方便也更容易实现。

PCNTL也使用ticks机制来作为信号处理机制(signal handle callback mechanism),可以最小程度地降低处理异步事件时的负载。这里的关键在于PCNTL扩展的模块初始化函数(PHP_MINIT_FUNCTION(pcntl))。在此模块做模块初始化时,它会调用: php_add_tick_function(pcntl_signal_dispatch);将pcntl的分发执行函数添加到ticks机制的调用函数中去,从而当ticks触发时就会调用PCNTL扩展函数中指定的所有方法。

PHP脚本运行超时管理机制

PHP脚本运行超时管理机制

在我们平常的开发中,也许曾经都遇到过PHP脚本运行超时的情况,此时PHP会显示错误说: “Fatal error: Maximum execution time of XXX seconds exceeded in XXX”,并终止脚本的运行。当遇到这种情况我们经常会通过使用 set_time_limit(非安全模式),或修改配置文件并重启服务器,或者修改程序减少程序的执行时间,使其在允许的范围之内,以解决此问题。但是,这些都是在应用层上我们可以看到的的表象,在PHP内核中有一套这样的机制支撑这样一个表象。

这是PHP为防止某些业务脚本长时间执行而阻塞其它脚本的处理或耗尽服务器资源,从而实现的脚本运行的超时管理机制。其本质上是PHP通过针对不同的平台实现定时器,依赖运行时的超时全局变量(EG(timeout_seconds))管理并控制定时器的运行。所有对脚本运行时长的管理,包括接口函数和配置文件对于最大运行时长的配置,最终都是通过管理超时全局变量并重启定时器来实现的。

初始化和超时配置项

在PHP内核的核心层文件/main/main.c文件中,定义了PHP的核心配置项以及每个配置项对应的on_modify方法。在模块初始化(php_module_startup)时,PHP内核会调用ini配置的注册函数,将定义的核心配置项添加到ini配置的指令集中,并且会调用每个配置项对应的on_modify方法。

用于定义脚本运行最长时间的max_execution_time配置项也是这些核心配置项的一员,它的默认值为30秒,对应的on_modify方法是OnUpdateTimeout。当注册这些核心配置项时,max_execution_time的on_modify方法将被调用,此时配置项的值将传递给超时全局变量:EG(timeout_seconds),并通过zend_set_timeout方法启动定时器。

针对WIN平台和类unix平台,PHP内核实现了不同的定时器。 Win32平台的定时器是在WM_TIME的基础上封装了一个计时器。通过创建一个独立线程控制计时器,并创建一个消息环,WaitForSingleObject用来阻塞zend_init_timeout_thread 返回。当接收到WM_REGISTER_ZEND_TIMEOUT时开始计时,实际上此时计时的任务是SetTimer(timeout_window, wParam, lParam*1000, NULL); 系统会在 seconds * 1000 后发个 WM_TIMER,这个时候就结束计时,中间可以被 WM_UNREGISTER_ZEND_TIMEOUT 打断。

类unix平台使用Linux的API函数setitimer,指定SIGPROF信号为超时处理信号,对应超时处理函数zend_timeout,当发生超时时,会发送此信号并触发函数zend_timeout显示错误信息并中止程序。

如果需要取消定时器,Win平台通过PostThreadMessage发送WM_UNREGISTER_ZEND_TIMEOUT给线程即可,类unix平台会重置定时器的时长为0。

超时管理

超时机制的管理非常灵活,有三种修改运行时长的方法。

1、 修改配置项。默认情况下PHP脚本的最长运行时长为30s。如果需要调整此项,可以通过修改php.ini文件中的max_execution_time项并重启动服务器达到修改最长运行时长的目的。此种方法适用于最开始的默认配置修改,或在其它方法无效的情况下使用。

2、 使用set_time_limit接口函数。此函数的作用是设置脚本最大执行时间。当此函数被调用时,set_time_limit()会从零开始重新启动超时计数器。比如,每一次设置是5秒,待脚本运行4秒后,脚本中又设置了5秒,那么,脚本在超时之前可运行总共时间为10秒。如下脚本示例:

    <?php
    set_time_limit(5);
    for ($i = 0; $i < 4; $i++) {
        sleep(1);
        echo $i, "<br />";
    }
 
    set_time_limit(5);
 
    for ($i = 0; $i < 4; $i++) {
        sleep(1);
        echo $i, "<br />";
    }

如上的代码,程序会执行完两个循环,都输出0,1,2,3。如果我们注释掉中间的set_time_limit(5),程序再运行一次,此时就会在第二个循环输出0后报错。

在安全模式下,无法通过set_time_limit和ini_set重新设置max_execution_time,只有关闭安全模式或改变php.ini中的时间限制才能达到修改此参数的目的。

3、 通过ini_set修改max_execution_time参数。

以上的三种方法,其实现过程基本类似,前一种是在初始化时调用on_modify指针函数。后两种在处理了参数后,调用zend_alter_ini_entry_ex函数,触发on_modify函数。于是,管理超时机制的所有操作最终都汇集到OnUpdateTimeout函数。在此函数中,通过zend_set_timeout重新设置脚本的超时时间。

细读PHP的生命周期

在《Extending and Embedding PHP》中,有一张经典的描述PHP单进程生命周期的图,一直也是按这个图理解其生命周期的,可是当准备一次内核分享时,却表现自己没有什么可以说的,于是就有了今天的这篇文章:细读PHP的生命周期。这里,我们会详细说明在CLI模式下PHP一个生命周期中做了哪些事情。

启动

在调用每个模块的模块初始化前,会有一个初始化的过程,它包括:

  • 初始化若干全局变量

这里的初始化全局变量大多数情况下是将其设置为NULL,有一些除外,比如设置zuf(zend_utility_functions),以zuf.printf_function = php_printf为例,这里的php_printf在zend_startup函数中会被赋值给zend_printf作为全局函数指针使用,而zend_printf函数通常会作为常规字符串输出使用,比如显示程序调用栈的debug_print_backtrace就是使用它打印相关信息。

  • 初始化若干常量

这里的常量是PHP自己的一些常量,这些常量要么是硬编码在程序中,比如PHP_VERSION,要么是写在配置头文件中,比如PEAR_EXTENSION_DIR,这些是写在config.w32.h文件中。

  • 初始化ZEND引擎和核心组件

前面提到的zend_startup函数的作用就是初始化ZEND引擎,这里的初始化操作包括内存管理初始化、全局使用的函数指针初始化(如前面所说的zend_printf等),对PHP源文件进行词法分析、语法分析、中间代码执行的函数指针的赋值,初始化若干HashTable(比如函数表,常量表等等),为ini文件解析做准备,为PHP源文件解析做准备,注册内置函数(如strlen、define等),注册标准常量(如E_ALL、TRUE、NULL等)、注册GLOBALS全局变量等。

  • 解析php.ini

php_init_config函数的作用是读取php.ini文件,设置配置参数,加载zend扩展并注册PHP扩展函数。此函数分为如下几步:初始化参数配置表,调用当前模式下的ini初始化配置,比如CLI模式下,会做如下初始化:

INI_DEFAULT("report_zend_debug", "0");
INI_DEFAULT("display_errors", "1");

不过在其它模式下却没有这样的初始化操作。接下来会的各种操作都是查找ini文件:

  1. 判断是否有php_ini_path_override,在CLI模式下可以通过-c参数指定此路径(在php的命令参数中-c表示在指定的路径中查找ini文件)。
  2. 如果没有php_ini_path_override,判断php_ini_ignore是否为非空(忽略php.ini配置,这里也就CLI模式下有用,使用-n参数)。
  3. 如果不忽略ini配置,则开始处理php_ini_search_path(查找ini文件的路径),这些路径包括CWD(当前路径,不过这种不适用CLI模式)、执行脚本所在目录、环境变量PATH和PHPRC和配置文件中的PHP_CONFIG_FILE_PATH的值。
  4. 在准备完查找路径后,PHP会判断现在的ini路径(php_ini_file_name)是否为文件和是否可打开。如果这里ini路径是文件并且可打开,则会使用此文件, 也就是CLI模式下通过-c参数指定的ini文件的优先级是最高的,其次是PHPRC指定的文件,第三是在搜索路径中查找php-%sapi-module-name%.ini文件(如CLI模式下应该是查找php-cli.ini文件),最后才是搜索路径中查找php.ini文件。
  • 全局操作函数的初始化

php_startup_auto_globals函数会初始化在用户空间所使用频率很高的一些全局变量,如:$_GET、$_POST、$_FILES等。这里只是初始化,所调用的zend_register_auto_global函数也只是将这些变量名添加到CG(auto_globals)这个变量表。

php_startup_sapi_content_types函数用来初始化SAPI对于不同类型内容的处理函数,这里的处理函数包括POST数据默认处理函数、默认数据处理函数等。

  • 初始化静态构建的模块和共享模块(MINIT)

php_register_internal_extensions_func函数用来注册静态构建的模块,也就是默认加载的模块,我们可以将其认为为内置模块。在PHP5.3.0版本中内置的模块包括PHP标准扩展模块(/ext/standard/目录,这里是我们用的最频繁的函数,比如字符串函数,数学函数,数组操作函数等等),日历扩展模块、FTP扩展模块、 session扩展模块等。这些内置模块并不是一成不变的,在不同的PHP模板中,由于不同时间的需求或其它影响因素会导致这些默认加载的模块会变化,比如从代码中我们就可以看到mysql、xml等扩展模块曾经或将来会作为内置模块出现。

模块初始化会执行两个操作: 1. 将这些模块注册到已注册模块列表(module_registry),如果注册的模块已经注册过了,PHP会报Module XXX already loaded的错误。 1. 将每个模块中包含的函数注册到函数表( CG(function_table) ),如果函数无法添加,则会报 Unable to register functions, unable to load。

在注册了静态构建的模块后,PHP会注册附加的模块,不同的模式下可以加载不同的模块集,比如在CLI模式下是没有这些附加的模块的。

在内置模块和附加模块后,接下来是注册通过共享对象(比如DLL)和php.ini文件灵活配置的扩展。

在所有的模块都注册后,PHP会马上执行模块初始化操作(zend_startup_modules)。它的整个过程就是依次遍历每个模块,调用每个模块的模块初始化函数,也就是在本小节前面所说的用宏PHP_MINIT_FUNCTION包含的内容。

  • 禁用函数和类

php_disable_functions函数用来禁用PHP的一些函数。这些被禁用的函数来自PHP的配置文件的disable_functions变量。其禁用的过程是调用zend_disable_function函数将指定的函数名从CG(function_table)函数表中删除。

php_disable_classes函数用来禁用PHP的一些类。这些被禁用的类来自PHP的配置文件的disable_classes变量。其禁用的过程是调用zend_disable_class函数将指定的类名从CG(class_table)类表中删除。

ACTIVATION

在处理了文件相关的内容,PHP会调用php_request_startup做请求初始化操作。请求初始化操作,除了图中显示的调用每个模块的请求初始化函数外,还做了较多的其它工作,其主要内容如下:

  • 激活ZEND引擎

gc_reset函数用来重置垃圾收集机制,当然这是在PHP5.3之后才有的。

init_compiler函数用来初始化编译器,比如将编译过程中在放opcode的数组清空,准备编译时用来的数据结构等等。

init_executor函数用来初始化中间代码执行过程。在编译过程中,函数列表、类列表等都存放在编译时的全局变量中,在准备执行过程时,会将这些列表赋值给执行的全局变量中,如:EG(function_table) = CG(function_table); 中间代码执行是在PHP的执行虚拟栈中,初始化时这些栈等都会一起被初始化。除了栈,还有存放变量的符号表(EG(symbol_table))会被初始化为50个元素的hashtable,存放对象的EG(objects_store)被初始化了1024个元素。 PHP的执行环境除了上面的一些变量外,还有错误处理,异常处理等等,这些都是在这里被初始化的。通过php.ini配置的zend_extensions也是在这里被遍历调用activate函数。

  • 激活SAPI

sapi_activate函数用来初始化SG(sapi_headers)和SG(request_info),并且针对HTTP请求的方法设置一些内容,比如当请求方法为HEAD时,设置SG(request_info).headers_only=1;此函数最重要的一个操作是处理请求的数据,其最终都会调用sapi_module.default_post_reader。而sapi_module.default_post_reader在前面的模块初始化是通过php_startup_sapi_content_types函数注册了默认处理函数为main/php_content_types.c文件中php_default_post_reader函数。此函数会将POST的原始数据写入$HTTP_RAW_POST_DATA变量。

在处理了post数据后,PHP会通过sapi_module.read_cookies读取cookie的值,在CLI模式下,此函数的实现为sapi_cli_read_cookies,而在函数体中却只有一个return NULL;

如果当前模式下有设置activate函数,则运行此函数,激活SAPI,在CLI模式下此函数指针被设置为NULL。

  • 环境初始化

这里的环境初始化是指在用户空间中需要用到的一些环境变量初始化,这里的环境包括服务器环境、请求数据环境等。实际到我们用到的变量,就是$_POST、$_GET、$_COOKIE、$_SERVER、$_ENV、$_FILES。和sapi_module.default_post_reader一样,sapi_module.treat_data的值也是在模块初始化时,通过php_startup_sapi_content_types函数注册了默认数据处理函数为main/php_variables.c文件中php_default_treat_data函数。

以$_COOKIE为例,php_default_treat_data函数会对依据分隔符,将所有的cookie拆分并赋值给对应的变量。

  • 模块请求初始化

PHP通过zend_activate_modules函数实现模块的请求初始化,也就是我们在图中看到Call each extension’s RINIT。此函数通过遍历注册在module_registry变量中的所有模块,调用其RINIT方法实现模块的请求初始化操作。

运行

php_execute_script函数包含了运行PHP脚本的全部过程。

当一个PHP文件需要解析执行时,它可能会需要执行三个文件,其中包括一个前置执行文件、当前需要执行的主文件和一个后置执行文件。非当前的两个文件可以在php.ini文件通过auto_prepend_file参数和auto_append_file参数设置。如果将这两个参数设置为空,则禁用对应的执行文件。

对于需要解析执行的文件,通过zend_compile_file(compile_file函数)做词法分析、语法分析和中间代码生成操作,返回此文件的所有中间代码。如果解析的文件有生成有效的中间代码,则调用zend_execute(execute函数)执行中间代码。如果在执行过程中出现异常并且用户有定义对这些异常的处理,则调用这些异常处理函数。在所有的操作都处理完后,PHP通过EG(return_value_ptr_ptr)返回结果。

DEACTIVATION

PHP关闭请求的过程是一个若干个关闭操作的集合,这个集合存在于php_request_shutdown函数中。这个集合包括如下内容:

  1. 调用所有通过register_shutdown_function()注册的函数。这些在关闭时调用的函数是在用户空间添加进来的。一个简单的例子,我们可以在脚本出错时调用一个统一的函数,给用户一个友好一些的页面,这个有点类似于网页中的404页面。
  2. 执行所有可用的__destruct函数。这里的析构函数包括在对象池(EG(objects_store)中的所有对象的析构函数以及EG(symbol_table)中各个元素的析构方法。
  3. 将所有的输出刷出去。
  4. 发送HTTP应答头。这也是一个输出字符串的过程,只是这个字符串可能符合某些规范。
  5. 遍历每个模块的关闭请求方法,执行模块的请求关闭操作,这就是我们在图中看到的Call each extension’s RSHUTDOWN。
  6. 销毁全局变量表(PG(http_globals))的变量。
  7. 通过zend_deactivate函数,关闭词法分析器、语法分析器和中间代码执行器。
  8. 调用每个扩展的post-RSHUTDOWN函数。只是基本每个扩展的post_deactivate_func函数指针都是NULL。
  9. 关闭SAPI,通过sapi_deactivate销毁SG(sapi_headers)、SG(request_info)等的内容。
  10. 关闭流的包装器、关闭流的过滤器。
  11. 关闭内存管理。
  12. 重新设置最大执行时间

结束

最终到了要收尾的地方了。

  • flush

sapi_flush将最后的内容刷新出去。其调用的是sapi_module.flush,在CLI模式下等价于fflush函数。

  • 关闭ZEND引擎

zend_shutdown将关闭ZEND引擎。

此时对应图中的流程,我们应该是执行每个模块的关闭模块操作。在这里只有一个zend_hash_graceful_reverse_destroy函数将module_registry销毁了。当然,它最终也是调用了关闭模块的方法的,其根源在于在初始化module_registry时就设置了这个hash表析构时调用ZEND_MODULE_DTOR宏。而ZEND_MODULE_DTOR宏对应的是module_destructor函数。在此函数中会调用模块的module_shutdown_func方法,即PHP_RSHUTDOWN_FUNCTION宏产生的那个函数。

在关闭所有的模块后,PHP继续销毁全局函数表,销毁全局类表、销售全局变量表等。通过zend_shutdown_extensions遍历zend_extensions所有元素,调用每个扩展的shutdown函数。

PS: 最近有同学问到TIPI项目的进度问题,主编说:在七月份会有一次版本发布,更多的内容可以查看项目的github。