PHPer跳出fpm开发模式拥抱高性能异步协程开发

4次阅读

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

1. 前言

本人是个有 4 年 php 开发经验的程序媛,诚然一直到目前为止都沉浸在 PHP 作为 WEB 服务端开发的快感中。由于最近在工作上碰到了瓶颈,想尽快的跳脱出这个舒适圈。本文适合想跳脱只做 CURD 的 php 程序员们,但是文章中探讨的 "高并发"、“异步”、“协程”,这些概念或者说是 计算机设计艺术,是不受编程语言所局限的,其他编程语言的程序员想了解这些概念也是合适的。文章是分析的是 PHP 语言,demo 也都是以 PHP 描述的,高并发协程异步的例子会使用 SWOOLE 描述,会借用 SWOOLE 进行简单的分析,但绝不是给 SWOOLE 打广告,只是为了给大家一个具象的体会。相信当你足够牛逼的时候,也能自己开发一个这样的一个工具,这也是我近 2 年的目标,当然还要吸取很多的知识。

php 的优势:1、简单,PHP 相比其他语言更容易入门和掌握。PHP 常用的数据结构都内置了,使用起来方便简单,也一点都不复杂。2、功能非常强大,PHP 官方的标准库和扩展库里提供了做服务器编程能用到的 99% 的东西。3、web 编程领域,LNMP 框架下服务的工业级稳定和可靠性。4、能快速开发。

但纵观 PHP 在编程语言领域的排行却在是逐年下滑,当然在传统 web 开发领域还是占绝对霸主地位的。但在移动互联网、云计算、大数据、人工智能等其他大的领域都没有生态。除了 web 生态,几乎没有其他的生态。但即使是在 web 领域,有高并发,大流量要求的 web 项目也在逐渐被 java 等语言所重写 (有了 swoole 可能好一些)。
做不了复杂大型的 server,PHP 程序员陷入在无止尽的 CURD 的业务开发中。

2. PHP 的劣势

剖析下 php 的劣势,到底哪些问题导致 php 除了 web 领域外,没有生态。

1、单进程单线程模型,后期代码层面提速空间有限。`yield?  pthreads?`
2、核心异步网络不支持。`libevent?`
3、cli 模式编程不够强大

针对 1、2 问题,PHP 没有其他语言内置支持的线程、协程的调度模型,单进程单线程模型 + 不支持异步编程,导致一个客户端连接就要占一整个进程资源,一个一旦执行到 I / O 相关的阻塞代码,整个进程都会陷入睡眠,让出 cpu 控制权给其他进程。直到 I / O 返回,再重新等待分配 cpu 资源执行后续的代码。
有个 误区 想说明下,实际上同步阻塞程序的性能并不差,它的效率很高,不会浪费资源 。当进程发生阻塞后,操作系统会将它挂起,不会分配 CPU,并不是说发生阻塞就使该进程所占的 cpu 时间片内发生 cpu 空转现象。只是 QPS 低,在高并发的请求下,后来的接口响应会越来越慢,操作系统可以创建的进程数量是有限的,还有大量进程会带来额外的进程调度消耗。
那么横向扩展进程数量来解决并发问题呢?并发能离取决于工作进程的数量,还有大量进程间的切换成本。而且创建一个进程占了操作系统很多资源。这种对进程资源的浪费,可以打个比方,以开印刷厂为例,租了个 1000 平米的,经过政府各种复杂的审批流程,也为这个印刷厂申请了各种资质认证,但这个印刷厂却只有一台普通产能的印刷机,来处理订单。要想增大产能,得再开厂,但是每个厂都只有一台印刷机工作。


所以问题本质是:一个进程只能处理并发处理一个请求,其实是对 cpu 和进程资源的浪费

这时肯定有人会说 PHP 有协程,可以使用 yield 就开启协程,但是 php 的 yield 是个 stackless 的协程,也就是无新开堆栈的协程,本质和单进程单线程模型没区别。yeild 更像是实现进程多任务协作的语法糖
肯定还会有人说 PHP 也有 pthreads 扩展,来提供实现单进程多线程模型。但是只能用在 CLI 命令行环境下,而且线程间通信机制和锁机制的不完善,无法贸贸然应用到生产环境(线上一旦出现奇怪问题无法及时有效的解决,那就是发布事故啦,严重影响绩效)。

对于异步有人说可以使用通过 PHP 的 libevent 扩展驱动呀,但是 libevent 已经 7 年没有更新了,支持的 php 的最高版本是 6.0。

