基于 Swoole 实现协程篇(一):基本概念和底层原理


协程是什么

协程可以理解为纯用户态的线程,其通过协作而不是抢占来进行切换,相对于进程或者线程,协程所有的操作都可以在用户态完成,创建和切换的消耗更低,Swoole 可以为每一个请求创建对应的协程,根据 IO 的状态来合理的调度协程。

在 Swoole 4.x 中,协程(Coroutine)取代了异步回调,成为 Swoole 官方推荐的编程方式。Swoole 协程解决了异步回调编程困难的问题,使用协程可以以传统同步编程的方法编写代码,底层自动切换为异步 IO,既保证了编程的简单性,又可借助异步 IO,提升系统的并发能力。

注:Swoole 4.x 之前的版本也支持协程,不过 4.x 版本对协程内核进行了重构,功能更加强大,提供了完整的协程+通道特性,带来全新的 CSP 编程模型,后续介绍和示例都是基于 Swoole 4.x 版本。

基本使用示例

  • PHP 版本要求:>= 7.0;
  • 基于 ServerHttp\ServerWebSocket\Server 进行开发的时候,Swoole 底层会在 onRequestonReceiveonConnect 等事件回调之前自动创建一个协程,在回调函数中即可使用协程 API;
  • 你也可以使用 Coroutine::creatego 方法创建协程,在创建的协程中使用协程 API 进行编程。

以 Swoole 自带的 TCP 服务器 Swoole\Server 实现为例,我们可以定义服务器端实现如下:

$server = new \Swoole\Server("127.0.0.1", 9501);

// 调用 onReceive 事件回调函数时底层会自动创建一个协程
$server->on('receive', function ($serv, $fd, $from_id, $data) {
    // 向客户端发送数据后关闭连接(在这里面可以调用 Swoole 协程 API)
    $serv->send($fd, 'Swoole: ' . $data);
    $serv->close($fd);
});

$server->start();

然后我们以协程方式实现 TCP 客户端如下:

// 通过 go 函数创建一个协程
go(function () {
    $client = new Swoole\Coroutine\Client(SWOOLE_SOCK_TCP);
    // 尝试与指定 TCP 服务端建立连接,这里会触发 IO 事件切换协程,交出控制权让 CPU 去处理其他事情
    if ($client->connect("127.0.0.1", 9501, 0.5)) {
        // 建立连接后发送内容
        $client->send("hello world\n");
        // 打印接收到的消息(调用 recv 函数会恢复协程继续处理后续代码,比如打印消息、关闭连接)
        echo $client->recv();
        // 关闭连接
        $client->close();
    } else {
        echo "connect failed.";
    }
});

底层实现原理

我们以 MySQL 连接查询为例,对 Swoole 协程底层实现做一个简单的介绍:

$server = new Swoole\Http\Server('127.0.0.1', 9501, SWOOLE_BASE);

#1
$server->on('Request', function($request, $response) {
    $mysql = new Swoole\Coroutine\MySQL();
    #2
    $res = $mysql->connect([
        'host' => '127.0.0.1',
        'user' => 'root',
        'password' => 'root',
        'database' => 'test',
    ]);
    #3
    if ($res == false) {
        $response->end("MySQL connect fail!");
        return;
    }
    $ret = $mysql->query('show tables', 2);
    $response->end("swoole response is ok, result=".var_export($ret, true));
});

$server->start();

