学习swoole之前,你需要知道的几件事

学习 swoole 需要的前置知识#

学习一项新的技术,最重要的就 why、what、how。

这门技术是什么,为什么用它,要怎么用它。这篇文档的作用就是为了解释 what 与 why。

php-fpm 与 swoole 的异同

同步与异步

cpu 上下文切换
事件循环–异步是如何实现的
总结

php-fpm 与 swoole 的异同#

常驻内存#

一个请求通过 nginx 转发到 php 来运行,中间是通过 php-fpm 来沟通连接的,通过一种叫 cgi 的协议来通信。

在 php-fpm 还未出现之前,php 一般都是通过 php-cgi 这个 php 官方组件来与 web server 进行通信的,它的特点是每次运行都需要重新加载一次 php.ini 中的配置,并且每有一个请求都会重新启动一个进程来执行,执行完毕后再销毁掉。

这种方式每次都会重新解析 php.ini、重新载入全部扩展,并重新初始化全部数据结构,这无疑是对 cpu 性能的浪费行为,于是就出现了 fast cgi

fast cgi 的应用体现便是 php-fpm。通过一个 master 进程管理多个 worker 进程的方式,在主进程启动时便将 php.ini 中的配置等信息载入内存,worker 进程创建时会继承 master 进程的数据,并且 worker 进程处理完一个请求后不会销毁,而是继续等待下一个请求。所以只需要一次加载,php.ini 与扩展和初始化数据这部分的性能便被节省出来了。虽然 php.ini 的配置初始化被节省掉了,但是我们平时使用的 laravel 等 php 开发框架中同样有冗长的 ioc 容器初始化,依赖创建的过程,这个问题 php-fpm 就无能为力了。

那么说完了 php-fpm,这些和 swoole 又有什么关系呢?相同点就在于,swoole 也是常驻内存的,也是一个 master 管理多个 worker 进程,所以也节省掉了多次载入 php.ini 等配置的消耗。

并且,由于swoole本身就是一个PHP扩展,它的启动是在php脚本内开始的,因此可以看做是php开发框架的一部分,那么php框架初始化的那一系列重复初始化便同样被节省掉了。这便是 swoole 高性能的原因。

swoole 在宣传上写的是为了解决传统 php-fpm 模式并发慢的问题而诞生的,那么就带来一个问题:

php-fpm 模式为什么慢?#

php-fpm 模式是以多进程方式来运行的,一个 master 进程创建并管理多个 work 进程。master 进程只负责接收请求和返回响应,剩下的运行工作交给 work 进程来执行。

alt php-fpm

也就是说每一个请求都对应一个 work 进程,同一时刻,服务器上有多少 work 进程,这台服务器就可以处理多少的并发。
这么一看是不是觉得 php-fpm 的并发能力特别差?假设不考虑服务器配置问题,默认的 400 个进程数同时就只能支持 400 的并发。
实际情况肯定没有这么差,假设很多的脚本只需要 0.001 秒就处理完成了,如果所有的请求都可以快速处理的话,那么我们可以说 1 秒钟的并发数就等于 400*1000=40 万的并发。

这么一看是不是觉得 php-fpm 的性能也没这么差了?

但是,如果你的业务数据量很大,mysql 的查询效率不高,每次请求都需要花费 1 秒钟的时间才能返回响应的话呢?
那么每秒钟的并发数就从 40W 又下降回 400 了。

而 swoole,就是为了解决这个问题所开发出来的一个 php 扩展,它使得每个 worker 进程不会因为 1 秒钟的 io 阻塞而白白让 cpu 浪费 1 秒钟的性能。

swoole 的运行方式#

按照刚刚的那个例子来解释的话,swoole 的处理方式就是在一个 worker 进程开始进行 mysql io 查询的时候就将这个请求任务暂时挂起,立马开始执行下一个请求,然后等到第一个请求中的 mysql io 数据返回之后,再切换回第一个请求中继续执行代码返回响应。这样一来,对于 cpu 来说,它一直在执行代码,没有因为请求中 mysql 的 1 秒 io 耗时处于空闲状态。

那么,既然那 1 秒的 io 耗时没有对 cpu 产生影响,那么对于服务器来说,每一秒钟的并发数和之前一样仍然是 40W,只不过由于每个请求还是有 1 秒的耗时,所以单个请求的响应时间依然是 1 秒钟,但是对于 cpu 来说,它每秒处理的请求数量并没有减少,因为对于 cpu 来说一个请求的 io 耗时是 1 秒,1000 个请求的总耗时依旧是 1 秒。

同步与异步#

什么是同步#

