前提
Workerman是一款纯PHP开发的开源高性能的PHP socket 服务器框架。本篇文章也要从使用Workerman开始。
在Workerman中, 每个Worker启动时, 预先fork多个进程($worker->count)作为这个Worker的进程池。在Worker启动时, 回调函数onWorkerStart
会被调用。
问题
先看如下代码 simple_worker.php
:
|
|
执行 php simple_worker.php start
, 输出如下内容:
|
|
可以看出, onWorkerStart
回调是在fork出的子进程中执行的。 fork出的子进程相当于复制了父进程的存储数据和代码空间, 现在输出的内容却有一些不符合我们的常识。
fork之后, 每个子进程应当有他们自己的$global_var
, 对于$global_var->bar++
, 每个进程对自己的$global_var
自增, 所以各个子进程之间互不影响, 所以输出的$global_var->bar = 1
。问题是为什么输出的spl_object_hash($global_var)
是相同的呢?
验证
为了理解这是如何造成的, 查看Workerman源码Worker.php
, 可以看到, 在Worker::runAll()
静态方法中执行了一系列的操作, 其中self::forkWorkers()
预先fork了Worker进程, 实际的fork操作在self::forkOneWorker($worker)
方法中。
由于Workerman的代码有些复杂, 提取大概逻辑如下 fork.php
:
|
|
执行该文件php fork.php
可以看出这里的spl_object_hash($global_var)
是不同的, 与上面的Workerman例子不同。这又是为什么呢。
spl_object_hash 的实现
为了找到这是为什么, 我从 php.net 下载了 PHP 的源代码, 在 ext/spl/php_spl.c
中找到了如下代码:
|
|
从这里可以看出, spl_object_hash
的逻辑是先查看全局变量hash_mask_init
是否为true
, 如果不为true
, 就用随机数填充变量hash_mask_handle
和hash_mask_handles
, 最后返回值由hash_mask_handle
与参数obj.value.obj.handle
按位与或, 最后与hash_mask_handles
拼接起来。
obj.value.obj.handle
是宏Z_OBJ_HANDLE_P(obj)
的展开, PHP7中, 变量在源码中的定义如下:
|
|
obj.value.obj.handle
也就是_zend_object
的handle
。
在我们自己写的测试程序fork.php
中, 在fork之前, hash_mask_init
未被初始化, 而变量$global_var
是在fork前全局定义的, $global_var.val.obj.handle
在fork后随着$global_var
被复制到各个子进程中。之所以每个子进程输出的spl_object_hash($global_var)
是不同的, 是因为在子进程中每次都要初始化hash_mask_handle
和hash_mask_handles
。
那为什么在Workerman中spl_object_hash($global_var)
是相同的呢? 可以猜测Workerman在fork之前执行过spl_object_hash
函数, 此时预先初始化了hash_mask_handle
和hash_mask_handles
。带着这个猜测在Workerman源码中搜索spl_object_hash
, 可以找到在Worker的构造函数中执行了$this->workerId = spl_object_hash($this);
, 将此行去掉之后再次运行, 每个子进程输出的spl_object_hash($global_var)
也是不同的。这也验证了之前在PHP源码中得到的结论。
意外发现
在寻找原因的过程中, 意外发现了另外一个问题, 先看代码:
在这段代码中, 如果与前面的代码结构相同, 不同的是将spl_object_hash($global_var)
去掉了, 改为随机函数mt_rand()
。运行这段代码会得到5个不同的随机数。
但如果将构造函数中mt_rand()
的注释去掉, 再次运行, 你将得到5个相同的随机数。
我们知道通过mt_rand()
生成的是伪随机数, 伪随机数生成需要一个种子, 伪随机数的种子一般以当前时间、进程ID或者计算机硬件代码来得出。在生成了随机数种子之后调用随机数函数得到的随机数序列已经确定了。
回到上面的代码, 在构造函数中执行mt_rand()
之后, 随机数种子已经生成, 在fork时被复制到子进程中, 所以子进程在执行随机数函数时生成的随机数是相同的。
解决办法是可以在子进程中重新播下随机数种子。
下面是PHP源码中mt_rand()
的实现:
|
|
可以看到mt_rand()
最终也调用了php_mt_rand()
, 而spl_object_hash
也调用了php_mt_rand()
。
再回到PHP源码中spl_obejct_hash
的实现, 它会在hash_mask_init
为false
时调用php_mt_rand()
, 这样会带来的问题是:如果在父进程中没有调用spl_object_hash
, 而调用了mt_rand()
也会造成fork后的子进程中spl_object_hash($global_var)
的结果相同, 读者可以自行在fork.php
中Worker的构造函数加上mt_rand()
进行测试。
总结
这应该不能算是PHP的一个Bug, 对于spl_object_hash
, 本身就是为了区分不同的对象, 而在两个进程中相同的object_hash必然也不是相同的对象。在编写多进程程序中, 这些都是应当考虑的问题。