Swoole协程之旅

39次阅读

共计 14370 个字符,预计需要花费 36 分钟才能阅读完成。

作者:韩天峰 原文地址:点击查看

协程是什么?

概念其实很早就出现了,摘 wiki 一段:According to Donald Knuth, the term coroutine was coined by Melvin Conway in 1958, after he applied it to construction of an assembly program.The first published explanation of the coroutine appeared later, in 1963. 协程要比 c 语言的历史还要悠久,究其概念,协程是子程序的一种,可以通过 yield 的方式转移程序控制权,协程之间不是调用者与被调用者的关系,而是彼此对称、平等的。协程完全有用户态程序控制,所以也被成为用户态的线程。协程由用户以非抢占的方式调度,而不是操作系统。正因为如此,没有系统调度上下文切换的开销,协程有了轻量,高效,快速等特点。(大部分为非抢占式,但是,比如 Golang 在 1.4 也加入了抢占式调度,其中一个协程发生死循环,不至于其他协程被饿死。需要在必要的时刻让出 CPU)

协程近几年如此火爆,很大一部分原因归功与 Golang 在中国的流行和快速发展,受到很多开发的喜爱。目前支持协程的语言有很多,例如:Golang、Lua、Python、C#、JavaScript 等。大家也可以用很短的代码用 C /C++ 撸出协程的模型。当然 PHP 也有自己的协程实现,也就是生成器,我们这里不展开讨论。

Swoole 1.x

Swoole 最初以高性能网络通讯引擎的姿态进入大家视线,Swoole1.x 的编码主要是异步回调的方式,虽然性能非常高效,但很多开发都会发现,随着项目工程的复杂程度增加,以异步回调的方式写业务代码是和人类正常思维相悖的,尤其是回调嵌套多层的时候,不仅开发维护成本指数级上升,而且出错的几率也大幅增加。大家理想的编码方式是:同步编码得到异步非阻塞的效果。所以 Swoole 很早的时候就开始了协程的探索。

最初的协程版本是基于 PHP 生成器 GeneratorsYield 的方式实现的,可以参考 PHP 大神 Nikita 的早期博客的关于协程介绍。PHP 和 Swoole 的事件驱动的结合可以参考腾讯出团队开源的 TSF 框架,我们也在很多生产项目中使用了该框架,确实让大家感受到了,以同步编程的方式写异步代码的快感,然而,现实总是很残酷,这种方式有几个致命的缺点:

  • 所有主动让出的逻辑都需要 yield 关键字。这会给程序员带来极大的概率犯错,导致大家对协程的理解转移到了对 generators 语法的原理的理解。
  • 由于语法无法兼容老的项目,改造老的项目工程复杂度巨大,成本太高。

这样使得无论新老项目,使用都无法得心应手。

Swoole 2.x

  2.x 之后的协程都是基于内核原生的协程,无需 yield 关键字。2.0 的版本是一个非常重要的里程碑,实现了 php 的栈管理,深入 zend 内核在协程创建,切换以及结束的时候操作 PHP 栈。

   2.x 主要使用了 setjmp/longjmp 的方式实现协程,很多 C 项目主要采用这种方式实现 try-catch-finally,大家也可以参考 Zend 内核的用法。setjmp 的首次调用返回值是 0,longjmp 跳转时,setjmp 的返回值是传给 longjmp 的 value。setjmp/longjmp 由于只有控制流跳转的能力。虽然可以还原 PC 和栈指针,但是无法还原栈帧,因此会出现很多问题。比如 longjmp 的时候,setjmp 的作用域已经退出,当时的栈帧已经销毁。这时就会出现未定义行为。假设有这样一个调用链:

func0() -> func1() -> ... -> funcN()

只有在 func{i}()中 setjmp,在 func{i+k}()中 longjmp 的情况下,程序的行为才是可预期的。

Swoole 3.x

3.x 是生命周期很短的一个版本,主要借鉴了 fiber-ext 项目,使用了 PHP7 的 VM interrupts 机制,该机制可以在 vm 中设置标记位,在执行一些指令的时候(例如:跳转和函数调用等)检查标记位,如果命中就可以执行相应的 hook 函数来切换 vm 的栈,进而实现协程。