我们平时编写的 php 代码就是同步代码。php 解释器一行一行的编译运行我们的代码,碰到数据库查询,或者第三方接口调用,或者系统磁盘读写。这些不归 php 当前进程管辖的部分都是 io 操作,它们可能是磁盘 io,可能是网络 io。而同步的代码一旦碰到这些 io 操作,它们就会停下来等待,等待 mysql 返回查询结果,等待第三方接口返回响应,等待 linxu 文件系统返回磁盘读取结果。在等待的过程中,cpu 的性能就被浪费掉了。

什么是异步#

异步代码就是说代码在运行到一个需要等待的 io 操作时,不在原地傻等,而是继续向下执行其他代码,等到 io 操作有返回结果通知的时候再回过头来执行处理逻辑。

一个简单的例子就是 js 中的 Ajax,下面这个例子当中,js 把 Ajax 请求发送出去就开始执行下一行代码了,所以是先 alert 2,然后等到 Ajax 响应返回再执行回调函数 alert1。

$.ajax({
    url:"http://www.test.com/get_data",
    success: function (result) {
        alert(1);
    }
});

alert(2);

对于生活中的例子来说,异步就是一个人同时使用洗衣机洗衣服与使用电饭煲做饭,假设洗衣机洗一次衣服要 40 分钟,电饭煲煮饭也需要 40 分钟,那么这两件事都完成需要多长时间呢?

是的,也是 40 分钟(最多多出一些把衣服和米分别放进机器的可以忽略不计的时间),因为人把衣服放进洗衣机就可以去做其他事了,不会守在洗衣机旁傻等,可以去开电饭煲了。而洗衣机洗好衣服以后,会有滴滴声提示人衣服已经洗好了。

回到一开始那个 swoole 并发数的例子,那些需要 1 秒钟来查询 mysql 数据的请求,它们的那 1 秒 io 操作也没有让 cpu 进行傻等,所以对于 cpu 来说,io 操作已经无法影响它的并发数了,因为它始终在工作,并没有浪费等待时间。

解析一下,如果使用异步的方式,那么会有两个比较关键的点:

  • 发起 io 操作,添加回调函数
  • 等任务完成后执行回调函数

异步编程完全没有浪费 cpu 一点性能,那如果所有的 io 耗时操作都用异步操作会怎么样呢?
了解 node.js 的朋友可能经常听见一个词回调地狱

前端开发中很少会有人在 Ajax 中嵌套 Ajax,但是如果你想通过异步的方式来提升代码的性能,那么不可避免的,只要你的程序中有多个 io 操作,那它们就会向下面这段代码一样变成层层嵌套,很快这段代码就变得不可维护了,甚至是修改的时候都会让人十分头疼。


login(user => {
    getStatus(status => {
        getOrder(order => {
            getPayment(payment => {
                getRecommendAdvertisements(ads => {
                    setTimeout(() => {
                        alert(ads)
                    }, 1000)
                })
            })
        })
    })
})

而 swoole 的出现,就是为了解决同步代码浪费性能的问题,让同步执行的代码变为异步执行,同时使用协程降低异步回调编程时的心智负担。

cpu 上下文切换#

现在的电脑,一边写代码,一边查文档,一边听音乐都是很常见的,因为 cpu 的核心数很多可以同时做好几件事。但是你在一开始学习 for 循环的时候一定听老师说过,当年的单核 cpu 写循环一定要小心,因为一旦出现死循环了,那么整台电脑都会卡死只能重启了。

cpu 在执行代码的时候是同步的,所以理论上来讲同一时刻只能做一件事,哪怕不进行死循环,按理说之前的老电脑也没办法做到同时写代码与查文档以及听音乐这些事才对,并且就算是现在的四核八核 cpu,那我也是可以同时开十几个网页,同时播放视频的。

让单核 cpu 同时运行多任务的魔法就是上下文切换了,主要的原理就是 cpu 在同时进行玩游戏与播放音乐时,先运行一会游戏,然后马上切到音乐程序上运行一会,不断地在这些应用之间来回切换运行,因为 cpu 的计算速度是远超人脑反应时间的,所以在人类眼中,这些应用就像是在同时运行一样。

那到底什么是上下文呢?就是程序运行中所需要的数据,包括存储在内存中的,以及 cpu 多级寄存器中的这些数据。在线程与进程切换的时候,需要把这些数据保存起来,等到它们恢复运行的时候再把数据读取回进程来运行。

本文主要是介绍 swoole 的,swoole 的重点在于异步与协程,为什么要提到上下文切换呢?因为不论是多进程、多线程还是协程,它们本质上都需要用到上下文切换来实现的。

