一起线上事故引发的对PHP超时控制的思考
最后更新于:2022-04-02 05:19:55
# 一起线上事故引发的对PHP超时控制的思考
几周以前我们的一个线上服务nginx请求日志里突然出现大量499、500、502的错误,于此同时发现php-fpm的worker进程不断的退出,新启动的worker几乎过几十秒就死掉了,在php-fpm.log里发现如下错误:
```
[28-Dec-2016 23:21:02] WARNING: [pool www] child 6528, script '/home/qinpeng/sofa/site/sofa/htdocs/test.php' (request: "GET /test.php") execution timed out (15.028107 sec), terminating
[28-Dec-2016 23:21:02] WARNING: [pool www] child 6528 exited on signal 15 (SIGTERM) after 53.265943 seconds from start
[28-Dec-2016 23:21:02] NOTICE: [pool www] child 26594 started
```
从日志里也可以看出fpm worker进程因为执行超时(超过15s)而被kill掉了。
最终经过排查确定是因为访问redis没有设置读写超时,后端redis实例挂了导致请求阻塞而引发的故障,事故造成的影响非常严重,在故障期间整个服务完全不可用。
事后一直不解为什么超时会导致fpm的退出?php-fpm.conf配置里有个:`request_terminate_timeout`,正是它导致fpm的退出,此配置项的注释中写的很清楚:如果一个request的执行时间超过request_terminate_timeout,worker进程将被killed。
此次事故引发我对PHP超时机制的进一步探究,fpm的处理方式太过暴力,那么除了`request_terminate_timeout`还有没有别的超时控制项可以避免这类问题?下面将根据PHP中几个涉及超时的配置分析内核是如何处理的。(版本:php-7.0.12)
## 1、PHP的超时配置
### 1.1 max_input_time
这个配置在php.ini中,含义是PHP解析请求数据的最大耗时,如解析GET、POST参数等,这个参数控制的PHP从解析请求到执行PHP脚本的超时,也就是从php_request_startup()到php_execute_script()之间的耗时。
此配置默认值为60s,cli模式下被强制设为-1,关于这个参数没有什么可说的,不再展开分析,下面重点分析`max_execution_time`。
### 1.2 max_execution_time
此配置也在php.ini中,也就是说它是php的配置而不是fpm的,从源码注释上看这个配置的含义是:每个PHP脚本的最长执行时间。
默认值为30s,cli模式下为0(即cli下此配置不生效)。
从字面意义上猜测这个配置控制的是整个PHP脚本的最大执行耗时,也就是超过这个值PHP就不再执行了。我们用下面的例子测试下(max_execution_time = 10s):
```
//test.php
```
`max_execution_time`配置的是10s,按照上面的猜测,浏览器请求test.php将因为超时不会有任何输出,并可能返回某个500以上的错误,我们来实际操作下(不要用cli执行):
```
curl http://127.0.0.1:8000/test.php
```
结果输出:
```
hello~
```
很遗憾,结果不是预期的那样,脚本执行的很顺利,并没有中断,难道`max_execution_time`配置对fpm无效?网上有些文章认为"如果php-fpm中设置了 request_terminate_timeout 的话,那么 max_execution_time 就不生效",事实上这是错误的,这俩值是没有任何关联的,下面我们就从内核看下`max_execution_time`具体的实现。
`max_execution_time`在`php_execute_script()`函数中使用的:
```
//main/main.c #line:2400
PHPAPI int php_execute_script(zend_file_handle *primary_file)
{
...
//注意zend_try,后面会用到
zend_try {
...
if (PG(max_input_time) != -1) { //非cli模式
...
zend_set_timeout(INI_INT("max_execution_time"), 0);
}
...
zend_execute_scripts(...);
}zend_end_try();
}
```
之前的一篇文章[《一张图看PHP框架的整体执行流程》](http://x.xiaojukeji.com/article.html?id=3906)画的一幅图已经介绍过`php_execute_script()`函数的先后调用顺序:`php_module_startup` -> `php_request_startup` -> `php_execute_script` -> `php_request_shutdown` -> `php_module_shutdown`,它是PHP脚本的具体解析、执行的入口,`max_execution_time`在这个位置设置的可以进一步确定它控制的是PHP的执行时长,我们再到`zend_set_timeout()`中看下(去除了一些windows的无关代码):
```
//Zend/zend_execute_API.c #line:1222
void zend_set_timeout(zend_long seconds, int reset_signals)
{
EG(timeout_seconds) = seconds;
...
{
struct itimerval t_r; /* timeout requested */
int signo;
if(seconds) {
t_r.it_value.tv_sec = seconds;
t_r.it_value.tv_usec = t_r.it_interval.tv_sec = t_r.it_interval.tv_usec = 0;
setitimer(ITIMER_PROF, &t_r, NULL); //设定一个定时器,seconds秒后触发,到达时间后将发出ITIMER_PROF信号
}
signo = SIGPROF;
if (reset_signals) {
# ifdef ZEND_SIGNALS
zend_signal(signo, zend_timeout);
# else
sigset_t sigset;
signal(signo, zend_timeout); //设置信号处理函数,这个例子中就是设置ITIMER_PROF信号由zend_timeout()处理
sigemptyset(&sigset);
sigaddset(&sigset, signo);
sigprocmask(SIG_UNBLOCK, &sigset, NULL);
# endif
}
}
}
```
如果你用过C语言里面的定时器看到这里应该明白`max_execution_time`的含义了吧?`zend_set_timeout`设定了一个间隔定时器(itimer),类型为`ITIMER_PROF`,问题就出在这,这个类型计算的程序在用户态、内核态下的`执行`时长,下面简单介绍下linux几种不同类型的定时器。
#### a. 间隔定时器itimer
间隔定时器设定的接口setitimer定义如下,setitimer()为Linux的API,并非C语言的Standard Library,setitimer()有两个功能,一是指定一段时间后,才执行某个function,二是每间格一段时间就执行某个function。
```
int setitimer(int which, const struct itimerval *value, struct itimerval *ovalue));
struct itimerval {
struct timeval it_interval; //it_value时间后每隔it_interval执行
struct timeval it_value; //it_value时间后将开始执行
};
struct timeval {
long tv_sec;
long tv_usec;
};
```
which为定时器类型:
* __ITIMER_REAL__ : 以__系统真实时间__来计算,它送出SIGALRM信号
* __ITIMER_VIRTUAL__ : 以该进程在__用户态__下花费的时间来计算,它送出SIGVTALRM信号
* __ITIMER_PROF__ : 以该进程在__用户态__下和__内核态__下所费的时间来计算,它送出SIGPROF信号
it_interval指定间隔时间,it_value指定初始定时时间。如果只指定it_value,就是实现一次定时;如果同时指定 it_interval,则超时后,系统会重新初始化it_value为it_interval,实现重复定时;两者都清零,则会清除定时器。
#### b. 内核态、用户态
操作系统的很多操作会消耗系统的物理资源,例如创建一个新进程时,要做很多底层的细致工作,如分配物理内存,从父进程拷贝相关信息,拷贝设置页目录、页表等,这些操作显然不能随便让任何程序都可以做,于是就产生了特权级别的概念,与系统相关的一些特别关键性的操作必须由高级别的程序来完成,这样可以做到集中管理,减少有限资源的访问和使用冲突。Intel的X86架构的CPU提供了0到3四个特权级,而在我们Linux操作系统中则主要采用了0和3两个特权级,也就是我们通常所说的内核态和用户态。
每个进程都有一个4G大小的虚拟地址空间,其中0~3G为用户空间,3~4G为内核空间,每个进程都有一各用户栈、内核栈,程序从用户空间开始执行,当发生`系统调用`、`发生异常`、`外设产生中断`时就从用户空间切换到内核空间,`系统调用`都有哪些呢?可以从kernal源码中查到:linux-4.9/arch/x86/entry/syscalls/syscall_xx.tbl,比如读写文件read/write、socket等。
PHP本质就是普通的C程序,所以我们直接按照C语言程序分析就行了,内核态、用户态的区分简单讲就是如果cpu当前执行在用户栈还是内核栈上,比如程序里写的if、for、+/-等都在用户态下执行,而读写文件、请求数据库则将切换到内核态。
#### c. linux IO模式
PHP中操作最多的就是IO,比如访问数据、rpc调用等等,因此这里单独分析下IO操作引起的进程挂起。
对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间,linux系统产生了下面五种网络模式:
* 阻塞 I/O(blocking IO)
* 非阻塞 I/O(nonblocking IO)
* I/O 多路复用( IO multiplexing)
* 信号驱动 I/O( signal driven IO)
* 异步 I/O(asynchronous IO): linux下很少用
阻塞IO下当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞、休眠。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。通过ps命令我们也可出fpm等待io响应时的状态:
```
[qinpeng@kvm980199 ~]$ ps aux|grep fpm
xiaoju 26700 0.0 0.2 207812 5340 ? S Dec28 0:16 php-fpm: pool www
```
ps命令进程的状态:R 正在运行或可运行 S 可中断睡眠 (休眠中, 受阻, 在等待某个条件的形成或接受到信号)。
最后我们回到PHP,总结一下:
`ITIMER_VIRTUAL`定时器只会在`用户态`下倒计时,在内核态下将停止倒计时,`ITIMER_PROF`在两种状态下都倒计时,`ITIMER_REAL`则以系统实际时间倒计时,因为除了这两种状态,程序还有一种状态:`挂起`,也就是说`ITIMER_REAL`之外的两种定时器记录的都是进程的活跃状态,也就是cpu忙碌的状态,而读写文件、sleep、socket等操作因为等待时间发生而挂起的时间则不包括。这就是为什么上面测试脚本执行的时间比`max_execution_time`长的原因。这个时间限制的是__执行__时间,不含io阻塞、sleep等等进程挂起的时长,所以PHP脚本的实际执行时间远远大于`max_execution_time`的设定。
所以如果PHP里的定时器`setitimer`用的是`ITIMER_REAL`或者用下面的代码测试,上面的例子结果就是我们预期了。
```
```
将返回: 500 Internal Server Error。
现在可以清楚上面测试例子为什么不是预期结果的原因了,文章开始提到的故障也是因为等待redis响应而导致fpm的worker进程挂起,等待redis响应的时间并不在`ITIMER_PROF`计时内,所以即使我们配的`max_execution_time < request_terminate_timeout`,也无法因为IO阻塞的原因而命中`max_execution_time`的限制,除非类似死循环这类导致长时间占用cpu的情况。
我们接着从源码看下`max_execution_time`超时时PHP是如何中断执行、返回错误的。
`zend_set_timeout()`函数中设定的`ITIMER_PROF`定时器超时信号处理函数为`zend_timeout()`:
```
//Zend/zend_execute_API.c #line:1181
ZEND_API void zend_timeout(int dummy)
{
if (zend_on_timeout) {
...
zend_on_timeout(EG(timeout_seconds));
}
zend_error_noreturn(E_ERROR, "Maximum execution time of %pd second%s exceeded", EG(timeout_seconds), EG(timeout_seconds) == 1 ? "" : "s");
}
```
不要着急去`zend_on_timeout`里看,注意这个函数__zend_error_noreturn()__,从函数名称可以猜测它抛出了一个error错误,实际这就是将PHP中断执行的操作:
```
//Zend/zend.c
ZEND_COLD void zend_error_noreturn(int type, const char *format, ...) __attribute__ ((alias("zend_error"),noreturn));
ZEND_API ZEND_COLD void zend_error(int type, const char *format, ...)
{
...
/* if we don't have a user defined error handler */
if (Z_TYPE(EG(user_error_handler)) == IS_UNDEF
|| !(EG(user_error_handler_error_reporting) & type)
|| EG(error_handling) != EH_NORMAL) {
zend_error_cb(type, error_filename, error_lineno, format, args);
} else switch (type) {
...
}
...
}
```
`zend_error_cb`是一个函数指针,它在`php_module_startup()`中定义:
```
//main/main.c #line:2011
int php_module_startup(sapi_module_struct *sf, zend_module_entry *additional_modules, uint num_additional_modules)
{
...
zuf.error_function = php_error_cb;
...
zend_startup(&zuf, NULL);
...
}
//Zend/zend.c #line:632
int zend_startup(zend_utility_functions *utility_functions, char **extensions)
{
...
zend_error_cb = utility_functions->error_function; //即:zend_error_cb = php_error_cb
...
}
```
最终调用的是`php_error_cb()`:
```
//main/main.c #line:973
static ZEND_COLD void php_error_cb(int type, const char *error_filename, const uint error_lineno, const char *format, va_list args)
{
...
switch (type) {
...
case E_ERROR:
case E_RECOVERABLE_ERROR:
case E_PARSE:
case E_COMPILE_ERROR:
case E_USER_ERROR:
...
/* the parser would return 1 (failure), we can bail out nicely */
if (type == E_PARSE) {
CG(parse_error) = 0;
} else {
/* restore memory limit */
zend_set_memory_limit(PG(memory_limit));
efree(buffer);
zend_objects_store_mark_destructed(&EG(objects_store));
zend_bailout(); //终止执行,try-catch
return;
}
...
}
...
}
```
再展开__zend_bailout()__:
```
//zend.h
#define zend_bailout() _zend_bailout(__FILE__, __LINE__)
//zend.c #line:893
ZEND_API ZEND_COLD void _zend_bailout(char *filename, uint lineno)
{
if (!EG(bailout)) {
zend_output_debug_string(1, "%s(%d) : Bailed out without a bailout address!", filename, lineno);
exit(-1);
}
CG(unclean_shutdown) = 1;
CG(active_class_entry) = NULL;
CG(in_compilation) = 0;
EG(current_execute_data) = NULL;
LONGJMP( *EG(bailout), FAILURE);
}
//zend_portability.h
# define SETJMP(a) sigsetjmp(a, 0)
# define LONGJMP(a,b) siglongjmp(a, b)
# define JMP_BUF sigjmp_buf
```
还记得上面`php_execute_script()`中在PHP脚本执行函数外的`zend_try{...}`吗?
实际这是PHP里面实现的C语言层面的`try-catch`机制,try时利用__sigsetjmp()__将当前执行位置保存到__EG(bailout)__,中间执行抛出异常时利用__siglongjmp()__跳回到try保存的位置__EG(bailout)__,展开来看`php_execute_script`:
```
PHPAPI int php_execute_script(zend_file_handle *primary_file)
{
...
JMP_BUF *__orig_bailout = EG(bailout);
JMP_BUF __bailout;
EG(bailout) = &__bailout;
if (SETJMP(__bailout)==0) { //初次设置时值为0,当执行LONGJMP时将跳回到这个位置,且值不为0,即从if之外的操作执行
...
if (PG(max_input_time) != -1) {
...
zend_set_timeout(INI_INT("max_execution_time"), 0);
}
...
zend_execute_scripts(...); //parse -> execute
}
//zend_bailout()将接着从这里执行
EG(bailout) = __orig_bailout;
...
}
```
更多siglongjmp、sigsetjmp的说明可以自行查下,[https://github.com/pangudashu/anywork/tree/master/try_catch](https://github.com/pangudashu/anywork/tree/master/try_catch)
现在你应该清楚`max_execution_time`的实现机制及用法了吧?
最后总结一下__max_execution_time__的内核处理:PHP从执行`php_execute_script`开始活跃时间累计达到`max_execution_time`时,系统送出`SIGPROF`信号,此信号由__zend_timeout()__处理,最终内核调用__zend_bailout()__,回到开始执行的位置,结束`php_execute_script`执行,进入`php_request_shutdown`阶段。
- - -
### 1.3 request_terminate_timeout
上一节我们详细分析了PHP自身`max_execution_time`的实现原理,这一节我们再简单看下fpm退出主因:`request_terminate_timeout`。
这个配置属于php-fpm,注释写的是:一个request执行的最长时间,超过这个时间worker进程将被killed。
php-fpm是多进程模型,与nginx类似,master负责管理worker进程,worker为进程阻塞模型,每个worker同一时刻只能处理一个请求。master与worker之间可以进行通信,master可以启动、杀掉worker。
这里不再对fpm详细说明,只简单看下`request_terminate_timeout`的处理:
```
//fpm_process_ctl.c
void fpm_pctl_heartbeat(struct fpm_event_s *ev, short which, void *arg)
{
...
if (which == FPM_EV_TIMEOUT) {
fpm_clock_get(&now);
fpm_pctl_check_request_timeout(&now);
return;
}
...
fpm_event_set_timer(&heartbeat, FPM_EV_PERSIST, &fpm_pctl_heartbeat, NULL);
fpm_event_add(&heartbeat, fpm_globals.heartbeat);
}
static void fpm_pctl_check_request_timeout(struct timeval *now)
{
...
int terminate_timeout = wp->config->request_terminate_timeout; //php-fpm.conf中的request_terminate_timeout配置
int slowlog_timeout = wp->config->request_slowlog_timeout;
...
fpm_request_check_timed_out(child, now, terminate_timeout, slowlog_timeout);
...
}
```
再看下`fpm_request_check_timed_out`:
```
//fpm_request.c
void fpm_request_check_timed_out(struct fpm_child_s *child, struct timeval *now, int terminate_timeout, int slowlog_timeout)
{
...
if (terminate_timeout && tv.tv_sec >= terminate_timeout) {
...
fpm_pctl_kill(child->pid, FPM_PCTL_TERM); //kill worker
zlog(...);
}
}
```
可以看到,master如果发现worker处理一个request时间超过了`request_terminate_timeout`将发送TERM信号给worker,直接导致worker退出,而这个时间是从worker接到请求开始计时的,是系统时间。
----
## 2、优化思路
上面分析了`request_terminate_timeout`及`max_execution_time`,两者在PHP脚本执行超时的控制上都有一些欠缺,首先fpm的处理,虽然直接kill调进程是最简单的方式,但对于业务而言成本太高,个别接口超时严重这种处理方式将直接导致所有的worker进程处于不断的重启状态,每一个进程只处理一个请求就被干掉了;另外`max_execution_time`的限制实际没有太大意义。
当然业务层面的优化才是根本解决之道,这里说的只是最后的一层防护,避免因为代码的疏漏导致业务雪崩,出现问题的时候尽量减小影响、尽快定位出现问题的地方。
最容易想到的优化就是将上面提到的超时定时器类型改为:`ITIMER_REAL`,关于这个方案我用PHP扩展实现了一个,通过callback回调机制控制一个函数的执行时间,有兴趣的具体可以翻下代码:[https://github.com/pangudashu/timeout](https://github.com/pangudashu/timeout),因为同一种定时器,linux下每个进程同一时刻只支持一个,所以目前不支持嵌套调用,可以适当修改支持多定时器。
';
defer推迟函数调用语法的实现
最后更新于:2022-04-02 05:19:53
# 附录2:defer推迟函数调用语法的实现
使用过Go语言的应该都知道defer这个语法,它用来推迟一个函数的执行,在函数执行返回前首先检查当前函数内是否有推迟执行的函数,如果有则执行,然后再返回。defer是一个非常有用的语法,这个功能可以很方便的在函数结束前执行一些清理工作,比如关闭打开的文件、关闭连接、释放资源、解锁等等。这样延迟一个函数有以下两个好处:
* (1) 靠近使用位置,避免漏掉清理工作,同时比放在函数结尾要清晰
* (2) 如果有多处返回的地方可以避免代码重复,比如函数中有很多处return
在一个函数中可以使用多个defer,其执行顺序与栈类似:后进先出,先定义的defer后执行。另外,在返回之后定义的defer将不会被执行,只有返回前定义的才会执行,通过exit退出程序的情况也不会执行任何defer。
在PHP中并没有实现类似的语法,本节我们将尝试在PHP中实现类似Go语言中defer的功能。此功能的实现需要对PHP的语法解析、抽象语法树/opcode的编译、opcode指令的执行等环节进行改造,涉及的地方比较多,但是改动点比较简单,可以很好的帮助大家完整的理解PHP编译、执行两个核心阶段的实现。总体实现思路:
* __(1)语法解析:__ defer本质上还是函数调用,只是将调用时机移到了函数的最后,所以编译时可以复用调用函数的规则,但是需要与普通的调用区分开,所以我们新增一个AST节点类型,其子节点为为正常函数调用编译的AST,语法我们定义为:`defer function_name()`;
* __(2)opcode编译:__ 编译opcode时也复用调用函数的编译逻辑,不同的地方在于把defer放在最后编译,另外需要在编译return前新增一条opcode,用于执行return前跳转到defer开始的位置,在defer的最后也需要新增一条opcode,用于执行完defer后跳回return的位置;
* __(3)执行阶段:__ 执行时如果发现是return前新增的opcode则跳转到defer开始的位置,同时把return的位置记录下来,执行完defer后再跳回return。
编译后的opcode指令如下图所示:
![](../img/defer.png)
接下来我们详细介绍下各个环节的改动,一步步实现defer功能。
__(1)语法解析__
想让PHP支持`defer function_name()`的语法首先需要修改的是词法解析规则,将"defer"关键词解析为token:T_DEFER,这样词法扫描器在匹配token时遇到"defer"将告诉语法解析器这是一个T_DEFER。这一步改动比较简单,PHP的词法解析规则定义在zend_language_scanner.l中,加入以下代码即可:
```c
"defer" {
RETURN_TOKEN(T_DEFER);
}
```
完成词法解析规则的修改后接着需要定义语法解析规则,这是非常关键的一步,语法解析器会根据配置的语法规则将PHP代码解析为抽象语法树(AST)。普通函数调用会被解析为ZEND_AST_CALL类型的AST节点,我们新增一种节点类型:ZEND_AST_DEFER_CALL,抽象语法树的节点类型为enum,定义在zend_ast.h中,同时此节点只需要一个子节点,这个子节点用于保存ZEND_AST_CALL节点,因此zend_ast.h的修改如下:
```c
enum _zend_ast_kind {
...
/* 1 child node */
...
ZEND_AST_DEFER_CALL
....
}
```
定义完AST节点后就可以在配置语法解析规则了,把defer语法解析为ZEND_AST_DEFER_CALL节点,我们把这条语法规则定义在"statement:"节点下,if、echo、for等语法都定义在此节点下,语法解析规则文件为zend_language_parser.y:
```c
statement:
'{' inner_statement_list '}' { $$ = $2; }
...
| T_DEFER function_call ';' { $$ = zend_ast_create(ZEND_AST_DEFER_CALL, $2); }
;
```
修改完这两个文件后需要分别调用re2c、yacc生成对应的C文件,具体的生成命令可以在Makefile.frag中看到:
```sh
$ re2c --no-generation-date --case-inverted -cbdFt Zend/zend_language_scanner_defs.h -oZend/zend_language_scanner.c Zend/zend_language_scanner.l
$ yacc -p zend -v -d Zend/zend_language_parser.y -oZend/zend_language_parser.c
```
执行完以后将在Zend目录下重新生成zend_language_scanner.c、zend_language_parser.c两个文件。到这一步已经完成生成抽象语法树的工作了,重新编译PHP后已经能够解析defer语法了,将会生成以下节点:
![](../img/defer_ast.png)
__(2)编译ZEND_AST_DEFER_CALL__
生成抽象语法树后接下来就是编译生成opcodes的操作,即从AST->Opcodes。编译ZEND_AST_DEFER_CALL节点时不能立即进行编译,需要等到当前脚本或函数全部编译完以后再进行编译,所以在编译过程需要把ZEND_AST_DEFER_CALL节点先缓存下来,参考循环结构编译时生成的zend_brk_cont_element的存储位置,我们也把ZEND_AST_DEFER_CALL节点保存在zend_op_array中,通过数组进行存储,将ZEND_AST_DEFER_CALL节点依次存入该数组,zend_op_array中加入以下几个成员:
* __last_defer:__ 整形,记录当前编译的defer数
* __defer_start_op:__ 整形,用于记录defer编译生成opcode指令的起始位置
* __defer_call_array:__ 保存ZEND_AST_DEFER_CALL节点的数组,用于保存ast节点的地址
```c
struct _zend_op_array {
...
int last_defer;
uint32_t defer_start_op;
zend_ast **defer_call_array;
}
```
修改完数据结构后接着对应修改zend_op_array初始化的过程:
```c
//zend_opcode.c
void init_op_array(zend_op_array *op_array, zend_uchar type, int initial_ops_size)
{
...
op_array->last_defer = 0;
op_array->defer_start_op = 0;
op_array->defer_call_array = NULL;
...
}
```
完成依赖的这些数据结构的改造后接下来开始编写具体的编译逻辑,也就是编译ZEND_AST_DEFER_CALL的处理。抽象语法树的编译入口函数为zend_compile_top_stmt(),然后根据不同节点的类型进行相应的编译,我们在zend_compile_stmt()函数中对ZEND_AST_DEFER_CALL节点进行编译:
```c
void zend_compile_stmt(zend_ast *ast)
{
...
switch (ast->kind) {
...
case ZEND_AST_DEFER_CALL:
zend_compile_defer_call(ast);
break
...
}
}
```
编译过程只是将ZEND_AST_DEFER_CALL的子节点(即:ZEND_AST_CALL)保存到zend_op_array->defer_call_array数组中,注意这里defer_call_array数组还没有分配内存,参考循环结构的实现,这里我们定义了一个函数用于数组的分配:
```c
//zend_compile.c
void zend_compile_defer_call(zend_ast *ast)
{
if(!ast){
return;
}
zend_ast **call_ast = NULL;
//将普通函数调用的ast节点保存到defer_call_array数组中
call_ast = get_next_defer_call(CG(active_op_array));
*call_ast = ast->child[0];
}
//zend_opcode.c
zend_ast **get_next_defer_call(zend_op_array *op_array)
{
op_array->last_defer++;
op_array->defer_call_array = erealloc(op_array->defer_call_array, sizeof(zend_ast*)*op_array->last_defer);
return &op_array->defer_call_array[op_array->last_defer-1];
}
```
既然分配了defer_call_array数组的内存就需要在zend_op_array销毁时释放:
```c
//zend_opcode.c
ZEND_API void destroy_op_array(zend_op_array *op_array)
{
...
if (op_array->defer_call_array) {
efree(op_array->defer_call_array);
}
...
}
```
编译完整个脚本或函数后,最后还会编译一条ZEND_RETURN,也就是返回指令,相当于ret指令,注意:这条opcode并不是我们在脚本中定义的return语句的,而是PHP内核为我们加的一条指令,这就是为什么有些函数我们没有写return也能返回的原因,任何函数或脚本都会生成这样一条指令。我们缓存在zend_op_array->defer_call_array数组中defer就是要在这时进行编译,也就是把defer的指令编译在最后。内核最后编译返回的这条指令由zend_emit_final_return()方法完成,我们把defer的编译放在此方法的末尾:
```c
//zend_compile.c
void zend_emit_final_return(zval *zv)
{
...
ret = zend_emit_op(NULL, returns_reference ? ZEND_RETURN_BY_REF : ZEND_RETURN, &zn, NULL);
ret->extended_value = -1;
//编译推迟执行的函数调用
zend_emit_defer_call();
}
```
前面已经说过,defer本质上就是函数调用,所以编译的过程直接复用普通函数调用的即可。另外,在编译时把起始位置记录到zend_op_array->defer_start_op中,因为在执行return前需要知道跳转到什么位置,这个值就是在那时使用的,具体的用法稍后再作说明。编译时按照倒序的顺序进行编译:
```c
//zend_compile.c
void zend_emit_defer_call()
{
if (!CG(active_op_array)->defer_call_array) {
return;
}
zend_ast *call_ast;
zend_op *nop;
znode result;
uint32_t opnum = get_next_op_number(CG(active_op_array));
int defer_num = CG(active_op_array)->last_defer;
//记录推迟的函数调用指令开始位置
CG(active_op_array)->defer_start_op = opnum;
while(--defer_num >= 0){
call_ast = CG(active_op_array)->defer_call_array[defer_num];
if (call_ast == NULL) {
continue;
}
nop = zend_emit_op(NULL, ZEND_NOP, NULL, NULL);
nop->op1.var = -2;
//编译函数调用
zend_compile_call(&result, call_ast, BP_VAR_R);
}
//compile ZEND_DEFER_CALL_END
zend_emit_op(NULL, ZEND_DEFER_CALL_END, NULL, NULL);
}
```
编译完推迟的函数调用之后,编译一条ZEND_DEFER_CALL_END指令,该指令用于执行完推迟的函数后跳回return的位置进行返回,opcode定义在zend_vm_opcodes.h中:
```c
//zend_vm_opcodes.h
#define ZEND_DEFER_CALL_END 174
```
还有一个地方你可能已经注意到,在逐个编译defer的函数调用前都生成了一条ZEND_NOP的指令,这个的目的是什么呢?开始的时候已经介绍过defer语法的特点,函数中定义的defer并不是全部执行,在return之后定义的defer是不会执行的,比如:
```go
func main(){
defer fmt.Println("A")
if 1 == 1{
return
}
defer fmt.Println("B")
}
```
这种情况下第2个defer就不会生效,因此在return前跳转的位置就不一定是zend_op_array->defer_start_op,有可能会跳过几个函数的调用,所以这里我们通过ZEND_NOP这条空指令对多个defer call进行隔离,同时为避免与其它ZEND_NOP指令混淆,增加一个判断条件:op1.var=-2。这样在return前跳转时就根据此前定义的defer数跳过部分函数的调用,如下图所示。
![](../img/defer_call.png)
到这一步我们已经完成defer函数调用的编译,此时重新编译PHP后可以看到通过defer推迟的函数调用已经被编译在最后了,只不过这个时候它们不能被执行。
__(3)编译return__
编译return时需要插入一条指令用于跳转到推迟执行的函数调用指令处,因此这里需要再定义一条opcode:ZEND_DEFER_CALL,在编译过程中defer call还未编译,因此此时还无法知道具体的跳转值。
```c
//zend_vm_opcodes.h
#define ZEND_DEFER_CALL 173
#define ZEND_DEFER_CALL_END 174
```
PHP脚本中声明的return语句由zend_compile_return()方法完成编译,在编译生成ZEND_DEFER_CALL指令时还需要将当前已定义的defer数(即在return前声明的defer)记录下来,用于计算具体的跳转值。
```c
void zend_compile_return(zend_ast *ast)
{
...
//在return前编译ZEND_DEFER_CALL:用于在执行retur前跳转到defer call
if (CG(active_op_array)->defer_call_array) {
defer_zn.op_type = IS_UNUSED;
defer_zn.u.op.num = CG(active_op_array)->last_defer;
zend_emit_op(NULL, ZEND_DEFER_CALL, NULL, &defer_zn);
}
//编译正常返回的指令
opline = zend_emit_op(NULL, by_ref ? ZEND_RETURN_BY_REF : ZEND_RETURN,
&expr_node, NULL);
...
}
```
除了这种return外还有一种我们上面已经提过的return,即PHP内核编译的return指令,当PHP脚本中没有声明return语句时将执行内核添加的那条指令,因此也需要在zend_emit_final_return()加上上面的逻辑。
```c
void zend_emit_final_return(zval *zv)
{
...
//在return前编译ZEND_DEFER_CALL:用于在执行retur前跳转到defer call
if (CG(active_op_array)->defer_call_array) {
//当前return之前定义的defer数
defer_zn.op_type = IS_UNUSED;
defer_zn.u.op.num = CG(active_op_array)->last_defer;
zend_emit_op(NULL, ZEND_DEFER_CALL, NULL, &defer_zn);
}
//编译返回指令
ret = zend_emit_op(NULL, returns_reference ? ZEND_RETURN_BY_REF : ZEND_RETURN, &zn, NULL);
ret->extended_value = -1;
//编译推迟执行的函数调用
zend_emit_defer_call();
}
```
__(4)计算ZEND_DEFER_CALL指令的跳转位置__
前面我们已经完成了推迟调用函数以及return编译过程的改造,在编译完成后ZEND_DEFER_CALL指令已经能够知道具体的跳转位置了,因为推迟调用的函数已经编译完成了,所以下一步就是为全部的ZEND_DEFER_CALL指令计算跳转值。前面曾介绍过,在编译完成有一个pass_two()的环节,我们就在这里完成具体跳转位置的计算,并把跳转位置保存到ZEND_DEFER_CALL指令的操作数中,在执行阶段直接跳转到对应位置。
```c
ZEND_API int pass_two(zend_op_array *op_array)
{
zend_op *opline, *end;
...
//遍历opcode
opline = op_array->opcodes;
end = opline + op_array->last;
while (opline < end) {
switch (opline->opcode) {
...
case ZEND_DEFER_CALL: //设置jmp
{
uint32_t defer_start = op_array->defer_start_op;
//skip_defer为当前return之后声明的defer数,也就是不需要执行的defer
uint32_t skip_defer = op_array->last_defer - opline->op2.num;
//defer_opline为推迟的函数调用起始位置
zend_op *defer_opline = op_array->opcodes + defer_start;
uint32_t n = 0;
while(n <= skip_defer){
if (defer_opline->opcode == ZEND_NOP && defer_opline->op1.var == -2) {
n++;
}
defer_opline++;
defer_start++;
}
//defer_start为opcode在op_array->opcodes数组中的位置
opline->op1.opline_num = defer_start;
//将跳转位置保存到操作数op1中
ZEND_PASS_TWO_UPDATE_JMP_TARGET(op_array, opline, opline->op1);
}
break;
}
...
}
...
}
```
这里我们并没有直接编译为ZEND_JMP跳转指令,虽然ZEND_JMP可以跳转到后面的指令位置,但是最后的那条跳回return位置的指令(即:ZEND_DEFER_CALL_END)由于可能存在多个return的原因无法在编译期间确定具体的跳转值,只能在运行期间执行ZEND_DEFER_CALL时才能确定,所以需要在ZEND_DEFER_CALL指令的handler中将return的位置记录下来,执行ZEND_DEFER_CALL_END时根据这个值跳回。
__(5)定义ZEND_DEFER_CALL、ZEND_DEFER_CALL_END指令的handler__
ZEND_DEFER_CALL指令执行时需要将return的位置保存下来,我们把这个值保存到zend_execute_data结构中:
```c
//zend_compile.h
struct _zend_execute_data {
...
const zend_op *return_opline;
...
}
```
opcode的handler定义在zend_vm_def.h文件中,定义完成后需要执行`php zend_vm_gen.php`脚本生成具体的handler函数。
```c
ZEND_VM_HANDLER(173, ZEND_DEFER_CALL, ANY, ANY)
{
USE_OPLINE
//1) 将return指令的位置保存到EX(return_opline)
EX(return_opline) = opline + 1;
//2) 跳转
ZEND_VM_SET_OPCODE(OP_JMP_ADDR(opline, opline->op1));
ZEND_VM_CONTINUE();
}
ZEND_VM_HANDLER(174, ZEND_DEFER_CALL_END, ANY, ANY)
{
USE_OPLINE
ZEND_VM_SET_OPCODE(EX(return_opline));
ZEND_VM_CONTINUE();
}
```
到目前为止我们已经完成了全部的修改,重新编译PHP后就可以使用defer语法了:
```php
function shutdown($a){
echo $a."\n";
}
function test(){
$a = 1234;
defer shutdown($a);
$a = 8888;
if(1){
return "mid end\n";
}
defer shutdown("9999");
return "last end\n";
}
echo test();
```
执行后将显示:
```sh
8888
mid end
```
这里我们只实现了普通函数调用的方式,关于成员方法、静态方法、匿名函数等调用方式并未实现,留给有兴趣的读者自己去实现。
完整代码:[https://github.com/pangudashu/php-7.0.12](https://github.com/pangudashu/php-7.0.12)
';
break/continue按标签中断语法实现
最后更新于:2022-04-02 05:19:50
# 附录1:break/continue按标签中断语法实现
## 1.1 背景
首先看下目前PHP中break/continue多层循环的情况:
```php
//loop1
while(...){
//loop2
for(...){
//loop3
foreach(...){
...
break 2;
}
}
//loop2 end
...
}
```
`break 2`表示要中断往上数两层也就是loop2这层循环,`break 2`之后将从loop2 end开始继续执行。PHP的break、continue只能根据数值中断对应的循环,当嵌套循环比较多的时候这种方式维护起来就变得很不方便,需要一层层的去数要中断的循环。
了解Go语言的读者应该知道在Go中可以按照标签中断,举个例子来看:
```go
//test.go
func main() {
loop1:
for i := 0; i < 2; i++ {
fmt.Println("loop1")
for j := 0; j < 5; j++ {
fmt.Println(" loop2")
if j == 2 {
break loop1
}
}
}
}
```
`go run test.go`将输出:
```
loop1
loop2
loop2
loop2
```
`break loop1`这种语法在PHP中是不支持的,接下来我们就对PHP进行改造,让PHP实现同样的功能。
## 1.2 实现
想让PHP支持类似Go语言那样的语法首先需要明确PHP中循环及中断语句的实现,关于这两部分内容前面《PHP基础语法实现》一章已经详细介绍过了,这里再简单概括下实现的关键点:
* 不管是哪种循环结构,其编译时都生成了一个`zend_brk_cont_element`结构,此结构记录着这个循环break、continue要跳转的位置,以及嵌套的父层循环
* break/continue编译时分为两个步骤:首先初步编译为临时opcode,此opcode记录着break/continue所在循环层以及要中断的层级(即:`break n`,默认n=1);然后在脚本全部编译完之后的pass_two()中,根据当前循环层及中断的层级n向上查找对应的循环层,最后根据查找到的要中断的循环`zend_brk_cont_element`结构得到对应的跳转位置,生成一条ZEND_JMP指令
仔细研究循环、中断的实现可以发现,这里面的关键就在于找到break/continue要中断的那层循环,嵌套循环之间是链表的结构,所以目前的查找就变得很容易了,直接从break/continue当前循环层向前移动n即可。
标签在内核中通过HashTable的结构保存(即:CG(context).labels),key就是标签名,标签会记录当前opcode的位置,我们要实现`break 标签`的语法需要根据标签取到循环,因此我们为标签赋予一种新的含义:循环标签,只有标签紧挨着循环的才认为是这种含义,比如:
```php
loop1:
for(...){
...
}
```
标签与循环之间有其它表达式的则只能认为是普通标签:
```php
loop1:
$a = 123;
for(...){
}
```
既然要按照标签进行break、continue,那么很容易想到把中断的循环层级id保存到标签中,编译break/continue时先查找标签,再查找循环的`zend_brk_cont_element`即可,这样实现的话需要循环编译时将自己`zend_brk_cont_element`的存储位置保存到标签中,标签的结构需要修改,另外一个问题是标签编译不会生成任何opcode,循环结构无法直接根据上一条opcode判断它是不是 ***循环标签*** ,所以我们换一种方式实现,具体思路如下:
* __(1)__ 循环结构开始编译前先编译一条空opcode(ZEND_NOP),用于标识这是一个循环,并把这个循环`zend_brk_cont_element`的存储位置记录在此opcode中
* __(2)__ break编译时如果发现是一个标签,则从CG(context).labels)中取出标签结构,然后判断此标签的下一条opcode是否为ZEND_NOP,如果不是则说明这不是一个 ***>循环标签*** ,无法break/continue,如果是则取出循环结构
* __(3)__ 得到循环结构之后的处理就比较简单了,但是此时还不能直接编译为ZEND_JMP,因为循环可能还未编译完成,break只能编译为临时opcode,这里可以把标签标记的循环存储位置记录在临时opcode中,然后在pass_two()中再重新获取,需要对pass_two()中的逻辑进行改动,为减少改动,这个地方转化一下实现方式:计算label标记的循环相对break所在循环的位置,也就是转为现有的`break n`,这样以来就无需对pass_two()进行改动了
接下来看下具体的实现,以for为例。
__(1) 编译循环语句__
```c
void zend_compile_for(zend_ast *ast)
{
zend_ast *init_ast = ast->child[0];
zend_ast *cond_ast = ast->child[1];
zend_ast *loop_ast = ast->child[2];
zend_ast *stmt_ast = ast->child[3];
znode result;
uint32_t opnum_start, opnum_jmp, opnum_loop;
zend_op *mark_look_opline;
//新增:创建一条空opcode,用于标识接下来是一个循环结构
mark_look_opline = zend_emit_op(NULL, ZEND_NOP, NULL, NULL);
zend_compile_expr_list(&result, init_ast);
zend_do_free(&result);
opnum_jmp = zend_emit_jump(0);
zend_begin_loop(ZEND_NOP, NULL);
//新增:保存当前循环的brk,同时为了防止与其它ZEND_NOP混淆,把op1标为-1
mark_look_opline->op1.var = -1;
mark_look_opline->extended_value = CG(context).current_brk_cont;
...
}
```
__(2) 编译中断语句__
首先明确一点:`break label`将被编译为以下语法结构:
![](../img/ast_break_div.png)
`ZEND_AST_BREAK`只有一个子节点,如果是数值那么这个子节点类型为`ZEND_AST_ZVAL`,如果是标签则类型是`ZEND_AST_CONST`,`ZEND_AST_CONST`也有一个类型为`ZEND_AST_ZVAL`子节点。下面看下break/continue修改后的编译逻辑:
```c
void zend_compile_break_continue(zend_ast *ast)
{
zend_ast *depth_ast = ast->child[0];
zend_op *opline;
int depth;
ZEND_ASSERT(ast->kind == ZEND_AST_BREAK || ast->kind == ZEND_AST_CONTINUE);
if (CG(context).current_brk_cont == -1) {
zend_error_noreturn(E_COMPILE_ERROR, "'%s' not in the 'loop' or 'switch' context",
ast->kind == ZEND_AST_BREAK ? "break" : "continue");
}
if (depth_ast) {
switch(depth_ast->kind){
case ZEND_AST_ZVAL: //break 数值;
{
zval *depth_zv;
depth_zv = zend_ast_get_zval(depth_ast);
if (Z_TYPE_P(depth_zv) != IS_LONG || Z_LVAL_P(depth_zv) < 1) {
zend_error_noreturn(E_COMPILE_ERROR, "'%s' operator accepts only positive numbers",
ast->kind == ZEND_AST_BREAK ? "break" : "continue");
}
depth = Z_LVAL_P(depth_zv);
break;
}
case ZEND_AST_CONST://break 标签;
{
//获取label名称
zend_string *label = zend_ast_get_str(depth_ast->child[0]);
//根据label获取标记的循环,以及相对break所在循环的位置
depth = zend_loop_get_depth_by_label(label);
if(depth > 0){
goto SET_OP;
}
break;
}
default:
zend_error_noreturn(E_COMPILE_ERROR, "'%s' operator with non-constant operand "
"is no longer supported", ast->kind == ZEND_AST_BREAK ? "break" : "continue");
}
} else {
depth = 1;
}
if (!zend_handle_loops_and_finally_ex(depth)) {
zend_error_noreturn(E_COMPILE_ERROR, "Cannot '%s' %d level%s",
ast->kind == ZEND_AST_BREAK ? "break" : "continue",
depth, depth == 1 ? "" : "s");
}
SET_OP:
opline = zend_emit_op(NULL, ast->kind == ZEND_AST_BREAK ? ZEND_BRK : ZEND_CONT, NULL, NULL);
opline->op1.num = CG(context).current_brk_cont;
opline->op2.num = depth;
}
```
`zend_loop_get_depth_by_label()`这个函数用来计算标签标记的循环相对break/continue所在循环的层级:
```c
int zend_loop_get_depth_by_label(zend_string *label_name)
{
zval *label_zv;
zend_label *label;
zend_op *next_opline;
if(UNEXPECTED(CG(context).labels == NULL)){
zend_error_noreturn(E_COMPILE_ERROR, "can't find label:'%s' or it not mark a loop", ZSTR_VAL(label_name));
}
// 1) 查找label
label_zv = zend_hash_find(CG(context).labels, label_name);
if(UNEXPECTED(label_zv == NULL)){
zend_error_noreturn(E_COMPILE_ERROR, "can't find label:'%s' or it not mark a loop", ZSTR_VAL(label_name));
}
label = (zend_label *)Z_PTR_P(label_zv);
// 2) 获取label下一条opcode
next_opline = &(CG(active_op_array)->opcodes[label->opline_num]);
if(UNEXPECTED(next_opline == NULL)){
zend_error_noreturn(E_COMPILE_ERROR, "can't find label:'%s' or it not mark a loop", ZSTR_VAL(label_name));
}
int label_brk_offset, curr_brk_offset; //标签标识的循环、break当前所在循环
int depth = 0; //break当前循环至标签循环的层级
zend_brk_cont_element *brk_cont_element;
if(next_opline->opcode == ZEND_NOP && next_opline->op1.var == -1){
label_brk_offset = next_opline->extended_value;
curr_brk_offset = CG(context).current_brk_cont;
brk_cont_element = &(CG(active_op_array)->brk_cont_array[curr_brk_offset]);
//计算标签标记的循环相对位置
while(1){
depth++;
if(label_brk_offset == curr_brk_offset){
return depth;
}
curr_brk_offset = brk_cont_element->parent;
if(curr_brk_offset < 0){
//label标识的不是break所在循环
zend_error_noreturn(E_COMPILE_ERROR, "can't break/conitnue label:'%s' because it not mark a loop", ZSTR_VAL(label_name));
}
}
}else{
//label没有标识一个循环
zend_error_noreturn(E_COMPILE_ERROR, "can't break/conitnue label:'%s' because it not mark a loop", ZSTR_VAL(label_name));
}
return -1;
}
```
改动后重新编译PHP,然后测试新的语法是否生效:
```php
//test.php
loop1:
for($i = 0; $i < 2; $i++){
echo "loop1\n";
for($j = 0; $j < 5; $j++){
echo " loop2\n";
if($j == 2){
break loop1;
}
}
}
```
`php test.php`输出:
```
loop1
loop2
loop2
loop2
```
其它几个循环结构的改动与for相同,有兴趣的可以自己去尝试下。
';
附录
最后更新于:2022-04-02 05:19:48
[break/continue按标签中断语法实现](break-continue%E6%8C%89%E6%A0%87%E7%AD%BE%E4%B8%AD%E6%96%AD%E8%AF%AD%E6%B3%95%E5%AE%9E%E7%8E%B0.md)
[defer推迟函数调用语法的实现](defer%E6%8E%A8%E8%BF%9F%E5%87%BD%E6%95%B0%E8%B0%83%E7%94%A8%E8%AF%AD%E6%B3%95%E7%9A%84%E5%AE%9E%E7%8E%B0.md)
[一起线上事故引发的对PHP超时控制的思考](%E4%B8%80%E8%B5%B7%E7%BA%BF%E4%B8%8A%E4%BA%8B%E6%95%85%E5%BC%95%E5%8F%91%E7%9A%84%E5%AF%B9PHP%E8%B6%85%E6%97%B6%E6%8E%A7%E5%88%B6%E7%9A%84%E6%80%9D%E8%80%83.md)
';
8.3.3 动态用法
最后更新于:2022-04-02 05:19:46
### 8.3.3 动态用法
前面介绍的这些命名空间的使用都是名称为CONST类型的情况,所有的处理都是在编译环节完成的,PHP是动态语言,能否动态使用命名空间呢?举个例子:
```php
$class_name = "\aa\bb\my_class";
$obj = new $class_name;
```
如果类似这样的用法只能只用完全限定名称,也就是按照实际存储的名称使用,无法进行自动名称补全。
';
8.3.2 use导入
最后更新于:2022-04-02 05:19:43
### 8.3.2 use导入
使用一个命名空间中的类、函数、常量虽然可以通过完全限定名称的形式访问,但是这种方式需要在每一处使用的地方都加上完整的namespace名称,如果将来namespace名称变更了就需要所有使用的地方都改一遍,这将是很痛苦的一件事,为此,PHP提供了一种命名空间导入/别名的机制,可以通过use关键字将一个命名空间导入或者定义一个别名,然后在使用时就可以通过导入的namespace名称最后一个域或者别名访问,不需要使用完整的名称,比如:
```php
//ns_define.php
namespace aa\bb\cc\dd;
const MY_CONST = 1234;
```
可以采用如下几种方式使用:
```php
//方式1:
include 'ns_define.php';
use aa\bb\cc\dd;
echo dd\MY_CONST;
```
```php
//方式2:
include 'ns_define.php';
use aa\bb\cc;
echo cc\dd\MY_CONST;
```
```php
//方式3:
include 'ns_define.php';
use aa\bb\cc\dd as DD;
echo DD\MY_CONST;
```
```php
//方式4:
include 'ns_define.php';
use aa\bb\cc as CC;
echo CC\dd\MY_CONST;
```
这种机制的实现原理也比较简单:编译期间如果发现use语句 ,那么就将把这个use后的命名空间名称插入一个哈希表:FC(imports),而哈希表的key就是定义的别名,如果没有定义别名则key使用按"\"分割的最后一节,比如方式2的情况将以cc作为key,即:FC(imports)["cc"] = "aa\bb\cc\dd";接下来在使用类、函数和常量时会把名称按"\"分割,然后以第一节为key查找FC(imports),如果找到了则将FC(imports)中保存的名称与使用时的名称拼接在一起,组成完整的名称。实际上这种机制是把完整的名称切割缩短然后缓存下来,使用时再拼接成完整的名称,也就是内核帮我们组装了名称,对内核而言,最终使用的都是包括完整namespace的名称。
![](../img/namespace_com.png)
use除了上面介绍的用法外还可以导入一个类,导入后再使用类就不需要加namespace了,例如:
```php
//ns_define.php
namespace aa\bb\cc\dd;
class my_class { /* ... */ }
```
```php
include 'ns_define.php';
//导入一个类
use aa\bb\cc\dd\my_class;
//直接使用
$obj = new my_class();
var_dump($obj);
```
use的这两种用法实现原理是一样的,都是在编译时通过查找FC(imports)实现的名称补全。从PHP 5.6起,use又提供了两种针对函数、常量的导入,可以通过`use function xxx`及`use const xxx`导入一个函数、常量,这种用法的实现原理与上面介绍的实际是相同,只是在编译时没有保存到FC(imports),zend_file_context结构中的另外两个哈希表就是在这种情况下使用的:
```c
typedef struct _zend_file_context {
...
//用于保存导入的类或命名空间
HashTable *imports;
//用于保存导入的函数
HashTable *imports_function;
//用于保存导入的常量
HashTable *imports_const;
} zend_file_context;
```
简单总结下use的几种不同用法:
* __a.导入命名空间:__ 导入的名称保存在FC(imports)中,编译使用的语句时搜索此符号表进行补全
* __b.导入类:__ 导入的名称保存在FC(imports)中,与a不同的时如果不会根据"\"切割后的最后一节检索,而是直接使用类名查找
* __c.导入函数:__ 通过`use function`导入到FC(imports_function),补全时先查找FC(imports_function),如果没有找到则继续按照a的情况处理
* __d.导入常量:__ 通过`use const`导入到FC(imports_const),不全是先查找FC(imports_const),如果没有找到则继续按照a的情况处理
```php
use aa\bb; //导入namespace
use aa\bb\MY_CLASS; //导入类
use function aa\bb\my_func; //导入函数
use const aa\bb\MY_CONST; //导入常量
```
接下来看下内核的具体实现,首先看下use的编译:
```c
void zend_compile_use(zend_ast *ast)
{
zend_string *current_ns = FC(current_namespace);
//use的类型
uint32_t type = ast->attr;
//根据类型获取存储哈希表:FC(imports)、FC(imports_function)、FC(imports_const)
HashTable *current_import = zend_get_import_ht(type);
...
//use可以同时导入多个
for (i = 0; i < list->children; ++i) {
zend_ast *use_ast = list->child[i];
zend_ast *old_name_ast = use_ast->child[0];
zend_ast *new_name_ast = use_ast->child[1];
//old_name为use后的namespace名称,new_name为as定义的别名
zend_string *old_name = zend_ast_get_str(old_name_ast);
zend_string *new_name, *lookup_name;
if (new_name_ast) {
//如果有as别名则直接使用
new_name = zend_string_copy(zend_ast_get_str(new_name_ast));
} else {
const char *unqualified_name;
size_t unqualified_name_len;
if (zend_get_unqualified_name(old_name, &unqualified_name, &unqualified_name_len)) {
//按"\"分割,取最后一节为new_name
new_name = zend_string_init(unqualified_name, unqualified_name_len, 0);
} else {
//名称中没有"\":use aa
new_name = zend_string_copy(old_name);
}
}
//如果是use const则大小写敏感,其它用法都转为小写
if (case_sensitive) {
lookup_name = zend_string_copy(new_name);
} else {
lookup_name = zend_string_tolower(new_name);
}
...
if (current_ns) {
//如果当前是在命名空间中则需要检查名称是否冲突
...
}
//插入FC(imports/imports_function/imports_const),key为lookup_name,value为old_name
if (!zend_hash_add_ptr(current_import, lookup_name, old_name)) {
...
}
}
}
```
从use的编译过程可以看到,编译时的主要处理是把use导入的名称以别名或最后分节为key存储到对应的哈希表中,接下来我们看下在编译使用类、函数、常量的语句时是如何处理的。使用的语法类型比较多,比如类的使用就有new、访问静态属性、调用静态方法等,但是不管什么语句都会经历获取类名、函数名、常量名这一步,类名的补全就是在这一步完成的。
__(1)补全类名__
编译时通过zend_resolve_class_name()方法进行类名补全,如果没有任何namespace那么就返回原始的类名,比如编译`new my_class()`时,首先会把"my_class"传入该函数,如果查找FC(imports)后发现是一个use导入的类则把补全后的完整名称返回,然后再进行后续的处理。
```c
zend_string *zend_resolve_class_name(zend_string *name, uint32_t type)
{
char *compound;
//"namespace\xxx\类名"这种用法表示使用当前命名空间
if (type == ZEND_NAME_RELATIVE) {
return zend_prefix_with_ns(name);
}
//完全限定的形式:new \aa\bb\my_class()
if (type == ZEND_NAME_FQ || ZSTR_VAL(name)[0] == '\\') {
if (ZSTR_VAL(name)[0] == '\\') {
name = zend_string_init(ZSTR_VAL(name) + 1, ZSTR_LEN(name) - 1, 0);
} else {
zend_string_addref(name);
}
...
return name;
}
//如果当前脚本有通过use导入namespace
if (FC(imports)) {
compound = memchr(ZSTR_VAL(name), '\\', ZSTR_LEN(name));
if (compound) {
// 1) 没有直接导入一个类的情况,用法a
//名称中包括"\",比如:new aa\bb\my_class()
size_t len = compound - ZSTR_VAL(name);
//根据按"\"分割后的最后一节为key查找FC(imports)
zend_string *import_name =
zend_hash_find_ptr_lc(FC(imports), ZSTR_VAL(name), len);
//如果找到了表示通过use导入了namespace
if (import_name) {
return zend_concat_names(
ZSTR_VAL(import_name), ZSTR_LEN(import_name), ZSTR_VAL(name) + len + 1, ZSTR_LEN(name) - len - 1);
}
} else {
// 2) 通过use导入一个类的情况,用法b
//直接根据原始类名查找
zend_string *import_name
= zend_hash_find_ptr_lc(FC(imports), ZSTR_VAL(name), ZSTR_LEN(name));
if (import_name) {
return zend_string_copy(import_name);
}
}
}
//没有使用use或没命中任何use导入的namespace,按照基本用法处理:如果当前在一个namespace下则解释为currentnamespace\my_class
return zend_prefix_with_ns(name);
}
```
此方法除了类的名称后还有一个type参数,这个参数是解析语法是根据使用方式确定的,共有三种类型:
* __ZEND_NAME_NOT_FQ:__ 非限定名称,也就是普通的类名,没有加namespace,比如:new my_class()
* __ZEND_NAME_RELATIVE:__ 相对名称,强制按照当前所属命名空间解析,使用时通过在类前加"namespace\xx",比如:new namespace\my_class(),如果当前是全局空间则等价于:new my_class,如果当前命名空间为currentnamespace,则解析为"currentnamespace\my_class"
* __ZEND_NAME_FQ:__ 完全限定名称,即以"\"开头的
__(2)补全函数名、常量名__
函数与常量名称的补全操作是相同的:
```c
//补全函数名称
zend_string *zend_resolve_function_name(zend_string *name, uint32_t type, zend_bool *is_fully_qualified)
{
return zend_resolve_non_class_name(
name, type, is_fully_qualified, 0, FC(imports_function));
}
//补全常量名称
zend_string *zend_resolve_const_name(zend_string *name, uint32_t type, zend_bool *is_fully_qualified)
return zend_resolve_non_class_name(
name, type, is_fully_qualified, 1, FC(imports_const));
}
```
可以看到函数与常量最终调用同一方法处理,不同点在于传入了各自的存储哈希表:
```c
zend_string *zend_resolve_non_class_name(
zend_string *name, uint32_t type, zend_bool *is_fully_qualified,
zend_bool case_sensitive, HashTable *current_import_sub
) {
char *compound;
*is_fully_qualified = 0;
//完整名称,直接返回,不需要补全
if (ZSTR_VAL(name)[0] == '\\') {
*is_fully_qualified = 1;
return zend_string_init(ZSTR_VAL(name) + 1, ZSTR_LEN(name) - 1, 0);
}
//与类的用法相同
if (type == ZEND_NAME_RELATIVE) {
*is_fully_qualified = 1;
return zend_prefix_with_ns(name);
}
//current_import_sub如果是函数则为FC(imports_function),否则为FC(imports_const)
if (current_import_sub) {
//查找FC(imports_function)或FC(imports_const)
...
}
//查找FC(imports)
compound = memchr(ZSTR_VAL(name), '\\', ZSTR_LEN(name));
...
return zend_prefix_with_ns(name);
}
```
可以看到,函数与常量的的补全逻辑只是优先用原始名称去FC(imports_function)或FC(imports_const)查找,如果没有找到再去FC(imports)中匹配。如果我们这样导入了一个函数:`use aa\bb\my_func;`,编译`my_func()`会在FC(imports_function)中根据"my_func"找到"aa\bb\my_func",从而使用完整的这个名称。
';
8.3.1 基本用法
最后更新于:2022-04-02 05:19:41
### 8.3.1 基本用法
上一节我们知道了定义在命名空间中的类、函数和常量只是加上了namespace名称作为前缀,既然是这样那么在使用时加上同样的前缀是否就可以了呢?答案是肯定的,比如上面那个例子:在com\aa命名空间下定义了一个常量MY_CONST,那么就可以这么使用:
```php
include 'ns_define.php';
echo \com\aa\MY_CONST;
```
这种按照实际类名、函数名、常量名使用的方式很容易理解,与普通的类型没有差别,这种以"\"开头使用的名称称之为:完全限定名称,类似于绝对目录的概念,使用这种名称PHP会直接根据"\"之后的名称去对应的符号表中查找(namespace定义时前面是没有加"\"的,所以查找时也会去掉这个字符)。
除了这种形式的名称之外,还有两种形式的名称:
* __非限定名称:__ 即没有加任何namespace前缀的普通名称,比如my_func(),使用这种名称时如果当前有命名空间则会被解析为:currentnamespace\my_func,如果当前没有命名空间则按照原始名称my_func解析
* __部分限定名称:__ 即包含namespace前缀,但不是以"\"开始的,比如:aa\my_func(),类似相对路径的概念,这种名称解析规则比较复杂,如果当前空间没有使用use导入任何namespace那么与非限定名称的解析规则相同,即如果当前有命名空间则会把解析为:currentnamespace\aa\my_func,否则解析为aa\my_func,使用use的情况后面再作说明
';
8.3 命名空间的使用
最后更新于:2022-04-02 05:19:39
[8.3.1 基本用法](8.3.1%E5%9F%BA%E6%9C%AC%E7%94%A8%E6%B3%95.md)
[8.3.2 use导入](8.3.2use%E5%AF%BC%E5%85%A5.md)
[8.3.3 动态用法](8.3.3%E5%8A%A8%E6%80%81%E7%94%A8%E6%B3%95.md)
';
8.2.2 内部实现
最后更新于:2022-04-02 05:19:37
### 8.2.2 内部实现
命名空间的实现实际比较简单,当声明了一个命名空间后,接下来编译类、函数和常量时会把类名、函数名和常量名统一加上命名空间的名称作为前缀存储,也就是说声明在命名空间中的类、函数和常量的实际名称是被修改过的,这样来看他们与普通的定义方式是没有区别的,只是这个前缀是内核帮我们自动添加的,例如:
```php
//ns_define.php
namespace com\aa;
const MY_CONST = 1234;
function my_func(){ /* ... */ }
class my_class { /* ... */ }
```
最终MY_CONST、my_func、my_class在EG(zend_constants)、EG(function_table)、EG(class_table)中的实际存储名称被修改为:com\aa\MY_CONST、com\aa\my_func、com\aa\my_class。
下面具体看下编译过程,namespace语法被编译为ZEND_AST_NAMESPACE类型的语法树节点,它有两个子节点:child[0]为命名空间的名称、child[1]为通过{}方式定义时包裹的语句。
![](../img/ast_namespace.png)
此节点的编译函数为zend_compile_namespace():
```c
void zend_compile_namespace(zend_ast *ast)
{
zend_ast *name_ast = ast->child[0];
zend_ast *stmt_ast = ast->child[1];
zend_string *name;
zend_bool with_bracket = stmt_ast != NULL;
//检查声明方式,不允许{}与非{}混用
...
if (FC(current_namespace)) {
zend_string_release(FC(current_namespace));
}
if (name_ast) {
name = zend_ast_get_str(name_ast);
if (ZEND_FETCH_CLASS_DEFAULT != zend_get_class_fetch_type(name)) {
zend_error_noreturn(E_COMPILE_ERROR, "Cannot use '%s' as namespace name", ZSTR_VAL(name));
}
//将命名空间名称保存到FC(current_namespace)
FC(current_namespace) = zend_string_copy(name);
} else {
FC(current_namespace) = NULL;
}
//重置use导入的命名空间符号表
zend_reset_import_tables();
...
if (stmt_ast) {
//如果是通过namespace xxx { ... }这种方式声明的则直接编译{}中的语句
zend_compile_top_stmt(stmt_ast);
zend_end_namespace();
}
}
```
从上面的编译过程可以看出,命名空间定义的编译过程非常简单,最主要的操作是把FC(current_namespace)设置为当前定义的命名空间名称,FC()这个宏为:CG(file_context),前面曾介绍过,file_context是在编译过程中使用的一个结构:
```c
typedef struct _zend_file_context {
zend_declarables declarables;
znode implementing_class;
//当前所属namespace
zend_string *current_namespace;
//是否在namespace中
zend_bool in_namespace;
//当前namespace是否为{}定义
zend_bool has_bracketed_namespaces;
//下面这三个值在后面介绍use时再说明,这里忽略即可
HashTable *imports;
HashTable *imports_function;
HashTable *imports_const;
} zend_file_context;
```
编译完namespace声明语句后接着编译下面的语句,此后定义的类、函数、常量均属于此命名空间,直到遇到下一个namespace的定义,接下来继续分析下这三种类型编译过程中有何不同之处。
__(1)编译类、函数__
前面章节曾详细介绍过函数、类的编译过程,总结下主要分为两步:第1步是编译函数、类,这个过程将分别生成一条ZEND_DECLARE_FUNCTION、ZEND_DECLARE_CLASS的opcode;第2步是在整个脚本编译的最后执行zend_do_early_binding(),这一步相当于执行ZEND_DECLARE_FUNCTION、ZEND_DECLARE_CLASS,函数、类正是在这一步注册到EG(function_table)、EG(class_table)中去的。
在生成ZEND_DECLARE_FUNCTION、ZEND_DECLARE_CLASS两条opcode时会把函数名、类名的存储位置通过操作数记录下来,然后在zend_do_early_binding()阶段直接获取函数名、类名作为key注册到EG(function_table)、EG(class_table)中,定义在命名空间中的函数、类的名称修改正是在生成ZEND_DECLARE_FUNCTION、ZEND_DECLARE_CLASS时完成的,下面以函数为例看下具体的处理:
```c
//函数的编译方法
void zend_compile_func_decl(znode *result, zend_ast *ast)
{
...
//生成函数声明的opcode:ZEND_DECLARE_FUNCTION
zend_begin_func_decl(result, op_array, decl);
//编译参数、函数体
...
}
```
```c
static void zend_begin_func_decl(znode *result, zend_op_array *op_array, zend_ast_decl *decl)
{
...
//获取函数名称
op_array->function_name = name = zend_prefix_with_ns(unqualified_name);
lcname = zend_string_tolower(name);
if (FC(imports_function)) {
//如果通过use导入了其他命名空间则检查函数名称是否已存在
}
....
//生成一条opcode:ZEND_DECLARE_FUNCTION
opline = get_next_op(CG(active_op_array));
opline->opcode = ZEND_DECLARE_FUNCTION;
//函数名的存储位置记录在op2中
opline->op2_type = IS_CONST;
LITERAL_STR(opline->op2, zend_string_copy(lcname));
...
}
```
函数名称通过zend_prefix_with_ns()方法获取:
```c
zend_string *zend_prefix_with_ns(zend_string *name) {
if (FC(current_namespace)) {
//如果当前是在namespace下则拼上namespace名称作为前缀
zend_string *ns = FC(current_namespace);
return zend_concat_names(ZSTR_VAL(ns), ZSTR_LEN(ns), ZSTR_VAL(name), ZSTR_LEN(name));
} else {
return zend_string_copy(name);
}
}
```
在zend_prefix_with_ns()方法中如果发现FC(current_namespace)不为空则将函数名加上FC(current_namespace)作为前缀,接下来向EG(function_table)注册时就使用修改后的函数名作为key,类的情况与函数的处理方式相同,不再赘述。
__(2)编译常量__
常量的编译过程与函数、类基本相同,也是在编译过程获取常量名时检查FC(current_namespace)是否为空,如果不为空表示常量声明在namespace下,则为常量名加上FC(current_namespace)前缀。
总结下命名空间的定义:编译时如果发现定义了一个namespace,则将命名空间名称保存到FC(current_namespace),编译类、函数、常量时先判断FC(current_namespace)是否为空,如果为空则按正常名称编译,如果不为空则将类名、函数名、常量名加上FC(current_namespace)作为前缀,然后再以修改后的名称注册。整个过程相当于PHP帮我们补全了类名、函数名、常量名。
';
8.2.1 定义语法
最后更新于:2022-04-02 05:19:34
### 8.2.1 定义语法
命名空间通过关键字namespace 来声明,如果一个文件中包含命名空间,它必须在其它所有代码之前声明命名空间,除了declare关键字以外,也就是说除declare之外任何代码都不能在namespace之前声明。另外,命名空间并没有文件限制,可以在多个文件中声明同一个命名空间,也可以在同一文件中声明多个命名空间。
```php
namespace com\aa;
const MY_CONST = 1234;
function my_func(){ /* ... */ }
class my_class { /* ... */ }
```
另外也可以通过{}将类、函数、常量封装在一个命名空间下:
```php
namespace com\aa{
const MY_CONST = 1234;
function my_func(){ /* ... */ }
class my_class { /* ... */ }
}
```
但是同一个文件中这两种定义方式不能混用,下面这样的定义将是非法的:
```php
namespace com\aa{
/* ... */
}
namespace com\bb;
/* ... */
```
如果没有定义任何命名空间,所有的类、函数和常量的定义都是在全局空间,与 PHP 引入命名空间概念前一样。
';
8.2 命名空间的定义
最后更新于:2022-04-02 05:19:32
[8.2.1 定义语法](8.2.1%E5%AE%9A%E4%B9%89%E8%AF%AD%E6%B3%95.md)
[8.2.2 内部实现](8.2.2%E5%86%85%E9%83%A8%E5%AE%9E%E7%8E%B0.md)
';
8.1 概述
最后更新于:2022-04-02 05:19:30
## 8.1 概述
什么是命名空间?从广义上来说,命名空间是一种封装事物的方法。在很多地方都可以见到这种抽象概念。例如,在操作系统中目录用来将相关文件分组,对于目录中的文件来说,它就扮演了命名空间的角色。具体举个例子,文件 foo.txt 可以同时在目录/home/greg 和 /home/other 中存在,但在同一个目录中不能存在两个 foo.txt 文件。另外,在目录 /home/greg 外访问 foo.txt 文件时,我们必须将目录名以及目录分隔符放在文件名之前得到 /home/greg/foo.txt。这个原理应用到程序设计领域就是命名空间的概念。(引用自php.net)
命名空间主要用来解决两类问题:
* 用户编写的代码与PHP内部的或第三方的类、函数、常量、接口名字冲突
* 为很长的标识符名称创建一个别名的名称,提高源代码的可读性
PHP命名空间提供了一种将相关的类、函数、常量和接口组合到一起的途径,不同命名空间的类、函数、常量、接口相互隔离不会冲突,注意:PHP命名空间只能隔离类、函数、常量和接口,不包括全局变量。
接下来的两节将介绍下PHP命名空间的内部实现,主要从命名空间的定义及使用两个方面分析。
';
第8章 命名空间
最后更新于:2022-04-02 05:19:28
[8.1 概述](8.1%E6%A6%82%E8%BF%B0.md)
[8.2 命名空间的定义](8.2%E5%91%BD%E5%90%8D%E7%A9%BA%E9%97%B4%E7%9A%84%E5%AE%9A%E4%B9%89.md)
[8.3 命名空间的使用](8.3%E5%91%BD%E5%90%8D%E7%A9%BA%E9%97%B4%E7%9A%84%E4%BD%BF%E7%94%A8.md)
';
7.8.2 Redis
最后更新于:2022-04-02 05:19:25
7.8.1 Yaf
最后更新于:2022-04-02 05:19:23
7.11 经典扩展解析
最后更新于:2022-04-02 05:19:21
[7.8.1 Yaf](7.8.1Yaf.md)
[7.8.2 Redis](7.8.2Redis.md)
';
7.10 资源类型
最后更新于:2022-04-02 05:19:19
7.9.5 类的实例化
最后更新于:2022-04-02 05:19:16
7.9.4 定义常量
最后更新于:2022-04-02 05:19:14
7.9.3 定义成员方法
最后更新于:2022-04-02 05:19:12