对于问题 3,大家都知道 PHP 有 fpm 模式和 cli 模式。fpm 更简单,也是现阶段 php 开发的主流。而 cli 模式,大多数 phper 是用来脚本的。HTTP/HTTPS 这些协议的解析和实现,并不需要 PHP-FPM 下的 PHP 程序关心, 单个请求内的脚本运行周期, 也不用担心内存泄漏这种问题, 还有就是 PHP-FPM 自带一套进程管理机制, 保证总是有工作进程在服务, 服务基本上是不会中断的,开发者不需要考虑太多业务逻辑之外的问题。题外话,大部分 PHPer 就是在 php-fpm 下过的太安逸了,随着年龄增长,日复一日的 CURD 日子,不知道大家有没有为自己的核心竞争力在哪里所焦虑过?
而如果你是自己基于 cli 写一个稍微复杂的 server, 你就需要关心很多东西。
1、要自己实现一套进程管理机制,保证服务进程因为代码出错后自动重启一个新的进程,比如说 php 语法错误会导致 cli 进程直接退出,却不会导致 fpm 进程退出。
2、还要自己实现一套多进程架构来利用 cpu 多核。
3、为了超越 fpm 这种阻塞型架构,还得为你的 cli 服务增加事件驱动的支持,php 需要用到 event(libevent)这类的事件通知库,来体现出单个进程维持 C10K 个连接这种不具备的能力。
4、还得自己实现网络协议的解析,把读到的原始数据,进行解析,fpm 里面我们可以使用 fpm 解析完成的 $_SERVER,$_POST,$_GET,$_COOKIE,$_SERVER 这些全局变量来拿到这些数据

而 cli 目前还是比较糙的,提供的很多 api 还是接近于底层的原始接口,容易使用出错,要求开发者的 扎实的基础知识 以及 linux 编程能力,不然每一步都 举步维艰

3. 渴求 PHP 拥有的能力

3.1 进程管理

我们需要完善的进程管理机制


什么是进程?什么是进程管理?进程管理需要什么?

维基百科:进程是一个资源分配的单位。每个进程在操作系统中都有一个“进程控制块 PCB*”来描述一个进程,在 linux 中使用 task_struct 这个结构体描述一个进程 / 线程。”PCB”就是为了描述控制进程的运行,系统中存放进程的管理和控制信息的数据结构称为进程控制块(PCB Process Control Block),它是进程实体的一部分,是操作系统中最重要的记录性数据结构。

听着有点懵,记住它就是个 很复杂的数据结构 就可以了。

提到进程管理,首先要知道 进程间的组织方式:父子关系
每个进程都有父进程,而所有的进程以 init(内核 0 号)进程为根,形成一个树状结构。
父子间进程管理保证:父进程退出,要通知所有的子进程退出,避免产生孤儿进程;子进程退出。要通知父进程回收资源,避免产生僵尸进程。

进程管理需要进程之间的 最基本能力就是通信能力
进程间通信的方式有:消息队列、信号量、共享存储、Socket、管道。

pcntl 是原生 php 提供的多进程编程的扩展。大家可以在 fpm 框架下(例如 laravel、symfony)vender 包以“pcntl”为关键字进行搜索,有用到,可以看下怎么使用的。

pnctl 存在的问题:1、没有提供进程间通信的功能,需要开发者自己实现
               2、不支持重定向标准输入和输出
               3、pcntl 只提供了 fork 这样原始的接口,编程难度高,容易使用错误

使用 pcntl 实现两个进程通信。本例使用消息队列通信,这段代码存在的问题,其实可读性上是存在些难度的,两个进程的执行代码都混在在一起,没有层次上的区分,在这个例子中,你得花点力气才能看清 producer()方法是被子进程调度。

<?php

// 创建消息队列, 以及定义消息类型
$id = ftok(__FILE__,'m');
$msgQueue = msg_get_queue($id);
const MSG_TYPE = 1;

// 消息队列的生产者
function producer(){
    global $msgQueue;
    $str = "测试";
    msg_send($msgQueue,MSG_TYPE,$str);
}

// 消息队列消费者
function consumer(){
    global $msgQueue;
    msg_receive($msgQueue,MSG_TYPE,$msgType,1024,$message);
    echo "from exec: {$message}\n";
}