在这段代码中,我们启动一个基于 Swoole 实现的 HTTP 服务器监听客户端请求,如果有 onRequest 事件发生,则通过基于 Swoole 协程实现的异步 MySQL 客户端组件对 MySQL 服务器发起连接请求,并执行查询操作,然后将结果以响应方式返回给 HTTP 客户端,下面我们来看一下协程在这段代码中的应用:

  • 调用 Swoole\Http\ServeronRequest 事件回调函数时,底层会调用 C 函数 coro_create 创建一个协程(#1位置),同时保存这个时间点的 CPU 寄存器状态和 ZendVM 堆栈信息;
  • 调用 mysql->connect 时会发生 IO 操作,底层会调用 C 函数 coro_save 保存当前协程的状态,包括 ZendVM 上下文以及协程描述信息,并调用 coro_yield 让出程序控制权,当前的请求会挂起(#2位置);
  • 协程让出程序控制权后,会继续进入 HTTP 服务器的事件循环处理其他事件,这时 Swoole 可以继续去处理其他客户端发来的请求;
  • 当数据库 IO 事件完成后,MySQL 连接成功或失败,底层调用 C 函数 coro_resume 恢复对应的协程,恢复 ZendVM 上下文,继续向下执行 PHP 代码(#3位置);
  • mysql->query 的执行过程与 mysql->connect 一样,也会触发 IO 事件并进行一次协程切换调度;
  • 所有操作完成后,调用 end 方法返回结果,并销毁此协程。

注:更深层次的协程底层实现可以参考 Swoole 官方文档的介绍。

上面这段代码我们借助了 Swoole 实现的协程 MySQL 客户端(Swoole 还提供了很多其他协程客户端,如 Redis、HTTP等,后面我们会详细介绍),所有的编码和之前编写同步代码时并没有任何不同,但是 Swoole 底层会在 IO 事件发生时,保存当前状态,将程序控制权交出,以便 CPU 处理其它事件,当 IO 事件完成时恢复并继续执行后续逻辑,从而实现异步 IO 的功能,这正是协程的强大之处,它可以让服务器同时可以处理更多请求,而不会阻塞在这里等待 IO 事件处理完成,从而极大提高系统的并发性。

协程的适用场景

通过上面这个简单的示例,我们得出协程非常适合并发编程,常见的并发编程场景如下:

  • 高并发服务,如秒杀系统、高性能 API 接口、RPC 服务器,使用协程模式,服务的容错率会大大增加,某些接口出现故障时,不会导致整个服务崩溃;
  • 爬虫,可实现非常强大的并发能力,即使是非常慢速的网络环境,也可以高效地利用带宽;
  • 即时通信服务,如 IM 聊天、游戏服务器、物联网、消息服务器等等,可以确保消息通信完全无阻塞,每个消息包均可即时地被处理。

协程引入的问题

协程再为我们带来便利的同时,也引入了一些新的问题:

  • 协程需要为每个并发保存栈内存并维护对应的虚拟机状态,如果程序并发很大可能会占用大量内存;
  • 协程调度会增加额外的一些 CPU 开销。

尽管如此,在处理高并发应用时,使用协程带来的优势还是远远高于 PHP 默认的同步阻塞机制。

协程 vs 线程

Swoole 的协程在底层实现上是单线程的,因此同一时间只有一个协程在工作,协程的执行是串行的,这与线程不同,多个线程会被操作系统调度到多个 CPU 并行执行。

一个协程正在运行时,其他协程会停止工作。当前协程执行阻塞 IO 操作时会挂起,底层调度器会进入事件循环。当有 IO 完成事件时,底层调度器恢复事件对应的协程的执行。

在 Swoole 中对 CPU 多核的利用,仍然依赖于 Swoole 引擎的多进程机制。

协程 vs 生成器

一些框架中会使用 PHP 的生成器来实现半自动化的协程,但在实际使用中,开发者需要在涉及协程逻辑的函数调用前增加 yield 关键字,这带来了额外的学习和维护成本,非常容易犯错,此外 Yield/Generator 代码风格与传统的同步风格代码存在冲突,无法复用已有代码。

Swoole 协程是全自动化的协程,开发者无需添加任何关键字,底层自动实现协程的切换和调度,此外,Swoole 协程风格与传统的同步风格代码是一致的,因此可以复用已有代码。

使用时的注意事项

编程范式

  • 协程之间通讯不要使用全局变量或者引用外部变量到当前作用域,而要使用 Channel(后面会介绍具体使用)
  • 项目中如果有扩展 hook 了 zend_execute_ex 或者 zend_execute_internal 这两个函数,需要特别注意一下 C 栈,可以使用 co::set 重新设置 C 栈大小

扩展冲突

由于某些跟踪调试的 PHP 扩展大量使用了全局变量,可能会导致 Swoole 协程发生崩溃,请关闭这些相关扩展:

  • xdebug
  • phptrace
  • aop
  • molten
  • xhprof
  • phalcon(Swoole协程无法运行在 phalcon 框架中)

严重错误

由于多个协程是并发执行的,所以以下行为可能会导致协程出现严重错误:

  • 不能使用类静态变量/全局变量保存协程上下文内容,否则可能导致变量被污染,要使用 Context 管理上下文
  • 同一时间可能会有很多个请求在并行处理,多个协程共用一个客户端连接的话,就会导致不同协程之间发生数据错乱

错误和异常处理

在协程编程中可直接使用 try/catch 处理异常,但必须在协程内捕获,不得跨协程捕获异常。

此外,如果在协程内使用 exit 终止程序执行退出当前协程的话,会抛出 Swoole\ExitException 异常,你可以在需要的位置捕获该异常并实现与原生 PHP 一样的退出逻辑:

go(function () {
    try {
        Swoole\Coroutine::sleep(1);  // 模拟 IO 事件让出控制权
        exit(SWOOLE_EXIT_IN_COROUTINE);
    } catch (Swoole\ExitException $exception) {
        assert($exception->getStatus() === 1);
        assert($exception->getFlags() === SWOOLE_EXIT_IN_COROUTINE);
        return;
    }
});

注:不能将 go 函数放到 try 语句块中,这样就是跨协程捕获异常了。


点赞 取消点赞 收藏 取消收藏

<< 上一篇: 通过 Swoole\Table 实现 Swoole 多进程数据共享

>> 下一篇: 基于 Swoole 实现协程篇(二):通过协程实现并发编程