Swoole 4.x

从 4.x 开始,Swoole 实现了双栈模式的协程内核。并且将所有 IO 和系统操作封装了到了底层,实现了彻底的内核协程化。另外,还提供了全新的 Runtime Hook 模块,可以使得已有的旧的 PHP 同步代码变为协程模式。

Swoole4 协程分析

先从一个协程最简单的例子入手:

<?php
go(function(){
    echo "coro 1 start\n";
    co::sleep(1);
    echo "coro 1 exit";
});
echo "main flag\n";
go(function(){
    echo "coro 2 start\n";
    co::sleep(1);
    echo "coro 2 exit\n";
});
echo "main end\n";
// 输出内容为
coro 1 start
main flag
coro 2 start
main end
coro 1 exit
coro 2 exit

可以发现,原生协程是在函数内部发生了跳转,控制流从第 4 行跳转到第 7 行,接着执行从第 8 行开始执行 go 函数,到第 10 行跳转到了第 13 行,紧接着执行第 9 行,然后执行第 15 行的代码。为什么 Swoole 的协程可以这样执行呢?我们下面将一步一步进行分析。

  我们知道 PHP 作为一门解释型的语言,需要经过编译为中间字节码才可以执行,首先会经过词法和语法分析,将脚本编译为 opcode 数组,成为 zend_op_array,然后经过 vm 引擎来执行。我们这里只关注 vm 执行部分。执行的部分需要关注几个重要的数据结构

Opcodes

struct _zend_op {
    const void *handler;// 每个 opcode 对应的 c 处理函数
    znode_op op1;// 操作数 1
    znode_op op2;// 操作数 2
    znode_op result;// 返回值
    uint32_t extended_value;
    uint32_t lineno;
    zend_uchar opcode;//opcode 指令
    zend_uchar op1_type;// 操作数 1 类型
    zend_uchar op2_type;// 操作数 2 类型
    zend_uchar result_type;// 返回值类型
};

从结构中很容易发现 opcodes 本质上是一个三地址码,这里 opcode 是指令的类型,有两个输入的操作数数和一个表示输出的操作数。每个指令可能全部或者部分使用这些操作数,比如加、减、乘、除等会用到全部三个;!操作只用到 op1 和 result 两个;函数调用会涉及到是否有返回值等。

Op Array

zend_op_array PHP 的主脚本会生成一个 zend_op_array, 每个 function,eval, 甚至是 assert 断言一个表达式等都会生成一个新得 op_array。

struct _zend_op_array {
    /* Common zend_function header here */
    /* ... */
    uint32_t last;// 数组中 opcode 的数量
    zend_op *opcodes;//opcode 指令数组
    int last_var;// CVs 的数量
    uint32_t T;//IS_TMP_VAR、IS_VAR 的数量
    zend_string **vars;// 变量名数组
    /* ... */
    int last_literal;// 字面量数量
    zval *literals;// 字面量数组 访问时通过_zend_op_array->literals + 偏移量读取
    /* ... */
};

我们已经熟知 php 的函数内部有自己的单独的作用域,这归功于每个 zend_op_array 包含有当前作用域下所有的堆栈信息,函数之间的调用关系也是基于 zend_op_array 的切换来实现。

PHP 栈帧

PHP 执行需要的所有状态都保存在一个个通过链表结构关联的 VM 栈里,每个栈默认会初始化为 256K,Swoole 可以单独定制这个栈的大小(协程默认为 8k), 当栈容量不足的时候,会自动扩容,仍然以链表的关系关联每个栈。在每次函数调用的时候,都会在 VM Stack 空间上申请一块新的栈帧来容纳当前作用域执行所需。栈帧结构的内存布局如下所示:

+----------------------------------------+
| zend_execute_data                      |
+----------------------------------------+
| VAR[0]                =         ARG[1] | arguments
| ...                                    |
| VAR[num_args-1]       =         ARG[N] |
| VAR[num_args]         =   CV[num_args] | remaining CVs
| ...                                    |
| VAR[last_var-1]       = CV[last_var-1] |
| VAR[last_var]         =         TMP[0] | TMP/VARs
| ...                                    |
| VAR[last_var+T-1]     =         TMP[T] |
| ARG[N+1] (extra_args)                  | extra arguments
| ...                                    |
+----------------------------------------+