// 创建子进程
function createProgress($callback){$pid = pcntl_fork();
    if ($pid == -1) {
        // 创建失败
        exit("fork progress error!\n");
    } else if ($pid == 0) {
        // 子进程执行程序
        $pid = posix_getpid();
        $callback();
        exit("({$pid})child progress end!\n");
    }else{
        // 父进程执行程序
        return $pid;
    }
}

// 子进程作为生产者
createProgress('producer');
// 主进程作为消费者
consumer();

// 等待子进程结束
pcntl_wait($status);

swoole 使用了 socket 通信方式,虽然通信的方式不通,先不关注通信方式效率和速度,光从代码的可读性上来说,必包的写法,让我们很清晰的看清子进程的执行代码逻辑。

<?php

use Swoole\Process;

// 创建子进程
$process = new Process(function (Process $worker) {echo '测试';}, true, 1, true); // 需要启用标准输入输出重定向

$process->start();

// 主进程监听 socket
Co\run(function() use($process) {$socket = $process->exportSocket();
    echo "from exec:" . $socket->recv() . "\n";});

// 子进程回收
Process::wait();

SWOOLE 代码更简洁,可读性也更高,并且减少手动维护消息队列的成本。

3.2 协程

刚刚提到问题本质。那么衍生为我们期望能解决的问题是:怎么能让单进程能并发处理多个连接?
协程使得一个进程维持多个客户端连接成为可能

协程可以简单的理解为线程,只不过这个线程是用户态的,不需要操作系统参与,创建和销毁的成本非常低

所谓用户态,不需要操作系统控制,指的就是协程的创建销毁、协程之间的调度和切换都是进程自己控制的。线程是 CPU 调度的基本单元,协程是寄宿在线程内的

协程本质上就是个内存数据结构 ,其实和进程一样,但是比进程轻量多了,占用的内存空间小。
协程的上下文小,也就是所占的空间小,一个进程能轻松创建成千上万个协程,并且协程之前的切换成本也小。
可以类比:进程切换好比跨部门沟通、线程切换好比本部门内沟通,协程切换就是团队内沟通了。

stackless 也就是无额外堆栈分配, stackfull 就是有堆栈空间分配
stackless 协程就只是实现了代码非串行执行而已。

与同步阻塞模式不同,我们期望程序是并发执行的,也就是同一时间内 Server 会存在多个请求,因此应用程序必须为每个客户端或请求,创建不同的资源和上下文。否则不同的客户端和请求之间可能会产生数据和逻辑错乱。
而 stackfull 协程真正的实现了一个进程处理能实现多个连接,为每一个请求分配一个寄存器和栈来保存请求状态

用图形象说明下

这张图想把具体的业务和协程结合,做个具象的表述。
以登陆接口: php 先对参数进行正则校验,然后从数据库查询密码,php 代码判断密码是否正确,再从数据库中查询用户权限,php 判断用户权限是否匹配,最后查询出详细的用户信息。
横向表示单进程请求量,纵坐标表示请求处理时间。
图中每一条,表示一个请求要做的事情。
红色的是代表 io 操作,绿色的标代表非 io 操作。

3.3 异步
刚刚提到问题本质。那么衍生为我们期望能解决的问题是:怎么能让单进程能并发处理多个连接?
而异步则为并发提供了可能

编程语言层面的异步指的是让 CPU 暂时搁置当前请求的响应, 处理下一个请求, 当通过轮询或其他方式得到回调通知后,开始运行。

异步其实就是一种协作机制。
异步有 2 个主体,对于 swoole 来说:就是 swoole 进程 + 内核进程。异步通过 epoll(linux 的系统调用)得到回到通知,来并发处理请求。

以这个登陆接口为例,简单解释下协程(单进程单线程多协程模型下)和异步是怎么配合的。其实 swoole 就是一直循环在消费 epoll 的就绪队列。进程开启,发现就绪队列上有 2 个请求进来啦,swoole 开启了 2 个协程,分别处理这 2 个请求,执行完第一个协程的正则校验入参后,这个协程发生了 I / O 事件去数据库查询密码,于是进程把 cpu 的控制权交给了另外一个协程去处理校验入参,也发生了 I / O 事件。这时 cpu 分配给进程的时间片消费完了,进程被强制让出 cpu 控制权。等到 CPU 控制权再交给该进程的时候,发现就绪队列上有三个事件,2 个是已有协程的产生的 I / O 事件结果返回了,1 个是新的请求连接产生了。进程会依次调度已有的两个协程进程密码是否正确的判断,在新开一个协程去处理新连接的正则校验入参。一直循环往下,直到协程执行完,资源被回收。