多进程模式,是由系统来决定每个进程的运行分片时长。而多线程由于它们一定有一个父级进程,所以每个线程的运行分片时长则是由进程来决定的。这也是为什么多线程语言的教程里都会提到不是线程开的越多越好的原因,多线程会有线程争抢和系统调度的开销。同时,由于 cpu 同一段时间内运算速度的总量是固定的,所以线程只需要尽量把 cpu 空闲的算力占满就好,开过多的线程反而会因为增加系统线程调度开销造成业务部分线程性能的下降。

那么协程与多线程多进程又有什么不同呢?通过实例来对比:

  • php-cgi 便是多进程的一种体现,每个请求对应一个 cgi 进程,带来的缺点是进程频繁创建销毁的开销以及每次都需要加载 php.ini 配置的性能浪费。
  • php-fpm 多进程模式的改良,通过 master/worker 的模式,让多进程少了重新加载配置与频繁创建销毁进程的开销。
  • 假设 php 有多线程,省略多次 php.ini 的加载,省略多次开发框架初始化,相应的带来线程调度开销,多线程抢占式模型需要注意数据访问的线程安全,需要给数据加锁,并带来锁争抢与死锁问题。
  • 协程,省略多次 php.ini 加载,省略多次开发框架初始化,由于协程是用户态的线程,所以由代码来控制什么时候进行切换,没有线程调度开销。并且 swoole 以同步的方式编写异步代码,协程的切换由底层调度器自行切换,开发者无需关注线程锁与死锁问题。

swoole 的协程切换是基于 io 来调度的,也就是说它只会在遇到 io 操作的时候才会进行切换,通过节省 io 等待时间来提高服务器性能,因此 swoole 的协程是无法进行并发计算的。不过遇到需要并行计算的场景,swoole 也提供了多进程的运行方式,如果需要多进程协同操作同一个数据,就需要加进程锁了。

事件循环–异步是如何实现的#

现在我们已经知道多进程,多线程,协程都是异步的编程方式了,那么异步是怎么实现的呢?
这是一个大问题,先从最基础的看起,基础异步编程就是异步回调模式,也就是在执行任务的同时传入一个回调函数,等到任务执行完毕,回调函数自然而然的就开始运行了。类似 js 的 Ajax 一样,发起一个 Ajax 请求的时候便是发起了异步任务,同时在 $.ajax 方法的第三个参数传入一个匿名函数,等到后端返回响应以后再继续执行回调函数中的代码。

那么就出现了一个问题,是谁来通知当前进程异步任务已经完成了的呢?

做过 im 通信朋友都知道,两个客户端的对话除了发送消息,最难实现的还是接收消息,因为需要服务端主动做推送。如果不使用 WebSocket 的话,要实现服务端推送就只能使用长连接 + 轮询的方式了。接收消息的那一方客户端需要每隔一段时间就请求一次服务器,看看有没有消息发送给自己。对于异步回调来说,它的实现方式也是有异曲同工之处。

处理异步回调的部分叫做事件循环,可以理解为每个进程有一个死循环,不断的查看当前有没有待执行的任务、已经执行完需要通知的回调。当我们进行异步任务调用的时候,就是向这个循环中投递了一个任务与对应的回调。当任务完成的时候,循环便把任务从监听数组中去除,并执行回调。

下面来看一个简单的事件循环的例子。

可以看到,EventLoop 类中维护了一个 event 数组,用来存储需所有需要监听的事件。在调用 addEventHandler 方法时,则需要将事件的类型、参数,以及回调函数一同传入。

当调用 run 方法时,这个循环就被开启了,可以看到 run 方法中是一个 while 死循环,用来不断的检测是否有已完成的任务。而 while 循环内层的 foreach 则是为了查看所有事件中是否有已完成的单个任务。

processTimersprocessIOEvents 方法则代表了 swoole 中典型的两种事件,io 事件与定时器。由于 linux 系统中万物皆文件的特性,很多看似是网络 io 的功能,其实都要用到文件系统来实现,所以 processIOEvents 方法需要传入 fp 文件指针以及 read 与 write 两种读写事件。例如假设我们投递的是读取事件,那么就调用 fread 函数来读取文件,并把读取到的数据传递给回调函数来执行。这就是一个事件循环的回调过程了。

<?php

class EventLoop
{
    private $event_handlers = [];

    public function addEventHandler($event_type, $handler)
    {
        $this->event_handlers[$event_type][] = $handler;
    }

    public function run()
    {
        while (true) {
            foreach ($this->event_handlers as $event_type => $handlers) {
                switch ($event_type) {
                    case 'timer':
                        $this->processTimers($handlers);
                        break;
                    case 'io':
                        $this->processIOEvents($handlers);
                        break;
                    // 可以根据需求添加更多事件类型
                }
            }
        }
    }