zend_execute_data 最后要介绍的一个结构,也是最重要的一个。

struct _zend_execute_data {
    const zend_op       *opline;// 当前执行的 opcode,初始化会 zend_op_array 起始
    zend_execute_data   *call;//
    zval                *return_value;// 返回值
    zend_function       *func;// 当前执行的函数(非函数调用时为空)zval                 This;/* this + call_info + num_args    */
    zend_class_entry    *called_scope;// 当前 call 的类
    zend_execute_data   *prev_execute_data;
    zend_array          *symbol_table;// 全局变量符号表
    void               **run_time_cache;   /* cache op_array->run_time_cache */
    zval                *literals;         /* cache op_array->literals       */
};

prev_execute_data 表示前一个栈帧结构,当前栈执行结束以后,会把当前执行指针 (类比 PC) 指向这个栈帧。PHP 的执行流程正是将很多个 zend_op_array 依次装载在栈帧上执行。这个过程可以分解为以下几个步骤:

  • 1:为当前需要执行的 op_array 从 vm stack 上申请当前栈帧,结构如上。初始化全局变量符号表,将全局指针 EG(current_execute_data)指向新分配的 zend_execute_data 栈帧,EX(opline)指向 op_array 起始位置。
  • 2: 从 EX(opline)开始调用各 opcode 的 C 处理 handler(即_zend_op.handler),每执行完一条 opcode 将 EX(opline)++ 继续执行下一条,直到执行完全部 opcode,遇到函数或者类成员方法调用:

    • 从 EG(function_table)中根据 function_name 取出此 function 对应的 zend_op_array,然后重复步骤 1,将 EG(current_execute_data)赋值给新结构的 prev_execute_data,再将 EG(current_execute_data)指向新的 zend_execute_data 栈帧,然后开始执行新栈帧,从位置 zend_execute_data.opline 开始执行,函数执行完将 EG(current_execute_data)重新指向 EX(prev_execute_data),释放分配的运行栈帧,执行位置回到函数执行结束的下一条 opline。
  • 3: 全部 opcodes 执行完成后将 1 分配的栈帧释放,执行阶段结束。

有了以上 php 执行的细节,我们回到最初的例子,可以发现协程需要做的是,改变原本 php 的运行方式,不是在函数运行结束切换栈帧,而是在函数执行当前 op_array 中间任意时候(swoole 内部控制为遇到 IO 等待),可以灵活切换到其他栈帧。接下来我们将 Zend VM 和 Swoole 结合分析,如何创建协程栈,遇到 IO 切换,IO 完成后栈恢复,以及协程退出时栈帧的销毁等细节。先介绍协程 PHP 部分的主要结构:

  • 协程 php_coro_task
struct php_coro_task
{
    /* 只列出关键结构 */
    /*...*/
    zval *vm_stack_top;// 栈顶
    zval *vm_stack_end;// 栈底
    zend_vm_stack vm_stack;// 当前协程栈指针
    /*...*/
    zend_execute_data *execute_data;// 当前协程栈帧
    /*...*/
    php_coro_task *origin_task;// 上一个协程栈帧,类比 prev_execute_data 的作用
};

协程切换主要是针对当前栈执行发生中断时对上下文保存,和恢复。结合上面 VM 的执行流程我们可以知道上面几个字段的作用。

  • execute_data 栈帧指针需要保存和恢复是毋容置疑的
  • vm_stack* 系列是什么作用呢?原因是 PHP 是动态语言,我们上面分析到,每次有新函数进入执行和退出的时候,都需要在全局 stack 上创建和释放栈帧,所以需要正确保存和恢复对应的全局栈指针,才能保障每个协程栈帧得到释放,不会导致内存泄漏的问题。(当以 debug 模式编译 PHP 后,每次释放都会检查当全局栈是否合法)
  • origin_task 是当前协程执行结束后需要自动执行的前一个栈帧。

