Swoole PHP协程入口函数实战分析(一)

本篇文章,我们来实战分析一下Swoole PHP协程入口函数的实现原理以及细节。大家需要准备好一份Swoole源码好和我们一起动手操作,我们使用的PHP版本是7.3.12Swoole的版本是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
2
3
4
5
6
struct php_coro_args
{
zend_fcall_info_cache *fci_cache;
zval *argv;
uint32_t argc;
};

可以发现,这三个成员是我们调用一个函数的基础,分别对应了函数本体、传递给函数的参数、传递给函数参数的个数。

在函数的开头,我们看到了BAILOUT这个东西,我们先来看看如果没有这个东西会有什么问题,我们把commit切一下:

1
2
3
git checkout ef1db99ecfa475ce34d4be744d1f811fadf566ac

git reset HEAD~

此时我们可以看到为支持BAILOUT功能而做出的文件改动。

(现在main_func这个函数名字是旧的名字create_func

我们会发现,在create_func函数里面,增加了zend_first_tryzend_catch这个结构。我们很容易的想到这是用来捕获PHP异常的。

并且增加了swoole::Coroutine::bailout这个函数,这个函数会在zend_catch里面被调用:

1
2
3
zend_catch {
Coroutine::bailout([](){ sw_zend_bailout(); });
} zend_end_try();

我们来看看bailout这个函数做了什么事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void Coroutine::bailout(sw_coro_bailout_t func)
{
if (!func)
{
swError("bailout without bailout function");
}
if (!current)
{
func();
}
else
{
Coroutine *co = current;
while (co->origin)
{
co = co->origin;
}
// it will jump to main context directly (it also breaks contexts)
on_bailout = func;
co->yield();
}
// expect that never here
exit(1);
}

其中:

1
2
3
4
if (!current)
{
func();
}

表示如果不在协程环境,那么直接执行函数func。如果在协程环境,那么执行:

1
2
3
4
5
6
7
8
Coroutine *co = current;
while (co->origin)
{
co = co->origin;
}
// it will jump to main context directly (it also breaks contexts)
on_bailout = func;
co->yield();

表示查找到主协程,然后再把函数on_bailout = func赋值给swoole::Coroutine::on_bailout这个函数指针。

然后调用co->yield()切换协程上下文。这个时候,就回到非协程环境了。

现在,我们来看两个问题,第一个问题是这里的函数func是什么,第二个问题是on_bailout在哪里被调用了。

首先来看第一个问题。函数func是:

1
sw_zend_bailout();

对应着:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#define sw_zend_bailout() zend_bailout()

BEGIN_EXTERN_C()
ZEND_API ZEND_COLD void _zend_bailout(const char *filename, uint32_t lineno) /* {{{ */
{

if (!EG(bailout)) {
zend_output_debug_string(1, "%s(%d) : Bailed out without a bailout address!", filename, lineno);
exit(-1);
}
gc_protect(1);
CG(unclean_shutdown) = 1;
CG(active_class_entry) = NULL;
CG(in_compilation) = 0;
EG(current_execute_data) = NULL;
LONGJMP(*EG(bailout), FAILURE);
}
/* }}} */
END_EXTERN_C()

zend_bailout函数是用来结束程序运行的。我们重点关注LONGJMP(*EG(bailout), FAILURE)做了什么事情。我们知道在C语言里面,有一对函数setjmplongjmp,分别用来保存当前程序运行的环境和恢复被保存的环境。

我们来看看宏LONGJMP展开来是什么东西:

1
# define LONGJMP(a,b) longjmp(a, b)

就是我们的longjmp函数。

我们可以看一下longjmp这个函数的描述:

1
2
3
Jump to the environment saved in ENV, making the setjmp call there return VAL, or 1 if VAL is 0.

翻译:跳转到在ENV中保存的环境,使setjmp调用在那里返回VAL,如果VAL为0则返回1。

所以,LONGJMP(*EG(bailout), FAILURE)的意图就比较明显了,程序会跳转到*EG(bailout)上下文中,然后使SETJMP调用在那里返回FAILURE,也就是-1

我们再来看第二个问题。on_bailout是在函数swoole::Coroutine::check_end中被调用的:

1
2
3
4
5
6
7
8
9
10
11
inline void check_end()
{
if (ctx.is_end())
{
close();
}
if (unlikely(on_bailout))
{
on_bailout();
}
}

也就是说,当on_bailout这个函数指针不为空的时候,会去调用这个on_bailout,也就是函数sw_zend_bailout。并且,只有当程序逻辑进入了:

1
2
3
zend_catch {
Coroutine::bailout([](){ sw_zend_bailout(); });
} zend_end_try();

的时候(即程序抛出了异常),这个on_bailout指针才不为空。

现在,我们执行如下命令:

1
git reset --hard a0384ea2981125fc9a7e1a68e489ffb5b40ad426

此时,我们的Swoole就处于没有bailout的样子了。我们重新编译、安装扩展后,编写如下测试脚本:

1
2
3
4
5
6
7
8
9
<?php

register_shutdown_function(function () {
echo 'shutdown' . PHP_EOL;
});

echo 'execute script' . PHP_EOL;

throw new Exception();

我们执行脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
[root@592b0366acbf bailout]# php error.php
execute script
PHP Fatal error: Uncaught Exception in /root/codeDir/phpCode/swoole/coroutine/bailout/error.php:9
Stack trace:
#0 {main}
thrown in /root/codeDir/phpCode/swoole/coroutine/bailout/error.php on line 9

Fatal error: Uncaught Exception in /root/codeDir/phpCode/swoole/coroutine/bailout/error.php:9
Stack trace:
#0 {main}
thrown in /root/codeDir/phpCode/swoole/coroutine/bailout/error.php on line 9
shutdown
[root@592b0366acbf bailout]#

可以发现,在脚本结束执行后,会调用我们通过register_shutdown_function函数注册的匿名函数,打印出字符串shutdown

我们现在修改脚本:

1
2
3
4
5
6
7
8
9
10
11
<?php

register_shutdown_function(function () {
echo 'shutdown' . PHP_EOL;
});

echo 'execute script' . PHP_EOL;

go(function () {
throw new Exception;
});

然后执行脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
[root@592b0366acbf bailout]# php error.php
execute script
PHP Fatal error: Uncaught Exception in /root/codeDir/phpCode/swoole/coroutine/bailout/error.php:10
Stack trace:
#0 {main}
thrown in /root/codeDir/phpCode/swoole/coroutine/bailout/error.php on line 10

Fatal error: Uncaught Exception in /root/codeDir/phpCode/swoole/coroutine/bailout/error.php:10
Stack trace:
#0 {main}
thrown in /root/codeDir/phpCode/swoole/coroutine/bailout/error.php on line 10
/root/php-7.3.12/main/main.c(1414) : Bailed out without a bailout address!
[root@592b0366acbf bailout]#

可以发现,此时我们通过register_shutdown_function函数注册的匿名函数无法被执行。第二个脚本和第一个脚本的区别就是第二个脚本在PHP协程里面抛出了异常。

为什么我们PHP协程里面跑出异常会出现这个问题呢?我们需要跟踪一下程序的执行流程。首先,当我们的程序跑出异常的时候,会使得协程入口函数create_func进入如下代码:

1
2
3
4
if (UNEXPECTED(EG(exception)))
{
zend_exception_error(EG(exception), E_ERROR);
}

之后,程序就会调用php_error_cb,在这个函数里面打印出异常信息。

之后,程序就会执行如下代码:

1
2
3
4
5
6
7
8
if (type != E_PARSE) {
/* restore memory limit */
zend_set_memory_limit(PG(memory_limit));
efree(buffer);
zend_objects_store_mark_destructed(&EG(objects_store));
zend_bailout();
return;
}

我们发现,这里调用了zend_bailout函数,我们上面分析了这个函数,它会调用LONGJMP,然后把上下文切换到EG(bailout)。因为这里的EG(bailout)是空指针,所以zend_bailout这个函数自然就会报错了。

所以,Swoole的解决方案就是,在create_func这个协程入口函数体里面包了一层try ... catch

我们切换一下commit

1
2
3
git checkout ef1db99ecfa475ce34d4be744d1f811fadf566ac

git reset HEAD~

然后重新编译、安装扩展。

编写如下测试脚本:

1
2
3
4
5
6
7
8
9
10
11
<?php

register_shutdown_function(function () {
echo 'shutdown' . PHP_EOL;
});

echo 'execute script' . PHP_EOL;

go(function () {
throw new Exception;
});

执行脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
[root@592b0366acbf bailout]# php error.php
execute script
PHP Fatal error: Uncaught Exception in /root/codeDir/phpCode/swoole/coroutine/bailout/error.php:10
Stack trace:
#0 {main}
thrown in /root/codeDir/phpCode/swoole/coroutine/bailout/error.php on line 10

Fatal error: Uncaught Exception in /root/codeDir/phpCode/swoole/coroutine/bailout/error.php:10
Stack trace:
#0 {main}
thrown in /root/codeDir/phpCode/swoole/coroutine/bailout/error.php on line 10
shutdown
[root@592b0366acbf bailout]#

我们发现,此时register_shutdown_function注册的匿名函数被执行了。也就是说,程序没有因为我们抛出异常使得php cli提早终止执行了。

我们现在来跟踪一下程序的执行流程。

首先,程序会执行zend_first_try,而这个宏会去设置EG(bailout)的地址,这个地址就在create_func里面,并且会保存原来的EG(bailout)地址。

然后,我们的PHP脚本退出时候,程序会执行到以下代码:

1
2
3
4
if (UNEXPECTED(EG(exception)))
{
zend_exception_error(EG(exception), E_ERROR);
}

然后程序依然会执行函数php_error_cb来打印出异常信息。

然后程序会执行函数zend_bailout

1
2
3
4
5
6
7
8
9
10
11
12
13
ZEND_API ZEND_COLD void _zend_bailout(const char *filename, uint32_t lineno) /* {{{ */
{
if (!EG(bailout)) {
zend_output_debug_string(1, "%s(%d) : Bailed out without a bailout address!", filename, lineno);
exit(-1);
}
gc_protect(1);
CG(unclean_shutdown) = 1;
CG(active_class_entry) = NULL;
CG(in_compilation) = 0;
EG(current_execute_data) = NULL;
LONGJMP(*EG(bailout), FAILURE);
}

因为EG(bailout)不在为空了,所以程序会执行到代码:

1
LONGJMP(*EG(bailout), FAILURE)

此时,程序会回到Swoole内核create_funczend_first_try宏里面。然后进入zend_catch

1
2
3
zend_catch {
Coroutine::bailout([](){ sw_zend_bailout(); });
} zend_end_try();

然后执行函数swoole::Coroutine::bailout的如下代码:

1
2
3
4
5
6
7
8
9
10
11
else
{
Coroutine *co = current;
while (co->origin)
{
co = co->origin;
}
// it will jump to main context directly (it also breaks contexts)
on_bailout = func;
co->yield();
}

因为,我们现在就处于第一个协程里面,所以co->originnullptr。接着,程序执行代码:

1
on_bailout = func;

实际上就是:

1
on_bailout = sw_zend_bailout;

接着,程序执行co->yield(),切换上下文,此时,就会到非协程环境。然后程序就执行到了函数swoole::Coroutine::run里面的check_end()这个位置:

1
2
3
4
5
6
7
8
9
inline long run()
{
long cid = this->cid;
origin = current;
current = this;
ctx.swap_in();
check_end();
return cid;
}

我们前面分析过,check_end这个函数会去调用on_bailout回调函数,也就是sw_zend_bailout。而sw_zend_bailout对应着zend_bailout

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ZEND_API ZEND_COLD void _zend_bailout(const char *filename, uint32_t lineno) /* {{{ */
{

if (!EG(bailout)) {
zend_output_debug_string(1, "%s(%d) : Bailed out without a bailout address!", filename, lineno);
exit(-1);
}
gc_protect(1);
CG(unclean_shutdown) = 1;
CG(active_class_entry) = NULL;
CG(in_compilation) = 0;
EG(current_execute_data) = NULL;
LONGJMP(*EG(bailout), FAILURE);
}

此时的EG(bailout)地址已经不再是create_func函数的zend_first_try里面了,而是zend_first_try中的__orig_bailout

所以,当程序执行完LONGJMP(*EG(bailout), FAILURE)之后,就会回到php cliphp_execute_script函数的zend_try里面:

1
2
3
4
5
6
7
8
zend_try {
char realfile[MAXPATHLEN];

#ifdef PHP_WIN32
if(primary_file->filename) {
UpdateIniFromRegistry((char*)primary_file->filename);
}
#endif

接着,php cli程序可以顺利的回到do_cli函数的:

1
2
3
4
out:
if (request_started) {
php_request_shutdown((void *) 0);
}

这个位置。

执行完函数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程序正常退出。