本篇文章,我们来实战分析一下Swoole PHP
协程入口函数的实现原理以及细节。大家需要准备好一份Swoole
源码好和我们一起动手操作,我们使用的PHP
版本是7.3.12
,Swoole
的版本是v4.4.16
。
首先,我们需要明白PHP
协程的入口函数是swoole::PHPCoroutine::main_func
,也就是说,我们创建的每一个PHP
协程,都会从main_func
这个函数开始。
我们现在来逐行分析main_func
这个函数。首先,这个函数的参数是void *arg
,是一个void
类型的指针,从
1 | php_coro_args *php_arg = (php_coro_args *) arg; |
可以看出arg
原来的类型是php_coro_args
类型的指针,php_coro_args
这个结构体对应的成员如下:
1 | struct php_coro_args |
可以发现,这三个成员是我们调用一个函数的基础,分别对应了函数本体、传递给函数的参数、传递给函数参数的个数。
在函数的开头,我们看到了BAILOUT
这个东西,我们先来看看如果没有这个东西会有什么问题,我们把commit
切一下:
1 | git checkout ef1db99ecfa475ce34d4be744d1f811fadf566ac |
此时我们可以看到为支持BAILOUT
功能而做出的文件改动。
(现在main_func
这个函数名字是旧的名字create_func
)
我们会发现,在create_func
函数里面,增加了zend_first_try
和zend_catch
这个结构。我们很容易的想到这是用来捕获PHP
异常的。
并且增加了swoole::Coroutine::bailout
这个函数,这个函数会在zend_catch
里面被调用:
1 | zend_catch { |
我们来看看bailout
这个函数做了什么事情:
1 | void Coroutine::bailout(sw_coro_bailout_t func) |
其中:
1 | if (!current) |
表示如果不在协程环境,那么直接执行函数func
。如果在协程环境,那么执行:
1 | Coroutine *co = current; |
表示查找到主协程,然后再把函数on_bailout = func
赋值给swoole::Coroutine::on_bailout
这个函数指针。
然后调用co->yield()
切换协程上下文。这个时候,就回到非协程环境了。
现在,我们来看两个问题,第一个问题是这里的函数func
是什么,第二个问题是on_bailout
在哪里被调用了。
首先来看第一个问题。函数func
是:
1 | sw_zend_bailout(); |
对应着:
1 |
|
zend_bailout
函数是用来结束程序运行的。我们重点关注LONGJMP(*EG(bailout), FAILURE)
做了什么事情。我们知道在C
语言里面,有一对函数setjmp
和longjmp
,分别用来保存当前程序运行的环境和恢复被保存的环境。
我们来看看宏LONGJMP
展开来是什么东西:
1 |
就是我们的longjmp
函数。
我们可以看一下longjmp
这个函数的描述:
1 | Jump to the environment saved in ENV, making the setjmp call there return VAL, or 1 if VAL is 0. |
所以,LONGJMP(*EG(bailout), FAILURE)
的意图就比较明显了,程序会跳转到*EG(bailout)
上下文中,然后使SETJMP
调用在那里返回FAILURE
,也就是-1
。
我们再来看第二个问题。on_bailout
是在函数swoole::Coroutine::check_end
中被调用的:
1 | inline void check_end() |
也就是说,当on_bailout
这个函数指针不为空的时候,会去调用这个on_bailout
,也就是函数sw_zend_bailout
。并且,只有当程序逻辑进入了:
1 | zend_catch { |
的时候(即程序抛出了异常),这个on_bailout
指针才不为空。
现在,我们执行如下命令:
1 | git reset --hard a0384ea2981125fc9a7e1a68e489ffb5b40ad426 |
此时,我们的Swoole
就处于没有bailout
的样子了。我们重新编译、安装扩展后,编写如下测试脚本:
1 |
|
我们执行脚本:
1 | [root@592b0366acbf bailout]# php error.php |
可以发现,在脚本结束执行后,会调用我们通过register_shutdown_function
函数注册的匿名函数,打印出字符串shutdown
。
我们现在修改脚本:
1 |
|
然后执行脚本:
1 | [root@592b0366acbf bailout]# php error.php |
可以发现,此时我们通过register_shutdown_function
函数注册的匿名函数无法被执行。第二个脚本和第一个脚本的区别就是第二个脚本在PHP
协程里面抛出了异常。
为什么我们PHP
协程里面跑出异常会出现这个问题呢?我们需要跟踪一下程序的执行流程。首先,当我们的程序跑出异常的时候,会使得协程入口函数create_func
进入如下代码:
1 | if (UNEXPECTED(EG(exception))) |
之后,程序就会调用php_error_cb
,在这个函数里面打印出异常信息。
之后,程序就会执行如下代码:
1 | if (type != E_PARSE) { |
我们发现,这里调用了zend_bailout
函数,我们上面分析了这个函数,它会调用LONGJMP
,然后把上下文切换到EG(bailout)
。因为这里的EG(bailout)
是空指针,所以zend_bailout
这个函数自然就会报错了。
所以,Swoole
的解决方案就是,在create_func
这个协程入口函数体里面包了一层try ... catch
。
我们切换一下commit
:
1 | git checkout ef1db99ecfa475ce34d4be744d1f811fadf566ac |
然后重新编译、安装扩展。
编写如下测试脚本:
1 |
|
执行脚本:
1 | [root@592b0366acbf bailout]# php error.php |
我们发现,此时register_shutdown_function
注册的匿名函数被执行了。也就是说,程序没有因为我们抛出异常使得php cli
提早终止执行了。
我们现在来跟踪一下程序的执行流程。
首先,程序会执行zend_first_try
,而这个宏会去设置EG(bailout)
的地址,这个地址就在create_func
里面,并且会保存原来的EG(bailout)
地址。
然后,我们的PHP
脚本退出时候,程序会执行到以下代码:
1 | if (UNEXPECTED(EG(exception))) |
然后程序依然会执行函数php_error_cb
来打印出异常信息。
然后程序会执行函数zend_bailout
:
1 | ZEND_API ZEND_COLD void _zend_bailout(const char *filename, uint32_t lineno) /* {{{ */ |
因为EG(bailout)
不在为空了,所以程序会执行到代码:
1 | LONGJMP(*EG(bailout), FAILURE) |
此时,程序会回到Swoole
内核create_func
的zend_first_try
宏里面。然后进入zend_catch
:
1 | zend_catch { |
然后执行函数swoole::Coroutine::bailout
的如下代码:
1 | else |
因为,我们现在就处于第一个协程里面,所以co->origin
为nullptr
。接着,程序执行代码:
1 | on_bailout = func; |
实际上就是:
1 | on_bailout = sw_zend_bailout; |
接着,程序执行co->yield()
,切换上下文,此时,就会到非协程环境。然后程序就执行到了函数swoole::Coroutine::run
里面的check_end()
这个位置:
1 | inline long run() |
我们前面分析过,check_end
这个函数会去调用on_bailout
回调函数,也就是sw_zend_bailout
。而sw_zend_bailout
对应着zend_bailout
:
1 | ZEND_API ZEND_COLD void _zend_bailout(const char *filename, uint32_t lineno) /* {{{ */ |
此时的EG(bailout)
地址已经不再是create_func
函数的zend_first_try
里面了,而是zend_first_try
中的__orig_bailout
。
所以,当程序执行完LONGJMP(*EG(bailout), FAILURE)
之后,就会回到php cli
的php_execute_script
函数的zend_try
里面:
1 | zend_try { |
接着,php cli
程序可以顺利的回到do_cli
函数的:
1 | out: |
这个位置。
执行完函数php_request_shutdown
之后,我们在PHP
脚本里面通过register_shutdown_function
函数注册的匿名函数就会被执行。最终,php cli
程序正常退出。
总结
Swoole
内核通过zend_first_try
保存当前地址到EG(bailout)
,使得程序在抛出异常的时候,程序先回到Swoole
内核的zend_first_try
里面。然后,恢复EG(bailout)
为__orig_bailout
。最终,使得php cli
程序正常退出。