主要涉及到的方法有

  • 协程的创建 create,在全局 stack 上为协程申请栈帧。

    • 协程的创建是创建一个闭包函数,将函数 (可以理解为需要执行的 op_array) 当作一个参数传入 Swoole 的内建函数 go();
  • 协程让出,yield,遇到 IO,保存当前栈帧的上下文信息
  • 协程的恢复,resume,IO 完成,恢复需要执行的协程上下文信息到 yield 让出前的状态
  • 协程的退出,exit, 协程 op_array 全部执行完毕,释放栈帧和 swoole 协程的相关数据。

经过上面的介绍大家应该对 Swoole 协程在运行过程中可以在函数内部实现跳转有一个大概了解,回到最初我们例子结合上面 php 执行细节,我们能够知道,该例子会生成 3 个 op_array, 分别为 主脚本,协程 1,协程 2。我们可以利用一些工具打印出 opcodes 来直观的观察一下。通常我们会使用下面两个工具

//Opcache, version >= PHP 7.1
php -d opcache.opt_debug_level=0x10000 test.php

//vld, 第三方扩展
php -d vld.active=1 test.php

我们用 opcache 来观察没有被优化前的 opcodes, 我们可以很清晰的看到这三组 op_array 的详细信息。

php -dopcache.enable_cli=1 -d opcache.opt_debug_level=0x10000 test.php
$_main: ; (lines=11, args=0, vars=0, tmps=4)
    ; (before optimizer)
    ; /path-to/test.php:2-6
L0 (2):     INIT_FCALL 1 96 string("go")
L1 (2):     T0 = DECLARE_LAMBDA_FUNCTION string("")
L2 (6):     SEND_VAL T0 1
L3 (6):     DO_ICALL
L4 (7):     ECHO string("main flag")
L5 (8):     INIT_FCALL 1 96 string("go")
L6 (8):     T2 = DECLARE_LAMBDA_FUNCTION string("")
L7 (12):    SEND_VAL T2 1
L8 (12):    DO_ICALL
L9 (13):    ECHO string("main end")
L10 (14):   RETURN int(1)

{closure}: ; (lines=6, args=0, vars=0, tmps=1)
    ; (before optimizer)
    ; /path-to/test.php:2-6
L0 (9):     ECHO string("coro 2 start")
L1 (10):    INIT_STATIC_METHOD_CALL 1 string("co") string("sleep")
L2 (10):    SEND_VAL_EX int(1) 1
L3 (10):    DO_FCALL//yiled from 当前 op_array [coro 1] ; resume
L4 (11):    ECHO string("coro 2 exit")
L5 (12):    RETURN null

{closure}: ; (lines=6, args=0, vars=0, tmps=1)
    ; (before optimizer)
    ; /path-to/test.php:2-6
L0 (3):     ECHO string("coro 1 start")
L1 (4):     INIT_STATIC_METHOD_CALL 1 string("co") string("sleep")
L2 (4):     SEND_VAL_EX int(1) 1
L3 (4):     DO_FCALL//yiled from 当前 op_array [coro 2];resume
L4 (5):     ECHO string("coro 1 exit")
L5 (6):     RETURN null
coro 1 start
main flag
coro 2 start
main end
coro 1 exit
coro 2 exit

Swoole 在执行 co::sleep()的时候让出当前控制权,跳转到下一个 op_array, 结合以上注释,也就是在 DO_FCALL 的时候分别让出和恢复协程执行栈,达到原生协程控制流跳转的目的。

我们分析下 INIT_FCALL DO_FCALL 指令在内核中如何执行。以便于更好理解函数调用栈切换的关系。

VM 内部指令会根据当前的操作数返回值等特殊化为一个 c 函数,我们这个例子中 有以下对应关系

INIT_FCALL => ZEND_INIT_FCALL_SPEC_CONST_HANDLER

DO_FCALL => ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER

