协程CPU密集场景调度实现
最后更新于:2022-04-02 06:41:16
# 协程CPU密集场景调度实现
[TOC]
### 抢占式 vs 非抢占式
如果服务场景是IO密集型,非抢占式可以表现的非常完美,但是如果服务中加入了CPU密集型操作,我们不得不考虑重新协程的调度模式。
在Swoole的协程系列文章中我们曾经介绍过IO密集场景下协程基于非抢占式调度的优势和卓越的性能。但是在CPU密集的场景下抢占式调度是非常重要的。试想有以下场景,程序中有A,B两个协程,协程A一直在执行CPU密集运算,非抢占式的调度模型中,A不会主动让出控制权,从而导致B得不到时间片,协程得不到均衡调度。导致的问题是假如当前服务A,B同时对外提供服务,B协程处理的请求就可能因为得不到时间片导致请求超时,在企业级的应用中,这种情况是不容忽视的。
### PHP的实现方式
因为PHP是单线程运行,所以针对PHP的协程调度和golang完全不一样。我们选择使用[declare(tick=N)](http://php.net/manual/zh/control-structures.declare.php "d")语法功能实现协程调度。Tick(时钟周期)是一个在 declare 代码段中解释器每执行 N 条可计时的低级语句就会发生的事件。N 的值是在 declare 中的 directive 部分用 ticks=N 来指定的。不是所有语句都可计时。通常条件表达式和参数表达式都不可计时。以下类型都是不可被tick计数的。
~~~
static inline zend_bool zend_is_unticked_stmt(zend_ast *ast) /* {{{ */
{
return ast->kind == ZEND_AST_STMT_LIST || ast->kind == ZEND_AST_LABEL
|| ast->kind == ZEND_AST_PROP_DECL || ast->kind == ZEND_AST_CLASS_CONST_DECL
|| ast->kind == ZEND_AST_USE_TRAIT || ast->kind == ZEND_AST_METHOD;
}
~~~
协程调度的逻辑就是每次触发`tick handler`,我们判断当前协程相对最近一次调度时间是否大于协程最大执行时间,这样就可以将协程超出执行时间后被抢占(其实也是主动让出),这种调度表现为抢占式调度,而且不是基于IO。首先来一段PHP最简单加法指令执行的压测
~~~
`tick=100`,handler执行的周期为`100 * opcode_time = 5000ns = 0.005ms`,10ms 误差为0.005ms
>
> `tick=1000`,handler执行的周期为`1000 * opcode_time = 50000ns = 0.05ms`,10ms 误差为0.05ms
>
> `tick=10000`,handler执行的周期为`10000 * opcode_time = 500000ns = 0.5ms`,10ms 误差为0.5ms
可以看出这样的误差颗粒是完全可以接受的,误差的范围和tick的参数有关系,当然tick越大,对性能的影响为每`tick=number`条指令执行一次tick hander 约`50ns`,性能的影响非常小。
下面有一个例子
~~~
$max_msec,
]);
$s = microtime(1);
echo "start\n";
$flag = 1;
go(function () use (&$flag, $max_msec){
echo "coro 1 start to loop for $max_msec msec\n";
$i = 0;
while($flag) {
$i ++;
}
echo "coro 1 can exit\n";
});
$t = microtime(1);
$u = $t-$s;
echo "shedule use time ".round($u * 1000, 5)." ms\n";
go(function () use (&$flag){
echo "coro 2 set flag = false\n";
$flag = false;
});
echo "end\n";
~~~
输出结果为
~~~
start
coro 1 start to loop for 10 msec
shedule use time 10.2849 ms
coro 2 set flag = false
end
coro 1 can exit
~~~
其中coro1的opcodes为
~~~
{closure}: ; (lines=18, args=0, vars=3, tmps=5)
; (before optimizer)
; /path-to-tick/tick.php:12-19
L0 (12): BIND_STATIC (ref) CV0($flag) string("flag")
L1 (12): BIND_STATIC CV1($max_msec) string("max_msec")
L2 (13): T4 = ROPE_INIT 3 string("coro 1 start to loop for ")
L3 (13): T4 = ROPE_ADD 1 T4 CV1($max_msec)
L4 (13): T3 = ROPE_END 2 T4 string(" msec
")
L5 (13): ECHO T3
L6 (13): TICKS 1000
L7 (14): ASSIGN CV2($i) int(0)
L8 (14): TICKS 1000
L9 (15): JMP L13
L10 (16): T7 = POST_INC CV2($i)
L11 (16): FREE T7
L12 (16): TICKS 1000
L13 (15): JMPNZ CV0($flag) L10
L14 (15): TICKS 1000
L15 (18): ECHO string("coro 1 can exit
")
L16 (18): TICKS 1000
L17 (19): RETURN null
LIVE RANGES:
4: L2 - L4 (rope)
~~~
现在基于tick的调度实现已经完成在单独的[调度分支](https://github.com/swoole/swoole-src/tree/schedule "调度分支"),测试用例可以在[这里](https://github.com/swoole/swoole-src/tree/schedule/tests/swoole_coroutine/schedule "这里")可以找到。
### 写在最后
使用`tick`的方式实现的有一个最大的缺点是需要用户在PHP层的脚本开始处声明[`declare(tick=N)`](http://php.net/manual/zh/control-structures.declare.php "d"),这样使得这个功能对于扩展层来说不够完备。但是他能够处理所有的PHP指令。同时我们在处理`tick handler`的时候,HOOK了PHP默认的方式,因为使用默认的方式,PHP用户层可以注册tick函数会造成干扰。大家可以发现,我们的历史提交记录中有一种方式是基于HOOK循环指令的方式实现的,我们假设使得CPU密集的类型是大量的循环操作,我们检测循环的次数和当前协程执行的时间,即每次遇到循环指令的handler,我们去检查当前循环的次数和协程执行的时间,进而可以发现执行时间较长的协程。但是,这种方式无法处理没有使用循环,假如只有单纯的大量PHP指令密集运算是无法检测到的。我们权衡利弊,最终选择PHP`tick`这种方式实现。
### END
欢迎大家随时反馈。
';