    private function processTimers($handlers)
    {
        $now = time();
        foreach ($handlers as $timer) {
            if ($now >= $timer['time']) {
                $timer['callback']();
                if ($timer['interval']) {
                    $timer['time'] = $now + $timer['interval'];
                } else {
                    unset($this->event_handlers['timer'][array_search($timer, $handlers)]);
                }
            }
        }
    }

    private function processIOEvents($handlers)
    {
        foreach ($handlers as $io_event) {
            $fp = $io_event['fp'];
            $callback = $io_event['callback'];
            $events = $io_event['events'];
            $read = in_array('read', $events);
            $write = in_array('write', $events);

            if ($read) {
                $read_data = fread($fp, 8192);
                if ($read_data) {
                    $callback($read_data);
                } else {
                    unset($this->event_handlers['io'][array_search($io_event, $handlers)]);
                    fclose($fp);
                }
            }

            if ($write) {
                $callback();
                unset($this->event_handlers['io'][array_search($io_event, $handlers)]);
                fclose($fp);
            }
        }
    }
}

// 使用示例
$loop = new EventLoop();

// 添加定时器
$loop->addEventHandler('timer', [
    'time' => time() + 5,
    'interval' => 0,
    'callback' => function () {
        echo "5 seconds have passed\n";
    },
]);

// 添加 IO 事件
$fp = fopen(__FILE__, 'r');
$loop->addEventHandler('io', [
    'fp' => $fp,
    'events' => ['read', 'write'],
    'callback' => function ($read_data = null) {
        if ($read_data) {
            echo "read data: $read_data";
        } else {
            echo "io event has occurred\n";
        }
    },
]);

// 启动事件循环
$loop->run();

在理解了时间循环以后,那么事件循环与 swoole 与多进程、多线程、协程之间有什么关系呢?

没错,无论是多进程、多线程还是协程,它们底层都依赖事件循环来实现异步,例如进程与线程之间切换的时候如何通知对应的进程与线程?依赖系统级事件循环。例如协程之间多个协程的切换要如何通知对应的协程?也是依赖事件循环。不过 swoole 为了降低上下文切换带来的消耗,没有依赖系统级事件循环而是自己实现了一套,swoole 的协程上下文切换都是内存读取,避免了 cpu 寄存器、堆栈以及系统内核态与用户态之间的切换,因此切换开销极小。

总结#

说了这么多概念,那么 swoole 到底是什么呢?它融合了 php-fpm 的结构模式,优化了单进程的性能浪费,弱化了多线程的调度开销,屏蔽了异步回调的复杂逻辑,是一个常驻内存的高性能 web 扩展。

做一个不严谨的类比,你也可以认为 swoole 是一个语言层面实现的 php-fpm,毕竟 swoole 也支持完全的多进程模式,这种模式下与 php-fpm 的运行方式大同小异。不过由于在语言层面便常驻内存了,所以带来的福利便是在启动 php 脚本的开发框架时,只需要一次载入便保存在内存中了,避免了 php-fpm 每个请求都重新初始化框架的性能浪费。那么同样的由于服务常驻内存了,所以哪怕是在开发过程中,代码相关的改动都需要重启一下 swoole 服务。

而 swoole 的架构,对应下面这张图,便是 master、manager、worker 的结构,在 swoole 服务启动时,master 进程便 fork 出 manager 进程来对 worker 进程进行创建和管理,master 进程自己则通过 reactor 线程来接受与分发请求,master 进程接收到的请求通过 reactor 线程直接发送到 worker 进程中,而 worker 进程负责对请求进行具体的处理。如果开启了协程模式,并且代码也是以协程的方式运行,则一个 worker 可能会一段时间内 (例如 1s) 处理多个请求。因为每个请求遇到 io 等待时,worker 便切换协程直接开始处理下一个请求了,直到 io 任务返回结果,worker 再切换回上一个请求将响应返回给 master 进程。

alt swoole

swoole 对性能的提升带来的代价是编程思维的转变,因为常驻内存了,所以编写业务代码时,对内存变量的使用就需要更加小心,避免造成内存泄露。因为基于异步编程,所以要理解异步的思想,避免写出同步阻塞的代码。

本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 2年前 自动加精
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 13
jiangjun

厉害

2年前 评论
cl_echo (楼主) 2年前

第一次理解了一点事件循环,mark

2年前 评论

同步异步阻塞非阻塞没讲好, 应该讲理论, 同步阻塞模型,同步非阻塞模型,异步模型,以及协程

2年前 评论
tsingyan (作者) 2年前
cl_echo (楼主) 2年前