ZEND_INIT_FCALL_SPEC_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
    USE_OPLINE

    zval *fname = EX_CONSTANT(opline->op2);
    zval *func;
    zend_function *fbc;
    zend_execute_data *call;

    fbc = CACHED_PTR(Z_CACHE_SLOT_P(fname));
    if (UNEXPECTED(fbc == NULL)) {func = zend_hash_find(EG(function_table), Z_STR_P(fname));
        if (UNEXPECTED(func == NULL)) {SAVE_OPLINE();
            zend_throw_error(NULL, "Call to undefined function %s()", Z_STRVAL_P(fname));
            HANDLE_EXCEPTION();}
        fbc = Z_FUNC_P(func);
        CACHE_PTR(Z_CACHE_SLOT_P(fname), fbc);
        if (EXPECTED(fbc->type == ZEND_USER_FUNCTION) && UNEXPECTED(!fbc->op_array.run_time_cache)) {init_func_run_time_cache(&fbc->op_array);
        }
    }

    call = zend_vm_stack_push_call_frame_ex(
        opline->op1.num, ZEND_CALL_NESTED_FUNCTION,
        fbc, opline->extended_value, NULL, NULL); // 从全局 stack 上申请当前函数的执行栈
    call->prev_execute_data = EX(call); // 将正在执行的栈赋值给将要执行函数栈的 prev_execute_data,函数执行结束后恢复到此处
    EX(call) = call; // 将函数栈赋值到全局执行栈,即将要执行的函数栈
    ZEND_VM_NEXT_OPCODE();}
ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
    USE_OPLINE
    zend_execute_data *call = EX(call);// 获取到执行栈
    zend_function *fbc = call->func;// 当前函数
    zend_object *object;
    zval *ret;

    SAVE_OPLINE();// 有全局寄存器的时候 ((execute_data)->opline) = opline
    EX(call) = call->prev_execute_data;// 当前执行栈 execute_data->call = EX(call)->prev_execute_data 函数执行结束后恢复到被调函数
    /*...*/
    LOAD_OPLINE();

    if (EXPECTED(fbc->type == ZEND_USER_FUNCTION)) {
        ret = NULL;
        if (0) {ret = EX_VAR(opline->result.var);
            ZVAL_NULL(ret);
        }

        call->prev_execute_data = execute_data;
        i_init_func_execute_data(call, &fbc->op_array, ret);

        if (EXPECTED(zend_execute_ex == execute_ex)) {ZEND_VM_ENTER();
        } else {ZEND_ADD_CALL_FLAG(call, ZEND_CALL_TOP);
            zend_execute_ex(call);
        }
    } else if (EXPECTED(fbc->type < ZEND_USER_FUNCTION)) {
        zval retval;

        call->prev_execute_data = execute_data;
        EG(current_execute_data) = call;
        /*...*/
        ret = 0 ? EX_VAR(opline->result.var) : &retval;
        ZVAL_NULL(ret);

        if (!zend_execute_internal) {
            /* saves one function call if zend_execute_internal is not used */
            fbc->internal_function.handler(call, ret);
        } else {zend_execute_internal(call, ret);
        }

        EG(current_execute_data) = execute_data;
        zend_vm_stack_free_args(call);// 释放局部变量

        if (!0) {zval_ptr_dtor(ret);
        }

    } else { /* ZEND_OVERLOADED_FUNCTION */
        /*...*/
    }

fcall_end:
        /*...*/
    }
    zend_vm_stack_free_call_frame(call);// 释放栈
    if (UNEXPECTED(EG(exception) != NULL)) {zend_rethrow_exception(execute_data);
        HANDLE_EXCEPTION();}
    ZEND_VM_SET_OPCODE(opline + 1);
    ZEND_VM_CONTINUE();}

Swoole 在 PHP 层可以按照以上方式来进行切换,至于执行过程中有 IO 等待发生,需要额外的技术来驱动,我们后续的文章将会介绍每个版本的驱动技术结合 Swoole 原有的事件模型,讲述 Swoole 协程如何进化到现在。

Swoole4 协程双栈

 由于我们系统存在 C 栈和 PHP 栈两部分,约定名字:

  • C 协程 C 栈管理部分,
  • PHP 协程 PHP 栈管理部分。

增加 C 栈是 4.x 协程最重要也是最关键的部分,之前的版本种种无法完美支持 PHP 语法也是由于没有保存 C 栈信息。接下来我们将展开分析,C 栈切换的支持最初我们是使用腾讯出品 libco 来支持,但通过压测会有内存读写错误而且开源社区很不活跃,有问题无法得到及时的反馈处理,所以,我们剥离的 c ++ boost 库的汇编部分,现在的协程 C 栈的驱动就是在这个基础上做的。

系统架构图