** 宏观上看请求都是在往下走的,微观上看是进程调度协程横向执行的。**

协程 + 异步的组合,上万的并发请求能够得到轻松处理。

对比协程 + 异步后的速度提升,相同的业务逻辑:一个任务执行 3000 次,每次 sleep3000 毫秒。
PHP 原生实现

<?php
$start_time = microtime(true);

for ($i = 0; $i <= 3000; $i++) {echo "task{$i}\n";
    usleep(3000);
}

$end_time = microtime(true);

echo $end_time - $start_time;

// output: 10.952800035477

swoole 的协程 + 异步的实现

<?php
$s = microtime(true);

Co\run(function() {for ($i = 0; $i <= 3000; $i++) {go(function () use ($i) {co::sleep(0.003);
                echo "task{$i}\n";
        });
    }
});

echo 'use' . (microtime(true) - $s) . 's';

// output: 0.14848017692566

执行时间上差了大概 100 倍,而且随着循环次数的增加,花费的时间是呈指数增长的

4. socket 编程

完善的 server 服务,就必须要实现进程间相互通信,包括协程间的 socket 通信,都需要扩展 PHP 的请求生命周期,通信就离不开网络编程。

什么是 socket

我们经常把 Socket 翻译为套接字,socket 是在应用层和传输层之间的一个抽象层,它把 TCP/IP 层复杂的操作抽象为几个简单的接口供应用层调用以实现进程在网络中通信。socket 编程也就是网络编程,其实也就是操作系统提供给开发人员进行网络开发的 API 接口开发。

TCP
基本 tcp 客户 / 服务器程序套接字函数

图示为TCP/ 服务器交互中发生的典型情形的时间线图

上面的这些函数就是操作系统提供给应用进程的套接字的内核 api。

简单描述下来流程就是,服务器和客户端各自开启一个 tcp 的 socket 连接,服务端绑定监听端口,一直阻塞到该端口发生连接请求,这时客户端向服务端该端口发起连接请求,经过三次握手后,成功与服务端建立起连接。客户端向服务端发送数据,服务端读取数据,并处理该请求,处理完成后,发回数据应答。这边的 write()和 read()存在循环的原因,是因为存在需传输数据量较大的情况下,会出现数据分包,进行多次传输处理的情况。一直等到客户端请求结束,向服务端结束通知,服务端读取到通知,经过四次挥手就关闭了这个请求连接。


时序图也有了,操作系统把 api 也提供了。让你实现个 tcp 服务呢?
还是很麻烦的,你得自己把这些把这些 api 函数串联成完整的流程,需要处理分包分批请求的情况,还有很多细节要处理。


swoole 就帮我们隐藏掉了细节,对这些内核 api 又做了封装,让我们使用起来更简单。

这样就创建了一个 TCP 服务器,监听本机 9501 端口。它的逻辑很简单,当客户端 Socket 通过网络发送一个 hello 字符串时,服务器会回复一个 Server: hello 字符串。


把流程图与 swoole 的 api 关联起来看。
不得不感叹 swoole 封装做得真的很绝妙。

UDP
基本 udp 客户 / 服务器程序套接字函数

图示为 UDP/ 服务器交互中发生的典型情形的时间线图。

UDP 服务器与 TCP 服务器不同,UDP 没有连接的概念。
简单描述下来流程就是,服务器和客户端各自开启一个 udp 的 socket 连接,服务端绑定监听端口,一直阻塞到收到客户端的数据,客户端向服务端发送数据,服务端接受数据,处理请求,返回应答。客户端处理完数据,关闭连接。

UPD server demo

创建 UDP server 的 demo。

启动 Server 后,客户端无需 Connect,直接可以向 Server 监听的 9502 端口发送数据包。对应的事件为 onPacket。


UPD 的流程图与 swoole 的 api 结合起来看。

最后

本文的分析就到这里为止了,由于有些是和理论相关的东西,如果有错误,希望大家指出来,我会及时修改,这里先谢谢大家。
最近会基于 linux 提供的 socket 服务器,自己用首先 c 实现tcp server\udp server\http server\websocket server,然后会对 swoole 的 server 进行源码级别的分析,再进行实现对比分析,大家有兴趣可以继续关注。

最后的最后,想说 程序员要一直在路上,不断跳脱出自己的舒适圈

正文完
 0