从内核层面解析PHP的声明周期、变量zval、引用计数。
《Extending and Embedding PHP》读后总结
1.PHP的生命周期
不论是使用cli指令行还是使用webserver(apache、nginx)形式解释执行php程序。最终的进程模型都可以归为三类:
- 单进程(cli):指令行执行php就是开启一个php进程,执行完php程序后控制权返回给shell。
- 多进程(nginx+php-fpm):php-fpm实际处理php请求的只是worker进程,在单个进程空间中并不会创建线程去处理多个请求,因此我们将它的进程模型归类为多进程。(apache也有多行程的模式)
- 多线程(apache):这里的多线程指的是同一个进程中还会创建多个子进程来处理多个请求,这些请求共享该进程的内存与配置。(apache有单进程多线程、多进程多线程的模式)
因为php本身并没有主进程的概念,因此它的声明周期均是以单进程
为维度讨论。
- 进程启动时:执行所有扩展的MINIT方法进行初始化操作
- 接收到请求时:执行所有扩展中的RINIT方法进行请求的初始化操作
- 解释执行PHP
- 处理完请求时:执行所有扩展中的RSHUTDOWN方法执行回收操作
- 进程结束时:执行所有扩展中的MSHUTDOWN方法执行回收操作
因此如果是多进程的模型,启动时,每一个进程都会进行上述的初始化操作,因此每一个进程中的数据是完全隔离的。
这里就可以明确的看出为什么PHP本身不支持db等各类连接池。如果是在php代码层面实现连接池,那么请求处理完毕连接池就直接释放了,没有任何意义。如果是在扩展层面实现连接池,因为PHP并没有公用的内存空间,那么连接池顶多是单进程内多请求共享(php-mysqli的pconnet就是这种模式),那么当开启例如500个PHP-FPM进程时候,相当于有500个连接池,反而可能会大量消耗可用数据库连接。
关于线程安全,默认是关闭线程安全模式。在正常的多进程模型下也不需要开启线程安全模式,因为数据本身就是进程间隔离的,不存在线程安全问题。开启反而会造成无意义的额外开销。
2.变量的实现
php中弱类型的实现是因为php底层所有的变量均为一个zval的结构体。
typedef struct _zval_struct {
zval_value value;
zend_uint refcount;
zend_uchar type;
zend_uchar is_ref;
} zval;
- zval_value是一个union类型,包含了所有类型。
typedef union _zvalue_value {
long lval;
double dval;
struct {
char *var;
int len;
} str;
HashTable *ht;
zend_object_value obj;
} zvalue_value;
- refcount 引用计数,用于垃圾回收。
- type 标记了value的真实类型,扩展开发中可以使用类型宏进行判断。
- is_ref 简单的标记值,变量本身是否是引用。
3.变量的存储
当创建一个的变量的时候,zend引擎会将将这个zval的指针存储到一个内部map(符号表)中。
struct _zend_execution_globals {
...
HashTable symbol_table;
HashTable *active_symbol_table;
...
};
- symbol_table,代表了php脚本的全局作用域。
- active_symbol_table,当用户侧的一个函数、方法被调用时,会分配一个新的符号表用于该生命周期,并定义为激活的符号表,定的变量名则存储在对应空间的符号表中。key为定义的变量名,value为指向zval的指针zval *
<?php $foo = 'bar'; ?>
/*上面php创建变量的C实现*/
{
zval *fooval;
MAKE_STD_ZVAL(fooval); //zend引擎宏命令,创建一个空zval
ZVAL_STRING(fooval, "bar", 1); //zend引擎宏命令,创建一个字符串,会自动设置字符串本身+长度,最后一个参数为1时,会分配新的内存并cp字符串,如果为0则简单的指向字符串已有的地址。
ZEND_SET_SYMBOL(EG(active_symbol_table), "foo", fooval); //设置符号表
}
4.引用计数
Zend引擎内部对于内存的管理做过了许多优化,虽然在用户侧,PHP的表现一直是在非引用的情况下都是以值传递的,但是实际上在内部,大部分情况都是传递的指针,通过引用计数+是否是引用来确定是否需要cp内容。
<?php
$a = 'Hello World';
$b = $a;
?>
在用户侧我们会想当然的理解为:
- 申请了一个12个字节的内存,用于存放Hello World+NULL结尾。
- 变量a指向该内存。
- 复制该内存的内容,并将变量b指向复制后的新地址。
但是实际上Zend引擎并没有copy字符串,而只是将那个Hello World的zval结构体中的refcount+1。
那么为什么我们改变$b的字符串的值并不会改变$a的值呢?那么是因为Zend引擎使用了写时拷贝
。
<?php
$a = 'Hello World';
$b = $a;
$b = 'aaa';
?>
这时Zend引擎的操作是:
- 判断zval的refcount如果小于2,不需要隔离,直接使用该值。
- 如果不是,则先对zval进行一次浅拷贝+一次深拷贝,在符号表删除b与该zval的关联,此时zval的refcount已经变成了1。将拷贝出来的zval的refcount设置成1,再设置进符号表与b建立关联。
<?php
$a = 'Hello World';
$b = &$a;
$b = 'aaa';
?>
如果是引用呢?此时在用户侧我们知道$a的值也应该变成aaa。实际在引擎层面很简单,因为原本内部就是通过指针指向的同一个地址,并不需要特殊处理,只需要在判断zval的refcount的值的时候,多判断一下zval的is_ref是否是1,如果是那么直接返回该值就可以了。
zval *get_var_and_separate(char *varname, int varname_len TSRMLS_DC) {
zval **varval, *varcopy;
if (zend_hash_find(EG(active_symbol_table),
varname, varname_len + 1, (void**)&varval) == FAILURE) {
/* 变量不存在 */
return NULL;
}
if ( (*varval)->is_ref || (*varval)->refcount < 2) {
/* 变量名只有⼀个引用或是引用, 不需要隔离 */
return *varval;
}
/* 其他情况, 对zval *做⼀一次浅拷贝 */
MAKE_STD_ZVAL(varcopy);
varcopy = *varval;
/* 对zval *进行⼀一次深拷贝 */
zval_copy_ctor(varcopy);
/* 破坏varname和varval之间的关系, 这⼀一步会将varval的引用计数减小1 */
zend_hash_del(EG(active_symbol_table), varname, varname_len + 1);
/* 初始化新创建的值的引用计数, 并为新创建的值和varname建立关联 */
varcopy->refcount = 1;
varcopy->is_ref = 0;
zend_hash_add(EG(active_symbol_table), varname, varname_len + 1,
/* 返回新的zval * */
return varcopy;
}