可以发现,Swoole 的角色是粘合在系统 API 和 php ZendVM,给 PHPer 用户深度接口编写高性能的代码; 不仅如此,也支持给 C ++/ C 用户开发使用,详细请参考文档 C ++ 开发者如何使用 Swoole。C 部分的代码主要分为几个部分:

  • 汇编 ASM 驱动
  • Conext 上下文封装
  • Socket 协程套接字封装
  • PHP Stream 系封装,可以无缝协程化 PHP 相关函数
  • ZendVM 结合层

Swoole 底层系统层次更加分明,Socket 将作为整个网络驱动的基石,原来的版本中,每个客户端都要基于异步回调的方式维护上下文,所以 4.x 版本较之前版本比较,无论是从项目的复杂程度,还是系统的稳定性,可以说都有一个质的飞跃。代码目录层级

$ tree swoole-src/src/coroutine/
swoole-src/src/coroutine/
├── base.cc // C 协程 API, 可回调 PHP 协程 API
├── channel.cc //channel
├── context.cc // 协程实现 基于 ASM make_fcontext jump_fcontext
├── hook.cc //hook
└── socket.cc // 网络操作协程封装
swoole-src/swoole_coroutine.cc //ZendVM 相关封装,PHP 协程 API

我们从用户层到系统至上而下有 PHP 协程 API, C 协程 API, ASM 协程 API。其中 Socket 层是兼容系统 API 的网络封装。我们至下而上进行分析。ASM x86-64 架构为例,共有 16 个 64 位通用寄存器,各寄存器及用途如下:

  • %rax 通常用于存储函数调用的返回结果,同时也用于乘法和除法指令中。在 imul 指令中,两个 64 位的乘法最多会产生 128 位的结果,需要 %rax 与 %rdx 共同存储乘法结果,在 div 指令中被除数是 128 位的,同样需要 %rax 与 %rdx 共同存储被除数。

    • %rsp 是堆栈指针寄存器,通常会指向栈顶位置,堆栈的 pop 和 push 操作就是通过改变 %rsp 的值即移动堆栈指针的位置来实现的。
    • %rbp 是栈帧指针,用于标识当前栈帧的起始位置
    • %rdi, %rsi, %rdx, %rcx,%r8, %r9 六个寄存器用于存储函数调用时的 6 个参数
  • %rbx,%r12,%r13,%14,%15 用作数据存储,遵循被调用者使用规则

%r10,%r11 用作数据存储,遵循调用者使用规则

也就是说在进入汇编函数后,第一个参数值已经放到了 %rdi 寄存器中,第二个参数值已经放到了 %rsi 寄存器中,并且栈指针 %rsp 指向的位置即栈顶中存储的是父函数的返回地址 x86-64 使用 swoole-src/thirdparty/boost/asm/make_x86_64_sysv_elf_gas.S

// 在当前栈顶创建一个上下文,用来执行执行第三个参数函数 fn,返回初始化完成后的执行环境上下文
fcontext_t make_fcontext(void *sp, size_t size, void (*fn)(intptr_t));
make_fcontext:
    /* first arg of make_fcontext() == top of context-stack */
    movq  %rdi, %rax

    /* shift address in RAX to lower 16 byte boundary */
    andq  $-16, %rax

    /* reserve space for context-data on context-stack */
    /* size for fc_mxcsr .. RIP + return-address for context-function */
    /* on context-function entry: (RSP -0x8) % 16 == 0 */
    leaq  -0x48(%rax), %rax

    /* third arg of make_fcontext() == address of context-function */
    movq  %rdx, 0x38(%rax)

    /* save MMX control- and status-word */
    stmxcsr  (%rax)
    /* save x87 control-word */
    fnstcw   0x4(%rax)

    /* compute abs address of label finish */
    leaq  finish(%rip), %rcx
    /* save address of finish as return-address for context-function */
    /* will be entered after context-function returns */
    movq  %rcx, 0x40(%rax)

    ret /* return pointer to context-data * 返回 rax 指向的栈底指针,作为 context 返回 /
// 将当前上下文 (包括栈指针,PC 程序计数器以及寄存器) 保存至 *ofc,从 nfc 恢复上下文并开始执行。intptr_t jump_fcontext(fcontext_t *ofc, fcontext_t nfc, intptr_t vp, bool preserve_fpu = false);

jump_fcontext:
// 保存当前寄存器,压栈
    pushq  %rbp  /* save RBP */
    pushq  %rbx  /* save RBX */
    pushq  %r15  /* save R15 */
    pushq  %r14  /* save R14 */
    pushq  %r13  /* save R13 */
    pushq  %r12  /* save R12 */

    /* prepare stack for FPU */
    leaq  -0x8(%rsp), %rsp

    /* test for flag preserve_fpu */
    cmp  $0, %rcx
    je  1f

    /* save MMX control- and status-word */
    stmxcsr  (%rsp)
    /* save x87 control-word */
    fnstcw   0x4(%rsp)

1:
    /* store RSP (pointing to context-data) in RDI  保存当前栈顶到 rdi 即: 将当前栈顶指针保存到第一个参数 %rdi ofc 中 */
    movq  %rsp, (%rdi)

    /* restore RSP (pointing to context-data) from RSI 修改栈顶地址,为新协程的地址,rsi 为第二个参数地址 */
    movq  %rsi, %rsp

    /* test for flag preserve_fpu */
    cmp  $0, %rcx
    je  2f

    /* restore MMX control- and status-word */
    ldmxcsr  (%rsp)
    /* restore x87 control-word */
    fldcw  0x4(%rsp)

2:
    /* prepare stack for FPU */
    leaq  0x8(%rsp), %rsp
// 寄存器恢复
    popq  %r12  /* restrore R12 */
    popq  %r13  /* restrore R13 */
    popq  %r14  /* restrore R14 */
    popq  %r15  /* restrore R15 */
    popq  %rbx  /* restrore RBX */
    popq  %rbp  /* restrore RBP */

    /* restore return-address  将返回地址放到 r8 寄存器中 */
    popq  %r8

    /* use third arg as return-value after jump*/
    movq  %rdx, %rax
    /* use third arg as first arg in context function */
    movq  %rdx, %rdi

    /* indirect jump to context */
    jmp  *%r8

context 管理位于 context.cc,是对 ASM 的封装,提供两个 API

bool Context::SwapIn()
bool Context::SwapOut()

最终的协程 API 位于 base.cc, 最主要的 API 为

// 创建一个 c 栈协程,并提供一个执行入口函数,并进入函数开始执行上下文
// 例如 PHP 栈的入口函数 Coroutine::create(PHPCoroutine::create_func, (void*) &php_coro_args);
long Coroutine::create(coroutine_func_t fn, void* args = nullptr); 
// 从当前上下文中切出,并且调用钩子函数 例如 php 栈切换函数 void PHPCoroutine::on_yield(void *arg)
void Coroutine::yield()
// 从当前上下文中切入,并且调用钩子函数 例如 php 栈切换函数 void PHPCoroutine::on_resume(void *arg)
void Coroutine::resume()
// C 协程执行结束,并且调用钩子函数 例如 php 栈清理 void PHPCoroutine::on_close(void *arg)
void Coroutine::close()

接下来是 ZendVM 的粘合层 位于 swoole-src/swoole_coroutine.cc

PHPCoroutine 供 C 协程或者底层接口调用
//PHP 协程创建入口函数,参数为 php 函数
static long create(zend_fcall_info_cache *fci_cache, uint32_t argc, zval *argv);
// C 协程创建 API
static void create_func(void *arg);
// C 协程钩子函数 上一部分 base.cc 的 C 协程会关联到以下三个钩子函数
static void on_yield(void *arg);
static void on_resume(void *arg);
static void on_close(void *arg);
//PHP 栈管理
static inline void vm_stack_init(void);
static inline void vm_stack_destroy(void);
static inline void save_vm_stack(php_coro_task *task);
static inline void restore_vm_stack(php_coro_task *task);
// 输出缓存管理相关
static inline void save_og(php_coro_task *task);
static inline void restore_og(php_coro_task *task);

有了以上基础部分的建设,结合 PHP 内核执行栈管理,就可以从 C 协程驱动 PHP 协程,实现 C 栈 +PHP 栈的双栈的原生协程。

作者信息

韩天峰,Swoole 开源项目创始人,学而思网校首席架构师。

正文完
 0